@everystack/cli 0.4.91 → 0.4.92

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.91",
3
+ "version": "0.4.92",
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>",
@@ -32,7 +32,7 @@
32
32
  import type { QueryRunner } from '../authz-contract';
33
33
  import type { SourceObject } from '../derived-source';
34
34
  import { introspectDerived } from '../derived-introspect';
35
- import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
35
+ import { planReconcile, objectGrantDelta, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
36
36
  import {
37
37
  renderReconcileSql,
38
38
  renderProvenanceUpsert,
@@ -269,8 +269,9 @@ export async function executeReconcile(
269
269
  // stable regardless of the create-time wide path above — and that wide path survives the read.
270
270
  // The unwrapped path has no transaction to borrow, so it takes a session of its own.
271
271
  phase = 'verify';
272
- const after = await introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
273
- const liveById = new Map(after.objects.map((o) => [o.identity, o]));
272
+ const reread = () => introspectDerived(atomic ? borrowedSessionRunner(runner) : session);
273
+ let after = await reread();
274
+ let liveById = new Map(after.objects.map((o) => [o.identity, o]));
274
275
  const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
275
276
 
276
277
  // THE ORPHAN CHECK — an outcome assertion, not a plan-shape one. Every identity whose
@@ -309,6 +310,82 @@ export async function executeReconcile(
309
310
  );
310
311
  }
311
312
 
313
+ // THE GRANT CONVERGENCE CHECK — the same outcome assertion, for authz. Every object whose
314
+ // grants this batch set (creates, replaces, regrants) must now match its declaration, read
315
+ // from the real post-DDL catalog with the same diff the plan used.
316
+ //
317
+ // A statement that ran is not a statement that worked. PostgreSQL answers a REVOKE that
318
+ // matches nothing with `WARNING: no privileges could be revoked` and a success tag: issued by
319
+ // a role that is not the grantor (an operator while ownership is still converging, or a grant
320
+ // someone with grant option made by hand), it removes nothing. Without this check the apply
321
+ // reported success while the role kept its privilege — the exact failure a declared-authz
322
+ // tool exists to prevent. Inside the atomic path the throw rolls the whole batch back.
323
+ //
324
+ // SETTLE FIRST. A create's ACL is its attachments PLUS whatever the database adds on its own:
325
+ // `ALTER DEFAULT PRIVILEGES` grants (db:provision --with-reader sets exactly these), and — for a
326
+ // superuser builder that creates then hands over — a builder grant folded into the pre-transfer
327
+ // owner's entry and lost by ALTER … OWNER. The plan cannot see either; only the post-DDL catalog
328
+ // can. So each created/replaced object's remaining delta runs NOW, in the same batch, and only
329
+ // then is convergence checked. Without this, those shapes fail every apply, forever: the
330
+ // rollback restores the plan's `create`, which rebuilds the same ACL. (Main converged them one
331
+ // apply late, via the next plan's regrant.) Regrants are not re-run: they were computed from
332
+ // this catalog already, and a repeat of a statement that did not land lands no better.
333
+ const built = plan.actions.filter((a) => a.action === 'create' || a.action === 'replace').map((a) => a.identity);
334
+ const settle: string[] = [];
335
+ for (const identity of built) {
336
+ const src = srcById.get(identity);
337
+ const liveObj = liveById.get(identity);
338
+ if (!src || !liveObj) continue;
339
+ const delta = objectGrantDelta(src, liveObj);
340
+ if (!delta || delta.columnScoped || delta.statements.length === 0) continue;
341
+ // As the owner under set-role, for the reason regrants are: GRANT/REVOKE needs ownership.
342
+ // The preflight already proved every created object's owner assumable.
343
+ if (src.owner && liveObj.owner === src.owner && (ownerMode ?? 'set-role') === 'set-role') {
344
+ settle.push(`SET ROLE ${src.owner}`, ...delta.statements, 'RESET ROLE');
345
+ } else {
346
+ settle.push(...delta.statements);
347
+ }
348
+ }
349
+ if (settle.length > 0) {
350
+ // Recorded BEFORE it runs, so a failed attempt's schema_log row carries the statement that failed.
351
+ rendered = { ...rendered, statements: [...rendered.statements, ...settle] };
352
+ phase = 'ddl'; // a GRANT/REVOKE that fails is a DDL failure, not a verify one
353
+ try {
354
+ if (atomic) await runner(settle.join(';\n'));
355
+ else for (const statement of settle) await runner(statement);
356
+ } catch (err) {
357
+ throw new Error(
358
+ `${String((err as any)?.message ?? err)}\n\nThis came from the grant settle pass, which runs after the DDL: `
359
+ + `the grants the built objects still needed once the catalog was read back (${settle.filter((st) => !/ROLE\b/.test(st)).join('; ')}).`,
360
+ );
361
+ }
362
+ phase = 'verify';
363
+ after = await reread();
364
+ liveById = new Map(after.objects.map((o) => [o.identity, o]));
365
+ }
366
+
367
+ const grantTouched = new Set([...built, ...plan.regrants.map((r) => r.identity)]);
368
+ const unconverged: string[] = [];
369
+ for (const identity of [...grantTouched].sort()) {
370
+ const src = srcById.get(identity);
371
+ const liveObj = liveById.get(identity);
372
+ if (!src || !liveObj) continue;
373
+ const delta = objectGrantDelta(src, liveObj);
374
+ if (delta && !delta.columnScoped && delta.statements.length > 0) {
375
+ unconverged.push(`${identity}: still needs ${delta.statements.join('; ')}`);
376
+ }
377
+ }
378
+ if (unconverged.length > 0) {
379
+ throw new Error(
380
+ `grants did not converge — the statements ran, but the catalog afterwards still differs from the `
381
+ + `declaration:\n ${unconverged.join('\n ')}\n\nA REVOKE that matches nothing only WARNs in PostgreSQL. `
382
+ + `The usual cause of a REVOKE that did not land is a grant whose grantor is not the role that ran it: `
383
+ + `the object's live owner differs from its declared owner (converge ownership with \`everystack `
384
+ + `db:provision\`), or a role holding grant option granted it by hand (revoke it as that role). A GRANT `
385
+ + `that did not land means the role that ran it holds no grant option on the object. No provenance was written.`,
386
+ );
387
+ }
388
+
312
389
  const bookkeeping: string[] = [];
313
390
  // ORDER MATTERS: migrate, then record, then remove. This ordering is LOAD-BEARING — it is the
314
391
  // fix, not the belt. `renderProvenanceMigrate`'s own guard is the belt (see its docstring).
@@ -426,9 +503,10 @@ export function explainReconcileError(err: unknown, phase: 'ddl' | 'verify' | 'b
426
503
  `${msg}\n\nThis failed AFTER the DDL, not in it. `
427
504
  + (phase === 'verify'
428
505
  ? `The failure came from the verify half: the post-apply catalog re-read, or one of the checks `
429
- + `over it — a recorded identity with no source object, an object the catalog cannot see `
430
- + `afterwards, or a backend pid that moved. No provenance and no 'applied' schema_log row `
431
- + `was written; the bookkeeping write is never reached`
506
+ + `over it — an object whose DROP left it live, grants that did not converge, a recorded `
507
+ + `identity with no source object, an object the catalog cannot see afterwards, or a backend `
508
+ + `pid that moved. No provenance and no 'applied' schema_log row was written; the bookkeeping `
509
+ + `write is never reached`
432
510
  : `The failure came from the bookkeeping write that records what was built — the provenance and `
433
511
  + `schema_log statements, or the COMMIT that seals them. The post-apply catalog re-read `
434
512
  + `succeeded, so every recorded object was introspectable when it ran`)
@@ -273,7 +273,9 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
273
273
  // Declared-owner preflight — the refusal that beats a raw Postgres error.
274
274
  // ---------------------------------------------------------------------------
275
275
 
276
- /** One declared owner and every schema the batch will create an object of theirs in. */
276
+ /** One declared owner and every schema the batch will create an object of theirs in. Empty
277
+ * `schemas` means the batch only GRANTs/REVOKEs as that owner: it must exist and be assumable,
278
+ * but it creates nothing, so it needs CREATE nowhere. */
277
279
  export interface OwnerRequirement {
278
280
  owner: string;
279
281
  schemas: string[];
@@ -282,7 +284,7 @@ export interface OwnerRequirement {
282
284
  /**
283
285
  * The declared owners this batch will actually enact, and where.
284
286
  *
285
- * Scoped to CREATE and REPLACE, because those are the actions that run under `SET ROLE`. A
287
+ * Scoped to CREATE, REPLACE and owned REGRANTS, because those are what run under `SET ROLE`. A
286
288
  * baseline, a prune or an unchanged object never assumes anyone's identity, so demanding its
287
289
  * owner be assumable would refuse work the batch was never going to attempt. On a from-scratch
288
290
  * build every object is created, so this set is every declared owner anyway.
@@ -296,6 +298,9 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
296
298
  if (!obj?.owner) continue;
297
299
  (byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
298
300
  }
301
+ for (const r of plan.regrants) {
302
+ if (r.owner && !byOwner.has(r.owner)) byOwner.set(r.owner, new Set());
303
+ }
299
304
  return [...byOwner.entries()]
300
305
  .map(([owner, schemas]) => ({ owner, schemas: [...schemas].sort() }))
301
306
  .sort((a, b) => a.owner.localeCompare(b.owner));
@@ -310,7 +315,9 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
310
315
  * and the privilege only evaluated when it is really there.
311
316
  */
312
317
  export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
313
- const pairs = requirements.flatMap((r) => r.schemas.map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)})`));
318
+ // A grant-only owner still needs its row (does it exist, can we assume it): one with no schema.
319
+ const pairs = requirements.flatMap((r) => (r.schemas.length ? r.schemas : [null])
320
+ .map((s) => `(${escapeLiteral(r.owner)}, ${s === null ? 'NULL::text' : escapeLiteral(s)})`));
314
321
  return `
315
322
  WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
316
323
  SELECT
@@ -345,7 +352,8 @@ export interface OwnerPreflightRow {
345
352
  builder?: unknown;
346
353
  builder_is_superuser?: unknown;
347
354
  owner_name: string;
348
- schema_name: string;
355
+ /** Null for a grant-only owner — nothing is created, so there is no schema to check. */
356
+ schema_name: string | null;
349
357
  role_exists?: unknown;
350
358
  schema_exists?: unknown;
351
359
  owner_is_superuser?: unknown;
@@ -403,6 +411,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
403
411
  // A superuser owner holds CREATE everywhere, including in a schema this build is about to
404
412
  // make — there is nothing to refuse.
405
413
  if (truthy(row.owner_is_superuser)) continue;
414
+ if (row.schema_name == null) continue;
406
415
  if (!truthy(row.schema_exists)) noCreate.push({ owner, schema: String(row.schema_name), exists: false });
407
416
  else if (!truthy(row.owner_can_create)) noCreate.push({ owner, schema: String(row.schema_name), exists: true });
408
417
  }
@@ -410,7 +419,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
410
419
  if (!missing.size && !unassumable.size && !noCreate.length) return null;
411
420
 
412
421
  const lines: string[] = [
413
- `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
422
+ `declared owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
414
423
  `The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
415
424
  `pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
416
425
  `The exact statements, if you must run them by hand:`,
@@ -424,7 +433,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
424
433
  }
425
434
  for (const owner of [...unassumable].sort()) {
426
435
  lines.push(
427
- ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE runs as the owner:`,
436
+ ` CANNOT SET ROLE '${builderName}' cannot assume '${owner}'. The CREATE and every GRANT/REVOKE on its objects run as the owner:`,
428
437
  ` GRANT ${owner} TO ${builderName} WITH INHERIT FALSE, SET TRUE;`,
429
438
  );
430
439
  }
@@ -498,8 +507,14 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
498
507
  }
499
508
 
500
509
  // Grant convergence rides the same batch, after every object exists. Idempotent
501
- // REVOKE/GRANT — no provenance to record (ACLs are not in any hash).
502
- for (const r of plan.regrants) statements.push(...r.statements);
510
+ // REVOKE/GRANT — no provenance to record (ACLs are not in any hash). An owned object's grants
511
+ // run AS its owner, for the reason objectSql keeps attachments inside the block: GRANT/REVOKE
512
+ // needs ownership, and a non-superuser operator does not have it. A superuser builder
513
+ // (alter-owner) acts as any owner already.
514
+ for (const r of plan.regrants) {
515
+ if (r.owner && ownerMode === 'set-role') statements.push(`SET ROLE ${r.owner}`, ...r.statements, 'RESET ROLE');
516
+ else statements.push(...r.statements);
517
+ }
503
518
 
504
519
  // NOTE — a `remove` entry that is also a migration's TARGET is CORRECT, not a contradiction.
505
520
  // A legacy-keyed function the source has removed migrates onto its signature identity and is
@@ -27,7 +27,9 @@
27
27
  import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source';
28
28
  import type { DerivedCatalog, LiveObject } from './derived-introspect';
29
29
  import { triggerDropSql } from './derived-apply';
30
- import { parseGrantAttachments, diffObjectGrants, revokesPublic } from './derived-grants';
30
+ import {
31
+ parseGrantAttachments, diffObjectGrants, revokesPublic, type GrantOwnerContext, type ObjectGrantDiff,
32
+ } from './derived-grants';
31
33
  import { legacyFunctionIdentity, splitFunctionIdentity } from './pg-argtypes';
32
34
 
33
35
  export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
@@ -58,6 +60,11 @@ export interface DriftFinding {
58
60
  export interface RegrantFinding {
59
61
  identity: string;
60
62
  statements: string[];
63
+ /** The role the statements must run as: GRANT/REVOKE on an object needs its owner, and a
64
+ * non-superuser operator is not it. Set only when the declared owner IS the live owner — under
65
+ * any other role a REVOKE can match nothing and only WARN. That case runs as the operator, and
66
+ * the post-apply convergence check (db-reconcile) fails it rather than letting it read as success. */
67
+ owner?: string;
61
68
  }
62
69
 
63
70
  /** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
@@ -148,6 +155,40 @@ export interface ReconcileOptions {
148
155
 
149
156
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
150
157
 
158
+ const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext => ({
159
+ ...(src.owner !== undefined ? { declared: src.owner } : {}),
160
+ ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
161
+ });
162
+
163
+ /**
164
+ * The REVOKE/GRANT delta between an object's declared grants and a live ACL — the ONE diff the
165
+ * drift pass, a replace's revokes and the post-apply convergence check all use, so the check can
166
+ * never disagree with the plan about what "converged" means. Null when there is nothing to
167
+ * compare (no ACL read, or a kind with no grants). Column-scoped grants are not compared
168
+ * (attacl is not introspected) and say so.
169
+ *
170
+ * Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded from
171
+ * the live read for relations too (`a.grantee <> c.relowner`), so a model that both owns an object
172
+ * and lists that owner in its grants declares a self-grant the read can never confirm —
173
+ * diffObjectGrants normalizes the owner grantee away. The earlier belief that a relation's grant
174
+ * set "does not shift with its owner" holds only while relacl is NULL; the first non-owner grant
175
+ * materializes the ACL and the owner's own rows drop out, exactly as for a function. (A consumer's
176
+ * ADOPT'd view, owned by a role its model also grants, looped forever — 2026-09-04.)
177
+ * KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that is a
178
+ * failure to ADD access (availability), never to remove it.
179
+ */
180
+ export function objectGrantDelta(
181
+ src: SourceObject,
182
+ liveObj: LiveObject,
183
+ liveGrants: Record<string, string[]> | undefined = liveObj.grants,
184
+ ): (ObjectGrantDiff & { columnScoped: boolean }) | null {
185
+ if (liveGrants === undefined || src.kind === 'trigger' || src.kind === 'sql') return null;
186
+ const parsed = parseGrantAttachments(src.attachments);
187
+ if (parsed.hasColumnGrants) return { statements: [], suppressed: [], columnScoped: true };
188
+ const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
189
+ return { ...diffObjectGrants(target, parsed.grants, liveGrants, ownerContext(src, liveObj)), columnScoped: false };
190
+ }
191
+
151
192
  export function planReconcile(
152
193
  source: ParsedSources,
153
194
  live: DerivedCatalog,
@@ -465,10 +506,6 @@ export function planReconcile(
465
506
 
466
507
  const regrants: RegrantFinding[] = [];
467
508
  const dependencyDrift: DependencyDriftFinding[] = [];
468
- const ownerContext = (src: SourceObject, liveObj: LiveObject): { declared?: string; live?: string } => ({
469
- ...(src.owner !== undefined ? { declared: src.owner } : {}),
470
- ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
471
- });
472
509
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
473
510
  // and running the checks NOW means a migration converges (grants ride the same apply)
474
511
  // instead of surfacing on the next plan.
@@ -482,30 +519,21 @@ export function planReconcile(
482
519
  // Every object is descriptor-compiled (db/sql retired, B7), and the compiler
483
520
  // ALWAYS declares authz — a private relation is declared-empty, not undeclared —
484
521
  // so every up-to-date object is grant-checked.
485
- if (liveObj?.grants !== undefined) {
486
- const parsed = parseGrantAttachments(src.attachments);
487
- if (parsed.hasColumnGrants) {
488
- extraWarnings.push(
489
- `${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
490
- );
491
- } else {
492
- const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
493
- // Ownership context is passed for EVERY kind. An owner's implicit privileges are excluded
494
- // from the live read for relations too (`a.grantee <> c.relowner`), so a model that both
495
- // owns an object and lists that owner in its grants declares a self-grant the read can
496
- // never confirm — diffObjectGrants normalizes the owner grantee away. The earlier belief
497
- // that a relation's grant set "does not shift with its owner" holds only while relacl is
498
- // NULL; the first non-owner grant materializes the ACL and the owner's own rows drop out,
499
- // exactly as for a function. (A consumer's ADOPT'd view, owned by a role its model also
500
- // grants, looped forever — 2026-09-04.)
501
- // KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
502
- // is a failure to ADD access (availability), never to remove it, and unnormalized code did
503
- // not converge on it either.
504
- const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, ownerContext(src, liveObj));
505
- if (statements.length > 0) regrants.push({ identity: src.identity, statements });
506
- // Never silent: a withheld revoke is reported every run until the owner is declared.
507
- for (const note of suppressed) extraWarnings.push(note);
522
+ const delta = liveObj ? objectGrantDelta(src, liveObj) : null;
523
+ if (delta?.columnScoped) {
524
+ extraWarnings.push(
525
+ `${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
526
+ );
527
+ } else if (delta && liveObj) {
528
+ if (delta.statements.length > 0) {
529
+ // Run as the owner only when the declared owner IS the live owner: under any other role a
530
+ // REVOKE can match nothing and only WARN. The post-apply convergence check catches that
531
+ // case whichever role runs it.
532
+ const owner = src.owner !== undefined && liveObj.owner === src.owner ? src.owner : undefined;
533
+ regrants.push({ identity: src.identity, statements: delta.statements, ...(owner ? { owner } : {}) });
508
534
  }
535
+ // Never silent: a withheld revoke is reported every run until the owner is declared.
536
+ for (const note of delta.suppressed) extraWarnings.push(note);
509
537
  }
510
538
 
511
539
  // Dependency drift — declared, then verified. Only descriptor-home relations carry a
@@ -653,14 +681,12 @@ export function planReconcile(
653
681
  const src = srcById.get(id);
654
682
  const liveObj = liveById.get(id);
655
683
  if (!src || !liveObj || liveObj.grants === undefined) return [];
656
- const parsed = parseGrantAttachments(src.attachments);
657
- if (parsed.hasColumnGrants) return [];
658
- const target = parsed.target ?? `FUNCTION ${src.identity}`;
659
684
  const afterReplace = { ...liveObj.grants };
660
685
  if (revokesPublic(src.attachments)) delete afterReplace.PUBLIC;
661
- const { statements, suppressed } = diffObjectGrants(target, parsed.grants, afterReplace, ownerContext(src, liveObj));
662
- for (const note of suppressed) extraWarnings.push(note);
663
- return statements.filter((st) => /^REVOKE\b/i.test(st));
686
+ const delta = objectGrantDelta(src, liveObj, afterReplace);
687
+ if (!delta || delta.columnScoped) return [];
688
+ for (const note of delta.suppressed) extraWarnings.push(note);
689
+ return delta.statements.filter((st) => /^REVOKE\b/i.test(st));
664
690
  };
665
691
 
666
692
  const actions: ReconcileAction[] = [