@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
@@ -33,6 +33,7 @@ import type {
33
33
  IndexSchema,
34
34
  SequenceSchema,
35
35
  } from './schema-introspect';
36
+ import { renderConstraintRename, renderIndexRename } from './catalog-rename';
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
39
  // The change set.
@@ -73,8 +74,14 @@ export type SchemaChange =
73
74
  * from `from`, so it can only ever emit the one statement that applies.
74
75
  */
75
76
  | { kind: 'setIdentity'; table: string; column: string; identity: 'always' | 'byDefault' | null; from: 'always' | 'byDefault' | null }
76
- /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
77
- | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
77
+ /**
78
+ * A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning.
79
+ * `toEnum`: the target is a declared enum. PostgreSQL has no cast between two enum types, so
80
+ * the USING goes through text. `dropDefault`: the live column has a default, which cannot be
81
+ * cast automatically either, so it is dropped first; a `setDefault` after the retype restores
82
+ * the declared one. Both are absent, never false, so every other retype keeps its shape.
83
+ */
84
+ | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk; toEnum?: true; dropDefault?: true }
78
85
  | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
79
86
  /**
80
87
  * A foreign key that is already installed on the live table but has never been checked
@@ -85,9 +92,23 @@ export type SchemaChange =
85
92
  * This is what the state after an interrupted two-step add re-plans to — VALIDATE only.
86
93
  */
87
94
  | { kind: 'validateConstraint'; table: string; name: string }
88
- | { kind: 'dropConstraint'; table: string; name: string }
89
- | { kind: 'addPrimaryKey'; table: string; columns: string[] }
90
- /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
95
+ /**
96
+ * `replacesPrimaryKey`: this is the LIVE primary key, dropped so the declared one (these
97
+ * columns) can take its place. The structured marker for a key replacement — it exempts the
98
+ * drop from the held-removal gate (an `addPrimaryKey` follows it in the same edge) and it is
99
+ * what the emitter writes the `-- WARNING: primary key …` note from. Absent on every other drop.
100
+ */
101
+ | { kind: 'dropConstraint'; table: string; name: string; replacesPrimaryKey?: string[] }
102
+ /**
103
+ * `name` is set when the key REPLACES a live one, and is what a from-scratch build names it
104
+ * ({@link primaryKeyConstraintName}). A first-time add carries none and PostgreSQL names it.
105
+ */
106
+ | { kind: 'addPrimaryKey'; table: string; columns: string[]; name?: string }
107
+ /**
108
+ * A PK change the differ cannot plan: the live key's name is unknown (a snapshot read before
109
+ * introspection carried it), so there is nothing to drop it by. A notice — and db:plan refuses
110
+ * to mint over one, because the fingerprint sees the key the plan would leave unchanged.
111
+ */
91
112
  | { kind: 'primaryKeyChange'; table: string; from: string[]; to: string[] }
92
113
  /** A `renamedFrom` marker satisfied: old column present, new absent → preserve the data. */
93
114
  | { kind: 'renameColumn'; table: string; from: string; to: string }
@@ -226,6 +247,67 @@ function bareTable(table: string): string {
226
247
  return table.replace(/^[^.]+\./, '');
227
248
  }
228
249
 
250
+ /** The longest identifier PostgreSQL keeps (NAMEDATALEN - 1), in bytes. */
251
+ const MAX_IDENTIFIER_BYTES = 63;
252
+
253
+ /** `s` clipped to at most `max` UTF-8 bytes, never splitting a character. */
254
+ function clipBytes(s: string, max: number): string {
255
+ const enc = new TextEncoder();
256
+ let out = s;
257
+ while (enc.encode(out).length > max) out = out.slice(0, -1);
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * The name a from-scratch build gives a table's primary key — ONE definition, read by the
263
+ * greenfield compiler and by the differ when it replaces a live key, so an evolved database and
264
+ * a fresh build of the same models name the key the same way.
265
+ *
266
+ * - one column: the compiler writes it inline (`"id" uuid PRIMARY KEY`) and PostgreSQL names
267
+ * it `<table>_pkey`, clipping the TABLE part (never the suffix) to fit 63 bytes.
268
+ * - several columns: the compiler names the table constraint `<table>_<cols>_pk`, the way
269
+ * drizzle does.
270
+ *
271
+ * `table` is the bare table name, never schema-qualified.
272
+ */
273
+ export function primaryKeyConstraintName(table: string, columns: readonly string[]): string {
274
+ if (columns.length > 1) return `${table}_${columns.join('_')}_pk`;
275
+ const suffix = '_pkey';
276
+ return `${clipBytes(table, MAX_IDENTIFIER_BYTES - suffix.length)}${suffix}`;
277
+ }
278
+
279
+ /**
280
+ * The note a primary key REPLACEMENT carries on its DROP, and its parser. Rendered from the
281
+ * change's structured `replacesPrimaryKey` marker and read back by the plain plan report for its
282
+ * `⚠ CONSTRAINT` line; the two live side by side so they cannot drift, and the parser accepts
283
+ * exactly the line the renderer writes. `WARNING`, not `NOTICE`: the statement under it runs
284
+ * (NOTICE is the reserved prefix of a pure comment).
285
+ */
286
+ export interface PrimaryKeyReplacement {
287
+ /** Schema-qualified, as the snapshot spells it (`public.likes`). */
288
+ table: string;
289
+ /** The live key being dropped. */
290
+ name: string;
291
+ /** The declared key's columns. */
292
+ columns: string[];
293
+ }
294
+
295
+ export function renderPrimaryKeyReplacement(r: PrimaryKeyReplacement): string {
296
+ return `-- WARNING: primary key ${quote(r.name)} on ${r.table} is replaced by (${colList(r.columns)}).`;
297
+ }
298
+
299
+ // Assumes a whitespace-free table name (`\S+`); the plan file carries statements only, so this note is the one record.
300
+ const PK_REPLACEMENT_RE = /^-- WARNING: primary key "([^"]*)" on (\S+) is replaced by \(((?:"[^"]*"(?:, )?)+)\)\.$/;
301
+
302
+ /** The replacement a statement's note records, or null when it carries none. */
303
+ export function parsePrimaryKeyReplacement(statement: string): PrimaryKeyReplacement | null {
304
+ for (const line of statement.split('\n')) {
305
+ const m = PK_REPLACEMENT_RE.exec(line.trim());
306
+ if (m) return { name: m[1], table: m[2], columns: [...m[3].matchAll(/"([^"]*)"/g)].map((c) => c[1]) };
307
+ }
308
+ return null;
309
+ }
310
+
229
311
  /** `CREATE SEQUENCE` DDL for a standalone sequence — emitted BEFORE any table, so a
230
312
  * column's `defaultSql(nextval(…))` can draw on it from the first CREATE. */
231
313
  export function sequenceCreateSql(seq: SequenceSchema): string {
@@ -412,8 +494,8 @@ function parenOnlyNote(kind: string, name: string, liveName: string, liveExpr: s
412
494
  /**
413
495
  * Diff desired (compiled Model) against current (introspected DB) into an ordered,
414
496
  * dependency-safe change set. The order each change is emitted in is the order it must
415
- * be applied: new tables, then new columns, then column alters, then dropped
416
- * constraints, then added constraints, then dropped columns, then dropped tables — so a
497
+ * be applied: new tables, then new columns, then dropped constraints and primary keys,
498
+ * then column alters, then added constraints, then dropped columns, then dropped tables — so a
417
499
  * replaced constraint never clashes with its old self and a column is never dropped out
418
500
  * from under a constraint that still references it.
419
501
  */
@@ -426,6 +508,10 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
426
508
  const alters: SchemaChange[] = [];
427
509
  const dropConstraints: SchemaChange[] = [];
428
510
  const addConstraints: SchemaChange[] = [];
511
+ /** Primary keys: the live key's drop after every other constraint drop (a foreign key on it
512
+ * goes first), the declared key's add before every other add (a foreign key may need it). */
513
+ const dropPrimaryKeys: SchemaChange[] = [];
514
+ const addPrimaryKeys: SchemaChange[] = [];
429
515
  const createIndexes: SchemaChange[] = [];
430
516
  /** Catalog-only renames — indexes (B3) and FK constraints (A8). */
431
517
  const catalogRenames: SchemaChange[] = [];
@@ -510,6 +596,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
510
596
  currentByName.set(to, { ...source, table: to });
511
597
  }
512
598
 
599
+ const declaredEnumNames = new Set((desired.enums ?? []).map((e) => e.name));
513
600
  for (const d of desired.tables) {
514
601
  const c = currentByName.get(d.table);
515
602
  if (!c) {
@@ -526,13 +613,14 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
526
613
  for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
527
614
  continue;
528
615
  }
529
- const cd = diffColumns(d, c, renames[d.table] ?? {});
616
+ const cd = diffColumns(d, c, renames[d.table] ?? {}, declaredEnumNames);
530
617
  renameColumns.push(...cd.renameColumns);
531
618
  addColumns.push(...cd.addColumns);
532
619
  alters.push(...cd.alters);
533
620
  dropColumns.push(...cd.dropColumns);
534
621
  notices.push(...cd.notices);
535
622
  diffConstraints(d, c, dropConstraints, addConstraints, alters, catalogRenames);
623
+ diffPrimaryKey(d, c, dropPrimaryKeys, addPrimaryKeys, alters);
536
624
  diffIndexes(d, c, createIndexes, dropIndexes, catalogRenames, redefineIndexes);
537
625
  }
538
626
 
@@ -587,12 +675,15 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
587
675
  ...e.creates, ...e.adds, ...sequenceCreates,
588
676
  ...moveTables,
589
677
  ...renameTables,
590
- ...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
678
+ ...creates, ...renameColumns, ...addColumns,
591
679
  // Renames are catalog-only and precede the creates, so a declared name freed by renaming an
592
680
  // existing object is never claimed by a fresh CREATE first.
593
681
  // redefineIndexes immediately precede createIndexes: PostgreSQL has no CREATE OR REPLACE
594
682
  // INDEX, so the old one must be gone before its replacement claims the name.
595
- ...catalogRenames, ...dropConstraints, ...addConstraints, ...redefineIndexes, ...createIndexes,
683
+ // A replaced primary key: the old one is dropped after every other constraint drop and the
684
+ // new one added after column alters and before every other add — so a partial unique index or a foreign key that
685
+ // needs the new key comes after it, and nothing still references the old one when it goes.
686
+ ...catalogRenames, ...dropConstraints, ...dropPrimaryKeys, ...alters, ...trailingDefaults, ...addPrimaryKeys, ...addConstraints, ...redefineIndexes, ...createIndexes,
596
687
  ...dropColumns, ...dropIndexes, ...dropTables,
597
688
  ...e.drops, ...notices, ...e.notices, ...sequenceNotices,
598
689
  ];
@@ -745,10 +836,21 @@ interface ColumnDiff {
745
836
  }
746
837
 
747
838
  /** Column-level type/nullability/default changes between a renamed-or-matched column and its source. */
748
- function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema, into: SchemaChange[]): void {
749
- if (desired.type !== source.type) into.push({ kind: 'alterType', table, column: desired.name, from: source.type, to: desired.type, risk: classifyTypeChange(source.type, desired.type) });
839
+ function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema, into: SchemaChange[], enumNames: ReadonlySet<string>): void {
840
+ // A retype onto an enum drops the live default first (see `alterType`), so the declared
841
+ // default must be set again even when it reads the same on both sides.
842
+ const dropsDefault = desired.type !== source.type && enumNames.has(desired.type) && source.default != null;
843
+ if (desired.type !== source.type) {
844
+ into.push({
845
+ kind: 'alterType', table, column: desired.name, from: source.type, to: desired.type, risk: classifyTypeChange(source.type, desired.type),
846
+ ...(enumNames.has(desired.type) ? { toEnum: true as const } : {}),
847
+ ...(dropsDefault ? { dropDefault: true as const } : {}),
848
+ });
849
+ }
750
850
  if (desired.notNull !== source.notNull) into.push({ kind: 'setNotNull', table, column: desired.name, notNull: desired.notNull });
751
- if (normalizeDefault(desired.default) !== normalizeDefault(source.default)) into.push({ kind: 'setDefault', table, column: desired.name, expr: desired.default });
851
+ if (normalizeDefault(desired.default) !== normalizeDefault(source.default) || (dropsDefault && desired.default != null)) {
852
+ into.push({ kind: 'setDefault', table, column: desired.name, expr: desired.default });
853
+ }
752
854
  // Emitted AFTER setDefault deliberately: Postgres refuses ADD GENERATED on a column that
753
855
  // still holds a default, which is exactly the shape of a serial → identity migration.
754
856
  if ((desired.identity ?? null) !== (source.identity ?? null)) {
@@ -756,7 +858,7 @@ function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema
756
858
  }
757
859
  }
758
860
 
759
- function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string, string>): ColumnDiff {
861
+ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string, string>, enumNames: ReadonlySet<string>): ColumnDiff {
760
862
  const out: ColumnDiff = { renameColumns: [], addColumns: [], alters: [], dropColumns: [], notices: [] };
761
863
  const currentByName = new Map(c.columns.map((col) => [col.name, col]));
762
864
  const desiredNames = new Set(d.columns.map((col) => col.name));
@@ -769,7 +871,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
769
871
  if (cur) {
770
872
  // New name already present. A marker here means the rename is already applied → inert.
771
873
  if (oldName) out.notices.push({ kind: 'renameSatisfied', table: d.table, column: col.name, from: oldName });
772
- columnAlters(d.table, col, cur, out.alters);
874
+ columnAlters(d.table, col, cur, out.alters, enumNames);
773
875
  continue;
774
876
  }
775
877
 
@@ -778,7 +880,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
778
880
  if (oldName && source) {
779
881
  out.renameColumns.push({ kind: 'renameColumn', table: d.table, from: oldName, to: col.name });
780
882
  consumedByRename.add(oldName);
781
- columnAlters(d.table, col, source, out.alters); // a rename may also change type/default/nullability
883
+ columnAlters(d.table, col, source, out.alters, enumNames); // a rename may also change type/default/nullability
782
884
  } else if (oldName) {
783
885
  // Marker points at a column the database doesn't have — fall back to an honest add.
784
886
  out.addColumns.push({ kind: 'addColumn', table: d.table, column: col });
@@ -849,8 +951,12 @@ export function foreignKeyContentKey(
849
951
  * same predicate is the same check no matter what it's called, so a pulled check (whose DB
850
952
  * name we didn't choose) re-generates clean. A changed predicate drops the old (by its real
851
953
  * name) and adds the new.
954
+ *
955
+ * R9, the CHECK half of B3/A8: a predicate match whose DECLARED name disagrees with the live
956
+ * one is the same check under another name — `RENAME CONSTRAINT`, catalog-only, no re-scan.
957
+ * An undeclared (positional) name never renames the database.
852
958
  */
853
- function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
959
+ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], renames: SchemaChange[]): void {
854
960
  const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
855
961
  const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
856
962
  // Paren-blind lookup, diagnostic only (see diffIndexes): a compound CHECK churns for the
@@ -860,7 +966,11 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
860
966
  if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
861
967
  }
862
968
  for (const [pred, ck] of desiredByPred) {
863
- if (currentByPred.has(pred)) continue;
969
+ const live = currentByPred.get(pred);
970
+ if (live) {
971
+ if (ck.nameDeclared && live.name !== ck.name) renames.push({ kind: 'renameConstraint', table: d.table, from: live.name, to: ck.name });
972
+ continue;
973
+ }
864
974
  const twin = currentByParenBlind.get(stripAllParens(ck.expr));
865
975
  // Annotate only a twin that is itself being dropped (no exact desired match) — a real churn.
866
976
  const churning = twin && !desiredByPred.has(normalizeCheck(twin.expr));
@@ -921,16 +1031,35 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
921
1031
  for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
922
1032
  }
923
1033
 
924
- diffChecks(d, c, dropConstraints, addConstraints);
1034
+ diffChecks(d, c, dropConstraints, addConstraints, renames);
1035
+ }
925
1036
 
926
- // Primary key: a first-time add is safe; any other change needs the existing
927
- // constraint's name to drop it (which the snapshot discards), so it is surfaced.
928
- const dPk = d.primaryKey.join(',');
929
- const cPk = c.primaryKey.join(',');
930
- if (dPk !== cPk) {
931
- if (c.primaryKey.length === 0) addConstraints.push({ kind: 'addPrimaryKey', table: d.table, columns: d.primaryKey });
932
- else alters.push({ kind: 'primaryKeyChange', table: d.table, from: c.primaryKey, to: d.primaryKey });
1037
+ /**
1038
+ * Primary key. Compared by columns only — the name is not state, so a live key under another
1039
+ * name with the same columns is no change at all.
1040
+ *
1041
+ * - first-time add: `ADD PRIMARY KEY`, PostgreSQL names it.
1042
+ * - removed (declared has none): a plain drop of the live key, held like any other removal.
1043
+ * - replaced: drop the live key BY ITS LIVE NAME, add the declared one under the name a
1044
+ * from-scratch build gives it. Never CASCADE: a foreign key on the old key fails the apply.
1045
+ * - the live name is unknown (an old snapshot): a `primaryKeyChange` notice, never a guess.
1046
+ */
1047
+ function diffPrimaryKey(d: TableSchema, c: TableSchema, drops: SchemaChange[], adds: SchemaChange[], alters: SchemaChange[]): void {
1048
+ if (d.primaryKey.join(',') === c.primaryKey.join(',')) return;
1049
+ if (c.primaryKey.length === 0) {
1050
+ adds.push({ kind: 'addPrimaryKey', table: d.table, columns: d.primaryKey });
1051
+ return;
1052
+ }
1053
+ if (!c.primaryKeyName) {
1054
+ alters.push({ kind: 'primaryKeyChange', table: d.table, from: c.primaryKey, to: d.primaryKey });
1055
+ return;
1056
+ }
1057
+ if (d.primaryKey.length === 0) {
1058
+ drops.push({ kind: 'dropConstraint', table: d.table, name: c.primaryKeyName });
1059
+ return;
933
1060
  }
1061
+ drops.push({ kind: 'dropConstraint', table: d.table, name: c.primaryKeyName, replacesPrimaryKey: d.primaryKey });
1062
+ adds.push({ kind: 'addPrimaryKey', table: d.table, columns: d.primaryKey, name: primaryKeyConstraintName(bareTable(d.table), d.primaryKey) });
934
1063
  }
935
1064
 
936
1065
  // ---------------------------------------------------------------------------
@@ -938,6 +1067,11 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
938
1067
  // ---------------------------------------------------------------------------
939
1068
 
940
1069
  const quote = (name: string): string => `"${name}"`;
1070
+ /** A type identity — `post_status` or `analytics.post_status` — quoted part by part. */
1071
+ const quoteType = (name: string): string => {
1072
+ const dot = name.indexOf('.');
1073
+ return dot === -1 ? quote(name) : `${quote(name.slice(0, dot))}.${quote(name.slice(dot + 1))}`;
1074
+ };
941
1075
  const colList = (cols: string[]): string => cols.map(quote).join(', ');
942
1076
  /** A single-quoted enum value literal, doubling any embedded quote. */
943
1077
  const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
@@ -1101,12 +1235,15 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
1101
1235
  }
1102
1236
  case 'alterType': {
1103
1237
  const setType = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DATA TYPE ${change.to}`;
1104
- if (change.risk === 'safe') return `${setType};`;
1105
- const ddl = `${setType} USING ${quote(change.column)}::${change.to};`;
1238
+ if (change.risk === 'safe' && !change.toEnum) return `${setType};`;
1239
+ const ddl = `${setType} USING ${quote(change.column)}::${change.toEnum ? 'text::' : ''}${change.to};`;
1106
1240
  const warn = change.risk === 'lossy'
1107
1241
  ? `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) may truncate or lose data; review before running.`
1108
1242
  : `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) casts with USING and may FAIL on rows that don't convert; review before running.`;
1109
- return `${warn}\n${ddl}`;
1243
+ const retype = change.risk === 'safe' ? ddl : `${warn}\n${ddl}`;
1244
+ return change.dropDefault
1245
+ ? [`ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} DROP DEFAULT;`, retype]
1246
+ : retype;
1110
1247
  }
1111
1248
  case 'addConstraint': {
1112
1249
  const [first, ...rest] = addConstraintSql(change.table, change.constraint);
@@ -1117,9 +1254,16 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
1117
1254
  case 'validateConstraint':
1118
1255
  return validateConstraintSql(change.table, change.name);
1119
1256
  case 'dropConstraint':
1120
- return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
1257
+ // A replaced primary key drops WITHOUT `IF EXISTS`: a key renamed between plan and apply
1258
+ // fails on the DROP itself, never silently skips it and fails later on the ADD.
1259
+ return change.replacesPrimaryKey
1260
+ ? `${renderPrimaryKeyReplacement({ table: change.table, name: change.name, columns: change.replacesPrimaryKey })}\n` +
1261
+ `ALTER TABLE ${change.table} DROP CONSTRAINT ${quote(change.name)};`
1262
+ : `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
1121
1263
  case 'addPrimaryKey':
1122
- return `ALTER TABLE ${change.table} ADD PRIMARY KEY (${colList(change.columns)});`;
1264
+ return change.name
1265
+ ? `ALTER TABLE ${change.table} ADD CONSTRAINT ${quote(change.name)} PRIMARY KEY (${colList(change.columns)});`
1266
+ : `ALTER TABLE ${change.table} ADD PRIMARY KEY (${colList(change.columns)});`;
1123
1267
  case 'primaryKeyChange':
1124
1268
  return `-- NOTICE: primary key on ${change.table} changed (${change.from.join(', ')} → ${change.to.join(', ')}); dropping a primary key needs its constraint name — regenerate this change manually.`;
1125
1269
  case 'renameColumn':
@@ -1148,11 +1292,11 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
1148
1292
  case 'renameHint':
1149
1293
  return `-- NOTICE: dropping ${quote(change.from)} and adding ${quote(change.to)} on ${change.table}; if this is a rename, replace those two with: ALTER TABLE ${change.table} RENAME COLUMN ${quote(change.from)} TO ${quote(change.to)}; (or mark the field .renamedFrom('${change.from}')).`;
1150
1294
  case 'createType':
1151
- return `CREATE TYPE ${quote(change.name)} AS ENUM (${change.values.map(enumLiteral).join(', ')});`;
1295
+ return `CREATE TYPE ${quoteType(change.name)} AS ENUM (${change.values.map(enumLiteral).join(', ')});`;
1152
1296
  case 'addEnumValue':
1153
- return `-- WARNING: ADD VALUE to enum ${quote(change.type)} cannot run inside a transaction on PostgreSQL < 12, and the new value can't be used until the statement commits; apply it on its own.\nALTER TYPE ${quote(change.type)} ADD VALUE ${enumLiteral(change.value)};`;
1297
+ return `-- WARNING: ADD VALUE to enum ${quoteType(change.type)} cannot run inside a transaction on PostgreSQL < 12, and the new value can't be used until the statement commits; apply it on its own.\nALTER TYPE ${quoteType(change.type)} ADD VALUE ${enumLiteral(change.value)};`;
1154
1298
  case 'dropType':
1155
- return `DROP TYPE ${quote(change.name)};`;
1299
+ return `DROP TYPE ${quoteType(change.name)};`;
1156
1300
  case 'createIndex': {
1157
1301
  const unique = change.unique ? 'UNIQUE ' : '';
1158
1302
  const using = change.using ? ` USING ${change.using}` : '';
@@ -1170,18 +1314,17 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
1170
1314
  }
1171
1315
  case 'renameConstraint':
1172
1316
  // Catalog-only and instant — the constraint and its backing index keep their identity.
1173
- return `ALTER TABLE ${change.table} RENAME CONSTRAINT ${quote(change.from)} TO ${quote(change.to)};`;
1317
+ // Rendered beside its parser: db:apply reads this exact text back (catalog-rename).
1318
+ return renderConstraintRename(change);
1174
1319
  case 'redefineIndex': {
1175
1320
  // Same statement as a removal; a DIFFERENT decision, which is why it is a different kind.
1176
1321
  const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
1177
1322
  return `DROP INDEX ${schema}${quote(change.name)};`;
1178
1323
  }
1179
- case 'renameIndex': {
1180
- // Non-destructive and instant — a catalog rename, no rebuild. The schema qualifier comes
1181
- // from the table; ALTER INDEX takes it on the OLD name only.
1182
- const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
1183
- return `ALTER INDEX ${schema}${quote(change.from)} RENAME TO ${quote(change.to)};`;
1184
- }
1324
+ case 'renameIndex':
1325
+ // Non-destructive and instant — a catalog rename, no rebuild. Rendered beside its parser:
1326
+ // db:apply reads this exact text back (catalog-rename).
1327
+ return renderIndexRename(change);
1185
1328
  case 'enumValuesChanged':
1186
1329
  return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
1187
1330
  case 'createSequence':
@@ -53,7 +53,8 @@
53
53
  import { createHash } from 'node:crypto';
54
54
  import { canonicalAuthz } from './authz-canonical';
55
55
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
56
- import type { SchemaSnapshot, TableSchema } from './schema-introspect';
56
+ import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
57
+ import type { EnumType, SchemaSnapshot, TableSchema } from './schema-introspect';
57
58
  import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
58
59
  import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
59
60
  import { compileTableContract } from './authz-compile';
@@ -340,6 +341,27 @@ export function schemaOf(qualifiedName: string): string {
340
341
  return dot === -1 ? 'public' : qualifiedName.slice(0, dot);
341
342
  }
342
343
 
344
+ /**
345
+ * `stats.status` → `status`; a bare (public) name is returned as is.
346
+ * Live-side hash inputs are unchanged. The models side now also strips a qualifier a model
347
+ * declared (`field.enum('stats.status', …)`), which never converged against live anyway.
348
+ */
349
+ const bareEnumName = (name: string): string => (schemaOf(name) === 'public' ? name : name.slice(name.indexOf('.') + 1));
350
+
351
+ /**
352
+ * The enum list every canon hashes: bare names, in the order the introspection read used to
353
+ * give them (bare name, then schema). Two schemas holding the same bare name still hash both,
354
+ * as before, and in a fixed order.
355
+ */
356
+ function bareEnums(enums: readonly EnumType[]): EnumType[] {
357
+ return [...enums]
358
+ .sort((a, b) => {
359
+ const [an, bn, as, bs] = [bareEnumName(a.name), bareEnumName(b.name), schemaOf(a.name), schemaOf(b.name)];
360
+ return an < bn ? -1 : an > bn ? 1 : as < bs ? -1 : as > bs ? 1 : 0;
361
+ })
362
+ .map((e) => ({ ...e, name: bareEnumName(e.name) }));
363
+ }
364
+
343
365
  export interface CanonicalizeOptions {
344
366
  /** Restrict the state to these schemas — the content address of ONE schema (e.g. a
345
367
  * schema-scoped artifact) instead of the whole database. Omitted = every schema. */
@@ -376,12 +398,15 @@ export function canonicalizeState(
376
398
  const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
377
399
 
378
400
  const tables = snapshot.tables.filter((t) => keepTable(t.table));
379
- // Enums carry no schema in the descriptor (keyed by bare name, like the model side).
401
+ // The canon keys enums by BARE name. The snapshot now qualifies a non-public enum
402
+ // (`stats.status`) so the diff cannot mistake it for a public one; the canon strips that
403
+ // back off, so every hash input is exactly what it was. Keying the canon by the qualified
404
+ // identity is a canon change, and needs a FINGERPRINT_VERSION bump of its own.
380
405
  // Scope them by USE: an enum is in-scope when a kept table has a column of that type
381
406
  // (the type may be qualified under canonical introspection — `stats.status` — so match
382
407
  // on the bare tail). This keeps the exact set the scoped models declare.
383
408
  const usedEnum = new Set<string>();
384
- for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
409
+ for (const t of tables) for (const c of t.columns) usedEnum.add(bareEnumName(c.type));
385
410
 
386
411
  const sequences = byKey(
387
412
  (snapshot.sequences ?? [])
@@ -397,7 +422,7 @@ export function canonicalizeState(
397
422
  v: FINGERPRINT_VERSION,
398
423
  tables: byKey(tables.map(canonicalTable), (t) => String(t.table)),
399
424
  enums: byKey(
400
- (snapshot.enums ?? [])
425
+ bareEnums(snapshot.enums ?? [])
401
426
  .filter((e) => inScope === null || usedEnum.has(e.name))
402
427
  .map((e) => ({ name: e.name, values: e.values })),
403
428
  (e) => e.name,
@@ -468,9 +493,14 @@ export function fingerprintModels(
468
493
  models: ModelDescriptor[],
469
494
  opts: { schema?: string; sequences?: SequenceDescriptor[]; schemas?: string[] } = {},
470
495
  ): Fingerprint {
496
+ // Over the WHOLE set, before scoping: an external target a model declares is refused wherever
497
+ // that model lives. Nothing else here knows an external FK from a model FK.
498
+ assertExternalTargets(models);
499
+ // Also over the whole set: a parent's hasMany `dependent` sets the ON DELETE of a child's FK.
500
+ const dependents = relationDeleteActions(models);
471
501
  const scoped = opts.schemas ? models.filter((m) => opts.schemas!.includes(m.schema)) : models;
472
502
  const snapshot: SchemaSnapshot = {
473
- tables: scoped.map((m) => compileTableSchema(m, opts)),
503
+ tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents })),
474
504
  enums: compileEnums(scoped),
475
505
  ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
476
506
  };
@@ -577,7 +607,10 @@ export function fingerprintLiveAtVersion(
577
607
  // Thread the measured serial-sequence ACLs the way `fingerprintLive` does, so a v11 canon
578
608
  // recomputes the SAME hash the apply recorded. A retired canon that predates sequence ACLs
579
609
  // (v10) simply ignores the field — its self-contained copy never reads `sequenceGrants`.
580
- return canon ? canon(snapshot, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants }) : null;
610
+ // The retired canons are frozen copies that key enums by the name they are handed, so they get
611
+ // the bare names they were written against (see `bareEnums`).
612
+ const bare: SchemaSnapshot = snapshot.enums ? { ...snapshot, enums: bareEnums(snapshot.enums) } : snapshot;
613
+ return canon ? canon(bare, contract.tables, { ...opts, sequenceGrants: contract.sequenceGrants }) : null;
581
614
  }
582
615
 
583
616
  // ---------------------------------------------------------------------------
@@ -88,6 +88,8 @@ export interface CheckConstraint {
88
88
  name: string;
89
89
  /** The predicate text, as `pg_get_constraintdef` deparses it. */
90
90
  expr: string;
91
+ /** The name was DECLARED (`check(sql, { name })`), not positional — same rule as {@link IndexSchema.nameDeclared}. */
92
+ nameDeclared?: boolean;
91
93
  }
92
94
 
93
95
  /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
@@ -123,6 +125,13 @@ export interface TableSchema {
123
125
  columns: ColumnSchema[];
124
126
  /** Primary-key column names (empty when the table has no PK). */
125
127
  primaryKey: string[];
128
+ /**
129
+ * The live primary key constraint's NAME — introspection's observation, absent on a compiled
130
+ * (declared) table and on a snapshot read before this field existed. The differ needs it to
131
+ * drop a key that is being replaced. It is NOT state: no fingerprint version hashes it, so a
132
+ * key under another name with the same columns is the same database.
133
+ */
134
+ primaryKeyName?: string;
126
135
  uniques: UniqueConstraint[];
127
136
  checks: CheckConstraint[];
128
137
  foreignKeys: ForeignKey[];
@@ -131,6 +140,7 @@ export interface TableSchema {
131
140
 
132
141
  /** A Postgres enum type — its name and its values in `enumsortorder` (the declared order). */
133
142
  export interface EnumType {
143
+ /** Bare for public (`post_status`), qualified otherwise (`analytics.post_status`). */
134
144
  name: string;
135
145
  values: string[];
136
146
  }
@@ -243,6 +253,55 @@ WHERE con.contype IN ('p', 'u', 'c', 'f')
243
253
  ORDER BY n.nspname, c.relname, con.conname;
244
254
  `.trim();
245
255
 
256
+ /**
257
+ * Every foreign key's target, read from the CATALOG by `confrelid` (schema and table as separate
258
+ * names). db:pull reads it to declare an external target exactly. The FK IR carries the target as
259
+ * one dotted, quote-stripped string from the deparse (`refTable`), and that string is ambiguous:
260
+ * public."a.b" and a.b both read as `a.b`. The IR and the fingerprint keep that string; only the
261
+ * renderer, which has to split it into schema and table, needs this. Same scope as CONSTRAINTS_SQL.
262
+ */
263
+ export const FK_TARGETS_SQL = `
264
+ SELECT
265
+ n.nspname AS schema,
266
+ c.relname AS "table",
267
+ con.conname AS name,
268
+ rn.nspname AS ref_schema,
269
+ r.relname AS ref_table
270
+ FROM pg_constraint con
271
+ JOIN pg_class c ON c.oid = con.conrelid
272
+ JOIN pg_namespace n ON n.oid = c.relnamespace
273
+ JOIN pg_class r ON r.oid = con.confrelid
274
+ JOIN pg_namespace rn ON rn.oid = r.relnamespace
275
+ WHERE con.contype = 'f'
276
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
277
+ AND n.nspname NOT LIKE 'pg_%'
278
+ ORDER BY n.nspname, c.relname, con.conname;
279
+ `.trim();
280
+
281
+ export interface ForeignKeyTargetRow {
282
+ schema: unknown;
283
+ table: unknown;
284
+ name: unknown;
285
+ ref_schema: unknown;
286
+ ref_table: unknown;
287
+ }
288
+
289
+ /** The key a {@link foreignKeyTargets} map is read with: the owning table (qualified) and the FK name. */
290
+ export function foreignKeyTargetKey(table: string, constraint: string): string {
291
+ const dot = table.indexOf('.');
292
+ const qualified = dot === -1 ? `public.${table}` : table;
293
+ return `${qualified}\u0000${constraint}`;
294
+ }
295
+
296
+ /** FK_TARGETS_SQL rows → (owning table, FK name) → the target's exact schema and table. */
297
+ export function foreignKeyTargets(rows: readonly ForeignKeyTargetRow[]): Map<string, { schema: string; table: string }> {
298
+ const out = new Map<string, { schema: string; table: string }>();
299
+ for (const r of rows) {
300
+ out.set(foreignKeyTargetKey(`${String(r.schema)}.${String(r.table)}`, String(r.name)), { schema: String(r.ref_schema), table: String(r.ref_table) });
301
+ }
302
+ return out;
303
+ }
304
+
246
305
  /**
247
306
  * Every enum type and its values, in `enumsortorder` (the order the values were declared,
248
307
  * which `CREATE TYPE` must reproduce). Extension-owned types are excluded via pg_depend,
@@ -615,9 +674,14 @@ export interface EnumRow {
615
674
  values: unknown;
616
675
  }
617
676
 
677
+ /**
678
+ * The same identity rule as sequences and tables: public bare, non-public qualified. A bare
679
+ * name for every schema let a stray `analytics.post_status` satisfy the declared public
680
+ * `post_status`, so the plan never created the type the column actually needs.
681
+ */
618
682
  export function enumRowToDescriptor(row: EnumRow): EnumType {
619
683
  const values = Array.isArray(row.values) ? row.values.map(String) : [];
620
- return { name: row.name, values };
684
+ return { name: row.schema === 'public' ? row.name : `${row.schema}.${row.name}`, values };
621
685
  }
622
686
 
623
687
  export interface SequenceRow {
@@ -687,7 +751,7 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
687
751
  const t = tables.get(d.table);
688
752
  if (!t) continue; // a constraint on a table we didn't enumerate (e.g. a view)
689
753
  switch (d.kind) {
690
- case 'primaryKey': t.primaryKey = d.columns ?? []; break;
754
+ case 'primaryKey': t.primaryKey = d.columns ?? []; t.primaryKeyName = d.name; break;
691
755
  case 'unique': t.uniques.push({ name: d.name, columns: d.columns ?? [] }); break;
692
756
  case 'check': t.checks.push({ name: d.name, expr: d.expr ?? '' }); break;
693
757
  case 'foreignKey':