@ultimat3/testing 30.3.0 → 30.5.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.
@@ -41,6 +41,31 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
41
41
  });
42
42
  };
43
43
 
44
+ const refusedRow = (error: unknown): boolean =>
45
+ !isUltimateError(error) || ROW_REFUSALS.has(error.code) || error.code === 'X_ENTITY_APPEND_ONLY';
46
+
47
+ /**
48
+ * A deferred key the database refuses to set is left null: only a nullable key is ever deferred
49
+ * (`seed-graph-order.ts`), so the row as inserted is already a valid one. The refusal is a rule no
50
+ * declaration carries — a trigger that refuses every UPDATE of the table. An environment failure
51
+ * still rejects with its own code. `onRefused` hears each update left out.
52
+ */
53
+ export const deferredMayStayNull = (
54
+ writer: SeedWriter,
55
+ onRefused: (update: SeedUpdate) => void = () => undefined,
56
+ ): SeedWriter => ({
57
+ insert: (row) => writer.insert(row),
58
+ ...(writer.insertMany === undefined
59
+ ? {}
60
+ : { insertMany: (rows: readonly SeedRow[]) => writer.insertMany?.(rows) ?? Promise.resolve() }),
61
+ update: (update) =>
62
+ writer.update(update).catch((error: unknown) => {
63
+ if (!refusedRow(error)) return Promise.reject(error);
64
+ onRefused(update);
65
+ }),
66
+ snapshot: (rows) => writer.snapshot(rows),
67
+ });
68
+
44
69
  /** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
45
70
  const coded = (writer: SeedWriter): SeedWriter => ({
46
71
  insert: (row) =>
@@ -146,6 +171,10 @@ export async function seedPass(
146
171
  seed: options.seed,
147
172
  ...(options.factories === undefined ? {} : { factories: options.factories }),
148
173
  });
149
- await insertSeedGraph(graph, coded(writer));
150
- return graph;
174
+ const refused = new Set<SeedUpdate>();
175
+ await insertSeedGraph(graph, coded(deferredMayStayNull(writer, (update) => refused.add(update))));
176
+ // What was written: an update the database refused is no part of the seeded state.
177
+ return refused.size === 0
178
+ ? graph
179
+ : { ...graph, updates: graph.updates.filter((update) => !refused.has(update)) };
151
180
  }
@@ -0,0 +1,51 @@
1
+ // The `matches(/…/)` patterns an entity declares, per property. Read back from each invariant's
2
+ // own CHECK text, which `expr.ts` builds as `<column> ~ <literal(source)>` (or `btrim(<column>)`,
3
+ // `~*` for `/i`) — the same source `pattern.test` runs, so nothing here is a guess. A rule of any
4
+ // other shape (`iff`, a predicate, two columns) is not a pattern and is skipped.
5
+
6
+ import type { SeedEntity } from './seed-graph-shape';
7
+
8
+ /** `col ~ E'src'` / `btrim(col) ~* 'src'`: the whole CHECK, one column, one quoted literal. */
9
+ const PATTERN_CHECK = /^(?:btrim\(([a-z_][a-z0-9_$]*)\)|([a-z_][a-z0-9_$]*)) (~\*?) (E?)'(.*)'$/s;
10
+
11
+ /**
12
+ * The `RegExp` one CHECK enforces on `column`, or `undefined`. The literal is undone exactly as
13
+ * `@ultimat3/db`'s `literal()` made it: `''` is one quote, and under `E'…'` `\\` is one backslash.
14
+ */
15
+ export function patternOfCheck(sql: string, column: string): RegExp | undefined {
16
+ const match = PATTERN_CHECK.exec(sql);
17
+ if (match === null || (match[1] ?? match[2]) !== column) return undefined;
18
+ const body = match[5] ?? '';
19
+ // A lone quote inside the body is not something `literal()` writes: not this shape after all.
20
+ if (body.replaceAll("''", '').includes("'")) return undefined;
21
+ const unquoted = body.replaceAll("''", "'");
22
+ const source = match[4] === 'E' ? unquoted.replaceAll('\\\\', '\\') : unquoted;
23
+ try {
24
+ return new RegExp(source, match[3] === '~*' ? 'i' : '');
25
+ } catch {
26
+ // A source JavaScript cannot compile is no pattern to sample from; the solver still judges
27
+ // every candidate by the rule itself, so skipping it loses a hint and never a check.
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ /** Property -> the patterns its `check` invariants declare. Empty for an entity with none. */
33
+ export function declaredPatterns(
34
+ entity: Pick<SeedEntity, '$invariants' | '$describe'>,
35
+ ): ReadonlyMap<string, readonly RegExp[]> {
36
+ const propertyOf = new Map(
37
+ entity.$describe().columns.map((column) => [column.column, column.property] as const),
38
+ );
39
+ const out = new Map<string, RegExp[]>();
40
+ for (const invariant of entity.$invariants) {
41
+ const [column] = invariant.columns;
42
+ if (invariant.kind !== 'check' || invariant.sql === null || invariant.columns.length !== 1) {
43
+ continue;
44
+ }
45
+ const property = column === undefined ? undefined : propertyOf.get(column);
46
+ const pattern = column === undefined ? undefined : patternOfCheck(invariant.sql, column);
47
+ if (property === undefined || pattern === undefined) continue;
48
+ out.set(property, [...(out.get(property) ?? []), pattern]);
49
+ }
50
+ return out;
51
+ }
@@ -1,8 +1,12 @@
1
1
  // A value for one column, read off the column's own declaration — its kind, nullability, enumerated
2
- // values, length, money parts and default — never off its name. Every candidate is run through the
3
- // column's `$parse`, which refuses exactly what its CHECK refuses, so what survives is storable.
2
+ // values, length, money parts, default, `matches()` patterns and `json(schema)` — never off its
3
+ // name. Every candidate is run through the column's `$parse`, which refuses exactly what its CHECK
4
+ // refuses, so what survives is storable.
4
5
 
6
+ import { fnv1a } from '@ultimat3/core';
5
7
  import type { ColumnMeta } from '@ultimat3/entity';
8
+ import type * as Sample from '@ultimat3/schema/sample';
9
+ import { patternSamples } from './seed-pattern-sample';
6
10
 
7
11
  /** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
8
12
  export interface SeedColumn {
@@ -18,6 +22,11 @@ export interface ValueContext {
18
22
  readonly canary: string;
19
23
  /** Declaration position of the column, so two instants on one row are ordered as declared. */
20
24
  readonly position: number;
25
+ /**
26
+ * The `matches(/…/)` patterns the entity's invariants hold this column to (`declaredPatterns`).
27
+ * Each one adds strings built from the pattern itself, so a rule no fixed shape meets is met.
28
+ */
29
+ readonly patterns?: readonly RegExp[] | undefined;
21
30
  uuid(): string;
22
31
  }
23
32
 
@@ -59,7 +68,46 @@ const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext):
59
68
  `https://seed.test/${word}/${ctx.canary}`,
60
69
  ctx.canary,
61
70
  ];
62
- return meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
71
+ const sized = meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
72
+ return [...sized, ...fromPatterns(property, ctx)];
73
+ };
74
+
75
+ /** Strings drawn from each declared pattern, kept only where every other pattern agrees. */
76
+ const fromPatterns = (property: string, ctx: ValueContext): string[] => {
77
+ const patterns = ctx.patterns ?? [];
78
+ return patterns
79
+ .flatMap((pattern) => patternSamples(pattern, `${property}:${ctx.canary}`))
80
+ .filter((sample) => patterns.every((pattern) => pattern.test(sample)));
81
+ };
82
+
83
+ let loadedSampler: typeof Sample | undefined;
84
+
85
+ /**
86
+ * `@ultimat3/schema/sample`, loaded on the first `json(schema)` column and never at import: this
87
+ * module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
88
+ * `require` and not `import()` because every caller of `candidatesFor` is synchronous.
89
+ */
90
+ const sampler = (): typeof Sample => {
91
+ loadedSampler ??= require('@ultimat3/schema/sample') as typeof Sample;
92
+ return loadedSampler;
93
+ };
94
+
95
+ /**
96
+ * Values the column's own schema accepts, seeded by the row so two rows differ and a rerun does
97
+ * not; then the bare `{}`/`[]`, which a schema-less `jsonb` column stores.
98
+ */
99
+ const jsonCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
100
+ const schema = meta.schema;
101
+ if (schema === undefined) return [{}, []];
102
+ const { arbitrary, sampleInput, BOUNDARY_RUNS } = sampler();
103
+ const seed = fnv1a(`${property}:${ctx.canary}`);
104
+ return [
105
+ arbitrary(schema, { seed, run: BOUNDARY_RUNS }),
106
+ arbitrary(schema, { seed, run: 0 }),
107
+ sampleInput(schema),
108
+ {},
109
+ [],
110
+ ];
63
111
  };
64
112
 
65
113
  const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
@@ -84,7 +132,7 @@ const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[
84
132
  case 'date':
85
133
  return [`2026-01-${String((n % 28) + 1).padStart(2, '0')}`];
86
134
  case 'jsonb':
87
- return [{}, []];
135
+ return jsonCandidates(property, meta, ctx);
88
136
  case 'bytea':
89
137
  return [new Uint8Array([n % 256])];
90
138
  case 'array':
@@ -104,6 +152,12 @@ const parsed = (column: SeedColumn, value: unknown): { readonly ok: unknown } |
104
152
  }
105
153
  };
106
154
 
155
+ /**
156
+ * What every driver binds as SQL NULL — a JSON `null` included (`pg-row.ts` `bindable`): `t.json()`
157
+ * accepts it and the sampler draws it, so a NOT NULL `jsonb` column must never be offered one.
158
+ */
159
+ const storesAsSqlNull = (value: unknown): boolean => value === null || value === undefined;
160
+
107
161
  /**
108
162
  * Every storable value for this column, most plausible first: the declared default, then what the
109
163
  * kind offers, then `null` when the column allows it. Empty means nothing generated is storable.
@@ -119,13 +173,23 @@ export function candidatesFor(
119
173
  const out: unknown[] = [];
120
174
  for (const value of raw) {
121
175
  const result = parsed(column, value);
122
- if (result !== undefined && !out.some((seen) => Bun.deepEquals(seen, result.ok))) {
123
- out.push(result.ok);
124
- }
176
+ if (result === undefined || (meta.notNull && storesAsSqlNull(result.ok))) continue;
177
+ if (!out.some((seen) => Bun.deepEquals(seen, result.ok))) out.push(result.ok);
125
178
  }
126
- return out;
179
+ return keepsPatternsFirst(out, ctx.patterns ?? []);
127
180
  }
128
181
 
182
+ /**
183
+ * Stable: what keeps every declared pattern moves ahead, so `factoriesFor`, which takes the FIRST
184
+ * candidate and runs no solver, builds a row its own rules accept.
185
+ */
186
+ const keepsPatternsFirst = (values: unknown[], patterns: readonly RegExp[]): unknown[] => {
187
+ if (patterns.length === 0) return values;
188
+ const keeps = (value: unknown): boolean =>
189
+ typeof value === 'string' && patterns.every((pattern) => pattern.test(value));
190
+ return [...values.filter(keeps), ...values.filter((value) => !keeps(value))];
191
+ };
192
+
129
193
  /** Candidates offered verbatim (a factory's value), kept only where the column stores them. */
130
194
  export function storable(column: SeedColumn, values: readonly unknown[]): readonly unknown[] {
131
195
  return values.flatMap((value) => {
package/src/seed-graph.ts CHANGED
@@ -7,6 +7,7 @@ import type { ReferenceDescription } from '@ultimat3/entity';
7
7
  import { DEFAULT_SEED, seededUuid } from './determinism';
8
8
  import { seedGraphUnsatisfied } from './seed-graph-error';
9
9
  import { edgeKey, insertOrder } from './seed-graph-order';
10
+ import { declaredPatterns } from './seed-graph-patterns';
10
11
  import type {
11
12
  SeedCanary,
12
13
  SeedGraph,
@@ -91,6 +92,7 @@ export function seedGraph(
91
92
  [...shapes.values()].map((shape) => ({
92
93
  name: shape.name,
93
94
  references: [...shape.references.values()],
95
+ updatable: shape.entity.$describe().appendOnly !== true,
94
96
  })),
95
97
  );
96
98
  const tenantStream = seededUuid(streamSeed(seed, '$tenants'));
@@ -163,6 +165,8 @@ export function seedGraph(
163
165
  const allUniques = [...shape.uniques, ...shape.partialUniques];
164
166
  const taken = allUniques.map(() => new Set<string>());
165
167
  const rootProperty = roots.get(name);
168
+ // Read once per table: each `matches(/…/)` an invariant holds a column to, sampled from.
169
+ const patterns = declaredPatterns(entity);
166
170
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
167
171
  let ordinal = 0;
168
172
  for (const [bucket, tenant] of buckets.entries()) {
@@ -195,6 +199,7 @@ export function seedGraph(
195
199
  ordinal,
196
200
  canary,
197
201
  position,
202
+ patterns: patterns.get(property),
198
203
  uuid: stream,
199
204
  });
200
205
  return [
@@ -257,6 +262,8 @@ export function seedGraph(
257
262
  const updates: SeedUpdate[] = [];
258
263
  for (const name of order) {
259
264
  const shape = shapes.get(name) as EntityShape;
265
+ // An appendOnly row is never updated (its trigger refuses): a key deferred there stays null.
266
+ if (shape.entity.$describe().appendOnly === true) continue;
260
267
  const later = shape.columns.filter(([property]) => deferred.has(edgeKey(name, property)));
261
268
  if (later.length === 0) continue;
262
269
  for (const { tenant, row, index } of placed.get(name) ?? []) {
@@ -0,0 +1,216 @@
1
+ // Strings a declared `matches(/…/)` pattern accepts, built by walking the pattern rather than
2
+ // guessing shapes. The common subset only — literals, classes, `.`, groups, alternation,
3
+ // quantifiers, anchors — and an honest `[]` for anything past it (lookaround, backreference, `\b`,
4
+ // `\p{…}`): a pattern this cannot read leaves the column's other candidates to the solver.
5
+
6
+ import { fnv1a } from '@ultimat3/core';
7
+ import { seededRandom } from './determinism';
8
+
9
+ type Node =
10
+ | { readonly kind: 'chars'; readonly chars: readonly string[] }
11
+ | { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
12
+ | { readonly kind: 'empty' };
13
+
14
+ interface Piece {
15
+ readonly node: Node;
16
+ readonly min: number;
17
+ readonly max: number;
18
+ }
19
+
20
+ /** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
21
+ const UNBOUNDED_SPAN = 6;
22
+
23
+ /** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
24
+ const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
25
+ const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
26
+ const WORD = PRINTABLE.filter((c) => /\w/.test(c));
27
+ const SPACE = [' ', '\t'];
28
+
29
+ const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
30
+ d: DIGITS,
31
+ D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
32
+ w: WORD,
33
+ W: PRINTABLE.filter((c) => !WORD.includes(c)),
34
+ s: SPACE,
35
+ S: PRINTABLE.filter((c) => c !== ' '),
36
+ };
37
+
38
+ const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
39
+
40
+ class Parser {
41
+ private at = 0;
42
+ /** The first construct past the subset; set once, and it ends the walk. */
43
+ private refused: string | undefined;
44
+ constructor(private readonly source: string) {}
45
+
46
+ /** The tree, or `undefined` when any part of the pattern is past the subset. */
47
+ parse(): Node | undefined {
48
+ const node = this.alternation();
49
+ if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
50
+ return this.refused === undefined ? node : undefined;
51
+ }
52
+
53
+ /** Records the refusal and moves to the end, so every loop above stops on its next look. */
54
+ private refuse(construct: string): readonly string[] {
55
+ this.refused ??= construct;
56
+ this.at = this.source.length;
57
+ return [];
58
+ }
59
+
60
+ private peek(): string | undefined {
61
+ return this.source[this.at];
62
+ }
63
+
64
+ private alternation(): Node {
65
+ const branches: Piece[][] = [this.sequence()];
66
+ while (this.peek() === '|') {
67
+ this.at += 1;
68
+ branches.push(this.sequence());
69
+ }
70
+ return { kind: 'alt', branches };
71
+ }
72
+
73
+ private sequence(): Piece[] {
74
+ const pieces: Piece[] = [];
75
+ for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
76
+ const node = this.atom();
77
+ const [min, max] = this.quantifier();
78
+ pieces.push({ node, min, max });
79
+ }
80
+ return pieces;
81
+ }
82
+
83
+ private quantifier(): readonly [number, number] {
84
+ const c = this.peek();
85
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
86
+ let range: readonly [number, number] | undefined;
87
+ if (c === '*') range = [0, UNBOUNDED_SPAN];
88
+ else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
89
+ else if (c === '?') range = [0, 1];
90
+ else if (bounds !== null) {
91
+ const low = Number(bounds[1]);
92
+ const high =
93
+ bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
94
+ range = [low, high];
95
+ }
96
+ if (range === undefined) return [1, 1];
97
+ this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
98
+ // A lazy quantifier matches the same strings; only the engine's preference differs.
99
+ if (this.peek() === '?') this.at += 1;
100
+ return range;
101
+ }
102
+
103
+ private atom(): Node {
104
+ const c = this.peek() as string;
105
+ this.at += 1;
106
+ if (c === '^' || c === '$') return { kind: 'empty' };
107
+ if (c === '.') return { kind: 'chars', chars: PRINTABLE };
108
+ if (c === '[') return { kind: 'chars', chars: this.charClass() };
109
+ if (c === '(') return this.group();
110
+ if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
111
+ if (c === '*' || c === '+' || c === '?' || c === '{')
112
+ return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
113
+ return { kind: 'chars', chars: [c] };
114
+ }
115
+
116
+ private group(): Node {
117
+ if (this.peek() === '?') {
118
+ const head = this.source.slice(this.at, this.at + 3);
119
+ if (head.startsWith('?:')) this.at += 2;
120
+ else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
121
+ this.at = this.source.indexOf('>', this.at) + 1;
122
+ } else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
123
+ }
124
+ const inner = this.alternation();
125
+ if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
126
+ this.at += 1;
127
+ return inner;
128
+ }
129
+
130
+ private escape(inClass: boolean): readonly string[] {
131
+ const c = this.peek();
132
+ if (c === undefined) return this.refuse('a trailing backslash');
133
+ this.at += 1;
134
+ const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
135
+ if (shorthand !== undefined) return shorthand;
136
+ const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
137
+ if (control !== undefined) return [control];
138
+ const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
139
+ if (hex > 0) {
140
+ const digits = this.source.slice(this.at, this.at + hex);
141
+ if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
142
+ this.at += hex;
143
+ return [String.fromCharCode(Number.parseInt(digits, 16))];
144
+ }
145
+ // `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
146
+ if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
147
+ return [c];
148
+ }
149
+
150
+ /** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
151
+ private charClass(): readonly string[] {
152
+ const negated = this.peek() === '^';
153
+ if (negated) this.at += 1;
154
+ const members = new Set<string>();
155
+ let first = true;
156
+ for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
157
+ if (c === undefined) return this.refuse('an unclosed class');
158
+ first = false;
159
+ const low = this.classMember();
160
+ if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
161
+ this.at += 1;
162
+ const high = this.classMember();
163
+ if (high.length !== 1) return this.refuse('a range ending in a shorthand');
164
+ const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
165
+ // Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
166
+ const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
167
+ for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
168
+ } else {
169
+ for (const ch of low) members.add(ch);
170
+ }
171
+ }
172
+ this.at += 1;
173
+ return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
174
+ }
175
+
176
+ private classMember(): readonly string[] {
177
+ const c = this.peek() as string;
178
+ this.at += 1;
179
+ return c === '\\' ? this.escape(true) : [c];
180
+ }
181
+ }
182
+
183
+ const draw = (node: Node, random: () => number): string => {
184
+ const pick = <T>(items: readonly T[]): T | undefined =>
185
+ items[Math.floor(random() * items.length)];
186
+ if (node.kind === 'empty') return '';
187
+ if (node.kind === 'chars') return pick(node.chars) ?? '';
188
+ const branch = pick(node.branches) ?? [];
189
+ return branch
190
+ .map((piece) => {
191
+ const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
192
+ return Array.from({ length: count }, () => draw(piece.node, random)).join('');
193
+ })
194
+ .join('');
195
+ };
196
+
197
+ /** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
198
+ const ATTEMPTS = 8;
199
+
200
+ /**
201
+ * Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
202
+ * machine. Empty when the pattern is past the subset, or when no draw lands inside it.
203
+ */
204
+ export function patternSamples(pattern: RegExp, seed: string, count = 4): readonly string[] {
205
+ const tree = new Parser(pattern.source).parse();
206
+ if (tree === undefined) return [];
207
+ const random = seededRandom(fnv1a(`${pattern.source}/${pattern.flags}:${seed}`) || 1);
208
+ // `lastIndex` would make `test` stateful under `g`/`y`; a copy without them judges each draw.
209
+ const judge = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
210
+ const out: string[] = [];
211
+ for (let attempt = 0; attempt < count * ATTEMPTS && out.length < count; attempt += 1) {
212
+ const sample = draw(tree, random);
213
+ if (judge.test(sample) && !out.includes(sample)) out.push(sample);
214
+ }
215
+ return out;
216
+ }
@@ -8,6 +8,7 @@ import { ctxOf, finiteOption, runWithContext, serviceActor } from '@ultimat3/cor
8
8
  import type { Subject, SubjectPlan } from '@ultimat3/jobs';
9
9
  import { memoryStepStore, planSubject, stepRunner, subjectEraseTables } from '@ultimat3/jobs';
10
10
  import { seedGraph } from './seed-graph';
11
+ import { deferredMayStayNull } from './seed-graph-pass';
11
12
  import type { SeedFactory, SeedGraph, SeedWriter } from './seed-graph-plan';
12
13
  import { insertSeedGraph } from './seed-graph-plan';
13
14
  import type { SeedEntity } from './seed-graph-shape';
@@ -111,7 +112,8 @@ export async function subjectProof<I>(
111
112
  tenants: 2,
112
113
  ...(options.factories === undefined ? {} : { factories: options.factories }),
113
114
  });
114
- await insertSeedGraph(graph, options.writer);
115
+ // The pass's rule: a nullable cycle key the database refuses to set stays null.
116
+ await insertSeedGraph(graph, deferredMayStayNull(options.writer));
115
117
  const tenant = graph.tenants[0] ?? null;
116
118
  const person = graph.inserts.find(
117
119
  (i) => i.entity === subject.entity && (i.tenant === tenant || i.tenant === null),