-
Notifications
You must be signed in to change notification settings - Fork 2
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.
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/cacherbackend. 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.
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.
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, andd1are fetch-only. They need no sockets and work in a Worker and in a browser. -
postgresis 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. -
mariawraps the third-partymariadbdriver directly, bypassingcompat, and has been confirmed connecting over TCP on Workers. The driver needs Node globals such asprocess, so it does not run in a browser. -
mongohas 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.
| 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. |
Deno:
deno add @tundralibs/normBun:
bunx jsr add @tundralibs/normNode.js:
npx jsr add @tundralibs/normOr 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| 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.
@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.
// 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.
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 readEntity(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.
// 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.
.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 restAdd .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.
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.
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 scopeinsert 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.
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.
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 twoNorms sharing a cache engine stay isolated as long as theirnames 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-skipwarning. 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-processMEMORYengine. Any other engine makesuse()throw at compose time. -
Backend failures degrade to the database. When Redis or Memcached
is unreachable, a failed
getis a miss and a failedsetor prune is skipped. Each surfaces acache-errorwarning. The query itself never fails. -
Any cacher engine.
MEMORY,REDIS,MEMCACHED, andWORKERS_KVall work through cacher's unified API, and each engine'sclear()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.
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:
-
callfor every executed operation, andcacheHitfor a read served from the cache (nocallfires for it). -
warning, with codes such ascache-skip(a joined read could not be cached) andcache-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, andtransactionRollback. - The engine's own events, proxied from the driver:
connect,disconnect,connectionFailed,error,transactionTimeout,query, andslowQuery.
Subscribe inline with _on<event> keys, or later with
norm.on(event, fn).
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).
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.
| 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.
- 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,
@AsOfpoint-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
Migratorworkflow. - Scoping: tenant scoping and default filters.
-
Errors: the error classes and every stable
NormErrorCode.
MIT