@everystack/cli 0.4.48 → 0.4.49

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/cli",
3
- "version": "0.4.48",
3
+ "version": "0.4.49",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.12"
112
+ "@everystack/model": "0.4.13"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -232,6 +232,32 @@ function policiesFor(contract: TableContract, role: string, command: string): Po
232
232
  );
233
233
  }
234
234
 
235
+ /**
236
+ * Does this grant actually give the role rows?
237
+ *
238
+ * **The rule, in one line: an ability needs BOTH a grant and a covering permissive policy.**
239
+ *
240
+ * Postgres checks the grant and THEN the policy. Either half alone gives nothing, and the two
241
+ * failures look identical in a diff:
242
+ *
243
+ * - policy, no grant → dead code. Declaring it ADDs a grant. (handled in deriveAbilities)
244
+ * - grant, no policy → dead rows. Declaring it ADDs a policy. (this predicate)
245
+ *
246
+ * The second half was fixed for `admin` first, because that is where a consumer hit it. The
247
+ * round-trip gate (integration/authz-roundtrip) then found the SAME defect on the read branch —
248
+ * `authenticated` holding SELECT with no policy rendered `can('read')`, compiling to
249
+ * `CREATE POLICY … USING (true)` against a database that has no such policy. Hence one predicate,
250
+ * used at every site that turns a grant into an ability, rather than a fix per role.
251
+ *
252
+ * A restrictive-only policy does not count: RESTRICTIVE policies subtract, so with no permissive
253
+ * policy to pass first the role still sees nothing.
254
+ */
255
+ function effective(contract: TableContract, role: string, priv: string): boolean {
256
+ if (!granted(contract, role, priv)) return false;
257
+ if (!contract.rls.enabled) return true;
258
+ return policiesFor(contract, role, priv).some((p) => p.permissive);
259
+ }
260
+
235
261
  /** A sql`…` fragment literal, or null when the predicate is vacuous. */
236
262
  function sqlFragment(pred: string | null): string | null {
237
263
  if (!pred) return null;
@@ -303,10 +329,72 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
303
329
  const abilities: string[] = [];
304
330
  const notes: string[] = [];
305
331
  const table = contract.table;
332
+ /** Live CRUD grants no policy covers — declared as grants so the plan neither invents a
333
+ * policy (a widening) nor revokes a real privilege (a narrowing). */
334
+ const deadGrants: Record<string, string[]> = {};
306
335
 
307
336
  // --- the admin bypass: one role holding every privilege ------------------------------
308
- const adminAll = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].every((p) => granted(contract, 'admin', p));
309
- if (adminAll) abilities.push(`can('manage', { role: 'admin' })`);
337
+ //
338
+ // The grant is only HALF the question. Postgres checks the grant and THEN the policy, so a
339
+ // role holding all four privileges with no policy covering it reads zero rows. Rendering
340
+ // `can('manage')` there compiles to a GRANT **plus** `CREATE POLICY … TO admin USING (true)`,
341
+ // handing admin a table the database currently withholds — a WIDENING emitted by the adoption
342
+ // path itself. That is the same trap the policy-without-grant branch below prevents, reached
343
+ // from the other side, and it shipped: three tables at one adopter, one of the three
344
+ // statements in their plan, refused by them.
345
+ //
346
+ // The grants are still declared — via `privileges` below — so the plan neither invents a
347
+ // policy nor revokes a real grant. Do NOT "simplify" this to dropping the grants: a grantee
348
+ // with rolbypassrls holds them live (our introspection checks ownership, not rolbypassrls),
349
+ // and they re-arm the day anyone runs ALTER TABLE … DISABLE ROW LEVEL SECURITY.
350
+ //
351
+ // No owner ambiguity: GRANTS_SQL excludes the table owner, so a role present in `grants` is a
352
+ // non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
353
+ const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
354
+ const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
355
+ const adminLive = adminGrants.filter((p) => effective(contract, 'admin', p));
356
+ const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
357
+
358
+ if (adminLive.length === CRUD.length) {
359
+ abilities.push(`can('manage', { role: 'admin' })`);
360
+ } else {
361
+ // PARTIAL. `manage` means all four, so it would be a lie — but the privileges that ARE
362
+ // policied are live authorization, and rendering nothing for them makes the plan DROP the
363
+ // policy and REVOKE the grant. That narrowing is how the round-trip gate caught this case:
364
+ // an admin table policied for SELECT only lost both halves on the way through.
365
+ for (const priv of adminLive) {
366
+ const pol = policiesFor(contract, 'admin', priv).find((p) => p.permissive);
367
+ const frag = sqlFragment(pol?.using ?? null);
368
+ const verb = DML[priv];
369
+ abilities.push(
370
+ frag ? `can('${verb}', { role: 'admin', sql: ${frag} })` : `can('${verb}', { role: 'admin' })`,
371
+ );
372
+ }
373
+ }
374
+
375
+ if (adminDead.length) {
376
+ deadGrants.admin = adminDead;
377
+ notes.push(
378
+ `admin holds ${adminDead.join(', ')} but no policy covers admin —`,
379
+ );
380
+ notes.push(
381
+ ` dead while RLS is on, unrestricted if RLS is ever disabled. Declared as a grant, not`,
382
+ );
383
+ notes.push(
384
+ ` an ability: an ability would ADD the policy the database does not have.`,
385
+ );
386
+ }
387
+
388
+ // The same accounting for the app roles. A grant they hold with no policy behind it is
389
+ // declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
390
+ // (so the plan does not CREATE the policy the database is missing).
391
+ for (const role of ['authenticated', 'anon']) {
392
+ const dead = CRUD.filter((p) => granted(contract, role, p) && !effective(contract, role, p));
393
+ if (!dead.length) continue;
394
+ deadGrants[role] = dead;
395
+ notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
396
+ notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
397
+ }
310
398
 
311
399
  // --- policed but not granted at the table level ---------------------------------------
312
400
  //
@@ -340,8 +428,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
340
428
  }
341
429
 
342
430
  // --- reads ---------------------------------------------------------------------------
343
- const anonRead = granted(contract, 'anon', 'SELECT');
344
- const authedRead = granted(contract, 'authenticated', 'SELECT');
431
+ // `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
432
+ // which compiles to CREATE POLICY … USING (true) manufacturing a policy the database does
433
+ // not have. Same defect as the admin branch above; the round-trip gate found it here.
434
+ const anonRead = effective(contract, 'anon', 'SELECT');
435
+ const authedRead = effective(contract, 'authenticated', 'SELECT');
345
436
 
346
437
  if (anonRead) {
347
438
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
@@ -465,7 +556,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
465
556
  // --- writes --------------------------------------------------------------------------
466
557
  for (const [command, action] of Object.entries(DML)) {
467
558
  if (action === 'read') continue;
468
- if (!granted(contract, 'authenticated', command)) continue;
559
+ if (!effective(contract, 'authenticated', command)) continue;
469
560
 
470
561
  const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
471
562
  ?? policiesFor(contract, 'authenticated', command)[0];
@@ -535,7 +626,15 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
535
626
  notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
536
627
  }
537
628
 
538
- return { abilities, notes, unmappedRoles, privileges: deriveExtraPrivileges(contract) };
629
+ // The beyond-CRUD privileges plus any live CRUD grant no policy covers. Both are "grants
630
+ // can() cannot say", for the same reason: `can()` decides a grant AND a policy together, and
631
+ // these have no policy to decide.
632
+ const privileges = deriveExtraPrivileges(contract);
633
+ for (const [role, privs] of Object.entries(deadGrants)) {
634
+ privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
635
+ }
636
+
637
+ return { abilities, notes, unmappedRoles, privileges };
539
638
  }
540
639
 
541
640
  /** An object key, quoted only when the role name is not a bare JS identifier. */
@@ -46,3 +46,64 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
46
46
  }
47
47
  return gaps;
48
48
  }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Naked DML grants — the safety that used to live in the parser.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * A CRUD grant declared without the policy that gates it.
56
+ *
57
+ * `privileges` used to REFUSE the DML four at model-definition time, so that a grant could never
58
+ * be declared without its policy. That throw was removed because the invariant was vacuous for
59
+ * adoption: a live table can hold `GRANT SELECT TO admin` with no policy covering admin, and a
60
+ * language that cannot spell it makes such a database a permanent MISMATCH against
61
+ * `db:fingerprint` — so adopting one required mutating it first. Expressibility is total over the
62
+ * fingerprint's domain; the opinion belongs here instead.
63
+ *
64
+ * Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
65
+ * The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
66
+ * table-wide privilege. That case cannot arise from models: `compileTableContract` emits
67
+ * `rls: { enabled: true }` UNCONDITIONALLY for every modeled table (authz-compile.ts:558). So a
68
+ * declared naked grant is always dead-on-arrival and only becomes live if someone later disables
69
+ * RLS on that table — a real risk, but a future one, and failing CI over a faithful rendering of
70
+ * a database the adopter is trying to adopt would make `db:pull` unusable on exactly the schemas
71
+ * it exists for.
72
+ */
73
+ export interface NakedGrant {
74
+ schema: string;
75
+ table: string;
76
+ role: string;
77
+ privileges: string[];
78
+ message: string;
79
+ }
80
+
81
+ const DML = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
82
+
83
+ /**
84
+ * Grants declared in `privileges` that carry no policy — reported for hand-authored and pulled
85
+ * models alike, because the shape is equally wrong to author and equally true to find.
86
+ */
87
+ export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[] {
88
+ const out: NakedGrant[] = [];
89
+ for (const m of models) {
90
+ for (const role of Object.keys(m.privileges ?? {}).sort()) {
91
+ const dml = (m.privileges[role] ?? []).filter((p) => DML.has(p)).sort();
92
+ if (!dml.length) continue;
93
+ out.push({
94
+ schema: m.schema,
95
+ table: m.table,
96
+ role,
97
+ privileges: dml,
98
+ message:
99
+ `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
100
+ `It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
101
+ `table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
102
+ `db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
103
+ `(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
104
+ `which decides the grant and the policy together.`,
105
+ });
106
+ }
107
+ }
108
+ return out;
109
+ }
@@ -41,9 +41,11 @@ import { loadModels } from './db-generate.js';
41
41
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
42
42
  import type { SequenceDescriptor } from '@everystack/model';
43
43
  import type { SourceObject } from '../derived-source.js';
44
- import { findReadAuthzGaps } from '../authz-lint.js';
44
+ import { findReadAuthzGaps, findNakedGrants } from '../authz-lint.js';
45
45
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline.js';
46
- import { findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings } from '../derived-lint.js';
46
+ import {
47
+ findDerivedReadGaps, findSecdefExecuteGaps, findMatviewSnapshotWarnings, findPublicExecutableSecdef,
48
+ } from '../derived-lint.js';
47
49
  import { step, success, fail, info, warn } from '../output.js';
48
50
 
49
51
  // The role derivation moved to the shared builder (db-build) with brick
@@ -160,6 +162,13 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
160
162
  }
161
163
  }
162
164
 
165
+ // A CRUD grant declared with no policy beside it. WARN, never fatal: this is what a faithful
166
+ // db:pull of a brownfield database looks like, and failing CI over it would make the pull
167
+ // unusable on the schemas it exists for. See findNakedGrants for why it is not an error.
168
+ for (const naked of findNakedGrants(input.models)) {
169
+ findings.push({ level: 'warn', area: 'authz', message: naked.message });
170
+ }
171
+
163
172
  // The adoption baseline, ARTIFACT ONLY.
164
173
  //
165
174
  // This check cannot see the threat and must not pretend to. The foreign grantees it
@@ -209,7 +218,7 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
209
218
  for (const gap of derivedGaps) {
210
219
  findings.push({ level: 'fail', area: 'authz', message: gap.message });
211
220
  }
212
- for (const warning of findMatviewSnapshotWarnings(input.derived)) {
221
+ for (const warning of [...findMatviewSnapshotWarnings(input.derived), ...findPublicExecutableSecdef(input.derived)]) {
213
222
  findings.push({ level: 'warn', area: 'authz', message: warning.message });
214
223
  }
215
224
  if (derivedGaps.length === 0) {
@@ -19,7 +19,7 @@
19
19
 
20
20
  import type {
21
21
  ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
22
- FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
22
+ FunctionDescriptor, SqlDescriptor, TriggerSpec, TriggerDescriptor, Ability, SetofReturn, DependsOnRef,
23
23
  } from '@everystack/model';
24
24
  import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
25
25
  import { isPlainGrantAttachment } from './derived-grants.js';
@@ -51,9 +51,21 @@ function refName(ref: SetofReturn['setof']): string {
51
51
  // Grants — the same audience semantics as tables
52
52
  // ---------------------------------------------------------------------------
53
53
 
54
- /** Relation read grants: bare read → anon + authenticated (the table precedent);
55
- * `{ role }` narrows; `{ columns }` scopes the SELECT to a column list. */
56
- function relationGrants(target: string, abilities: readonly Ability[]): Attachment[] {
54
+ /**
55
+ * Relation read grants: bare read anon + authenticated (the table precedent);
56
+ * `{ role }` narrows; `{ columns }` scopes the SELECT to a column list.
57
+ *
58
+ * `privileges` is the second source — grants that EXIST, recorded by `db:pull` rather than
59
+ * chosen. It is what lets a brownfield view be rendered at all: the write grants a blanket
60
+ * `GRANT ALL ON ALL TABLES IN SCHEMA` left behind, and a `SELECT` to `PUBLIC`, are not
61
+ * expressible as read abilities, and emitting the object WITHOUT them would make the next
62
+ * generate plan a REVOKE of each one.
63
+ */
64
+ function relationGrants(
65
+ target: string,
66
+ abilities: readonly Ability[],
67
+ privileges: Readonly<Record<string, string[]>> = {},
68
+ ): Attachment[] {
57
69
  const out: Attachment[] = [];
58
70
  for (const a of abilities) {
59
71
  const roles = a.condition.role ? a.condition.role : 'anon, authenticated';
@@ -62,17 +74,46 @@ function relationGrants(target: string, abilities: readonly Ability[]): Attachme
62
74
  : '';
63
75
  out.push({ kind: 'grant', sql: `GRANT SELECT${cols} ON ${target} TO ${roles}` });
64
76
  }
77
+ // Grantee-sorted, privileges in the declared order the model already normalized, so a
78
+ // re-render of an unchanged database is byte-stable.
79
+ for (const grantee of Object.keys(privileges).sort()) {
80
+ out.push({ kind: 'grant', sql: `GRANT ${privileges[grantee].join(', ')} ON ${target} TO ${grantee}` });
81
+ }
65
82
  return out;
66
83
  }
67
84
 
68
- /** Function authz: `REVOKE ALL … FROM PUBLIC` UNCONDITIONALLY (PostgreSQL grants EXECUTE
69
- * to PUBLIC by default the silent-open inverse of the RLS trap), then the declared
70
- * `can('execute', { role })` grants. */
71
- function functionGrants(signature: string, abilities: readonly Ability[]): Attachment[] {
72
- const out: Attachment[] = [{ kind: 'grant', sql: `REVOKE ALL ON FUNCTION ${signature} FROM PUBLIC` }];
85
+ /**
86
+ * Function authz: `REVOKE ALL FROM PUBLIC` (PostgreSQL grants EXECUTE to PUBLIC by
87
+ * default the silent-open inverse of the RLS trap), then the declared grants.
88
+ *
89
+ * Two sources, and the distinction is the whole design: `abilities` are the INTENDED
90
+ * audience (`can('execute', { role })`), `privileges` are grants that EXIST, recorded by
91
+ * `db:pull` without claiming intent.
92
+ *
93
+ * **A declared `PUBLIC` privilege suppresses the revoke**, and that is not a loophole. The
94
+ * revoke is unconditional precisely because nobody could SAY "PUBLIC holds EXECUTE here";
95
+ * once a descriptor says it, revoking anyway would make a faithful pull of a live function
96
+ * plan a REVOKE the adopter never asked for — turning adoption into mutation. Declaring it
97
+ * is still a finding, raised where findings belong: db:check WARNs on a PUBLIC-executable
98
+ * SECURITY DEFINER function.
99
+ */
100
+ function functionGrants(
101
+ signature: string,
102
+ abilities: readonly Ability[],
103
+ privileges: Readonly<Record<string, string[]>> = {},
104
+ ): Attachment[] {
105
+ const declaresPublic = Object.keys(privileges).some((g) => g.toUpperCase() === 'PUBLIC');
106
+ const out: Attachment[] = declaresPublic
107
+ ? []
108
+ : [{ kind: 'grant', sql: `REVOKE ALL ON FUNCTION ${signature} FROM PUBLIC` }];
73
109
  for (const a of abilities) {
74
110
  out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${a.condition.role}` });
75
111
  }
112
+ // Sorted so a re-render of an unchanged database is byte-stable — object key order is
113
+ // insertion order, and the introspection's row order is not a contract.
114
+ for (const grantee of Object.keys(privileges).sort()) {
115
+ out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${grantee}` });
116
+ }
76
117
  return out;
77
118
  }
78
119
 
@@ -98,7 +139,7 @@ function renderView(v: ViewDescriptor): { sql: string; attachments: Attachment[]
98
139
  sql: `CREATE VIEW ${target}${invoker} AS\n${v.as}`,
99
140
  attachments: [
100
141
  ...commentAttachment('VIEW', target, v.comment),
101
- ...relationGrants(target, v.abilities),
142
+ ...relationGrants(target, v.abilities, v.privileges ?? {}),
102
143
  ],
103
144
  };
104
145
  }
@@ -128,7 +169,7 @@ function renderMaterializedView(mv: MaterializedViewDescriptor): { sql: string;
128
169
  attachments: [
129
170
  ...indexes,
130
171
  ...commentAttachment('MATERIALIZED VIEW', target, mv.comment),
131
- ...relationGrants(target, mv.abilities),
172
+ ...relationGrants(target, mv.abilities, mv.privileges ?? {}),
132
173
  ],
133
174
  };
134
175
  }
@@ -166,7 +207,7 @@ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Att
166
207
  `AS ${tag}\n${fn.body}\n${tag}`,
167
208
  attachments: [
168
209
  ...commentAttachment('FUNCTION', signature, fn.comment),
169
- ...functionGrants(signature, fn.abilities),
210
+ ...functionGrants(signature, fn.abilities, fn.privileges ?? {}),
170
211
  ],
171
212
  };
172
213
  }
@@ -207,9 +248,22 @@ interface Node {
207
248
  function derivedIdentity(d: DerivedDescriptor): string {
208
249
  const { schema, name } = parseQualified(d.name);
209
250
  if (d.kind === 'function') return functionIdentity(schema, name, d.args.map((a) => a.type));
251
+ // A trigger's name is unique only WITHIN its table — two tables may both carry
252
+ // `touch_updated_at` — so its identity is the owner's, plus the name.
253
+ if (d.kind === 'trigger') {
254
+ const o = triggerOwnerOf(d);
255
+ return `${o.schema}.${o.name}.${d.name}`;
256
+ }
210
257
  return `${schema}.${name}`;
211
258
  }
212
259
 
260
+ /** Where a standalone binding rides: a model is state (no derived identity), a view is not. */
261
+ function triggerOwnerOf(d: TriggerDescriptor): { schema: string; name: string; ownerIdentity: string | null } {
262
+ if ('table' in d.on) return { schema: d.on.schema, name: d.on.table, ownerIdentity: null };
263
+ const q = parseQualified(d.on.name);
264
+ return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}` };
265
+ }
266
+
213
267
  /** What a bad ref IS, for the gate's message — null if the value is a usable object. */
214
268
  function badRef(r: unknown): string | null {
215
269
  if (r === undefined) return 'undefined';
@@ -233,6 +287,13 @@ function rejectBadRefs(models: readonly ModelDescriptor[], derived: readonly Der
233
287
  hole('compileDerived', 'models', models);
234
288
  hole('compileDerived', 'derived', derived);
235
289
  for (const d of derived) {
290
+ // A trigger has no dependsOn — its two deps come free (the target, the execute
291
+ // function) — but its `on` is a ref like any other and a stale import must be loud.
292
+ if (d.kind === 'trigger') {
293
+ hole(`trigger '${d.name}'`, 'on', [d.on], false);
294
+ hole(`trigger '${d.name}'`, 'execute', [d.execute], false);
295
+ continue;
296
+ }
236
297
  hole(`${d.kind} '${d.name}'`, 'dependsOn', d.dependsOn ?? []);
237
298
  if (d.kind === 'function' && typeof d.returns !== 'string') {
238
299
  hole(`function '${d.name}'`, 'returns.setof', [d.returns.setof], false);
@@ -348,6 +409,14 @@ export function compileDerived(models: readonly ModelDescriptor[], derived: read
348
409
  const q = parseQualified(v.name);
349
410
  return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}`, triggers: v.triggers ?? [] };
350
411
  }),
412
+ // Standalone bindings (defineTrigger) — the same unit, reached from the descriptor
413
+ // rather than through its target. Both spellings compile to an identical node, which
414
+ // is the point: the deprecated attached form keeps working while `db:pull` renders the
415
+ // standalone one (a binding on a model would make the generated module import the
416
+ // compute layer, which is a fatal cycle rather than a style preference).
417
+ ...derived
418
+ .filter((d): d is TriggerDescriptor => d.kind === 'trigger')
419
+ .map((d) => ({ ...triggerOwnerOf(d), triggers: [d as TriggerSpec] })),
351
420
  ];
352
421
  for (const owner of triggerOwners) {
353
422
  for (const t of owner.triggers) {
@@ -38,7 +38,13 @@ export interface ParsedGrants {
38
38
  // GRANT SELECT[ ("c1", "c2")] ON <target> TO r1[, r2…]
39
39
  // GRANT EXECUTE ON FUNCTION <sig> TO r
40
40
  // REVOKE ALL ON FUNCTION <sig> FROM PUBLIC
41
- const GRANT_RE = /^GRANT\s+([A-Z]+)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
41
+ // The privilege slot is a LIST — `GRANT SELECT, INSERT ON x TO y` is one statement, and
42
+ // PostgreSQL's own grammar says so. It used to match a single word, so a multi-privilege
43
+ // grant parsed as no grant at all: the declared contract came back empty and the very next
44
+ // check planned a REVOKE of every privilege the object had just been given. That stayed
45
+ // latent only because the compiler happened to emit one privilege per statement; recording
46
+ // a relation's live ACL (which is naturally multi-privilege) walked straight into it.
47
+ const GRANT_RE = /^GRANT\s+([A-Z]+(?:\s*,\s*[A-Z]+)*)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
42
48
  const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
43
49
 
44
50
  /**
@@ -72,14 +78,15 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
72
78
  }
73
79
  const grant = a.sql.match(GRANT_RE);
74
80
  if (!grant) continue;
75
- const [, privilege, columns, onTarget, roleList] = grant;
81
+ const [, privilegeList, columns, onTarget, roleList] = grant;
76
82
  if (columns) {
77
83
  hasColumnGrants = true;
78
84
  continue; // attacl territory — declared, applied at create, not drift-checked yet
79
85
  }
80
86
  target ??= onTarget.trim();
87
+ const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
81
88
  for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
82
- (grants[role] ??= new Set()).add(privilege.toUpperCase());
89
+ for (const privilege of privileges) (grants[role] ??= new Set()).add(privilege);
83
90
  }
84
91
  }
85
92
 
@@ -45,6 +45,19 @@ export interface LiveObject {
45
45
  rows?: number;
46
46
  /** Trigger only: the (bare or schema-qualified) relation it rides — `DROP TRIGGER … ON` needs it. */
47
47
  table?: string;
48
+ /** Trigger only: the owner's full `schema.table`, for joining to a model or a derived view. */
49
+ ownerIdentity?: string;
50
+ /** Trigger only (R2 pull rendering): the structured binding `defineTrigger` is rendered from.
51
+ * Absent when the introspection predates R2 — absent means "not measured", never "no WHEN". */
52
+ trg?: {
53
+ shape: TriggerShape;
54
+ /** `schema.name` of the trigger function. Trigger functions take no arguments. */
55
+ functionIdentity: string;
56
+ /** `UPDATE OF (cols)` in declaration order. */
57
+ updateOf?: string[];
58
+ /** The WHEN predicate as the catalog deparses it. */
59
+ when?: string;
60
+ };
48
61
  /** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
49
62
  * Absent on triggers (they take no grants) and when the introspection predates B4. */
50
63
  grants?: Record<string, string[]>;
@@ -198,13 +211,20 @@ ORDER BY n.nspname, t.relname, idx.indexrelid;
198
211
  * lookups by MANAGED derived identities, so table-referenced edges never join it.
199
212
  * System-schema referenced sides are excluded on both branches: a view reading
200
213
  * pg_catalog is not an edge anyone declares.
214
+ *
215
+ * `::text` on the first branch's name columns is the same load-bearing cast as
216
+ * DERIVED_GRANTS_SQL: later branches put `proname(argtypes)` on both the dependent and
217
+ * the referenced side, and a UNION types each column from the FIRST branch, so a bare
218
+ * `relname` (`name`, 63 bytes) would truncate a long function signature. The edge would
219
+ * then name an identity that joins nothing, and the edge is silently dropped — a wrong
220
+ * rebuild order, and a `dependsOn` the pull never writes.
201
221
  */
202
222
  export const DERIVED_DEPENDS_SQL = `
203
223
  SELECT DISTINCT
204
- dn.nspname AS dependent_schema,
205
- dc.relname AS dependent_name,
206
- rn.nspname AS referenced_schema,
207
- rc.relname AS referenced_name
224
+ dn.nspname::text AS dependent_schema,
225
+ dc.relname::text AS dependent_name,
226
+ rn.nspname::text AS referenced_schema,
227
+ rc.relname::text AS referenced_name
208
228
  FROM pg_depend d
209
229
  JOIN pg_rewrite rw ON rw.oid = d.objid
210
230
  JOIN pg_class dc ON dc.oid = rw.ev_class
@@ -282,13 +302,22 @@ WHERE d.classid = 'pg_proc'::regclass
282
302
  * `LEFT JOIN pg_roles` + COALESCE('PUBLIC') keeps a dropped-role ACL from failing the
283
303
  * read (the table path's offline-safety). `kind` disambiguates a relation and a
284
304
  * function sharing a name.
305
+ *
306
+ * `::text` on the name-typed columns is load-bearing, not tidiness. `relname` is
307
+ * PostgreSQL's `name` type (63 bytes) and a UNION takes each column's type from the
308
+ * FIRST branch, so without the cast the function branch's `proname(argtypes)` text is
309
+ * coerced DOWN to `name` and silently truncated at 63. Every consumer keys on that
310
+ * identity, so a function with ~5 ordinary parameters lost its ACL outright: db:pull
311
+ * rendered no `can('execute')`, and diffObjectGrants — which unions the DECLARED and LIVE
312
+ * grantees — never saw the live grantee, so no REVOKE was planned and reconcile reported
313
+ * converged while the grant was still live.
285
314
  */
286
315
  export const DERIVED_GRANTS_SQL = `
287
316
  SELECT
288
- n.nspname AS schema,
289
- c.relname AS name,
290
- 'r' AS kind,
291
- COALESCE(r.rolname, 'PUBLIC') AS grantee,
317
+ n.nspname::text AS schema,
318
+ c.relname::text AS name,
319
+ 'r'::text AS kind,
320
+ COALESCE(r.rolname, 'PUBLIC')::text AS grantee,
292
321
  array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
293
322
  FROM pg_class c
294
323
  JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -331,17 +360,83 @@ export const PROVENANCE_SQL = `
331
360
  SELECT identity, src_hash, def_hash, to_jsonb(p) AS extra FROM everystack.derived_provenance p;
332
361
  `.trim();
333
362
 
334
- /** User triggers (never internal/constraint machinery) with their canonical definition —
335
- * the live side of the trigger reconcile. Compound identity: schema.table.name. */
363
+ /**
364
+ * The bits PostgreSQL packs into `pg_trigger.tgtype`
365
+ * (src/include/catalog/pg_trigger.h). Timing, event set and level all share one mask.
366
+ */
367
+ export const TRIGGER_TYPE_BITS = {
368
+ row: 1, before: 2, insert: 4, delete: 8, update: 16, truncate: 32, instead: 64,
369
+ } as const;
370
+
371
+ /** The canonical event order — the model's own (`EVENT_ORDER` in model/src/derived.ts).
372
+ * Ordering by the MASK instead would render `update, insert` for one trigger and
373
+ * `insert, update` for another, and the two would hash differently while being equal. */
374
+ const TRIGGER_EVENT_ORDER = ['insert', 'update', 'delete', 'truncate'] as const;
375
+
376
+ export interface TriggerShape {
377
+ timing: 'before' | 'after' | 'insteadOf';
378
+ events: ('insert' | 'update' | 'delete' | 'truncate')[];
379
+ forEach: 'row' | 'statement';
380
+ }
381
+
382
+ /**
383
+ * `tgtype` → the shape `trigger()` declares. The bitmask is the ONLY structured source
384
+ * for a trigger's timing/events/level; `pg_get_triggerdef` is prose, and parsing prose
385
+ * back into structure is how a renderer starts disagreeing with the catalog.
386
+ *
387
+ * `instead` is checked before `before` because they are different fields sharing a mask:
388
+ * an INSTEAD OF trigger is never BEFORE, and reading the bits in the other order would
389
+ * silently render a view's INSTEAD OF trigger as a table-shaped BEFORE one.
390
+ */
391
+ export function parseTriggerType(tgtype: number): TriggerShape {
392
+ const has = (bit: number): boolean => (tgtype & bit) !== 0;
393
+ const events = TRIGGER_EVENT_ORDER.filter((e) => has(TRIGGER_TYPE_BITS[e]));
394
+ if (events.length === 0) {
395
+ throw new Error(`trigger tgtype ${tgtype} has no event bits — PostgreSQL never writes that, so the read is wrong, not the database.`);
396
+ }
397
+ return {
398
+ timing: has(TRIGGER_TYPE_BITS.instead) ? 'insteadOf' : has(TRIGGER_TYPE_BITS.before) ? 'before' : 'after',
399
+ events: [...events],
400
+ forEach: has(TRIGGER_TYPE_BITS.row) ? 'row' : 'statement',
401
+ };
402
+ }
403
+
404
+ /**
405
+ * User triggers (never internal/constraint machinery) with their canonical definition —
406
+ * the live side of the trigger reconcile. Compound identity: schema.table.name.
407
+ *
408
+ * The STRUCTURED half (`tgtype`, `update_of`, `when_expr`, the function identity) is what
409
+ * a `trigger()` binding is rendered from; the `definition` text stays for the def hash.
410
+ *
411
+ * **`when_expr` is scraped out of `pg_get_triggerdef`, and that is not laziness.**
412
+ * `pg_get_expr(t.tgqual, t.tgrelid)` — the obvious call — FAILS OUTRIGHT on any trigger
413
+ * carrying a WHEN clause that mentions both OLD and NEW:
414
+ *
415
+ * ERROR: expression contains variables of more than one relation
416
+ *
417
+ * A WHEN over two pseudo-relations is the common case (`WHEN (OLD.* IS DISTINCT FROM
418
+ * NEW.*)`), so the direct read is not merely lossy — it errors, and it errors on the
419
+ * whole query, taking every other trigger's row with it. `pg_get_triggerdef` renders the
420
+ * same expression correctly because it knows the OLD/NEW aliases. Verified against a live
421
+ * brownfield database, 2026-08-06.
422
+ */
336
423
  export const DERIVED_TRIGGERS_SQL = `
337
424
  SELECT
338
425
  n.nspname AS schema,
339
426
  c.relname AS "table",
340
427
  t.tgname AS name,
341
- pg_get_triggerdef(t.oid) AS definition
428
+ pg_get_triggerdef(t.oid) AS definition,
429
+ t.tgtype::int AS tgtype,
430
+ fn.nspname::text || '.' || p.proname::text AS function_identity,
431
+ (SELECT array_agg(a.attname::text ORDER BY x.ord)
432
+ FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS x(attnum, ord)
433
+ JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = x.attnum) AS update_of,
434
+ substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr
342
435
  FROM pg_trigger t
343
436
  JOIN pg_class c ON c.oid = t.tgrelid
344
437
  JOIN pg_namespace n ON n.oid = c.relnamespace
438
+ JOIN pg_proc p ON p.oid = t.tgfoid
439
+ JOIN pg_namespace fn ON fn.oid = p.pronamespace
345
440
  WHERE NOT t.tgisinternal
346
441
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
347
442
  AND n.nspname NOT LIKE 'pg_%'
@@ -409,6 +504,16 @@ export interface TriggerRow {
409
504
  table: string;
410
505
  name: string;
411
506
  definition: unknown;
507
+ /** The structured half — all optional, because a pre-R2 introspection has none of it
508
+ * and an absent field must read as "not measured", never as "no WHEN clause". */
509
+ tgtype?: unknown;
510
+ /** `schema.name` of the trigger function. Trigger functions take no arguments, so the
511
+ * bare name identifies one — no signature needed, unlike every other function read. */
512
+ function_identity?: unknown;
513
+ /** `UPDATE OF (cols)`, column names in declaration order. NULL when unqualified. */
514
+ update_of?: unknown;
515
+ /** The WHEN predicate, scraped from pg_get_triggerdef — see DERIVED_TRIGGERS_SQL. */
516
+ when_expr?: unknown;
412
517
  }
413
518
 
414
519
  export interface GrantAclRow {
@@ -547,11 +652,21 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
547
652
  const ownerIdentity = `${row.schema}.${row.table}`;
548
653
  const identity = `${ownerIdentity}.${row.name}`;
549
654
  const definition = String(row.definition ?? '');
655
+ const updateOf = pgTextArray(row.update_of);
550
656
  objects.push({
551
657
  kind: 'trigger', schema: row.schema, name: row.name, identity,
552
658
  definition, indexes: [],
553
659
  defHash: computeDefHash(definition, []),
554
660
  table: row.schema === 'public' ? row.table : ownerIdentity,
661
+ ownerIdentity,
662
+ ...(row.tgtype != null && row.function_identity != null ? {
663
+ trg: {
664
+ shape: parseTriggerType(Number(row.tgtype)),
665
+ functionIdentity: String(row.function_identity),
666
+ ...(updateOf.length ? { updateOf } : {}),
667
+ ...(row.when_expr != null ? { when: String(row.when_expr) } : {}),
668
+ },
669
+ } : {}),
555
670
  });
556
671
  if (relationIdentities.has(ownerIdentity)) {
557
672
  triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
@@ -132,11 +132,32 @@ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): Deri
132
132
  * is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
133
133
  * revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
134
134
  * review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
135
+ *
136
+ * TRIGGER FUNCTIONS ARE EXEMPT, and the exemption is structural rather than a courtesy.
137
+ * A trigger function has no caller role to declare: PostgreSQL performs no EXECUTE check
138
+ * on the invoking role when a trigger fires, so its audience is the table event, not a
139
+ * grantee. Live ones therefore carry no EXECUTE grant, and the gate could only be
140
+ * satisfied by declaring one — which compiles to a GRANT the database never had. That is
141
+ * the same widening this module's read/manage siblings exist to prevent, arriving on the
142
+ * function branch, and it made a faithful pull of a SECDEF trigger function unshippable:
143
+ * the adopter's only way past db:check was to widen their own database.
144
+ *
145
+ * The discriminator is `returns === 'trigger'` — the return type, never the name. It is
146
+ * the same fact `trigger()` already relies on (model/src/derived.ts, which refuses an
147
+ * `execute:` whose function does not return `trigger`), so the two agree by construction.
148
+ *
149
+ * A RECORDED grant counts as a declared caller too. `privileges: { PUBLIC: ['EXECUTE'] }`
150
+ * is `db:pull` saying "the database grants this" — the audience IS declared and reviewable,
151
+ * it simply was not chosen by the author. The gap gate is about undeclared audience, so it
152
+ * stops firing; the objection moves to `findPublicExecutableSecdef` below, which is a
153
+ * warning. Abilities are opinion, privileges are fact, and the linter holds the opinions.
135
154
  */
136
155
  export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
137
156
  const gaps: DerivedAuthzGap[] = [];
138
157
  for (const d of derived) {
139
158
  if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
159
+ if (d.returns === 'trigger') continue;
160
+ if (Object.keys(d.privileges ?? {}).length > 0) continue;
140
161
  const identity = identityOf(d);
141
162
  gaps.push({
142
163
  identity,
@@ -148,6 +169,38 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
148
169
  return gaps;
149
170
  }
150
171
 
172
+ // ---------------------------------------------------------------------------
173
+ // Gate: SECURITY DEFINER executable by PUBLIC (db:check warn)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Privileged code any role can call. This is where the objection to a PUBLIC EXECUTE grant
178
+ * lives now that the grant is spellable — a WARN, not a failure, because on an adopted
179
+ * database it is a faithful report of what is already true, and failing CI on a faithful
180
+ * pull would make `db:pull` unusable on exactly the schemas it exists for. (Same call, and
181
+ * the same reasoning, as `findNakedGrants` on the table side.)
182
+ *
183
+ * Only DEFINER functions: an invoker function executes with the caller's own rights, so
184
+ * PUBLIC EXECUTE on one grants no authority the caller did not already have.
185
+ */
186
+ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
187
+ const warnings: DerivedAuthzGap[] = [];
188
+ for (const d of derived) {
189
+ if (d.kind !== 'function' || d.security !== 'definer') continue;
190
+ if (d.returns === 'trigger') continue;
191
+ if (!Object.keys(d.privileges ?? {}).some((g) => g.toUpperCase() === 'PUBLIC')) continue;
192
+ const identity = identityOf(d);
193
+ warnings.push({
194
+ identity,
195
+ message:
196
+ `${identity} is SECURITY DEFINER and executable by PUBLIC — it runs with its owner's rights and every ` +
197
+ `role can call it, including anon. This is recorded as live fact, not a choice: confirm the body is safe ` +
198
+ `for an untrusted caller, then narrow it to can('execute', { role: '…' }) and drop the PUBLIC privilege.`,
199
+ });
200
+ }
201
+ return warnings;
202
+ }
203
+
151
204
  // ---------------------------------------------------------------------------
152
205
  // Gate: matview snapshot over row-scoped sources (db:check warn)
153
206
  // ---------------------------------------------------------------------------
@@ -77,26 +77,45 @@ function tsTemplate(body: string): string {
77
77
  return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
78
78
  }
79
79
 
80
+ /** An object key, bare when it is a valid identifier and quoted when it is not — a role
81
+ * name is a PostgreSQL identifier and may hold characters JavaScript will not take bare. */
82
+ function tsKey(s: string): string {
83
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : tsString(s);
84
+ }
85
+
80
86
  function tsString(s: string): string {
81
87
  return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
82
88
  }
83
89
 
84
- /** The relation grant shapes `abilities` can say. Null = inexpressible (caller FIXMEs). */
85
- function relationAbilities(grants: Record<string, string[]>): string[] | null {
86
- const roles = Object.keys(grants);
87
- for (const role of roles) {
88
- if (role.toUpperCase() === 'PUBLIC') return null;
89
- if (grants[role].some((p) => p !== 'SELECT')) return null;
90
+ /**
91
+ * Split a relation's live ACL into what `abilities` can SAY and what must be RECORDED.
92
+ *
93
+ * A grantee whose privileges are exactly `SELECT`, and which is a named app role, is an
94
+ * intended read audience — `can('read')`. Everything else is fact without intent:
95
+ * `PUBLIC` (not a role the ability vocabulary addresses) and any privilege beyond SELECT
96
+ * (write grants, REFERENCES/TRIGGER/TRUNCATE). Those become `privileges`.
97
+ *
98
+ * Splitting rather than rejecting is the whole of R1: previously ANY inexpressible grant
99
+ * discarded the entire object, body included.
100
+ */
101
+ function splitRelationGrants(
102
+ grants: Record<string, string[]>,
103
+ ): { abilities: string[]; privileges: Record<string, string[]> } {
104
+ const readRoles = new Set<string>();
105
+ const privileges: Record<string, string[]> = {};
106
+ for (const [grantee, privs] of Object.entries(grants)) {
107
+ const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
108
+ if (selectOnly && grantee.toUpperCase() !== 'PUBLIC') readRoles.add(grantee);
109
+ else privileges[grantee] = [...privs].sort();
90
110
  }
91
- const set = new Set(roles);
92
- const out: string[] = [];
93
- if (set.has('anon') && set.has('authenticated')) {
94
- out.push(`can('read')`);
95
- set.delete('anon');
96
- set.delete('authenticated');
111
+ const abilities: string[] = [];
112
+ if (readRoles.has('anon') && readRoles.has('authenticated')) {
113
+ abilities.push(`can('read')`);
114
+ readRoles.delete('anon');
115
+ readRoles.delete('authenticated');
97
116
  }
98
- for (const role of [...set].sort()) out.push(`can('read', { role: ${tsString(role)} })`);
99
- return out;
117
+ for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
118
+ return { abilities, privileges };
100
119
  }
101
120
 
102
121
  /**
@@ -202,6 +221,32 @@ function renderTableIndexBuilder(indexdef: string): string | null {
202
221
  return s;
203
222
  }
204
223
 
224
+ /**
225
+ * A collision-free variable for a trigger binding, chosen deterministically so a re-pull of
226
+ * an unchanged database is byte-stable. In order: the bare name, then `<name>Trigger`, then
227
+ * owner-qualified, then owner-qualified + `Trigger`. The last step cannot collide —
228
+ * a trigger name is unique within its table, so table + name is unique in the schema.
229
+ */
230
+ function uniqueVar(base: string, o: LiveObject, used: Set<string>): string {
231
+ const ownerName = (o.table ?? '').split('.').pop() ?? '';
232
+ for (const candidate of [
233
+ base,
234
+ `${base}Trigger`,
235
+ toCamelCase(`${ownerName}_${o.name}`),
236
+ toCamelCase(`${ownerName}_${o.name}_trigger`),
237
+ ]) {
238
+ if (!used.has(candidate)) {
239
+ used.add(candidate);
240
+ return candidate;
241
+ }
242
+ }
243
+ // Unreachable on a real catalog; a counter beats throwing on a name.
244
+ let i = 2;
245
+ while (used.has(`${base}Trigger${i}`)) i += 1;
246
+ used.add(`${base}Trigger${i}`);
247
+ return `${base}Trigger${i}`;
248
+ }
249
+
205
250
  export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
206
251
  const warnings: string[] = [];
207
252
  const lines: string[] = [];
@@ -227,10 +272,12 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
227
272
 
228
273
  // -- emission order: dependencies first (Kahn over derived→derived edges) ---
229
274
 
275
+ // Triggers are held out of the topological pass and emitted LAST (see the trigger block
276
+ // at the end). A binding needs BOTH its target and its execute function to already
277
+ // exist, and the catalog records no trigger→function edge, so file order — not the
278
+ // dependency sort — is what guarantees it.
230
279
  const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
231
- for (const t of catalog.objects.filter((o) => o.kind === 'trigger')) {
232
- warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
233
- }
280
+ const triggers = catalog.objects.filter((o) => o.kind === 'trigger');
234
281
  const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
235
282
  // Var names carry the schema for non-public objects — two schemas can share a bare name.
236
283
  // OVERLOADS share everything but their argument types, so a bare name would emit two
@@ -302,22 +349,34 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
302
349
  const { refs, fixmes } = depsFor(o.identity);
303
350
 
304
351
  if (o.kind === 'view' || o.kind === 'materialized view') {
305
- const abilities = relationAbilities(o.grants ?? {});
306
- if (abilities === null) {
307
- const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
352
+ // The live ACL splits in two: the part `can('read')` can SAY (plain SELECT to named
353
+ // app roles) becomes abilities, and everything else is RECORDED as `privileges`.
354
+ //
355
+ // This used to skip the object outright — definition and all — whenever any grant
356
+ // was inexpressible, which on a real brownfield schema is every relation it has:
357
+ // three roles hold the blanket DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/
358
+ // UPDATE that `GRANT ALL ON ALL TABLES IN SCHEMA` leaves behind, and PUBLIC holds
359
+ // SELECT on a few. The adopter got a FIXME instead of their view.
360
+ //
361
+ // Recording is also what makes rendering SAFE, and it is why the silent-REVOKE
362
+ // question that blocked this no longer needs an answer: emitting the view with only
363
+ // the abilities we can express would leave the write grants undeclared, so the next
364
+ // generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
365
+ // Declared == live means nothing is planned at all.
366
+ const { abilities, privileges } = splitRelationGrants(o.grants ?? {});
367
+ const writeGrants = Object.values(privileges).some((ps) =>
368
+ ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
369
+ );
370
+ if (writeGrants) {
308
371
  // A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
309
372
  // writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
310
373
  // SCHEMA public` in an old migration, which sweeps views up with the tables.
311
374
  // Naming that here saves the diagnosis; an adopter paid for it once already.
312
- const writeGrants = Object.values(o.grants ?? {}).some((ps) =>
313
- ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
375
+ const grantText = Object.entries(privileges).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
376
+ warnings.push(
377
+ `${o.identity}: write privileges (${grantText}) recorded as \`privileges\` — the object is declared and nothing will be revoked. ` +
378
+ 'Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first, then revoke and re-pull.',
314
379
  );
315
- const hint = writeGrants
316
- ? ' Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first.'
317
- : '';
318
- warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.${hint}`);
319
- lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
320
- continue;
321
380
  }
322
381
 
323
382
  // --matviews-as-tables (the gap-B consumer flip): the matview renders as a
@@ -359,7 +418,13 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
359
418
  lines.push(
360
419
  ...fixmes,
361
420
  `export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
362
- ` ${abilities.length ? `abilities: [${abilities.join(', ')}],` : 'private: true,'}`,
421
+ // Same split as the relation path: a model carries `privileges` too, so the
422
+ // inexpressible grants are recorded rather than dropped on this branch either.
423
+ ...(abilities.length ? [` abilities: [${abilities.join(', ')}],`]
424
+ : Object.keys(privileges).length ? [] : [' private: true,']),
425
+ ...(Object.keys(privileges).length
426
+ ? [` privileges: { ${Object.keys(privileges).sort().map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`).join(', ')} },`]
427
+ : []),
363
428
  ...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
364
429
  ' fields: {',
365
430
  ...fieldLines,
@@ -377,12 +442,21 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
377
442
  }
378
443
 
379
444
  const props: string[] = [];
445
+ const hasPrivileges = Object.keys(privileges).length > 0;
380
446
  if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
381
- if (abilities.length === 0) props.push('private: true,');
382
- else {
447
+ // `private: true` means "declared dark, no grants" — it contradicts a recorded
448
+ // grant, and the model refuses the pair. A relation whose only reach is recorded
449
+ // (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
450
+ if (abilities.length === 0 && !hasPrivileges) props.push('private: true,');
451
+ else if (abilities.length) {
383
452
  props.push(`abilities: [${abilities.join(', ')}],`);
384
453
  need('can');
385
454
  }
455
+ if (hasPrivileges) {
456
+ const entries = Object.keys(privileges).sort()
457
+ .map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`);
458
+ props.push(`privileges: { ${entries.join(', ')} },`);
459
+ }
386
460
  if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
387
461
  if (o.kind === 'materialized view') {
388
462
  const builders = o.indexes.map((def) => {
@@ -458,6 +532,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
458
532
  props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
459
533
  need('can');
460
534
  }
535
+ // A live PUBLIC EXECUTE is RECORDED, not converted into an ability. Rendering it as
536
+ // can('execute', { role: … }) would invent an audience the database never named, and
537
+ // dropping it silently would make the descriptor plan a REVOKE on adoption. Both are
538
+ // the adopter's database being changed to suit our vocabulary.
539
+ if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
461
540
  if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
462
541
  if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
463
542
  props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
@@ -465,8 +544,11 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
465
544
  need('sql');
466
545
  lines.push(
467
546
  ...fixmes,
468
- ...(publicExec
469
- ? [`// FIXME: ${o.identity} live grants EXECUTE to PUBLIC; descriptors ALWAYS revoke PUBLIC. Declare the intended callers (adopting this descriptor applies the revoke).`]
547
+ // Not a FIXME any more — `privileges: { PUBLIC: ['EXECUTE'] }` above says it, and
548
+ // adopting the descriptor no longer applies a revoke. It is still worth a look on a
549
+ // SECURITY DEFINER function, which is what db:check now WARNs about.
550
+ ...(publicExec && f.secdef
551
+ ? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
470
552
  : []),
471
553
  `export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
472
554
  ...props.map((p) => ` ${p}`),
@@ -476,6 +558,69 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
476
558
  names.push(varName);
477
559
  }
478
560
 
561
+ // -- trigger bindings, last -------------------------------------------------
562
+ //
563
+ // Every identifier the file has already exported. A trigger's variable must dodge all of
564
+ // them, not just other triggers.
565
+ const usedVars = new Set<string>([...names, ...sequenceNames, ...materializedTableNames]);
566
+ //
567
+ // One descriptor per BINDING, never per function. The two are many-to-one — on the
568
+ // reference schema 18 bindings share 11 functions, `_touch_updated_at()` alone serving
569
+ // five tables — so a renderer keyed on the function would silently emit 11 and lose the
570
+ // rest. The target may be a MODEL (imported) or a derived VIEW (declared above).
571
+ for (const o of [...triggers].sort((a, b) => a.identity.localeCompare(b.identity))) {
572
+ const owner = o.ownerIdentity ?? (o.table?.includes('.') ? o.table : `${o.schema}.${o.table ?? ''}`);
573
+ const modelVar = opts.knownTables.get(owner);
574
+ const targetVar = modelVar ?? varOf.get(owner);
575
+ if (!o.trg || !targetVar) {
576
+ warnings.push(
577
+ `${o.identity}: trigger not rendered — ${!o.trg
578
+ ? 'the introspection carried no structured binding (pre-R2 catalog read)'
579
+ : `its target '${owner}' is neither a known model nor a rendered view`}.`,
580
+ );
581
+ continue;
582
+ }
583
+ // The function is matched by identity. Trigger functions take no arguments, so the
584
+ // catalog's signature is always `()` — no overload can exist to disambiguate.
585
+ const fnVar = varOf.get(`${o.trg.functionIdentity}()`);
586
+ if (!fnVar) {
587
+ warnings.push(`${o.identity}: trigger not rendered — its function '${o.trg.functionIdentity}' is not in the rendered set.`);
588
+ continue;
589
+ }
590
+ // Registered only once the binding actually renders — an import emitted for a skipped
591
+ // trigger would be an unused name in generated code.
592
+ if (modelVar) modelRefs.add(owner);
593
+ // A trigger's name collides freely: with its OWN function (`update_users_tsvector` is
594
+ // both, on the reference schema) and with a same-named trigger on another table
595
+ // (trigger names are unique per table, not per schema). Either collision emits two
596
+ // `export const X` — the file does not compile, and `execute: X` would bind the
597
+ // trigger to itself. Suffix only on collision, so an uncontended schema renders
598
+ // byte-identically to a name-per-object scheme.
599
+ const varName = uniqueVar(
600
+ toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`),
601
+ o,
602
+ usedVars,
603
+ );
604
+ const props = [
605
+ `on: ${targetVar},`,
606
+ `timing: ${tsString(o.trg.shape.timing)},`,
607
+ `events: [${o.trg.shape.events.map(tsString).join(', ')}],`,
608
+ `forEach: ${tsString(o.trg.shape.forEach)},`,
609
+ ...(o.trg.updateOf?.length ? [`of: [${o.trg.updateOf.map(tsString).join(', ')}],`] : []),
610
+ ...(o.trg.when ? [`condition: sql\`${tsTemplate(o.trg.when)}\`,`] : []),
611
+ `execute: ${fnVar},`,
612
+ ];
613
+ if (o.trg.when) need('sql');
614
+ need('defineTrigger');
615
+ lines.push(
616
+ `export const ${varName} = defineTrigger(${tsString(o.name)}, {`,
617
+ ...props.map((p) => ` ${p}`),
618
+ '});',
619
+ '',
620
+ );
621
+ names.push(varName);
622
+ }
623
+
479
624
  return {
480
625
  block: lines.join('\n').trimEnd(),
481
626
  names,
@@ -25,12 +25,32 @@ function isFlag(arg: string): boolean {
25
25
  return arg.startsWith('-') && arg.length > 1;
26
26
  }
27
27
 
28
- /** Parse `--key value` / `-k value` pairs; a flag with no value becomes `'true'`. */
28
+ /** Parse `--key value`, `--key=value`, `-k value`; a flag with no value becomes `'true'`. */
29
29
  export function parseFlags(args: string[]): Record<string, string> {
30
30
  const flags: Record<string, string> = {};
31
31
  for (let i = 0; i < args.length; i++) {
32
32
  const arg = args[i];
33
33
  if (!isFlag(arg)) continue;
34
+
35
+ // `--key=value`. Split on the FIRST `=` only: the value is routinely a connection string
36
+ // whose own query carries more (`postgresql://h/db?sslmode=require`), and splitting on
37
+ // every `=` would truncate it to the host.
38
+ //
39
+ // This form used to be dropped ENTIRELY — the whole `--database-url=…` token parsed as a
40
+ // key with no value, so the flag was simply absent. For a venue flag that is not a parse
41
+ // error, it is a silent CHANGE OF TARGET: `db:plan --database-url=postgres://localhost/x`
42
+ // found no url source, fell through to the stage lane, and went to AWS. A consumer lost an
43
+ // afternoon to it against a local database with no AWS at all. On a destructive verb the
44
+ // same slip aims at the deployed stage instead of the local database.
45
+ const eq = arg.indexOf('=');
46
+ if (eq > 1) {
47
+ const rawKey = arg.slice(0, eq);
48
+ const key = rawKey.startsWith('--') ? rawKey.slice(2) : rawKey.slice(1);
49
+ // The empty string is a VALUE, not an absent one — same rule as `--set ''` below.
50
+ flags[key] = arg.slice(eq + 1);
51
+ continue;
52
+ }
53
+
34
54
  const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
35
55
  const next = args[i + 1];
36
56
  flags[key] = next !== undefined && !isFlag(next) ? args[++i] : 'true';