-
Notifications
You must be signed in to change notification settings - Fork 2
NORM Schema
Everything norm needs to know about your data lives in one place: the
Column.* builders, the Entity() constructor, and the Schema() /
use() composition functions. From that single declaration norm
derives your types, validation, migrations, and at-rest encryption,
with no codegen, no decorators, and no import cycles. This guide is the
full builder reference: every column factory and modifier, every entity
kind and option, foreign keys and derived relations, and how schemas
compose.
- Overview
- Column builders
- Defining entities
- Foreign keys and relations
- Hash siblings
- Schemas and composition
- Related documentation
A definition is three layers, inside-out:
import { Column, Entity, Schema, use } from '@tundralibs/norm';
// 1. Columns — immutable, chainable builders.
const email = Column.varchar(255).encrypt().hash();
// 2. Entities — a name, a column map, and an options bag.
const Users = Entity('users', { id: Column.uuid(), email /* … */ }, {
pk: ['id'],
});
// 3. Schemas — a named collection of entities, composed with use().
const Identity = Schema('Identity', { Users });
const registry = use(Identity);Builders are immutable: every chained call returns a new builder, and
the chain's result is carried on .spec as plain, serializable data,
which is what snapshots and migrations diff. The TypeScript value type
rides along as a phantom generic, so RowOf, InsertOf, and
UpdateOf are read straight off the builder type with no as const
discipline and no literal-widening pitfalls.
Invalid combinations do not type-check. hash() exists only after
encrypt(), the string validators disappear once you encrypt, and a
digest column has no encrypt(). Most of what a literal API would
reject at runtime is a compile error here; see
Correct by construction.
Start every column with a factory from the Column object, then chain
modifiers. Import it from the package root:
import { Column } from '@tundralibs/norm';| Factory | SQL type | TS value | Notes |
|---|---|---|---|
Column.varchar(length) |
VARCHAR(n) |
string |
String validators apply. |
Column.char(length) |
CHAR(n) |
string |
Fixed-width string. |
Column.text() |
TEXT |
string |
Unbounded string. |
Column.clob() |
CLOB |
string |
Character large object (TEXT/LONGTEXT/TEXT). String validators apply. |
Column.xml() |
XML |
string |
Native XML on Postgres, TEXT elsewhere. |
Column.uuid() |
UUID |
string |
Native, format-checked UUID. Pair with .default({ $$_expression: 'UUID' }) for a DB-side default. |
Column.ulid() |
VARCHAR(26) |
string |
Defaulted to a fresh ULID per row, via @tundralibs/id. |
Column.cuid() |
VARCHAR(25) |
string |
Defaulted to a fresh CUID per row, via @tundralibs/id. |
Column.cuid2(length?) |
VARCHAR(n) |
string |
Defaulted to a fresh CUID2 per row (default length 24), via @tundralibs/id. |
Column.nanoId(size?) |
VARCHAR(n) |
string |
Defaulted to a fresh nanoID per row (default size 21), via @tundralibs/id. |
Column.objectId() |
VARCHAR(26) |
string |
Defaulted to @tundralibs/id's ObjectID — one counter/machine-id generator shared by every row of this column. |
Column.simpleId() |
BIGINT |
bigint |
Defaulted to @tundralibs/id's simpleID — one shared counter/seed per column. |
Column.integer() |
INTEGER |
number |
Numeric validators apply. |
Column.int() |
INT |
number |
Dialect synonym of integer. |
Column.tinyint() |
TINYINT |
number |
1-byte int (SMALLINT/INTEGER where absent). |
Column.smallint() |
SMALLINT |
number |
2-byte int. |
Column.bigint() |
BIGINT |
bigint |
Values ride as bigint (0n). |
Column.decimal(precision, scale) |
DECIMAL(p,s) |
number |
Fixed-point. |
Column.numeric(precision, scale) |
NUMERIC(p,s) |
number |
Exact fixed-point; synonym of decimal. |
Column.float() |
FLOAT |
number |
|
Column.double() |
DOUBLE |
number |
|
Column.real() |
REAL |
number |
|
Column.bit() |
BIT |
number |
Bit value (BIT/BIT/INTEGER). |
Column.boolean() |
BOOLEAN |
boolean |
No numeric/string validators. |
Column.date() |
DATE |
Date |
Date validators apply. |
Column.time() |
TIME |
Date |
Only the clock part is significant. |
Column.datetime() |
DATETIME |
Date |
For engines that distinguish it from TIMESTAMP (MariaDB). |
Column.timestamp() |
TIMESTAMP |
Date |
Wall-clock, no zone. |
Column.timestamptz() |
TIMESTAMPTZ |
Date |
Timestamp with time zone: TIMESTAMPTZ on Postgres, tz-aware TIMESTAMP on MariaDB, ISO-with-offset TEXT on SQLite. |
Column.json<Shape>() / Column.json(schema)
|
JSONB |
Shape |
Typed object. Renders as JSONB on Postgres (never bare JSON), native JSON on MariaDB, TEXT on SQLite. Given a Guardian.object({...}) schema, Shape is INFERRED and every key is validated at write time — see below. |
Column.blob() |
BLOB |
Uint8Array |
Raw bytes. The crypto codec is text-canonical, so binary cannot be encrypted; encrypt an encoded text form instead. |
Column.binary(length) |
BINARY(n) |
Uint8Array |
Fixed-length raw bytes (BYTEA/BINARY/BLOB). Like blob, not encryptable. |
Column.varbinary(length) |
VARBINARY(n) |
Uint8Array |
Variable-length raw bytes. Like blob, not encryptable. |
Column.hash(algorithm?) |
VARCHAR |
string |
One-way digest column. Default 'SHA-256'. |
Column.password(algorithm?) |
VARCHAR |
string |
Auth digest: SHA-* (deterministic, filterable) or 'PBKDF2' (salted, verify-based). See Digest columns. |
Column.mask(source, fn) |
(virtual) | string |
Computed-on-read mask; never stored. |
json, boolean, blob, binary, varbinary, and bit are the
base builder: they carry the common modifiers and
encrypt(), but none of the value validators, since there is nothing
to range-check on a boolean.
These chain on every builder kind; a few are overridden on masks:
| Modifier | Effect |
|---|---|
.nullable() |
Column accepts NULL; also makes it omittable on insert. Adds | null to the TS type. |
.default(v) |
Insert default; see Defaults. |
.defaultOnUpdate(v) |
Auto-touch on every update (e.g. updatedAt). |
.comment(text) |
Documentation + DDL comment (COMMENT ON COLUMN …). |
.insertOnly() |
Written on insert, then fixed: update() rejects it, UpdateOf omits it, and an upsert conflict never changes it. For authorship (CreatedBy). |
.hidden() |
Exclude from default projections. ReadRowOf drops it, but it stays explicitly projectable and stays writable. |
.unfilterable() |
Reject the column in WHERE / ORDER BY. |
.renamedFrom(oldName) |
Migration hint: emit RENAME COLUMN instead of a data-losing drop+add. Inert everywhere else; delete it once applied everywhere. |
.beforeWrite(fn) |
Transform before validate/encrypt/write. |
.afterRead(fn) |
Transform on the way back out. |
.guard(g) |
Value validation + transforms — an already-built Guardian, pinned to the column's own concrete class. One-shot. |
.encrypt() |
Encrypt at rest. |
import { Column } from '@tundralibs/norm';
// hidden + unfilterable: readable only when explicitly asked for,
// never a filter or sort target.
const passwordHash = Column.varchar(64).hidden().unfilterable();A column's value-level rules — validators AND transforms, in whatever
order you write them — live in ONE already-built
Guardian, handed to .guard(g):
import { Column } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const email = Column.varchar(255)
.guard(Guardian.string().trim().toLowerCase().email());
const age = Column.integer().guard(Guardian.number().min(0));
const bornBefore = Column.date().guard(Guardian.date().past());
const clicks = Column.bigint().guard(Guardian.bigint().min(0n)).default(0n);.guard(g) is one-shot — call it once with everything composed
(Guardian.string().trim().pattern(...).minLength(...)); a second call
throws rather than silently replacing or stacking. g's type is pinned
to the column's OWN concrete guardian class — StringGuardian for
varchar/char/text/clob/uuid/xml, NumberGuardian/
BigIntGuardian for the numeric kinds (bigint gets BigIntGuardian,
every other numeric kind including bit gets NumberGuardian),
DateGuardian for date/timestamp/…, BooleanGuardian for
boolean, and UnknownGuardian<Shape> for json/blob/binary/
varbinary — never the generic BaseGuardian<T>. Some of these classes
also carry methods that change the value's TYPE (NumberGuardian .toBigInt(), DateGuardian.toISOString(), BigIntGuardian.toHex(),
BooleanGuardian.toNumber(), …); pinning the parameter this way means a
chain ending in one of those simply fails to type-check, rather than
silently letting a transform swap the column's declared type.
Nullability and defaults stay column-only — .nullable() /
.default(), never on the guard. A guard that's already .nullable()/
.optional()'d is rejected at construction ("declare nullable()/
default() on the column, not on the guard"), and so is an
async-refined guard (norm validates synchronously).
.guard() must precede .encrypt() — it runs against the PLAINTEXT:
import { Column } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const password = Column.varchar(255)
.guard(Guardian.string().minLength(12))
.encrypt().hash();Physical constraints stay independent of .guard() and always apply:
the declared VARCHAR(n) width is enforced regardless of what the
guard says (checked AFTER any transform, so .trim() shrinking a value
below the cap is judged on the trimmed result), and integer/int/
tinyint/smallint/bit always reject non-whole numbers.
For a literal-value restriction that also narrows the TS type — the
.lov() replacement — use Column.enum(...) instead of .guard():
import { Column } from '@tundralibs/norm';
const status = Column.enum(['active', 'banned']); // VARCHAR(6), TS: 'active' | 'banned'
const priority = Column.enum([1, 2, 3]); // INTEGER, TS: 1 | 2 | 3
const bits = Column.enum([1n, 2n]); // BIGINT, TS: 1n | 2nWidth/kind is derived from the values (VARCHAR sized to the longest
string, or INTEGER/BIGINT); mixed-type value lists are rejected.
.guard() is unavailable on a Column.enum(...) column — the values
already generated its EnumGuardian, which .guard()'s type pinning
can't accept (an EnumGuardian isn't a StringGuardian/NumberGuardian
— same class-mismatch reasoning as Column.json(schema) below). For a
literal-value restriction that ISN'T TS-narrowed — e.g. case-insensitive
matching — use .guard()'s own StringGuardian.isIn() instead, with a
normalizing transform before it:
import { Column } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const status = Column.varchar(6)
.guard(Guardian.string().toLowerCase().isIn(['active', 'banned']));db.repo(key).guardians exposes the two Guardians the repo itself
validates every payload against — the same objects, not a rebuilt
copy, so they can never drift from what insert()/update() actually
enforce. Useful for validating a payload (an API request body, say)
before it reaches norm at all:
// Inside an HTTP handler, before the payload ever reaches norm:
const { insert } = db.repo('Users').guardians;
const [err, value] = insert.safeParse(requestBody);
if (err) return badRequest(err.message);
await db.repo('Users').insert(value);Both are .strict(): an unknown key — including one disableInsert/
disableUpdate'd by the entity's own insert/update pick-list (a
hash sibling, say, or a column outside a write scope) — is a loud
error, not a silent drop. insert's shape mirrors InsertOf<D>
(every non-nullable, non-defaulted column required); update's
mirrors UpdateOf<D> (every column optional — a partial payload is
the point of a PATCH-style update).
Neither one runs encryption, hashing, masks, hooks, or DB-side
expression defaults — those are norm's write pipeline, not the
Guardian layer, so parsing here never writes anything. It validates
shape and the rules you gave each column's .guard(); the write
itself still goes through insert()/update().
Column.json<Shape>() on its own validates nothing about the value beyond
"is a non-array object" — Shape is a pure TS phantom. Pass a
Guardian.object({...}) schema instead and
Shape is INFERRED from it, with every key validated per write using the
schema's own rules:
import { Column, Entity } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const Preferences = Guardian.object({
theme: Guardian.enum(['light', 'dark'] as const),
notifications: Guardian.boolean(),
});
const Users = Entity('users', {
id: Column.uuid(),
preferences: Column.json(Preferences), // Shape inferred, no hand-kept <Shape>
}, { pk: ['id'] });A malformed preferences payload now fails with the schema's own per-key
error (e.g. theme not in 'light' | 'dark'), not the generic "must be a
non-array object". Column.json<Shape>() (no argument) is unchanged and
remains additive alongside this overload.
Because the schema is already a fully configurable ObjectGuardian
— .strict() / .passthrough() / .catchall() / .refine() all apply —
configure it directly, before passing it to Column.json():
import { Guardian } from '@tundralibs/guardian';
const Preferences = Guardian.object({ theme: Guardian.string() }).strict();.guard() is unavailable on a schema-provided JSON column (it throws):
the base ColumnBuilder.guard()'s type doesn't match this column's real
runtime guardian (the schema instance itself), so norm keeps this a hard,
documented error rather than a silently wrong type.
.default(v) fires when the insert payload omits the column or passes
explicit undefined; an explicit null is validated as a value, not
replaced. It also makes the column omittable in InsertOf.
.defaultOnUpdate(v) does the same on every update. Both accept three
forms:
import { Column } from '@tundralibs/norm';
// 1. Literal — used as-is.
const isActive = Column.boolean().default(true);
// 2. Local generator — a plain JS function, called per row by the
// generated Guardian (great for timestamps / ULIDs client-side).
const createdAt = Column.timestamp().default(() => new Date());
// 3. DB-side expression — evaluated by the database.
const id = Column.uuid().default({ $$_expression: 'UUID' });
// defaultOnUpdate auto-touches on every write:
const updatedAt = Column.timestamp()
.default(() => new Date())
.defaultOnUpdate(() => new Date());An ExpressionDefault is { $$_expression: string, args?: unknown }.
It is passed through to the query untouched and never validated as a
JS value.
On MongoDB an insert document cannot evaluate expressions, so the clock
expressions (NOW, CURRENT_TIMESTAMP, CURRENT_DATE, UNIX_TIMESTAMP)
and UUID are computed when the insert is built, the clock ones at one
instant per statement. Any other expression default fails the insert with
DialectUnsupportedError; use a local generator instead. Updates and
upserts evaluate expressions in the database on every dialect.
.beforeWrite(fn) normalizes a value before it is validated,
encrypted, and written; .afterRead(fn) transforms it as it comes back
from a read. Both are runtime-only callbacks and are stripped from the
JSON and snapshot export. Write-side normalization is usually better
expressed inside .guard()'s own Guardian (.trim().toLowerCase() runs
in the exact order you wrote it, alongside validation) — .beforeWrite()
stays available for a norm-native hook, and is the only option on the
FILTER side for a hashed column's equality lookups.
import { Column } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const email = Column.varchar(255)
.guard(Guardian.string().trim().toLowerCase().pattern(/^\S+@\S+\.\S+$/));
const country = Column.char(2).beforeWrite((v) => v.toUpperCase());
// afterRead runs on the way out — after decrypt, before masks compute.
// Store a compact code, hand callers a display-ready value:
const status = Column.varchar(12).afterRead((v) => v.replace(/_/g, ' '));.encrypt() encrypts the column at rest (AES via the Norm secret) and
is available on every value kind: string, number, bigint, date,
boolean, json. The logical TS type is unchanged, so an encrypted
timestamp() still reads and writes Date. The runtime canonicalizes
the plaintext to a string before encrypting and decodes it back on
read, and the physical column becomes TEXT.
import { Column } from '@tundralibs/norm';
// Date in TS, ciphertext TEXT at rest.
const birthday = Column.timestamp().encrypt().nullable();Two consequences follow:
-
.guard()must chain beforeencrypt(). It constrains the plaintext, soencrypt()narrows the builder to a surface where it no longer exists. - Encrypted columns are not filterable, because random-IV ciphertext
never matches an equality predicate. To filter or enforce uniqueness
by plaintext, add
.hash():
import { Column } from '@tundralibs/norm';
// Ciphertext at rest, still filterable / uniquable by plaintext.
const email = Column.varchar(255).encrypt().hash();.hash() exists only on an encrypted builder and synthesizes a
deterministic <col>_hash sibling column; see
Hash siblings. The crypto pipeline, digests, and
override hooks are in Security.
Column.hash(algorithm) is a standalone one-way digest column, for
values like passwords that must be comparable but never readable.
Callers write and filter by plaintext; the runtime digests on the way
in and stores only the hex digest, whose VARCHAR length derives from
the algorithm.
import { Column } from '@tundralibs/norm';
// Store a password digest, never the plaintext.
const pin = Column.hash('SHA-256').nullable();algorithm is one of 'SHA-256' (default, VARCHAR(64)), 'SHA-384'
(VARCHAR(96)), or 'SHA-512' (VARCHAR(128)). .guard() (inherited,
pinned to StringGuardian) chains here and constrains the plaintext,
which is where a password policy goes. encrypt() is a hard error,
since a digest is already one-way.
A digest column is the inverse of
.encrypt().hash(): a digest is write-and-forget, whereas an encrypted and hashed column is fully readable with an additional lookup digest on the side.
Column.password(algorithm?) is the auth-facing digest column. It has
two modes:
| Mode | Behaviour |
|---|---|
Column.password('SHA-256' | 'SHA-384' | 'SHA-512') (default) |
Deterministic digest, identical to hash(): write and filter by plaintext, store the digest. Fast and searchable, but a leaked table is brute-forceable. |
Column.password('PBKDF2') |
Salted PBKDF2 hash, the correct choice for real passwords. Each hash is unique, so the column is not filterable: read the row and pbkdf2Verify(candidate, row.field). |
import { Column, Entity, Norm, pbkdf2Verify, Schema } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const Users = Entity('users', {
id: Column.uuid().default({ $$_expression: 'UUID' }),
// salted, verify-based
secret: Column.password('PBKDF2').guard(Guardian.string().minLength(12)),
}, { pk: ['id'] });
declare const userId: string;
declare const candidatePassword: string;
const db = new Norm({ database: { dialect: 'sqlite', path: './data' } })
.use(Schema('Identity', { Users }));
// Log in: look the user up by a filterable column, then verify.
const row = (await db.repo('Users').find({ '@id': userId })).data[0];
const ok = row && await pbkdf2Verify(candidatePassword, row.secret);pbkdf2Verify is re-exported from @tundralibs/norm. The PBKDF2 KDF
(salt, OWASP-guided iterations) comes from @tundralibs/crypt and is
overridable via the instance's crypto.pbkdf2Hash.
Why not a plain digest for passwords? A fast SHA-2 digest is cheap to brute-force if the table leaks.
'PBKDF2'salts and stretches the input so each hash is unique and slow to attack, at the cost of plaintext filtering: verify instead of look up.
Column.mask(source, fn) is a virtual column computed client-side from
a sibling source column after decrypt and afterRead. It is never
stored, never in DDL or snapshots, and excluded from writes, filters,
and ordering.
import { Column } from '@tundralibs/norm';
const Users = {
apiKey: Column.varchar(256).encrypt(), // readable, never lookupable
apiKeyHint: Column.mask('apiKey', (v) => `…${v.slice(-4)}`),
};The mask has its own first-class key (the property name), several
masks may share one source, and the source's own .hidden() decision
is independent of the mask. Type the fn parameter to the source's
logical type when it is not a string, for example
Column.mask<Date>('birthday', (v) => v.getFullYear().toString()).
Only .nullable(), .hidden(), and .comment() chain on a mask.
.default(), .defaultOnUpdate(), .beforeWrite(), .afterRead(),
and .encrypt() all throw: a mask is presentation computed from its
source, with nothing to write or encrypt. Declare .nullable() when
the source is nullable, since a null source yields a null mask.
The illegal combinations of a literal API are not reachable here. Most are compile errors and a few are guarded runtime throws:
| Attempt | Result |
|---|---|
.hash() before .encrypt()
|
Compile error: hash() exists only on the encrypted builder. |
.guard() after .encrypt()
|
Compile error: .guard() lives on the plaintext builders. |
.encrypt().encrypt() |
Runtime throw: already encrypted. |
Column.hash(algo).encrypt() |
Runtime throw: digests are one-way. |
.default() / .beforeWrite() / .encrypt() on a mask
|
Runtime throw: masks are computed presentation. |
A _hash-named column colliding with a synthesized sibling |
Runtime throw at Entity(): norm owns <column>_hash names. |
Entity(name, columns, options);The options bag carries a type discriminator that selects the kind.
Omit it for the default, TABLE.
Physical, writable, DDL-emitting. pk is required; a composite key
lists several columns. fk aliases drive joins and reverse relations;
index and unique emit DDL indexes.
import { Column, Entity } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
export const Users = Entity('users', {
id: Column.uuid().default({ $$_expression: 'UUID' }),
email: Column.varchar(255)
.guard(Guardian.string().trim().toLowerCase().pattern(/^\S+@\S+\.\S+$/))
.encrypt().hash()
.comment('Sign-in identifier; encrypted at rest, unique via sibling'),
apiKey: Column.varchar(256).encrypt(),
apiKeyHint: Column.mask('apiKey', (v) => `…${v.slice(-4)}`),
role: Column.enum(['admin', 'editor', 'viewer']).default('viewer'),
displayName: Column.varchar(120).guard(Guardian.string().minLength(2)),
passwordHash: Column.varchar(64).hidden().unfilterable(),
pin: Column.hash('SHA-256').nullable(),
loginCount: Column.integer().guard(Guardian.number().min(0)).default(0),
createdAt: Column.timestamp().default(() => new Date()),
updatedAt: Column.timestamp().default(() => new Date())
.defaultOnUpdate(() => new Date()),
}, {
pk: ['id'],
comment: 'Registered accounts',
unique: { email: ['email_hash'] }, // uniqueness on the digest sibling
update: ['displayName', 'role', 'loginCount', 'passwordHash'],
hooks: {
beforeInsert: (row) => ({ ...row, displayName: row.displayName.trim() }),
},
});A composite primary key is a longer tuple:
import { Column, Entity } from '@tundralibs/norm';
export const PostTags = Entity('post_tags', {
postId: Column.integer(),
tagId: Column.integer(),
}, {
pk: ['postId', 'tagId'], // composite
fk: {
Post: { model: 'Posts', on: { postId: 'id' }, reverseAs: 'TagLinks' },
Tag: { model: 'Tags', on: { tagId: 'id' }, reverseAs: 'PostLinks' },
},
});Read-only and DB-side (CREATE VIEW … AS query). A view can be joined
against and can be the base of further stored queries. Pass a query
(an OQL SELECT), optionally materialized: true, and optionally a
logical fk for join linkage only, never DDL and never snapshotted;
see Many-to-many through a view.
import { Column, Entity } from '@tundralibs/norm';
export const ActiveLinks = Entity('active_links', {
id: Column.integer(),
slug: Column.varchar(32),
targetUrl: Column.text(),
clicks: Column.bigint(),
}, {
type: 'VIEW',
query: {
type: 'SELECT',
table: 'links',
columns: ['id', 'slug', 'targetUrl', 'clicks', 'isActive'],
projection: {
'@id': true,
'@slug': true,
'@targetUrl': true,
'@clicks': true,
},
where: { '@isActive': true },
},
});Views have no primary key, so a derived reverse relation is always
hasMany; declare reverseCardinality: 'hasOne' explicitly when the
view is one row per target. materialized: true emits
CREATE MATERIALIZED VIEW on Postgres and degrades to a plain view on
other dialects.
A read-only, client-side stored SELECT with no DDL. It is terminal:
it cannot be joined, cannot be built upon by other views or queries,
and cannot declare foreign keys.
import { Column, Entity } from '@tundralibs/norm';
export const TopLinks = Entity('top_links', {
slug: Column.varchar(32),
clicks: Column.bigint(),
}, {
type: 'QUERY',
query: {
type: 'SELECT',
table: 'active_links', // composes on the VIEW above
columns: ['slug', 'clicks'],
projection: { '@slug': true, '@clicks': true },
orderBy: { '@clicks': 'DESC' },
},
});| Option | Kind | Description |
|---|---|---|
type |
all |
'TABLE' (default), 'VIEW', or 'QUERY'. |
pk |
TABLE | Required. Primary-key column tuple; composite keys list several. |
fk |
TABLE, VIEW | Named FK aliases → target + column mapping. See Foreign keys. |
index |
TABLE | Named indexes: name → column tuple. Synthesized <col>_hash siblings are indexable. |
unique |
TABLE | Named UNIQUE constraints, emitted as unique indexes (diffable on every dialect). |
insert |
TABLE | Insert pick-list. |
update |
TABLE | Update pick-list. |
hooks |
all | Whole-row hooks. TABLEs get the write + delete hooks; read-only kinds get afterRead only. |
dbSchema |
TABLE, VIEW | Database namespace (e.g. Postgres public). Named dbSchema because "schema" already means a named entity collection in norm. |
comment |
all | Documentation + DDL comment (COMMENT ON TABLE …). |
defaultPageSize |
all | Rows a limit-less find() fetches (default 10). 0 means unbounded, and every such read emits a warning event. |
cache |
all | Read-cache TTL in minutes (0/omitted = off; needs a cache config on Norm). See Read caching. |
temporal |
TABLE | Make it an effective-dated temporal table: norm keeps every version; insert supersedes, and update/upsert/truncate/delete are disabled. |
audit |
TABLE | Generate a versioned audit replica: the table itself is unchanged; norm mirrors every write into it. |
query |
VIEW, QUERY | Required on read-only kinds. The stored OQL SELECT. |
materialized |
VIEW |
CREATE MATERIALIZED VIEW (Postgres; degrades elsewhere). |
renamedFrom |
TABLE | Migration hint: this table's previous physical name (optionally 'dbSchema.name'-qualified). Consumed only by the migration diff. |
Three options from the table above deserve a minimal example each.
dbSchema places a TABLE or VIEW in a database namespace instead of
the dialect's default one (Postgres public, or the equivalent):
import { Column, Entity } from '@tundralibs/norm';
export const Invoices = Entity('invoices', {
id: Column.integer(),
amount: Column.decimal(10, 2),
}, { pk: ['id'], dbSchema: 'billing' });Reach for it when one database hosts several logical groupings you
want DDL to land in separately, such as per-tenant schemas or billing
beside public. Leave it unset for the single-schema case: every
reader of the entity then has to know which schema it lives in.
defaultPageSize overrides how many rows a limit-less find()
fetches for this entity (the global default is 10; see
Pagination and totals):
import { Column, Entity } from '@tundralibs/norm';
export const AuditLog = Entity('audit_log', {
id: Column.bigint(),
message: Column.text(),
}, { pk: ['id'], defaultPageSize: 50 });Raise it for entities callers page through in bigger chunks. 0
removes the cap: every limit-less read then returns every row and emits
an unbounded-read warning event, since a missing limit on a large
table is a common cause of outages.
renamedFrom (entity-level) is consumed only by the migration
diff. It has no effect on reads, writes, or validation and does not
change what callers see. See
Table renames for the mechanics and
a worked example, including when a stable registry key already makes
it unnecessary.
Row-level hooks complement the per-column transforms: they see the whole row, or the filter for deletes. Returning a row replaces the payload; returning nothing means the hook mutated in place. Like column transforms, hooks are runtime-only and drop out of the JSON export.
TABLE entities take four hooks:
import { Column, Entity } from '@tundralibs/norm';
export const Tickets = Entity('tickets', {
id: Column.integer(),
subject: Column.varchar(200),
deletedAt: Column.timestamp().nullable(),
}, {
pk: ['id'],
hooks: {
beforeInsert: (row) => ({ ...row, subject: row.subject.trim() }),
beforeUpdate: (row) => row,
afterRead: (row) => row,
// Fires BEFORE a DELETE runs, with the caller's filter (undefined =
// the all-rows form). THROW to veto — audit gates, soft-delete
// enforcement. Runs for delete()/deleteByPK(), not truncate().
beforeDelete: (filter) => {
if (filter === undefined) throw new Error('refusing unfiltered delete');
},
},
});Read-only kinds (VIEW, QUERY) take afterRead only; the write-side
hooks are rejected at construction.
insert and update restrict which columns a caller may pass per
operation, the "request schema" pattern. Columns outside a declared
list become norm-owned for that operation, and InsertOf, UpdateOf,
and the generated Guardians are limited to the list. Omit a list and
every column is passable.
import { Column, Entity } from '@tundralibs/norm';
export const Accounts = Entity('accounts', {
id: Column.uuid().default({ $$_expression: 'UUID' }),
email: Column.varchar(255).encrypt().hash(),
displayName: Column.varchar(120),
role: Column.enum(['admin', 'user']).default('user'),
updatedAt: Column.timestamp().defaultOnUpdate(() => new Date()),
}, {
pk: ['id'],
// email/id are immutable for callers; updatedAt auto-touches from
// OUTSIDE the caller scope (norm-maintained behavior is unaffected).
update: ['displayName', 'role'],
});The lists govern the payload surface, not norm itself: norm-maintained
behavior such as hash siblings and the defaultOnUpdate auto-touch
always runs regardless.
A foreign key is a named alias mapping local columns to a target's
columns. Declared on fk, each one drives joins and a derived reverse
relation on the target.
import { Column, Entity } from '@tundralibs/norm';
export const Profiles = Entity('profiles', {
userId: Column.uuid(),
bio: Column.text().nullable(),
birthday: Column.timestamp().encrypt().nullable(),
}, {
pk: ['userId'],
fk: {
User: {
model: 'Users', // registry key of the target — not a table name
on: { userId: 'id' }, // local column → target column
reverseAs: 'Profile', // Users rows can project '@Profile'
reverseProject: true, // …and get it eagerly on default reads
onDelete: 'CASCADE', // a profile can't outlive its user
},
},
});model is the target's entity key, the stable name it is exposed under
in the schema registry (Schema('Identity', { Users }) gives
'Users'), not its database table name. Renaming the physical table,
or moving it to another dbSchema, is an ALTER; no FK declaration
changes. There are no model imports and no import cycles, and
definitions serialize cleanly. Keys resolve, with named errors, when
schemas compose in use(), so a
cross-schema FK (Posts → 'Users') is written exactly like an
in-schema one.
Every FK from A → B gives B a reverse relation.
-
reverseAsnames it. It defaults toA's registry key and auto-qualifies as<Key>_via_<alias>when two FKs share the same source and target. When two FKs point at the same target, name both reverses explicitly:import { Column, Entity } from '@tundralibs/norm'; export const Links = Entity('links', { id: Column.integer(), ownerId: Column.uuid(), createdById: Column.uuid(), }, { pk: ['id'], fk: { Owner: { model: 'Users', on: { ownerId: 'id' }, reverseAs: 'Links' }, CreatedBy: { model: 'Users', on: { createdById: 'id' }, reverseAs: 'CreatedLinks', }, }, });
-
reverseCardinality('hasOne'|'hasMany') sets whether the target sees one row or many. It defaults by derivation:hasOnewhen the FK's local columns equal this entity's primary key, elsehasMany. InProfilesabove the FK column (userId) is the whole pk, soUserssees@Profileas an object-or-null with no explicit cardinality needed. Views have no pk, so their reverses are alwayshasManyunless you declare otherwise.
A reverse name must be free on the target: it cannot collide with one
of the target's columns, with a foreign-key alias (those resolve first,
so the reverse would be unreachable), or with another reverse.
Composing with use() checks this once the
full graph is known and throws a REVERSE_COLLISION error naming the
clash; set an explicit reverseAs to resolve it.
By default, relations are fetched only when you project them. Two flags make a relation load eagerly on default (projection-less) reads:
-
project: trueeager-fetches this relation on this entity's default reads. Rows gain the alias asobject | null, the target's local default row, depth-1 with no transitive eager. -
reverseProject: trueeager-fetches the derived reverse on the target's default reads. OnlyhasOnereverses qualify (an explicitreverseCardinality: 'hasOne', or FK columns equal to the pk). An eager to-many list on every read would be a trap, sohasManyis rejected at construction.
Explicit projections replace the eager default entirely, and write
RETURNING stays flat, since it cannot join.
Both flags are plain booleans. Omitting either defaults to false
(not eager); writing project: false / reverseProject: false
explicitly type-checks and behaves exactly like omitting it.
onDelete and onUpdate take the cross-dialect-safe subset
CASCADE | RESTRICT | NO_ACTION | SET_NULL, applied to the physical FK
constraint. Omitting them uses the database default (RESTRICT). They
are TABLE-only: a VIEW's fk is a logical join with no physical
constraint to act on, so an action there is rejected at construction.
MongoDB can't physically enforce a FK at all, and degrades
best-effort rather than throw — the relation itself (joins, eager
projection, reverse relations) is unaffected, only the physical
constraint (and therefore onDelete/onUpdate) is skipped, for every
FK unconditionally. This surfaces a message in Migrator.plan()'s
PlannedStep.warnings and Migrator.apply()'s ApplyResult.warnings
(populated on a real apply, not only dryRun) — read it to see exactly
which FK was affected and why. See Migrations.
Every SQL dialect — including SQLite, which has no native schema object
and instead folds dbSchema into the physical table name as a prefix
(see Migrations § SQLite dbSchema) — enforces
every FK physically, a dbSchema boundary included.
The logical fk on a VIEW is what makes a view projectable from its
target, which is the many-to-many pattern. A junction⋈far VIEW flattens
the join once, DB-side, and its logical FK derives the reverse relation
on the target, so "posts with their tags" is one call and one SELECT
with no junction pivoting:
import { Column, Entity } from '@tundralibs/norm';
export const TagsOfPosts = Entity('tags_of_posts', {
postId: Column.integer(),
tagId: Column.integer(),
name: Column.varchar(40),
}, {
type: 'VIEW',
query: {
type: 'SELECT',
table: 'post_tags',
columns: ['postId', 'tagId'],
joins: {
T: {
table: 'tags',
columns: ['id', 'name'],
type: 'INNER',
on: { '@T.@id': '@tagId' },
},
},
projection: { '@postId': true, '@tagId': true, '@T.@name': 'name' },
},
fk: {
// Logical join fk only — never DDL. Gives Posts a '@Tags' relation.
Post: { model: 'Posts', on: { postId: 'id' }, reverseAs: 'Tags' },
},
});
// Posts now project their tags in one query:
// db.repo('Posts').find(undefined, {
// project: { '@title': true, '@Tags': { '@name': true } },
// });Chaining .encrypt().hash() tells Entity() to synthesize a
<col>_hash sibling column alongside the encrypted one. The sibling is
a deterministic SHA-256 digest (VARCHAR(64)), maintained by norm on
every write, so plaintext equality filters, $in, uniqueness, and
upsert conflict keys all rewrite to an indexed digest lookup.
You never declare the sibling; you reference it by name in index and
unique:
import { Column, Entity } from '@tundralibs/norm';
export const Users = Entity('users', {
id: Column.uuid().default({ $$_expression: 'UUID' }),
email: Column.varchar(255).encrypt().hash(),
}, {
pk: ['id'],
unique: { email: ['email_hash'] }, // uniqueness on the synthesized sibling
});The sibling is norm-owned (disableInsert / disableUpdate), its
nullability follows the source column, and it follows the source
through renamedFrom so a rename does not drop every stored digest.
Declaring your own column named <col>_hash that collides with a
synthesized sibling is an error at Entity(). For the crypto pipeline,
digest algorithms, masks, and override hooks, see
Security.
A schema is a named collection of entities; the database namespace is
the separate dbSchema option on Entity(). Group entities with
Schema(name, entities), then compose any number of schemas into one
typed registry with use(...schemas).
import { Schema, use } from '@tundralibs/norm';
import { Users } from './identity/users.ts';
import { Profiles } from './identity/profiles.ts';
import { Posts } from './blog/posts.ts';
const Identity = Schema('Identity', { Users, Profiles });
const Blog = Schema('Blog', { Posts }); // Posts' fk targets 'Users' cross-schema
// Compose exactly the schemas an instance exposes.
const registry = use(Identity, Blog);Schema() accepts a plain object or a namespace/barrel import;
non-entity exports (helpers, constants) are filtered out, so
Schema('Blog', BlogModule) works directly. It validates everything it
can see and defers unknown FK targets, because a target may live in
another schema.
use() merges the schemas into the flat registry a Norm instance is
constructed over, and this is where deferred names resolve. Composition
fails with named errors when:
- Registry keys collide across composed schemas (keys must be unique).
- A deferred FK target does not resolve (composing
BlogwithoutIdentity, say, so'Users'is missing). - An FK target resolves to a
QUERY. FK targets must be aTABLEorVIEW. - A stored
SELECT(of a VIEW or QUERY) reads from or joins a registeredQUERY's database name. Queries are terminal. - A reverse-relation name, derived or explicit
reverseAs, collides with a column, a foreign-key alias, or another reverse on the target. Resolving reverses needs the full graph, souse()is the first place this is caught;Schema(), which may still be missing cross-schema targets, skips it.
Once composed, the registry is what you hand to the Norm handle to open repos over your entities; see the Norm README quick start and Querying.
- Querying: filters, typed projections, relations, aggregates, and pagination.
- Security: encryption, digest siblings, masks, and crypto override hooks.
-
Migrations: snapshots, plans, the rebuild
engine, and
renamedFromhints. - Scoping: tenant scoping and default filters.