@everystack/cli 0.4.99 → 0.4.102

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 +105 -0
  2. package/package.json +3 -3
  3. package/src/cli/apply-execute.ts +15 -1
  4. package/src/cli/authz-baseline.ts +8 -4
  5. package/src/cli/authz-compile.ts +61 -7
  6. package/src/cli/authz-contract.ts +23 -14
  7. package/src/cli/authz-derive.ts +282 -30
  8. package/src/cli/authz-reconcile.ts +6 -4
  9. package/src/cli/catalog-rename.ts +115 -0
  10. package/src/cli/commands/db-check.ts +40 -0
  11. package/src/cli/commands/db-generate.ts +10 -9
  12. package/src/cli/commands/db-plan.ts +30 -13
  13. package/src/cli/commands/db-pull.ts +153 -63
  14. package/src/cli/commands/db-reconcile.ts +22 -3
  15. package/src/cli/commands/db-sync.ts +6 -5
  16. package/src/cli/commands/db.ts +91 -8
  17. package/src/cli/commands/update.ts +3 -2
  18. package/src/cli/db-build.ts +1 -0
  19. package/src/cli/declared-derived.ts +3 -1
  20. package/src/cli/declared-diff.ts +4 -1
  21. package/src/cli/derived-apply.ts +116 -10
  22. package/src/cli/derived-grants.ts +4 -2
  23. package/src/cli/derived-introspect.ts +23 -3
  24. package/src/cli/derived-plan.ts +37 -1
  25. package/src/cli/derived-render.ts +33 -34
  26. package/src/cli/edge-plan.ts +57 -9
  27. package/src/cli/generated-artifacts.ts +22 -0
  28. package/src/cli/generated-names.ts +108 -0
  29. package/src/cli/host-url.ts +48 -27
  30. package/src/cli/index.ts +5 -5
  31. package/src/cli/lifecycle-canon.ts +37 -0
  32. package/src/cli/lifecycle-derived.ts +721 -0
  33. package/src/cli/lifecycle-pull.ts +177 -0
  34. package/src/cli/lifecycle-verify.ts +232 -0
  35. package/src/cli/migration-compile.ts +8 -3
  36. package/src/cli/migration-generate.ts +23 -3
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +243 -77
  39. package/src/cli/plain-output.ts +198 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +57 -18
  42. package/src/cli/schema-diff.ts +184 -41
  43. package/src/cli/schema-fingerprint.ts +39 -6
  44. package/src/cli/schema-introspect.ts +66 -2
  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
@@ -85,6 +85,13 @@ export interface LiveObject {
85
85
  /** The WHEN predicate as the catalog deparses it. */
86
86
  when?: string;
87
87
  };
88
+ /**
89
+ * Trigger only: `pg_trigger.tgenabled` — `O` (fires on origin, the default every declared
90
+ * trigger has), `D` disabled, `R` replica-only, `A` always. Read LIVE and compared to `O` by
91
+ * the plan; it is deliberately NOT in `defHash` (pg_get_triggerdef does not render it), so no
92
+ * recorded provenance hash changes. Absent means not measured.
93
+ */
94
+ enabled?: string;
88
95
  /** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
89
96
  * Absent on triggers (they take no grants) and when the introspection predates B4. */
90
97
  grants?: Record<string, string[]>;
@@ -492,7 +499,8 @@ SELECT
492
499
  (SELECT array_agg(a.attname::text ORDER BY x.ord)
493
500
  FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS x(attnum, ord)
494
501
  JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = x.attnum) AS update_of,
495
- substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr
502
+ substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr,
503
+ t.tgenabled::text AS enabled
496
504
  FROM pg_trigger t
497
505
  JOIN pg_class c ON c.oid = t.tgrelid
498
506
  JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -587,6 +595,8 @@ export interface TriggerRow {
587
595
  update_of?: unknown;
588
596
  /** The WHEN predicate, scraped from pg_get_triggerdef — see DERIVED_TRIGGERS_SQL. */
589
597
  when_expr?: unknown;
598
+ /** `tgenabled` as text (O/D/R/A). Optional: a pre-F2 read has none — not measured. */
599
+ enabled?: unknown;
590
600
  }
591
601
 
592
602
  export interface GrantAclRow {
@@ -651,13 +661,14 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
651
661
  for (const row of rows.grants ?? []) {
652
662
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
653
663
  const key = `${String(row.kind)}:${row.schema}.${row.name}`;
654
- const acc = grantsByKey.get(key) ?? {};
664
+ // Null prototype: a grantee is a live role name, and '__proto__' is a legal one (see assembleContract).
665
+ const acc = grantsByKey.get(key) ?? Object.create(null) as Record<string, string[]>;
655
666
  acc[row.grantee] = [...new Set([...(acc[row.grantee] ?? []), ...pgTextArray(row.privileges)])].sort();
656
667
  grantsByKey.set(key, acc);
657
668
  }
658
669
  const grantsFor = (kindClass: 'r' | 'f', identity: string): { grants?: Record<string, string[]> } => {
659
670
  if (rows.grants === undefined) return {}; // pre-B4 caller — absent, not empty
660
- return { grants: grantsByKey.get(`${kindClass}:${identity}`) ?? {} };
671
+ return { grants: grantsByKey.get(`${kindClass}:${identity}`) ?? Object.create(null) };
661
672
  };
662
673
 
663
674
  const indexesByIdentity = new Map<string, string[]>();
@@ -756,6 +767,7 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
756
767
  defHash: computeDefHash(definition, []),
757
768
  table: row.schema === 'public' ? row.table : ownerIdentity,
758
769
  ownerIdentity,
770
+ ...(row.enabled != null ? { enabled: String(row.enabled) } : {}),
759
771
  ...(row.tgtype != null && row.function_identity != null ? {
760
772
  trg: {
761
773
  shape: parseTriggerType(Number(row.tgtype)),
@@ -768,6 +780,14 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
768
780
  if (relationIdentities.has(ownerIdentity)) {
769
781
  triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
770
782
  }
783
+ // The trigger depends on the function it executes: PostgreSQL refuses DROP FUNCTION while
784
+ // the trigger exists, so a run removing both must drop the trigger first. Trigger functions
785
+ // take no arguments; a pre-signature function row carries the bare name.
786
+ if (row.function_identity != null) {
787
+ const fn = String(row.function_identity);
788
+ const executes = [`${fn}()`, fn].find((id) => relationIdentities.has(id));
789
+ if (executes) triggerEdges.push({ dependent: identity, referenced: executes });
790
+ }
771
791
  }
772
792
 
773
793
  const edges: DependencyEdge[] = [
@@ -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
 
@@ -30,14 +30,14 @@ import type { OwnerApplyMode } from './derived-apply';
30
30
  import type { ModelDescriptor } from '@everystack/model';
31
31
  import type { SchemaSnapshot, TableSchema } from './schema-introspect';
32
32
  import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
33
- import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
34
- import { generateMigrationSql, unmodeledTables } from './migration-generate';
33
+ import { ownSequenceName, foreignKeyContentKey, type SchemaChange } from './schema-diff';
34
+ import { generateMigration, unmodeledTables } from './migration-generate';
35
35
  import { compileTableContract } from './authz-compile';
36
36
  import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
37
37
  import { BASELINE_FILE } from './authz-baseline';
38
38
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
39
39
  import { statementHead, statementHeadOrSelf } from './sql-statement';
40
- import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
40
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, divergentSurfaces, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
41
41
  import { compileDeclaredState } from './declared-diff';
42
42
  import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
43
43
 
@@ -408,7 +408,7 @@ export function predictLiveState(
408
408
  // sets those explicitly and the declared contract already models them.
409
409
  const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
410
410
  if (!scope) return {};
411
- const out: Record<string, string[]> = {};
411
+ const out: Record<string, string[]> = Object.create(null); // keyed by a live role name
412
412
  for (const key of ['', schema]) {
413
413
  for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
414
414
  if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
@@ -461,7 +461,7 @@ export function predictLiveState(
461
461
  const inherited = inheritedSequenceGrants(d);
462
462
  const liveName = liveNameFor(d.table);
463
463
  for (const s of liveSeqByTable.get(liveName) ?? []) {
464
- const foreign: Record<string, string[]> = {};
464
+ const foreign: Record<string, string[]> = Object.create(null); // keyed by a live role name
465
465
  for (const [role, privileges] of Object.entries(s.grants)) {
466
466
  if (foreignGrantee(role)) foreign[role] = privileges;
467
467
  }
@@ -540,13 +540,37 @@ export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: s
540
540
  return grantees;
541
541
  }
542
542
 
543
+ /**
544
+ * A change the differ can only hold as a notice, but which the fingerprint SEES — so a plan
545
+ * that skips it predicts an endpoint its own apply can never reach: the verify after the apply
546
+ * fails with the rest of the edge already committed, and the next db:plan reads "Up to date" over
547
+ * a database db:fingerprint calls MISMATCH. The reason, or null for every other change (a notice
548
+ * the fingerprint does not see — a satisfied rename marker, a rename hint — mints as before).
549
+ *
550
+ * primaryKeyChange the key is hashed by its columns; without the live key's name the edge
551
+ * has nothing to drop it by.
552
+ * enumValuesChanged the enum's values are hashed; ADD VALUE is all the edge can do in place.
553
+ */
554
+ function unplannableReason(change: SchemaChange): string | null {
555
+ switch (change.kind) {
556
+ case 'primaryKeyChange':
557
+ return `the primary key of ${change.table} changes (${change.from.join(', ')} → ${change.to.join(', ')}), ` +
558
+ 'but the database read did not name the live key, so the plan has nothing to drop it by';
559
+ case 'enumValuesChanged':
560
+ return `enum ${change.name} changes its values ([${change.from.join(', ')}] → [${change.to.join(', ')}]) ` +
561
+ 'in a way ALTER TYPE … ADD VALUE cannot make';
562
+ default:
563
+ return null;
564
+ }
565
+ }
566
+
543
567
  export function mintEdgePlan(
544
568
  models: ModelDescriptor[],
545
569
  snapshot: SchemaSnapshot,
546
570
  contract: AuthzContract,
547
571
  opts: MintOptions = {},
548
572
  ): EdgePlan {
549
- const statements = generateMigrationSql(models, snapshot, {
573
+ const { statements, changes } = generateMigration(models, snapshot, {
550
574
  schema: opts.schema,
551
575
  allowDrops: opts.allowDrops,
552
576
  liveAuthz: contract,
@@ -571,6 +595,31 @@ export function mintEdgePlan(
571
595
  const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
572
596
  const breakdown = partitionStatements(classified.executable, { declaredGrantees });
573
597
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
598
+ const governedRoles = governedRolesForModels(models, opts.governedRoles);
599
+ const from = fingerprintLive(snapshot, contract);
600
+ const to = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true });
601
+
602
+ // A plan must reach its own endpoint. Two ways it cannot, both refused here — the mint is the
603
+ // last point that holds the edge and both endpoints together.
604
+ //
605
+ // 1. A change held as a notice that the fingerprint sees. The rest of the edge would commit
606
+ // and the verify after it would fail. Checked FIRST: it names the precise reason.
607
+ const unplannable = changes.map(unplannableReason).filter((r): r is string => r !== null);
608
+ if (unplannable.length > 0) {
609
+ throw new Error(
610
+ `the planner cannot express ${unplannable.length} change(s), and the database would not match the models without them: ` +
611
+ `${unplannable.join('; ')}. Make the change by hand, then re-plan.`,
612
+ );
613
+ }
614
+ // 2. Nothing to run, yet the endpoints differ, whatever the cause. Minted, this is a plan that
615
+ // prints "Up to date" over a database db:fingerprint calls MISMATCH.
616
+ if (classified.executable.length === 0 && from.hash !== to.hash) {
617
+ const surfaces = divergentSurfaces(to.canonical, from.canonical);
618
+ throw new Error(
619
+ `the planner cannot express the difference between this database and the models (${surfaces.join('; ') || 'no surface named'}): ` +
620
+ 'there is no statement to run, but the database would not match the models. Make the change by hand, then re-plan.',
621
+ );
622
+ }
574
623
 
575
624
  // The plan's two endpoints are both FULL live hashes, deliberately:
576
625
  // - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
@@ -580,12 +629,11 @@ export function mintEdgePlan(
580
629
  // empty plan means `from == to`, and db:apply verifies the full landed hash against it.
581
630
  // The governed set still rides on the plan — the descent declares-test and db:check use it — but
582
631
  // the endpoint itself is full.
583
- const governedRoles = governedRolesForModels(models, opts.governedRoles);
584
632
  return {
585
633
  v: PLAN_VERSION,
586
- fromFingerprint: fingerprintLive(snapshot, contract).hash,
634
+ fromFingerprint: from.hash,
587
635
  fpVersion: FINGERPRINT_VERSION,
588
- toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true }),
636
+ toFingerprint: to.hash,
589
637
  toFlavor: 'full',
590
638
  defaultAclHash: defaultAclHash(contract),
591
639
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
@@ -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
+ }