@schemic/postgres 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +103 -0
- package/lib/index.d.ts +240 -0
- package/lib/index.js +1161 -0
- package/lib/index.js.map +1 -0
- package/package.json +61 -0
- package/src/authoring.ts +356 -0
- package/src/emit.ts +253 -0
- package/src/index.ts +732 -0
- package/src/kinds.ts +390 -0
- package/src/lower.ts +171 -0
package/src/kinds.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// The POSTGRES kind registry (core-v2). Core no longer hard-codes object kinds; each driver registers
|
|
2
|
+
// its KINDS on a per-driver KindRegistry and core orchestrates generically (lowerSchema / planKinds /
|
|
3
|
+
// buildKindDiff / emitKinds). See packages/core/docs/kind-registry-contract.md.
|
|
4
|
+
//
|
|
5
|
+
// Postgres registers THREE kinds (coarse-to-fine, registration order == ordinal):
|
|
6
|
+
// table — the structured kind: columns (substrate) + PK + table CHECKs; CREATE TABLE; field-
|
|
7
|
+
// level overwrite. A FK COLUMN stays a plain `text` column here; the FK CONSTRAINT is
|
|
8
|
+
// its own kind (below) so the dependency graph can break mutual-FK cycles.
|
|
9
|
+
// index — own kind, deps -> its table (no `owner`); CREATE [UNIQUE] INDEX.
|
|
10
|
+
// constraint — own kind (FK first), deps -> [its table, the referenced table]; ALTER ADD CONSTRAINT.
|
|
11
|
+
//
|
|
12
|
+
// index/constraint DECLINE `owner` (opt-in clustering): without it the spine falls back to ordinal+name,
|
|
13
|
+
// so the emit order is all tables -> all indexes -> all constraints (pg's rank-grouped convention),
|
|
14
|
+
// not clustered per-table. Cross-table FK still emits after both tables (deps); a genuine mutual FK is
|
|
15
|
+
// broken because constraints depend on tables, not each other.
|
|
16
|
+
//
|
|
17
|
+
// `splitTable` turns the driver's table IR (`PgTable`, from ./lower for authoring or ./index's
|
|
18
|
+
// pgIntrospect for a live DB) into these kind objects. The Driver feeds it through both seams:
|
|
19
|
+
// `explode = splitTables(pgLower(...))` (authoring) and `introspectAll = splitTables(pgIntrospect(...))`
|
|
20
|
+
// (live), and core runs the generic spine (lowerSchema/buildKindDiff/emitKinds) over the result. Per
|
|
21
|
+
// the contract, kinds are pg's own — never cross-driver.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
type KindEngine,
|
|
25
|
+
KindRegistry,
|
|
26
|
+
type PortableObject,
|
|
27
|
+
type Ref,
|
|
28
|
+
} from "@schemic/core";
|
|
29
|
+
import type { DiffItem, PortableField } from "@schemic/core/driver";
|
|
30
|
+
import {
|
|
31
|
+
addFkSql,
|
|
32
|
+
canonField,
|
|
33
|
+
createTableDdl,
|
|
34
|
+
dropFkSql,
|
|
35
|
+
dropTableSql,
|
|
36
|
+
escId,
|
|
37
|
+
fieldColumnDdl,
|
|
38
|
+
fkActions,
|
|
39
|
+
fkName,
|
|
40
|
+
normAction,
|
|
41
|
+
type PgTable,
|
|
42
|
+
pgColumn,
|
|
43
|
+
pgEmitFields,
|
|
44
|
+
} from "./emit";
|
|
45
|
+
|
|
46
|
+
// --- the kinds' portable objects ----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** The `table` kind's portable form: emit-ready columns (substrate) + composite PK + table CHECKs. */
|
|
49
|
+
export interface PgTablePortable extends PortableObject {
|
|
50
|
+
kind: "table";
|
|
51
|
+
name: string;
|
|
52
|
+
fields: PortableField[];
|
|
53
|
+
primaryKey?: string[];
|
|
54
|
+
checks?: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `index` kind's portable form (a secondary/unique index over one table's columns). */
|
|
58
|
+
export interface PgIndexPortable extends PortableObject {
|
|
59
|
+
kind: "index";
|
|
60
|
+
name: string;
|
|
61
|
+
table: string;
|
|
62
|
+
cols: string[];
|
|
63
|
+
unique: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `constraint` kind's portable form (FK only for now; deps -> table + referenced table). */
|
|
67
|
+
export interface PgConstraintPortable extends PortableObject {
|
|
68
|
+
kind: "constraint";
|
|
69
|
+
name: string;
|
|
70
|
+
table: string;
|
|
71
|
+
ctype: "fk";
|
|
72
|
+
column: string;
|
|
73
|
+
refTable: string;
|
|
74
|
+
onDelete?: string;
|
|
75
|
+
onUpdate?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tableRef = (name: string): Ref => ({ kind: "table", name });
|
|
79
|
+
|
|
80
|
+
// --- table kind ---------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Column-level `COMMENT ON COLUMN` lines for a table's commented fields (emit-only; not introspected). */
|
|
83
|
+
function commentLines(t: PgTablePortable): string[] {
|
|
84
|
+
return t.fields
|
|
85
|
+
.filter((f) => f.comment !== undefined)
|
|
86
|
+
.map(
|
|
87
|
+
(f) =>
|
|
88
|
+
`COMMENT ON COLUMN ${escId(t.name)}.${escId(f.name)} IS '${(f.comment ?? "").replace(/'/g, "''")}';`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clauses pg cannot ALTER in place (need a drop+recreate): identity, generated, field CHECK. */
|
|
93
|
+
const hasHardClause = (f: PortableField) =>
|
|
94
|
+
f.identity !== undefined || f.computed !== undefined || f.check !== undefined;
|
|
95
|
+
|
|
96
|
+
const sameArr = (a?: string[], b?: string[]) =>
|
|
97
|
+
JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
|
|
98
|
+
|
|
99
|
+
/** Structural equality of two emit-ready fields (the table kind's change unit). */
|
|
100
|
+
const sameField = (a: PortableField, b: PortableField) =>
|
|
101
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
102
|
+
|
|
103
|
+
const fieldKey = (table: string, name: string) => `field:${table}:${name}`;
|
|
104
|
+
const createInput = (t: PgTablePortable) => ({
|
|
105
|
+
name: t.name,
|
|
106
|
+
fields: t.fields,
|
|
107
|
+
...(t.primaryKey ? { primaryKey: t.primaryKey } : {}),
|
|
108
|
+
...(t.checks ? { checks: t.checks } : {}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Per-FIELD display items for a table change (Manuel's per-field decision): each carries its owner
|
|
113
|
+
* `table` so the CLI groups them under it. `(prev,next)` diffs columns -> add/change/remove; an added
|
|
114
|
+
* table `(undefined,next)` lists every column as an add (the `--full` projection); a dropped table
|
|
115
|
+
* `(prev,undefined)` lists every column as a remove. A change with no column delta (a PK / table-CHECK
|
|
116
|
+
* only change) falls back to a single whole-table item so it's never silently empty. DISPLAY ONLY.
|
|
117
|
+
*/
|
|
118
|
+
function fieldDisplayItems(
|
|
119
|
+
prev: PgTablePortable | undefined,
|
|
120
|
+
next: PgTablePortable | undefined,
|
|
121
|
+
): DiffItem[] {
|
|
122
|
+
const table = (next ?? prev)?.name ?? "";
|
|
123
|
+
const before = new Map((prev?.fields ?? []).map((f) => [f.name, f]));
|
|
124
|
+
const after = new Map((next?.fields ?? []).map((f) => [f.name, f]));
|
|
125
|
+
const items: DiffItem[] = [];
|
|
126
|
+
for (const f of next?.fields ?? [])
|
|
127
|
+
if (!before.has(f.name))
|
|
128
|
+
items.push({
|
|
129
|
+
op: "add",
|
|
130
|
+
key: fieldKey(table, f.name),
|
|
131
|
+
kind: "field",
|
|
132
|
+
table,
|
|
133
|
+
ddl: fieldColumnDdl(f),
|
|
134
|
+
});
|
|
135
|
+
for (const f of next?.fields ?? []) {
|
|
136
|
+
const b = before.get(f.name);
|
|
137
|
+
if (!b) continue;
|
|
138
|
+
if (fieldColumnDdl(b) !== fieldColumnDdl(f) || b.comment !== f.comment)
|
|
139
|
+
items.push({
|
|
140
|
+
op: "change",
|
|
141
|
+
key: fieldKey(table, f.name),
|
|
142
|
+
kind: "field",
|
|
143
|
+
table,
|
|
144
|
+
before: fieldColumnDdl(b),
|
|
145
|
+
after: fieldColumnDdl(f),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
for (const f of prev?.fields ?? [])
|
|
149
|
+
if (!after.has(f.name))
|
|
150
|
+
items.push({
|
|
151
|
+
op: "remove",
|
|
152
|
+
key: fieldKey(table, f.name),
|
|
153
|
+
kind: "field",
|
|
154
|
+
table,
|
|
155
|
+
ddl: `ALTER TABLE ${escId(table)} DROP COLUMN IF EXISTS ${escId(f.name)};`,
|
|
156
|
+
old: fieldColumnDdl(f),
|
|
157
|
+
});
|
|
158
|
+
if (items.length === 0 && prev && next)
|
|
159
|
+
items.push({
|
|
160
|
+
op: "change",
|
|
161
|
+
key: `table:${table}:${table}`,
|
|
162
|
+
kind: "table",
|
|
163
|
+
table,
|
|
164
|
+
before: createTableDdl(createInput(prev)),
|
|
165
|
+
after: createTableDdl(createInput(next)),
|
|
166
|
+
});
|
|
167
|
+
return items;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tableEngine: KindEngine<PgTablePortable, PgTablePortable> = {
|
|
171
|
+
// Objects arrive already in this kind's portable shape (from `explode`/`introspectAll` via splitTable
|
|
172
|
+
// / lowerSchema). `lower` is the identity — the split already produced the normalized portable object.
|
|
173
|
+
lower: (t) => t,
|
|
174
|
+
|
|
175
|
+
// CREATE TABLE (columns + PK + table CHECKs) followed by any column COMMENTs.
|
|
176
|
+
emit: (t) => [
|
|
177
|
+
createTableDdl({
|
|
178
|
+
name: t.name,
|
|
179
|
+
fields: t.fields,
|
|
180
|
+
...(t.primaryKey ? { primaryKey: t.primaryKey } : {}),
|
|
181
|
+
...(t.checks ? { checks: t.checks } : {}),
|
|
182
|
+
}),
|
|
183
|
+
...commentLines(t),
|
|
184
|
+
],
|
|
185
|
+
|
|
186
|
+
// Change-detection key (NOT the emitted DDL): the equality-relevant shape only — canonField keeps
|
|
187
|
+
// type/nullability/identity/FK-actions and DROPS the rewrite-prone/non-introspected clauses
|
|
188
|
+
// (DEFAULT/CHECK/GENERATED/COMMENT), and table-level CHECKs are omitted too. So those clauses stay
|
|
189
|
+
// faithful in `emit` (fresh apply) but never count as a change -> no phantom-diff of a freshly
|
|
190
|
+
// applied schema vs introspect (PG rewrites exprs on read; comments aren't introspected). This is
|
|
191
|
+
// the fixed-slot driver's emit/equal asymmetry, restored at the kind seam.
|
|
192
|
+
canonical: (t) =>
|
|
193
|
+
createTableDdl({
|
|
194
|
+
name: t.name,
|
|
195
|
+
fields: t.fields.map((f) => canonField(f, t.name)),
|
|
196
|
+
...(t.primaryKey ? { primaryKey: t.primaryKey } : {}),
|
|
197
|
+
}),
|
|
198
|
+
|
|
199
|
+
// Per-FIELD display items (Manuel's decision: field-level changes grouped under their table). Each
|
|
200
|
+
// carries `table` so the CLI groups them hierarchically. DISPLAY ONLY — never affects up/down DDL.
|
|
201
|
+
// (prev,next): diff the columns; (undefined,next): list all columns as adds (the --full projection).
|
|
202
|
+
displayItems: (prev, next) => fieldDisplayItems(prev, next),
|
|
203
|
+
|
|
204
|
+
remove: (t) => [dropTableSql(t.name)],
|
|
205
|
+
|
|
206
|
+
// In-place column ALTERs (add/drop/type/nullability/default/comment). A structural change pg can't
|
|
207
|
+
// ALTER (PK, table CHECK, or a column's identity/generated/field-CHECK) falls back to drop+recreate.
|
|
208
|
+
overwrite: (prev, next) => {
|
|
209
|
+
const before = new Map(prev.fields.map((f) => [f.name, f]));
|
|
210
|
+
const after = new Map(next.fields.map((f) => [f.name, f]));
|
|
211
|
+
|
|
212
|
+
// Hard structural deltas -> recreate (coarse, destructive — but correct; pg has no in-place form).
|
|
213
|
+
const changedHard = next.fields.some((f) => {
|
|
214
|
+
const b = before.get(f.name);
|
|
215
|
+
return b && (hasHardClause(f) || hasHardClause(b)) && !sameField(b, f);
|
|
216
|
+
});
|
|
217
|
+
const addedHard = next.fields.some(
|
|
218
|
+
(f) => !before.has(f.name) && hasHardClause(f),
|
|
219
|
+
);
|
|
220
|
+
const removedHard = prev.fields.some(
|
|
221
|
+
(f) => !after.has(f.name) && hasHardClause(f),
|
|
222
|
+
);
|
|
223
|
+
if (
|
|
224
|
+
!sameArr(prev.primaryKey, next.primaryKey) ||
|
|
225
|
+
!sameArr(prev.checks, next.checks) ||
|
|
226
|
+
changedHard ||
|
|
227
|
+
addedHard ||
|
|
228
|
+
removedHard
|
|
229
|
+
) {
|
|
230
|
+
return [...tableEngine.remove(prev), ...tableEngine.emit(next)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const out: string[] = [];
|
|
234
|
+
const t = next.name;
|
|
235
|
+
// added columns (full column DDL — plain columns match the fixed-slot ADD COLUMN byte-for-byte)
|
|
236
|
+
for (const f of next.fields)
|
|
237
|
+
if (!before.has(f.name))
|
|
238
|
+
out.push(`ALTER TABLE ${escId(t)} ADD COLUMN ${fieldColumnDdl(f)};`);
|
|
239
|
+
// changed columns: type, nullability, default, comment
|
|
240
|
+
for (const f of next.fields) {
|
|
241
|
+
const b = before.get(f.name);
|
|
242
|
+
if (!b) continue;
|
|
243
|
+
const ca = pgColumn(f.type);
|
|
244
|
+
const cb = pgColumn(b.type);
|
|
245
|
+
if (ca.sql !== cb.sql)
|
|
246
|
+
out.push(
|
|
247
|
+
`ALTER TABLE ${escId(t)} ALTER COLUMN ${escId(f.name)} TYPE ${ca.sql};`,
|
|
248
|
+
);
|
|
249
|
+
if (ca.nullable !== cb.nullable)
|
|
250
|
+
out.push(
|
|
251
|
+
`ALTER TABLE ${escId(t)} ALTER COLUMN ${escId(f.name)} ${ca.nullable ? "DROP NOT NULL" : "SET NOT NULL"};`,
|
|
252
|
+
);
|
|
253
|
+
if (f.default !== b.default)
|
|
254
|
+
out.push(
|
|
255
|
+
f.default === undefined
|
|
256
|
+
? `ALTER TABLE ${escId(t)} ALTER COLUMN ${escId(f.name)} DROP DEFAULT;`
|
|
257
|
+
: `ALTER TABLE ${escId(t)} ALTER COLUMN ${escId(f.name)} SET DEFAULT ${f.default};`,
|
|
258
|
+
);
|
|
259
|
+
if (f.comment !== b.comment)
|
|
260
|
+
out.push(
|
|
261
|
+
f.comment === undefined
|
|
262
|
+
? `COMMENT ON COLUMN ${escId(t)}.${escId(f.name)} IS NULL;`
|
|
263
|
+
: `COMMENT ON COLUMN ${escId(t)}.${escId(f.name)} IS '${f.comment.replace(/'/g, "''")}';`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
// dropped columns
|
|
267
|
+
for (const f of prev.fields)
|
|
268
|
+
if (!after.has(f.name))
|
|
269
|
+
out.push(
|
|
270
|
+
`ALTER TABLE ${escId(t)} DROP COLUMN IF EXISTS ${escId(f.name)};`,
|
|
271
|
+
);
|
|
272
|
+
return out;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// --- index kind ---------------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
const indexEngine: KindEngine<PgIndexPortable, PgIndexPortable> = {
|
|
279
|
+
lower: (i) => i,
|
|
280
|
+
emit: (i) => [
|
|
281
|
+
`CREATE ${i.unique ? "UNIQUE " : ""}INDEX ${escId(i.name)} ON ${escId(i.table)} (${i.cols.map(escId).join(", ")});`,
|
|
282
|
+
],
|
|
283
|
+
remove: (i) => [`DROP INDEX IF EXISTS ${escId(i.name)};`],
|
|
284
|
+
// An index emits AFTER its table (deps), but NO `owner` -> no clustering: the spine then falls back
|
|
285
|
+
// to ordinal+name, so all indexes emit as a rank group after all tables (pg's emit convention),
|
|
286
|
+
// rather than clustered next to each table. owner is opt-in readability we deliberately decline.
|
|
287
|
+
deps: (i) => [tableRef(i.table)],
|
|
288
|
+
// no overwrite: an index change is a drop+recreate (the spine's default).
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// --- constraint kind (FK) -----------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
const constraintEngine: KindEngine<PgConstraintPortable, PgConstraintPortable> =
|
|
294
|
+
{
|
|
295
|
+
lower: (c) => c,
|
|
296
|
+
emit: (c) => [
|
|
297
|
+
addFkSql(
|
|
298
|
+
c.table,
|
|
299
|
+
c.column,
|
|
300
|
+
c.refTable,
|
|
301
|
+
fkActions({ on_delete: c.onDelete, on_update: c.onUpdate }),
|
|
302
|
+
),
|
|
303
|
+
],
|
|
304
|
+
remove: (c) => [dropFkSql(c.table, c.column)],
|
|
305
|
+
// A FK emits AFTER both its own table and the referenced table — this is what breaks mutual-FK
|
|
306
|
+
// cycles (tables have no deps, so they create first, then the constraints between them). NO
|
|
307
|
+
// `owner`: like the index kind, constraints emit as a rank group after all tables (pg convention).
|
|
308
|
+
deps: (c) =>
|
|
309
|
+
c.refTable === c.table
|
|
310
|
+
? [tableRef(c.table)]
|
|
311
|
+
: [tableRef(c.table), tableRef(c.refTable)],
|
|
312
|
+
// no overwrite: a FK change is drop+recreate (the spine's default).
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// --- the registry -------------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export const registry = new KindRegistry();
|
|
318
|
+
registry.define({
|
|
319
|
+
name: "table",
|
|
320
|
+
build: (t: PgTablePortable) => t,
|
|
321
|
+
...tableEngine,
|
|
322
|
+
});
|
|
323
|
+
registry.define({
|
|
324
|
+
name: "index",
|
|
325
|
+
build: (i: PgIndexPortable) => i,
|
|
326
|
+
...indexEngine,
|
|
327
|
+
});
|
|
328
|
+
registry.define({
|
|
329
|
+
name: "constraint",
|
|
330
|
+
build: (c: PgConstraintPortable) => c,
|
|
331
|
+
...constraintEngine,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// --- splitTable: the driver's table IR -> the registry's kind objects ----------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Split one `PgTable` (from `lowerTable` for authoring, or `pgIntrospect` for a live DB) into the
|
|
338
|
+
* registry's portable objects: the `table` (columns substrate + PK + table CHECKs, with FK columns kept
|
|
339
|
+
* as plain `text` columns) plus its `index` and `constraint` (FK) objects. The single seam both
|
|
340
|
+
* `explode` (authoring) and `introspectAll` (live) go through, so a clean apply round-trips to a zero
|
|
341
|
+
* diff. Replaces the old PortableDb<->objects facade adapter.
|
|
342
|
+
*/
|
|
343
|
+
export function splitTable(t: PgTable): PortableObject[] {
|
|
344
|
+
const out: PortableObject[] = [];
|
|
345
|
+
const fields = pgEmitFields(t);
|
|
346
|
+
const table: PgTablePortable = {
|
|
347
|
+
kind: "table",
|
|
348
|
+
name: t.name,
|
|
349
|
+
fields,
|
|
350
|
+
...(t.primaryKey && t.primaryKey.length > 0
|
|
351
|
+
? { primaryKey: t.primaryKey }
|
|
352
|
+
: {}),
|
|
353
|
+
...(t.checks && t.checks.length > 0 ? { checks: t.checks } : {}),
|
|
354
|
+
};
|
|
355
|
+
out.push(table);
|
|
356
|
+
|
|
357
|
+
for (const ix of t.indexes) {
|
|
358
|
+
const index: PgIndexPortable = {
|
|
359
|
+
kind: "index",
|
|
360
|
+
name: ix.name,
|
|
361
|
+
table: t.name,
|
|
362
|
+
cols: ix.cols,
|
|
363
|
+
unique: ix.unique,
|
|
364
|
+
};
|
|
365
|
+
out.push(index);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const f of fields) {
|
|
369
|
+
const ref = pgColumn(f.type).references;
|
|
370
|
+
if (!ref) continue;
|
|
371
|
+
const onDelete = normAction(f.reference?.on_delete);
|
|
372
|
+
const onUpdate = normAction(f.reference?.on_update);
|
|
373
|
+
const fk: PgConstraintPortable = {
|
|
374
|
+
kind: "constraint",
|
|
375
|
+
name: fkName(t.name, f.name),
|
|
376
|
+
table: t.name,
|
|
377
|
+
ctype: "fk",
|
|
378
|
+
column: f.name,
|
|
379
|
+
refTable: ref,
|
|
380
|
+
...(onDelete ? { onDelete } : {}),
|
|
381
|
+
...(onUpdate ? { onUpdate } : {}),
|
|
382
|
+
};
|
|
383
|
+
out.push(fk);
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Split many `PgTable`s into the flat kind-object list (the `explode`/`introspectAll` shape). */
|
|
389
|
+
export const splitTables = (tables: PgTable[]): PortableObject[] =>
|
|
390
|
+
tables.flatMap(splitTable);
|
package/src/lower.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// lower: the pg `s.*` authoring objects (./authoring.ts) -> the driver's table IR (`PgTable`). The
|
|
2
|
+
// driver's `explode` then splits each PgTable into kind objects (table/index/constraint); `emit` turns
|
|
3
|
+
// those into pg DDL and `introspectAll` reads DDL back into the same kind objects, so author -> lower
|
|
4
|
+
// -> explode -> emit -> introspect -> diff round-trips. Field TYPE comes from the Zod schema's
|
|
5
|
+
// structural wrappers (optional/nullable/array) combined with the PgMeta pg-type token; DDL clauses
|
|
6
|
+
// (default/check/generated/identity/comment/reference) ride PgMeta into the field's clause slots.
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
PortableField,
|
|
10
|
+
PortableType,
|
|
11
|
+
ScalarName,
|
|
12
|
+
} from "@schemic/core/driver";
|
|
13
|
+
import type { PgField, PgMeta, PgTableDef } from "./authoring";
|
|
14
|
+
import type { PgIndexInfo, PgTable } from "./emit";
|
|
15
|
+
|
|
16
|
+
/** Minimal view of a Zod schema's internal def (zod v4) — enough to peel structural wrappers. */
|
|
17
|
+
interface ZodDef {
|
|
18
|
+
type: string;
|
|
19
|
+
innerType?: { _zod: { def: ZodDef } };
|
|
20
|
+
element?: { _zod: { def: ZodDef } };
|
|
21
|
+
}
|
|
22
|
+
const defOf = (schema: { _zod: { def: ZodDef } }): ZodDef => schema._zod.def;
|
|
23
|
+
|
|
24
|
+
/** Canonical pg types that map to a PORTABLE scalar (so they port cross-dialect). Others -> native. */
|
|
25
|
+
const CANON: Record<string, ScalarName> = {
|
|
26
|
+
text: "string",
|
|
27
|
+
integer: "int",
|
|
28
|
+
"double precision": "float",
|
|
29
|
+
numeric: "decimal",
|
|
30
|
+
boolean: "bool",
|
|
31
|
+
timestamptz: "datetime",
|
|
32
|
+
uuid: "uuid",
|
|
33
|
+
bytea: "bytes",
|
|
34
|
+
interval: "duration",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A pg type token (+ params) -> portable leaf type: canonical -> scalar, parameterized/other -> native. */
|
|
38
|
+
function tokenToPortable(
|
|
39
|
+
type: string,
|
|
40
|
+
params?: (string | number)[],
|
|
41
|
+
): PortableType {
|
|
42
|
+
if (type === "jsonb") return { t: "object", fields: {} };
|
|
43
|
+
if (params && params.length > 0)
|
|
44
|
+
return { t: "native", db: "postgres", name: type, params };
|
|
45
|
+
const scalar = CANON[type];
|
|
46
|
+
return scalar
|
|
47
|
+
? { t: "scalar", name: scalar }
|
|
48
|
+
: { t: "native", db: "postgres", name: type };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Peel the Zod structural wrappers (optional/nullable/array; default/prefault/catch/readonly are transparent). */
|
|
52
|
+
function structure(schema: PgField["schema"]): {
|
|
53
|
+
wrappers: ("option" | "nullable" | "array")[];
|
|
54
|
+
leaf: { _zod: { def: ZodDef } };
|
|
55
|
+
} {
|
|
56
|
+
const wrappers: ("option" | "nullable" | "array")[] = [];
|
|
57
|
+
let cur: { _zod: { def: ZodDef } } = schema;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const def = defOf(cur);
|
|
60
|
+
if (def.type === "optional" && def.innerType) {
|
|
61
|
+
wrappers.push("option");
|
|
62
|
+
cur = def.innerType;
|
|
63
|
+
} else if (def.type === "nullable" && def.innerType) {
|
|
64
|
+
wrappers.push("nullable");
|
|
65
|
+
cur = def.innerType;
|
|
66
|
+
} else if (def.type === "array" && def.element) {
|
|
67
|
+
wrappers.push("array");
|
|
68
|
+
cur = def.element;
|
|
69
|
+
} else if (
|
|
70
|
+
(def.type === "default" ||
|
|
71
|
+
def.type === "prefault" ||
|
|
72
|
+
def.type === "catch" ||
|
|
73
|
+
def.type === "readonly") &&
|
|
74
|
+
def.innerType
|
|
75
|
+
) {
|
|
76
|
+
cur = def.innerType; // App-land wrappers, transparent to the column type
|
|
77
|
+
} else {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { wrappers, leaf: cur };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The leaf portable type from the field's pg metadata (FK -> record; else the pg-type token). */
|
|
85
|
+
function leafPortable(meta: PgMeta): PortableType {
|
|
86
|
+
if (meta.references) return { t: "record", tables: [meta.references.table] };
|
|
87
|
+
if (!meta.pg) return { t: "scalar", name: "string" };
|
|
88
|
+
return tokenToPortable(meta.pg.type, meta.pg.params);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Combine the structural wrappers (outermost-first) around the leaf type. */
|
|
92
|
+
function portableType(
|
|
93
|
+
meta: PgMeta,
|
|
94
|
+
wrappers: ("option" | "nullable" | "array")[],
|
|
95
|
+
): PortableType {
|
|
96
|
+
let type = leafPortable(meta);
|
|
97
|
+
for (let i = wrappers.length - 1; i >= 0; i--) {
|
|
98
|
+
const w = wrappers[i];
|
|
99
|
+
type =
|
|
100
|
+
w === "array"
|
|
101
|
+
? { t: "array", elem: type }
|
|
102
|
+
: w === "option"
|
|
103
|
+
? { t: "option", inner: type }
|
|
104
|
+
: { t: "nullable", inner: type };
|
|
105
|
+
}
|
|
106
|
+
return type;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One pg field -> a portable field, carrying its DDL clauses into the IR's clause slots. */
|
|
110
|
+
function lowerField(
|
|
111
|
+
name: string,
|
|
112
|
+
table: string,
|
|
113
|
+
field: PgField,
|
|
114
|
+
): PortableField {
|
|
115
|
+
const meta = field.native;
|
|
116
|
+
const { wrappers } = structure(field.schema);
|
|
117
|
+
const pf: PortableField = {
|
|
118
|
+
name,
|
|
119
|
+
table,
|
|
120
|
+
type: portableType(meta, wrappers),
|
|
121
|
+
};
|
|
122
|
+
if (meta.default !== undefined) pf.default = meta.default;
|
|
123
|
+
if (meta.check !== undefined) pf.check = meta.check;
|
|
124
|
+
if (meta.generated !== undefined) pf.computed = meta.generated;
|
|
125
|
+
if (meta.identity !== undefined) pf.identity = meta.identity;
|
|
126
|
+
if (meta.comment !== undefined) pf.comment = meta.comment;
|
|
127
|
+
if (meta.references) {
|
|
128
|
+
const ref: { on_delete?: string; on_update?: string } = {};
|
|
129
|
+
if (meta.references.onDelete) ref.on_delete = meta.references.onDelete;
|
|
130
|
+
if (meta.references.onUpdate) ref.on_update = meta.references.onUpdate;
|
|
131
|
+
if (ref.on_delete !== undefined || ref.on_update !== undefined)
|
|
132
|
+
pf.reference = ref;
|
|
133
|
+
}
|
|
134
|
+
return pf;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One pg table definition -> the driver's table IR (fields + composite PK + table CHECKs + indexes). */
|
|
138
|
+
export function lowerTable(def: PgTableDef): PgTable {
|
|
139
|
+
const fields: PortableField[] = [];
|
|
140
|
+
const indexes: PgIndexInfo[] = [];
|
|
141
|
+
const pkCols: string[] = [...(def.config.primaryKey ?? [])];
|
|
142
|
+
|
|
143
|
+
for (const [name, field] of Object.entries(def.fields)) {
|
|
144
|
+
fields.push(lowerField(name, def.name, field));
|
|
145
|
+
if (field.native.unique)
|
|
146
|
+
indexes.push({
|
|
147
|
+
name: `${def.name}_${name}_key`,
|
|
148
|
+
cols: [name],
|
|
149
|
+
unique: true,
|
|
150
|
+
});
|
|
151
|
+
if (field.native.primaryKey && !pkCols.includes(name)) pkCols.push(name);
|
|
152
|
+
}
|
|
153
|
+
for (const ix of def.config.indexes ?? []) {
|
|
154
|
+
indexes.push({
|
|
155
|
+
name: ix.name ?? `${def.name}_${ix.cols.join("_")}_idx`,
|
|
156
|
+
cols: ix.cols,
|
|
157
|
+
unique: !!ix.unique,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const table: PgTable = { name: def.name, fields, indexes };
|
|
162
|
+
if (pkCols.length > 0) table.primaryKey = pkCols;
|
|
163
|
+
if (def.config.checks && def.config.checks.length > 0)
|
|
164
|
+
table.checks = def.config.checks;
|
|
165
|
+
return table;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Lower the authored pg tables (+ standalone defs — none in the pg surface yet) to the table IR. */
|
|
169
|
+
export function pgLower(tables: PgTableDef[]): PgTable[] {
|
|
170
|
+
return tables.map(lowerTable);
|
|
171
|
+
}
|