@everystack/cli 0.4.100 → 0.4.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +5 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +92 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
@@ -14,6 +14,16 @@
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
+ * ⚠ LOCK db:plan only: one line per plain CREATE INDEX on a live table with rows (it
21
+ * blocks writes while it builds), with the row estimate. Informational: the plan
22
+ * is still "Safe to apply", needs no --confirm and exits the same (index-lock.ts).
23
+ * ⚠ CONSTRAINT db:plan only: one line per primary key the plan replaces, naming the old key.
24
+ * Read from the replacement note the emitter writes from the change itself
25
+ * (schema-diff's parsePrimaryKeyReplacement), never from the DDL. Not
26
+ * destructive, but never called "Safe to apply" either.
17
27
  *
18
28
  * And a banned list (BANNED_DEFAULT_WORDS): words that mean something to us and nothing to the
19
29
  * reader. Nothing is deleted — the full internal report is exactly as it was, behind --verbose.
@@ -28,6 +38,10 @@
28
38
  */
29
39
 
30
40
  import { statementBody } from './sql-statement';
41
+ import { parsePrimaryKeyReplacement } from './schema-diff';
42
+ import { lockLines, type LockWarning } from './index-lock';
43
+ import type { ReconcileAction, ReconcilePlan } from './derived-plan';
44
+ import type { DerivedKind } from './derived-source';
31
45
 
32
46
  // ---------------------------------------------------------------------------
33
47
  // The banned vocabulary.
@@ -168,7 +182,12 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
168
182
  return { symbol: '-', text: `remove column ${table}.${unquote(a[1])}` };
169
183
  }
170
184
  if ((a = /^ADD\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
171
- return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}` };
185
+ // A foreign key names what it points at, always as schema.table: the reviewer must see
186
+ // when a key reaches into a table no model declares (another system's auth.users).
187
+ const ref = /\sFOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+((?:"[^"]*"|[^\s("]+)(?:\.(?:"[^"]*"|[^\s("]+))?)/i.exec(rest);
188
+ const target = ref ? unquote(ref[1]) : null;
189
+ const referencing = target ? ` referencing ${target.includes('.') ? target : `public.${target}`}` : '';
190
+ return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}${referencing}` };
172
191
  }
173
192
  if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
174
193
  return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
@@ -420,25 +439,207 @@ export interface PlanReportInput {
420
439
  planPath: string | null;
421
440
  /** --verbose: the promise behind "(--verbose for the full list)" — show every line, uncapped. */
422
441
  verbose?: boolean;
442
+ /**
443
+ * The function/view/trigger reconcile db:apply runs after the plan (planReconcile's output).
444
+ * Absent, or with nothing that changes an object, the report is byte-identical to one without it.
445
+ */
446
+ derived?: Pick<ReconcilePlan, 'actions' | 'regrants'>;
447
+ /** The plan's ⚠ LOCK data (EdgePlan.lockWarnings). Informational; absent or empty prints nothing. */
448
+ lockWarnings?: readonly LockWarning[];
449
+ /** The plan's deleted-row reads (EdgePlan.deletedReads): one ⚠ ACCESS line each. */
450
+ deletedReads?: readonly { table: string; role: string }[];
423
451
  }
424
452
 
453
+ // ---------------------------------------------------------------------------
454
+ // The functions, views and triggers db:apply reconciles after the plan.
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /** The reader's word for each kind. A matview is a view to the reader, as it is above. */
458
+ const DERIVED_KIND_WORD: Record<DerivedKind, string> = {
459
+ function: 'function', view: 'view', 'materialized view': 'view', trigger: 'trigger', sql: 'object',
460
+ };
461
+
462
+ /** `public.posts.posts_touch` (a trigger's identity) → `posts_touch on posts`; anything else → rel(). */
463
+ function derivedName(a: Pick<ReconcileAction, 'identity' | 'kind'>): string {
464
+ if (a.kind === 'trigger') {
465
+ const dot = a.identity.lastIndexOf('.');
466
+ if (dot > 0) return `${a.identity.slice(dot + 1)} on ${rel(a.identity.slice(0, dot))}`;
467
+ }
468
+ return rel(a.identity);
469
+ }
470
+
471
+ /**
472
+ * The reconcile plan db:apply will run, as plain change lines — one per OBJECT, never per
473
+ * statement, so the reader sees what changes rather than how the DDL has to be written.
474
+ *
475
+ * It reads `planReconcile`'s output and decides nothing: db:plan builds that plan with the same
476
+ * declared objects and the same options db:apply's catch-up passes to `executeReconcile`, so the
477
+ * list is what the apply will do. A rebuild (the drop and the create of one identity) is ONE
478
+ * `+ update` line, as it is for the table edge. Bookkeeping that changes no object — baseline,
479
+ * rebaseline, backfill, prune — is left out; db:apply's catch-up never asks for the first two.
480
+ *
481
+ * `statements` carries the GRANT/REVOKE SQL behind the grant lines, so the access count reads the
482
+ * same statements the lines describe. `removed` names the kind of every object that is dropped
483
+ * and NOT rebuilt: such an object can enforce access (a SECURITY DEFINER function, a soft-delete
484
+ * trigger, a definer view), and dropping a function takes its EXECUTE grants with it — no REVOKE
485
+ * statement exists for the count to see.
486
+ */
487
+ export function describeDerivedChanges(plan: Pick<ReconcilePlan, 'actions' | 'regrants'>): {
488
+ changes: PlainChange[];
489
+ statements: string[];
490
+ removed: string[];
491
+ } {
492
+ const created = new Set(plan.actions.filter((a) => a.action === 'create').map((a) => a.identity));
493
+ const rebuilt = new Set(plan.actions.filter((a) => a.action === 'drop' && created.has(a.identity)).map((a) => a.identity));
494
+ const changes: PlainChange[] = [];
495
+ const statements: string[] = [];
496
+ const removed: string[] = [];
497
+ const grantLines = (sqls: readonly string[]): void => {
498
+ for (const s of sqls) {
499
+ statements.push(s);
500
+ changes.push(describeChange(s));
501
+ }
502
+ };
503
+ for (const a of plan.actions) {
504
+ const what = `${DERIVED_KIND_WORD[a.kind] ?? 'object'} ${derivedName(a)}`;
505
+ switch (a.action) {
506
+ case 'drop':
507
+ if (!rebuilt.has(a.identity)) {
508
+ changes.push({ symbol: '-', text: `remove ${what}` });
509
+ removed.push(DERIVED_KIND_WORD[a.kind] ?? 'object');
510
+ }
511
+ break;
512
+ case 'create':
513
+ changes.push(rebuilt.has(a.identity)
514
+ ? { symbol: '+', text: `update ${what}: ${a.reason.startsWith('source changed') ? 'definition updated' : 'rebuilt'}` }
515
+ : { symbol: '+', text: `add ${what}` });
516
+ break;
517
+ case 'replace':
518
+ changes.push({ symbol: '+', text: `update ${what}: definition updated` });
519
+ grantLines(a.revokes ?? []);
520
+ break;
521
+ case 'refresh':
522
+ changes.push({ symbol: '+', text: `refresh ${what}` });
523
+ break;
524
+ default:
525
+ break;
526
+ }
527
+ }
528
+ for (const r of plan.regrants) grantLines(r.statements);
529
+ return { changes, statements, removed };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Access: what a plan takes away, which is not the same as what it destroys.
534
+ // ---------------------------------------------------------------------------
535
+
536
+ export interface AccessChanges {
537
+ /** DROP POLICY statements. */
538
+ policies: number;
539
+ /** REVOKE statements. */
540
+ revokes: number;
541
+ /** Distinct tables with DISABLE / NO FORCE ROW LEVEL SECURITY (one table can get both). */
542
+ rowSecurityOff: number;
543
+ }
544
+
545
+ /**
546
+ * Count the statements that change who can read or write: a removed policy, a revoked grant,
547
+ * row security turned off. Read from the statement BODY (sql-statement's reading), so a
548
+ * `-- WARNING:` prologue that mentions a REVOKE does not count and one hiding a REVOKE does.
549
+ *
550
+ * NOT a destructive class. DESTRUCTIVE means data can be lost and gates --confirm, a snapshot
551
+ * and the stage refusal; none of those fit a policy change. This only decides the wording.
552
+ */
553
+ export function accessChanges(statements: readonly string[]): AccessChanges {
554
+ const out: AccessChanges = { policies: 0, revokes: 0, rowSecurityOff: 0 };
555
+ const rlsOff = new Set<string>();
556
+ let m: RegExpExecArray | null;
557
+ for (const statement of statements) {
558
+ const s = head(statement);
559
+ if (/^DROP\s+POLICY\b/i.test(s)) out.policies++;
560
+ else if (/^REVOKE\b/i.test(s)) out.revokes++;
561
+ 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]));
562
+ }
563
+ out.rowSecurityOff = rlsOff.size;
564
+ return out;
565
+ }
566
+
567
+ /** The order removed things are named in, after policies. */
568
+ const REMOVED_KIND_ORDER = ['function', 'trigger', 'view', 'object'];
569
+
570
+ /** The ⚠ ACCESS line, or null when the plan takes no access away. */
571
+ function accessLine(a: AccessChanges, removedKinds: readonly string[] = []): string | null {
572
+ const removed = [
573
+ ...(a.policies ? [`${a.policies} ${plural(a.policies, 'policy', 'policies')}`] : []),
574
+ ...REMOVED_KIND_ORDER.flatMap((kind) => {
575
+ const n = removedKinds.filter((k) => k === kind).length;
576
+ return n ? [`${n} ${plural(n, kind, `${kind}s`)}`] : [];
577
+ }),
578
+ ];
579
+ const parts = [
580
+ ...(removed.length ? [`removes ${removed.join(', ')}`] : []),
581
+ ...(a.revokes ? [`revokes ${a.revokes} ${plural(a.revokes, 'grant', 'grants')}`] : []),
582
+ ...(a.rowSecurityOff ? [`turns off row security on ${a.rowSecurityOff} ${plural(a.rowSecurityOff, 'table', 'tables')}`] : []),
583
+ ];
584
+ return parts.length === 0 ? null : `${ACCESS_MARK} — ${parts.join(', ')}. Check who can still read and write.`;
585
+ }
586
+
587
+ const ACCESS_MARK = '⚠ ACCESS';
588
+
589
+ /** The ⚠ CONSTRAINT lines: one per primary key the plan replaces. */
590
+ function constraintLines(statements: readonly string[]): string[] {
591
+ return statements.flatMap((statement) => {
592
+ const r = parsePrimaryKeyReplacement(statement);
593
+ 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.`] : [];
594
+ });
595
+ }
596
+
597
+ const CONSTRAINT_MARK = '⚠ CONSTRAINT';
598
+
425
599
  /** db:plan's default output — the whole thing. */
426
600
  export function renderPlanReport(input: PlanReportInput): string[] {
427
601
  const { executable, target, stage, planPath, verbose = false } = input;
428
- if (executable.length === 0) return ['Up to date. Nothing to plan.'];
602
+ const derived = input.derived ? describeDerivedChanges(input.derived) : { changes: [], statements: [], removed: [] };
603
+ if (executable.length === 0 && derived.changes.length === 0) return ['Up to date. Nothing to plan.'];
429
604
 
605
+ const limit = (n: number): number => (verbose ? n : MAX_LISTED_CHANGES);
430
606
  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
-
607
+ const lines: string[] = executable.length > 0
608
+ ? [
609
+ `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
610
+ '',
611
+ ...changeLines(executable, destructive, limit(executable.length)),
612
+ '',
613
+ ]
614
+ : [`Plan: no table, column or grant changes (${target})`, ''];
615
+
616
+ // Not in the plan file, by design: db:apply reads these from the checkout at apply time and
617
+ // they go stale between mint and apply (see db-plan.ts). The header says so, so the file is
618
+ // never read as the whole change.
619
+ if (derived.changes.length > 0) {
620
+ const shown = derived.changes.slice(0, limit(derived.changes.length)).map((c) => ` ${c.symbol} ${c.text}`);
621
+ const hidden = derived.changes.length - shown.length;
622
+ if (hidden > 0) shown.push(` … and ${hidden} more (--verbose for the full list)`);
623
+ lines.push(`Also on db:apply (from the checkout, not in ${planPath ?? 'the plan'}):`, '', ...shown, '');
624
+ }
625
+
626
+ const keys = constraintLines(executable);
627
+ lines.push(...keys);
628
+ lines.push(...lockLines(input.lockWarnings ?? []));
629
+ const access = accessLine(accessChanges([...executable, ...derived.statements]), derived.removed);
630
+ if (access) lines.push(access);
631
+ // can('read', { deleted }): a role that can now read deleted rows. Not destructive (no --confirm);
632
+ // like every other ACCESS line, the plan is then not called "Safe to apply".
633
+ const deletedReads = (input.deletedReads ?? []).map((d) => `${ACCESS_MARK} — role ${d.role} can read deleted rows of ${d.table}.`);
634
+ lines.push(...deletedReads);
438
635
  lines.push(
439
636
  destructive.size > 0
440
637
  ? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
441
- : 'Non-destructive. Safe to apply.',
638
+ : access || deletedReads.length > 0
639
+ ? 'Non-destructive. Verify the access changes before applying.'
640
+ : keys.length > 0
641
+ ? 'Non-destructive. Verify the replaced keys before applying.'
642
+ : 'Non-destructive. Safe to apply.',
442
643
  );
443
644
 
444
645
  // 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,10 +19,11 @@
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 { createHash } from 'node:crypto';
23
+ import type { ModelDescriptor, FieldSpec, SequenceDescriptor, DependentActions, DependentIndexes } from '@everystack/model';
23
24
  import { positionalCheckName } from '@everystack/model';
24
25
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
25
- import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
26
+ import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
26
27
  import { normalizeDeparsedExpr } from './deparse-normal';
27
28
 
28
29
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
@@ -54,6 +55,33 @@ export function generatedIndexName(table: string, columns: string[]): string {
54
55
  return `${table}_${parts.join('_')}_index`;
55
56
  }
56
57
 
58
+ /**
59
+ * The name of a generated lifecycle FK index: `<table>_<columns>_lifecycle_fk_idx`. The suffix keeps
60
+ * it apart from author names (an author's generated name ends `_index`) and marks it generated, so
61
+ * db:pull folds it. Over PostgreSQL's 63 bytes it keeps 56 and appends `_` and 6 hex of a sha256 of
62
+ * the full name, branch-db's convention, so two long names never truncate to one.
63
+ */
64
+ export function lifecycleFkIndexName(table: string, columns: readonly string[]): string {
65
+ const name = `${table}_${columns.join('_')}_lifecycle_fk_idx`;
66
+ if (Buffer.byteLength(name) <= 63) return name;
67
+ // 56 BYTES, never inside a character: a multi-byte name cut by characters overran 63 bytes, and
68
+ // PostgreSQL then truncated it to a name the generator never planned. ASCII cuts as before.
69
+ let prefix = '';
70
+ for (const ch of name) {
71
+ if (Buffer.byteLength(prefix + ch) > 56) break;
72
+ prefix += ch;
73
+ }
74
+ return `${prefix}_${createHash('sha256').update(name).digest('hex').slice(0, 6)}`;
75
+ }
76
+
77
+ /** The set's generated FK indexes for one model (`lifecycleDependentIndexes(models)`), as IR. */
78
+ export function dependentIndexSpecs(model: ModelDescriptor, dependentIndexes?: DependentIndexes): IndexSchema[] {
79
+ return (dependentIndexes?.get(model) ?? []).map((keys) => {
80
+ const columns = keys.map(toSnakeCase);
81
+ return { name: lifecycleFkIndexName(model.table, columns), columns, unique: false };
82
+ });
83
+ }
84
+
57
85
  export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
58
86
  const uniques: UniqueConstraint[] = [];
59
87
  const checks: CheckConstraint[] = [];
@@ -154,7 +182,7 @@ const PG_TYPE: Record<string, string> = {
154
182
  date: 'date',
155
183
  };
156
184
 
157
- function pgType(spec: FieldSpec): string {
185
+ export function pgType(spec: FieldSpec): string {
158
186
  // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
159
187
  // `format_type` spells it.
160
188
  return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
@@ -214,16 +242,27 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
214
242
  * key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
215
243
  * for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
216
244
  * the state a build of that declaration produces would be two different states.
245
+ *
246
+ * `dependents` is the model SET's derived ON DELETE (`relationDeleteActions(models)`): a parent's
247
+ * hasMany `dependent` sets the action of this model's FK on that field. A caller holding the set
248
+ * passes it; it is never read from or written into a field spec, so the result depends only on
249
+ * the set. Without it a field compiles exactly as it declares.
217
250
  */
218
- export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
251
+ export function modelForeignKeys(model: ModelDescriptor, dependents?: DependentActions): ForeignKey[] {
219
252
  const out: ForeignKey[] = [];
253
+ const derived = dependents?.get(model);
220
254
 
221
255
  for (const [name, field] of Object.entries(model.fields)) {
256
+ // An external target (`{ schema, table, column }`, a table no model declares) yields the SAME
257
+ // fields a model target does, and nothing below this point knows which it was: the diff plans
258
+ // it and the fingerprint hashes it with no special case. Its column is a PostgreSQL name,
259
+ // verbatim; a model target's is its primary-key field, snake_cased.
260
+ const external = field.spec.referencesExternal;
222
261
  const ref = field.spec.references;
223
- if (!ref) continue;
224
- const target = ref() as ModelDescriptor;
262
+ if (!ref && !external) continue;
263
+ const target = external ? { schema: external.schema, table: external.table } : ref!() as ModelDescriptor;
225
264
  const col = toSnakeCase(name);
226
- const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
265
+ const targetPk = external ? external.column : toSnakeCase((target as ModelDescriptor).primaryKey[0] ?? 'id');
227
266
  out.push({
228
267
  // A DECLARED name wins; the generated one is the fallback. An adopted database keeps the
229
268
  // FK names its previous tooling chose (`fk_rails_9a1b2c3d4e`) instead of being renamed
@@ -233,16 +272,17 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
233
272
  columns: [col],
234
273
  refTable: refTableName(target),
235
274
  refColumns: [targetPk],
236
- ...refActions(field.spec),
275
+ ...refActions({ onDelete: derived?.get(name) ?? field.spec.onDelete, onUpdate: field.spec.onUpdate }),
237
276
  ...(field.spec.referencesValidate === false ? { notValid: true } : {}),
238
277
  });
239
278
  }
240
279
 
241
280
  for (const con of model.constraints) {
242
281
  if (con.kind !== 'foreignKey') continue;
243
- const target = con.references() as ModelDescriptor;
282
+ // External (`{ schema, table }`): the reference columns are PostgreSQL names, verbatim.
283
+ const target = con.external ?? con.references!() as ModelDescriptor;
244
284
  const cols = con.columns.map(toSnakeCase);
245
- const refCols = con.refColumns.map(toSnakeCase);
285
+ const refCols = con.external ? [...con.refColumns] : con.refColumns.map(toSnakeCase);
246
286
  out.push({
247
287
  name: con.constraintName ?? generatedForeignKeyName(model.table, cols, target.table, refCols),
248
288
  ...(con.constraintName !== undefined ? { nameDeclared: true } : {}),
@@ -276,7 +316,7 @@ export function generatedForeignKeyName(table: string, columns: string[], refTab
276
316
  * target schema is off the search_path — so the desired side must match, or an unchanged auth FK
277
317
  * would read as drift. public stays bare because it is on the search_path (the proven output).
278
318
  */
279
- function refTableName(target: ModelDescriptor): string {
319
+ function refTableName(target: { schema?: string; table: string }): string {
280
320
  return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
281
321
  }
282
322
 
@@ -479,7 +519,8 @@ export function qualifiedTable(model: ModelDescriptor): string {
479
519
  /**
480
520
  * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
481
521
  * composite keys and uniques become table constraints, named the way drizzle
482
- * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`).
522
+ * names them (`<table>_<cols>_pk`, `<table>_<col>_unique`). The key's name, either
523
+ * way, is `primaryKeyConstraintName`'s — the differ names a replaced key with it too.
483
524
  */
484
525
  export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
485
526
  const indent = opts.indent ?? '\t';
@@ -495,7 +536,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
495
536
  if (pkFields.length > 1) {
496
537
  const cols = pkFields.map(toSnakeCase);
497
538
  const quoted = cols.map((c) => `"${c}"`).join(',');
498
- lines.push(`${indent}CONSTRAINT "${model.table}_${cols.join('_')}_pk" PRIMARY KEY(${quoted})`);
539
+ lines.push(`${indent}CONSTRAINT "${primaryKeyConstraintName(model.table, cols)}" PRIMARY KEY(${quoted})`);
499
540
  }
500
541
 
501
542
  for (const [name, field] of entries) {
@@ -534,7 +575,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
534
575
  * structured form for the brownfield diff. They share the field→type/default/check
535
576
  * helpers, so they never disagree.)
536
577
  */
537
- export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
578
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions; dependentIndexes?: DependentIndexes } = {}): TableSchema {
538
579
  const schema = model.schema ?? opts.schema ?? 'public';
539
580
  const entries = Object.entries(model.fields);
540
581
 
@@ -586,10 +627,15 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
586
627
  table: `${schema}.${model.table}`,
587
628
  columns,
588
629
  primaryKey: model.primaryKey.map(toSnakeCase),
630
+ // What a from-scratch build names the key — the same field introspection fills, so the two
631
+ // producers still meet at one IR. Never hashed (the fingerprint reads the columns only).
632
+ ...(model.primaryKey.length > 0
633
+ ? { primaryKeyName: primaryKeyConstraintName(model.table, model.primaryKey.map(toSnakeCase)) }
634
+ : {}),
589
635
  uniques,
590
636
  checks,
591
- foreignKeys: modelForeignKeys(model),
592
- indexes: tableConstraints.indexes,
637
+ foreignKeys: modelForeignKeys(model, opts.dependents),
638
+ indexes: [...tableConstraints.indexes, ...dependentIndexSpecs(model, opts.dependentIndexes)],
593
639
  };
594
640
  }
595
641
 
@@ -648,8 +694,8 @@ export { sequenceCreateSql } from './schema-diff';
648
694
  * unproven-against-old-rows, so a migration artifact that installed it validated would build a
649
695
  * database that does not match the models it was generated from.
650
696
  */
651
- export function foreignKeySql(model: ModelDescriptor): string[] {
652
- return modelForeignKeys(model).map((fk) => {
697
+ export function foreignKeySql(model: ModelDescriptor, dependents?: DependentActions): string[] {
698
+ return modelForeignKeys(model, dependents).map((fk) => {
653
699
  const cols = fk.columns.map((c) => `"${c}"`).join(', ');
654
700
  const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
655
701
  // Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`