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 runtimeTypeID 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 idComposite 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 composite 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). Two or more columns are required — a single-column key is just id().
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-ablePlain 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.
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)
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 / Turso have no fixed-point type, so it's NUMERIC affinity storing the value as text (still exact). 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 in SQL), never JS +.
MSSQL ceiling: the tedious driver parses a
DECIMALinto a JS number on read, so a decimal whose full value exceeds ~15-17 significant digits loses low-order precision on read (the database stores it exactly; Postgres / MySQL / MariaDB / SQLite are all lossless). On MSSQL, project a large decimal via aCAST(col AS VARCHAR)raw fragment, or keep the value inside the JS-double range.bigint()is unaffected on every dialect.
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') // auto-bump on every writeAlways timestamptz (timestamp with time zone). Plain timestamp (without tz) is a footgun across deployments in different zones.
date()
date() // date — no time componentFor calendar dates (birthdays, anniversaries) where wall-clock semantics matter regardless of viewer's timezone.
interval()
interval() // interval — durationsUse 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's id column. 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.
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 SQLCHECKconstraint. DB-enforced: the rule holds no matter which client writes the row (a rawpsql, another service, a buggy migration) — defense in depth.expris raw SQL emitted verbatim, so YOU own its cross-dialect portability (stick to standard operators + unquoted column names; avoid dialect-specific functions / regex).CHECKis 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 leveltable().validate(Schema)(below) — app-leveleffect/Schemavalidation, run before the INSERT/UPDATE. Use it for the rich stuff a portableCHECKcan'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).
Rule of thumb: .check() when the DATABASE must guarantee it; 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 factoryThe 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 overwrittenBecause 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',
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, cheapALTER TYPE ADD VALUEmigrations. 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 viapgvector. - PostGIS —
geography()/geometry()from@voltro/plugin-postgisfor location-aware apps. - JSON —
json<T>()for arbitrary nested data; native JSONB on postgres.
Anti-patterns
serial/bigserialinteger ids. Leaks row counts via/users/12345. Useid()(TypeID).text(or a float) for currency. Usedecimal(15, 2)— exact fixed-point, carried as a string.timestampwithouttz. Usetimestamp()which istimestamptz.- Storing JSON blobs as
text. Usejson<T>()for type-safety + JSONB performance. references('table'). The constructor isreference(() => table)— singular, thunk-arg.- Declaring a
timestamp()column asSchema.Numberin a descriptor'soutput. 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/timestampMsOrNullfrom@voltro/database/wire. Do not reach forrowSchema(table)here: a descriptor cannot import a table value without dragging@voltro/databaseinto the browser bundle, and the boot aborts.rowSchemais a server-side row codec.