@everystack/model 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ export const models = [Account, Note];
72
72
  | `field.jsonb()` | `jsonb` |
73
73
  | `field.enum('name', [...])` | a `CREATE TYPE … AS ENUM` (name is explicit, so `db:pull` round-trips it) |
74
74
 
75
- Modifiers: `.primaryKey()`, `.notNull()` / `.nullable()`, `.unique()`, `.default(value)` / `.defaultNow()` / `.defaultRandom()`, `.references(() => Other, { onDelete, onUpdate })`, `.private()` (never serialized), `.readonly()` (never accepted on write), `.validate(zodSchema)`, and `.renamedFrom('oldField')` (turns a drop+add into a data-preserving `RENAME COLUMN`).
75
+ 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).
76
76
 
77
77
  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.
78
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/can.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * Default-deny is the model's contract: no matching `can()` means denied.
10
10
  */
11
11
 
12
- /** CanCan verbs. `manage` is "all actions". */
13
- export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage';
12
+ /** CanCan verbs. `manage` is "all actions". `execute` is FUNCTION-only (rejected on tables/relations). */
13
+ export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage' | 'execute';
14
14
 
15
15
  export interface AbilityCondition {
16
16
  /** Role-gated: policy `TO <role>` + grant. */
@@ -18,8 +18,8 @@ export type TableConstraint =
18
18
  | { kind: 'unique'; columns: string[] }
19
19
  /** `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. */
20
20
  | { kind: 'check'; predicate: string }
21
- /** A standalone index — `CREATE [UNIQUE] INDEX … (cols) [WHERE <predicate>]`. */
22
- | { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string }
21
+ /** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
22
+ | { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[] }
23
23
  /**
24
24
  * A multi-column `FOREIGN KEY (a, b) REFERENCES other (x, y)` — the composite a column-scoped
25
25
  * `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
@@ -60,8 +60,16 @@ export function foreignKey(
60
60
  }
61
61
 
62
62
  /**
63
- * A standalone index over the given field keys, chainable: `index('authorId')`,
64
- * `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial).
63
+ * A standalone index over the given keys, chainable: `index('authorId')`,
64
+ * `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial),
65
+ * `index('tags').using('gin')`, `index(sql\`lower(email)\`)` (expression).
66
+ *
67
+ * Entries are field keys (strings — the compiler snake_cases them) or raw `sql\`\`` for
68
+ * anything richer: an expression (`lower(email)`), a direction (`created_at DESC`), an
69
+ * opclass (`title gin_trgm_ops`). Raw entries pass to SQL verbatim — they are written in
70
+ * SQL column names, not field keys — and are part of the index's content identity, so a
71
+ * pulled expression index round-trips instead of churning (Brick E).
72
+ *
65
73
  * Indexes that merely back a primary key or unique constraint are NOT declared here — those
66
74
  * come from `.primaryKey()` / `.unique()` / the `unique(...)` constraint.
67
75
  */
@@ -69,7 +77,19 @@ export class IndexBuilder {
69
77
  readonly kind = 'index' as const;
70
78
  isUnique = false;
71
79
  predicate?: string;
72
- constructor(readonly columns: string[]) {}
80
+ /** Access method (`'gin'`, `'gist'`, `'hash'`, `'brin'`, `'spgist'`); omitted = btree. */
81
+ method?: string;
82
+ /** `INCLUDE (…)` covering columns — field keys, snake_cased by the compiler. */
83
+ includeColumns?: string[];
84
+ /** The key texts, in order — field keys for plain entries, verbatim SQL for raw ones. */
85
+ readonly columns: string[];
86
+ /** Which positions are raw `sql\`\`` (never snake_cased, never quoted). */
87
+ readonly rawPositions: boolean[];
88
+
89
+ constructor(entries: Array<string | RawSql>) {
90
+ this.columns = entries.map((e) => (typeof e === 'string' ? e : e.sql));
91
+ this.rawPositions = entries.map((e) => typeof e !== 'string');
92
+ }
73
93
 
74
94
  /** Make it a UNIQUE index. */
75
95
  unique(): this {
@@ -82,8 +102,20 @@ export class IndexBuilder {
82
102
  this.predicate = predicate.sql;
83
103
  return this;
84
104
  }
105
+
106
+ /** The access method: `index('tags').using('gin')`. Omitted = btree (never spelled). */
107
+ using(method: string): this {
108
+ this.method = method.toLowerCase();
109
+ return this;
110
+ }
111
+
112
+ /** Covering columns: `index('authorId').include('title')` → `INCLUDE ("title")`. */
113
+ include(...columns: string[]): this {
114
+ this.includeColumns = columns;
115
+ return this;
116
+ }
85
117
  }
86
118
 
87
- export function index(...columns: string[]): IndexBuilder {
88
- return new IndexBuilder(columns);
119
+ export function index(...entries: Array<string | RawSql>): IndexBuilder {
120
+ return new IndexBuilder(entries);
89
121
  }
package/src/derive.ts CHANGED
@@ -66,8 +66,10 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
66
66
  const privateCols = fieldsWhere(model, (f) => f.spec.isPrivate);
67
67
  if (privateCols.length) hiddenColumns[table] = privateCols;
68
68
 
69
- // .readonly() → protectedFields (mass-assignment guard).
70
- const readonlyCols = fieldsWhere(model, (f) => f.spec.isReadonly);
69
+ // .readonly() → protectedFields (mass-assignment guard). A deprecated field
70
+ // joins it — the contract phase rejects new writes but keeps reads serving
71
+ // (stale clients read for weeks; hiding it would break them before the drop).
72
+ const readonlyCols = fieldsWhere(model, (f) => f.spec.isReadonly || f.spec.isDeprecated);
71
73
  if (readonlyCols.length) protectedFields[table] = readonlyCols;
72
74
 
73
75
  // The first owner-shaped ability → the handler's rowOwnership IDOR backstop.
package/src/derived.ts ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * The declarative derived layer — views, materialized views, functions, sequences,
3
+ * triggers, and the gate-tight escape hatch. The `defineModel` philosophy extended to
4
+ * every object the database holds (docs/plans/read-model-everywhere.md).
5
+ *
6
+ * The boundary rule, uniform with models: STRUCTURE DECLARED (identity, deps, authz,
7
+ * security posture, indexes, signatures, trigger events), EXPRESSIONS AS sql`` (the
8
+ * SELECT, the function body, a WHEN predicate). No query DSL; no string mini-grammars
9
+ * in declared properties.
10
+ *
11
+ * Every rejection here is a define-time (author-time) error whose message carries its
12
+ * fix — the earliest gate there is. Lifecycle note: these descriptors are COMPUTE
13
+ * (deployed by db:reconcile, never migrated) — except `defineSequence`, which is STATE
14
+ * (a sequence holds a counter value; it migrates with the base schema).
15
+ */
16
+
17
+ import type { RawSql } from './raw-sql';
18
+ import type { Ability } from './can';
19
+ import type { ModelDescriptor } from './model';
20
+ import type { IndexBuilder } from './constraints';
21
+ import type { FieldBuilder } from './field';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Shared shapes
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Anything a derived object can declare a dependency on — order derives from these refs. */
28
+ export type DependsOnRef = ModelDescriptor | DerivedDescriptor;
29
+
30
+ /**
31
+ * Relations (views/matviews) carry read-only authz in v1; functions carry execute.
32
+ * The row-scoped condition keys (`owner`, `via`, `where`, `sql`) are meaningless on
33
+ * a relation — rows are filtered by the body or the underlying RLS — and rejected.
34
+ */
35
+ function validateRelationAbilities(kind: string, name: string, abilities: Ability[]): void {
36
+ for (const a of abilities) {
37
+ if (a.action !== 'read') {
38
+ throw new Error(
39
+ `${kind} '${name}': can('${a.action}') is not a relation ability — views and matviews are read surfaces in v1. Write through the table (INSTEAD-OF write-throughs are future work).`,
40
+ );
41
+ }
42
+ if (a.condition.owner || a.condition.via || a.condition.where || a.condition.sql) {
43
+ throw new Error(
44
+ `${kind} '${name}': row conditions cannot attach to a relation — a matview cannot carry policies and a view's rows are filtered by its body or the underlying RLS. Filter in the body, or use securityInvoker: true over the RLS'd table.`,
45
+ );
46
+ }
47
+ }
48
+ }
49
+
50
+ function validatePrivateContradiction(kind: string, name: string, priv: boolean | undefined, abilities: Ability[] | undefined): void {
51
+ if (priv && abilities?.length) {
52
+ throw new Error(`${kind} '${name}': private: true declares it dark — it cannot also declare abilities. Drop one.`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * A relation's `fields` declare its OUTPUT SHAPE — type + nullability + array, nothing
58
+ * else. Everything table-structural on a field builder (keys, FKs, defaults, write-surface
59
+ * markers, migration hints) is meaningless on a column a body computes, and rejected with
60
+ * the fix. The declared shape is a runtime-typing annotation: it never changes the CREATE
61
+ * statement, the content hash, or the reconcile plan — only the generated drizzle source.
62
+ */
63
+ function validateRelationFields(kind: string, name: string, fields: Record<string, FieldBuilder> | undefined, priv: boolean | undefined): void {
64
+ if (!fields) return;
65
+ if (priv) {
66
+ throw new Error(
67
+ `${kind} '${name}': private: true declares it dark — typed fields declare a read surface no role can reach. Drop one.`,
68
+ );
69
+ }
70
+ for (const [key, builder] of Object.entries(fields)) {
71
+ const at = `${kind} '${name}', fields.${key}`;
72
+ const s = builder.spec;
73
+ if (s.isPrimaryKey) throw new Error(`${at}: .primaryKey() is table structure — a relation column carries type, nullability, and array only.`);
74
+ if (s.isUnique) throw new Error(`${at}: .unique() is a table constraint — enforce it on the base table (or a matview index).`);
75
+ if (s.references) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
76
+ if (s.defaultKind) throw new Error(`${at}: defaults are meaningless on a relation column — the body computes every value.`);
77
+ if (s.isDeprecated) throw new Error(`${at}: .deprecated() is the table contract phase — remove the column from the body instead.`);
78
+ if (s.isPrivate) throw new Error(`${at}: .private() hides a table column from the API — a relation exposes only what its body selects; drop the column from the body.`);
79
+ if (s.isReadonly) throw new Error(`${at}: .readonly() marks a write surface — relations are read-only already.`);
80
+ if (s.renamedFrom) throw new Error(`${at}: .renamedFrom() is a migration hint — relations rebuild from their body, they don't migrate.`);
81
+ if (s.validation) throw new Error(`${at}: .validate() shapes the write surface — relations take no writes.`);
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Triggers — pure structure; a trigger has no body at all
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export type TriggerTiming = 'before' | 'after' | 'insteadOf';
90
+ export type TriggerEvent = 'insert' | 'update' | 'delete' | 'truncate';
91
+
92
+ /** Canonical event order — events are deduped and sorted so the SAME trigger always hashes the same. */
93
+ const EVENT_ORDER: TriggerEvent[] = ['insert', 'update', 'delete', 'truncate'];
94
+
95
+ export interface TriggerDefinition {
96
+ timing: TriggerTiming;
97
+ events: TriggerEvent[];
98
+ forEach: 'row' | 'statement';
99
+ /** `UPDATE OF (columns)` — only with an `update` event. */
100
+ of?: string[];
101
+ /** The SQL `WHEN` predicate — an expression, stays raw. */
102
+ condition?: RawSql;
103
+ /** The trigger function — a shared, standalone {@link defineFunction} returning `trigger`. */
104
+ execute: FunctionDescriptor;
105
+ }
106
+
107
+ export interface TriggerSpec {
108
+ name: string;
109
+ timing: TriggerTiming;
110
+ events: TriggerEvent[];
111
+ forEach: 'row' | 'statement';
112
+ of?: string[];
113
+ condition?: string;
114
+ execute: FunctionDescriptor;
115
+ }
116
+
117
+ /**
118
+ * A trigger on the model (or view) it is declared on. Deps are free: the table dep is
119
+ * the declaration site; the function dep is `execute:`. Target-specific legality
120
+ * (INSTEAD OF needs a view; views take no BEFORE/AFTER row triggers) is enforced where
121
+ * the trigger attaches — defineModel / defineView.
122
+ */
123
+ export function trigger(name: string, def: TriggerDefinition): TriggerSpec {
124
+ const events = EVENT_ORDER.filter((e) => def.events.includes(e));
125
+ if (events.length === 0) throw new Error(`trigger '${name}': declare at least one event.`);
126
+ if (def.of && !events.includes('update')) {
127
+ throw new Error(`trigger '${name}': of: (UPDATE OF columns) requires an update event.`);
128
+ }
129
+ if (events.includes('truncate')) {
130
+ if (def.forEach === 'row') throw new Error(`trigger '${name}': truncate is statement-only — forEach: 'statement'.`);
131
+ if (def.condition) throw new Error(`trigger '${name}': truncate takes no condition (WHEN).`);
132
+ }
133
+ if (def.timing === 'insteadOf') {
134
+ if (def.forEach !== 'row') throw new Error(`trigger '${name}': INSTEAD OF must be forEach: 'row'.`);
135
+ if (def.condition) throw new Error(`trigger '${name}': INSTEAD OF takes no condition (WHEN).`);
136
+ if (def.of) throw new Error(`trigger '${name}': INSTEAD OF takes no of: (UPDATE OF columns).`);
137
+ }
138
+ if (def.execute.returns !== 'trigger') {
139
+ throw new Error(`trigger '${name}': execute must be a function with returns: 'trigger' — '${def.execute.name}' returns '${String(typeof def.execute.returns === 'string' ? def.execute.returns : 'setof')}'.`);
140
+ }
141
+ return {
142
+ name,
143
+ timing: def.timing,
144
+ events,
145
+ forEach: def.forEach,
146
+ ...(def.of ? { of: def.of } : {}),
147
+ ...(def.condition ? { condition: def.condition.sql } : {}),
148
+ execute: def.execute,
149
+ };
150
+ }
151
+
152
+ /** The legality rules that depend on the trigger's TARGET (table vs view). */
153
+ export function validateTriggersFor(target: 'table' | 'view', owner: string, triggers: TriggerSpec[]): void {
154
+ for (const t of triggers) {
155
+ if (target === 'table' && t.timing === 'insteadOf') {
156
+ throw new Error(`table '${owner}', trigger '${t.name}': INSTEAD OF is a view trigger — attach it to the defineView it rewrites.`);
157
+ }
158
+ if (target === 'view' && t.timing !== 'insteadOf' && t.forEach === 'row') {
159
+ throw new Error(`view '${owner}', trigger '${t.name}': views take INSTEAD OF row triggers or BEFORE/AFTER statement-level triggers — never BEFORE/AFTER row.`);
160
+ }
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Functions — signature structured, body raw
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export interface ArgSpec {
169
+ name: string;
170
+ type: string;
171
+ /** A raw SQL default for the argument. */
172
+ default?: string;
173
+ }
174
+
175
+ /** One positional argument. An ARRAY of these is the signature — never object key order. */
176
+ export function arg(name: string, type: string, opts?: { default?: RawSql }): ArgSpec {
177
+ return { name, type, ...(opts?.default ? { default: opts.default.sql } : {}) };
178
+ }
179
+
180
+ /** `returns: setof(posts)` — a descriptor ref, so a typo is a TS error and the dep comes free. */
181
+ export interface SetofReturn {
182
+ setof: ModelDescriptor | ViewDescriptor | MaterializedViewDescriptor;
183
+ }
184
+
185
+ export function setof(ref: SetofReturn['setof']): SetofReturn {
186
+ return { setof: ref };
187
+ }
188
+
189
+ export interface FunctionDefinition {
190
+ args?: ArgSpec[];
191
+ returns: string | SetofReturn;
192
+ language: 'sql' | 'plpgsql';
193
+ /** Default `'invoker'` (the safe one). `'definer'` REQUIRES a pinned searchPath. */
194
+ security?: 'invoker' | 'definer';
195
+ /** An ARRAY of schemas, not a comma string. */
196
+ searchPath?: string[];
197
+ /** Default `'volatile'` (PostgreSQL's own). `immutable` enables expression indexes. */
198
+ volatility?: 'immutable' | 'stable' | 'volatile';
199
+ dependsOn?: DependsOnRef[];
200
+ /** Execute authz only, role REQUIRED — `PUBLIC` is always revoked by the compiler. */
201
+ abilities?: Ability[];
202
+ comment?: string;
203
+ body: RawSql;
204
+ }
205
+
206
+ export interface FunctionDescriptor {
207
+ kind: 'function';
208
+ name: string;
209
+ args: ArgSpec[];
210
+ returns: string | SetofReturn;
211
+ language: 'sql' | 'plpgsql';
212
+ security: 'invoker' | 'definer';
213
+ searchPath?: string[];
214
+ volatility: 'immutable' | 'stable' | 'volatile';
215
+ dependsOn: DependsOnRef[];
216
+ abilities: Ability[];
217
+ comment?: string;
218
+ body: string;
219
+ }
220
+
221
+ export function defineFunction(name: string, def: FunctionDefinition): FunctionDescriptor {
222
+ const security = def.security ?? 'invoker';
223
+ if (security === 'definer' && (!def.searchPath || def.searchPath.length === 0)) {
224
+ throw new Error(
225
+ `function '${name}': SECURITY DEFINER without a pinned searchPath is the classic landmine — pin searchPath: ['pg_catalog'] (plus the schemas the body needs), or use security: 'invoker'.`,
226
+ );
227
+ }
228
+ const args = def.args ?? [];
229
+ const seen = new Set<string>();
230
+ for (const a of args) {
231
+ if (seen.has(a.name)) throw new Error(`function '${name}': duplicate argument '${a.name}'.`);
232
+ seen.add(a.name);
233
+ }
234
+ for (const a of def.abilities ?? []) {
235
+ if (a.action !== 'execute') {
236
+ throw new Error(`function '${name}': can('${a.action}') is not a function ability — functions carry can('execute', { role }) only.`);
237
+ }
238
+ if (!a.condition.role) {
239
+ throw new Error(
240
+ `function '${name}': bare can('execute') has no safe default audience — declare { role: 'authenticated' }, or { role: 'anon' } for public-callable.`,
241
+ );
242
+ }
243
+ }
244
+ return {
245
+ kind: 'function',
246
+ name,
247
+ args,
248
+ returns: def.returns,
249
+ language: def.language,
250
+ security,
251
+ ...(def.searchPath ? { searchPath: def.searchPath } : {}),
252
+ volatility: def.volatility ?? 'volatile',
253
+ dependsOn: def.dependsOn ?? [],
254
+ abilities: def.abilities ?? [],
255
+ ...(def.comment ? { comment: def.comment } : {}),
256
+ body: def.body.sql,
257
+ };
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Views — securityInvoker is the forced decision
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export interface ViewDefinition {
265
+ /**
266
+ * REQUIRED, no default — the one decision that distinguishes the two legitimate
267
+ * shapes: `true` = readers' own RLS/grants apply through the view; `false` =
268
+ * definer semantics, the "safe slice" pattern (view grant WITHOUT table grant —
269
+ * the body must seal what it exposes).
270
+ */
271
+ securityInvoker: boolean;
272
+ abilities?: Ability[];
273
+ dependsOn?: DependsOnRef[];
274
+ /** `INSTEAD OF` row triggers, or BEFORE/AFTER statement-level. */
275
+ triggers?: TriggerSpec[];
276
+ comment?: string;
277
+ /** Declared dark — intentionally no grants; satisfies the read-gap gate honestly. */
278
+ private?: boolean;
279
+ /**
280
+ * The declared output shape — same `field.*` builders as models, type/nullability/array
281
+ * only. Opt-in: declaring fields emits a typed `pgView(...).existing()` into
282
+ * `schema.generated.ts` (typed SSR reads); omitting them leaves the relation out of the
283
+ * generated drizzle schema. Never touches the CREATE statement or the content hash.
284
+ *
285
+ * CONTRACT: each field key binds the snake_case column of that name (`displayName` →
286
+ * `display_name`) — the SELECT must emit that output column, aliasing where needed
287
+ * (`count(*) AS n`). Fields are author-declared, not introspected: a wrong NAME fails
288
+ * every read of the relation loudly (drizzle selects all declared columns), but a
289
+ * wrong type or nullability mistypes reads SILENTLY. Keep them truthful.
290
+ */
291
+ fields?: Record<string, FieldBuilder>;
292
+ as: RawSql;
293
+ }
294
+
295
+ export interface ViewDescriptor {
296
+ kind: 'view';
297
+ name: string;
298
+ securityInvoker: boolean;
299
+ abilities: Ability[];
300
+ dependsOn: DependsOnRef[];
301
+ triggers: TriggerSpec[];
302
+ comment?: string;
303
+ private: boolean;
304
+ fields?: Record<string, FieldBuilder>;
305
+ as: string;
306
+ }
307
+
308
+ export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
309
+ if (typeof def.securityInvoker !== 'boolean') {
310
+ throw new Error(
311
+ `view '${name}': securityInvoker is a required decision — true (readers' own RLS applies through the view) or false (definer semantics: the sealed-slice pattern; the body must not leak).`,
312
+ );
313
+ }
314
+ validateRelationAbilities('view', name, def.abilities ?? []);
315
+ validatePrivateContradiction('view', name, def.private, def.abilities);
316
+ validateRelationFields('view', name, def.fields, def.private);
317
+ validateTriggersFor('view', name, def.triggers ?? []);
318
+ return {
319
+ kind: 'view',
320
+ name,
321
+ securityInvoker: def.securityInvoker,
322
+ abilities: def.abilities ?? [],
323
+ dependsOn: def.dependsOn ?? [],
324
+ triggers: def.triggers ?? [],
325
+ ...(def.comment ? { comment: def.comment } : {}),
326
+ private: def.private ?? false,
327
+ ...(def.fields ? { fields: def.fields } : {}),
328
+ as: def.as.sql,
329
+ };
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Materialized views — grants-only authz; refresh/populate declare the HOW
334
+ // ---------------------------------------------------------------------------
335
+
336
+ export interface MaterializedViewDefinition {
337
+ abilities?: Ability[];
338
+ dependsOn?: DependsOnRef[];
339
+ indexes?: IndexBuilder[];
340
+ /** `'concurrently'` — compile-gated on a declared unique, non-partial index (PG requires it). */
341
+ refresh?: 'concurrently';
342
+ /** `'deferred'` — create WITH NO DATA, refresh outside the creation lock. */
343
+ populate?: 'deferred';
344
+ comment?: string;
345
+ private?: boolean;
346
+ /** The declared output shape — see {@link ViewDefinition.fields}. */
347
+ fields?: Record<string, FieldBuilder>;
348
+ as: RawSql;
349
+ }
350
+
351
+ export interface MaterializedViewDescriptor {
352
+ kind: 'materialized view';
353
+ name: string;
354
+ abilities: Ability[];
355
+ dependsOn: DependsOnRef[];
356
+ indexes: IndexBuilder[];
357
+ refresh?: 'concurrently';
358
+ populate?: 'deferred';
359
+ comment?: string;
360
+ private: boolean;
361
+ fields?: Record<string, FieldBuilder>;
362
+ as: string;
363
+ }
364
+
365
+ export function defineMaterializedView(name: string, def: MaterializedViewDefinition): MaterializedViewDescriptor {
366
+ validateRelationAbilities('materialized view', name, def.abilities ?? []);
367
+ validatePrivateContradiction('materialized view', name, def.private, def.abilities);
368
+ validateRelationFields('materialized view', name, def.fields, def.private);
369
+ const indexes = def.indexes ?? [];
370
+ if (def.refresh === 'concurrently' && !indexes.some((ix) => ix.isUnique && !ix.predicate)) {
371
+ throw new Error(
372
+ `materialized view '${name}': REFRESH CONCURRENTLY requires a unique, non-partial index — declare indexes: [index('...').unique()].`,
373
+ );
374
+ }
375
+ return {
376
+ kind: 'materialized view',
377
+ name,
378
+ abilities: def.abilities ?? [],
379
+ dependsOn: def.dependsOn ?? [],
380
+ indexes,
381
+ ...(def.refresh ? { refresh: def.refresh } : {}),
382
+ ...(def.populate ? { populate: def.populate } : {}),
383
+ ...(def.comment ? { comment: def.comment } : {}),
384
+ private: def.private ?? false,
385
+ ...(def.fields ? { fields: def.fields } : {}),
386
+ as: def.as.sql,
387
+ };
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Sequences — STATE, not compute (a sequence holds a counter value)
392
+ // ---------------------------------------------------------------------------
393
+
394
+ export interface SequenceDefinition {
395
+ as?: 'integer' | 'bigint';
396
+ start?: number;
397
+ increment?: number;
398
+ }
399
+
400
+ export interface SequenceDescriptor {
401
+ kind: 'sequence';
402
+ name: string;
403
+ as: 'integer' | 'bigint';
404
+ start?: number;
405
+ increment?: number;
406
+ }
407
+
408
+ /**
409
+ * A standalone sequence — one not owned by any serial column (those stay implicit).
410
+ * Declared on the module (`defineModule({ sequences })`), compiled into the MIGRATION
411
+ * stream before tables, fingerprinted with the base schema — the `extensions`
412
+ * precedent. This is what a `field.defaultSql(sql\`nextval('...')\`)` draws from when
413
+ * the sequence has no serial owner.
414
+ */
415
+ export function defineSequence(name: string, def: SequenceDefinition = {}): SequenceDescriptor {
416
+ return {
417
+ kind: 'sequence',
418
+ name,
419
+ as: def.as ?? 'bigint',
420
+ ...(def.start != null ? { start: def.start } : {}),
421
+ ...(def.increment != null ? { increment: def.increment } : {}),
422
+ };
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // The escape hatch — declared, hashed, ordered, and gate-tight
427
+ // ---------------------------------------------------------------------------
428
+
429
+ /** Kinds whose DROP is derivable from the name alone (`DROP <KIND> IF EXISTS <name>`). */
430
+ export const SIMPLE_DROP_KINDS = new Set(['domain', 'collation', 'event trigger']);
431
+
432
+ const CREATES_RELATION_RE = /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(MATERIALIZED\s+VIEW|VIEW)\b/i;
433
+
434
+ export interface SqlDefinition {
435
+ /** The PostgreSQL object kind (`'aggregate'`, `'domain'`, `'statement'`, …). REQUIRED. */
436
+ kind: string;
437
+ dependsOn?: DependsOnRef[];
438
+ as: RawSql;
439
+ /** REQUIRED unless the kind's DROP is derivable from the name ({@link SIMPLE_DROP_KINDS}). */
440
+ drop?: RawSql;
441
+ }
442
+
443
+ export interface SqlDescriptor {
444
+ kind: 'sql';
445
+ /** The declared PostgreSQL object kind (the descriptor discriminant stays 'sql'). */
446
+ objectKind: string;
447
+ name: string;
448
+ dependsOn: DependsOnRef[];
449
+ as: string;
450
+ drop?: string;
451
+ }
452
+
453
+ /**
454
+ * The generic declared object — for anything the structured vocabulary doesn't cover
455
+ * yet (aggregates, operators, event triggers). Still declared, content-hashed, and
456
+ * dependency-ordered like everything else. The relation refusal below is a guard
457
+ * against the ACCIDENTAL `CREATE VIEW` landing in the escape hatch — it is not an
458
+ * analyzer, and determined SQL has other ways to create readable state. Authored SQL
459
+ * here is trusted (the same boundary as a raw db/sql file before it), and the B2/B4
460
+ * gates skip 'sql'-kind objects: what this hatch declares is on its author.
461
+ */
462
+ export function defineSql(name: string, def: SqlDefinition): SqlDescriptor {
463
+ if (!def.kind || !def.kind.trim()) {
464
+ throw new Error(`defineSql '${name}': kind is required — the reconciler drops and joins objects by kind.`);
465
+ }
466
+ const m = def.as.sql.match(CREATES_RELATION_RE);
467
+ if (m) {
468
+ const suggested = /MATERIALIZED/i.test(m[1]) ? 'defineMaterializedView' : 'defineView';
469
+ throw new Error(
470
+ `defineSql '${name}': the escape hatch cannot create a ${m[1].toLowerCase()} — relations carry the read model. Use ${suggested}.`,
471
+ );
472
+ }
473
+ if (!def.drop && !SIMPLE_DROP_KINDS.has(def.kind.trim().toLowerCase())) {
474
+ throw new Error(
475
+ `defineSql '${name}': kind '${def.kind}' has no derivable DROP — declare drop: sql\`DROP ...\` so the reconciler can remove what it created.`,
476
+ );
477
+ }
478
+ return {
479
+ kind: 'sql',
480
+ objectKind: def.kind.trim().toLowerCase(),
481
+ name,
482
+ dependsOn: def.dependsOn ?? [],
483
+ as: def.as.sql,
484
+ ...(def.drop ? { drop: def.drop.sql } : {}),
485
+ };
486
+ }
487
+
488
+ /** Everything the derived (compute) stream carries — sequences are state and NOT here. */
489
+ export type DerivedDescriptor = ViewDescriptor | MaterializedViewDescriptor | FunctionDescriptor | SqlDescriptor;
package/src/drizzle.ts CHANGED
@@ -138,6 +138,7 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
138
138
  if (spec.defaultKind === 'now') col = col.defaultNow();
139
139
  else if (spec.defaultKind === 'random') col = col.defaultRandom();
140
140
  else if (spec.defaultKind === 'value') col = col.default(spec.default);
141
+ else if (spec.defaultKind === 'sql') col = col.default(sql.raw(String(spec.default)));
141
142
 
142
143
  // Single-column PK lives on the column; composite PKs are emitted table-level below.
143
144
  if (spec.isPrimaryKey && !isComposite) col = col.primaryKey();
package/src/field.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * See docs/plans/v3-model-api.md.
12
12
  */
13
13
 
14
+ import type { RawSql } from './raw-sql';
15
+
14
16
  export type FieldType =
15
17
  | 'text'
16
18
  | 'uuid'
@@ -29,7 +31,7 @@ export type FieldType =
29
31
  | 'jsonb'
30
32
  | 'enum';
31
33
 
32
- export type DefaultKind = 'value' | 'now' | 'random';
34
+ export type DefaultKind = 'value' | 'now' | 'random' | 'sql';
33
35
 
34
36
  /** A foreign-key referential action (`ON DELETE` / `ON UPDATE`). `'no action'` is the default. */
35
37
  export type ReferentialAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
@@ -55,6 +57,14 @@ export interface FieldSpec {
55
57
  isPrivate: boolean;
56
58
  /** `.readonly()` — never accepted on write (→ `protectedFields`). */
57
59
  isReadonly: boolean;
60
+ /**
61
+ * `.deprecated()` — the contract phase of contraction. The column stays in the
62
+ * database and stays READABLE (stale clients keep working); it leaves the write
63
+ * surface (→ `protectedFields`) and is struck through in generated types
64
+ * (`@deprecated` in the drizzle schema). Deleting the field from the model is
65
+ * the physical drop — a destructive, role-gated act.
66
+ */
67
+ isDeprecated: boolean;
58
68
  /** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
59
69
  references?: () => unknown;
60
70
  /** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
@@ -97,6 +107,7 @@ export class FieldBuilder {
97
107
  isArray: false,
98
108
  isPrivate: false,
99
109
  isReadonly: false,
110
+ isDeprecated: false,
100
111
  };
101
112
  }
102
113
 
@@ -139,6 +150,18 @@ export class FieldBuilder {
139
150
  return this;
140
151
  }
141
152
 
153
+ /**
154
+ * Mark the contract phase of contraction (expand → deprecate → drop). The column
155
+ * stays in the database and stays readable — old clients keep working — but new
156
+ * writes are rejected (`protectedFields`) and generated types strike it through.
157
+ * The physical drop is deleting the field from the model: a destructive plan,
158
+ * confirmed, snapshotted, and approver-gated on protected stages.
159
+ */
160
+ deprecated(): this {
161
+ this.spec.isDeprecated = true;
162
+ return this;
163
+ }
164
+
142
165
  /**
143
166
  * Declare a foreign key. The target is a model thunk (forward-reference safe). Optional
144
167
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
@@ -167,6 +190,20 @@ export class FieldBuilder {
167
190
  return this;
168
191
  }
169
192
 
193
+ /**
194
+ * A raw SQL default — the escape hatch for expressions no builder maps: a
195
+ * foreign/shared sequence (`nextval('contracts_id_seq')`), an interval
196
+ * arithmetic (`now() + interval '7 days'`). Stored verbatim behind the
197
+ * `sql\`\`` brand — the same author-controlled trust boundary as `check()`.
198
+ * A `nextval` default here is never the `serial` shorthand: serial would
199
+ * mint a NEW sequence; this declares a draw from an existing one.
200
+ */
201
+ defaultSql(expression: RawSql): this {
202
+ this.spec.default = expression.sql;
203
+ this.spec.defaultKind = 'sql';
204
+ return this;
205
+ }
206
+
170
207
  /** Attach a Zod schema. Stored now, compiled to CHECK + runtime validation in Phase 4. */
171
208
  validate(schema: unknown): this {
172
209
  this.spec.validation = schema;
package/src/index.ts CHANGED
@@ -25,8 +25,19 @@ export type { Relation } from './relations';
25
25
  export { defineModel } from './model';
26
26
  export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy } from './model';
27
27
 
28
- export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql } from './module';
28
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived } from './module';
29
29
  export type { Module, ModuleDefinition } from './module';
30
30
 
31
+ export {
32
+ defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
33
+ trigger, arg, setof, validateTriggersFor, SIMPLE_DROP_KINDS,
34
+ } from './derived';
35
+ export type {
36
+ DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor, FunctionDescriptor,
37
+ SqlDescriptor, SequenceDescriptor, TriggerSpec, TriggerDefinition, TriggerTiming,
38
+ TriggerEvent, ArgSpec, SetofReturn, DependsOnRef, ViewDefinition,
39
+ MaterializedViewDefinition, FunctionDefinition, SqlDefinition, SequenceDefinition,
40
+ } from './derived';
41
+
31
42
  export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
32
43
  export type { DerivedHandlerConfig, RelationConfig } from './derive';
package/src/model.ts CHANGED
@@ -15,6 +15,7 @@ import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
16
  import type { Relation } from './relations';
17
17
  import type { TableConstraint } from './constraints';
18
+ import { validateTriggersFor, type TriggerSpec } from './derived';
18
19
 
19
20
  /**
20
21
  * Per-table mutation hooks. Imperative function bodies the framework cannot
@@ -80,6 +81,14 @@ export interface ModelDefinition {
80
81
  * just not exposed. Named `private` to avoid colliding with `schema: 'public'`.
81
82
  */
82
83
  private?: boolean;
84
+ /**
85
+ * Triggers on this table — declared HERE because a trigger is existentially bound
86
+ * to its table and the table's owner must see what runs on every write (owner
87
+ * consent, visible in the model). Lifecycle note: declaration home ≠ lifecycle —
88
+ * triggers are COMPUTE, compiled into the derived stream (db:reconcile), never
89
+ * migrations; the base-schema fingerprint excludes this key.
90
+ */
91
+ triggers?: TriggerSpec[];
83
92
  }
84
93
 
85
94
  export interface ModelDescriptor {
@@ -101,6 +110,8 @@ export interface ModelDescriptor {
101
110
  private: boolean;
102
111
  /** One-shot rename hint: this table used to be named `renamedFrom` (same schema). */
103
112
  renamedFrom?: string;
113
+ /** Triggers on this table (compute — reconciled, never migrated; excluded from the base fingerprint). */
114
+ triggers: TriggerSpec[];
104
115
  }
105
116
 
106
117
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
@@ -108,6 +119,13 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
108
119
  .filter(([, f]) => f.spec.isPrimaryKey)
109
120
  .map(([name]) => name);
110
121
 
122
+ for (const a of def.abilities ?? []) {
123
+ if (a.action === 'execute') {
124
+ throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
125
+ }
126
+ }
127
+ validateTriggersFor('table', table, def.triggers ?? []);
128
+
111
129
  return {
112
130
  table,
113
131
  fields: def.fields,
@@ -120,5 +138,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
120
138
  schema: def.schema ?? 'public',
121
139
  private: def.private ?? false,
122
140
  ...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
141
+ triggers: def.triggers ?? [],
123
142
  };
124
143
  }
package/src/module.ts CHANGED
@@ -20,16 +20,33 @@
20
20
  */
21
21
 
22
22
  import type { ModelDescriptor } from './model';
23
+ import type { DerivedDescriptor, SequenceDescriptor } from './derived';
23
24
 
24
25
  export interface ModuleDefinition {
25
26
  /** The package's Models (schema + authz). */
26
27
  models?: ModelDescriptor[];
27
28
  /** Postgres extensions the module's SQL needs (e.g. `'pgcrypto'`). Emitted once, first. */
28
29
  extensions?: string[];
30
+ /**
31
+ * Standalone sequences (not owned by any serial column) — STATE, compiled into the
32
+ * migration stream before tables and fingerprinted with the base schema (the
33
+ * `extensions` precedent). What a `field.defaultSql(sql\`nextval('…')\`)` draws from
34
+ * when no serial column owns the sequence.
35
+ */
36
+ sequences?: SequenceDescriptor[];
37
+ /**
38
+ * The module's derived (compute) objects — views, materialized views, functions, and
39
+ * `defineSql` escape-hatch objects. Declared, content-hashed, dependency-ordered;
40
+ * deployed by `db:reconcile`, never migrated. Supersedes the `functions` thunk.
41
+ */
42
+ derived?: DerivedDescriptor[];
29
43
  /**
30
44
  * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
31
45
  * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
32
46
  * table list, say). Not part of the declarative contract; emitted verbatim.
47
+ *
48
+ * @deprecated Transitional — declare `derived: [defineFunction(…), …]` instead:
49
+ * descriptors are hashed, ordered, and gate-checked; the thunk sees none of that.
33
50
  */
34
51
  functions?: () => string;
35
52
  }
@@ -38,15 +55,34 @@ export interface ModuleDefinition {
38
55
  export interface Module {
39
56
  models: ModelDescriptor[];
40
57
  extensions: string[];
58
+ /** Standalone sequences — state layer (migrated, fingerprinted). */
59
+ sequences: SequenceDescriptor[];
60
+ /** Derived (compute) objects — reconciled, never migrated. */
61
+ derived: DerivedDescriptor[];
41
62
  /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
42
63
  functions: Array<() => string>;
43
64
  }
44
65
 
45
66
  /** Declare a package's database slice. Empty fields default to nothing — a models-only module is fine. */
46
67
  export function defineModule(def: ModuleDefinition): Module {
68
+ // Derived identity is one name per kind: `DROP FUNCTION` renders no argument list, so
69
+ // v1 rejects function overloads outright, and same-name relations collide in the
70
+ // catalog anyway — caught here, at composition, before any SQL exists.
71
+ const seen = new Set<string>();
72
+ for (const d of def.derived ?? []) {
73
+ const identity = `${d.kind === 'sql' ? d.objectKind : d.kind}:${d.name}`;
74
+ if (seen.has(identity)) {
75
+ throw new Error(
76
+ `defineModule: two derived declarations of '${d.name}' (${identity}) — one ${d.kind} per name (function overloads are v1-rejected; rename, or collapse to one signature with arg defaults).`,
77
+ );
78
+ }
79
+ seen.add(identity);
80
+ }
47
81
  return {
48
82
  models: def.models ?? [],
49
83
  extensions: def.extensions ?? [],
84
+ sequences: def.sequences ?? [],
85
+ derived: def.derived ?? [],
50
86
  functions: def.functions ? [def.functions] : [],
51
87
  };
52
88
  }
@@ -66,3 +102,13 @@ export function moduleExtensions(modules: Module[]): string[] {
66
102
  export function moduleFunctionSql(modules: Module[]): string[] {
67
103
  return modules.flatMap((m) => m.functions).map((fn) => fn());
68
104
  }
105
+
106
+ /** The flattened standalone-sequence list across modules — the state-layer pre-table phase. */
107
+ export function moduleSequences(modules: Module[]): SequenceDescriptor[] {
108
+ return modules.flatMap((m) => m.sequences);
109
+ }
110
+
111
+ /** The flattened derived-object list across modules — what the reconcile compiler consumes. */
112
+ export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
113
+ return modules.flatMap((m) => m.derived);
114
+ }