@ultimat3/testing 29.4.0 → 30.0.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 +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +82 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +448 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +68 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The seed graph an exercise pass writes: ONE graph for the whole pass, so a tenantless table
|
|
2
|
+
// (`plans`) is seeded once and every primitive's tenants reference those rows, and each primitive
|
|
3
|
+
// owns a fresh tenant pair of its own. One graph also means one set of unique keys: per-primitive
|
|
4
|
+
// graphs re-inserted the shared rows and collided on their primary keys.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { seedGraph } from './seed-graph';
|
|
8
|
+
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
9
|
+
import type { SeedGraph, SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
|
|
10
|
+
import { insertSeedGraph } from './seed-graph-plan';
|
|
11
|
+
import type { SeedEntity } from './seed-graph-shape';
|
|
12
|
+
|
|
13
|
+
/** Tenants per primitive: the actor's own, and a second the tenant proof reads across. */
|
|
14
|
+
export const TENANTS_PER_PRIMITIVE = 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The database refused a row the graph generated: a rule the declaration does not carry (a
|
|
18
|
+
* trigger, a hand-written CHECK). The row is the app's to describe, so the factory is the fix.
|
|
19
|
+
* Anything else coded — `X_SEAL_KEY_MISSING`, `X_DB_UNAVAILABLE` — keeps its own code and fix.
|
|
20
|
+
*/
|
|
21
|
+
const ROW_REFUSALS: ReadonlySet<string> = new Set([
|
|
22
|
+
'X_DB_UNIQUE_VIOLATION',
|
|
23
|
+
'X_DB_FOREIGN_KEY_VIOLATION',
|
|
24
|
+
'X_DB_STATEMENT_FAILED',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
|
|
28
|
+
if (isUltimateError(error) && !ROW_REFUSALS.has(error.code)) return error;
|
|
29
|
+
const why = isUltimateError(error) ? `${error.code}: ${error.cause}` : renderThrowable(error);
|
|
30
|
+
return seedGraphUnsatisfied({
|
|
31
|
+
entity: write.entity,
|
|
32
|
+
what: `the database refused a generated row — ${why}`,
|
|
33
|
+
columns: [],
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
|
|
38
|
+
const coded = (writer: SeedWriter): SeedWriter => ({
|
|
39
|
+
insert: (row) =>
|
|
40
|
+
writer.insert(row).catch((error: unknown) => Promise.reject(refused(row, error))),
|
|
41
|
+
update: (update) =>
|
|
42
|
+
writer.update(update).catch((error: unknown) => Promise.reject(refused(update, error))),
|
|
43
|
+
// A read, not a write: nothing a database refuses here is the generated row's fault.
|
|
44
|
+
snapshot: (rows) => writer.snapshot(rows),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** The tenant pair primitive `index` (0-based, in run order) acts in. */
|
|
48
|
+
export const tenantPair = (graph: SeedGraph, index: number): readonly [string, string] => [
|
|
49
|
+
graph.tenants[index * TENANTS_PER_PRIMITIVE] as string,
|
|
50
|
+
graph.tenants[index * TENANTS_PER_PRIMITIVE + 1] as string,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Plan and write the pass's graph: shared rows once, `TENANTS_PER_PRIMITIVE` tenants per
|
|
55
|
+
* primitive. Same seed, same rows. Before any primitive runs, so a failure refuses the pass.
|
|
56
|
+
*/
|
|
57
|
+
export async function seedPass(
|
|
58
|
+
entities: readonly SeedEntity[],
|
|
59
|
+
writer: SeedWriter,
|
|
60
|
+
options: { readonly primitives: number; readonly seed: number },
|
|
61
|
+
): Promise<SeedGraph> {
|
|
62
|
+
const graph = seedGraph(Object.fromEntries(entities.map((entity) => [entity.$name, entity])), {
|
|
63
|
+
tenants: Math.max(1, options.primitives) * TENANTS_PER_PRIMITIVE,
|
|
64
|
+
seed: options.seed,
|
|
65
|
+
});
|
|
66
|
+
await insertSeedGraph(graph, coded(writer));
|
|
67
|
+
return graph;
|
|
68
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The plan `seedGraph` returns and the one way it is written: every insert in order, then every
|
|
2
|
+
// key a cycle deferred. Data only, so a caller can diff two plans, print one, or hand it to a
|
|
3
|
+
// writer over any driver — the graph itself never touches a database.
|
|
4
|
+
|
|
5
|
+
/** A factory the app wrote, structurally: what it builds wins over anything generated. */
|
|
6
|
+
export interface SeedFactory {
|
|
7
|
+
build(): object;
|
|
8
|
+
reset(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SeedGraphOptions {
|
|
12
|
+
/** Tenants to seed, each with its own copy of every tenant-scoped table. Default 2. */
|
|
13
|
+
readonly tenants?: number;
|
|
14
|
+
/** Rows per table, per tenant for a tenant-scoped one. Default 3. */
|
|
15
|
+
readonly perTable?: number;
|
|
16
|
+
readonly seed?: number;
|
|
17
|
+
/** By entity name. A column the factory builds takes its value; keys stay the graph's. */
|
|
18
|
+
readonly factories?: Readonly<Record<string, SeedFactory>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SeedRow {
|
|
22
|
+
readonly entity: string;
|
|
23
|
+
readonly table: string;
|
|
24
|
+
/**
|
|
25
|
+
* The tenant instance the row belongs to: its tenant column's, the root row's own, or — for a
|
|
26
|
+
* child with no tenant column and no `shared` reason — the tenant of the parent it hangs off.
|
|
27
|
+
* `null` only for a row of no tenant. The tenant proof's per-instance id list is this field.
|
|
28
|
+
*/
|
|
29
|
+
readonly tenant: string | null;
|
|
30
|
+
/** As INSERTED: a key deferred to break a cycle is null here and set by an update. */
|
|
31
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SeedUpdate {
|
|
35
|
+
readonly entity: string;
|
|
36
|
+
readonly tenant: string | null;
|
|
37
|
+
/** The row's primary key, by property. */
|
|
38
|
+
readonly key: Readonly<Record<string, unknown>>;
|
|
39
|
+
readonly patch: Readonly<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One per row: the marker its text columns carry, so a later read can say whose row it saw. One
|
|
44
|
+
* marker per ROW, every text column carrying it: a find names the path, the row is the marker's.
|
|
45
|
+
* A column whose format cannot hold it (uuid, enum, a short char) is absent from `columns`.
|
|
46
|
+
*/
|
|
47
|
+
export interface SeedCanary {
|
|
48
|
+
readonly entity: string;
|
|
49
|
+
readonly tenant: string | null;
|
|
50
|
+
readonly key: Readonly<Record<string, unknown>>;
|
|
51
|
+
readonly marker: string;
|
|
52
|
+
readonly columns: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SeedGraph {
|
|
56
|
+
readonly seed: number;
|
|
57
|
+
readonly tenants: readonly string[];
|
|
58
|
+
readonly order: readonly string[];
|
|
59
|
+
readonly inserts: readonly SeedRow[];
|
|
60
|
+
readonly updates: readonly SeedUpdate[];
|
|
61
|
+
/** One per insert, in the same order: `canaries[i]` is `inserts[i]`'s. */
|
|
62
|
+
readonly canaries: readonly SeedCanary[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where `insertSeedGraph` writes. One insert per row, in order, then one update per deferred key. */
|
|
66
|
+
export interface SeedWriter {
|
|
67
|
+
insert(row: SeedRow): Promise<void>;
|
|
68
|
+
update(update: SeedUpdate): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Each row read back by its primary key, as one digest per row in the order given — `null` for
|
|
71
|
+
* a row no longer there. Equal digests mean an unchanged row; the tenant proof's write half
|
|
72
|
+
* compares two snapshots. Soft-deleted rows are read too, so a soft delete is a change.
|
|
73
|
+
*/
|
|
74
|
+
snapshot(rows: readonly SeedRow[]): Promise<readonly (string | null)[]>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Writes the plan in order: every insert, then every deferred key. Sequential, so it replays. */
|
|
78
|
+
export async function insertSeedGraph(graph: SeedGraph, writer: SeedWriter): Promise<void> {
|
|
79
|
+
for (const row of graph.inserts) await writer.insert(row);
|
|
80
|
+
for (const update of graph.updates) await writer.update(update);
|
|
81
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// One row of a seed graph, solved: every column has an ordered candidate list, and the first
|
|
2
|
+
// combination that keeps every declared rule (the entity's invariants, its keys and unique columns)
|
|
3
|
+
// wins. Only the columns a broken rule reads are varied, so the search stays a handful of tries.
|
|
4
|
+
|
|
5
|
+
export interface RowCheck {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** The properties the rule reads — the only columns worth varying when it breaks. */
|
|
8
|
+
readonly properties: readonly string[];
|
|
9
|
+
holds(row: Readonly<Record<string, unknown>>): boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RowProblem {
|
|
13
|
+
/** In declaration order; every list non-empty, first entry the most plausible. */
|
|
14
|
+
readonly columns: readonly (readonly [string, readonly unknown[]])[];
|
|
15
|
+
readonly checks: readonly RowCheck[];
|
|
16
|
+
/** Property sets no two rows of the table may share: the key, unique columns, unique rules. */
|
|
17
|
+
readonly uniques: readonly (readonly string[])[];
|
|
18
|
+
/** Per entry of `uniques`, the keys earlier rows already hold. */
|
|
19
|
+
readonly taken: readonly ReadonlySet<string>[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RowSolution =
|
|
23
|
+
| { readonly ok: true; readonly row: Readonly<Record<string, unknown>> }
|
|
24
|
+
| {
|
|
25
|
+
readonly ok: false;
|
|
26
|
+
/** The rules the most plausible row breaks, by name. */
|
|
27
|
+
readonly broken: readonly string[];
|
|
28
|
+
readonly properties: readonly string[];
|
|
29
|
+
/** Only uniqueness is out of reach: the table is full, not unsatisfiable. */
|
|
30
|
+
readonly capacity: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Past this many tries a rule is reported, not searched: the answer is a factory, not patience. */
|
|
34
|
+
const MAX_TRIES = 4096;
|
|
35
|
+
const ROUNDS = 3;
|
|
36
|
+
|
|
37
|
+
/** `undefined` when any part is null: Postgres never compares two NULLs equal in a unique key. */
|
|
38
|
+
export const uniqueKey = (
|
|
39
|
+
row: Readonly<Record<string, unknown>>,
|
|
40
|
+
properties: readonly string[],
|
|
41
|
+
): string | undefined => {
|
|
42
|
+
// Own properties only: a column named `constructor` must not read the prototype's.
|
|
43
|
+
const parts = properties.map((property) =>
|
|
44
|
+
Object.hasOwn(row, property) ? row[property] : undefined,
|
|
45
|
+
);
|
|
46
|
+
return parts.some((part) => part === null || part === undefined)
|
|
47
|
+
? undefined
|
|
48
|
+
: JSON.stringify(parts);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface Broken {
|
|
52
|
+
readonly names: readonly string[];
|
|
53
|
+
readonly properties: ReadonlySet<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const brokenBy = (problem: RowProblem, row: Record<string, unknown>, unique: boolean): Broken => {
|
|
57
|
+
const names: string[] = [];
|
|
58
|
+
const properties = new Set<string>();
|
|
59
|
+
for (const check of problem.checks) {
|
|
60
|
+
if (check.holds(row)) continue;
|
|
61
|
+
names.push(check.name);
|
|
62
|
+
for (const property of check.properties) properties.add(property);
|
|
63
|
+
}
|
|
64
|
+
if (unique) {
|
|
65
|
+
problem.uniques.forEach((set, index) => {
|
|
66
|
+
const key = uniqueKey(row, set);
|
|
67
|
+
if (key === undefined || problem.taken[index]?.has(key) !== true) return;
|
|
68
|
+
names.push(`unique (${set.join(', ')})`);
|
|
69
|
+
for (const property of set) properties.add(property);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { names, properties };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const build = (problem: RowProblem, picks: readonly number[]): Record<string, unknown> => {
|
|
76
|
+
const row: Record<string, unknown> = {};
|
|
77
|
+
problem.columns.forEach(([property, candidates], index) => {
|
|
78
|
+
row[property] = candidates[picks[index] ?? 0];
|
|
79
|
+
});
|
|
80
|
+
return row;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Odometer over the varied columns, last one fastest: deterministic and exhaustive up to the cap. */
|
|
84
|
+
function* combinations(problem: RowProblem, vary: readonly number[]): Generator<number[]> {
|
|
85
|
+
const picks = problem.columns.map(() => 0);
|
|
86
|
+
for (let tries = 0; tries < MAX_TRIES; tries += 1) {
|
|
87
|
+
yield [...picks];
|
|
88
|
+
let slot = vary.length - 1;
|
|
89
|
+
for (; slot >= 0; slot -= 1) {
|
|
90
|
+
const column = vary[slot] as number;
|
|
91
|
+
const size = problem.columns[column]?.[1].length ?? 1;
|
|
92
|
+
picks[column] = ((picks[column] ?? 0) + 1) % size;
|
|
93
|
+
if (picks[column] !== 0) break;
|
|
94
|
+
}
|
|
95
|
+
if (slot < 0) return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const search = (problem: RowProblem, unique: boolean): RowSolution => {
|
|
100
|
+
const zero = problem.columns.map(() => 0);
|
|
101
|
+
const first = brokenBy(problem, build(problem, zero), unique);
|
|
102
|
+
const failed: RowSolution = {
|
|
103
|
+
ok: false,
|
|
104
|
+
broken: first.names,
|
|
105
|
+
properties: [...first.properties],
|
|
106
|
+
capacity: false,
|
|
107
|
+
};
|
|
108
|
+
if (first.names.length === 0) return { ok: true, row: build(problem, zero) };
|
|
109
|
+
const variable = (properties: ReadonlySet<string>): number[] =>
|
|
110
|
+
problem.columns.flatMap(([property, candidates], index) =>
|
|
111
|
+
properties.has(property) && candidates.length > 1 ? [index] : [],
|
|
112
|
+
);
|
|
113
|
+
let wanted = new Set(first.properties);
|
|
114
|
+
for (let round = 0; round < ROUNDS; round += 1) {
|
|
115
|
+
const vary = variable(wanted);
|
|
116
|
+
const grown = new Set(wanted);
|
|
117
|
+
for (const picks of combinations(problem, vary)) {
|
|
118
|
+
const row = build(problem, picks);
|
|
119
|
+
const broken = brokenBy(problem, row, unique);
|
|
120
|
+
if (broken.names.length === 0) return { ok: true, row };
|
|
121
|
+
for (const property of broken.properties) grown.add(property);
|
|
122
|
+
}
|
|
123
|
+
if (variable(grown).length === vary.length) return failed;
|
|
124
|
+
wanted = grown;
|
|
125
|
+
}
|
|
126
|
+
return failed;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The first row that keeps every rule; failing that, whether only uniqueness stood in the way. */
|
|
130
|
+
export function solveRow(problem: RowProblem): RowSolution {
|
|
131
|
+
const found = search(problem, true);
|
|
132
|
+
if (found.ok) return found;
|
|
133
|
+
return search(problem, false).ok ? { ...found, capacity: true } : found;
|
|
134
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// What the seed graph reads off one entity, structurally — the value `entity()` returns, with no
|
|
2
|
+
// runtime import of `@ultimat3/entity` (this barrel must not load its registry). The rules a row
|
|
3
|
+
// must keep come from the entity's own declaration: invariants, key, unique columns and indexes.
|
|
4
|
+
|
|
5
|
+
import type { EntityDescription, Invariant, ReferenceDescription } from '@ultimat3/entity';
|
|
6
|
+
import type { RowCheck } from './seed-graph-row';
|
|
7
|
+
import type { SeedColumn } from './seed-graph-values';
|
|
8
|
+
|
|
9
|
+
/** The members of `entity()`'s value a seed graph reads. A real entity satisfies it as is. */
|
|
10
|
+
export interface SeedEntity {
|
|
11
|
+
readonly $name: string;
|
|
12
|
+
readonly $table: string;
|
|
13
|
+
readonly $columns: Readonly<Record<string, SeedColumn>>;
|
|
14
|
+
/** Property keys. */
|
|
15
|
+
readonly $primaryKey: readonly string[];
|
|
16
|
+
readonly $tenantColumn: string | null;
|
|
17
|
+
readonly $invariants: readonly Invariant<unknown>[];
|
|
18
|
+
$describe(): EntityDescription;
|
|
19
|
+
$references(): readonly ReferenceDescription[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EntityShape {
|
|
23
|
+
readonly entity: SeedEntity;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly columns: readonly (readonly [string, SeedColumn])[];
|
|
26
|
+
readonly references: ReadonlyMap<string, ReferenceDescription>;
|
|
27
|
+
readonly checks: readonly RowCheck[];
|
|
28
|
+
readonly uniques: readonly (readonly string[])[];
|
|
29
|
+
/**
|
|
30
|
+
* The soft-delete property, or `null`. Seeded null unless a factory builds it: a row born deleted
|
|
31
|
+
* is invisible to every read.
|
|
32
|
+
*/
|
|
33
|
+
readonly softDelete: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `@ultimat3/entity`'s `SOFT_DELETE_COLUMN`, restated because this module type-imports entity only.
|
|
38
|
+
* `seed-graph.test.ts` holds the two equal, so a rename there fails here.
|
|
39
|
+
*/
|
|
40
|
+
export const SEED_SOFT_DELETE_PROPERTY = 'deletedAt';
|
|
41
|
+
|
|
42
|
+
/** Physical column -> property. A money property owns all of its physical columns. */
|
|
43
|
+
const propertiesByColumn = (entity: SeedEntity): ReadonlyMap<string, string> => {
|
|
44
|
+
const map = new Map<string, string>();
|
|
45
|
+
const declared = Object.keys(entity.$columns);
|
|
46
|
+
for (const column of entity.$describe().columns) {
|
|
47
|
+
const owner = declared.includes(column.property)
|
|
48
|
+
? column.property
|
|
49
|
+
: declared.find(
|
|
50
|
+
(property) =>
|
|
51
|
+
entity.$columns[property]?.$meta.kind === 'money' &&
|
|
52
|
+
column.property.startsWith(property),
|
|
53
|
+
);
|
|
54
|
+
if (owner !== undefined) map.set(column.column, owner);
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const dedupe = (sets: readonly (readonly string[])[]): readonly (readonly string[])[] => {
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
return sets.filter((set) => {
|
|
62
|
+
const key = [...set].sort().join('\u0000');
|
|
63
|
+
if (set.length === 0 || seen.has(key)) return false;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function shapeOf(entity: SeedEntity): EntityShape {
|
|
70
|
+
const byColumn = propertiesByColumn(entity);
|
|
71
|
+
const toProperties = (columns: readonly string[]): readonly string[] =>
|
|
72
|
+
columns.flatMap((column) => {
|
|
73
|
+
const property = byColumn.get(column);
|
|
74
|
+
return property === undefined ? [] : [property];
|
|
75
|
+
});
|
|
76
|
+
const description = entity.$describe();
|
|
77
|
+
const checks = entity.$invariants
|
|
78
|
+
.filter((invariant) => invariant.kind !== 'unique')
|
|
79
|
+
.map(
|
|
80
|
+
(invariant): RowCheck => ({
|
|
81
|
+
name: invariant.name,
|
|
82
|
+
properties: toProperties(invariant.columns),
|
|
83
|
+
holds: (row) => invariant.holds(row),
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
// A partial unique (`where`) is treated as total: stricter than the table, never looser.
|
|
87
|
+
const uniques = dedupe([
|
|
88
|
+
entity.$primaryKey,
|
|
89
|
+
...description.columns.filter((column) => column.unique).map((c) => toProperties([c.column])),
|
|
90
|
+
...entity.$invariants
|
|
91
|
+
.filter((invariant) => invariant.kind === 'unique')
|
|
92
|
+
.map((invariant) => toProperties(invariant.columns)),
|
|
93
|
+
...description.indexes.filter((index) => index.unique).map((i) => toProperties(i.columns)),
|
|
94
|
+
]);
|
|
95
|
+
return {
|
|
96
|
+
entity,
|
|
97
|
+
name: entity.$name,
|
|
98
|
+
columns: Object.entries(entity.$columns),
|
|
99
|
+
references: new Map(entity.$references().map((reference) => [reference.property, reference])),
|
|
100
|
+
checks,
|
|
101
|
+
uniques,
|
|
102
|
+
softDelete: description.softDelete ? SEED_SOFT_DELETE_PROPERTY : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import type { ColumnMeta } from '@ultimat3/entity';
|
|
6
|
+
|
|
7
|
+
/** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
|
|
8
|
+
export interface SeedColumn {
|
|
9
|
+
readonly $meta: ColumnMeta;
|
|
10
|
+
$parse(value: unknown): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What one row lends every column it generates: its place, its canary and its id stream. */
|
|
14
|
+
export interface ValueContext {
|
|
15
|
+
/** 1-based and unique per table, so a value derived from it is unique per table. */
|
|
16
|
+
readonly ordinal: number;
|
|
17
|
+
/** The row's canary: lower-case hex, so it survives a slug or a handle pattern. */
|
|
18
|
+
readonly canary: string;
|
|
19
|
+
/** Declaration position of the column, so two instants on one row are ordered as declared. */
|
|
20
|
+
readonly position: number;
|
|
21
|
+
uuid(): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fixed, never `Date.now()`: the same seed must produce the same bytes on any day. */
|
|
25
|
+
const BASE_INSTANT = Date.UTC(2026, 0, 1);
|
|
26
|
+
const HOUR = 3_600_000;
|
|
27
|
+
const MINUTE = 60_000;
|
|
28
|
+
|
|
29
|
+
/** ISO-4217 codes a money column is offered, in order; a rule pairing it with an enum picks one. */
|
|
30
|
+
const CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD'] as const;
|
|
31
|
+
|
|
32
|
+
export const isSeedColumn = (value: unknown): value is SeedColumn =>
|
|
33
|
+
typeof value === 'object' &&
|
|
34
|
+
value !== null &&
|
|
35
|
+
'$meta' in value &&
|
|
36
|
+
typeof value.$meta === 'object' &&
|
|
37
|
+
value.$meta !== null &&
|
|
38
|
+
'$parse' in value &&
|
|
39
|
+
typeof value.$parse === 'function';
|
|
40
|
+
|
|
41
|
+
const rotate = <T>(values: readonly T[], by: number): readonly T[] =>
|
|
42
|
+
values.length === 0 ? values : values.map((_, i) => values[(i + by) % values.length] as T);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shapes, not guesses: the declared rule (a pattern, `contains('@')`) picks among them, and the
|
|
46
|
+
* property only makes the value readable. Each carries the canary, so each is unique per row.
|
|
47
|
+
*/
|
|
48
|
+
const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
|
|
49
|
+
const word = property.toLowerCase().replace(/[^a-z0-9]/g, '') || 'value';
|
|
50
|
+
const kebab = property
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
54
|
+
const shapes = [
|
|
55
|
+
`${kebab || word}-${ctx.canary}`,
|
|
56
|
+
`${word}${ctx.canary}`,
|
|
57
|
+
`${word}_${ctx.canary}`,
|
|
58
|
+
`${word}.${ctx.canary}@seed.test`,
|
|
59
|
+
`https://seed.test/${word}/${ctx.canary}`,
|
|
60
|
+
ctx.canary,
|
|
61
|
+
];
|
|
62
|
+
return meta.length === undefined ? shapes : [...shapes, ctx.canary.slice(0, meta.length)];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const byKind = (property: string, meta: ColumnMeta, ctx: ValueContext): unknown[] => {
|
|
66
|
+
const n = ctx.ordinal;
|
|
67
|
+
if (meta.values !== undefined) return [...rotate(meta.values, n - 1)];
|
|
68
|
+
switch (meta.kind) {
|
|
69
|
+
case 'uuid':
|
|
70
|
+
return [ctx.uuid()];
|
|
71
|
+
case 'text':
|
|
72
|
+
case 'char':
|
|
73
|
+
return textCandidates(property, meta, ctx);
|
|
74
|
+
case 'boolean':
|
|
75
|
+
return [false, true];
|
|
76
|
+
case 'integer':
|
|
77
|
+
return [n, 1, 0];
|
|
78
|
+
case 'bigint':
|
|
79
|
+
return [String(n), '1', '0'];
|
|
80
|
+
case 'numeric':
|
|
81
|
+
return [String(n), '1', '0'];
|
|
82
|
+
case 'timestamptz':
|
|
83
|
+
return [new Date(BASE_INSTANT + n * HOUR + ctx.position * MINUTE)];
|
|
84
|
+
case 'date':
|
|
85
|
+
return [`2026-01-${String((n % 28) + 1).padStart(2, '0')}`];
|
|
86
|
+
case 'jsonb':
|
|
87
|
+
return [{}, []];
|
|
88
|
+
case 'bytea':
|
|
89
|
+
return [new Uint8Array([n % 256])];
|
|
90
|
+
case 'array':
|
|
91
|
+
return [[]];
|
|
92
|
+
case 'money':
|
|
93
|
+
return CURRENCIES.map((currency) => ({ minor: n * 100, currency }));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const parsed = (column: SeedColumn, value: unknown): { readonly ok: unknown } | undefined => {
|
|
98
|
+
try {
|
|
99
|
+
return { ok: column.$parse(value) };
|
|
100
|
+
} catch {
|
|
101
|
+
// A refusal is the answer "not this one": the next candidate is tried, and an empty list is
|
|
102
|
+
// the caller's X_SEED_GRAPH_UNSATISFIED.
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every storable value for this column, most plausible first: the declared default, then what the
|
|
109
|
+
* kind offers, then `null` when the column allows it. Empty means nothing generated is storable.
|
|
110
|
+
*/
|
|
111
|
+
export function candidatesFor(
|
|
112
|
+
property: string,
|
|
113
|
+
column: SeedColumn,
|
|
114
|
+
ctx: ValueContext,
|
|
115
|
+
): readonly unknown[] {
|
|
116
|
+
const meta = column.$meta;
|
|
117
|
+
const declared = meta.default?.kind === 'value' ? [meta.default.value] : [];
|
|
118
|
+
const raw = [...declared, ...byKind(property, meta, ctx), ...(meta.notNull ? [] : [null])];
|
|
119
|
+
const out: unknown[] = [];
|
|
120
|
+
for (const value of raw) {
|
|
121
|
+
const result = parsed(column, value);
|
|
122
|
+
if (result !== undefined && !out.some((seen) => Bun.deepEquals(seen, result.ok))) {
|
|
123
|
+
out.push(result.ok);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Candidates offered verbatim (a factory's value), kept only where the column stores them. */
|
|
130
|
+
export function storable(column: SeedColumn, values: readonly unknown[]): readonly unknown[] {
|
|
131
|
+
return values.flatMap((value) => {
|
|
132
|
+
const result = parsed(column, value);
|
|
133
|
+
return result === undefined ? [] : [result.ok];
|
|
134
|
+
});
|
|
135
|
+
}
|