@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.
- package/CHANGELOG.md +91 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +226 -76
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +35 -17
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +64 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/src/cli/schema-diff.ts
CHANGED
|
@@ -74,8 +74,14 @@ export type SchemaChange =
|
|
|
74
74
|
* from `from`, so it can only ever emit the one statement that applies.
|
|
75
75
|
*/
|
|
76
76
|
| { kind: 'setIdentity'; table: string; column: string; identity: 'always' | 'byDefault' | null; from: 'always' | 'byDefault' | null }
|
|
77
|
-
/**
|
|
78
|
-
|
|
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 }
|
|
79
85
|
| { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
|
|
80
86
|
/**
|
|
81
87
|
* A foreign key that is already installed on the live table but has never been checked
|
|
@@ -86,9 +92,23 @@ export type SchemaChange =
|
|
|
86
92
|
* This is what the state after an interrupted two-step add re-plans to — VALIDATE only.
|
|
87
93
|
*/
|
|
88
94
|
| { kind: 'validateConstraint'; table: string; name: string }
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
*/
|
|
92
112
|
| { kind: 'primaryKeyChange'; table: string; from: string[]; to: string[] }
|
|
93
113
|
/** A `renamedFrom` marker satisfied: old column present, new absent → preserve the data. */
|
|
94
114
|
| { kind: 'renameColumn'; table: string; from: string; to: string }
|
|
@@ -227,6 +247,67 @@ function bareTable(table: string): string {
|
|
|
227
247
|
return table.replace(/^[^.]+\./, '');
|
|
228
248
|
}
|
|
229
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
|
+
|
|
230
311
|
/** `CREATE SEQUENCE` DDL for a standalone sequence — emitted BEFORE any table, so a
|
|
231
312
|
* column's `defaultSql(nextval(…))` can draw on it from the first CREATE. */
|
|
232
313
|
export function sequenceCreateSql(seq: SequenceSchema): string {
|
|
@@ -413,8 +494,8 @@ function parenOnlyNote(kind: string, name: string, liveName: string, liveExpr: s
|
|
|
413
494
|
/**
|
|
414
495
|
* Diff desired (compiled Model) against current (introspected DB) into an ordered,
|
|
415
496
|
* dependency-safe change set. The order each change is emitted in is the order it must
|
|
416
|
-
* be applied: new tables, then new columns, then
|
|
417
|
-
*
|
|
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
|
|
418
499
|
* replaced constraint never clashes with its old self and a column is never dropped out
|
|
419
500
|
* from under a constraint that still references it.
|
|
420
501
|
*/
|
|
@@ -427,6 +508,10 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
427
508
|
const alters: SchemaChange[] = [];
|
|
428
509
|
const dropConstraints: SchemaChange[] = [];
|
|
429
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[] = [];
|
|
430
515
|
const createIndexes: SchemaChange[] = [];
|
|
431
516
|
/** Catalog-only renames — indexes (B3) and FK constraints (A8). */
|
|
432
517
|
const catalogRenames: SchemaChange[] = [];
|
|
@@ -511,6 +596,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
511
596
|
currentByName.set(to, { ...source, table: to });
|
|
512
597
|
}
|
|
513
598
|
|
|
599
|
+
const declaredEnumNames = new Set((desired.enums ?? []).map((e) => e.name));
|
|
514
600
|
for (const d of desired.tables) {
|
|
515
601
|
const c = currentByName.get(d.table);
|
|
516
602
|
if (!c) {
|
|
@@ -527,13 +613,14 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
527
613
|
for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
|
|
528
614
|
continue;
|
|
529
615
|
}
|
|
530
|
-
const cd = diffColumns(d, c, renames[d.table] ?? {});
|
|
616
|
+
const cd = diffColumns(d, c, renames[d.table] ?? {}, declaredEnumNames);
|
|
531
617
|
renameColumns.push(...cd.renameColumns);
|
|
532
618
|
addColumns.push(...cd.addColumns);
|
|
533
619
|
alters.push(...cd.alters);
|
|
534
620
|
dropColumns.push(...cd.dropColumns);
|
|
535
621
|
notices.push(...cd.notices);
|
|
536
622
|
diffConstraints(d, c, dropConstraints, addConstraints, alters, catalogRenames);
|
|
623
|
+
diffPrimaryKey(d, c, dropPrimaryKeys, addPrimaryKeys, alters);
|
|
537
624
|
diffIndexes(d, c, createIndexes, dropIndexes, catalogRenames, redefineIndexes);
|
|
538
625
|
}
|
|
539
626
|
|
|
@@ -588,12 +675,15 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
588
675
|
...e.creates, ...e.adds, ...sequenceCreates,
|
|
589
676
|
...moveTables,
|
|
590
677
|
...renameTables,
|
|
591
|
-
...creates, ...renameColumns, ...addColumns,
|
|
678
|
+
...creates, ...renameColumns, ...addColumns,
|
|
592
679
|
// Renames are catalog-only and precede the creates, so a declared name freed by renaming an
|
|
593
680
|
// existing object is never claimed by a fresh CREATE first.
|
|
594
681
|
// redefineIndexes immediately precede createIndexes: PostgreSQL has no CREATE OR REPLACE
|
|
595
682
|
// INDEX, so the old one must be gone before its replacement claims the name.
|
|
596
|
-
|
|
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,
|
|
597
687
|
...dropColumns, ...dropIndexes, ...dropTables,
|
|
598
688
|
...e.drops, ...notices, ...e.notices, ...sequenceNotices,
|
|
599
689
|
];
|
|
@@ -746,10 +836,21 @@ interface ColumnDiff {
|
|
|
746
836
|
}
|
|
747
837
|
|
|
748
838
|
/** Column-level type/nullability/default changes between a renamed-or-matched column and its source. */
|
|
749
|
-
function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema, into: SchemaChange[]): void {
|
|
750
|
-
|
|
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
|
+
}
|
|
751
850
|
if (desired.notNull !== source.notNull) into.push({ kind: 'setNotNull', table, column: desired.name, notNull: desired.notNull });
|
|
752
|
-
if (normalizeDefault(desired.default) !== normalizeDefault(source.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
|
+
}
|
|
753
854
|
// Emitted AFTER setDefault deliberately: Postgres refuses ADD GENERATED on a column that
|
|
754
855
|
// still holds a default, which is exactly the shape of a serial → identity migration.
|
|
755
856
|
if ((desired.identity ?? null) !== (source.identity ?? null)) {
|
|
@@ -757,7 +858,7 @@ function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema
|
|
|
757
858
|
}
|
|
758
859
|
}
|
|
759
860
|
|
|
760
|
-
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 {
|
|
761
862
|
const out: ColumnDiff = { renameColumns: [], addColumns: [], alters: [], dropColumns: [], notices: [] };
|
|
762
863
|
const currentByName = new Map(c.columns.map((col) => [col.name, col]));
|
|
763
864
|
const desiredNames = new Set(d.columns.map((col) => col.name));
|
|
@@ -770,7 +871,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
|
|
|
770
871
|
if (cur) {
|
|
771
872
|
// New name already present. A marker here means the rename is already applied → inert.
|
|
772
873
|
if (oldName) out.notices.push({ kind: 'renameSatisfied', table: d.table, column: col.name, from: oldName });
|
|
773
|
-
columnAlters(d.table, col, cur, out.alters);
|
|
874
|
+
columnAlters(d.table, col, cur, out.alters, enumNames);
|
|
774
875
|
continue;
|
|
775
876
|
}
|
|
776
877
|
|
|
@@ -779,7 +880,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
|
|
|
779
880
|
if (oldName && source) {
|
|
780
881
|
out.renameColumns.push({ kind: 'renameColumn', table: d.table, from: oldName, to: col.name });
|
|
781
882
|
consumedByRename.add(oldName);
|
|
782
|
-
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
|
|
783
884
|
} else if (oldName) {
|
|
784
885
|
// Marker points at a column the database doesn't have — fall back to an honest add.
|
|
785
886
|
out.addColumns.push({ kind: 'addColumn', table: d.table, column: col });
|
|
@@ -931,15 +1032,34 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
|
|
|
931
1032
|
}
|
|
932
1033
|
|
|
933
1034
|
diffChecks(d, c, dropConstraints, addConstraints, renames);
|
|
1035
|
+
}
|
|
934
1036
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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;
|
|
942
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) });
|
|
943
1063
|
}
|
|
944
1064
|
|
|
945
1065
|
// ---------------------------------------------------------------------------
|
|
@@ -947,6 +1067,11 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
|
|
|
947
1067
|
// ---------------------------------------------------------------------------
|
|
948
1068
|
|
|
949
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
|
+
};
|
|
950
1075
|
const colList = (cols: string[]): string => cols.map(quote).join(', ');
|
|
951
1076
|
/** A single-quoted enum value literal, doubling any embedded quote. */
|
|
952
1077
|
const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
@@ -1110,12 +1235,15 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
|
|
|
1110
1235
|
}
|
|
1111
1236
|
case 'alterType': {
|
|
1112
1237
|
const setType = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DATA TYPE ${change.to}`;
|
|
1113
|
-
if (change.risk === 'safe') return `${setType};`;
|
|
1114
|
-
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};`;
|
|
1115
1240
|
const warn = change.risk === 'lossy'
|
|
1116
1241
|
? `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) may truncate or lose data; review before running.`
|
|
1117
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.`;
|
|
1118
|
-
|
|
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;
|
|
1119
1247
|
}
|
|
1120
1248
|
case 'addConstraint': {
|
|
1121
1249
|
const [first, ...rest] = addConstraintSql(change.table, change.constraint);
|
|
@@ -1126,9 +1254,16 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
|
|
|
1126
1254
|
case 'validateConstraint':
|
|
1127
1255
|
return validateConstraintSql(change.table, change.name);
|
|
1128
1256
|
case 'dropConstraint':
|
|
1129
|
-
|
|
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)};`;
|
|
1130
1263
|
case 'addPrimaryKey':
|
|
1131
|
-
return
|
|
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)});`;
|
|
1132
1267
|
case 'primaryKeyChange':
|
|
1133
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.`;
|
|
1134
1269
|
case 'renameColumn':
|
|
@@ -1157,11 +1292,11 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
|
|
|
1157
1292
|
case 'renameHint':
|
|
1158
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}')).`;
|
|
1159
1294
|
case 'createType':
|
|
1160
|
-
return `CREATE TYPE ${
|
|
1295
|
+
return `CREATE TYPE ${quoteType(change.name)} AS ENUM (${change.values.map(enumLiteral).join(', ')});`;
|
|
1161
1296
|
case 'addEnumValue':
|
|
1162
|
-
return `-- WARNING: ADD VALUE to enum ${
|
|
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)};`;
|
|
1163
1298
|
case 'dropType':
|
|
1164
|
-
return `DROP TYPE ${
|
|
1299
|
+
return `DROP TYPE ${quoteType(change.name)};`;
|
|
1165
1300
|
case 'createIndex': {
|
|
1166
1301
|
const unique = change.unique ? 'UNIQUE ' : '';
|
|
1167
1302
|
const using = change.using ? ` USING ${change.using}` : '';
|
|
@@ -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
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -125,6 +125,13 @@ export interface TableSchema {
|
|
|
125
125
|
columns: ColumnSchema[];
|
|
126
126
|
/** Primary-key column names (empty when the table has no PK). */
|
|
127
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;
|
|
128
135
|
uniques: UniqueConstraint[];
|
|
129
136
|
checks: CheckConstraint[];
|
|
130
137
|
foreignKeys: ForeignKey[];
|
|
@@ -133,6 +140,7 @@ export interface TableSchema {
|
|
|
133
140
|
|
|
134
141
|
/** A Postgres enum type — its name and its values in `enumsortorder` (the declared order). */
|
|
135
142
|
export interface EnumType {
|
|
143
|
+
/** Bare for public (`post_status`), qualified otherwise (`analytics.post_status`). */
|
|
136
144
|
name: string;
|
|
137
145
|
values: string[];
|
|
138
146
|
}
|
|
@@ -245,6 +253,55 @@ WHERE con.contype IN ('p', 'u', 'c', 'f')
|
|
|
245
253
|
ORDER BY n.nspname, c.relname, con.conname;
|
|
246
254
|
`.trim();
|
|
247
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
|
+
|
|
248
305
|
/**
|
|
249
306
|
* Every enum type and its values, in `enumsortorder` (the order the values were declared,
|
|
250
307
|
* which `CREATE TYPE` must reproduce). Extension-owned types are excluded via pg_depend,
|
|
@@ -617,9 +674,14 @@ export interface EnumRow {
|
|
|
617
674
|
values: unknown;
|
|
618
675
|
}
|
|
619
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
|
+
*/
|
|
620
682
|
export function enumRowToDescriptor(row: EnumRow): EnumType {
|
|
621
683
|
const values = Array.isArray(row.values) ? row.values.map(String) : [];
|
|
622
|
-
return { name: row.name
|
|
684
|
+
return { name: row.schema === 'public' ? row.name : `${row.schema}.${row.name}`, values };
|
|
623
685
|
}
|
|
624
686
|
|
|
625
687
|
export interface SequenceRow {
|
|
@@ -689,7 +751,7 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
689
751
|
const t = tables.get(d.table);
|
|
690
752
|
if (!t) continue; // a constraint on a table we didn't enumerate (e.g. a view)
|
|
691
753
|
switch (d.kind) {
|
|
692
|
-
case 'primaryKey': t.primaryKey = d.columns ?? []; break;
|
|
754
|
+
case 'primaryKey': t.primaryKey = d.columns ?? []; t.primaryKeyName = d.name; break;
|
|
693
755
|
case 'unique': t.uniques.push({ name: d.name, columns: d.columns ?? [] }); break;
|
|
694
756
|
case 'check': t.checks.push({ name: d.name, expr: d.expr ?? '' }); break;
|
|
695
757
|
case 'foreignKey':
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -16,17 +16,28 @@
|
|
|
16
16
|
* - Cross-schema FKs (e.g. `profiles.id → auth.users.id`) are OMITTED — the model
|
|
17
17
|
* can't express a reference to a non-Model table, so neither can the emitter.
|
|
18
18
|
* - `relationName` strings are synthesized deterministically (the `Relation` type
|
|
19
|
-
* carries no name); only the pairing of a belongsTo with its inverse hasMany
|
|
20
|
-
* matters, which the synthesis guarantees.
|
|
19
|
+
* carries no name); only the pairing of a belongsTo with its inverse hasMany or
|
|
20
|
+
* hasOne matters, which the synthesis guarantees. A hasOne renders as `one(target)`.
|
|
21
21
|
*
|
|
22
22
|
* Pure and deterministic: imports sorted, declarations in model order, relation
|
|
23
23
|
* keys in declared order — re-running on the same models is byte-identical.
|
|
24
24
|
*
|
|
25
|
-
* Input is a `@everystack/model` ModelDescriptor
|
|
25
|
+
* Input is a `@everystack/model` ModelDescriptor. The one runtime import is
|
|
26
|
+
* `relationDeleteActions`: a parent's hasMany `dependent` sets a child FK's onDelete, derived
|
|
27
|
+
* from the set passed in (pure; no spec is written).
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
|
-
import type
|
|
30
|
+
import { relationDeleteActions, type ModelDescriptor, type ReferentialAction, type Relation, type FieldSpec, type DerivedDescriptor, type ViewDescriptor, type MaterializedViewDescriptor } from '@everystack/model';
|
|
29
31
|
import { parseQualified } from './derived-source';
|
|
32
|
+
import { commentText } from './ts-template';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Drizzle relations carry no order, limit or filter, so `db.query.x.findMany({ with })` ignores a
|
|
36
|
+
* relation's display options and a hasOne returns any one matching row. Each such relation gets
|
|
37
|
+
* one note above it; a relation that declares none gets nothing (D8: byte-identical).
|
|
38
|
+
*/
|
|
39
|
+
const DISPLAY_NOTE = 'display options (where/limit/orderBy) are not applied by drizzle `with`: filter in the query';
|
|
40
|
+
const HAS_ONE_NOTE = 'hasOne: drizzle `with` returns any one matching row (orderBy and where are not applied): order and filter in the query';
|
|
30
41
|
|
|
31
42
|
/** `author_id` is the SQL identifier; `authorId` the field key. The single snake-case rule. */
|
|
32
43
|
function toSnakeCase(name: string): string {
|
|
@@ -182,7 +193,7 @@ function columnModifiers(
|
|
|
182
193
|
spec: FieldSpec,
|
|
183
194
|
isComposite: boolean,
|
|
184
195
|
modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
|
|
185
|
-
ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string
|
|
196
|
+
ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string>; onDelete?: ReferentialAction } = {},
|
|
186
197
|
): string {
|
|
187
198
|
let s = '';
|
|
188
199
|
if (spec.isArray) s += '.array()';
|
|
@@ -208,7 +219,9 @@ function columnModifiers(
|
|
|
208
219
|
if (entry) {
|
|
209
220
|
const parentPk = primaryKeyKey(target);
|
|
210
221
|
const opts: string[] = [];
|
|
211
|
-
|
|
222
|
+
// A parent's hasMany `dependent` (ctx.onDelete, derived from the set) or the child's own.
|
|
223
|
+
const onDelete = ctx.onDelete ?? spec.onDelete;
|
|
224
|
+
if (onDelete) opts.push(`onDelete: ${strLiteral(onDelete)}`);
|
|
212
225
|
if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
|
|
213
226
|
const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
|
|
214
227
|
// A SELF-referential FK needs drizzle's documented return-type annotation. Without it
|
|
@@ -302,7 +315,7 @@ function planRelationNames(models: ModelDescriptor[]): RelationNaming {
|
|
|
302
315
|
/** The `relationName: '…'` fragment for a relation, or '' when the pair is unambiguous. */
|
|
303
316
|
function relationNameFragment(
|
|
304
317
|
model: ModelDescriptor,
|
|
305
|
-
rel:
|
|
318
|
+
rel: Pick<Relation, 'kind' | 'column' | 'target'>,
|
|
306
319
|
naming: RelationNaming,
|
|
307
320
|
): string {
|
|
308
321
|
const other = rel.target().table;
|
|
@@ -348,6 +361,8 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
348
361
|
// generated schema. (The migration generator still emits its SQL + authz.) The
|
|
349
362
|
// command and the drift guard both call this, so filtering here keeps them in sync.
|
|
350
363
|
const models = allModels.filter((m) => !m.private);
|
|
364
|
+
// Over the whole set, private models included: a hasMany `dependent` sets a child FK's ON DELETE.
|
|
365
|
+
const dependents = relationDeleteActions(allModels);
|
|
351
366
|
const derivedRelations = typedRelations(_opts.derived ?? []);
|
|
352
367
|
|
|
353
368
|
const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
|
|
@@ -396,7 +411,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
396
411
|
const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
|
|
397
412
|
pgCoreBuilders.add(pgBuilder);
|
|
398
413
|
if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
|
|
399
|
-
const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
|
|
414
|
+
const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes, onDelete: dependents.get(model)?.get(key) });
|
|
400
415
|
if (spec.isDeprecated) {
|
|
401
416
|
// The contract phase (decision 13): the column stays in the database and
|
|
402
417
|
// stays readable; the strikethrough warns new code away at author time.
|
|
@@ -452,7 +467,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
452
467
|
if (relEntries.length === 0) continue;
|
|
453
468
|
const camel = modelCamel(model);
|
|
454
469
|
|
|
455
|
-
const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo');
|
|
470
|
+
const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo' || r.kind === 'hasOne');
|
|
456
471
|
const usesMany = relEntries.some(([, r]) => r.kind === 'hasMany');
|
|
457
472
|
const helpers = [usesOne ? 'one' : null, usesMany ? 'many' : null].filter(Boolean).join(', ');
|
|
458
473
|
|
|
@@ -461,6 +476,8 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
461
476
|
const target = rel.target();
|
|
462
477
|
const targetCamel = modelCamel(target);
|
|
463
478
|
const nameFrag = relationNameFragment(model, rel, relationNaming);
|
|
479
|
+
if (rel.kind === 'hasOne') lines.push(` // ${commentText(HAS_ONE_NOTE)}`);
|
|
480
|
+
else if (rel.orderBy !== undefined || rel.limit !== undefined || rel.where !== undefined) lines.push(` // ${commentText(DISPLAY_NOTE)}`);
|
|
464
481
|
if (rel.kind === 'belongsTo') {
|
|
465
482
|
// belongsTo(() => Target, column, references?) -> one(...)
|
|
466
483
|
const thisCol = rel.column; // FK FIELD KEY on this model
|
|
@@ -468,6 +485,20 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
468
485
|
lines.push(
|
|
469
486
|
` ${key}: one(${targetCamel}, { fields: [${camel}.${thisCol}], references: [${targetCamel}.${targetPk}]${nameFrag} }),`,
|
|
470
487
|
);
|
|
488
|
+
} else if (rel.kind === 'hasOne') {
|
|
489
|
+
// hasOne(() => Target, column) -> one(Target): the inverse of the target's belongsTo, as
|
|
490
|
+
// many(Target) is for a hasMany. Drizzle takes fields/references from that belongsTo, and
|
|
491
|
+
// a one() with no fields is typed nullable (a parent may have no such row). Its orderBy
|
|
492
|
+
// and where are display options with no drizzle form; F10 applies them per query.
|
|
493
|
+
// Drizzle's one() config requires fields, so a relationName alone is not an option: a
|
|
494
|
+
// pair joined by more than one FK is refused by name rather than emitted broken.
|
|
495
|
+
if (nameFrag) {
|
|
496
|
+
const at = model.schema === 'public' ? model.table : `${model.schema}.${model.table}`;
|
|
497
|
+
throw new Error(
|
|
498
|
+
`model '${at}': relation '${key}': a hasOne between two tables joined by more than one foreign key is not supported yet (${model.table} and ${target.table})`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
lines.push(` ${key}: one(${targetCamel}),`);
|
|
471
502
|
} else {
|
|
472
503
|
// hasMany(() => Target, column, references?) -> many(...)
|
|
473
504
|
if (nameFrag) {
|