@supawatch/target-seed 0.7.0 → 0.8.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 +92 -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";
|
|
@@ -63,7 +73,7 @@ function literalFor(runtime, col, table, rowIndex, rand) {
|
|
|
63
73
|
return sqlString(label) + `::"${col.pgTypeName.replace(/^_/, "")}"`;
|
|
64
74
|
}
|
|
65
75
|
case "array": {
|
|
66
|
-
const el = literalFor(runtime.element, col, table, rowIndex, rand);
|
|
76
|
+
const el = literalFor(runtime.element, col, table, rowIndex, rand, baseTypeOf);
|
|
67
77
|
if (el === null)
|
|
68
78
|
return null;
|
|
69
79
|
return `array[${el}]`;
|
|
@@ -74,40 +84,81 @@ function literalFor(runtime, col, table, rowIndex, rand) {
|
|
|
74
84
|
}
|
|
75
85
|
// Kahn's algorithm over single-column FK edges; nullable-FK edges are
|
|
76
86
|
// soft (broken first on cycles, seeded as null).
|
|
87
|
+
// Hard edges (required FKs) must be honored; soft edges (nullable FKs)
|
|
88
|
+
// are honored too, because a nullable FK cell with a value still needs
|
|
89
|
+
// its parent row first. Only when nothing can proceed does a soft edge
|
|
90
|
+
// break, and the broken cells seed as null on every row.
|
|
77
91
|
function topoSort(tables) {
|
|
78
92
|
const byName = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
79
|
-
const
|
|
93
|
+
const hard = new Map();
|
|
94
|
+
const soft = new Map();
|
|
80
95
|
for (const t of tables) {
|
|
81
96
|
const key = `${t.schema}.${t.name}`;
|
|
82
|
-
const
|
|
97
|
+
const h = new Set();
|
|
98
|
+
const s = new Map();
|
|
83
99
|
for (const fk of t.foreignKeys) {
|
|
84
100
|
const target = `${fk.referencedSchema}.${fk.referencedTable}`;
|
|
85
101
|
const col = t.columns.find((c) => c.name === fk.columns[0]);
|
|
86
|
-
if (target
|
|
87
|
-
|
|
102
|
+
if (target === key || !byName.has(target) || !col)
|
|
103
|
+
continue;
|
|
104
|
+
if (col.nullable) {
|
|
105
|
+
s.set(target, [...(s.get(target) ?? []), col.name]);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
h.add(target);
|
|
88
109
|
}
|
|
89
110
|
}
|
|
90
|
-
|
|
111
|
+
hard.set(key, h);
|
|
112
|
+
soft.set(key, s);
|
|
91
113
|
}
|
|
92
114
|
const ordered = [];
|
|
93
115
|
const done = new Set();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
progress = false;
|
|
116
|
+
const brokenSoft = new Set();
|
|
117
|
+
for (;;) {
|
|
118
|
+
let progress = false;
|
|
97
119
|
for (const t of tables) {
|
|
98
120
|
const key = `${t.schema}.${t.name}`;
|
|
99
121
|
if (done.has(key))
|
|
100
122
|
continue;
|
|
101
|
-
const
|
|
102
|
-
|
|
123
|
+
const hardLeft = [...(hard.get(key) ?? [])].some((d) => !done.has(d));
|
|
124
|
+
const softLeft = [...(soft.get(key)?.keys() ?? [])].some((d) => !done.has(d) && !brokenSoft.has(`${key}::${d}`));
|
|
125
|
+
if (!hardLeft && !softLeft) {
|
|
103
126
|
ordered.push(t);
|
|
104
127
|
done.add(key);
|
|
105
128
|
progress = true;
|
|
106
129
|
}
|
|
107
130
|
}
|
|
131
|
+
if (progress)
|
|
132
|
+
continue;
|
|
133
|
+
// stuck: break ONE soft edge on the first (deterministic) blocked
|
|
134
|
+
// table whose hard deps are satisfied, then retry
|
|
135
|
+
let broke = false;
|
|
136
|
+
for (const t of tables) {
|
|
137
|
+
const key = `${t.schema}.${t.name}`;
|
|
138
|
+
if (done.has(key))
|
|
139
|
+
continue;
|
|
140
|
+
if ([...(hard.get(key) ?? [])].some((d) => !done.has(d)))
|
|
141
|
+
continue;
|
|
142
|
+
const pending = [...(soft.get(key)?.keys() ?? [])].find((d) => !done.has(d) && !brokenSoft.has(`${key}::${d}`));
|
|
143
|
+
if (pending !== undefined) {
|
|
144
|
+
brokenSoft.add(`${key}::${pending}`);
|
|
145
|
+
broke = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!broke)
|
|
150
|
+
break;
|
|
108
151
|
}
|
|
109
152
|
const cyclic = tables.filter((t) => !done.has(`${t.schema}.${t.name}`));
|
|
110
|
-
|
|
153
|
+
// Column-level view of the broken edges for the emitter.
|
|
154
|
+
const brokenCols = new Set();
|
|
155
|
+
for (const marker of brokenSoft) {
|
|
156
|
+
const [key, target] = marker.split("::");
|
|
157
|
+
for (const colName of soft.get(key)?.get(target) ?? []) {
|
|
158
|
+
brokenCols.add(`${key}.${colName}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { ordered, cyclic, brokenSoft: brokenCols };
|
|
111
162
|
}
|
|
112
163
|
export class SeedTarget {
|
|
113
164
|
name = "seed";
|
|
@@ -136,6 +187,8 @@ export class SeedTarget {
|
|
|
136
187
|
// skip tables that require one. Unconstrained domains behave as
|
|
137
188
|
// their base type and seed normally.
|
|
138
189
|
const constrainedDomains = new Set(snapshot.domains.filter((d) => d.hasConstraints).map((d) => d.name));
|
|
190
|
+
const domainBase = new Map(snapshot.domains.map((d) => [d.name, d.baseTypeName]));
|
|
191
|
+
const baseTypeOf = (name) => domainBase.get(name) ?? name;
|
|
139
192
|
// The literal a table's Nth row uses for its single-column primary
|
|
140
193
|
// key. Children reuse this for their FK cells, so uuid and numeric
|
|
141
194
|
// parents both reference correctly. Deterministic by construction.
|
|
@@ -155,9 +208,9 @@ export class SeedTarget {
|
|
|
155
208
|
return String(i + 1);
|
|
156
209
|
}
|
|
157
210
|
const rand = mulberry32(hashString(`${t.schema}.${t.name}.${pkName}.${i}`));
|
|
158
|
-
return literalFor(col.runtime, col, t, i, rand);
|
|
211
|
+
return literalFor(col.runtime, col, t, i, rand, baseTypeOf);
|
|
159
212
|
};
|
|
160
|
-
const { ordered, cyclic } = topoSort(tables);
|
|
213
|
+
const { ordered, cyclic, brokenSoft } = topoSort(tables);
|
|
161
214
|
for (const t of cyclic) {
|
|
162
215
|
lines.push(`-- skipped ${t.schema}.${t.name}: required foreign keys form a cycle`);
|
|
163
216
|
}
|
|
@@ -196,10 +249,25 @@ export class SeedTarget {
|
|
|
196
249
|
continue;
|
|
197
250
|
}
|
|
198
251
|
if (fk) {
|
|
252
|
+
// FK cells reuse the parent's primary-key literals, which is
|
|
253
|
+
// only honest when the FK actually references that primary
|
|
254
|
+
// key. A reference to a UNIQUE column would get pk values in
|
|
255
|
+
// a non-pk column and violate on apply.
|
|
256
|
+
const parent = byName.get(`${fk.referencedSchema}.${fk.referencedTable}`);
|
|
257
|
+
const refsParentPk = parent !== undefined &&
|
|
258
|
+
parent.primaryKey.length === 1 &&
|
|
259
|
+
fk.referencedColumns.length === 1 &&
|
|
260
|
+
fk.referencedColumns[0] === parent.primaryKey[0];
|
|
261
|
+
if (!refsParentPk) {
|
|
262
|
+
if (!col.nullable && !col.hasDefault) {
|
|
263
|
+
skipReasons.push(`foreign key ${col.name} references ${fk.referencedTable}(${fk.referencedColumns.join(", ")}), not its primary key`);
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
199
267
|
cols.push(col);
|
|
200
268
|
continue;
|
|
201
269
|
}
|
|
202
|
-
const probe = literalFor(col.runtime, col, table, 0, mulberry32(1));
|
|
270
|
+
const probe = literalFor(col.runtime, col, table, 0, mulberry32(1), baseTypeOf);
|
|
203
271
|
if (probe === null) {
|
|
204
272
|
if (!col.nullable && !col.hasDefault) {
|
|
205
273
|
skipReasons.push(`no honest value for ${col.name} (${col.sqlType})`);
|
|
@@ -265,6 +333,10 @@ export class SeedTarget {
|
|
|
265
333
|
}
|
|
266
334
|
const fk = table.foreignKeys.find((f) => f.columns.includes(col.name));
|
|
267
335
|
if (fk) {
|
|
336
|
+
// a soft edge broken to escape a cycle seeds null on EVERY
|
|
337
|
+
// row: the parent rows do not exist yet at apply time
|
|
338
|
+
if (brokenSoft.has(`${table.schema}.${table.name}.${col.name}`))
|
|
339
|
+
return "null";
|
|
268
340
|
if (col.nullable && i === rows - 1)
|
|
269
341
|
return "null";
|
|
270
342
|
const parent = byName.get(`${fk.referencedSchema}.${fk.referencedTable}`);
|
|
@@ -274,7 +346,7 @@ export class SeedTarget {
|
|
|
274
346
|
if (col.nullable && i === rows - 1)
|
|
275
347
|
return "null";
|
|
276
348
|
const rand = mulberry32(hashString(`${table.schema}.${table.name}.${col.name}.${i}`));
|
|
277
|
-
return literalFor(col.runtime, col, table, i, rand) ?? "null";
|
|
349
|
+
return literalFor(col.runtime, col, table, i, rand, baseTypeOf) ?? "null";
|
|
278
350
|
});
|
|
279
351
|
lines.push(`insert into ${ident} (${colList})${overriding} values (${values.join(", ")});`);
|
|
280
352
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supawatch/target-seed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.8.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^22.20.1",
|