@supawatch/target-seed 0.7.1 → 0.9.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/dist/index.js +116 -20
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -27,7 +27,11 @@ function deterministicUuid(rand) {
|
|
|
27
27
|
// version 4 and variant bits set, so validators accept it
|
|
28
28
|
return `${s(8)}-${s(4)}-4${s(3)}-${"89ab"[Math.floor(rand() * 4)]}${s(3)}-${s(12)}`;
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
// Types whose input syntax accepts arbitrary text. Every other
|
|
31
|
+
// string-at-runtime type (inet, cidr, macaddr, interval, time, ...)
|
|
32
|
+
// constrains its input, so a text placeholder would fail to apply.
|
|
33
|
+
const FREE_TEXT_BASE_TYPES = new Set(["text", "varchar", "bpchar", "citext", "name"]);
|
|
34
|
+
function literalFor(runtime, col, table, rowIndex, rand, baseTypeOf) {
|
|
31
35
|
switch (runtime.kind) {
|
|
32
36
|
case "number":
|
|
33
37
|
return runtime.integer
|
|
@@ -42,10 +46,16 @@ function literalFor(runtime, col, table, rowIndex, rand) {
|
|
|
42
46
|
case "bigint":
|
|
43
47
|
return sqlString(String(1 + Math.floor(rand() * 100000)));
|
|
44
48
|
case "composite":
|
|
45
|
-
case "array-literal":
|
|
46
49
|
return null; // cannot construct honestly from here
|
|
47
|
-
default:
|
|
48
|
-
|
|
50
|
+
default: {
|
|
51
|
+
const base = baseTypeOf(col.pgTypeName.replace(/^_/, ""));
|
|
52
|
+
if (!FREE_TEXT_BASE_TYPES.has(base))
|
|
53
|
+
return null;
|
|
54
|
+
const placeholder = `${table.name} ${col.name} ${rowIndex + 1}`;
|
|
55
|
+
// varchar(n)/char(n) declare a character cap in sqlType
|
|
56
|
+
const cap = /\((\d+)\)/.exec(col.sqlType)?.[1];
|
|
57
|
+
return sqlString(cap ? placeholder.slice(0, Number(cap)) : placeholder);
|
|
58
|
+
}
|
|
49
59
|
}
|
|
50
60
|
case "boolean":
|
|
51
61
|
return rand() < 0.5 ? "true" : "false";
|
|
@@ -59,11 +69,13 @@ function literalFor(runtime, col, table, rowIndex, rand) {
|
|
|
59
69
|
case "json":
|
|
60
70
|
return sqlString("{}") + "::jsonb";
|
|
61
71
|
case "enum": {
|
|
72
|
+
if (runtime.labels.length === 0)
|
|
73
|
+
return null; // zero-label enums hold nothing
|
|
62
74
|
const label = runtime.labels[rowIndex % runtime.labels.length];
|
|
63
75
|
return sqlString(label) + `::"${col.pgTypeName.replace(/^_/, "")}"`;
|
|
64
76
|
}
|
|
65
77
|
case "array": {
|
|
66
|
-
const el = literalFor(runtime.element, col, table, rowIndex, rand);
|
|
78
|
+
const el = literalFor(runtime.element, col, table, rowIndex, rand, baseTypeOf);
|
|
67
79
|
if (el === null)
|
|
68
80
|
return null;
|
|
69
81
|
return `array[${el}]`;
|
|
@@ -74,40 +86,81 @@ function literalFor(runtime, col, table, rowIndex, rand) {
|
|
|
74
86
|
}
|
|
75
87
|
// Kahn's algorithm over single-column FK edges; nullable-FK edges are
|
|
76
88
|
// soft (broken first on cycles, seeded as null).
|
|
89
|
+
// Hard edges (required FKs) must be honored; soft edges (nullable FKs)
|
|
90
|
+
// are honored too, because a nullable FK cell with a value still needs
|
|
91
|
+
// its parent row first. Only when nothing can proceed does a soft edge
|
|
92
|
+
// break, and the broken cells seed as null on every row.
|
|
77
93
|
function topoSort(tables) {
|
|
78
94
|
const byName = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
79
|
-
const
|
|
95
|
+
const hard = new Map();
|
|
96
|
+
const soft = new Map();
|
|
80
97
|
for (const t of tables) {
|
|
81
98
|
const key = `${t.schema}.${t.name}`;
|
|
82
|
-
const
|
|
99
|
+
const h = new Set();
|
|
100
|
+
const s = new Map();
|
|
83
101
|
for (const fk of t.foreignKeys) {
|
|
84
102
|
const target = `${fk.referencedSchema}.${fk.referencedTable}`;
|
|
85
103
|
const col = t.columns.find((c) => c.name === fk.columns[0]);
|
|
86
|
-
if (target
|
|
87
|
-
|
|
104
|
+
if (target === key || !byName.has(target) || !col)
|
|
105
|
+
continue;
|
|
106
|
+
if (col.nullable) {
|
|
107
|
+
s.set(target, [...(s.get(target) ?? []), col.name]);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
h.add(target);
|
|
88
111
|
}
|
|
89
112
|
}
|
|
90
|
-
|
|
113
|
+
hard.set(key, h);
|
|
114
|
+
soft.set(key, s);
|
|
91
115
|
}
|
|
92
116
|
const ordered = [];
|
|
93
117
|
const done = new Set();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
progress = false;
|
|
118
|
+
const brokenSoft = new Set();
|
|
119
|
+
for (;;) {
|
|
120
|
+
let progress = false;
|
|
97
121
|
for (const t of tables) {
|
|
98
122
|
const key = `${t.schema}.${t.name}`;
|
|
99
123
|
if (done.has(key))
|
|
100
124
|
continue;
|
|
101
|
-
const
|
|
102
|
-
|
|
125
|
+
const hardLeft = [...(hard.get(key) ?? [])].some((d) => !done.has(d));
|
|
126
|
+
const softLeft = [...(soft.get(key)?.keys() ?? [])].some((d) => !done.has(d) && !brokenSoft.has(`${key}::${d}`));
|
|
127
|
+
if (!hardLeft && !softLeft) {
|
|
103
128
|
ordered.push(t);
|
|
104
129
|
done.add(key);
|
|
105
130
|
progress = true;
|
|
106
131
|
}
|
|
107
132
|
}
|
|
133
|
+
if (progress)
|
|
134
|
+
continue;
|
|
135
|
+
// stuck: break ONE soft edge on the first (deterministic) blocked
|
|
136
|
+
// table whose hard deps are satisfied, then retry
|
|
137
|
+
let broke = false;
|
|
138
|
+
for (const t of tables) {
|
|
139
|
+
const key = `${t.schema}.${t.name}`;
|
|
140
|
+
if (done.has(key))
|
|
141
|
+
continue;
|
|
142
|
+
if ([...(hard.get(key) ?? [])].some((d) => !done.has(d)))
|
|
143
|
+
continue;
|
|
144
|
+
const pending = [...(soft.get(key)?.keys() ?? [])].find((d) => !done.has(d) && !brokenSoft.has(`${key}::${d}`));
|
|
145
|
+
if (pending !== undefined) {
|
|
146
|
+
brokenSoft.add(`${key}::${pending}`);
|
|
147
|
+
broke = true;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!broke)
|
|
152
|
+
break;
|
|
108
153
|
}
|
|
109
154
|
const cyclic = tables.filter((t) => !done.has(`${t.schema}.${t.name}`));
|
|
110
|
-
|
|
155
|
+
// Column-level view of the broken edges for the emitter.
|
|
156
|
+
const brokenCols = new Set();
|
|
157
|
+
for (const marker of brokenSoft) {
|
|
158
|
+
const [key, target] = marker.split("::");
|
|
159
|
+
for (const colName of soft.get(key)?.get(target) ?? []) {
|
|
160
|
+
brokenCols.add(`${key}.${colName}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { ordered, cyclic, brokenSoft: brokenCols };
|
|
111
164
|
}
|
|
112
165
|
export class SeedTarget {
|
|
113
166
|
name = "seed";
|
|
@@ -136,6 +189,8 @@ export class SeedTarget {
|
|
|
136
189
|
// skip tables that require one. Unconstrained domains behave as
|
|
137
190
|
// their base type and seed normally.
|
|
138
191
|
const constrainedDomains = new Set(snapshot.domains.filter((d) => d.hasConstraints).map((d) => d.name));
|
|
192
|
+
const domainBase = new Map(snapshot.domains.map((d) => [d.name, d.baseTypeName]));
|
|
193
|
+
const baseTypeOf = (name) => domainBase.get(name) ?? name;
|
|
139
194
|
// The literal a table's Nth row uses for its single-column primary
|
|
140
195
|
// key. Children reuse this for their FK cells, so uuid and numeric
|
|
141
196
|
// parents both reference correctly. Deterministic by construction.
|
|
@@ -146,6 +201,10 @@ export class SeedTarget {
|
|
|
146
201
|
const col = t.columns.find((c) => c.name === pkName);
|
|
147
202
|
if (!col)
|
|
148
203
|
return null;
|
|
204
|
+
// A stored-generated pk computes its own value from an expression
|
|
205
|
+
// the generator cannot predict; children cannot reference it.
|
|
206
|
+
if (col.generated)
|
|
207
|
+
return null;
|
|
149
208
|
if (col.runtime.kind === "number")
|
|
150
209
|
return String(i + 1);
|
|
151
210
|
// bigint primary keys arrive as strings from the driver but seed
|
|
@@ -155,9 +214,9 @@ export class SeedTarget {
|
|
|
155
214
|
return String(i + 1);
|
|
156
215
|
}
|
|
157
216
|
const rand = mulberry32(hashString(`${t.schema}.${t.name}.${pkName}.${i}`));
|
|
158
|
-
return literalFor(col.runtime, col, t, i, rand);
|
|
217
|
+
return literalFor(col.runtime, col, t, i, rand, baseTypeOf);
|
|
159
218
|
};
|
|
160
|
-
const { ordered, cyclic } = topoSort(tables);
|
|
219
|
+
const { ordered, cyclic, brokenSoft } = topoSort(tables);
|
|
161
220
|
for (const t of cyclic) {
|
|
162
221
|
lines.push(`-- skipped ${t.schema}.${t.name}: required foreign keys form a cycle`);
|
|
163
222
|
}
|
|
@@ -192,14 +251,47 @@ export class SeedTarget {
|
|
|
192
251
|
continue;
|
|
193
252
|
}
|
|
194
253
|
if (col.name === pk) {
|
|
254
|
+
// A pk with no honest literal (interval, composite, ...) can
|
|
255
|
+
// only work when the database fills it; without a default the
|
|
256
|
+
// table cannot be seeded at all.
|
|
257
|
+
const pkProbe = literalFor(col.runtime, col, table, 0, mulberry32(1), baseTypeOf);
|
|
258
|
+
if (pkProbe === null) {
|
|
259
|
+
if (col.identity || col.hasDefault)
|
|
260
|
+
continue; // db fills it
|
|
261
|
+
skipReasons.push(`primary key ${col.name} has no honest literal (${col.sqlType})`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
195
264
|
cols.push(col);
|
|
196
265
|
continue;
|
|
197
266
|
}
|
|
198
267
|
if (fk) {
|
|
268
|
+
// FK cells reuse the parent's primary-key literals, which is
|
|
269
|
+
// only honest when the FK actually references that primary
|
|
270
|
+
// key. A reference to a UNIQUE column would get pk values in
|
|
271
|
+
// a non-pk column and violate on apply.
|
|
272
|
+
const parent = byName.get(`${fk.referencedSchema}.${fk.referencedTable}`);
|
|
273
|
+
const refsParentPk = parent !== undefined &&
|
|
274
|
+
parent.primaryKey.length === 1 &&
|
|
275
|
+
fk.referencedColumns.length === 1 &&
|
|
276
|
+
fk.referencedColumns[0] === parent.primaryKey[0];
|
|
277
|
+
if (!refsParentPk) {
|
|
278
|
+
if (!col.nullable && !col.hasDefault) {
|
|
279
|
+
skipReasons.push(`foreign key ${col.name} references ${fk.referencedTable}(${fk.referencedColumns.join(", ")}), not its primary key`);
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// ... and the parent's pk values must be predictable
|
|
284
|
+
// (generated or literal-less pks are not).
|
|
285
|
+
if (parent !== undefined && pkLiteral(parent, 0) === null) {
|
|
286
|
+
if (!col.nullable && !col.hasDefault) {
|
|
287
|
+
skipReasons.push(`foreign key ${col.name} references ${fk.referencedTable}, whose primary key values the generator cannot predict`);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
199
291
|
cols.push(col);
|
|
200
292
|
continue;
|
|
201
293
|
}
|
|
202
|
-
const probe = literalFor(col.runtime, col, table, 0, mulberry32(1));
|
|
294
|
+
const probe = literalFor(col.runtime, col, table, 0, mulberry32(1), baseTypeOf);
|
|
203
295
|
if (probe === null) {
|
|
204
296
|
if (!col.nullable && !col.hasDefault) {
|
|
205
297
|
skipReasons.push(`no honest value for ${col.name} (${col.sqlType})`);
|
|
@@ -265,6 +357,10 @@ export class SeedTarget {
|
|
|
265
357
|
}
|
|
266
358
|
const fk = table.foreignKeys.find((f) => f.columns.includes(col.name));
|
|
267
359
|
if (fk) {
|
|
360
|
+
// a soft edge broken to escape a cycle seeds null on EVERY
|
|
361
|
+
// row: the parent rows do not exist yet at apply time
|
|
362
|
+
if (brokenSoft.has(`${table.schema}.${table.name}.${col.name}`))
|
|
363
|
+
return "null";
|
|
268
364
|
if (col.nullable && i === rows - 1)
|
|
269
365
|
return "null";
|
|
270
366
|
const parent = byName.get(`${fk.referencedSchema}.${fk.referencedTable}`);
|
|
@@ -274,7 +370,7 @@ export class SeedTarget {
|
|
|
274
370
|
if (col.nullable && i === rows - 1)
|
|
275
371
|
return "null";
|
|
276
372
|
const rand = mulberry32(hashString(`${table.schema}.${table.name}.${col.name}.${i}`));
|
|
277
|
-
return literalFor(col.runtime, col, table, i, rand) ?? "null";
|
|
373
|
+
return literalFor(col.runtime, col, table, i, rand, baseTypeOf) ?? "null";
|
|
278
374
|
});
|
|
279
375
|
lines.push(`insert into ${ident} (${colList})${overriding} values (${values.join(", ")});`);
|
|
280
376
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supawatch/target-seed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Deterministic FK-aware seed.sql generated from live Postgres by supawatch: topological order, identity overriding, sequence resync.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"supabase",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dist"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@supawatch/core": "0.
|
|
37
|
+
"@supawatch/core": "0.9.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^22.20.1",
|