Skip to content

NORM Schema

GitHub Actions edited this page Sep 27, 2026 · 16 revisions

Schema definition

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.

Deno Bun Node.js

Table of Contents

Overview

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.

Column builders

Start every column with a factory from the Column object, then chain modifiers. Import it from the package root:

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

Factories

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.

Common modifiers

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();

Validation and transforms

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 | 2n

Width/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']));

Whole-row Guardians (insert/update)

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().

JSON schemas

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.

Defaults

.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.

Transforms

.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, ' '));

Encryption and hashing

.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:

  1. .guard() must chain before encrypt(). It constrains the plaintext, so encrypt() narrows the builder to a surface where it no longer exists.
  2. 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.

Digest columns

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.

Masked columns

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.

Correct by construction

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.

Defining entities

Entity(name, columns, options);

The options bag carries a type discriminator that selects the kind. Omit it for the default, TABLE.

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' },
  },
});

VIEW

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.

QUERY

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' },
  },
});

Options reference

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.

dbSchema, defaultPageSize, and table renames

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.

Hooks

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.

Write scoping (insert / update pick-lists)

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.

Foreign keys and relations

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 a registry key

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.

Reverse relations

Every FK from A → B gives B a reverse relation.

  • reverseAs names it. It defaults to A'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: hasOne when the FK's local columns equal this entity's primary key, else hasMany. In Profiles above the FK column (userId) is the whole pk, so Users sees @Profile as an object-or-null with no explicit cardinality needed. Views have no pk, so their reverses are always hasMany unless 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.

Eager projection

By default, relations are fetched only when you project them. Two flags make a relation load eagerly on default (projection-less) reads:

  • project: true eager-fetches this relation on this entity's default reads. Rows gain the alias as object | null, the target's local default row, depth-1 with no transitive eager.
  • reverseProject: true eager-fetches the derived reverse on the target's default reads. Only hasOne reverses qualify (an explicit reverseCardinality: 'hasOne', or FK columns equal to the pk). An eager to-many list on every read would be a trap, so hasMany is 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.

Referential actions

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.

Many-to-many through a view

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 } },
//   });

Hash siblings

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.

Schemas and composition

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 Blog without Identity, say, so 'Users' is missing).
  • An FK target resolves to a QUERY. FK targets must be a TABLE or VIEW.
  • A stored SELECT (of a VIEW or QUERY) reads from or joins a registered QUERY'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, so use() 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.

Related documentation

  • Querying: filters, typed projections, relations, aggregates, and pagination.
  • Security: encryption, digest siblings, masks, and crypto override hooks.
  • Migrations: snapshots, plans, the rebuild engine, and renamedFrom hints.
  • Scoping: tenant scoping and default filters.

← Back to NORM

Clone this wiki locally