Skip to content
GitHub Actions edited this page Sep 26, 2026 · 31 revisions

NORM

A typed, cross-runtime ORM built on OQL and @tundralibs/drivers. One schema declaration drives your types, validation, migrations, and at-rest column encryption, across PostgreSQL, MariaDB/MySQL, SQLite, and MongoDB, and, on edge runtimes, Neon, Turso, and Cloudflare D1 over HTTP.

JSR JSR Score Deno Bun Node.js

Standout features

What sets norm apart from a typical TypeScript ORM. Each item links to its guide.

  • At-rest encryption. .encrypt() any column, then filter and enforce uniqueness on the ciphertext through a digest sibling. See Security.
  • Audit trail. A generated, read-only replica mirrors every insert, update, and delete, with no change to the source table. See Audit tables.
  • Temporal tables. Every version of a row stays in place, with point-in-time (@AsOf) reads and scheduled changes. See Temporal tables.
  • Multi-tenant scoping. One call wraps every read and write of a handle in an always-on equality filter, enforced on cross-tenant writes as well. See Scoping.
  • Read-query caching. Opt-in per-entity TTLs with per-table invalidation on write, over any @tundralibs/cacher backend. See Read caching.
  • Zero-codegen types. RowOf, InsertOf, UpdateOf, and typed filters and projections are read straight off the entity declaration. There are no generated files and no build step. See Schema definition.
  • Cross-runtime. Deno, Bun, Node.js, and Cloudflare Workers from one codebase, with the fetch-only dialects running in the browser as well. See Browser / Worker compatibility.

The subscription-billing example shows several of these working together in one runnable app.

Overview

You define entities with a builder API. From that single declaration norm derives:

  • Types. RowOf, InsertOf, UpdateOf, and typed projections and filters, with no codegen step.
  • Validation. A generated Guardian runs before any SQL, so bad input is a typed error rather than a database error.
  • Migrations. Snapshot-based, with a table-rebuild engine, drift detection, reviewable per-dialect SQL plans, and a multi-machine advisory lock.
  • At-rest security. .encrypt() any column without changing its TypeScript type, filter encrypted columns through digest siblings, and mask sensitive values on read.

The same typed code runs against seven engines: four self-hosted (postgres, maria, sqlite, mongo) and three fetch-only engines for edge and serverless runtimes (neon, turso, d1). The live test suite exercises the four self-hosted dialects end to end.

Browser / Worker compatibility

The root barrel, @tundralibs/norm, registers six of the seven dialects as a side effect of one import. The exception is sqlite, which needs a native binding on every runtime (bun:sqlite, a Deno-only @db/sqlite import-map alias, node:sqlite). None of those resolve in a bundled target, so the barrel leaves it out and you register it yourself with import '@tundralibs/norm/engines/sqlite' on Deno, Bun, or Node. With the other six present, the barrel bundles cleanly for a Worker or browser build. That was confirmed with a real esbuild and wrangler build, not only a module-graph check.

Bundling is not the same as running:

  • neon, turso, and d1 are fetch-only. They need no sockets and work in a Worker and in a browser.
  • postgres is a hand-rolled wire protocol over @tundralibs/compat/net, which has a Workers backend (cloudflare:sockets) and has been confirmed connecting there. A browser has no raw-socket API, so it cannot run there.
  • maria wraps the third-party mariadb driver directly, bypassing compat, and has been confirmed connecting over TCP on Workers. The driver needs Node globals such as process, so it does not run in a browser.
  • mongo has not been verified on Workers. Treat it as server-only until someone checks.

To be explicit about what ships to an edge or browser build, use @tundralibs/norm/core plus the engine modules you need. The root barrel forces no unbundlable dependency on you other than sqlite.

Modules

Module Import Description
Root @tundralibs/norm Norm, NormDb, repos, Column, Entity, Schema, use, plus six of the seven dialects (all but sqlite).
Core @tundralibs/norm/core The same surface with no dialect registered. The explicit edge/serverless entry point.
Definition @tundralibs/norm/definition Builders, entity/schema types, doc + snapshot emitters.
Migrations @tundralibs/norm/migrations The Migrator: snapshot / plan / apply / rollback.
Asserts @tundralibs/norm/asserts Validate hand-built definitions with the same rules Entity() uses.
Engines @tundralibs/norm/engines registerEngine / resolveEngineFactory, the dialect registry.
Engine (one per dialect) @tundralibs/norm/engines/<dialect> Side-effect module registering one dialect: postgres, maria, sqlite, mongo, neon, turso, d1.
CLI @tundralibs/norm/cli norm init/upgrade/ping — see CLI below.

Installation

Deno:

deno add @tundralibs/norm

Bun:

bunx jsr add @tundralibs/norm

Node.js:

npx jsr add @tundralibs/norm

Or scaffold a schema project (or add norm to one that already exists) with the CLI — see CLI below:

deno run -A jsr:@tundralibs/norm/cli init

CLI

Command Does
init [--yes] Scaffold a schema project in the current directory, or add norm to one that already exists.
upgrade [--dir .] Bump @tundralibs/norm/@tundralibs/utils (and any other @tundralibs/* dep) to the latest release, and refresh norm.agent.md + its AGENTS.md/CLAUDE.md pointer.
ping [dir] Open the connection described by configs/Norm.yaml and report success/failure (exit 0/1) — a connectivity smoke test, not a query.

init has no dialect or runtime prompt: Entity/Schema never touch a dialect, so it lives as DATA in configs/Norm.yaml (every dialect norm supports is shown there — common network fields once, each dialect's own delta after) instead, and both deno.json and package.json are always written (every package in this monorepo ships both). It operates on the current directory rather than creating a subfolder: run it inside an existing project to add a models/ schema layer (and configs/Norm.yaml + db.ts) to it, or in an empty directory for a standalone schema project. Existing files are always merged into or left alone, never clobbered — re-running init is safe.

init also writes norm.agent.md: entity kinds (TABLE/VIEW/QUERY), hooks, the full repo method set (insert/find/update/upsert/delete/ truncate), transaction/cache dialect caveats, the events/witness observability surface, and the NormError hierarchy — the same guide this README documents, not a shorter summary. CLAUDE.md/AGENTS.md get (or merge in) only a short pointer to it, so anything you write elsewhere in those two files survives every future upgrade. upgrade refreshes norm.agent.md in full and re-syncs that pointer — never anything outside the delimited section it owns.

Choosing an entry point

@tundralibs/norm serves Deno, Bun, and Node, and Workers and browser builds too. The root barrel registers six of the seven dialects, so any database config other than sqlite constructs with no extra import. sqlite is held back because it needs a native binding on every runtime (jsr:@db/sqlite on Deno, bun:sqlite, node:sqlite) that no edge bundler can resolve. Keeping it out of the eager imports keeps the barrel bundlable for everyone else.

import { Norm } from '@tundralibs/norm';

declare const host: string, database: string, username: string;

const norm = new Norm({
  database: { dialect: 'postgres', host, database, username },
  secret: process.env.SECRET,
});

sqlite needs its own import before use, on any runtime:

import '@tundralibs/norm/engines/sqlite';
import { Norm } from '@tundralibs/norm';

const norm = new Norm({ database: { dialect: 'sqlite', path: './data' } });

@tundralibs/norm/core is the edge and serverless entry point. It has identical exports with nothing registered: you import the one engine you need and no other driver enters the bundle. Verified on workerd: core with engines/d1 (fetch-only, no pooling); core with engines/postgres (a real TCP connection through compat/net's cloudflare:sockets backend, with pooling and transactions working); and core with engines/maria (the third-party mariadb driver, independent of compat).

import '@tundralibs/norm/engines/d1'; // or /neon, /turso, /postgres, /maria
import { Norm } from '@tundralibs/norm/core';

declare const env: Record<string, string>; // the Worker's bindings

const norm = new Norm({
  database: {
    dialect: 'd1',
    accountId: env.CF_ACCOUNT_ID,
    databaseId: env.D1_DATABASE_ID,
    apiToken: env.CF_API_TOKEN,
  },
});

neon, turso, and d1 are one-shot fetch calls with no pooling and no transactions, as executor.capabilities reports. postgres and maria are real connections and carry no such limit. mongo is unverified on Workers, and sqlite cannot run there at all. A dialect whose module was never imported throws ENGINE_NOT_REGISTERED at construction and names the import to add. The registry behind this is documented in engines/registry.ts.

Quick Start

// sqlite needs its own explicit import before use, on any runtime —
// the other six dialects don't (see "Choosing an entry point" below).
import '@tundralibs/norm/engines/sqlite';
import { Column, Entity, Norm, Schema } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';

// 1. Define entities with the Column builders.
const Users = Entity('users', {
  id: Column.uuid().default({ $$_expression: 'UUID' }),
  email: Column.varchar(255).guard(Guardian.string().toLowerCase())
    .encrypt().hash(), // ciphertext at rest, still filterable by plaintext
  displayName: Column.varchar(120).guard(Guardian.string().minLength(2)),
  role: Column.enum(['admin', 'editor', 'viewer']).default('viewer'),
}, {
  pk: ['id'],
  unique: { email: ['email_hash'] }, // unique on the digest sibling
});

// 2. Group entities into a named schema.
const Identity = Schema('Identity', { Users });

// 3. Open a connection and compose the schema(s) — norm constructs and
//    owns the engine from a dialect config; you never see the instance.
const norm = new Norm({
  database: { dialect: 'sqlite', path: './data' },
  secret: process.env.SECRET,
});
const db = norm.use(Identity);

// 4. CRUD — typed, validated, encrypted.
const created = await db.repo('Users').insert({
  email: 'Ada@Example.dev',
  displayName: 'Ada',
});
created.data[0].email; // 'ada@example.dev' (decrypted, lowercased)

// Filter by the plaintext of an encrypted column — rewritten to the
// digest sibling under the hood:
const found = await db.repo('Users').findOne({ '@email': 'ada@example.dev' });
found.data?.role; // 'viewer'

Every operation returns a NormResult envelope: { id, op, count, time, isSlow, data?, total?, scoped? }. The id is a ULID that also appears on the call event, so logs correlate one to one.

Defining a schema

Column.* builders are immutable and chainable. Invalid combinations do not type-check: hash() exists only after encrypt(), and .guard() disappears after encrypt().

import { Column } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';

Column.varchar(255) // VARCHAR(255)
  .nullable() // NULL allowed
  .guard(
    Guardian.string() // one Guardian: transforms + validators, your order
      .trim()
      .minLength(3).maxLength(50)
      .pattern(/^[a-z]+$/)
      .refine((v: string) => !v.includes(' '), 'no spaces allowed')
      .slug(), // reach Guardian's own vocabulary directly
  )
  .beforeWrite((v) => v.trim())
  .afterRead((v) => v.toUpperCase())
  .default('a')
  .comment('A column');

// A literal-value restriction that also narrows the TS type:
Column.enum(['a', 'b', 'c']); // TS: 'a' | 'b' | 'c'

Column.integer();
Column.bigint();
Column.decimal(10, 2);
Column.float();
Column.double();
Column.real();
Column.boolean();
Column.json<{ tags: string[] }>();
Column.date();
Column.time();
Column.datetime();
Column.timestamp();
Column.uuid(); // native DB-checked UUID — pair with a DB-side default
Column.ulid(); // VARCHAR(26), defaulted to a fresh ULID per row
Column.cuid(); // VARCHAR(25), defaulted to a fresh CUID per row
Column.cuid2(); // VARCHAR(24), defaulted to a fresh CUID2 per row
Column.nanoId(); // VARCHAR(21), defaulted to a fresh nanoID per row
Column.objectId(); // VARCHAR(26), a shared ObjectID counter per column
Column.simpleId(); // BIGINT, a shared simpleID counter per column
Column.text();
Column.blob();
Column.hash('SHA-256'); // one-way digest column (passwords)
Column.mask('card', (v) => '****' + v.slice(-4)); // virtual, computed on read

Entity(name, columns, options) produces a TABLE (which needs a pk), a VIEW, or a terminal QUERY. Relationships are declared with foreign keys that reference the target's registry key, never a table name:

import { Column, Entity } from '@tundralibs/norm';

const Profiles = Entity('profiles', {
  userId: Column.uuid(),
  bio: Column.text().nullable(),
}, {
  pk: ['userId'],
  fk: {
    User: {
      model: 'Users', // the registry key
      on: { userId: 'id' },
      reverseAs: 'Profile', // Users can project '@Profile'
      onDelete: 'CASCADE',
    },
  },
});

Schema(name, entities) groups entities. use(...schemas) composes any number of schemas into one typed database handle and resolves foreign keys across schema boundaries.

See Schema definition for the full builder reference, relations, hooks, and validators.

Querying

// find(filter?, options?) — filter FIRST
await db.repo('Users').find({ '@role': 'admin' }, {
  orderBy: { '@displayName': 'ASC' },
  limit: 20,
  project: { '@id': true, '@displayName': true, '@Profile': { '@bio': true } },
  total: true, // also run a COUNT with the same filter → result.total
});

await db.repo('Users').findOne({ '@id': someId });
await db.repo('Users').getByPK({ id: someId });
await db.repo('Users').count({ '@role': 'admin' });

// Grouped aggregates on the typed surface:
await db.repo('Visits').find(undefined, {
  project: { '@country': true },
  aggregates: { total: { fn: 'COUNT', column: '@id' } },
});

Filters are the OQL filter language typed to your columns: $eq, $ne, $in, $like, $between, $null, $or and $and, and nested relation refs such as '@Profile.@bio'. Filtering through a to-many relation that is not projected is lifted into a correlated EXISTS subquery, so it never fans out.

See Querying for filters, typed projections, relations, aggregates, and pagination.

At-rest encryption

.encrypt() works on any column kind. The value keeps its declared TypeScript type and only the storage is ciphertext:

birthday: Column.timestamp().encrypt().nullable(), // Date in TS, TEXT at rest

Add .hash() to an encrypted column and norm synthesizes a <col>_hash digest sibling. Equality filters, $in, uniqueness, and upsert conflict keys then work against the ciphertext by rewriting to the digest:

email: Column.varchar(255).encrypt().hash(),
// unique on the sibling:
unique: { email: ['email_hash'] },
// filter by plaintext — rewritten to email_hash = sha256('ada@...'):
await db.repo('Users').findOne({ '@email': 'ada@example.dev' });

Column.hash('SHA-256') is a standalone one-way digest column, for a password digest that must never be readable. Column.mask(source, fn) is a virtual column computed after decryption. It is never stored and never sent to SQL.

See Security for encryption, digest columns, masks, and the crypto override hooks.

Migrations

The Migrator derives migrations from your definitions, with no hand-written SQL:

import { Migrator } from '@tundralibs/norm/migrations';

const mig = new Migrator(db, { dir: './migrations' });
await mig.snapshot(); // writes 0001.json (.sql opt-in: renderSql / renderPlans())
await mig.plan(); // inspect the DDL before applying
await mig.apply(); // execute + record in _norm_migrations
await mig.rollback({ to: 0 });

Migrations are state-based. Each version is a full physical snapshot, the diff between consecutive snapshots is the migration, and "down" is the reverse diff. A type, primary-key, or crypto change that no in-place ALTER can express becomes a table rebuild (rename aside, recreate, copy, verify, drop), including per-row decrypt and re-encrypt when a crypto marker flips. Drops are gated behind allowDrop and surfaced as blockedDrops. apply() refuses a plan whose hash does not match the reviewed .sql artifact, and takes a server-side advisory lock so two CI runners cannot migrate at once.

See Migrations for the full workflow, rename hints, the rebuild engine, and stored plans.

Scoping (multi-tenant / default filters)

db.scope({...}) returns a handle whose every read and write carries an always-on equality filter:

const orgDb = db.scope({ '@orgId': currentOrgId });

await orgDb.repo('Tickets').find();          // WHERE orgId = currentOrgId
await orgDb.repo('Tickets').insert({ ... }); // orgId auto-filled (may be omitted)
await orgDb.repo('Tickets').update(data, f); // rejects moving a row out of scope
await orgDb.repo('Tickets').upsert(d, opts); // can't touch another scope's row
await orgDb.repo('Tickets').truncate();      // refused — use delete({}) to clear scope

insert fills the scope column when the payload omits it, update refuses to move a row out of scope, and upsert refuses to touch another scope's row on every dialect. truncate refuses on a scoped handle because it carries no WHERE; use delete({}) to clear one scope. An entity without the scope column is queried unscoped, so one handle can span a mixed registry. The applied scope rides result.scoped. Scopes are equality-only. See Scoping.

Transactions & escape hatches

await db.transaction(async (tx) => {
  await tx.repo('Users').insert({ ... });
  await tx.repo('Audit').insert({ ... });
}); // commits on resolve, rolls back on throw

// Nesting opens a SAVEPOINT on the same tx — inner rolls back to the
// savepoint on throw, the outer transaction survives (SQL engines):
await db.transaction(async (tx) => {
  try {
    await tx.transaction((sp) => sp.repo('Users').insert(maybeBad));
  } catch { /* only the inner block rolled back */ }
});

// Typed IR escape hatch — bind to an entity to ride decrypt/afterRead:
await db.query({ type: 'SELECT', table: 'users', /* ... */ }, { entity: 'Users' });

// Raw SQL escape hatch — named params, injection-safe, rows come back RAW:
await db.raw('SELECT count(*) AS n FROM users WHERE role = :role:', {
  role: 'admin',
});

raw() and query() bypass the typed pipeline: no decrypt, no scope, no validation. raw() also emits a warning event on every call, so an audit can see the escape hatch in use; query() does not.

Read caching

Off by default. Pass a cache config to new Norm({...}) and give each entity a cache TTL in minutes. Non-transactional find, findOne, count, and getByPK reads are then served from @tundralibs/cacher, keyed by the query. The TTL is windowed: each hit resets the clock (except on WORKERS_KV, where it is fixed).

const norm = new Norm({
  name: 'app', // namespaces the cache (required on any engine but MEMORY)
  database: { dialect: 'sqlite', path: ':memory:' },
  cache: { engine: 'MEMORY' }, // or REDIS/MEMCACHED/WORKERS_KV + options
});

// Per-entity opt-in (minutes; 0/omitted = off):
Entity('users', {/* columns */}, { pk: ['id'], cache: 5 });

await users.find(); // miss → DB, then cached
await users.find(); // hit  → emits `cacheHit`
await users.find(undefined, { noCache: true }); // bypass for this call
await users.insert({/* ... */}); // any write prunes the table's cache
await db.repo('Users').clearCache(); // drop one entity (and dependent views)
await db.clearCache(); // drop every entity's cache
  • Per-table invalidation. Each entity gets its own cache namespace, <name>__Entity, so a write to one table prunes only that table, and two Norms sharing a cache engine stay isolated as long as their names differ. Inside a transaction, reads bypass the cache and the prune is deferred to commit. A rollback prunes nothing.
  • Joined reads are never cached. A joined entry would depend on more than one table, which per-table pruning cannot invalidate, so such reads emit a cache-skip warning. Model them as a VIEW to cache them. A single-table aggregate is cached normally, and a VIEW or QUERY is cacheable: norm resolves its stored query's source tables and prunes it when any of them is written.
  • Encryption guard. Decrypted rows on an external store would leak the plaintext of encrypt() columns, so an entity with encrypted columns may only be cached on the in-process MEMORY engine. Any other engine makes use() throw at compose time.
  • Backend failures degrade to the database. When Redis or Memcached is unreachable, a failed get is a miss and a failed set or prune is skipped. Each surfaces a cache-error warning. The query itself never fails.
  • Any cacher engine. MEMORY, REDIS, MEMCACHED, and WORKERS_KV all work through cacher's unified API, and each engine's clear() is scoped to the namespace. Memcached and Workers KV switch to a new namespace version rather than flushing the store. Workers KV is eventually consistent; see Read caching before using it.
  • Caveats. Prune-on-write is not atomic with the database write, so staleness is bounded to one read window. raw() and external writes do not invalidate.

Events

Wire the metadata-only event surface to your logger. It never carries row data, plaintext, or secrets:

import '@tundralibs/norm/engines/sqlite';
import { Norm } from '@tundralibs/norm';

const secret = process.env.SECRET;
const log = console;

const norm = new Norm({
  database: { dialect: 'sqlite', path: './data' },
  secret,
  _oncall: (entity, op, ms, isSlow, id) => log.info({ entity, op, ms, id }),
  _onwarning: (entity, op, code, msg) => log.warn({ entity, op, code, msg }),
  _ontransactionCommit: (txId) => log.debug({ txId, event: 'commit' }),
  // Engine events forwarded from the driver (query/slowQuery are
  // metadata-only — no SQL text, no params):
  _onconnect: (engineId) => log.info({ engineId, event: 'connect' }),
  _onslowQuery: (engineId, queryId, ms) => log.warn({ queryId, ms }),
});

The surface:

  • call for every executed operation, and cacheHit for a read served from the cache (no call fires for it).
  • warning, with codes such as cache-skip (a joined read could not be cached) and cache-error (a cache backend failed and the query fell back to the database).
  • decryptError, when an encrypted cell fails to decrypt on read. It is a data-integrity or key-rotation signal and carries metadata only.
  • transactionBegin, transactionCommit, and transactionRollback.
  • The engine's own events, proxied from the driver: connect, disconnect, connectionFailed, error, transactionTimeout, query, and slowQuery.

Subscribe inline with _on<event> keys, or later with norm.on(event, fn).

Tracing (witness)

Events give flat observability: a call record per operation and a query record per statement. For nested spans, where an operation is the parent of the queries it caused, configure a witness. Every repo operation and raw() runs through it, so a tracer's active span is open while the driver events fire, and their spans parent to it through ambient.

const norm = new Norm({
  database: { dialect: 'postgres', host, database, username },
  secret,
  witness: tracer.wrap,
});

tracer.wrap (tracer 0.4 or later) is the ready-made adapter: it opens an INTERNAL span named info.name ('norm.Users.find', 'norm.raw', and so on), seeds the attributes, and honours the witness contract. Hand-roll with startActiveSpan only when you want a different SpanKind or extra attributes.

GET /orders                      ← request span (middleware)
└─ norm.Orders.find              ← the witness
   ├─ db.query                   ← driver event, parents automatically
   └─ db.query   (relation load)

The witness is a generic wrap hook, not a tracer dependency. norm never imports tracer; you wire the two at the composition root, the same way slogger takes a contextProvider. A witness must observe without interfering: call fn exactly once, return its result unchanged, and rethrow its errors. The gap between the operation span and its query spans is norm's own overhead per operation (validation, hooks, and per-cell crypto on encrypted columns).

Testing your models

Test against a real SQLite database rather than mocking norm — it is fast enough to be the default, and the Migrator applies your actual definitions, so a test runs against the real schema (constraints, defaults, and all), not a stand-in:

import '@tundralibs/norm/engines/sqlite';
import { Migrator } from '@tundralibs/norm/migrations';

const tempDir = await Deno.makeTempDir();
const db = new Norm({
  database: { dialect: 'sqlite', path: tempDir },
  secret: 'test',
}).use(Identity, Shortener);
await new Migrator(db, { dir: tempDir }).snapshot();
await new Migrator(db, { dir: tempDir }).apply();
// ...run your app code against `db`, assert on the NormResult envelopes.

To unit-test code above the database with no engine at all, implement the exported Executor seam (execute, ddl, transaction, capabilities) as a mock and pass it to compileRuntime — norm's own test suite (runtime.test.ts, project.test.ts) does exactly this, since norm ships no ready-made mock executor of its own. See Testing your app for the full walkthrough.

Supported databases

Feature PostgreSQL Neon MariaDB/MySQL SQLite Turso D1 MongoDB
CRUD, filters, projections ✅ ✅ ✅ ✅ ✅ ✅ ✅
Relations (join / $lookup) ✅ ✅ ✅ ✅ ✅ ✅ ✅
FK constraint enforcement ✅ ✅ ✅ ✅ ✅ ✅ ❌⁷
At-rest encryption + digests ✅ ✅ ✅ ✅ ✅ ✅ ✅
Aggregates (GROUP BY) ✅ ✅ ✅ ✅ ✅ ✅ ✅
Migrations ✅ ⚠️⁵ ✅ ✅ ⚠️⁵ ⚠️⁵ ⚠️¹
Transactions ✅ ❌⁶ ✅ ✅ ❌⁶ ❌⁶ ❌²
$exists to-many filter lift ✅ ✅ ✅ ✅ ✅ ✅ ❌³
Raw SQL (db.raw) ✅ ✅ ✅ ✅ ✅ ✅ ❌⁴

¹ MongoDB is schemaless, so the Migrator does not own its schema; create indexes directly. ² MongoDB transactions require a replica set and are not exposed. ³ Correlated subqueries have no MongoDB find-filter form. ⁴ MongoDB has no SQL surface; use db.query() with OQL IR. ⁵ Neon, Turso, and D1 migrate without the advisory lock and without transactional DDL, so a version that fails halfway resumes from its checkpoint, as on MariaDB. ⁶ A fetch dialect sends one request per statement, so db.transaction() throws NormUnsupportedError and temporal and audit writes are best-effort. ⁷ MongoDB has no FK constraint concept at all — the constraint is skipped and named in Migrator.apply()'s warnings, never thrown — while the relation itself still works for joins/eager projection. See Referential actions.

Neon speaks PostgreSQL's SQL and Turso and D1 speak SQLite's, each through its base dialect's translator, and executor.capabilities reports the two gaps above. See Browser / Worker compatibility for where each dialect runs.

Guides

  • How-To Guide: build a real app end to end.
  • Schema definition: columns, entities, relations, hooks, validators.
  • Querying: filters, projections, relations, aggregates, pagination.
  • Read caching: per-entity TTLs, per-table invalidation, engines, and backend-failure behavior.
  • Temporal tables: effective-dated version history, @AsOf point-in-time reads, scheduling.
  • Audit tables: a generated, versioned replica that mirrors every write, with no change to the source table.
  • Security: encryption, digests, masks.
  • Migrations: the Migrator workflow.
  • Scoping: tenant scoping and default filters.
  • Errors: the error classes and every stable NormErrorCode.

License

MIT

Clone this wiki locally