@everystack/cli 0.4.48 → 0.4.50

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.50",
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",
@@ -64,8 +64,10 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
64
64
  DELETE: 'delete',
65
65
  };
66
66
 
67
- /** The roles the compiler itself emits policies for; anything else is app-specific. */
68
- const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
67
+ /** The roles the compiler itself emits policies for; anything else is app-specific.
68
+ * Exported so the DERIVED renderer applies the identical rule — the views path once had
69
+ * its own idea of which grantees to render, and the two disagreed inside one pull. */
70
+ export const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
69
71
 
70
72
  /**
71
73
  * The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
@@ -75,7 +77,7 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
75
77
  * an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
76
78
  * would GOVERN it — turning a rendering decision into an access decision for every table.
77
79
  */
78
- const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
80
+ export const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
79
81
 
80
82
  /** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
81
83
  function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
@@ -232,6 +234,32 @@ function policiesFor(contract: TableContract, role: string, command: string): Po
232
234
  );
233
235
  }
234
236
 
237
+ /**
238
+ * Does this grant actually give the role rows?
239
+ *
240
+ * **The rule, in one line: an ability needs BOTH a grant and a covering permissive policy.**
241
+ *
242
+ * Postgres checks the grant and THEN the policy. Either half alone gives nothing, and the two
243
+ * failures look identical in a diff:
244
+ *
245
+ * - policy, no grant → dead code. Declaring it ADDs a grant. (handled in deriveAbilities)
246
+ * - grant, no policy → dead rows. Declaring it ADDs a policy. (this predicate)
247
+ *
248
+ * The second half was fixed for `admin` first, because that is where a consumer hit it. The
249
+ * round-trip gate (integration/authz-roundtrip) then found the SAME defect on the read branch —
250
+ * `authenticated` holding SELECT with no policy rendered `can('read')`, compiling to
251
+ * `CREATE POLICY … USING (true)` against a database that has no such policy. Hence one predicate,
252
+ * used at every site that turns a grant into an ability, rather than a fix per role.
253
+ *
254
+ * A restrictive-only policy does not count: RESTRICTIVE policies subtract, so with no permissive
255
+ * policy to pass first the role still sees nothing.
256
+ */
257
+ function effective(contract: TableContract, role: string, priv: string): boolean {
258
+ if (!granted(contract, role, priv)) return false;
259
+ if (!contract.rls.enabled) return true;
260
+ return policiesFor(contract, role, priv).some((p) => p.permissive);
261
+ }
262
+
235
263
  /** A sql`…` fragment literal, or null when the predicate is vacuous. */
236
264
  function sqlFragment(pred: string | null): string | null {
237
265
  if (!pred) return null;
@@ -303,10 +331,72 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
303
331
  const abilities: string[] = [];
304
332
  const notes: string[] = [];
305
333
  const table = contract.table;
334
+ /** Live CRUD grants no policy covers — declared as grants so the plan neither invents a
335
+ * policy (a widening) nor revokes a real privilege (a narrowing). */
336
+ const deadGrants: Record<string, string[]> = {};
306
337
 
307
338
  // --- 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' })`);
339
+ //
340
+ // The grant is only HALF the question. Postgres checks the grant and THEN the policy, so a
341
+ // role holding all four privileges with no policy covering it reads zero rows. Rendering
342
+ // `can('manage')` there compiles to a GRANT **plus** `CREATE POLICY … TO admin USING (true)`,
343
+ // handing admin a table the database currently withholds — a WIDENING emitted by the adoption
344
+ // path itself. That is the same trap the policy-without-grant branch below prevents, reached
345
+ // from the other side, and it shipped: three tables at one adopter, one of the three
346
+ // statements in their plan, refused by them.
347
+ //
348
+ // The grants are still declared — via `privileges` below — so the plan neither invents a
349
+ // policy nor revokes a real grant. Do NOT "simplify" this to dropping the grants: a grantee
350
+ // with rolbypassrls holds them live (our introspection checks ownership, not rolbypassrls),
351
+ // and they re-arm the day anyone runs ALTER TABLE … DISABLE ROW LEVEL SECURITY.
352
+ //
353
+ // No owner ambiguity: GRANTS_SQL excludes the table owner, so a role present in `grants` is a
354
+ // non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
355
+ const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
356
+ const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
357
+ const adminLive = adminGrants.filter((p) => effective(contract, 'admin', p));
358
+ const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
359
+
360
+ if (adminLive.length === CRUD.length) {
361
+ abilities.push(`can('manage', { role: 'admin' })`);
362
+ } else {
363
+ // PARTIAL. `manage` means all four, so it would be a lie — but the privileges that ARE
364
+ // policied are live authorization, and rendering nothing for them makes the plan DROP the
365
+ // policy and REVOKE the grant. That narrowing is how the round-trip gate caught this case:
366
+ // an admin table policied for SELECT only lost both halves on the way through.
367
+ for (const priv of adminLive) {
368
+ const pol = policiesFor(contract, 'admin', priv).find((p) => p.permissive);
369
+ const frag = sqlFragment(pol?.using ?? null);
370
+ const verb = DML[priv];
371
+ abilities.push(
372
+ frag ? `can('${verb}', { role: 'admin', sql: ${frag} })` : `can('${verb}', { role: 'admin' })`,
373
+ );
374
+ }
375
+ }
376
+
377
+ if (adminDead.length) {
378
+ deadGrants.admin = adminDead;
379
+ notes.push(
380
+ `admin holds ${adminDead.join(', ')} but no policy covers admin —`,
381
+ );
382
+ notes.push(
383
+ ` dead while RLS is on, unrestricted if RLS is ever disabled. Declared as a grant, not`,
384
+ );
385
+ notes.push(
386
+ ` an ability: an ability would ADD the policy the database does not have.`,
387
+ );
388
+ }
389
+
390
+ // The same accounting for the app roles. A grant they hold with no policy behind it is
391
+ // declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
392
+ // (so the plan does not CREATE the policy the database is missing).
393
+ for (const role of ['authenticated', 'anon']) {
394
+ const dead = CRUD.filter((p) => granted(contract, role, p) && !effective(contract, role, p));
395
+ if (!dead.length) continue;
396
+ deadGrants[role] = dead;
397
+ notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
398
+ notes.push(` the grant is live in the catalog and gives no rows; declared as a grant, not an ability.`);
399
+ }
310
400
 
311
401
  // --- policed but not granted at the table level ---------------------------------------
312
402
  //
@@ -340,8 +430,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
340
430
  }
341
431
 
342
432
  // --- reads ---------------------------------------------------------------------------
343
- const anonRead = granted(contract, 'anon', 'SELECT');
344
- const authedRead = granted(contract, 'authenticated', 'SELECT');
433
+ // `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
434
+ // which compiles to CREATE POLICY … USING (true) manufacturing a policy the database does
435
+ // not have. Same defect as the admin branch above; the round-trip gate found it here.
436
+ const anonRead = effective(contract, 'anon', 'SELECT');
437
+ const authedRead = effective(contract, 'authenticated', 'SELECT');
345
438
 
346
439
  if (anonRead) {
347
440
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
@@ -465,7 +558,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
465
558
  // --- writes --------------------------------------------------------------------------
466
559
  for (const [command, action] of Object.entries(DML)) {
467
560
  if (action === 'read') continue;
468
- if (!granted(contract, 'authenticated', command)) continue;
561
+ if (!effective(contract, 'authenticated', command)) continue;
469
562
 
470
563
  const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
471
564
  ?? policiesFor(contract, 'authenticated', command)[0];
@@ -535,7 +628,15 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
535
628
  notes.push(`no grants found — this table is internal (nothing reaches it through the data API).`);
536
629
  }
537
630
 
538
- return { abilities, notes, unmappedRoles, privileges: deriveExtraPrivileges(contract) };
631
+ // The beyond-CRUD privileges plus any live CRUD grant no policy covers. Both are "grants
632
+ // can() cannot say", for the same reason: `can()` decides a grant AND a policy together, and
633
+ // these have no policy to decide.
634
+ const privileges = deriveExtraPrivileges(contract);
635
+ for (const [role, privs] of Object.entries(deadGrants)) {
636
+ privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
637
+ }
638
+
639
+ return { abilities, notes, unmappedRoles, privileges };
539
640
  }
540
641
 
541
642
  /** 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) {
@@ -2,7 +2,7 @@
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
4
  * db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
5
- * [--derived-out <file.ts>] [--abilities public-read]
5
+ * [--derived-out <file.ts>] [--abilities live|public-read]
6
6
  *
7
7
  * `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
8
8
  * baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
@@ -358,7 +358,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
358
358
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
359
359
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
360
360
  if (abilities === 'commented') {
361
- note(`Each model scaffolds its authz decision as comments author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
361
+ // `live` FIRST, and named. It is the mode brownfield adoption needs derive the authz
362
+ // from grants and policies the database already has — and it was advertised nowhere:
363
+ // not in the usage line, not in --help, not here. The only place an operator met the
364
+ // word was the error text for an unknown preset, so the discovery path for the right
365
+ // flag was to guess a wrong one. A consumer and their agent both walked past it in one
366
+ // session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
367
+ //
368
+ // `public-read` is deliberately no longer the headline: on an existing database it
369
+ // stamps can('read') on EVERY table, which on a real schema means public read of
370
+ // credit_cards, transactions, emails and users.
371
+ note(
372
+ `Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
373
+ + `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
374
+ + `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
375
+ );
362
376
  } else {
363
377
  note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
364
378
  }
@@ -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