@everystack/model
v0.4.1
Published
Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config
Downloads
1,221
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 actions.
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) — 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));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
