@everystack/cli 0.4.100 → 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 (46) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +61 -7
  5. package/src/cli/authz-contract.ts +23 -14
  6. package/src/cli/authz-derive.ts +282 -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 +30 -13
  11. package/src/cli/commands/db-pull.ts +153 -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 +4 -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 +57 -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.ts +5 -5
  29. package/src/cli/lifecycle-canon.ts +37 -0
  30. package/src/cli/lifecycle-derived.ts +721 -0
  31. package/src/cli/lifecycle-pull.ts +177 -0
  32. package/src/cli/lifecycle-verify.ts +232 -0
  33. package/src/cli/migration-compile.ts +8 -3
  34. package/src/cli/migration-generate.ts +23 -3
  35. package/src/cli/model-map.ts +255 -0
  36. package/src/cli/model-render.ts +226 -76
  37. package/src/cli/plain-output.ts +198 -10
  38. package/src/cli/runbook/model-section.ts +8 -5
  39. package/src/cli/schema-compile.ts +35 -17
  40. package/src/cli/schema-diff.ts +166 -31
  41. package/src/cli/schema-fingerprint.ts +39 -6
  42. package/src/cli/schema-introspect.ts +64 -2
  43. package/src/cli/schema-source.ts +40 -9
  44. package/src/cli/schema-swap.ts +3 -2
  45. package/src/cli/state-apply.ts +10 -4
  46. 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,10 +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
23
  import { positionalCheckName } from '@everystack/model';
24
24
  import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect';
25
- import { nextvalSequence, identityClause, type RenameMap } from './schema-diff';
25
+ import { nextvalSequence, identityClause, primaryKeyConstraintName, type RenameMap } from './schema-diff';
26
26
  import { normalizeDeparsedExpr } from './deparse-normal';
27
27
 
28
28
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
@@ -154,7 +154,7 @@ const PG_TYPE: Record<string, string> = {
154
154
  date: 'date',
155
155
  };
156
156
 
157
- function pgType(spec: FieldSpec): string {
157
+ export function pgType(spec: FieldSpec): string {
158
158
  // An array is the base type with `[]` appended — `text` → `text[]`, exactly as
159
159
  // `format_type` spells it.
160
160
  return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
@@ -214,16 +214,27 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
214
214
  * key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
215
215
  * for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
216
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.
217
222
  */
218
- export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
223
+ export function modelForeignKeys(model: ModelDescriptor, dependents?: DependentActions): ForeignKey[] {
219
224
  const out: ForeignKey[] = [];
225
+ const derived = dependents?.get(model);
220
226
 
221
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;
222
233
  const ref = field.spec.references;
223
- if (!ref) continue;
224
- const target = ref() as ModelDescriptor;
234
+ if (!ref && !external) continue;
235
+ const target = external ? { schema: external.schema, table: external.table } : ref!() as ModelDescriptor;
225
236
  const col = toSnakeCase(name);
226
- const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
237
+ const targetPk = external ? external.column : toSnakeCase((target as ModelDescriptor).primaryKey[0] ?? 'id');
227
238
  out.push({
228
239
  // A DECLARED name wins; the generated one is the fallback. An adopted database keeps the
229
240
  // FK names its previous tooling chose (`fk_rails_9a1b2c3d4e`) instead of being renamed
@@ -233,16 +244,17 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
233
244
  columns: [col],
234
245
  refTable: refTableName(target),
235
246
  refColumns: [targetPk],
236
- ...refActions(field.spec),
247
+ ...refActions({ onDelete: derived?.get(name) ?? field.spec.onDelete, onUpdate: field.spec.onUpdate }),
237
248
  ...(field.spec.referencesValidate === false ? { notValid: true } : {}),
238
249
  });
239
250
  }
240
251
 
241
252
  for (const con of model.constraints) {
242
253
  if (con.kind !== 'foreignKey') continue;
243
- 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;
244
256
  const cols = con.columns.map(toSnakeCase);
245
- const refCols = con.refColumns.map(toSnakeCase);
257
+ const refCols = con.external ? [...con.refColumns] : con.refColumns.map(toSnakeCase);
246
258
  out.push({
247
259
  name: con.constraintName ?? generatedForeignKeyName(model.table, cols, target.table, refCols),
248
260
  ...(con.constraintName !== undefined ? { nameDeclared: true } : {}),
@@ -276,7 +288,7 @@ export function generatedForeignKeyName(table: string, columns: string[], refTab
276
288
  * target schema is off the search_path — so the desired side must match, or an unchanged auth FK
277
289
  * would read as drift. public stays bare because it is on the search_path (the proven output).
278
290
  */
279
- function refTableName(target: ModelDescriptor): string {
291
+ function refTableName(target: { schema?: string; table: string }): string {
280
292
  return target.schema && target.schema !== 'public' ? `${target.schema}.${target.table}` : target.table;
281
293
  }
282
294
 
@@ -479,7 +491,8 @@ export function qualifiedTable(model: ModelDescriptor): string {
479
491
  /**
480
492
  * `CREATE TABLE` for a model's fields. Single-column primary keys are inline;
481
493
  * composite keys and uniques become table constraints, named the way drizzle
482
- * 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.
483
496
  */
484
497
  export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOptions = {}): string {
485
498
  const indent = opts.indent ?? '\t';
@@ -495,7 +508,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
495
508
  if (pkFields.length > 1) {
496
509
  const cols = pkFields.map(toSnakeCase);
497
510
  const quoted = cols.map((c) => `"${c}"`).join(',');
498
- lines.push(`${indent}CONSTRAINT "${model.table}_${cols.join('_')}_pk" PRIMARY KEY(${quoted})`);
511
+ lines.push(`${indent}CONSTRAINT "${primaryKeyConstraintName(model.table, cols)}" PRIMARY KEY(${quoted})`);
499
512
  }
500
513
 
501
514
  for (const [name, field] of entries) {
@@ -534,7 +547,7 @@ export function compileCreateTable(model: ModelDescriptor, opts: CompileTableOpt
534
547
  * structured form for the brownfield diff. They share the field→type/default/check
535
548
  * helpers, so they never disagree.)
536
549
  */
537
- export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string } = {}): TableSchema {
550
+ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: string; dependents?: DependentActions } = {}): TableSchema {
538
551
  const schema = model.schema ?? opts.schema ?? 'public';
539
552
  const entries = Object.entries(model.fields);
540
553
 
@@ -586,9 +599,14 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
586
599
  table: `${schema}.${model.table}`,
587
600
  columns,
588
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
+ : {}),
589
607
  uniques,
590
608
  checks,
591
- foreignKeys: modelForeignKeys(model),
609
+ foreignKeys: modelForeignKeys(model, opts.dependents),
592
610
  indexes: tableConstraints.indexes,
593
611
  };
594
612
  }
@@ -648,8 +666,8 @@ export { sequenceCreateSql } from './schema-diff';
648
666
  * unproven-against-old-rows, so a migration artifact that installed it validated would build a
649
667
  * database that does not match the models it was generated from.
650
668
  */
651
- export function foreignKeySql(model: ModelDescriptor): string[] {
652
- return modelForeignKeys(model).map((fk) => {
669
+ export function foreignKeySql(model: ModelDescriptor, dependents?: DependentActions): string[] {
670
+ return modelForeignKeys(model, dependents).map((fk) => {
653
671
  const cols = fk.columns.map((c) => `"${c}"`).join(', ');
654
672
  const refCols = fk.refColumns.map((c) => `"${c}"`).join(', ');
655
673
  // Both sides are schema-qualified for a non-public table — `ALTER TABLE "auth"."identities"`