@ultimat3/testing 30.2.0 → 30.4.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.
@@ -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
+ }
@@ -72,6 +72,12 @@ export interface SeedGraph {
72
72
  /** Where `insertSeedGraph` writes. One insert per row, in order, then one update per deferred key. */
73
73
  export interface SeedWriter {
74
74
  insert(row: SeedRow): Promise<void>;
75
+ /**
76
+ * Rows of ONE entity, in plan order, as one write — `insertAll`. Optional: a writer without it
77
+ * takes `insert` per row. With it, a graph costs one statement per table, not one per row: a
78
+ * pass seeding a fresh tenant pair per primitive writes primitives × tables rows.
79
+ */
80
+ insertMany?(rows: readonly SeedRow[]): Promise<void>;
75
81
  update(update: SeedUpdate): Promise<void>;
76
82
  /**
77
83
  * Each row read back by its primary key, as one digest per row in the order given — `null` for
@@ -81,8 +87,24 @@ export interface SeedWriter {
81
87
  snapshot(rows: readonly SeedRow[]): Promise<readonly (string | null)[]>;
82
88
  }
83
89
 
90
+ /**
91
+ * Each run of consecutive rows of one entity. The plan writes table by table, so a run is a table's
92
+ * rows — and a parent table's run always lands before its children's.
93
+ */
94
+ export function entityRuns(rows: readonly SeedRow[]): readonly (readonly SeedRow[])[] {
95
+ const runs: SeedRow[][] = [];
96
+ for (const row of rows) {
97
+ const last = runs[runs.length - 1];
98
+ if (last !== undefined && last[0]?.entity === row.entity) last.push(row);
99
+ else runs.push([row]);
100
+ }
101
+ return runs;
102
+ }
103
+
84
104
  /** Writes the plan in order: every insert, then every deferred key. Sequential, so it replays. */
85
105
  export async function insertSeedGraph(graph: SeedGraph, writer: SeedWriter): Promise<void> {
86
- for (const row of graph.inserts) await writer.insert(row);
106
+ const { insertMany } = writer;
107
+ if (insertMany === undefined) for (const row of graph.inserts) await writer.insert(row);
108
+ else for (const run of entityRuns(graph.inserts)) await insertMany.call(writer, run);
87
109
  for (const update of graph.updates) await writer.update(update);
88
110
  }
@@ -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':
@@ -123,9 +171,20 @@ export function candidatesFor(
123
171
  out.push(result.ok);
124
172
  }
125
173
  }
126
- return out;
174
+ return keepsPatternsFirst(out, ctx.patterns ?? []);
127
175
  }
128
176
 
177
+ /**
178
+ * Stable: what keeps every declared pattern moves ahead, so `factoriesFor`, which takes the FIRST
179
+ * candidate and runs no solver, builds a row its own rules accept.
180
+ */
181
+ const keepsPatternsFirst = (values: unknown[], patterns: readonly RegExp[]): unknown[] => {
182
+ if (patterns.length === 0) return values;
183
+ const keeps = (value: unknown): boolean =>
184
+ typeof value === 'string' && patterns.every((pattern) => pattern.test(value));
185
+ return [...values.filter(keeps), ...values.filter((value) => !keeps(value))];
186
+ };
187
+
129
188
  /** Candidates offered verbatim (a factory's value), kept only where the column stores them. */
130
189
  export function storable(column: SeedColumn, values: readonly unknown[]): readonly unknown[] {
131
190
  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,
@@ -72,15 +73,26 @@ export function seedGraph(
72
73
  // A view entity is a relation Postgres derives: no row is ever written to it, so it is no part of
73
74
  // the graph (`X_ENTITY_VIEW_READ_ONLY` otherwise) — the tables it reads from carry the rows. A
74
75
  // history table is the same from this side: its trigger writes every row, from the parent's.
76
+ // A `readOnly` table is the app's own statement that it writes no row there (a trigger refuses
77
+ // every INSERT): seeded like a view, never — a reference into one is null or refused below.
78
+ const closed = new Map(
79
+ Object.values(entities).flatMap((entity) => {
80
+ const reason = entity.$describe().readOnly;
81
+ return reason === undefined ? [] : [[entity.$name, reason] as const];
82
+ }),
83
+ );
75
84
  const tables = Object.values(entities).filter((entity) => {
76
85
  const described = entity.$describe();
77
- return described.view === undefined && described.historyOf === undefined;
86
+ return (
87
+ described.view === undefined && described.historyOf === undefined && !closed.has(entity.$name)
88
+ );
78
89
  });
79
90
  const shapes = new Map(tables.map((entity) => [entity.$name, shapeOf(entity)]));
80
91
  const { order, deferred } = insertOrder(
81
92
  [...shapes.values()].map((shape) => ({
82
93
  name: shape.name,
83
94
  references: [...shape.references.values()],
95
+ updatable: shape.entity.$describe().appendOnly !== true,
84
96
  })),
85
97
  );
86
98
  const tenantStream = seededUuid(streamSeed(seed, '$tenants'));
@@ -99,21 +111,34 @@ export function seedGraph(
99
111
  for (const factory of Object.values(options.factories ?? {})) factory.reset();
100
112
 
101
113
  const placed = new Map<string, Placed[]>();
114
+ // The same rows by tenant: a pool read per row was a filter of the whole parent table, so a pass
115
+ // seeding a tenant pair per primitive planned in primitives² time.
116
+ const placedIn = new Map<string, Map<string | null, Placed[]>>();
102
117
  const parentValues = (
103
118
  reference: ReferenceDescription,
104
119
  tenant: string | null,
105
120
  index: number,
106
121
  ): unknown[] => {
107
122
  if (!shapes.has(reference.targetEntity)) return reference.nullable ? [null] : [];
108
- const pool = (placed.get(reference.targetEntity) ?? []).filter(
109
- (parent) => tenant === null || !scoped(reference.targetEntity) || parent.tenant === tenant,
110
- );
123
+ const pool =
124
+ tenant === null || !scoped(reference.targetEntity)
125
+ ? (placed.get(reference.targetEntity) ?? [])
126
+ : (placedIn.get(reference.targetEntity)?.get(tenant) ?? []);
111
127
  const values =
112
128
  pool.length === 0 ? [] : rotate(pool, index).map((p) => p.row[reference.targetProperty]);
113
129
  return reference.nullable ? [...values, null] : values;
114
130
  };
115
131
  const refuseEmpty = (shape: EntityShape, property: string, tenant: string | null): never => {
116
132
  const reference = shape.references.get(property);
133
+ const reason = reference === undefined ? undefined : closed.get(reference.targetEntity);
134
+ if (reference !== undefined && reason !== undefined) {
135
+ throw seedGraphUnsatisfied({
136
+ entity: shape.name,
137
+ what: `${property} references ${reference.targetEntity}, which is readOnly (${reason}): no row of it is seeded and ${property} is not nullable, so no ${shape.name} row can be written`,
138
+ columns: [property],
139
+ fix: `entity('${shape.name}', { readOnly: '<why the app never writes it>' }) # when the app never writes ${shape.name} either — else ${property}: <its column>.nullable(), then x db gen "${shape.entity.$table} ${property} nullable"`,
140
+ });
141
+ }
117
142
  throw seedGraphUnsatisfied({
118
143
  entity: shape.name,
119
144
  what:
@@ -135,9 +160,13 @@ export function seedGraph(
135
160
  const factory = options.factories?.[name];
136
161
  const rows: Placed[] = [];
137
162
  placed.set(name, rows);
163
+ const byTenant = new Map<string | null, Placed[]>();
164
+ placedIn.set(name, byTenant);
138
165
  const allUniques = [...shape.uniques, ...shape.partialUniques];
139
166
  const taken = allUniques.map(() => new Set<string>());
140
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);
141
170
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
142
171
  let ordinal = 0;
143
172
  for (const [bucket, tenant] of buckets.entries()) {
@@ -170,6 +199,7 @@ export function seedGraph(
170
199
  ordinal,
171
200
  canary,
172
201
  position,
202
+ patterns: patterns.get(property),
173
203
  uuid: stream,
174
204
  });
175
205
  return [
@@ -210,7 +240,11 @@ export function seedGraph(
210
240
  const key = uniqueKey(row, set);
211
241
  if (key !== undefined) taken[i]?.add(key);
212
242
  });
213
- rows.push({ tenant, row, index });
243
+ const one = { tenant, row, index };
244
+ rows.push(one);
245
+ const bucketRows = byTenant.get(tenant);
246
+ if (bucketRows === undefined) byTenant.set(tenant, [one]);
247
+ else bucketRows.push(one);
214
248
  inserts.push({ entity: name, table: entity.$table, tenant, row: { ...row } });
215
249
  canaries.push({
216
250
  entity: name,
@@ -228,6 +262,8 @@ export function seedGraph(
228
262
  const updates: SeedUpdate[] = [];
229
263
  for (const name of order) {
230
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;
231
267
  const later = shape.columns.filter(([property]) => deferred.has(edgeKey(name, property)));
232
268
  if (later.length === 0) continue;
233
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
+ }
@@ -42,45 +42,52 @@ const keyColumns = (entity: EntityCore): readonly string[] => {
42
42
  );
43
43
  };
44
44
 
45
+ /** Joins a composite key's parts, in Postgres and here alike: no seeded key holds a unit separator. */
46
+ const KEY_SEPARATOR = '\u001f';
47
+
45
48
  /**
46
49
  * `md5(t::text)` per row, by primary key, through the client — the whole stored row, columns no
47
- * entity declares included. One statement per table for a one-column key, one per row for a
48
- * composite one. Unscoped on purpose: the question is whether the row changed, not who may read it.
50
+ * entity declares included. ONE statement, a `union all` branch per table, a composite key matched
51
+ * through parallel arrays: the tenant proof snapshots four times a primitive, and a statement per
52
+ * table was primitives × tables round trips. Unscoped on purpose: the question is whether the row
53
+ * changed, not who may read it.
49
54
  */
50
55
  async function snapshotOnClient(
51
56
  client: DbClient,
52
57
  rows: readonly SeedRow[],
53
58
  entities: readonly EntityCore[],
54
59
  ): Promise<readonly (string | null)[]> {
55
- const out: (string | null)[] = rows.map(() => null);
56
60
  const byEntity = new Map<EntityCore, number[]>();
57
61
  entities.forEach((entity, i) => {
58
- byEntity.set(entity, [...(byEntity.get(entity) ?? []), i]);
62
+ const indexes = byEntity.get(entity);
63
+ if (indexes === undefined) byEntity.set(entity, [i]);
64
+ else indexes.push(i);
59
65
  });
60
- for (const [entity, indexes] of byEntity) {
61
- const table = identifier(entity.$table);
62
- const columns = keyColumns(entity);
63
- const keyAt = (i: number, at: number) => String(rows[i]?.row[entity.$primaryKey[at] as string]);
64
- const [single] = columns;
65
- if (columns.length === 1 && single !== undefined) {
66
- const key = identifier(single);
67
- const found = await client.query<{ k: string; h: string }>(
68
- sql`select ${key}::text as k, md5(t::text) as h from ${table} t where ${key}::text = any(${indexes.map((i) => keyAt(i, 0))}::text[])`,
69
- );
70
- const digests = new Map(found.map((one) => [one.k, one.h]));
71
- for (const i of indexes) out[i] = digests.get(keyAt(i, 0)) ?? null;
72
- continue;
73
- }
66
+ const keyOf = (entity: EntityCore, i: number): string =>
67
+ entity.$primaryKey.map((property) => String(rows[i]?.row[property])).join(KEY_SEPARATOR);
68
+ const branches = [...byEntity].map(([entity, indexes], at): SqlFragment => {
69
+ const columns = keyColumns(entity).map((column) => identifier(column));
70
+ // Each key column against its values, the parameter UNTYPED so Postgres types it as the
71
+ // column: `id::text = any(…)` read every row of a table that grows with the primitives. A
72
+ // composite key matches a superset here; the full key is matched below.
73
+ const matched = entity.$primaryKey.map(
74
+ (property, part) =>
75
+ sql`${columns[part] as SqlFragment} = any(${[...new Set(indexes.map((i) => String(rows[i]?.row[property])))]})`,
76
+ );
77
+ const texts = columns.map((column) => sql`${column}::text`);
78
+ return sql`select ${at}::int as e, concat_ws(${KEY_SEPARATOR}, ${join(texts)}) as k, md5(t::text) as h from ${identifier(entity.$table)} t where ${join(matched, ' and ')}`;
79
+ });
80
+ if (branches.length === 0) return [];
81
+ const found = await client.query<{ e: number; k: string; h: string }>(
82
+ join(branches, ' union all '),
83
+ );
84
+ const digests = new Map(found.map((one) => [`${one.e}${KEY_SEPARATOR}${one.k}`, one.h]));
85
+ const out: (string | null)[] = rows.map(() => null);
86
+ [...byEntity].forEach(([entity, indexes], at) => {
74
87
  for (const i of indexes) {
75
- const terms: SqlFragment[] = columns.map(
76
- (column, at) => sql`${identifier(column)}::text = ${keyAt(i, at)}`,
77
- );
78
- const one = await client.one<{ h: string }>(
79
- sql`select md5(t::text) as h from ${table} t where ${join(terms, ' and ')}`,
80
- );
81
- out[i] = one?.h ?? null;
88
+ out[i] = digests.get(`${at}${KEY_SEPARATOR}${keyOf(entity, i)}`) ?? null;
82
89
  }
83
- }
90
+ });
84
91
  return out;
85
92
  }
86
93
 
@@ -146,6 +153,20 @@ export function entitySeedWriter(options: EntitySeedWriterOptions): SeedWriter {
146
153
  const { entity, repo } = await repoFor(row.entity);
147
154
  await repo.insert({ ...row.row }, scope(entity, row));
148
155
  },
156
+ async insertMany(rows) {
157
+ const [first] = rows;
158
+ if (first === undefined) return;
159
+ const { entity, repo } = await repoFor(first.entity);
160
+ // One scope when every row shares it. An insert's statement reads none — both drivers check
161
+ // a row's tenant column against the ACTOR, and a seed has none — so a table holding many
162
+ // tenants' rows is still one `insertAll`.
163
+ const scopes = new Set(rows.map((row) => scope(entity, row).orgId));
164
+ const [only] = scopes;
165
+ await repo.insertAll(
166
+ rows.map((row) => ({ ...row.row })),
167
+ scopes.size === 1 && only !== undefined ? { orgId: only } : {},
168
+ );
169
+ },
149
170
  async update(update) {
150
171
  const { entity, repo } = await repoFor(update.entity);
151
172
  await repo.updateWhere({ ...update.key }, { ...update.patch }, scope(entity, update));
@@ -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),