@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 +1 -1
- package/src/derived.ts +142 -6
- package/src/index.ts +6 -3
- package/src/model.ts +50 -18
- package/src/module.ts +72 -0
- package/src/qualified.ts +34 -0
package/package.json
CHANGED
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.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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 —
|
|
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 =
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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,
|
|
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,
|
|
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
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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 ${
|
|
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 (
|
|
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
|
|
333
|
-
+ (
|
|
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,
|
|
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[] {
|
package/src/qualified.ts
ADDED
|
@@ -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
|
+
}
|