@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
@@ -14,6 +14,13 @@
14
14
  * - something is no longer true — dropped, revoked.
15
15
  * ⚠ DESTRUCTIVE real data can be lost. Orthogonal to the other two: its own line, never
16
16
  * combined with + or -.
17
+ * ⚠ ACCESS db:plan only: one summary line when the plan removes a policy, revokes a
18
+ * grant, turns row security off, or removes a function, view or trigger. Not destructive (no --confirm), but such a
19
+ * plan is never called "Safe to apply".
20
+ * ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
21
+ * Read from the replacement note the emitter writes from the change itself
22
+ * (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
23
+ * destructive, but never called "Safe to apply" either.
17
24
  *
18
25
  * And a banned list (BANNED_DEFAULT_WORDS): words that mean something to us and nothing to the
19
26
  * reader. Nothing is deleted — the full internal report is exactly as it was, behind --verbose.
@@ -28,6 +35,9 @@
28
35
  */
29
36
 
30
37
  import { statementBody } from './sql-statement';
38
+ import { parsePrimaryKeyReplacement } from './schema-diff';
39
+ import type { ReconcileAction, ReconcilePlan } from './derived-plan';
40
+ import type { DerivedKind } from './derived-source';
31
41
 
32
42
  // ---------------------------------------------------------------------------
33
43
  // The banned vocabulary.
@@ -168,7 +178,12 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
168
178
  return { symbol: '-', text: `remove column ${table}.${unquote(a[1])}` };
169
179
  }
170
180
  if ((a = /^ADD\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
171
- return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}` };
181
+ // A foreign key names what it points at, always as schema.table: the reviewer must see
182
+ // when a key reaches into a table no model declares (another system's auth.users).
183
+ const ref = /\sFOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+((?:"[^"]*"|[^\s("]+)(?:\.(?:"[^"]*"|[^\s("]+))?)/i.exec(rest);
184
+ const target = ref ? unquote(ref[1]) : null;
185
+ const referencing = target ? ` referencing ${target.includes('.') ? target : `public.${target}`}` : '';
186
+ return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}${referencing}` };
172
187
  }
173
188
  if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
174
189
  return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
@@ -420,25 +435,198 @@ export interface PlanReportInput {
420
435
  planPath: string | null;
421
436
  /** --verbose: the promise behind "(--verbose for the full list)" — show every line, uncapped. */
422
437
  verbose?: boolean;
438
+ /**
439
+ * The function/view/trigger reconcile db:apply runs after the plan (planReconcile's output).
440
+ * Absent, or with nothing that changes an object, the report is byte-identical to one without it.
441
+ */
442
+ derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
423
443
  }
424
444
 
445
+ // ---------------------------------------------------------------------------
446
+ // The functions, views and triggers db:apply reconciles after the plan.
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /** The reader's word for each kind. A matview is a view to the reader, as it is above. */
450
+ const DERIVED_KIND_WORD: Record<DerivedKind, string> = {
451
+ function: 'function', view: 'view', 'materialized view': 'view', trigger: 'trigger', sql: 'object',
452
+ };
453
+
454
+ /** `public.posts.posts_touch` (a trigger's identity) → `posts_touch on posts`; anything else → rel(). */
455
+ function derivedName(a: Pick<ReconcileAction, 'identity' | 'kind'>): string {
456
+ if (a.kind === 'trigger') {
457
+ const dot = a.identity.lastIndexOf('.');
458
+ if (dot > 0) return `${a.identity.slice(dot + 1)} on ${rel(a.identity.slice(0, dot))}`;
459
+ }
460
+ return rel(a.identity);
461
+ }
462
+
463
+ /**
464
+ * The reconcile plan db:apply will run, as plain change lines — one per OBJECT, never per
465
+ * statement, so the reader sees what changes rather than how the DDL has to be written.
466
+ *
467
+ * It reads `planReconcile`'s output and decides nothing: db:plan builds that plan with the same
468
+ * declared objects and the same options db:apply's catch-up passes to `executeReconcile`, so the
469
+ * list is what the apply will do. A rebuild (the drop and the create of one identity) is ONE
470
+ * `+ update` line, as it is for the table edge. Bookkeeping that changes no object — baseline,
471
+ * rebaseline, backfill, prune — is left out; db:apply's catch-up never asks for the first two.
472
+ *
473
+ * `statements` carries the GRANT/REVOKE SQL behind the grant lines, so the access count reads the
474
+ * same statements the lines describe. `removed` names the kind of every object that is dropped
475
+ * and NOT rebuilt: such an object can enforce access (a SECURITY DEFINER function, a soft-delete
476
+ * trigger, a definer view), and dropping a function takes its EXECUTE grants with it — no REVOKE
477
+ * statement exists for the count to see.
478
+ */
479
+ export function describeDerivedChanges(plan: Pick<ReconcilePlan, 'actions' | 'regrants'>): {
480
+ changes: PlainChange[];
481
+ statements: string[];
482
+ removed: string[];
483
+ } {
484
+ const created = new Set(plan.actions.filter((a) => a.action === 'create').map((a) => a.identity));
485
+ const rebuilt = new Set(plan.actions.filter((a) => a.action === 'drop' && created.has(a.identity)).map((a) => a.identity));
486
+ const changes: PlainChange[] = [];
487
+ const statements: string[] = [];
488
+ const removed: string[] = [];
489
+ const grantLines = (sqls: readonly string[]): void => {
490
+ for (const s of sqls) {
491
+ statements.push(s);
492
+ changes.push(describeChange(s));
493
+ }
494
+ };
495
+ for (const a of plan.actions) {
496
+ const what = `${DERIVED_KIND_WORD[a.kind] ?? 'object'} ${derivedName(a)}`;
497
+ switch (a.action) {
498
+ case 'drop':
499
+ if (!rebuilt.has(a.identity)) {
500
+ changes.push({ symbol: '-', text: `remove ${what}` });
501
+ removed.push(DERIVED_KIND_WORD[a.kind] ?? 'object');
502
+ }
503
+ break;
504
+ case 'create':
505
+ changes.push(rebuilt.has(a.identity)
506
+ ? { symbol: '+', text: `update ${what}: ${a.reason.startsWith('source changed') ? 'definition updated' : 'rebuilt'}` }
507
+ : { symbol: '+', text: `add ${what}` });
508
+ break;
509
+ case 'replace':
510
+ changes.push({ symbol: '+', text: `update ${what}: definition updated` });
511
+ grantLines(a.revokes ?? []);
512
+ break;
513
+ case 'refresh':
514
+ changes.push({ symbol: '+', text: `refresh ${what}` });
515
+ break;
516
+ default:
517
+ break;
518
+ }
519
+ }
520
+ for (const r of plan.regrants) grantLines(r.statements);
521
+ return { changes, statements, removed };
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Access: what a plan takes away, which is not the same as what it destroys.
526
+ // ---------------------------------------------------------------------------
527
+
528
+ export interface AccessChanges {
529
+ /** DROP POLICY statements. */
530
+ policies: number;
531
+ /** REVOKE statements. */
532
+ revokes: number;
533
+ /** Distinct tables with DISABLE / NO FORCE ROW LEVEL SECURITY (one table can get both). */
534
+ rowSecurityOff: number;
535
+ }
536
+
537
+ /**
538
+ * Count the statements that change who can read or write: a removed policy, a revoked grant,
539
+ * row security turned off. Read from the statement BODY (sql-statement's reading), so a
540
+ * `-- WARNING:` prologue that mentions a REVOKE does not count and one hiding a REVOKE does.
541
+ *
542
+ * NOT a destructive class. DESTRUCTIVE means data can be lost and gates --confirm, a snapshot
543
+ * and the stage refusal; none of those fit a policy change. This only decides the wording.
544
+ */
545
+ export function accessChanges(statements: readonly string[]): AccessChanges {
546
+ const out: AccessChanges = { policies: 0, revokes: 0, rowSecurityOff: 0 };
547
+ const rlsOff = new Set<string>();
548
+ let m: RegExpExecArray | null;
549
+ for (const statement of statements) {
550
+ const s = head(statement);
551
+ if (/^DROP\s+POLICY\b/i.test(s)) out.policies++;
552
+ else if (/^REVOKE\b/i.test(s)) out.revokes++;
553
+ else if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s[\s\S]*\b(?:DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/i.exec(s))) rlsOff.add(rel(m[1]));
554
+ }
555
+ out.rowSecurityOff = rlsOff.size;
556
+ return out;
557
+ }
558
+
559
+ /** The order removed things are named in, after policies. */
560
+ const REMOVED_KIND_ORDER = ['function', 'trigger', 'view', 'object'];
561
+
562
+ /** The ⚠ ACCESS line, or null when the plan takes no access away. */
563
+ function accessLine(a: AccessChanges, removedKinds: readonly string[] = []): string | null {
564
+ const removed = [
565
+ ...(a.policies ? [`${a.policies} ${plural(a.policies, 'policy', 'policies')}`] : []),
566
+ ...REMOVED_KIND_ORDER.flatMap((kind) => {
567
+ const n = removedKinds.filter((k) => k === kind).length;
568
+ return n ? [`${n} ${plural(n, kind, `${kind}s`)}`] : [];
569
+ }),
570
+ ];
571
+ const parts = [
572
+ ...(removed.length ? [`removes ${removed.join(', ')}`] : []),
573
+ ...(a.revokes ? [`revokes ${a.revokes} ${plural(a.revokes, 'grant', 'grants')}`] : []),
574
+ ...(a.rowSecurityOff ? [`turns off row security on ${a.rowSecurityOff} ${plural(a.rowSecurityOff, 'table', 'tables')}`] : []),
575
+ ];
576
+ return parts.length === 0 ? null : `${ACCESS_MARK} — ${parts.join(', ')}. Check who can still read and write.`;
577
+ }
578
+
579
+ const ACCESS_MARK = '⚠ ACCESS';
580
+
581
+ /** The ⚠ CONSTRAINT lines: one per primary key the plan replaces. */
582
+ function constraintLines(statements: readonly string[]): string[] {
583
+ return statements.flatMap((statement) => {
584
+ const r = parsePrimaryKeyReplacement(statement);
585
+ return r ? [`${CONSTRAINT_MARK} — primary key ${r.name} on ${rel(r.table)} is replaced by (${r.columns.join(', ')}). A foreign key on the old key fails the apply.`] : [];
586
+ });
587
+ }
588
+
589
+ const CONSTRAINT_MARK = '⚠ CONSTRAINT';
590
+
425
591
  /** db:plan's default output — the whole thing. */
426
592
  export function renderPlanReport(input: PlanReportInput): string[] {
427
593
  const { executable, target, stage, planPath, verbose = false } = input;
428
- if (executable.length === 0) return ['Up to date. Nothing to plan.'];
594
+ const derived = input.derived ? describeDerivedChanges(input.derived) : { changes: [], statements: [], removed: [] };
595
+ if (executable.length === 0 && derived.changes.length === 0) return ['Up to date. Nothing to plan.'];
429
596
 
597
+ const limit = (n: number): number => (verbose ? n : MAX_LISTED_CHANGES);
430
598
  const destructive = new Set(input.destructive.map(statementKey));
431
- const lines: string[] = [
432
- `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
433
- '',
434
- ...changeLines(executable, destructive, verbose ? executable.length : MAX_LISTED_CHANGES),
435
- '',
436
- ];
437
-
599
+ const lines: string[] = executable.length > 0
600
+ ? [
601
+ `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
602
+ '',
603
+ ...changeLines(executable, destructive, limit(executable.length)),
604
+ '',
605
+ ]
606
+ : [`Plan: no table, column or grant changes (${target})`, ''];
607
+
608
+ // Not in the plan file, by design: db:apply reads these from the checkout at apply time and
609
+ // they go stale between mint and apply (see db-plan.ts). The header says so, so the file is
610
+ // never read as the whole change.
611
+ if (derived.changes.length > 0) {
612
+ const shown = derived.changes.slice(0, limit(derived.changes.length)).map((c) => ` ${c.symbol} ${c.text}`);
613
+ const hidden = derived.changes.length - shown.length;
614
+ if (hidden > 0) shown.push(` … and ${hidden} more (--verbose for the full list)`);
615
+ lines.push(`Also on db:apply (from the checkout, not in ${planPath ?? 'the plan'}):`, '', ...shown, '');
616
+ }
617
+
618
+ const keys = constraintLines(executable);
619
+ lines.push(...keys);
620
+ const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
621
+ if (access) lines.push(access);
438
622
  lines.push(
439
623
  destructive.size > 0
440
624
  ? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
441
- : 'Non-destructive. Safe to apply.',
625
+ : access
626
+ ? 'Non-destructive. Verify the access changes before applying.'
627
+ : keys.length > 0
628
+ ? 'Non-destructive. Verify the replaced keys before applying.'
629
+ : 'Non-destructive. Safe to apply.',
442
630
  );
443
631
 
444
632
  // No file, no command to give — `--out -` piped the plan somewhere only the caller knows.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ModelLinkError } from '@everystack/model';
11
- import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
11
+ import { relationDeleteActions, type Ability, type FieldSpec, type ModelDescriptor, type ReferentialAction, type Relation } from '@everystack/model';
12
12
 
13
13
  function typeLabel(spec: FieldSpec): string {
14
14
  let base: string;
@@ -42,14 +42,15 @@ function referenceTarget(spec: FieldSpec): string | null {
42
42
  }
43
43
  }
44
44
 
45
- function fieldLine(name: string, spec: FieldSpec): string {
45
+ function fieldLine(name: string, spec: FieldSpec, derivedOnDelete?: ReferentialAction): string {
46
+ const onDelete = derivedOnDelete ?? spec.onDelete;
46
47
  const parts = [typeLabel(spec)];
47
48
  if (spec.isPrimaryKey) parts.push('pk');
48
49
  if (spec.isNotNull && !spec.isPrimaryKey) parts.push('not null');
49
50
  if (spec.isUnique) parts.push('unique');
50
51
  const target = referenceTarget(spec);
51
52
  if (target) {
52
- parts.push(`→ ${target}${spec.onDelete && spec.onDelete !== 'no action' ? ` (${spec.onDelete})` : ''}`);
53
+ parts.push(`→ ${target}${onDelete && onDelete !== 'no action' ? ` (${onDelete})` : ''}`);
53
54
  }
54
55
  if (spec.defaultKind === 'now') parts.push('default: now()');
55
56
  else if (spec.defaultKind === 'random') parts.push('default: random');
@@ -79,7 +80,7 @@ function relationLine(name: string, rel: Relation): string {
79
80
  if (err instanceof ModelLinkError) throw err;
80
81
  /* forward reference that can't resolve here — label stays unknown */
81
82
  }
82
- const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
83
+ const shape = rel.kind === 'belongsTo' ? 'belongs to' : rel.kind === 'hasOne' ? 'has one' : 'has many';
83
84
  return `- \`${name}\` — ${shape} ${target} (via \`${rel.column}\`)`;
84
85
  }
85
86
 
@@ -155,6 +156,8 @@ export function renderDataModelSection(models: ModelDescriptor[], opts: DataMode
155
156
  '',
156
157
  "Compiled from the app's Models — edit `models/` and regenerate; never edit this section.",
157
158
  ];
159
+ // A parent's hasMany `dependent` sets a child FK's ON DELETE; derived from this set.
160
+ const dependents = relationDeleteActions(models);
158
161
  for (const m of models) {
159
162
  const heading = m.schema && m.schema !== 'public' ? `${m.schema}.${m.table}` : m.table;
160
163
  lines.push('', `### ${heading}`, '');
@@ -163,7 +166,7 @@ export function renderDataModelSection(models: ModelDescriptor[], opts: DataMode
163
166
  if (m.writtenBy !== 'app') annotations.push(`written by ${m.writtenBy}`);
164
167
  if (annotations.length) lines.push(`_Operational table — ${annotations.join('; ')}._`, '');
165
168
  for (const [name, builder] of Object.entries(m.fields)) {
166
- lines.push(fieldLine(name, builder.spec));
169
+ lines.push(fieldLine(name, builder.spec, dependents.get(m)?.get(name)));
167
170
  }
168
171
  const relations = Object.entries(m.relations ?? {});
169
172
  if (relations.length) {
@@ -19,9 +19,10 @@
19
19
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
20
20
  */
21
21
 
22
- import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
22
+ import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions } from '@everystack/model';
23
+ import { positionalCheckName } from '@everystack/model';
23
24
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
24
- import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
25
+ import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
25
26
  import { normalizeDeparsedExpr } from './deparse-normal';
26
27
 
27
28
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
@@ -59,6 +60,13 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
59
60
  const indexes: IndexSchema[] = [];
60
61
  let checkIndex = 0;
61
62
  const usedIndexNames = new Set<string>();
63
+ // A field's `.validate()` CHECK is named `<table>_<column>_check`; a declared table check may
64
+ // not take that name, or CREATE TABLE fails with two constraints under one name.
65
+ const fieldCheckNames = new Map<string, string>();
66
+ for (const [key, field] of Object.entries(model.fields)) {
67
+ const col = toSnakeCase(key);
68
+ if (fieldCheckPredicate(col, field.spec)) fieldCheckNames.set(`${model.table}_${col}_check`, key);
69
+ }
62
70
  for (const con of model.constraints) {
63
71
  if (con.kind === 'unique') {
64
72
  const cols = con.columns.map(toSnakeCase);
@@ -67,7 +75,20 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
67
75
  // Normalized like the live producer (schema-introspect): a model that transcribes a
68
76
  // pulled predicate must compare equal to the database it came from, whichever of the
69
77
  // two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
70
- checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: normalizeDeparsedExpr(con.predicate) });
78
+ //
79
+ // A DECLARED name is used verbatim and takes no position, so naming one check never
80
+ // renames its unnamed siblings; defineModel already refused duplicates and collisions
81
+ // with a positional name.
82
+ if (con.name !== undefined && fieldCheckNames.has(con.name)) {
83
+ throw new Error(
84
+ `model '${model.table}': check(sql\`${con.predicate}\`) is named '${con.name}', the name of the CHECK that field '${fieldCheckNames.get(con.name)}'.validate() compiles to. Choose another name.`,
85
+ );
86
+ }
87
+ checks.push({
88
+ name: con.name ?? positionalCheckName(model.table, checkIndex++),
89
+ expr: normalizeDeparsedExpr(con.predicate),
90
+ ...(con.name !== undefined ? { nameDeclared: true } : {}),
91
+ });
71
92
  } else if (con.kind === 'index') {
72
93
  // Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
73
94
  // DESC, opclass) pass verbatim — they are already SQL (Brick E).
@@ -133,7 +154,7 @@ const PG_TYPE: Record<string, string> = {
133
154
  date: 'date',
134
155
  };
135
156
 
136
- function pgType(spec: FieldSpec): string {
157
+ export function pgType(spec: FieldSpec): string {
137
158
  // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
138
159
  // `format_type` spells it.
139
160
  return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
@@ -193,16 +214,27 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
193
214
  * key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
194
215
  * for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
195
216
  * the state a build of that declaration produces would be two different states.
217
+ *
218
+ * `dependents` is the model SET's derived ON DELETE (`relationDeleteActions(models)`): a parent's
219
+ * hasMany `dependent` sets the action of this model's FK on that field. A caller holding the set
220
+ * passes it; it is never read from or written into a field spec, so the result depends only on
221
+ * the set. Without it a field compiles exactly as it declares.
196
222
  */
197
- export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
223
+ export function modelForeignKeys(model: ModelDescriptor, dependents?: DependentActions): ForeignKey[] {
198
224
  const out: ForeignKey[] = [];
225
+ const derived = dependents?.get(model);
199
226
 
200
227
  for (const [name, field] of Object.entries(model.fields)) {
228
+ // An external target (`{ schema, table, column }`, a table no model declares) yields the SAME
229
+ // fields a model target does, and nothing below this point knows which it was: the diff plans
230
+ // it and the fingerprint hashes it with no special case. Its column is a PostgreSQL name,
231
+ // verbatim; a model target's is its primary-key field, snake_cased.
232
+ const external = field.spec.referencesExternal;
201
233
  const ref = field.spec.references;
202
- if (!ref) continue;
203
- const target = ref() as ModelDescriptor;
234
+ if (!ref && !external) continue;
235
+ const target = external ? { schema: external.schema, table: external.table } : ref!() as ModelDescriptor;
204
236
  const col = toSnakeCase(name);
205
- const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
237
+ const targetPk = external ? external.column : toSnakeCase((target as ModelDescriptor).primaryKey[0] ?? 'id');
206
238
  out.push({
207
239
  // A DECLARED name wins; the generated one is the fallback. An adopted database keeps the
208
240
  // FK names its previous tooling chose (`fk_rails_9a1b2c3d4e`) instead of being renamed
@@ -212,16 +244,17 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
212
244
  columns: [col],
213
245
  refTable: refTableName(target),
214
246
  refColumns: [targetPk],
215
- ...refActions(field.spec),
247
+ ...refActions({ onDelete: derived?.get(name) ?? field.spec.onDelete, onUpdate: field.spec.onUpdate }),
216
248
  ...(field.spec.referencesValidate === false ? { notValid: true } : {}),
217
249
  });
218
250
  }
219
251
 
220
252
  for (const con of model.constraints) {
221
253
  if (con.kind !== 'foreignKey') continue;
222
- const target = con.references() as ModelDescriptor;
254
+ // External (`{ schema, table }`): the reference columns are PostgreSQL names, verbatim.
255
+ const target = con.external ?? con.references!() as ModelDescriptor;
223
256
  const cols = con.columns.map(toSnakeCase);
224
- const refCols = con.refColumns.map(toSnakeCase);
257
+ const refCols = con.external ? [...con.refColumns] : con.refColumns.map(toSnakeCase);
225
258
  out.push({
226
259
  name: con.constraintName ?? generatedForeignKeyName(model.table, cols, target.table, refCols),
227
260
  ...(con.constraintName !== undefined ? { nameDeclared: true } : {}),
@@ -255,7 +288,7 @@ export function generatedForeignKeyName(table: string, columns: string[], refTab
255
288
  * target schema is off the search_path — so the desired side must match, or an unchanged auth FK
256
289
  * would read as drift. public stays bare because it is on the search_path (the proven output).
257
290
  */
258
- function refTableName(target: ModelDescriptor): string {
291
+ function refTableName(target: { schema?: string; table: string }): string {
259
292
  return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
260
293
  }
261
294
 
@@ -458,7 +491,8 @@ export function qualifiedTable(model: ModelDescriptor): string {
458
491
  /**
459
492
  * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
460
493
  * composite keys and uniques become table constraints, named the way drizzle
461
- * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`).
494
+ * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`). The key's name, either
495
+ * way, is `primaryKeyConstraintName`'s — the differ names a replaced key with it too.
462
496
  */
463
497
  export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
464
498
  const indent = opts.indent ?? '\t';
@@ -474,7 +508,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
474
508
  if (pkFields.length > 1) {
475
509
  const cols = pkFields.map(toSnakeCase);
476
510
  const quoted = cols.map((c) => `"${c}"`).join(',');
477
- lines.push(`${indent}CONSTRAINT "${model.table}_${cols.join('_')}_pk" PRIMARY KEY(${quoted})`);
511
+ lines.push(`${indent}CONSTRAINT "${primaryKeyConstraintName(model.table, cols)}" PRIMARY KEY(${quoted})`);
478
512
  }
479
513
 
480
514
  for (const [name, field] of entries) {
@@ -513,7 +547,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
513
547
  * structured form for the brownfield diff. They share the field→type/default/check
514
548
  * helpers, so they never disagree.)
515
549
  */
516
- export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
550
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
517
551
  const schema = model.schema ?? opts.schema ?? 'public';
518
552
  const entries = Object.entries(model.fields);
519
553
 
@@ -565,9 +599,14 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
565
599
  table: `${schema}.${model.table}`,
566
600
  columns,
567
601
  primaryKey: model.primaryKey.map(toSnakeCase),
602
+ // What a from-scratch build names the key — the same field introspection fills, so the two
603
+ // producers still meet at one IR. Never hashed (the fingerprint reads the columns only).
604
+ ...(model.primaryKey.length > 0
605
+ ? { primaryKeyName: primaryKeyConstraintName(model.table, model.primaryKey.map(toSnakeCase)) }
606
+ : {}),
568
607
  uniques,
569
608
  checks,
570
- foreignKeys: modelForeignKeys(model),
609
+ foreignKeys: modelForeignKeys(model, opts.dependents),
571
610
  indexes: tableConstraints.indexes,
572
611
  };
573
612
  }
@@ -627,8 +666,8 @@ export { sequenceCreateSql } from './schema-diff';
627
666
  * unproven-against-old-rows, so a migration artifact that installed it validated would build a
628
667
  * database that does not match the models it was generated from.
629
668
  */
630
- export function foreignKeySql(model: ModelDescriptor): string[] {
631
- return modelForeignKeys(model).map((fk) => {
669
+ export function foreignKeySql(model: ModelDescriptor, dependents?: DependentActions): string[] {
670
+ return modelForeignKeys(model, dependents).map((fk) => {
632
671
  const cols = fk.columns.map((c) => `"${c}"`).join(', ');
633
672
  const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
634
673
  // Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`