@supawatch/target-seed 0.3.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 +26 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +243 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omar Dulaimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @supawatch/target-seed
|
|
2
|
+
|
|
3
|
+
The seed target for
|
|
4
|
+
[supawatch](https://github.com/omar-dulaimi/supawatch). Emits `seed.sql`:
|
|
5
|
+
deterministic, FK-aware seed rows. Parents insert before children,
|
|
6
|
+
identity columns get explicit ids via `OVERRIDING SYSTEM VALUE`,
|
|
7
|
+
sequences are resynced with `setval`, enum labels are real, and the byte
|
|
8
|
+
output is stable for an unchanged schema, so the file diffs like code.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
targets: [{ kind: "seed", rows: 3 }]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Drop the output at `supabase/seed.sql` and `supabase db reset` applies
|
|
15
|
+
it. The repo's suite proves the hard parts against a real empty
|
|
16
|
+
database: an identity-always bigint parent, a uuid parent, FK chains
|
|
17
|
+
with a nullable edge, and a post-seed insert that does not collide
|
|
18
|
+
because sequences were resynced. Seeded rows are ground-truth checked
|
|
19
|
+
against the generated Zod schemas.
|
|
20
|
+
|
|
21
|
+
Honest limits, emitted as comments rather than guesses: tables whose
|
|
22
|
+
required foreign keys form a cycle, multi-column foreign keys, and
|
|
23
|
+
required columns with no default whose types have no honest literal
|
|
24
|
+
(composites, unknown types) are skipped by name.
|
|
25
|
+
|
|
26
|
+
MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snapshot, SnapshotFile, Target, TargetCapabilities, TargetOptions } from "@supawatch/core";
|
|
2
|
+
export interface SeedTargetOptions extends TargetOptions {
|
|
3
|
+
rows?: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class SeedTarget implements Target<SeedTargetOptions> {
|
|
6
|
+
readonly name = "seed";
|
|
7
|
+
readonly fileExtension = ".sql";
|
|
8
|
+
readonly barrel = false;
|
|
9
|
+
readonly capabilities: TargetCapabilities;
|
|
10
|
+
renderTable(): never;
|
|
11
|
+
renderSnapshot(snapshot: Snapshot, opts: SeedTargetOptions): SnapshotFile[];
|
|
12
|
+
}
|
|
13
|
+
export default SeedTarget;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Deterministic PRNG (mulberry32), seeded per column so output is
|
|
2
|
+
// stable regardless of table iteration order changes.
|
|
3
|
+
function mulberry32(seed) {
|
|
4
|
+
let a = seed >>> 0;
|
|
5
|
+
return () => {
|
|
6
|
+
a |= 0;
|
|
7
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
8
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
9
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
10
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function hashString(s) {
|
|
14
|
+
let h = 2166136261;
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
h ^= s.charCodeAt(i);
|
|
17
|
+
h = Math.imul(h, 16777619);
|
|
18
|
+
}
|
|
19
|
+
return h >>> 0;
|
|
20
|
+
}
|
|
21
|
+
function sqlString(s) {
|
|
22
|
+
return "'" + s.replace(/'/g, "''") + "'";
|
|
23
|
+
}
|
|
24
|
+
function deterministicUuid(rand) {
|
|
25
|
+
const hex = () => Math.floor(rand() * 16).toString(16);
|
|
26
|
+
const s = (n) => Array.from({ length: n }, hex).join("");
|
|
27
|
+
// version 4 and variant bits set, so validators accept it
|
|
28
|
+
return `${s(8)}-${s(4)}-4${s(3)}-${"89ab"[Math.floor(rand() * 4)]}${s(3)}-${s(12)}`;
|
|
29
|
+
}
|
|
30
|
+
function literalFor(runtime, col, table, rowIndex, rand) {
|
|
31
|
+
switch (runtime.kind) {
|
|
32
|
+
case "number":
|
|
33
|
+
return runtime.integer
|
|
34
|
+
? String(1 + Math.floor(rand() * 1000))
|
|
35
|
+
: (rand() * 100).toFixed(2);
|
|
36
|
+
case "string":
|
|
37
|
+
switch (runtime.format) {
|
|
38
|
+
case "uuid":
|
|
39
|
+
return sqlString(deterministicUuid(rand));
|
|
40
|
+
case "numeric":
|
|
41
|
+
return sqlString((rand() * 1000).toFixed(2));
|
|
42
|
+
case "bigint":
|
|
43
|
+
return sqlString(String(1 + Math.floor(rand() * 100000)));
|
|
44
|
+
case "composite":
|
|
45
|
+
case "array-literal":
|
|
46
|
+
return null; // cannot construct honestly from here
|
|
47
|
+
default:
|
|
48
|
+
return sqlString(`${table.name} ${col.name} ${rowIndex + 1}`);
|
|
49
|
+
}
|
|
50
|
+
case "boolean":
|
|
51
|
+
return rand() < 0.5 ? "true" : "false";
|
|
52
|
+
case "date": {
|
|
53
|
+
const day = 1 + Math.floor(rand() * 27);
|
|
54
|
+
const month = 1 + Math.floor(rand() * 12);
|
|
55
|
+
return sqlString(`2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T09:00:00Z`);
|
|
56
|
+
}
|
|
57
|
+
case "bytes":
|
|
58
|
+
return "'\\x00'";
|
|
59
|
+
case "json":
|
|
60
|
+
return sqlString("{}") + "::jsonb";
|
|
61
|
+
case "enum": {
|
|
62
|
+
const label = runtime.labels[rowIndex % runtime.labels.length];
|
|
63
|
+
return sqlString(label) + `::"${col.pgTypeName.replace(/^_/, "")}"`;
|
|
64
|
+
}
|
|
65
|
+
case "array": {
|
|
66
|
+
const el = literalFor(runtime.element, col, table, rowIndex, rand);
|
|
67
|
+
if (el === null)
|
|
68
|
+
return null;
|
|
69
|
+
return `array[${el}]`;
|
|
70
|
+
}
|
|
71
|
+
case "unknown":
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Kahn's algorithm over single-column FK edges; nullable-FK edges are
|
|
76
|
+
// soft (broken first on cycles, seeded as null).
|
|
77
|
+
function topoSort(tables) {
|
|
78
|
+
const byName = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
79
|
+
const deps = new Map();
|
|
80
|
+
for (const t of tables) {
|
|
81
|
+
const key = `${t.schema}.${t.name}`;
|
|
82
|
+
const set = new Set();
|
|
83
|
+
for (const fk of t.foreignKeys) {
|
|
84
|
+
const target = `${fk.referencedSchema}.${fk.referencedTable}`;
|
|
85
|
+
const col = t.columns.find((c) => c.name === fk.columns[0]);
|
|
86
|
+
if (target !== key && byName.has(target) && col && !col.nullable) {
|
|
87
|
+
set.add(target);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
deps.set(key, set);
|
|
91
|
+
}
|
|
92
|
+
const ordered = [];
|
|
93
|
+
const done = new Set();
|
|
94
|
+
let progress = true;
|
|
95
|
+
while (progress) {
|
|
96
|
+
progress = false;
|
|
97
|
+
for (const t of tables) {
|
|
98
|
+
const key = `${t.schema}.${t.name}`;
|
|
99
|
+
if (done.has(key))
|
|
100
|
+
continue;
|
|
101
|
+
const remaining = [...(deps.get(key) ?? [])].filter((d) => !done.has(d));
|
|
102
|
+
if (remaining.length === 0) {
|
|
103
|
+
ordered.push(t);
|
|
104
|
+
done.add(key);
|
|
105
|
+
progress = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const cyclic = tables.filter((t) => !done.has(`${t.schema}.${t.name}`));
|
|
110
|
+
return { ordered, cyclic };
|
|
111
|
+
}
|
|
112
|
+
export class SeedTarget {
|
|
113
|
+
name = "seed";
|
|
114
|
+
fileExtension = ".sql";
|
|
115
|
+
barrel = false;
|
|
116
|
+
capabilities = {
|
|
117
|
+
strictObjects: false,
|
|
118
|
+
brandedTypes: false,
|
|
119
|
+
dateInstances: false,
|
|
120
|
+
};
|
|
121
|
+
renderTable() {
|
|
122
|
+
throw new Error("seed is a snapshot-level target");
|
|
123
|
+
}
|
|
124
|
+
renderSnapshot(snapshot, opts) {
|
|
125
|
+
const rows = opts.rows ?? 3;
|
|
126
|
+
const lines = [
|
|
127
|
+
"-- Generated by supawatch. Do not edit.",
|
|
128
|
+
"-- Deterministic seed data; identical schema produces identical bytes.",
|
|
129
|
+
"begin;",
|
|
130
|
+
];
|
|
131
|
+
const tables = snapshot.tables.filter((t) => t.kind === "table");
|
|
132
|
+
const byName = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
133
|
+
// The literal a table's Nth row uses for its single-column primary
|
|
134
|
+
// key. Children reuse this for their FK cells, so uuid and numeric
|
|
135
|
+
// parents both reference correctly. Deterministic by construction.
|
|
136
|
+
const pkLiteral = (t, i) => {
|
|
137
|
+
const pkName = t.primaryKey.length === 1 ? t.primaryKey[0] : null;
|
|
138
|
+
if (!pkName)
|
|
139
|
+
return null;
|
|
140
|
+
const col = t.columns.find((c) => c.name === pkName);
|
|
141
|
+
if (!col)
|
|
142
|
+
return null;
|
|
143
|
+
if (col.runtime.kind === "number")
|
|
144
|
+
return String(i + 1);
|
|
145
|
+
// bigint primary keys arrive as strings from the driver but seed
|
|
146
|
+
// sequentially like any serial, so sequences resync and post-seed
|
|
147
|
+
// inserts reference real parents.
|
|
148
|
+
if (col.runtime.kind === "string" && col.runtime.format === "bigint") {
|
|
149
|
+
return String(i + 1);
|
|
150
|
+
}
|
|
151
|
+
const rand = mulberry32(hashString(`${t.schema}.${t.name}.${pkName}.${i}`));
|
|
152
|
+
return literalFor(col.runtime, col, t, i, rand);
|
|
153
|
+
};
|
|
154
|
+
const { ordered, cyclic } = topoSort(tables);
|
|
155
|
+
for (const t of cyclic) {
|
|
156
|
+
lines.push(`-- skipped ${t.schema}.${t.name}: required foreign keys form a cycle`);
|
|
157
|
+
}
|
|
158
|
+
for (const table of ordered) {
|
|
159
|
+
const q = (s) => '"' + s.replace(/"/g, '""') + '"';
|
|
160
|
+
const ident = `${q(table.schema)}.${q(table.name)}`;
|
|
161
|
+
const pk = table.primaryKey.length === 1 ? table.primaryKey[0] : null;
|
|
162
|
+
const skipReasons = [];
|
|
163
|
+
const cols = [];
|
|
164
|
+
for (const col of table.columns) {
|
|
165
|
+
if (col.generated)
|
|
166
|
+
continue;
|
|
167
|
+
// A non-PK identity-always column cannot take a value without
|
|
168
|
+
// OVERRIDING applying to it too; let the database fill it.
|
|
169
|
+
if (col.identity === "always" && col.name !== pk)
|
|
170
|
+
continue;
|
|
171
|
+
const fk = table.foreignKeys.find((f) => f.columns.includes(col.name));
|
|
172
|
+
if (fk && f_multi(fk)) {
|
|
173
|
+
if (!col.nullable && !col.hasDefault) {
|
|
174
|
+
skipReasons.push(`multi-column foreign key on ${col.name}`);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (col.name === pk) {
|
|
179
|
+
cols.push(col);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (fk) {
|
|
183
|
+
cols.push(col);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const probe = literalFor(col.runtime, col, table, 0, mulberry32(1));
|
|
187
|
+
if (probe === null) {
|
|
188
|
+
if (!col.nullable && !col.hasDefault) {
|
|
189
|
+
skipReasons.push(`no honest value for ${col.name} (${col.sqlType})`);
|
|
190
|
+
}
|
|
191
|
+
continue; // defaulted or nullable: let the database fill it
|
|
192
|
+
}
|
|
193
|
+
cols.push(col);
|
|
194
|
+
}
|
|
195
|
+
if (skipReasons.length > 0) {
|
|
196
|
+
lines.push(`-- skipped ${table.schema}.${table.name}: ${skipReasons.join("; ")}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (cols.length === 0)
|
|
200
|
+
continue;
|
|
201
|
+
const hasIdentityAlways = table.columns.some((c) => c.identity === "always" && c.name === pk);
|
|
202
|
+
const overriding = hasIdentityAlways ? " overriding system value" : "";
|
|
203
|
+
const colList = cols.map((c) => q(c.name)).join(", ");
|
|
204
|
+
for (let i = 0; i < rows; i++) {
|
|
205
|
+
const values = cols.map((col) => {
|
|
206
|
+
if (col.name === pk) {
|
|
207
|
+
const ref = pkLiteral(table, i);
|
|
208
|
+
if (ref !== null)
|
|
209
|
+
return ref;
|
|
210
|
+
}
|
|
211
|
+
const fk = table.foreignKeys.find((f) => f.columns.includes(col.name));
|
|
212
|
+
if (fk) {
|
|
213
|
+
if (col.nullable && i === rows - 1)
|
|
214
|
+
return "null";
|
|
215
|
+
const parent = byName.get(`${fk.referencedSchema}.${fk.referencedTable}`);
|
|
216
|
+
const ref = parent ? pkLiteral(parent, i % rows) : null;
|
|
217
|
+
return ref ?? "null";
|
|
218
|
+
}
|
|
219
|
+
if (col.nullable && i === rows - 1)
|
|
220
|
+
return "null";
|
|
221
|
+
const rand = mulberry32(hashString(`${table.schema}.${table.name}.${col.name}.${i}`));
|
|
222
|
+
return literalFor(col.runtime, col, table, i, rand) ?? "null";
|
|
223
|
+
});
|
|
224
|
+
lines.push(`insert into ${ident} (${colList})${overriding} values (${values.join(", ")});`);
|
|
225
|
+
}
|
|
226
|
+
if (pk) {
|
|
227
|
+
const pkCol = table.columns.find((c) => c.name === pk);
|
|
228
|
+
const pkIsSequential = pkCol &&
|
|
229
|
+
(pkCol.runtime.kind === "number" ||
|
|
230
|
+
(pkCol.runtime.kind === "string" && pkCol.runtime.format === "bigint"));
|
|
231
|
+
if (pkCol && (pkCol.identity || pkCol.hasDefault) && pkIsSequential) {
|
|
232
|
+
lines.push(`select setval(pg_get_serial_sequence('${ident.replace(/'/g, "''")}', ${sqlString(pk)}), ${rows}, true);`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
lines.push("commit;", "");
|
|
237
|
+
return [{ file: "seed.sql", content: lines.join("\n") }];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function f_multi(fk) {
|
|
241
|
+
return fk.columns.length > 1;
|
|
242
|
+
}
|
|
243
|
+
export default SeedTarget;
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supawatch/target-seed",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Deterministic FK-aware seed.sql generated from live Postgres by supawatch: topological order, identity overriding, sequence resync.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"supabase",
|
|
7
|
+
"postgres",
|
|
8
|
+
"postgresql",
|
|
9
|
+
"codegen",
|
|
10
|
+
"schema",
|
|
11
|
+
"typescript",
|
|
12
|
+
"seed",
|
|
13
|
+
"fixtures",
|
|
14
|
+
"seed-sql",
|
|
15
|
+
"supabase-seed"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Omar Dulaimi",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/omar-dulaimi/supawatch.git",
|
|
22
|
+
"directory": "packages/target-seed"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@supawatch/core": "0.4.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.1",
|
|
41
|
+
"typescript": "^5.9.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json"
|
|
45
|
+
}
|
|
46
|
+
}
|