@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.
Files changed (2) hide show
  1. package/dist/index.js +116 -20
  2. 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
- function literalFor(runtime, col, table, rowIndex, rand) {
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
- return sqlString(`${table.name} ${col.name} ${rowIndex + 1}`);
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 deps = new Map();
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 set = new Set();
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 !== key && byName.has(target) && col && !col.nullable) {
87
- set.add(target);
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
- deps.set(key, set);
113
+ hard.set(key, h);
114
+ soft.set(key, s);
91
115
  }
92
116
  const ordered = [];
93
117
  const done = new Set();
94
- let progress = true;
95
- while (progress) {
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 remaining = [...(deps.get(key) ?? [])].filter((d) => !done.has(d));
102
- if (remaining.length === 0) {
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
- return { ordered, cyclic };
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.7.1",
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.7.1"
37
+ "@supawatch/core": "0.9.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^22.20.1",