@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
@@ -9,22 +9,25 @@
9
9
  * It renders what the field builder can express today (type, primary key, not-null,
10
10
  * single-column unique, single-column references, the common defaults) and SURFACES what it
11
11
  * can't as inline comments rather than faking it: an unmapped column type, a composite
12
- * unique, a CHECK that needs a manual `.validate()`, a foreign key to a table outside the
13
- * pulled set. Reversing a CHECK back to its Zod refinement is a later increment.
12
+ * unique, a CHECK that needs a manual `.validate()`. A foreign key to a table outside the
13
+ * pulled set is declared in the external `{ schema, table, column }` form.
14
14
  *
15
15
  * Pure and deterministic — the command shell (commands/db-pull.ts) does the IO.
16
16
  */
17
17
 
18
18
  import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect';
19
+ import { foreignKeyTargetKey } from './schema-introspect';
19
20
  import type { DerivedRenderResult } from './derived-render';
20
21
  import type { TableContract } from './authz-contract';
21
22
  import { deriveAbilities, renderDerivedAbilities } from './authz-derive';
22
23
  import { normalizeDefault, normalizeCheck } from './schema-diff';
23
24
  import { generatedIndexName, generatedForeignKeyName } from './schema-compile';
25
+ import { templateBody, tsString as tsLiteral, commentText } from './ts-template';
26
+ import { safeBinding, safeKey, safeFileStem, liveObject } from './generated-names';
24
27
  // The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
25
28
  // Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
26
29
  // the rule is not, so the two surfaces cannot drift on when a declaration is required.
27
- import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
30
+ import { needsSoftDeleteDeclaration, privilegesGrantDelete, positionalCheckName, externalIdentifierRefusal } from '@everystack/model';
28
31
 
29
32
  /**
30
33
  * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
@@ -85,13 +88,21 @@ export interface RenderOptions {
85
88
  */
86
89
  liveAuthz?: Map<string, TableContract>;
87
90
  /**
88
- * `--govern-roles` — the operator's explicit decision to transcribe these foreign roles'
89
- * grants into `privileges`, which GOVERNS them. Required for migration deletion when the
90
- * migrations created grants to roles outside the vocabulary (an ops/connection role): a
91
- * fresh build from models must recreate them or the deletion loses the ops lane its
92
- * access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
91
+ * `--govern-roles` — the operator's explicit decision to GOVERN these foreign roles: their
92
+ * grants and policies render as role-scoped `can()` where they pair exactly, `privileges`
93
+ * otherwise, and the module footer declares them in `governedRoles`. Required for migration
94
+ * deletion when the migrations created grants to roles outside the vocabulary (an
95
+ * ops/connection role): a fresh build from models must recreate them or the deletion loses
96
+ * the ops lane its access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
93
97
  */
94
98
  governRoles?: string[];
99
+ /**
100
+ * Each FK's target as the CATALOG names it (FK_TARGETS_SQL → foreignKeyTargets). db:pull always
101
+ * passes it: an external target is then declared from these exact names, and a key it does not
102
+ * hold renders as a comment. Absent (a pure caller), the deparsed `refTable` is split instead,
103
+ * which is ambiguous for a quoted name holding a dot.
104
+ */
105
+ fkTargets?: FkTargets;
95
106
  /** The rendered derived layer (B5) — rides the barrel: block after the models,
96
107
  * sequences/derived arrays on the module wrapper, symbols on the import header. */
97
108
  derived?: DerivedRenderResult;
@@ -99,6 +110,8 @@ export interface RenderOptions {
99
110
  * wires it instead of embedding it. Mutually exclusive with `derived` in practice —
100
111
  * passing neither is what silently produced `defineModule({ models })`. */
101
112
  externalDerived?: ExternalDerived;
113
+ /** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
114
+ lifecycleTables?: ReadonlySet<string>;
102
115
  }
103
116
 
104
117
  /**
@@ -363,6 +376,21 @@ function schemaOf(table: string): string {
363
376
  return dot === -1 ? 'public' : table.slice(0, dot);
364
377
  }
365
378
 
379
+ /** `users` / `public.users` → `public.users`; `a6.users` stays. pg_get_constraintdef leaves a public ref bare. */
380
+ function qualifiedName(table: string): string {
381
+ return `${schemaOf(table)}.${bareName(table)}`;
382
+ }
383
+
384
+ /**
385
+ * The pulled set, keyed on QUALIFIED names: an FK renders as a model reference exactly when its
386
+ * qualified target is pulled. Keyed on bare names, a pull of `a6` alone (holding `a6.users`)
387
+ * rendered an FK into `public.users` as `() => User` and imported it from a file the pull never
388
+ * wrote. A caller's bare entry still reads as public.
389
+ */
390
+ function knownSet(tables: Iterable<string>): Set<string> {
391
+ return new Set([...tables].map(qualifiedName));
392
+ }
393
+
366
394
  /**
367
395
  * A table name → its model variable: PascalCase, naively singularized (one trailing
368
396
  * `s` strips, whatever the word). `image_variants` → `ImageVariant`.
@@ -383,7 +411,7 @@ export function modelVarName(table: string): string {
383
411
  // nothing renames when a second schema arrives. Same rule the derived renderer already
384
412
  // uses for its own symbols.
385
413
  const owner = schemaOf(table);
386
- return owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular);
414
+ return safeBinding(owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular), liveObject('table', table));
387
415
  }
388
416
 
389
417
  /**
@@ -489,11 +517,6 @@ function parsePgArrayElements(literal: string): Array<{ raw: string; quoted: boo
489
517
  return out;
490
518
  }
491
519
 
492
- /** A single-quoted TS string literal (the model idiom), escaping embedded quotes. */
493
- function tsLiteral(value: string): string {
494
- return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
495
- }
496
-
497
520
  /**
498
521
  * The `field.serial()`/`field.bigserial()` factory for an integer/bigint column whose
499
522
  * default is its own conventional sequence (`<table>_<column>_seq`, optionally
@@ -509,7 +532,7 @@ function serialFactoryCall(table: TableSchema, col: ColumnSchema): string | null
509
532
  }
510
533
 
511
534
  /** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
512
- function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>): string {
535
+ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>, fkTargets?: FkTargets): string {
513
536
  // An enum column's type is the enum's name; render the explicit-name field.enum with its
514
537
  // values (the always-explicit form round-trips the name and the value order).
515
538
  const enumValues = enums.get(col.type);
@@ -522,7 +545,7 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
522
545
  // type is the identity, so the round trip is clean: no coercion, no FIXME, no
523
546
  // plan statement. The note stays — a verbatim type is a fact worth seeing.
524
547
  let expr = call ?? verbatimFieldCall(col.type);
525
- let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
548
+ let comment = call ? '' : ` // verbatim: no first-class field for '${commentText(col.type)}' — carried as-is`;
526
549
 
527
550
  // The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
528
551
  // pulled surrogate key reads the way one is authored, and rendered at all because without it
@@ -540,26 +563,20 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
540
563
 
541
564
  const fk = table.foreignKeys.find((f) => f.columns.length === 1 && f.columns[0] === col.name);
542
565
  if (fk) {
543
- if (known.has(bareName(fk.refTable))) {
544
- const actions = [
545
- fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
546
- fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
547
- ].filter(Boolean);
548
- // A8 — the FK's own name, said only when it is not the one the compiler would generate.
549
- if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
550
- actions.push(`name: ${tsLiteral(fk.name)}`);
551
- }
552
- // The key is live and has NEVER been checked against the rows already in the table. Rendering
553
- // it as an ordinary reference would declare a state this database is not in — and the FIRST
554
- // plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
555
- // this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
556
- // in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
557
- // found, the adoption plan comes out empty, and removing the flag later is how an operator
558
- // opts into the scan. Same rule as the index and constraint names one line up: be faithful.
559
- if (fk.notValid) actions.push('validate: false');
560
- const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
566
+ const opts = foreignKeyOptions(table, fk);
567
+ if (known.has(qualifiedName(fk.refTable))) {
561
568
  expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
562
- } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
569
+ } else {
570
+ // F3e — the target is outside the pulled set, so it is declared as what it is: a table no
571
+ // model declares. The FK stays in the declared state, so pull then plan is empty; as a
572
+ // comment it was undeclared, and the first plan proposed to drop it.
573
+ const target = externalTarget(table, fk, fkTargets);
574
+ if (target.ok) {
575
+ expr += `.references({ schema: ${tsLiteral(target.schema)}, table: ${tsLiteral(target.table)}, `
576
+ + `column: ${tsLiteral(fk.refColumns[0])} }${opts})`;
577
+ comment += externalCascadeComment(table, fk);
578
+ } else comment += ` // FK → ${commentText(target.shown)} (not in the pulled set)`;
579
+ }
563
580
  }
564
581
 
565
582
  if (col.default != null && !serial) {
@@ -568,13 +585,125 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
568
585
  // normalized form — stripping a trailing cast here could change semantics
569
586
  // (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
570
587
  // Drift closed by construction: pull output converges instead of shedding a TODO.
571
- expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
588
+ expr += d ?? `.defaultSql(sql\`${templateBody(col.default)}\`)`;
572
589
  }
573
590
 
574
591
  const validate = validates.get(col.name);
575
592
  if (validate) expr += `.validate(${validate})`;
576
593
 
577
- return ` ${toCamelCase(col.name)}: ${expr},${comment}`;
594
+ const key = safeKey(toCamelCase(col.name), `${liveObject('column', col.name)} of ${liveObject('table', table.table)}`);
595
+ return ` ${key}: ${expr},${comment}`;
596
+ }
597
+
598
+ type LiveForeignKey = TableSchema['foreignKeys'][number];
599
+
600
+ /**
601
+ * The `, { … }` options a pulled FK renders with, '' when it needs none. Shared by the model
602
+ * thunk, the external form and the composite form, so all three say the same things.
603
+ */
604
+ function foreignKeyOptions(table: TableSchema, fk: LiveForeignKey): string {
605
+ const actions = [
606
+ fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
607
+ fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
608
+ ].filter(Boolean);
609
+ // A8 — the FK's own name, said only when it is not the one the compiler would generate.
610
+ if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
611
+ actions.push(`name: ${tsLiteral(fk.name)}`);
612
+ }
613
+ // The key is live and has NEVER been checked against the rows already in the table. Rendering
614
+ // it as an ordinary reference would declare a state this database is not in — and the FIRST
615
+ // plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
616
+ // this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
617
+ // in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
618
+ // found, the adoption plan comes out empty, and removing the flag later is how an operator
619
+ // opts into the scan. Same rule as the index and constraint names: be faithful.
620
+ if (fk.notValid) actions.push('validate: false');
621
+ return actions.length ? `, { ${actions.join(', ')} }` : '';
622
+ }
623
+
624
+ /** (owning table, FK name) → the target's exact schema and table, from the catalog. */
625
+ export type FkTargets = ReadonlyMap<string, { schema: string; table: string }>;
626
+
627
+ /** `schema.table`, with a part double-quoted when it holds a dot or a quote: public."a.b", never public.a.b. */
628
+ function unambiguousName(...parts: string[]): string {
629
+ return parts.map((p) => (/[."]/.test(p) ? `"${p.replace(/"/g, '""')}"` : p)).join('.');
630
+ }
631
+
632
+ type ExternalTarget =
633
+ | { ok: true; schema: string; table: string }
634
+ | { ok: false; shown: string; reason: string };
635
+
636
+ /**
637
+ * The exact target of an out-of-set FK, and whether the external form can declare it.
638
+ *
639
+ * The schema and table come from the CATALOG (`fkTargets`, by confrelid), never from splitting
640
+ * the deparsed `refTable`: quotes are stripped there, so public."a.b" reads as `a.b`, and a split
641
+ * declared it as { schema: 'a', table: 'b' }, another table, while plan and fingerprint agreed
642
+ * (both sides compile to `a.b`). A key the catalog map does not hold fails closed. Without a map
643
+ * (a pure caller), the split is the only answer there is.
644
+ *
645
+ * Each name must be one `@everystack/model` accepts (no quote, dot or control character; at most
646
+ * 63 bytes). One that is not keeps the comment it had before F3e, and db:pull warns.
647
+ */
648
+ function externalTarget(table: TableSchema, fk: LiveForeignKey, fkTargets?: FkTargets): ExternalTarget {
649
+ const found = fkTargets
650
+ ? fkTargets.get(foreignKeyTargetKey(table.table, fk.name))
651
+ : { schema: schemaOf(fk.refTable), table: bareName(fk.refTable) };
652
+ if (!found) return { ok: false, shown: fk.refTable, reason: 'the catalog read names no target for it' };
653
+ const shown = unambiguousName(found.schema, found.table);
654
+ // A CHANGED target fails closed like a missing one: on --stage the catalog read and the
655
+ // snapshot are two reads at two moments. Compared in the deparse form (public bare, quotes
656
+ // stripped), which is exactly what the snapshot's refTable holds.
657
+ const deparsed = found.schema === 'public' ? found.table : `${found.schema}.${found.table}`;
658
+ if (fkTargets && deparsed !== fk.refTable) {
659
+ return {
660
+ ok: false, shown: fk.refTable,
661
+ reason: `the catalog read names ${shown}, a different target than the snapshot: the two reads disagree; pull again`,
662
+ };
663
+ }
664
+ const parts: Array<[string, string]> = [['schema', found.schema], ['table', found.table], ...fk.refColumns.map((c): [string, string] => ['column', c])];
665
+ for (const [what, name] of parts) {
666
+ const refusal = externalIdentifierRefusal(name);
667
+ if (refusal) return { ok: false, shown, reason: `${what} ${JSON.stringify(name)} ${refusal}` };
668
+ }
669
+ if (fk.refColumns.length !== fk.columns.length) return { ok: false, shown, reason: 'its column lists differ in length' };
670
+ return { ok: true, schema: found.schema, table: found.table };
671
+ }
672
+
673
+ /** The model comment for an external ON DELETE CASCADE ('' otherwise): a delete elsewhere reaches here. */
674
+ function externalCascadeComment(table: TableSchema, fk: LiveForeignKey): string {
675
+ if (fk.onDelete !== 'cascade') return '';
676
+ return ` // external FK ON DELETE CASCADE: a delete in ${commentText(qualifiedName(fk.refTable))} cascades into ${commentText(qualifiedName(table.table))}`;
677
+ }
678
+
679
+ /**
680
+ * The pull's warnings about FKs that point OUTSIDE the pulled set: one per key the external form
681
+ * cannot declare (it stays a comment, so the next plan proposes to drop it), and one per key with
682
+ * ON DELETE CASCADE (deleting a row in another system's table deletes rows in this one; the model
683
+ * carries that fact as a comment too).
684
+ */
685
+ export function externalForeignKeyWarnings(snapshot: SchemaSnapshot, schema: string | string[], fkTargets?: FkTargets): string[] {
686
+ const tables = pullableTables(snapshot, schema);
687
+ const known = knownSet(tables.map((t) => t.table));
688
+ const out: string[] = [];
689
+ for (const t of tables) {
690
+ for (const fk of t.foreignKeys) {
691
+ if (known.has(qualifiedName(fk.refTable))) continue;
692
+ const target = externalTarget(t, fk, fkTargets);
693
+ if (!target.ok) {
694
+ out.push(
695
+ `${qualifiedName(t.table)}: foreign key ${fk.name} references ${target.shown}, which the external form cannot declare (${target.reason}): `
696
+ + 'it is left as a comment, and the next plan proposes to drop it.',
697
+ );
698
+ }
699
+ if (fk.onDelete !== 'cascade') continue;
700
+ out.push(
701
+ `${qualifiedName(t.table)}: foreign key ${fk.name} references ${qualifiedName(fk.refTable)} (not pulled) with ON DELETE CASCADE: `
702
+ + `a delete in another system's table cascades into this one.`,
703
+ );
704
+ }
705
+ }
706
+ return out;
578
707
  }
579
708
 
580
709
  /**
@@ -584,7 +713,8 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
584
713
  * renders its introspected columns with the SAME field vocabulary a pulled table gets.
585
714
  */
586
715
  export function renderFieldLines(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string[] {
587
- return table.columns.map((c) => renderField(table, c, known, enums, new Map()));
716
+ const pulled = knownSet(known);
717
+ return table.columns.map((c) => renderField(table, c, pulled, enums, new Map()));
588
718
  }
589
719
 
590
720
  /**
@@ -592,26 +722,37 @@ export function renderFieldLines(table: TableSchema, known: Set<string>, enums:
592
722
  * multi-column foreign keys — the table-level surface a column-scoped `field()` can't carry.
593
723
  * Single-column uniques and FKs are rendered on the field (`.unique()` / `.references()`), so
594
724
  * only composite ones land here. A composite FK whose target isn't in the pulled set is
595
- * surfaced as a comment, not faked. Returns '' when there are none.
725
+ * declared in the external `{ schema, table }` form (a comment only when its names cannot be).
726
+ * Returns '' when there are none.
596
727
  */
597
- function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string>): string {
728
+ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string>, fkTargets?: FkTargets): string {
598
729
  const items: string[] = [];
599
730
  for (const u of table.uniques) {
600
731
  if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
601
732
  }
733
+ // R9 — a check keeps the name the database has. Said only when it is NOT the positional name
734
+ // the compiler would give it: position counts only the checks rendered bare, exactly as the
735
+ // compiler counts only unnamed ones, so every pulled check compiles back to its live name.
736
+ let position = 0;
602
737
  for (const ck of checks) {
603
- items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
738
+ const predicate = `sql\`${templateBody(normalizeCheck(ck.expr))}\``;
739
+ if (ck.name === positionalCheckName(bareName(table.table), position)) {
740
+ items.push(`check(${predicate})`);
741
+ position++;
742
+ } else {
743
+ items.push(`check(${predicate}, { name: ${tsLiteral(ck.name)} })`);
744
+ }
604
745
  }
605
746
  for (const ix of table.indexes) {
606
747
  // Plain entries render as field keys; canonical-text entries (expressions, DESC,
607
748
  // opclass) render as raw sql`` args — the same form the builder declares (Brick E).
608
749
  const entry = (c: string): string =>
609
- /^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
750
+ /^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${templateBody(c)}\``;
610
751
  let s = `index(${ix.columns.map(entry).join(', ')})`;
611
752
  if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
612
753
  if (ix.unique) s += '.unique()';
613
754
  if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
614
- if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
755
+ if (ix.where) s += `.where(sql\`${templateBody(normalizeCheck(ix.where))}\`)`;
615
756
  // A7 — keep the name the database actually has, but only say it when it is not the one the
616
757
  // compiler would generate anyway. A brownfield schema arrives with its previous tooling's
617
758
  // names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
@@ -623,52 +764,61 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
623
764
  }
624
765
  items.push(s);
625
766
  }
767
+ let target: ExternalTarget;
626
768
  for (const fk of table.foreignKeys) {
627
769
  if (fk.columns.length <= 1) continue; // single-column FKs are rendered on the field
628
770
  const cols = `[${fk.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
629
771
  const refCols = `[${fk.refColumns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
630
- if (!known.has(bareName(fk.refTable))) {
631
- items.push(`// FIXME: composite FK ${cols} → ${fk.refTable} ${refCols} (target not in the pulled set)`);
632
- continue;
633
- }
634
- const actions = [
635
- fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
636
- fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
637
- ].filter(Boolean);
638
- // A8 — the FK's own name, said only when it is not the one the compiler would generate.
639
- if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
640
- actions.push(`name: ${tsLiteral(fk.name)}`);
772
+ const opts = foreignKeyOptions(table, fk);
773
+ if (known.has(qualifiedName(fk.refTable))) {
774
+ items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
775
+ } else if ((target = externalTarget(table, fk, fkTargets)).ok) {
776
+ // F3e — the composite twin of the external `.references()`: the reference columns are the
777
+ // live PostgreSQL names, verbatim (there is no model whose field keys they could be).
778
+ const cascade = externalCascadeComment(table, fk);
779
+ if (cascade) items.push(cascade.trim());
780
+ items.push(
781
+ `foreignKey(${cols}, { schema: ${tsLiteral(target.schema)}, table: ${tsLiteral(target.table)} }, `
782
+ + `[${fk.refColumns.map(tsLiteral).join(', ')}]${opts})`,
783
+ );
784
+ } else {
785
+ const shown = target.ok ? fk.refTable : target.shown;
786
+ items.push(`// FIXME: composite FK ${cols} → ${commentText(shown)} ${refCols} (target not in the pulled set)`);
641
787
  }
642
- // Faithful about validity, exactly as the single-column case above is — see there for why.
643
- if (fk.notValid) actions.push('validate: false');
644
- const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
645
- items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
646
788
  }
647
789
  if (!items.length) return '';
648
- return `\n constraints: [\n${items.map((i) => ` ${i},`).join('\n')}\n ],`;
790
+ // A comment line takes no trailing comma.
791
+ return `\n constraints: [\n${items.map((i) => (i.startsWith('//') ? ` ${i}` : ` ${i},`)).join('\n')}\n ],`;
649
792
  }
650
793
 
651
794
  /** One `export const X = defineModel(...)` block for a table. */
652
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): string {
795
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle = false): string {
653
796
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
654
797
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
655
798
  const validates = new Map<string, string>();
656
799
  const columnNames = new Set(table.columns.map((c) => c.name));
657
800
  const tableChecks: CheckConstraint[] = [];
801
+ // A `.validate()` compiles to a CHECK named `<table>_<column>_check`. When a DIFFERENT live
802
+ // check already holds that name, folding this one into the field would give the model two
803
+ // constraints under one name, so it stays a table-level check and keeps its own.
804
+ const liveCheckNames = new Set(table.checks.map((ck) => ck.name));
658
805
  for (const ck of table.checks) {
659
806
  const v = checkToValidate(ck.expr);
660
- if (v && columnNames.has(v.column) && !validates.has(v.column)) validates.set(v.column, v.zod);
807
+ const fieldCheckName = v ? `${bareName(table.table)}_${v.column}_check` : '';
808
+ const nameFree = ck.name === fieldCheckName || !liveCheckNames.has(fieldCheckName);
809
+ if (v && columnNames.has(v.column) && !validates.has(v.column) && nameFree) validates.set(v.column, v.zod);
661
810
  else tableChecks.push(ck);
662
811
  }
663
812
 
664
- const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
665
- const constraints = renderConstraintsBlock(table, tableChecks, known);
813
+ const pulled = knownSet(known);
814
+ const fields = table.columns.map((c) => renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
815
+ const constraints = renderConstraintsBlock(table, tableChecks, pulled, fkTargets);
666
816
  // The authz decision renders FIRST — before fields — because it is the first thing a
667
817
  // reviewer must resolve about a model (and where the field-report consumer's codemod
668
818
  // put it, proving the position is mechanical-edit-friendly).
669
819
  const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
670
820
  const writtenBy = writtenByStanza(table, liveAuthz);
671
- const softDelete = softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
821
+ const softDelete = lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
672
822
 
673
823
  // A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
674
824
  // qualification cannot ride in the first argument (that would make the table literally
@@ -677,8 +827,8 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
677
827
  // selects FROM metrics.impressions then failed to build. Same idiom the derived renderer
678
828
  // already uses for defineMaterializedTable.
679
829
  const owner = schemaOf(table.table);
680
- const schemaProp = owner === 'public' ? '' : ` schema: '${owner}',\n`;
681
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
830
+ const schemaProp = owner === 'public' ? '' : ` schema: ${tsLiteral(owner)},\n`;
831
+ return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
682
832
  }
683
833
 
684
834
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -716,6 +866,7 @@ function moduleFooter(
716
866
  multiline = false,
717
867
  external?: ExternalDerived,
718
868
  extensions?: string[],
869
+ governedRoles?: readonly string[],
719
870
  ): string {
720
871
  // A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
721
872
  // render (topo-ordered with the objects around it) but its name belongs in `models`.
@@ -751,9 +902,15 @@ function moduleFooter(
751
902
  if (extensions?.length) {
752
903
  // Sorted HERE, not just at the introspection: the renderer owns byte-stability, and a
753
904
  // caller passing an unsorted list must not produce a diff against an identical database.
754
- parts.push(`export const extensions = [${[...extensions].sort().map((e) => `'${e}'`).join(', ')}];`);
905
+ parts.push(`export const extensions = [${[...extensions].sort().map(tsLiteral).join(', ')}];`);
755
906
  keys.push('extensions');
756
907
  }
908
+ // --govern-roles is an access decision, so it is DECLARED where db:plan reads it: the module.
909
+ // Without it a later re-pull, or a table where the role holds nothing yet, would leave the
910
+ // role ungoverned again. Sorted for byte-stability; absent (byte-identical) without the flag.
911
+ if (governedRoles?.length) {
912
+ keys.push(`governedRoles: [${[...new Set(governedRoles)].sort().map(tsLiteral).join(', ')}]`);
913
+ }
757
914
  parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
758
915
  parts.push(`export const modules = [appModule];`);
759
916
  return parts.join('\n\n');
@@ -785,17 +942,17 @@ function externalDerivedNames(e: ExternalDerived): string[] {
785
942
  export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
786
943
  const schema = opts.schema ?? 'public';
787
944
  const tables = pullableTables(snapshot, schema);
788
- const known = new Set(tables.map((t) => bareName(t.table)));
945
+ const known = knownSet(tables.map((t) => t.table));
789
946
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
790
947
 
791
- const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles)));
948
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table)));
792
949
  // The derived layer (B5): sequences + views/matviews/functions, after the models they
793
950
  // reference, before the module wrapper that composes all three.
794
951
  if (opts.derived?.block) blocks.push(opts.derived.block);
795
952
 
796
953
  // Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
797
954
  // so a fresh brownfield project lands on the same shape the framework composes.
798
- const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions);
955
+ const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions, opts.governRoles);
799
956
 
800
957
  const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
801
958
 
@@ -821,15 +978,24 @@ export function modelImportPath(table: string): string {
821
978
  export function modelFileName(table: string): string {
822
979
  const owner = schemaOf(table);
823
980
  const bare = bareName(table).replace(/_/g, '-');
824
- return owner === 'public' ? `${bare}.ts` : `${owner.replace(/_/g, '-')}-${bare}.ts`;
981
+ const stem = owner === 'public' ? bare : `${owner.replace(/_/g, '-')}-${bare}`;
982
+ // The stem is a path written under --out and an import specifier: a live name holding a
983
+ // separator or a dot would write, or import, outside the directory.
984
+ return `${safeFileStem(stem, liveObject('table', table))}.ts`;
825
985
  }
826
986
 
827
- /** The bare tables (other than itself) a table's rendered FKs reference within the pulled set. */
987
+ /**
988
+ * The tables (other than itself) a table's rendered FKs reference within the pulled set,
989
+ * QUALIFIED — the `.references(() => …)` thunk names `modelVarName(fk.refTable)`, so the
990
+ * import must too. Keeping only the bare name imported `Group` from './groups' for a thunk
991
+ * naming `A6Group`, and a pulled non-public schema could not plan. pg_get_constraintdef
992
+ * leaves a public ref bare, so a bare ref is read as public.
993
+ */
828
994
  function referencedTables(table: TableSchema, known: Set<string>): string[] {
829
- const self = bareName(table.table);
995
+ const self = qualifiedName(table.table);
830
996
  const refs = new Set<string>();
831
997
  for (const fk of table.foreignKeys) {
832
- const target = bareName(fk.refTable);
998
+ const target = qualifiedName(fk.refTable);
833
999
  if (target !== self && known.has(target)) refs.add(target);
834
1000
  }
835
1001
  return [...refs].sort();
@@ -852,11 +1018,11 @@ export interface RenderedModelFile {
852
1018
  export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions = {}): RenderedModelFile[] {
853
1019
  const schema = opts.schema ?? 'public';
854
1020
  const tables = pullableTables(snapshot, schema);
855
- const known = new Set(tables.map((t) => bareName(t.table)));
1021
+ const known = knownSet(tables.map((t) => t.table));
856
1022
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
857
1023
 
858
1024
  const files: RenderedModelFile[] = tables.map((t) => {
859
- const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles));
1025
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleTables?.has(t.table));
860
1026
  const crossImports = referencedTables(t, known).map(
861
1027
  (target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
862
1028
  );
@@ -883,7 +1049,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
883
1049
  // Re-exported so the barrel remains the one place that describes the database.
884
1050
  ...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
885
1051
  ...(opts.derived?.block ? [opts.derived.block] : []),
886
- moduleFooter(names, opts.derived, true, ext, snapshot.extensions),
1052
+ moduleFooter(names, opts.derived, true, ext, snapshot.extensions, opts.governRoles),
887
1053
  ].join('\n\n');
888
1054
  files.push({ file: 'index.ts', source: index + '\n' });
889
1055