@everystack/model
v0.4.17
Published
Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config
Downloads
2,232
Readme
@everystack/model
Declare a table once — its columns, validation, authorization, and relations — and compile it to Postgres migrations, RLS policies, and handler config. One declaration, three targets, no drift between them.
Install
pnpm add @everystack/modelzod is an optional peer dependency, needed only when a field uses .validate(...).
Declare a model
import { defineModel, field, can, unique, index, check, foreignKey, sql } from '@everystack/model';
import { z } from 'zod';
export const Account = defineModel('accounts', {
fields: {
id: field.uuid().primaryKey().defaultRandom(),
email: field.text().notNull().unique(),
displayName: field.text().notNull(),
bio: field.text().nullable().validate(z.string().max(160)),
status: field.enum('account_status', ['active', 'suspended', 'closed']).notNull().default('active'),
createdAt: field.timestamptz().notNull().defaultNow(),
},
abilities: [
can('read'), // anyone can read
can('update', { owner: 'id' }), // you can update your own row
],
});
export const Note = defineModel('notes', {
fields: {
id: field.uuid().primaryKey().defaultRandom(),
accountId: field.uuid().notNull().references(() => Account, { onDelete: 'cascade' }),
title: field.text().notNull(),
createdAt: field.timestamptz().notNull().defaultNow(),
},
abilities: [
can('read', { owner: 'accountId' }),
can('create', { owner: 'accountId' }),
can('update', { owner: 'accountId' }),
can('delete', { owner: 'accountId' }),
],
constraints: [
unique('accountId', 'title'), // composite unique
index('createdAt').where(sql`pinned = true`), // partial index
check(sql`char_length(title) > 0`), // table-level CHECK
],
});
export const models = [Account, Note];Fields
field.*() builds a column. The type is the Postgres type; the chained modifiers are the constraints.
| Factory | Postgres type |
|---------|---------------|
| field.uuid() | uuid |
| field.text() | text |
| field.varchar(n?) | character varying(n) |
| field.integer() | integer |
| field.bigint() / field.bigserial() | bigint / bigserial |
| field.numeric(p?, s?) | numeric(p, s) |
| field.boolean() | boolean |
| field.timestamptz() / field.timestamp() | timestamp with[out] time zone |
| field.date() | date |
| field.jsonb() | jsonb |
| field.enum('name', [...]) | a CREATE TYPE … AS ENUM (name is explicit, so db:pull round-trips it) |
Modifiers: .primaryKey(), .notNull() / .nullable(), .unique(), .default(value) / .defaultNow() / .defaultRandom(), .references(() => Other, { onDelete, onUpdate }), .private() (never serialized), .readonly() (never accepted on write), .validate(zodSchema), .renamedFrom('oldField') (turns a drop+add into a data-preserving RENAME COLUMN), and .deprecated() (the contract phase of contraction: the column stays in the database and stays readable — old clients keep working — while new writes are rejected and generated types strike it through; deleting the field later is the actual drop, destructive and gated).
Renaming a whole table works the same way, at the model level: defineModel('articles', { renamedFrom: 'posts', ... }) compiles to one ALTER TABLE … RENAME TO … (data preserved, policies ride along) instead of CREATE plus a held DROP. Both markers are one-shot — remove them once every environment is past the state; a satisfied rename is an inert notice.
.validate(...) does double duty: the SQL-expressible refinements (min/max/length/range/enum) become a Postgres CHECK — the backstop for the SSR-direct write path that never runs app-layer Zod — while richer refinements stay app-layer only.
Constraints
The table-level surface, composed in constraints: [...], for what a column-scoped field() can't carry:
unique('a', 'b')— a composite unique constraint.index('a').unique().where(sql\…`)` — a standalone index, optionally unique and/or partial.check(sql\a >= b`)` — a multi-column CHECK.foreignKey(['a', 'b'], () => Other, ['x', 'y'], { onDelete })— a composite foreign key.
Authorization
can(action, condition) declares one rule (default-deny: no matching can() means denied). Conditions:
{ owner: 'column' }— row owner, via the JWTsubclaim by default (override withuserField). Also feeds the handler'srowOwnershipIDOR backstop.{ role: 'authenticated' }— role-gated (policyTO <role>+ grant).{ where: 'deleted_at IS NULL' }— a column/scalar predicate, compiled to the policy'sUSINGclause.{ sql: … }— a verbatim RLS predicate escape hatch.
These compile to RLS policies and grants (via @everystack/cli's compileTableContract, what db:generate emits). can('manage', …) covers all four DML actions: SELECT, INSERT, UPDATE, DELETE.
Exactly those four, and no more. A verb that expanded to "every privilege" would hand out TRUNCATE, which no DELETE policy filters. The other table privileges are named one at a time:
defineModel('posts', {
fields: { … },
abilities: [can('read'), can('manage', { role: 'admin' })],
privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] },
});privileges takes only those three, and it is additive — it widens what a role holds and can never narrow it. DML stays in can(), where the grant and the row policy are decided together. db:pull --abilities live writes this key from the live grants, so an existing database that grants its admin role all three keeps them.
Soft delete
A deletedAt column is a column. softDelete is the decision about what it MEANS, and it sits
on the model beside fields:
export const Upload = defineModel('uploads', {
fields: {
id: field.uuid().primaryKey().defaultRandom(),
accountId: field.uuid().notNull().references(() => Account),
s3Key: field.text().notNull(),
deletedAt: field.timestamptz(),
},
softDelete: true,
abilities: [can('read', { owner: 'accountId' }), can('delete', { owner: 'accountId' })],
});It changes two different things, and neither default is safe:
- Durability, on every model —
truemakes aDELETEmarkdeleted_atinstead of removing the row, and hides marked rows from the data API's reads and updates.falsemakesDELETEpermanent. - Visibility, only where there is a public read —
trueAND-sdeleted_at IS NULLinto the anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either way, so durability is the axis that matters there.
defineModel refuses to guess when a model has deletedAt AND either a public read or a way
to DELETE (can('delete'), can('manage'), or a privileges DELETE grant). Omit the line on
such a model and it throws at import, naming which branch it tripped. Where nothing reads
publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
nothing is required.
Pick true if the row is meant to survive its own deletion — anything with a cleanup or purge
job, an audit trail, or rows other tables reference. Pick false if deleted_at here is
audit-only and a DELETE really should remove the row. It is not cosmetic: a table whose S3
cleanup job finds work by deleted_at IS NOT NULL orphans every file it should have purged if
the row is hard-deleted instead.
A column named deleted_at is not a statement of intent — it is carried for audit, for
app-layer filtering, for half-finished migrations. This used to be inferred from the field's
presence, which made the compiler author a security rule nobody wrote. db:pull writes the
flag explicitly for the same reason.
Relations
import { belongsTo, hasMany } from '@everystack/model';
defineModel('notes', {
fields: { /* … */ },
relations: {
account: belongsTo(() => Account, 'accountId'),
comments: hasMany(() => Comment, 'noteId'),
},
});Compile
@everystack/model is the declaration surface; @everystack/cli is the compiler. With your models array:
everystack db:generate --stage dev # diff models vs the live DB → one ordered migration (data + authz)
everystack db:pull --stage dev # reverse an existing database into model source (the brownfield on-ramp)db:generate is non-destructive by default — drops are held back as comments unless you pass --allow-drops. A faithful db:pull then db:generate is a no-op: the declaration and the database it built are the same thing.
Handler config
deriveHandlerConfig(models) compiles the same Models into the authorization config createHandler consumes (exposed tables, hidden/protected columns, relations, row ownership, softDelete.tables) — so the HTTP handler and the database enforce the same declared rules, with no second source of truth.
import { deriveHandlerConfig } from '@everystack/model';
const handler = createHandler(db, schema, deriveHandlerConfig(models));Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
hand-kept copy drifts silently: softDelete.tables losing two entries is what let a table start
hard-deleting rows whose S3 objects a purge job keyed on deleted_at IS NOT NULL. Handler-level
options that are not per-table — auth, pgSettings, rpc, limits — stay explicit.
Programmatic API (@everystack/cli/model)
The same compiler the CLI uses is importable for your own CI and tests — point it at your live DB and assert the declaration and the database agree, or red-team the deployed authorization:
import {
compileMigration, generateMigrationSql, introspectSchema, diffSchema, // data layer
compileTableContract, introspectContract, diffContracts, // authz layer
buildOwnerProbeSql, evaluateOwnerProbe, // owner-isolation red-team
} from '@everystack/cli/model';- Data —
introspectSchema(run)reads the whole DB;diffSchema(desired, current)/generateMigrationSql(models, current)produce the structural delta. A greendb:generateis this returning empty. - Authz —
compileTableContract(model)is the declared RLS/grants;introspectContract(run)is what's live;diffContracts(declared, live)is the drift audit (db:authz:diff). - Red-team —
buildOwnerProbeSql/evaluateOwnerProbedrive two JWT identities per owner-scoped table to catch IDOR (db:authz:owner).
run is any QueryRunner — a function that executes SQL against your DB (e.g. wrapping an everystack db:query invoke or a direct connection). This is what the example app's dogfood test uses to prove its real Models round-trip against real Postgres.
License
AGPL-3.0-only
