@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.
- package/CLAUDE.md +2 -2
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +11 -0
- package/src/errors.ts +2 -0
- package/src/exercise-job-tenant.ts +103 -0
- package/src/exercise-report.ts +12 -2
- package/src/exercise-setup.ts +68 -0
- package/src/exercise-subjects.ts +133 -0
- package/src/exercise-sweeps.ts +6 -10
- package/src/exercise.ts +30 -92
- package/src/factory-registry.ts +14 -2
- package/src/index.ts +1 -0
- package/src/plan-oracle.ts +5 -2
- package/src/plan-sweep-fix.ts +59 -7
- package/src/plan-sweep-report.ts +22 -3
- package/src/registry-leak-guard.ts +2 -2
- package/src/seed-graph-error.ts +5 -1
- package/src/seed-graph-order.ts +9 -5
- package/src/seed-graph-pass.ts +98 -3
- package/src/seed-graph-patterns.ts +51 -0
- package/src/seed-graph-plan.ts +23 -1
- package/src/seed-graph-values.ts +64 -5
- package/src/seed-graph.ts +41 -5
- package/src/seed-pattern-sample.ts +216 -0
- package/src/seed-writer.ts +47 -26
- package/src/subject-proof.ts +3 -1
|
@@ -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
|
+
}
|
package/src/seed-graph-plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/seed-graph-values.ts
CHANGED
|
@@ -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
|
|
3
|
-
// column's `$parse`, which refuses exactly what its CHECK
|
|
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
|
-
|
|
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
|
|
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 =
|
|
109
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/seed-writer.ts
CHANGED
|
@@ -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.
|
|
48
|
-
*
|
|
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
|
-
|
|
62
|
+
const indexes = byEntity.get(entity);
|
|
63
|
+
if (indexes === undefined) byEntity.set(entity, [i]);
|
|
64
|
+
else indexes.push(i);
|
|
59
65
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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));
|
package/src/subject-proof.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|