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