Column types

Every column constructor — id, text, integer, boolean, timestamp, json, vector, reference — with their SQL types, defaults, and modifiers.

A column declaration is a single function call returning a ColumnBuilder. You can chain modifiers (.nullable(), .unique(), .default(...), .computed(...), .onUpdate(...)). Indexes are declared at the table level — there is no column-level .index().

import { text } from '@voltro/database'

text()                       // text NOT NULL
text().nullable()            // text NULL
text().unique()              // text NOT NULL UNIQUE
text().default('draft')      // text NOT NULL DEFAULT 'draft'

The complete column constructor list

id()

id()                         // PRIMARY KEY — value generated by the runtime

TypeID by default — <prefix>_<26-char-ULID> (Stripe-style), with the prefix auto-derived from the table name. Sortable by creation time, URL-safe, no leaking sequential counts, and branded to its table type at compile time. You don't pass it explicitly on insert; the runtime generates it.

To override the scheme:

import { id } from '@voltro/database'
id()                         // TypeID — `<prefix>_<26-char-ULID>` (default)
id({ prefix: 'org' })        // TypeID with an explicit prefix
id({ scheme: 'ulid' })       // bare ULID — no prefix (don't leak the table type)
id({ scheme: 'numeric' })    // DB-managed BIGSERIAL / AUTO_INCREMENT / IDENTITY
id({ scheme: 'snowflake' })  // 64-bit Snowflake id

Composite primary key

For a natural composite key — a join table keyed on both its FKs, a time-series table keyed on (deviceId, ts) — declare it at the table level with .primaryKey([...]). It replaces the single-column id() PK: the listed columns become the table's key, emitted as PRIMARY KEY (a, b) on every dialect.

import { reference, text, table } from '@voltro/database'

table('memberships', {
  userId: reference(() => users),
  orgId:  reference(() => orgs),
  role:   text(),
}).primaryKey(['userId', 'orgId'])
// → PRIMARY KEY ("userId", "orgId")

The member columns are type-checked against the row, and the key implies NOT NULL on each. Don't also declare an id() column — the declared key is the key; a stray id() would emit a second primary key and the DDL is rejected (the DSL catches this at declaration time).

One column is allowed too, and it is not the same thing as id(). id() mints a key the table owns; a single-column .primaryKey(['userId']) says the key is a value that comes from somewhere else — which is how you declare a one-to-one that shares its parent's key:

import { reference, table } from '@voltro/database'

// The profile's key IS its foreign key — one row per user, enforced by the PK.
table('profiles', {
  userId: reference(() => users),
  bio:    text(),
}).primaryKey(['userId'])
// → PRIMARY KEY ("userId")

Without this, a shared primary-key/foreign-key was undeclarable: you had to invent a second key column purely to satisfy a two-column minimum, and then defend a uniqueness the schema no longer stated.

Address such a row by its columns. The keyed store forms take EITHER an id string or the columns that identify the row, so a declared key is a first-class key rather than a reason to drop into the predicate builder:

await store.update('memberships', { userId: u, orgId: o }, { role: 'member' })
await store.delete('memberships', { userId: u, orgId: o })
await store.patchJson('memberships', { userId: u, orgId: o }, 'prefs.theme', 'dark')

A bare string still means the id column — unchanged, and what nearly every call is. On a table that has no id it is refused with the key columns named, because the earlier behaviour was silence: the call returned null / false, which is also what a missing row returns, so a write that could never land looked like one against a row that was already gone.

text()

text()                       // unbounded text NOT NULL (TEXT / LONGTEXT)
text().nullable()            // text NULL
text().oneOf(['a', 'b', 'c'])  // narrows to 'a' | 'b' | 'c' + CHECK (col IN (...))
text().maxLength(191)        // VARCHAR(191) (NVARCHAR on mssql) — bounded + index-able

Plain text() is unbounded — TEXT on Postgres/SQLite, LONGTEXT on MySQL/MariaDB (full TEXT there silently truncates past 64 KB). That's the right default for user prose (article bodies, JSON-as-string blobs).

Reach for .maxLength(n) when the column is a short identifier (a kind, slug, external id, resource discriminator) that participates in a .unique([...]) or composite .index([...]): an unbounded column can only back a HASH long-unique constraint (MariaDB) or a prefix index, whereas a bounded VARCHAR(n) takes a plain BTREE key. It's cross-dialect (VARCHAR(n) on Postgres/MySQL/MariaDB, NVARCHAR(n) on MSSQL, TEXT on SQLite) — unlike raw('varchar(20)'), which hardcodes one dialect's DDL.

Changing the bound on an EXISTING column is a real migration. The differ compares maxLength on both sides, so text() → text().maxLength(64), or 64 → 128, produces an operation:

  • widening (a larger bound, or dropping the bound) is safe — no value can be lost;
  • narrowing is needs-backfill: the ALTER fails at the database for any existing value longer than the new bound. The plan says so and gives you the query to run first.

Not compared on SQLite, which has no length-enforced type — a declared bound there is real, the live column is always TEXT, and comparing them would re-plan the same ALTER on every boot.

For value constraints (length ranges, regex, email, numeric min/max, cross-field invariants), use table-level table().validate(Schema) (see below) — that's the single validation surface, not a pile of per-column modifiers. There are no .minLength() / .pattern() column modifiers by design.

integer()

integer()                    // integer NOT NULL
integer().default(0)

32-bit integer. For values past the 32-bit range use bigint(); for money use decimal(p, s) — never a float. Range constraints (>= 0, between) go in table().validate(Schema), not on the column.

decimal(precision, scale) / numeric(precision, scale)

For declared decimal columns, the memory store compares min() and max() numerically, including grouped aggregates, without converting values to JavaScript numbers. The selected value retains its stored representation; null values are ignored and an all-null group returns null. Text columns retain lexical ordering. This does not establish decimal sum() or avg() support in the memory store.

Fixed-point exact decimal — money, tax rates, anything where floating-point rounding is unacceptable. numeric is an exact alias of decimal (same DDL; Postgres treats NUMERIC and DECIMAL as the same type).

import { decimal, numeric } from '@voltro/database'

decimal(12, 2)               // up to 9_999_999_999.99
numeric(5, 4)                // 0.0000 .. 9.9999  (alias for decimal)
decimal(38, 9).nullable()

Emits NUMERIC(p, s) on Postgres and DECIMAL(p, s) on MySQL / MariaDB / MSSQL. SQLite, native Turso and libSQL use exact, order-preserving encoded TEXT storage. The store encodes writes and defaults and decodes reads to decimal strings at the declared scale; NULL remains NULL. The SQLite type marker includes the precision and scale. Raw stored bytes are not plain decimal strings. precision is the total number of significant digits; scale the digits after the point (0 <= scale <= precision, default 0) — both validated at declaration time.

The row type is string, not number — on purpose. A JS number is an IEEE-754 double and silently loses precision past ~15-17 significant digits (0.1 + 0.2 !== 0.3), so a big DECIMAL round-tripped through a JS number would corrupt. The value carries as a string end-to-end. Do arithmetic with a decimal library or supported typed query-builder operations, never JS +. On SQLite-family stores, do not apply raw SQL numeric arithmetic or CAST(... AS NUMERIC) to encoded columns. Review custom SQL views, triggers and external readers/writers when adopting this storage format.

MSSQL typed row queries preserve declared decimal() fields as exact strings, including projections, aliases and DISTINCT pagination. Sorting, filtering and nested SQL expressions still operate on numeric values; only the final row projection converts decimals to text before driver parsing. Mutation return rows and Change Tracking also preserve physical DECIMAL and NUMERIC columns, including upsert results, bulk change-event images and columns added while the Change Tracking reader is running. NULL remains NULL.

MSSQL precision boundary: Raw SQL returning DECIMAL/NUMERIC directly still passes through the tedious driver's JavaScript-number conversion. Direct aggregate results, set-operation results and eager JSON projections are not covered by the typed-row conversion described above. Do not assume lossless precision for these shapes; explicitly convert the final SQL result to text when exact digits are required.

Upgrades crossing 0.84.0 include a manual decimal-conversion notice, including upgrades from 0.83.1. voltro update does not rewrite stored decimal values or apply the offline conversion for you.

Existing SQLite-family NUMERIC columns require a reviewed offline conversion before application startup. The app refuses a physical representation mismatch even when automatic migration is disabled. Stop all readers and writers during conversion; changing the type marker alone does not convert the values. A NUMERIC source may already have lost precision, so conversion requires explicit review rather than inventing missing digits. Native SQL dialects keep their existing physical decimal types.

bigint()

64-bit integer — Snowflake ids, byte counters, large sequence numbers, anything past the 32-bit integer() range or the JS-safe-integer ceiling (2^53 − 1).

import { bigint } from '@voltro/database'

bigint()                     // BIGINT (INTEGER on sqlite/turso — both 64-bit)
bigint().default('0')

The row type is string (not number) so a value larger than Number.MAX_SAFE_INTEGER survives intact. Parse with BigInt(value) when you need to compute on it.

boolean()

boolean()                    // boolean NOT NULL
boolean().default(false)

timestamp()

timestamp()                  // timestamptz NOT NULL — always UTC
timestamp().default('now')   // server-side now()
timestamp().onUpdate('now')  // application-clock timestamp on updates

Always timestamptz (timestamp with time zone). Plain timestamp (without tz) is a footgun across deployments in different zones.

Column .onUpdate('now') / .onUpdate(() => value) runs in the request-scoped store before validation and encryption, without requiring audit(): keyed/bulk updates, soft deletes, upsert conflicts and JSON patches. Explicit defined values (including null) win; undefined is omitted. Audit rules remain separate. An insert-only upsert field does not suppress the callback unless the conflict projection selects it.

Factories must be synchronous and side-effect-free. Bulk writes evaluate them per statement. A prepared static upsert can evaluate them even if INSERT wins, but does not write their values to that INSERT. Raw drivers, outside SQL and the boot-only codec do not execute application callbacks. This is not a database trigger; now here uses the application clock.

date()

date()                       // date — no time component

For calendar dates (birthdays, anniversaries) where wall-clock semantics matter regardless of viewer's timezone.

interval()

interval()                   // interval — durations

Use Effect's Duration.toIso when writing; reads come back as ISO 8601 strings (PT1H30M).

json<T>()

Typed JSONB column. The type parameter flows to ctx.store.select(...) so reads are narrowed without manual casts.

import { json } from '@voltro/database'

interface NotePrefs {
  readonly fontSize: 'sm' | 'md' | 'lg'
  readonly collapsed: ReadonlyArray<string>
}

const notes = table('notes', {
  id:    id(),
  prefs: json<NotePrefs>().default({ fontSize: 'md', collapsed: [] }),
})

See JSON columns for indexing + path queries.

vector(dimensions)

vector(1536)                 // pgvector vector(1536)

The HNSW index is table-level — there is no column-level .index('hnsw'). Declare it as .index('docsEmbeddingHnsw', ['embedding'], { kind: 'hnsw' }). Requires the pgvector extension on postgres (the framework emits CREATE EXTENSION IF NOT EXISTS vector automatically). See Vector columns for the full RAG story.

reference(() => table, options?)

Foreign key to another table — its id column by default. Pass a thunk returning the target table (not a string name) — singular reference, not references.

import { reference } from '@voltro/database'

const messages = table('messages', {
  id:       id(),
  threadId: reference(() => threads, { onDelete: 'cascade' }),
  authorId: reference(() => users, { onDelete: 'setNull' }).nullable(),
})
onDelete SQL
'restrict' (default) ON DELETE RESTRICT
'cascade' ON DELETE CASCADE
'setNull' ON DELETE SET NULL (requires .nullable())
'noAction' ON DELETE NO ACTION

Every FK column gets a B-tree index by default (index: true); opt out with reference(() => buckets, { index: false }) for tiny lookup tables.

Matching a target that isn't a text id

Two options describe the target key. Both default to the framework convention, so an ordinary FK needs neither.

// The target's key is numeric, so the FK column must be too.
deviceId: reference(() => devices, { storage: 'numeric' }),   // BIGINT, decodes as number

// Point at a unique column other than `id`.
tenantSlug: reference(() => tenants, { column: 'slug' }),

storage: 'numeric' is required when the target declares id({ scheme: 'numeric' }): the column renders as the dialect's 64-bit integer, the row decodes it as a number, and snapshots, introspection and the migration planner all round-trip that choice instead of churning it back to text. A numeric target referenced without it produces a text column that will never match.

column: 'slug' points the FK at a different column, which must be unique — every dialect requires the referenced side of a foreign key to be a key. Deferred cyclic constraints, subject graphs and test factories carry the target column through.

dbEnum(name, values)

Postgres-native enum. Declare it once, then build a column from it with .column(); the migrator creates the type + the column.

import { dbEnum } from '@voltro/database'

const NoteStatus = dbEnum('note_status', ['draft', 'published', 'archived'])

const notes = table('notes', {
  status: NoteStatus.column().default('draft'),
})

You get TypeScript autocomplete on where('status', '=', 'published') — typos fail the type check.

raw<T>(ddl)

The escape hatch for any column type the DSL doesn't model:

import { table, id, raw } from '@voltro/database'

const events = table('events', {
  id:         id(),
  occurredAt: raw<Date>('timestamptz NOT NULL'),
  geo:        raw<string>('geography(Point, 4326)'),
})

The TypeScript row shape comes from the generic parameter; the DDL fragment is emitted verbatim on every dialect — the framework does no translation, so you own dialect-portability of the text.

Because the fragment already encodes the type, nullability, and default, the fragment is the single source of truth: chaining .nullable(), .default(), or .unique() on a raw(...) column throws at declaration time (two sources would drift). Encode NULL / DEFAULT / UNIQUE directly in the fragment instead.

Universal modifiers

These chain on every column type:

Modifier What it does
.nullable() Drops the NOT NULL constraint.
.unique() Adds a unique constraint. Optional { dedup } migration strategy.
.default(value) Default value. Literal, 'now' for now(), OR a callback — see Default with callback.
.computed(row => ...) Computed-from-row value, app-side at write-time. See Computed columns.
.onUpdate(value) Auto-bump on UPDATE (timestamps).
.generatedAs(expr, { stored }) DB-level generated column — see Generated columns.
.oneOf([...]) Closed value set (text only) — narrows the type + emits a CHECK.
.maxLength(n) Bounds a text column → VARCHAR(n) / NVARCHAR(n) (text only). For index-ability — see the text() section.
.check(expr) Raw SQL CHECK on the column — DB-enforced. Chainable for multiple. Any column type. See DB-level checks.

DB-level checks

Two layers enforce row invariants, and they're complementary:

  • .check(expr) (column) / table().check(name, expr) (table) — emit a SQL CHECK constraint. DB-enforced: the rule holds no matter which client writes the row (a raw psql, another service, a buggy migration) — defense in depth. expr is raw SQL emitted verbatim, so YOU own its cross-dialect portability (stick to standard operators + unquoted column names; avoid dialect-specific functions / regex). CHECK is enforced on Postgres / MySQL 8+ / MariaDB / MSSQL / SQLite.

    table('bookings', {
      id:       id(),
      seats:    integer().check('seats > 0'),
      startsAt: timestamp(),
      endsAt:   timestamp(),
    })
      .check('booking_window', 'startsAt < endsAt')   // cross-column → table level
  • table().validate(Schema) (below) — app-level effect/Schema validation, run before the INSERT/UPDATE. Use it for the rich stuff a portable CHECK can't express: regex / email / structured strings, length ranges, numeric bounds, cross-field rules with custom typed errors. It's the single validation surface — don't reach for a pile of .min() / .max() / .pattern() column modifiers (there aren't any, by design).

  • table().rule(name, predicate) (cross-table) — neither of the above can read ANOTHER table. A business rule is a predicate the runtime evaluates inside the mutation transaction, so it can compare the written row against rows in other tables (an invoice total vs. its line items) and rolls the mutation back with a typed BusinessRuleViolation on failure.

Rule of thumb: .check() when the DATABASE must guarantee a single-row constraint; table().rule() for a cross-table invariant; validate(Schema) for everything else. There is no .comment() / .index() column modifier — indexes are table-level.

Default with callback

default() accepts three forms:

status:        text().default('pending')              // literal — lands in DDL DEFAULT
createdAt:     timestamp().default('now')              // server-now — lands in DDL DEFAULT
correlationId: text().default(() => crypto.randomUUID())  // app-side factory

The literal + 'now' forms emit DEFAULT in the table DDL — the database fills in the value when the row omits the column. The callback form is evaluated app-side at insert time by the MutationStore wrapper, BEFORE the row reaches the database. Use it for per-insert dynamic values the DB can't produce:

import { id, table, text, timestamp } from '@voltro/database'

export const sessions = table('sessions', {
  id:            id(),
  correlationId: text().default(() => crypto.randomUUID()),    // unique per row, JS-generated
  region:        text().default(() => process.env.REGION!),     // env-driven
  startedAt:     timestamp().default('now'),                    // DB-generated, lands in DDL
})

Callback defaults compose with the framework's existing auto-stamps (audit createdAt / updatedAt, tenant tenantId, etc.) — your callback runs after the framework fills its own auto-stamps, so the callback's row argument carries the stamped fields.

Important: callback defaults are NOT emitted in the DDL. Raw SQL inserts that bypass the framework (psql, manual migrations, third-party tooling) won't see the callback fire — only the literal portion of the column declaration lands in CREATE TABLE. For DB-enforced defaults always reachable from raw SQL, use the literal/'now' form.

Computed columns

.computed(row => ...) marks the column as derived from other row fields. The function runs on INSERT (AFTER framework auto-stamps + factory defaults) and again on every UPDATE; its return value overwrites whatever the caller passed for that column (computed values are an opinion of the schema, not a user-overridable input).

import { id, table, text } from '@voltro/database'

const slugify = (s: string): string => s.toLowerCase().replace(/\s+/g, '-')

export const posts = table('posts', {
  id:    id(),
  title: text(),
  // slug ALWAYS comes from title — no caller can pass a stale slug
  slug:  text().computed((row) => slugify(String(row.title))),
})

Computed columns recompute on UPDATE too. Every computed column on the table re-runs against the merged post-update row (the patch layered over the existing row), so a derived slug stays in sync when its source title changes — no dependsOn declaration, no explicit recompute in the handler:

// mutations/posts.update.mutation.server.ts — slug is recomputed automatically
const execute = async (input, ctx) =>
  ctx.store.update('posts', input.id, { title: input.title })
  // slug becomes slugify(input.title) on its own; a `slug` in the patch is overwritten

Because all computed columns re-run on every UPDATE, keep the functions pure and cheap. They receive the full merged row, so an unchanged source column the computed value reads is still available.

Table-level validation

.validate(Schema) adds a pre-INSERT validation pass. The framework decodes the row against the schema BEFORE the SQL INSERT runs. Decode failure throws a typed TableValidationFailed error you can declare on the mutation's error: channel.

import { id, table, text } from '@voltro/database'
import { Schema } from 'effect'

export const users = table('users', {
  id:    id(),
  email: text().unique(),
}).validate(Schema.Struct({
  id:    Schema.String,
  email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/)),
}))

The validation runs after framework auto-stamps + computed columns — by the time the decoder sees the row, createdAt/updatedAt from audit(), tenantId from tenant(), your .computed() values, and your callback .default() values are all in place. The schema sees the fully-stamped row.

This lets you express constraints the column DSL can't:

  • Cross-field invariants (startDate < endDate, total === items.reduce(sum))
  • Format checks beyond oneOf (regex patterns, length bounds, structured strings)
  • Refinements (Schema.NonEmptyString, Schema.Number.pipe(Schema.between(0, 1)))
  • Discriminated unions for shape variants

Catch the failure in your mutation handler if you want typed UI feedback:

// mutations/users.create.mutation.ts
import { TableValidationFailed } from '@voltro/runtime'

export const createUser = defineMutation({
  name:   'users.create',
  guards: [{ scope: 'users:write' }],
  input:  Schema.Struct({ email: Schema.String }),
  output: Schema.Struct({ id: Schema.String }),
  target: { table: 'users', op: 'insert' },
  error:  TableValidationFailed,
})

Validating UPDATE patches — .validatePatch(Schema)

.validate(...) runs on INSERT. For UPDATE patches, add .validatePatch(Schema) — the framework wraps the schema in Schema.partial, so a patch that touches only some columns is valid; every field that IS present must satisfy its rule. A bad patch throws the same typed TableValidationFailed error.

import { id, integer, table, text } from '@voltro/database'
import { Schema } from 'effect'

export const users = table('users', {
  id:    id(),
  email: text().unique(),
  age:   integer(),
})
  .validate(Schema.Struct({
    email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/)),
  }))
  .validatePatch(Schema.Struct({
    age: Schema.Number.pipe(Schema.greaterThanOrEqualTo(0)),
  }))

Pass the same shape you'd pass to .validate(...) — the partial wrapping is applied for you, so don't pre-wrap fields in Schema.optional. The patch decoder runs on the (stamped) patch right before the UPDATE; ctx.store.update('users', id, { age: -5 }) rejects, while ctx.store.update('users', id, { email: 'new@x.io' }) passes (the age rule only fires when age is in the patch).

Specialized column types

Beyond the core text / integer / boolean / timestamp / etc., the framework ships richer types for specific use cases:

  • Enums (dbEnum) — postgres-native ENUM types with literal-union narrowing, cheap ALTER TYPE ADD VALUE migrations. Falls back to CHECK constraints on other dialects.
  • Generated columns (generatedAs) — DB-level computed values, kept consistent on UPDATE without app-side stamping. Distinct from .computed() which runs in the MutationStore middleware at INSERT.
  • Arrays + intervals — postgres-native array(text()) / array(integer()) / interval() with JSON-stringified codec for non-postgres dialects.
  • Vectors — vector(dim) for embedding- based semantic search via pgvector.
  • PostGIS — geography() / geometry() from @voltro/plugin-postgis for location-aware apps.
  • JSON — json<T>() for arbitrary nested data; native JSONB on postgres.

An id that deliberately has no foreign key — .externalId()

export const webhookMappings = table('webhook_mappings', {
  id: id(),
  providerObjectId: text().externalId(),
})

Use .externalId() when an id-shaped value belongs to a system outside this database and therefore cannot be a reference(). It is declaration metadata: the stored type and query behavior stay unchanged, while schema tooling and voltro doctor no longer mistake the column for a missing junction foreign key. It cannot be combined with reference() or used on the table's primary id().

Pointing at a plugin-owned row — pluginRef

import { pluginRef } from '@voltro/database'
import { aiFlowsTable } from '@voltro/plugin-ai-flows'

export const favourites = table('favourites', {
  id: id(),
  flowId:     pluginRef(aiFlowsTable, { orphanPolicy: 'delete' }),
  sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable(),
})

A plain typed id column with no foreign key, plus a declared orphan rule the framework runs on the post-commit change channel.

Reach for reference() first. A real foreign key across a plugin boundary works and survives the plugin renaming its table, because reference() takes the table as a VALUE — see plugins/overview. pluginRef is for the case where you have deliberately chosen NOT to have a key: it enforces nothing at the database level.

What it restores is the piece that goes missing with that choice — not the constraint but the RULE, which otherwise becomes a hand-written subscriber per app that nobody notices the absence of.

policy on the target's delete
'delete' delete the referencing row — for one that only exists to point (a favourite, a pin, a share)
'null' clear the column, keep the row. Requires .nullable()
'keep' (default) nothing — the explicit "I handle it myself"

Four things worth knowing before you rely on it:

  • The tenant boundary fails closed. A referencing row whose tenant differs from the deleted row's — or which has none — is left alone. Deleting across a tenant boundary because a scope was missing is worse than leaving an orphan.
  • Soft deletes are opt-in (onSoftDelete: true). A soft delete is a state the target can undo, so cascading on it destroys rows a restore cannot bring back — and plugin tables are inconsistent about carrying deletedAt at all.
  • The target is a table VALUE. A rename carries the rule with it, which is the whole reason the FK was refused; a string would put the coupling back.
  • A missing plugin refuses at boot, naming both sides. A declared rule against a table nothing registers would sit there looking enforced.

Anti-patterns

  • serial/bigserial integer ids. Leaks row counts via /users/12345. Use id() (TypeID).
  • text (or a float) for currency. Use decimal(15, 2) — exact fixed-point, carried as a string.
  • timestamp without tz. Use timestamp() which is timestamptz.
  • Storing JSON blobs as text. Use json<T>() for type-safety + JSONB performance.
  • references('table'). The constructor is reference(() => table) — singular, thunk-arg.
  • Declaring a timestamp() column as Schema.Number in a descriptor's output. That describes the wire type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand. Declare the field schema instead — timestampMs / timestampMsOrNull from @voltro/database/wire. Do not reach for rowSchema(table) here: a descriptor cannot import a table value without dragging @voltro/database into the browser bundle, and the boot aborts. rowSchema is a server-side row codec.