@everystack/model 0.4.11 → 0.4.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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/derived.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import type { RawSql } from './raw-sql';
18
18
  import type { Ability } from './can';
19
19
  import type { ModelDescriptor } from './model';
20
+ import { TABLE_PRIVILEGES } from './model';
20
21
  import type { IndexBuilder } from './constraints';
21
22
  import type { FieldBuilder } from './field';
22
23
 
@@ -130,10 +131,14 @@ export interface TriggerSpec {
130
131
  }
131
132
 
132
133
  /**
133
- * A trigger on the model (or view) it is declared on. Deps are free: the table dep is
134
- * the declaration site; the function dep is `execute:`. Target-specific legality
135
- * (INSTEAD OF needs a view; views take no BEFORE/AFTER row triggers) is enforced where
136
- * the trigger attaches — defineModel / defineView.
134
+ * A trigger on the model (or view) it is declared on.
135
+ *
136
+ * @deprecated Use {@link defineTrigger} and put the binding in the `derived` array. A
137
+ * trigger depends on BOTH a table and a function, so declaring it ON the table forces the
138
+ * base layer to import the compute layer — which `db:pull --derived-out` cannot render at
139
+ * all, because the generated derived module already imports the models (the cycle is fatal,
140
+ * not stylistic). `trigger()` keeps working and compiles to the identical object; it will
141
+ * be removed in the next major.
137
142
  */
138
143
  export function trigger(name: string, def: TriggerDefinition): TriggerSpec {
139
144
  if (def.execute == null) {
@@ -167,6 +172,87 @@ export function trigger(name: string, def: TriggerDefinition): TriggerSpec {
167
172
  };
168
173
  }
169
174
 
175
+ /**
176
+ * Grants that EXIST on a relation, recorded without claiming anyone intended them.
177
+ *
178
+ * The same key, and the same rule, as tables and functions: **abilities are opinion,
179
+ * privileges are fact.** `can('read')` declares an intended audience; this records what
180
+ * the database actually holds — the blanket
181
+ * DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/UPDATE that `GRANT ALL ON ALL TABLES IN
182
+ * SCHEMA` hands out, or a `SELECT` to `PUBLIC`. Neither is expressible as a read ability,
183
+ * and without a way to say them `db:pull` had to skip the whole object, body included.
184
+ *
185
+ * Recording them is also what makes the object safe to render: declared == live, so
186
+ * `db:generate` plans nothing. Rendering only the expressible half would plan a REVOKE of
187
+ * the rest, which turns a silent skip into a silent revoke.
188
+ */
189
+ function validateRelationPrivileges(
190
+ kindWord: string,
191
+ name: string,
192
+ privileges: Record<string, string[]> | undefined,
193
+ ): Record<string, string[]> {
194
+ const out: Record<string, string[]> = {};
195
+ for (const [grantee, privs] of Object.entries(privileges ?? {})) {
196
+ if (privs.length === 0) {
197
+ throw new Error(`${kindWord} '${name}': privileges for '${grantee}' is empty — remove the key, or name the privileges.`);
198
+ }
199
+ for (const p of privs) {
200
+ if (!(TABLE_PRIVILEGES as readonly string[]).includes(p.toUpperCase())) {
201
+ throw new Error(
202
+ `${kindWord} '${name}': '${p}' is not a relation privilege — one of ${TABLE_PRIVILEGES.join(', ')}. ` +
203
+ `(EXECUTE belongs on a function.)`,
204
+ );
205
+ }
206
+ }
207
+ out[grantee] = privs.map((p) => p.toUpperCase());
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /** What a standalone binding names as its target. A view is a legal trigger target too. */
213
+ export type TriggerTarget = ModelDescriptor | ViewDescriptor;
214
+
215
+ export interface StandaloneTriggerDefinition extends TriggerDefinition {
216
+ /** The table or view the trigger rides. A binding is a property of neither endpoint. */
217
+ on: TriggerTarget;
218
+ }
219
+
220
+ export interface TriggerDescriptor extends TriggerSpec {
221
+ kind: 'trigger';
222
+ on: TriggerTarget;
223
+ }
224
+
225
+ /**
226
+ * A trigger binding, declared standing on its own rather than hanging off its table.
227
+ *
228
+ * **Why standalone.** A trigger depends on BOTH a table and a function, so its declaration
229
+ * site must be downstream of both. Models are the base layer; the derived layer points at
230
+ * models and never the reverse. Declaring the binding on the model inverts that, and
231
+ * `db:pull --derived-out` makes the inversion fatal rather than merely untidy — the
232
+ * generated derived module imports the model files for view `dependsOn`, so a binding on
233
+ * the model closes the loop and the module graph dies with
234
+ * `ReferenceError: Cannot access 'X' before initialization`.
235
+ *
236
+ * It also matches PostgreSQL's own grammar — `CREATE TRIGGER` is a standalone statement
237
+ * naming its table and its function — and it matches reality: bindings and functions are
238
+ * many-to-one (one real schema has 18 bindings over 11 functions, one of them in a
239
+ * different schema from the tables it serves), so the binding belongs to neither endpoint.
240
+ *
241
+ * Goes in the `derived` array: `defineModule({ models, derived: [touchFn, rowsTouch] })`.
242
+ */
243
+ export function defineTrigger(name: string, def: StandaloneTriggerDefinition): TriggerDescriptor {
244
+ if (def.on == null) {
245
+ throw new Error(`trigger '${name}': on is ${def.on === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
246
+ }
247
+ const spec = trigger(name, def);
248
+ // A standalone descriptor knows its own target, so it enforces target legality itself.
249
+ // The attached form could not: the target was only known at the attach site.
250
+ const isView = 'kind' in def.on && def.on.kind === 'view';
251
+ const owner = isView ? (def.on as ViewDescriptor).name : `${(def.on as ModelDescriptor).schema}.${(def.on as ModelDescriptor).table}`;
252
+ validateTriggersFor(isView ? 'view' : 'table', owner, [spec]);
253
+ return { ...spec, kind: 'trigger', on: def.on };
254
+ }
255
+
170
256
  /** The legality rules that depend on the trigger's TARGET (table vs view). */
171
257
  export function validateTriggersFor(target: 'table' | 'view', owner: string, triggers: TriggerSpec[]): void {
172
258
  rejectHoles(`${target} '${owner}'`, 'triggers', triggers);
@@ -267,8 +353,28 @@ export interface FunctionDefinition {
267
353
  /** Default `'volatile'` (PostgreSQL's own). `immutable` enables expression indexes. */
268
354
  volatility?: 'immutable' | 'stable' | 'volatile';
269
355
  dependsOn?: DependsOnRef[];
270
- /** Execute authz only, role REQUIRED — `PUBLIC` is always revoked by the compiler. */
356
+ /** Execute authz only, role REQUIRED — the INTENDED audience. */
271
357
  abilities?: Ability[];
358
+ /**
359
+ * EXECUTE grants that EXIST, recorded without claiming anyone intended them —
360
+ * `privileges: { PUBLIC: ['EXECUTE'] }`.
361
+ *
362
+ * **Abilities are opinion; privileges are fact.** `can('execute', { role })` declares an
363
+ * audience and compiles a grant to serve it. This key says only "the database has this
364
+ * grant", which is what `db:pull` needs to render a live function faithfully.
365
+ *
366
+ * The state that forced it: PostgreSQL grants every new function EXECUTE to PUBLIC, and a
367
+ * brownfield schema is full of ones nobody revoked. Without a spelling for that, a pull
368
+ * had to either invent a role — a GRANT the database never had, while silently revoking
369
+ * the live PUBLIC one — or leave the function undeclarable, which is a permanent
370
+ * `db:fingerprint` MISMATCH. Declaring a `PUBLIC` key also suppresses the compiler's
371
+ * otherwise-unconditional `REVOKE ALL … FROM PUBLIC`, so the pull round-trips to zero
372
+ * statements instead of planning a revoke the adopter never asked for.
373
+ *
374
+ * It is still usually a finding: `db:check` WARNs that a SECURITY DEFINER function is
375
+ * PUBLIC-executable. Opinion lives in the linter, never in the parser.
376
+ */
377
+ privileges?: Record<string, string[]>;
272
378
  comment?: string;
273
379
  body: RawSql;
274
380
  }
@@ -284,6 +390,8 @@ export interface FunctionDescriptor {
284
390
  volatility: 'immutable' | 'stable' | 'volatile';
285
391
  dependsOn: DependsOnRef[];
286
392
  abilities: Ability[];
393
+ /** Recorded EXECUTE grants — see {@link FunctionDefinition.privileges}. */
394
+ privileges: Record<string, string[]>;
287
395
  comment?: string;
288
396
  body: string;
289
397
  }
@@ -312,6 +420,22 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
312
420
  );
313
421
  }
314
422
  }
423
+ // EXECUTE is the only privilege a function has, so the map is a one-member enum today.
424
+ // The table's shape is kept anyway: one shape across object kinds beats a bespoke
425
+ // boolean that has to grow a second member later.
426
+ const privileges: Record<string, string[]> = {};
427
+ for (const [grantee, privs] of Object.entries(def.privileges ?? {})) {
428
+ if (privs.length === 0) {
429
+ throw new Error(`function '${name}': privileges for '${grantee}' is empty — remove the key, or say ['EXECUTE'].`);
430
+ }
431
+ for (const p of privs) {
432
+ if (p.toUpperCase() !== 'EXECUTE') {
433
+ throw new Error(`function '${name}': '${p}' is not a function privilege — EXECUTE is the only one. (Table privileges live on the model.)`);
434
+ }
435
+ }
436
+ privileges[grantee] = privs.map((p) => p.toUpperCase());
437
+ }
438
+
315
439
  return {
316
440
  kind: 'function',
317
441
  name,
@@ -323,6 +447,7 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
323
447
  volatility: def.volatility ?? 'volatile',
324
448
  dependsOn: def.dependsOn ?? [],
325
449
  abilities: def.abilities ?? [],
450
+ privileges,
326
451
  ...(def.comment ? { comment: def.comment } : {}),
327
452
  body: def.body.sql,
328
453
  };
@@ -341,6 +466,8 @@ export interface ViewDefinition {
341
466
  */
342
467
  securityInvoker: boolean;
343
468
  abilities?: Ability[];
469
+ /** Grants that EXIST, recorded rather than chosen — see {@link validateRelationPrivileges}. */
470
+ privileges?: Record<string, string[]>;
344
471
  dependsOn?: DependsOnRef[];
345
472
  /** `INSTEAD OF` row triggers, or BEFORE/AFTER statement-level. */
346
473
  triggers?: TriggerSpec[];
@@ -368,6 +495,7 @@ export interface ViewDescriptor {
368
495
  name: string;
369
496
  securityInvoker: boolean;
370
497
  abilities: Ability[];
498
+ privileges: Record<string, string[]>;
371
499
  dependsOn: DependsOnRef[];
372
500
  triggers: TriggerSpec[];
373
501
  comment?: string;
@@ -378,6 +506,7 @@ export interface ViewDescriptor {
378
506
 
379
507
  export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
380
508
  rejectHoles(`view '${name}'`, 'dependsOn', def.dependsOn);
509
+ const privileges = validateRelationPrivileges('view', name, def.privileges);
381
510
  if (typeof def.securityInvoker !== 'boolean') {
382
511
  throw new Error(
383
512
  `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).`,
@@ -392,6 +521,7 @@ export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
392
521
  name,
393
522
  securityInvoker: def.securityInvoker,
394
523
  abilities: def.abilities ?? [],
524
+ privileges,
395
525
  dependsOn: def.dependsOn ?? [],
396
526
  triggers: def.triggers ?? [],
397
527
  ...(def.comment ? { comment: def.comment } : {}),
@@ -407,6 +537,8 @@ export function defineView(name: string, def: ViewDefinition): ViewDescriptor {
407
537
 
408
538
  export interface MaterializedViewDefinition {
409
539
  abilities?: Ability[];
540
+ /** Grants that EXIST, recorded rather than chosen — see {@link validateRelationPrivileges}. */
541
+ privileges?: Record<string, string[]>;
410
542
  dependsOn?: DependsOnRef[];
411
543
  indexes?: IndexBuilder[];
412
544
  /** `'concurrently'` — compile-gated on a declared unique, non-partial index (PG requires it). */
@@ -424,6 +556,7 @@ export interface MaterializedViewDescriptor {
424
556
  kind: 'materialized view';
425
557
  name: string;
426
558
  abilities: Ability[];
559
+ privileges: Record<string, string[]>;
427
560
  dependsOn: DependsOnRef[];
428
561
  indexes: IndexBuilder[];
429
562
  refresh?: 'concurrently';
@@ -436,6 +569,7 @@ export interface MaterializedViewDescriptor {
436
569
 
437
570
  export function defineMaterializedView(name: string, def: MaterializedViewDefinition): MaterializedViewDescriptor {
438
571
  rejectHoles(`materialized view '${name}'`, 'dependsOn', def.dependsOn);
572
+ const privileges = validateRelationPrivileges('materialized view', name, def.privileges);
439
573
  validateRelationAbilities('materialized view', name, def.abilities ?? []);
440
574
  validatePrivateContradiction('materialized view', name, def.private, def.abilities);
441
575
  validateRelationFields('materialized view', name, def.fields, def.private);
@@ -449,6 +583,7 @@ export function defineMaterializedView(name: string, def: MaterializedViewDefini
449
583
  kind: 'materialized view',
450
584
  name,
451
585
  abilities: def.abilities ?? [],
586
+ privileges,
452
587
  dependsOn: def.dependsOn ?? [],
453
588
  indexes,
454
589
  ...(def.refresh ? { refresh: def.refresh } : {}),
@@ -560,4 +695,5 @@ export function defineSql(name: string, def: SqlDefinition): SqlDescriptor {
560
695
  }
561
696
 
562
697
  /** Everything the derived (compute) stream carries — sequences are state and NOT here. */
563
- export type DerivedDescriptor = ViewDescriptor | MaterializedViewDescriptor | FunctionDescriptor | SqlDescriptor;
698
+ export type DerivedDescriptor =
699
+ ViewDescriptor | MaterializedViewDescriptor | FunctionDescriptor | SqlDescriptor | TriggerDescriptor;
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './fie
12
12
 
13
13
  export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
14
14
 
15
+ export { parseQualified } from './qualified';
16
+
15
17
  export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
16
18
  export type { TableConstraint } from './constraints';
17
19
 
@@ -24,20 +26,21 @@ export type { Ability, AbilityCondition, Action } from './can';
24
26
  export { belongsTo, hasMany } from './relations';
25
27
  export type { Relation } from './relations';
26
28
 
27
- export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES } from './model';
28
- export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
29
+ export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
30
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
29
31
 
30
32
  export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
31
33
  export type { Module, ModuleDefinition } from './module';
32
34
 
33
35
  export {
34
36
  defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
35
- trigger, arg, setof, validateTriggersFor, SIMPLE_DROP_KINDS,
37
+ trigger, defineTrigger, arg, setof, validateTriggersFor, SIMPLE_DROP_KINDS,
36
38
  normalizeArgType, functionArgSignature,
37
39
  } from './derived';
38
40
  export type {
39
41
  DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor, FunctionDescriptor,
40
42
  SqlDescriptor, SequenceDescriptor, TriggerSpec, TriggerDefinition, TriggerTiming,
43
+ TriggerDescriptor, StandaloneTriggerDefinition, TriggerTarget,
41
44
  TriggerEvent, ArgSpec, SetofReturn, DependsOnRef, ViewDefinition,
42
45
  MaterializedViewDefinition, FunctionDefinition, SqlDefinition, SequenceDefinition,
43
46
  } from './derived';
package/src/model.ts CHANGED
@@ -68,6 +68,35 @@ export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
68
68
  /** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
69
69
  export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
70
70
 
71
+ /** The four CRUD privileges. Normally they arrive via `can()`, which decides the policy too. */
72
+ export const DML_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
73
+ export type DmlPrivilege = (typeof DML_PRIVILEGES)[number];
74
+
75
+ /**
76
+ * Every table privilege this language can spell.
77
+ *
78
+ * The DML four used to be REFUSED here, so that a grant could never be declared without the
79
+ * policy that gates it. That invariant was already vacuous for any database being adopted: a
80
+ * live table can hold `GRANT SELECT TO admin` with no policy covering admin, and refusing to
81
+ * say it did not make it not so — it only made the database unspellable.
82
+ *
83
+ * That matters because `db:fingerprint` compares the LIVE schema against the MODELS. A state
84
+ * the language cannot express is a permanent MISMATCH, so adopting such a database required
85
+ * mutating it first, which inverts who is the source of truth during adoption.
86
+ *
87
+ * The rule this settles (Fable, 2026-08-06): **expressibility is total over the fingerprint's
88
+ * domain; opinion lives in the linter, never in the parser.** Declaring a naked DML grant is
89
+ * still a mistake in hand-authored code — `db:check` says so, as an ERROR on a table without
90
+ * RLS (where it is an unrestricted table-wide privilege) and a WARNING on a table with RLS
91
+ * (where it is dead until someone adds a broad policy or disables RLS). It is no longer a
92
+ * parse error, because `db:pull` has to be able to render what is actually there.
93
+ *
94
+ * Policies still come ONLY from `can()`. This key can never mint row access by itself.
95
+ */
96
+ export type TablePrivilege = ExtraPrivilege | DmlPrivilege;
97
+
98
+ export const TABLE_PRIVILEGES: readonly TablePrivilege[] = [...EXTRA_PRIVILEGES, ...DML_PRIVILEGES];
99
+
71
100
  export interface ModelDefinition {
72
101
  fields: Record<string, FieldBuilder>;
73
102
  abilities?: Ability[];
@@ -81,17 +110,20 @@ export interface ModelDefinition {
81
110
  * Privileges a role holds on this table BEYOND the CRUD an ability compiles to —
82
111
  * `privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] }`.
83
112
  *
84
- * Only {@link ExtraPrivilege} values are accepted. The four DML privileges are NOT
85
- * declarable here on purpose: `SELECT`/`INSERT`/`UPDATE`/`DELETE` come from `can()`, which
86
- * decides the grant and the RLS policy together. A grant declared without its policy is a
87
- * privilege with no row gate — the exact shape `db:pull` refuses to render.
113
+ * The DML four are accepted but are almost never what you want by hand: `SELECT`/`INSERT`/
114
+ * `UPDATE`/`DELETE` normally come from `can()`, which decides the grant and the RLS policy
115
+ * together. Declared here they compile to a GRANT with no policy beside it. `db:check` flags
116
+ * that — an ERROR on a table without RLS, where it is an unrestricted table-wide privilege;
117
+ * a WARNING on a table with RLS, where it is dead until a broad policy appears or RLS is
118
+ * disabled. They are spellable because `db:pull` must be able to render a live database that
119
+ * is already in that shape; see {@link TablePrivilege}.
88
120
  *
89
121
  * ADDITIVE, never subtractive: these are unioned with what the abilities grant, so this key
90
122
  * can widen a role's privileges and can never narrow them. Naming a role here GOVERNS it —
91
123
  * same rule as `can(…, { role })` — so a role that appears only in this map has its other
92
124
  * privileges reconciled everywhere, not just on this table.
93
125
  */
94
- privileges?: Record<string, ExtraPrivilege[]>;
126
+ privileges?: Record<string, TablePrivilege[]>;
95
127
  /**
96
128
  * `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
97
129
  * and from the generic data API. **Required — not inferred — on any model that has a
@@ -166,7 +198,7 @@ export interface ModelDescriptor {
166
198
  /** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
167
199
  writtenBy: WrittenBy;
168
200
  /** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
169
- privileges: Record<string, ExtraPrivilege[]>;
201
+ privileges: Record<string, TablePrivilege[]>;
170
202
  /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
171
203
  softDelete: boolean;
172
204
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
@@ -317,22 +349,22 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
317
349
  );
318
350
  }
319
351
 
320
- // A privilege outside the beyond-CRUD set is refused rather than passed through. The DML
321
- // four are the dangerous ones: declared here they would compile to a GRANT with no policy
322
- // beside it, which is a privilege nobody row-gated. They come from can(), where the grant
323
- // and the policy are decided in one place.
352
+ // A privilege outside the table-privilege vocabulary is still refused — that is a typo, not a
353
+ // state a database can hold. The DML four ARE accepted now: they used to throw here so that a
354
+ // grant could never be declared without its policy, but that invariant was vacuous for any
355
+ // database being adopted (a live table can hold a policy-less grant whether or not we can
356
+ // spell it) and it made such a database a permanent fingerprint MISMATCH. Expressibility is
357
+ // total over the fingerprint's domain; the opinion moved to db:check, which flags a naked DML
358
+ // grant as an ERROR on a non-RLS table and a WARNING on an RLS one. See TablePrivilege.
324
359
  for (const [role, privs] of Object.entries(def.privileges ?? {})) {
325
360
  if (!Array.isArray(privs)) {
326
- throw new Error(`model '${table}': privileges['${role}'] must be an array of ${EXTRA_PRIVILEGES.join(' | ')}.`);
361
+ throw new Error(`model '${table}': privileges['${role}'] must be an array of ${TABLE_PRIVILEGES.join(' | ')}.`);
327
362
  }
328
363
  for (const p of privs) {
329
- if (EXTRA_PRIVILEGES.includes(p)) continue;
330
- const dml = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].includes(String(p).toUpperCase());
364
+ if (TABLE_PRIVILEGES.includes(p)) continue;
331
365
  throw new Error(
332
- `model '${table}': privileges['${role}'] contains '${p}', which is not one of ${EXTRA_PRIVILEGES.join(', ')}. `
333
- + (dml
334
- ? `Declare ${String(p).toUpperCase()} with can() — that is what decides the row policy beside the grant. A grant declared here would have no policy.`
335
- : `This key names only the table privileges can() has no verb for.`),
366
+ `model '${table}': privileges['${role}'] contains '${p}', which is not a table privilege. `
367
+ + `Expected one of ${TABLE_PRIVILEGES.join(', ')}.`,
336
368
  );
337
369
  }
338
370
  }
@@ -398,7 +430,7 @@ export interface MaterializedTableDefinition {
398
430
  private?: boolean;
399
431
  renamedFrom?: string;
400
432
  /** Beyond-CRUD privileges per role. See {@link ModelDefinition.privileges}. */
401
- privileges?: Record<string, ExtraPrivilege[]>;
433
+ privileges?: Record<string, TablePrivilege[]>;
402
434
  }
403
435
 
404
436
  export function defineMaterializedTable(table: string, def: MaterializedTableDefinition): ModelDescriptor {
package/src/module.ts CHANGED
@@ -22,6 +22,8 @@
22
22
  import type { ModelDescriptor } from './model';
23
23
  import type { DerivedDescriptor, SequenceDescriptor } from './derived';
24
24
  import { rejectHoles, functionArgSignature } from './derived';
25
+ import { deriveFunctionSchemas } from './derive';
26
+ import { parseQualified } from './qualified';
25
27
 
26
28
  export interface ModuleDefinition {
27
29
  /** The package's Models (schema + authz). */
@@ -114,6 +116,7 @@ export function defineModule(def: ModuleDefinition): Module {
114
116
  }
115
117
  seen.add(identity);
116
118
  }
119
+ assertFunctionsAreDeclared(def);
117
120
  return {
118
121
  models: def.models ?? [],
119
122
  extensions: def.extensions ?? [],
@@ -124,6 +127,75 @@ export function defineModule(def: ModuleDefinition): Module {
124
127
  };
125
128
  }
126
129
 
130
+ /**
131
+ * `writtenBy: 'functions'` is a PROMISE that functions do the writing. This is where the
132
+ * promise gets checked.
133
+ *
134
+ * The defect it closes, found 2026-08-04: `@everystack/auth` declared seven models
135
+ * `writtenBy: 'functions'` and shipped 21 SECURITY DEFINER functions — through the deprecated
136
+ * `functions` thunk, which exactly ONE command reads (`db:generate --init`). `db:reconcile`
137
+ * reads `derived`, and `derived` was empty. So a brownfield adopter installed the tables, got
138
+ * ENABLE-not-FORCE RLS on the strength of `writtenBy: 'functions'`, installed none of the
139
+ * functions that were supposed to be the only writers, and every gate reported clean. The
140
+ * declaration and the thing it depends on were checked by nobody.
141
+ *
142
+ * The check is per-MODULE, not across the composed set: a module that owns a
143
+ * functions-written model owns the functions that write it, and firing here can name the
144
+ * module at fault. Across modules the error arrives after everything is flattened and can
145
+ * only say that something, somewhere, is missing.
146
+ *
147
+ * A thunk downgrades the error to a warning rather than satisfying the check — the thunk lane
148
+ * does install, just on one command, so a consumer still on it is degraded and not broken.
149
+ */
150
+ function assertFunctionsAreDeclared(def: ModuleDefinition): void {
151
+ const models = def.models ?? [];
152
+ const owed = deriveFunctionSchemas(models);
153
+ const declared = new Set(
154
+ (def.derived ?? [])
155
+ .filter((d) => (d.kind === 'sql' ? d.objectKind : d.kind) === 'function')
156
+ .map((d) => parseQualified(d.name).schema),
157
+ );
158
+ const missing = owed.filter((schema) => !declared.has(schema));
159
+
160
+ if (missing.length === 0) {
161
+ if (def.functions) {
162
+ console.warn(
163
+ 'defineModule: the `functions` thunk is deprecated and only `db:generate --init` reads it — '
164
+ + '`db:reconcile` reads `derived`, so anything declared there alone never reaches a brownfield '
165
+ + 'database. Move it to `derived: [defineFunction(…)]`.',
166
+ );
167
+ }
168
+ return;
169
+ }
170
+
171
+ const blame = missing
172
+ .map((schema) => {
173
+ const tables = models
174
+ .filter((m) => m.writtenBy === 'functions' && m.schema === schema)
175
+ .map((m) => `${m.schema}.${m.table}`)
176
+ .sort();
177
+ return `schema '${schema}' (${tables.join(', ')})`;
178
+ })
179
+ .join('; ');
180
+
181
+ if (def.functions) {
182
+ console.warn(
183
+ `defineModule: ${blame} declares writtenBy: 'functions' but every function is in the deprecated `
184
+ + '`functions` thunk, which only `db:generate --init` reads. `db:reconcile` reads `derived`, so a '
185
+ + 'brownfield database gets the tables and none of their writers. Move them to '
186
+ + '`derived: [defineFunction(…)]`.',
187
+ );
188
+ return;
189
+ }
190
+
191
+ throw new Error(
192
+ `defineModule: ${blame} declares writtenBy: 'functions' but the module declares no function in `
193
+ + `that schema. writtenBy: 'functions' is what turns RLS FORCE off, so the tables ship writable `
194
+ + `only by functions that do not exist. Declare them as \`derived: [defineFunction('${missing[0]}.…', …)]\`, `
195
+ + `or change writtenBy if functions are not the writer.`,
196
+ );
197
+ }
198
+
127
199
  /** The flattened model list across modules — for everything that still takes a model array
128
200
  * (handler-config derivation, FORCE carveouts, the drift guard). */
129
201
  export function moduleModels(modules: Module[]): ModelDescriptor[] {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Splitting a declared name into `{ schema, name }`.
3
+ *
4
+ * This lives in `@everystack/model` because the names it parses are MODEL vocabulary: a
5
+ * descriptor declares `defineFunction('auth.sign_in_session', …)` and a model declares its
6
+ * `schema` separately, so anything that has to line those two up — the composition guard here,
7
+ * every emitter and the reconciler in `@everystack/cli` — must agree on where the dot is. The
8
+ * cli re-exports this from `derived-source.ts`, so there is ONE definition and its callers
9
+ * cannot drift apart. (Same reasoning as `fieldFactoryCall`: the parser belongs with the
10
+ * vocabulary it parses, not with the first consumer that needed it.)
11
+ *
12
+ * `public` is the unqualified default because that is what PostgreSQL resolves a bare name to
13
+ * under the default `search_path` — the parse must match the server, not a convention.
14
+ */
15
+
16
+ /** Strip quotes from an identifier and split an optionally schema-qualified name. */
17
+ export function parseQualified(raw: string): { schema: string; name: string } {
18
+ const parts: string[] = [];
19
+ let current = '';
20
+ let quoted = false;
21
+ for (let i = 0; i < raw.length; i++) {
22
+ const ch = raw[i];
23
+ if (ch === '"') {
24
+ if (quoted && raw[i + 1] === '"') { current += '"'; i++; continue; }
25
+ quoted = !quoted;
26
+ continue;
27
+ }
28
+ if (ch === '.' && !quoted) { parts.push(current); current = ''; continue; }
29
+ current += ch;
30
+ }
31
+ parts.push(current);
32
+ if (parts.length >= 2) return { schema: parts[0], name: parts.slice(1).join('.') };
33
+ return { schema: 'public', name: parts[0] };
34
+ }