@everystack/cli 0.4.100 → 0.4.103

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +5 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +92 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
@@ -67,6 +67,29 @@ export interface RegrantFinding {
67
67
  owner?: string;
68
68
  }
69
69
 
70
+ /**
71
+ * A managed trigger whose live `tgenabled` is not `O`, the state every declared trigger has.
72
+ * `ALTER TABLE … DISABLE TRIGGER` touches no definition and no hash, so this is the only place it
73
+ * surfaces. Converged like a regrant, without ceremony: `ENABLE TRIGGER` restores the declared
74
+ * state and loses nothing but the drift itself.
75
+ */
76
+ export interface ReenableFinding {
77
+ identity: string;
78
+ /** The trigger name. */
79
+ name: string;
80
+ /** Its table, always `schema.table` — the ALTER TABLE target. */
81
+ table: string;
82
+ /** The live tgenabled: D, R or A. */
83
+ state: string;
84
+ }
85
+
86
+ /** What a tgenabled value means, in the words the report uses. */
87
+ export const TRIGGER_ENABLED_STATE: Record<string, string> = {
88
+ D: 'disabled — PostgreSQL never fires it',
89
+ R: 'enabled for replica only — it does not fire in an ordinary session',
90
+ A: 'enabled always — it also fires under session_replication_role = replica, where the declared trigger does not',
91
+ };
92
+
70
93
  /** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
71
94
  export interface DependencyDriftFinding {
72
95
  identity: string;
@@ -107,6 +130,9 @@ export interface ReconcilePlan {
107
130
  regrants: RegrantFinding[];
108
131
  /** Declared-vs-actual dependency drift — check-fails (fix the declaration), never blocks apply. */
109
132
  dependencyDrift: DependencyDriftFinding[];
133
+ /** Managed triggers that are not enabled as declared. Optional because a plan can arrive over
134
+ * the ops-Lambda wire from a stage that predates it; absent reads as none. */
135
+ reenables?: ReenableFinding[];
110
136
  }
111
137
 
112
138
  export interface ReconcileOptions {
@@ -506,6 +532,7 @@ export function planReconcile(
506
532
 
507
533
  const regrants: RegrantFinding[] = [];
508
534
  const dependencyDrift: DependencyDriftFinding[] = [];
535
+ const reenables: ReenableFinding[] = [];
509
536
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
510
537
  // and running the checks NOW means a migration converges (grants ride the same apply)
511
538
  // instead of surfacing on the next plan.
@@ -518,9 +545,15 @@ export function planReconcile(
518
545
  const lensSet = new Set([...skipped, ...rebaseline, ...backfill, ...baseline]);
519
546
  for (const src of source.objects) {
520
547
  if (!lensSet.has(src.identity)) continue;
521
- if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
522
548
  const liveObj = liveById.get(src.identity);
523
549
 
550
+ // Trigger enabled state — only for a trigger this source declares (internal FK triggers and
551
+ // unmanaged ones never reach here), and only when the catalog measured it (absent ≠ 'O').
552
+ if (src.kind === 'trigger' && liveObj?.enabled !== undefined && liveObj.enabled !== 'O' && liveObj.ownerIdentity) {
553
+ reenables.push({ identity: src.identity, name: liveObj.name, table: liveObj.ownerIdentity, state: liveObj.enabled });
554
+ }
555
+ if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
556
+
524
557
  // Grant drift — only when the catalog actually reported ACLs (absent ≠ empty).
525
558
  // Every object is descriptor-compiled (db/sql retired, B7), and the compiler
526
559
  // ALWAYS declares authz — a private relation is declared-empty, not undeclared —
@@ -735,5 +768,8 @@ export function planReconcile(
735
768
  migrations,
736
769
  regrants: regrants.sort((a, b) => a.identity.localeCompare(b.identity)),
737
770
  dependencyDrift: dependencyDrift.sort((a, b) => a.identity.localeCompare(b.identity)),
771
+ // Present only when there is one: an unchanged database's plan (and its --json) stays
772
+ // byte-identical to what it was before this field existed.
773
+ ...(reenables.length ? { reenables: reenables.sort((a, b) => a.identity.localeCompare(b.identity)) } : {}),
738
774
  };
739
775
  }
@@ -20,6 +20,8 @@ import { parseIndexDefinition } from './schema-introspect';
20
20
  import { modelImportPath, modelFileName, renderFieldLines } from './model-render';
21
21
  import { splitFunctionIdentity } from './pg-argtypes';
22
22
  import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive';
23
+ import { templateBody, tsString, commentText } from './ts-template';
24
+ import { safeBinding, safeFileStem, liveObject, refuseProtoKey } from './generated-names';
23
25
 
24
26
  /**
25
27
  * One rendered object's own lines, plus what it depends on — the unit `--derived-out <dir>`
@@ -101,21 +103,12 @@ function toCamelCase(name: string): string {
101
103
  return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
102
104
  }
103
105
 
104
- /** Escape a body for a template literal in generated source — it must always parse. */
105
- function tsTemplate(body: string): string {
106
- return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
107
- }
108
-
109
106
  /** An object key, bare when it is a valid identifier and quoted when it is not — a role
110
107
  * name is a PostgreSQL identifier and may hold characters JavaScript will not take bare. */
111
108
  function tsKey(s: string): string {
112
109
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : tsString(s);
113
110
  }
114
111
 
115
- function tsString(s: string): string {
116
- return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
117
- }
118
-
119
112
  /**
120
113
  * Split a relation's live ACL into what `abilities` can SAY and what must be RECORDED.
121
114
  *
@@ -134,6 +127,9 @@ function splitRelationGrants(
134
127
  const privileges: Record<string, string[]> = {};
135
128
  const foreign: string[] = [];
136
129
  for (const [grantee, privs] of Object.entries(grants)) {
130
+ // `privileges[grantee] = …` below would set the prototype for a grantee named __proto__.
131
+ // Introspection keeps such a grant (null-prototype maps), so the pull refuses here, naming it.
132
+ refuseProtoKey(grantee, liveObject('role', grantee));
137
133
  const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
138
134
  // ABILITIES are limited to the compiler's own vocabulary (authz-derive's KNOWN_ROLES).
139
135
  // A foreign grantee is FACT, not opinion, so it is RECORDED — never declared as an
@@ -303,7 +299,7 @@ export function renderedOwner(
303
299
  // in which case rendering would emit source that throws on import.
304
300
  if (!RENDERABLE_ROLE.test(owner)) {
305
301
  const why = `${o.identity}: owner '${owner}' is not a bare lowercase role name, which is all the model's owner property accepts (it is interpolated into SET ROLE) — ownership NOT declared. Rename the role, or declare the owner by hand.`;
306
- return { fixme: `// FIXME: ${why}`, warning: why };
302
+ return { fixme: `// FIXME: ${commentText(why)}`, warning: why };
307
303
  }
308
304
  return { prop: `owner: ${tsString(owner)},` };
309
305
  }
@@ -311,7 +307,7 @@ export function renderedOwner(
311
307
  if (!ambient.length) {
312
308
  // Owner known, discriminator not measured. Refuse to classify rather than guess.
313
309
  return {
314
- fixme: `// FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`,
310
+ fixme: `// ${commentText(`FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`)}`,
315
311
  };
316
312
  }
317
313
 
@@ -321,11 +317,11 @@ export function renderedOwner(
321
317
  // on a brownfield database it is the common shape, and echoing all of it would bury the rest.
322
318
  return elevated
323
319
  ? {
324
- fixme: `// FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`,
320
+ fixme: `// ${commentText(`FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`)}`,
325
321
  warning: `${o.identity}: ${elevated.posture}, owned by '${owner}' (${because}) — ${elevated.what.toLowerCase()} runs with that role's privileges. Ownership was NOT declared; declare the intended owner with owner: '<role>'.`,
326
322
  }
327
323
  : {
328
- fixme: `// FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`,
324
+ fixme: `// ${commentText(`FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`)}`,
329
325
  };
330
326
  }
331
327
 
@@ -344,12 +340,12 @@ export function renderedFunctionOwner(
344
340
  function renderIndexBuilder(indexdef: string): string | null {
345
341
  const parsed = parseIndexDefinition(indexdef);
346
342
  if (!parsed) return null;
347
- const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${tsTemplate(c)}\``);
343
+ const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${templateBody(c)}\``);
348
344
  let s = `index(${parsed.columns.map(entry).join(', ')})`;
349
345
  if (parsed.using) s += `.using(${tsString(parsed.using)})`;
350
346
  if (parsed.unique) s += '.unique()';
351
347
  if (parsed.include?.length) s += `.include(${parsed.include.map(tsString).join(', ')})`;
352
- if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
348
+ if (parsed.where) s += `.where(sql\`${templateBody(parsed.where)}\`)`;
353
349
  return s;
354
350
  }
355
351
 
@@ -361,12 +357,12 @@ function renderIndexBuilder(indexdef: string): string | null {
361
357
  function renderTableIndexBuilder(indexdef: string): string | null {
362
358
  const parsed = parseIndexDefinition(indexdef);
363
359
  if (!parsed) return null;
364
- const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(toCamelCase(c)) : `sql\`${tsTemplate(c)}\``);
360
+ const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(toCamelCase(c)) : `sql\`${templateBody(c)}\``);
365
361
  let s = `index(${parsed.columns.map(entry).join(', ')})`;
366
362
  if (parsed.using) s += `.using(${tsString(parsed.using)})`;
367
363
  if (parsed.unique) s += '.unique()';
368
364
  if (parsed.include?.length) s += `.include(${parsed.include.map((c) => tsString(toCamelCase(c))).join(', ')})`;
369
- if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
365
+ if (parsed.where) s += `.where(sql\`${templateBody(parsed.where)}\`)`;
370
366
  return s;
371
367
  }
372
368
 
@@ -412,7 +408,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
412
408
  const sequenceNames: string[] = [];
413
409
  for (const seq of opts.sequences ?? []) {
414
410
  const linesStart = lines.length;
415
- const varName = toCamelCase(seq.name.replace(/\./g, '_'));
411
+ const varName = safeBinding(toCamelCase(seq.name.replace(/\./g, '_')), liveObject('sequence', seq.name));
416
412
  const props: string[] = [];
417
413
  if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
418
414
  if (seq.start != null) props.push(`start: ${seq.start}`);
@@ -436,8 +432,9 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
436
432
 
437
433
  // Triggers are held out of the topological pass and emitted LAST (see the trigger block
438
434
  // at the end). A binding needs BOTH its target and its execute function to already
439
- // exist, and the catalog records no trigger→function edge, so file order — not the
440
- // dependency sort — is what guarantees it.
435
+ // exist. The catalog does record trigger→function (and trigger→owning view) edges, for
436
+ // the reconcile drop order, but this pass never sees triggers: file order — not the
437
+ // dependency sort — is what guarantees it here.
441
438
  const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
442
439
  const triggers = catalog.objects.filter((o) => o.kind === 'trigger');
443
440
  const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
@@ -450,7 +447,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
450
447
  const varCounts = new Map<string, number>();
451
448
  for (const o of renderable) varCounts.set(baseVar(o), (varCounts.get(baseVar(o)) ?? 0) + 1);
452
449
  const varOf = new Map(renderable.map((o) =>
453
- [o.identity, (varCounts.get(baseVar(o)) ?? 0) > 1 ? baseVar(o) + argSuffix(o.identity) : baseVar(o)]));
450
+ [o.identity, safeBinding((varCounts.get(baseVar(o)) ?? 0) > 1 ? baseVar(o) + argSuffix(o.identity) : baseVar(o), liveObject(o.kind, o.identity))]));
454
451
  // The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
455
452
  // round-trips it, so the compiled identity matches the live object it was pulled from —
456
453
  // a bare name would compile to public.<name>, baseline would never join, and reconcile
@@ -509,7 +506,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
509
506
  } else if (derived) {
510
507
  refs.push(derived);
511
508
  derivedIdents.push(e.referenced);
512
- } else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
509
+ } else fixmes.push(`// FIXME: dependsOn ${commentText(e.referenced)} — not in the pulled set; declare it by hand.`);
513
510
  }
514
511
  return { refs: refs.sort(), fixmes, identRefs: { model: modelIdents.sort(), derived: derivedIdents.sort() } };
515
512
  };
@@ -618,7 +615,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
618
615
  ...(builders.length ? [` constraints: [${builders.join(', ')}],`] : []),
619
616
  ...(refs.length ? [` dependsOn: [${refs.join(', ')}],`] : []),
620
617
  ...(o.comment ? [` comment: ${tsString(o.comment)},`] : []),
621
- ` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
618
+ ` as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`,
622
619
  '});',
623
620
  '',
624
621
  );
@@ -675,7 +672,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
675
672
  if (o.populated === false) props.push(`populate: 'deferred',`);
676
673
  }
677
674
  if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
678
- props.push(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
675
+ props.push(`as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`);
679
676
  need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
680
677
  need('sql');
681
678
  lines.push(
@@ -711,14 +708,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
711
708
  if (ownership.prop) {
712
709
  const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
713
710
  warnings.push(why);
714
- lines.push(`// FIXME: ${why}`);
711
+ lines.push(`// FIXME: ${commentText(why)}`);
715
712
  }
716
713
  lines.push(
717
- `// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
714
+ `// FIXME: ${commentText(o.identity)} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
718
715
  `export const ${varName} = defineSql(${tsString(sqlName)}, {`,
719
716
  ` kind: 'function',`,
720
- ` drop: sql\`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}\`,`,
721
- ` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
717
+ ` drop: sql\`${templateBody(`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}`)}\`,`,
718
+ ` as: sql\`${templateBody(o.definition.trim().replace(/;$/, ''))}\`,`,
722
719
  '});',
723
720
  '',
724
721
  );
@@ -731,7 +728,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
731
728
  const props: string[] = [];
732
729
  if (args.length) {
733
730
  const rendered = args.map((a) =>
734
- `arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
731
+ `arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${templateBody(a.default)}\` }` : ''})`);
735
732
  props.push(`args: [${rendered.join(', ')}],`);
736
733
  need('arg');
737
734
  }
@@ -773,7 +770,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
773
770
  if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
774
771
  if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
775
772
  if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
776
- props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
773
+ props.push(`body: sql\`${templateBody(f.src.trim())}\`,`);
777
774
  need('defineFunction');
778
775
  need('sql');
779
776
  lines.push(
@@ -782,7 +779,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
782
779
  // adopting the descriptor no longer applies a revoke. It is still worth a look on a
783
780
  // SECURITY DEFINER function, which is what db:check now WARNs about.
784
781
  ...(publicExec && f.secdef
785
- ? [`// 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.`]
782
+ ? [`// NOTE: ${commentText(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.`]
786
783
  : []),
787
784
  `export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
788
785
  ...props.map((p) => ` ${p}`),
@@ -842,18 +839,18 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
842
839
  // `export const X` — the file does not compile, and `execute: X` would bind the
843
840
  // trigger to itself. Suffix only on collision, so an uncontended schema renders
844
841
  // byte-identically to a name-per-object scheme.
845
- const varName = uniqueVar(
842
+ const varName = safeBinding(uniqueVar(
846
843
  toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`),
847
844
  o,
848
845
  usedVars,
849
- );
846
+ ), liveObject('trigger', o.identity));
850
847
  const props = [
851
848
  `on: ${targetVar},`,
852
849
  `timing: ${tsString(o.trg.shape.timing)},`,
853
850
  `events: [${o.trg.shape.events.map(tsString).join(', ')}],`,
854
851
  `forEach: ${tsString(o.trg.shape.forEach)},`,
855
852
  ...(o.trg.updateOf?.length ? [`of: [${o.trg.updateOf.map(tsString).join(', ')}],`] : []),
856
- ...(o.trg.when ? [`condition: sql\`${tsTemplate(o.trg.when)}\`,`] : []),
853
+ ...(o.trg.when ? [`condition: sql\`${templateBody(o.trg.when)}\`,`] : []),
857
854
  `execute: ${fnVar},`,
858
855
  ];
859
856
  if (o.trg.when) need('sql');
@@ -943,6 +940,8 @@ function derivedBucket(kind: DerivedRecord['kind'], schema: string): string {
943
940
  if (kind === 'view' || kind === 'materialized view') return 'views';
944
941
  if (kind === 'trigger') return 'triggers';
945
942
  if (kind === 'sequence') return 'index';
943
+ // The schema names a split FILE, written under the derived directory.
944
+ safeFileStem(schema.replace(/_/g, '-'), liveObject('schema', schema));
946
945
  return `functions-${schema}`;
947
946
  }
948
947
 
@@ -24,20 +24,21 @@
24
24
  * JSON — recorded as `plan_ref` on the schema_log row the apply writes.
25
25
  */
26
26
 
27
+ import { indexLockWarnings, type LockWarning } from './index-lock';
27
28
  import { createHash } from 'node:crypto';
28
29
  import type { Attachment } from './derived-source';
29
30
  import type { OwnerApplyMode } from './derived-apply';
30
31
  import type { ModelDescriptor } from '@everystack/model';
31
32
  import type { SchemaSnapshot, TableSchema } from './schema-introspect';
32
33
  import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
33
- import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
34
- import { generateMigrationSql, unmodeledTables } from './migration-generate';
34
+ import { ownSequenceName, foreignKeyContentKey, type SchemaChange } from './schema-diff';
35
+ import { generateMigration, unmodeledTables } from './migration-generate';
35
36
  import { compileTableContract } from './authz-compile';
36
37
  import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
37
38
  import { BASELINE_FILE } from './authz-baseline';
38
39
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
39
40
  import { statementHead, statementHeadOrSelf } from './sql-statement';
40
- import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
41
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, divergentSurfaces, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
41
42
  import { compileDeclaredState } from './declared-diff';
42
43
  import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
43
44
 
@@ -253,6 +254,18 @@ export interface EdgePlan {
253
254
  * plans minted before this field existed; the summary falls back to the text-only reading.
254
255
  */
255
256
  dark?: string[];
257
+ /**
258
+ * The plain CREATE INDEX statements that build on a live table with rows, with the catalog's row
259
+ * estimate — db:plan's ⚠ LOCK lines. Read at mint, like `dark`; informational, never a gate, and
260
+ * outside both fingerprints. Absent on plans minted before it, or when there is nothing to warn.
261
+ */
262
+ lockWarnings?: LockWarning[];
263
+ /**
264
+ * The read policies this plan creates or replaces for a can('read', { role, deleted }) ability:
265
+ * db:plan's `⚠ ACCESS — role <r> can read deleted rows of <table>` lines. Informational, outside
266
+ * both fingerprints. Absent when there is none, and on plans minted before it.
267
+ */
268
+ deletedReads?: DeletedRead[];
256
269
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
257
270
  unmodeled: string[];
258
271
  gitRef: string | null;
@@ -408,7 +421,7 @@ export function predictLiveState(
408
421
  // sets those explicitly and the declared contract already models them.
409
422
  const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
410
423
  if (!scope) return {};
411
- const out: Record<string, string[]> = {};
424
+ const out: Record<string, string[]> = Object.create(null); // keyed by a live role name
412
425
  for (const key of ['', schema]) {
413
426
  for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
414
427
  if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
@@ -461,7 +474,7 @@ export function predictLiveState(
461
474
  const inherited = inheritedSequenceGrants(d);
462
475
  const liveName = liveNameFor(d.table);
463
476
  for (const s of liveSeqByTable.get(liveName) ?? []) {
464
- const foreign: Record<string, string[]> = {};
477
+ const foreign: Record<string, string[]> = Object.create(null); // keyed by a live role name
465
478
  for (const [role, privileges] of Object.entries(s.grants)) {
466
479
  if (foreignGrantee(role)) foreign[role] = privileges;
467
480
  }
@@ -540,13 +553,57 @@ export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: s
540
553
  return grantees;
541
554
  }
542
555
 
556
+ /**
557
+ * A change the differ can only hold as a notice, but which the fingerprint SEES — so a plan
558
+ * that skips it predicts an endpoint its own apply can never reach: the verify after the apply
559
+ * fails with the rest of the edge already committed, and the next db:plan reads "Up to date" over
560
+ * a database db:fingerprint calls MISMATCH. The reason, or null for every other change (a notice
561
+ * the fingerprint does not see — a satisfied rename marker, a rename hint — mints as before).
562
+ *
563
+ * primaryKeyChange the key is hashed by its columns; without the live key's name the edge
564
+ * has nothing to drop it by.
565
+ * enumValuesChanged the enum's values are hashed; ADD VALUE is all the edge can do in place.
566
+ */
567
+ function unplannableReason(change: SchemaChange): string | null {
568
+ switch (change.kind) {
569
+ case 'primaryKeyChange':
570
+ return `the primary key of ${change.table} changes (${change.from.join(', ')} → ${change.to.join(', ')}), ` +
571
+ 'but the database read did not name the live key, so the plan has nothing to drop it by';
572
+ case 'enumValuesChanged':
573
+ return `enum ${change.name} changes its values ([${change.from.join(', ')}] → [${change.to.join(', ')}]) ` +
574
+ 'in a way ALTER TYPE … ADD VALUE cannot make';
575
+ default:
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /** A read policy, created or replaced by this plan, that lets a role see deleted lifecycle rows. */
581
+ export interface DeletedRead { table: string; role: string; deleted: 'include' | 'only' }
582
+
583
+ /** The can('read', { role, deleted }) policies whose CREATE POLICY this plan runs. */
584
+ export function deletedReadChanges(models: readonly ModelDescriptor[], statements: readonly string[]): DeletedRead[] {
585
+ const out: DeletedRead[] = [];
586
+ for (const m of models) {
587
+ if (m.lifecycle !== true) continue;
588
+ for (const a of m.abilities) {
589
+ if (a.action !== 'read' || !a.condition.deleted || !a.condition.role) continue;
590
+ const name = `${m.table}_select_${a.condition.role.toLowerCase()}`;
591
+ // The emitter writes the name bare when it is a plain identifier, quoted otherwise.
592
+ const created = new RegExp(`^\\s*CREATE POLICY (?:"${name}"|${name}) `, 'm');
593
+ if (!statements.some((st) => created.test(st))) continue;
594
+ out.push({ table: m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table, role: a.condition.role, deleted: a.condition.deleted });
595
+ }
596
+ }
597
+ return out;
598
+ }
599
+
543
600
  export function mintEdgePlan(
544
601
  models: ModelDescriptor[],
545
602
  snapshot: SchemaSnapshot,
546
603
  contract: AuthzContract,
547
604
  opts: MintOptions = {},
548
605
  ): EdgePlan {
549
- const statements = generateMigrationSql(models, snapshot, {
606
+ const { statements, changes } = generateMigration(models, snapshot, {
550
607
  schema: opts.schema,
551
608
  allowDrops: opts.allowDrops,
552
609
  liveAuthz: contract,
@@ -571,6 +628,31 @@ export function mintEdgePlan(
571
628
  const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
572
629
  const breakdown = partitionStatements(classified.executable, { declaredGrantees });
573
630
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
631
+ const governedRoles = governedRolesForModels(models, opts.governedRoles);
632
+ const from = fingerprintLive(snapshot, contract);
633
+ const to = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true });
634
+
635
+ // A plan must reach its own endpoint. Two ways it cannot, both refused here — the mint is the
636
+ // last point that holds the edge and both endpoints together.
637
+ //
638
+ // 1. A change held as a notice that the fingerprint sees. The rest of the edge would commit
639
+ // and the verify after it would fail. Checked FIRST: it names the precise reason.
640
+ const unplannable = changes.map(unplannableReason).filter((r): r is string => r !== null);
641
+ if (unplannable.length > 0) {
642
+ throw new Error(
643
+ `the planner cannot express ${unplannable.length} change(s), and the database would not match the models without them: ` +
644
+ `${unplannable.join('; ')}. Make the change by hand, then re-plan.`,
645
+ );
646
+ }
647
+ // 2. Nothing to run, yet the endpoints differ, whatever the cause. Minted, this is a plan that
648
+ // prints "Up to date" over a database db:fingerprint calls MISMATCH.
649
+ if (classified.executable.length === 0 && from.hash !== to.hash) {
650
+ const surfaces = divergentSurfaces(to.canonical, from.canonical);
651
+ throw new Error(
652
+ `the planner cannot express the difference between this database and the models (${surfaces.join('; ') || 'no surface named'}): ` +
653
+ 'there is no statement to run, but the database would not match the models. Make the change by hand, then re-plan.',
654
+ );
655
+ }
574
656
 
575
657
  // The plan's two endpoints are both FULL live hashes, deliberately:
576
658
  // - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
@@ -580,12 +662,11 @@ export function mintEdgePlan(
580
662
  // empty plan means `from == to`, and db:apply verifies the full landed hash against it.
581
663
  // The governed set still rides on the plan — the descent declares-test and db:check use it — but
582
664
  // the endpoint itself is full.
583
- const governedRoles = governedRolesForModels(models, opts.governedRoles);
584
665
  return {
585
666
  v: PLAN_VERSION,
586
- fromFingerprint: fingerprintLive(snapshot, contract).hash,
667
+ fromFingerprint: from.hash,
587
668
  fpVersion: FINGERPRINT_VERSION,
588
- toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true }),
669
+ toFingerprint: to.hash,
589
670
  toFlavor: 'full',
590
671
  defaultAclHash: defaultAclHash(contract),
591
672
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
@@ -612,6 +693,8 @@ export function mintEdgePlan(
612
693
  // contract. The summary is rendered later from the plan alone, and answering "does a read
613
694
  // survive?" from statement text is what produced three false positives on a real plan.
614
695
  dark: tablesLeftWithoutARead(statements, contract),
696
+ ...(() => { const lockWarnings = indexLockWarnings(statements, snapshot); return lockWarnings.length ? { lockWarnings } : {}; })(),
697
+ ...(() => { const deletedReads = deletedReadChanges(models, statements); return deletedReads.length ? { deletedReads } : {}; })(),
615
698
  unmodeled: unmodeledTables(models, snapshot),
616
699
  gitRef: opts.gitRef ?? null,
617
700
  mintedBy: opts.actor ?? null,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The generated artifacts db:generate (and db:sync) refresh from the models on every run, before
3
+ * any database is read: the drizzle runtime schema, and beside it the model map (model-map.ts).
4
+ * One writer, so every verb that refreshes one refreshes both, and db:check's byte-for-byte gates
5
+ * judge exactly what was written. Writing them opens no connection.
6
+ */
7
+
8
+ import fs from 'node:fs/promises';
9
+ import type { DerivedDescriptor, ModelDescriptor } from '@everystack/model';
10
+ import { compileDrizzleSource } from './schema-source';
11
+ import { modelMapPath, renderModelMap } from './model-map';
12
+
13
+ export async function writeGeneratedArtifacts(
14
+ schemaOut: string,
15
+ models: ModelDescriptor[],
16
+ derived: DerivedDescriptor[] | undefined,
17
+ ): Promise<{ schemaOut: string; modelMapOut: string }> {
18
+ const modelMapOut = modelMapPath(schemaOut);
19
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived }), 'utf8');
20
+ await fs.writeFile(modelMapOut, renderModelMap(models), 'utf8');
21
+ return { schemaOut, modelMapOut };
22
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The one check every db:pull renderer applies where a live PostgreSQL name becomes generated
3
+ * TypeScript that is NOT a string literal: a variable name, a bare object key, a file name or
4
+ * an import path.
5
+ *
6
+ * A quoted PostgreSQL name can hold any character. camelCase alone turned a view named
7
+ * `a=process.exit(1),b` into `export const a=process.exit(1),b = defineView(…)`, which ran on
8
+ * import, and a table named `/../../x` in a schema into a file written outside `--out`. So a
9
+ * name that cannot become a safe identifier or file name is REFUSED, naming the object and its
10
+ * live name. It is never renamed here: a rename would separate the generated symbol from the
11
+ * name it declares, and that is a design decision of its own.
12
+ *
13
+ * The live name itself keeps flowing as a string literal (`defineView('name', …)`), escaped by
14
+ * `tsString` in ts-template.ts.
15
+ */
16
+
17
+ import path from 'node:path';
18
+
19
+ /** A db:pull refusal: a live name has no safe spelling in the generated source. */
20
+ export class UnsafeGeneratedNameError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'UnsafeGeneratedNameError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The JavaScript IdentifierName grammar without escapes: a Unicode ID_Start (or `$`, `_`), then
29
+ * ID_Continue (or `$`). Unicode is kept because a name like `café` rendered and worked before.
30
+ * ZWJ/ZWNJ are left out on purpose: they are legal but invisible.
31
+ */
32
+ const IDENTIFIER_NAME = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u;
33
+
34
+ /**
35
+ * Words a binding cannot take in an ES module (strict mode): the reserved words, the strict
36
+ * future reserved words, and `eval`/`arguments`, which strict mode refuses as a binding.
37
+ * As an object key every one of these is legal, which is why a column named `class` renders.
38
+ */
39
+ const RESERVED_BINDINGS = new Set([
40
+ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete',
41
+ 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import',
42
+ 'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try',
43
+ 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface',
44
+ 'package', 'private', 'protected', 'public', 'eval', 'arguments',
45
+ ]);
46
+
47
+ /** A file stem: identifier characters and `-`. No `/`, `\`, `.`, so no `..` and no leading dot. */
48
+ const FILE_STEM = /^[\p{ID_Continue}$-]+$/u;
49
+
50
+ /** `view "public.v"` — how a refusal names the live object. */
51
+ export function liveObject(kind: string, liveName: string): string {
52
+ return `${kind} ${JSON.stringify(liveName)}`;
53
+ }
54
+
55
+ function refuse(what: string, role: string, rendered: string, why: string): never {
56
+ throw new UnsafeGeneratedNameError(
57
+ `db:pull refuses ${what}: it would render as the TypeScript ${role} ${JSON.stringify(rendered)}, which ${why}. `
58
+ + 'Nothing was written. Rename it in the database, or pull a schema that does not hold it.',
59
+ );
60
+ }
61
+
62
+ /** A generated `export const` / import binding. Returns `rendered` unchanged when it is safe. */
63
+ export function safeBinding(rendered: string, what: string): string {
64
+ if (!IDENTIFIER_NAME.test(rendered)) refuse(what, 'variable', rendered, 'is not a JavaScript identifier');
65
+ if (RESERVED_BINDINGS.has(rendered)) refuse(what, 'variable', rendered, 'is a reserved word');
66
+ return rendered;
67
+ }
68
+
69
+ /** A generated bare object key. Reserved words are legal keys, so a column named `class` renders. */
70
+ export function safeKey(rendered: string, what: string): string {
71
+ if (!IDENTIFIER_NAME.test(rendered)) refuse(what, 'object key', rendered, 'is not a JavaScript identifier');
72
+ refuseProtoKey(rendered, what);
73
+ return rendered;
74
+ }
75
+
76
+ /**
77
+ * `__proto__` written as a key in an object literal, bare or quoted, SETS the object's
78
+ * prototype instead of adding the entry, so the entry silently vanishes. Every other name
79
+ * (`constructor`, `prototype`) is an ordinary key in a literal. Used by the renderers that
80
+ * quote a non-identifier key, too: quoting does not change this.
81
+ */
82
+ export function refuseProtoKey(rendered: string, what: string): void {
83
+ if (rendered === '__proto__') refuse(what, 'object key', rendered, 'sets the object prototype instead of adding the entry');
84
+ }
85
+
86
+ /** A generated file name without its extension, also used as an import specifier. */
87
+ export function safeFileStem(rendered: string, what: string): string {
88
+ if (!FILE_STEM.test(rendered)) {
89
+ refuse(what, 'file name', rendered, 'is not a safe file name (letters, digits, _, $ and - only: no path separator, no dot)');
90
+ }
91
+ return rendered;
92
+ }
93
+
94
+ /**
95
+ * The path a generated file is written to, refused unless it resolves inside `dir`. The file
96
+ * names are already safe stems; this is the second wall at the write itself, so no future
97
+ * renderer can write outside the directory the operator named.
98
+ */
99
+ export function containedPath(dir: string, file: string): string {
100
+ const root = path.resolve(dir);
101
+ const target = path.resolve(root, file);
102
+ if (path.dirname(target) !== root) {
103
+ throw new UnsafeGeneratedNameError(
104
+ `db:pull refuses to write ${JSON.stringify(file)}: it resolves to ${target}, outside ${root}. Nothing was written.`,
105
+ );
106
+ }
107
+ return target;
108
+ }