@ultimat3/testing 30.0.0 → 30.2.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/README.md +1 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +10 -0
- package/src/exercise.ts +4 -0
- package/src/scale-sweep-graph.ts +8 -2
- package/src/scale-sweep-run.ts +4 -1
- package/src/seed-graph-error.ts +1 -1
- package/src/seed-graph-pass.ts +20 -3
- package/src/seed-graph-plan.ts +7 -0
- package/src/seed-graph-row.ts +3 -1
- package/src/seed-graph-shape.ts +20 -3
- package/src/seed-graph.ts +13 -4
- package/src/subject-proof.ts +4 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ unitTest('the clock moves only when the test moves it', ({ clock }) => {
|
|
|
34
34
|
| `seed-graph-error.ts` | `X_SEED_GRAPH_UNSATISFIED`, its fix naming the `defineFactory` to write; `X_SEED_GRAPH_ENTITY_UNKNOWN`, a plan and a writer built from two entity sets |
|
|
35
35
|
| `seed-writer.ts` | `entitySeedWriter({ entities, client })` or `({ entities, driver? })` — the `SeedWriter` that writes a graph through each entity's own repository: Postgres or PGlite through a client, or a driver. `@ultimat3/entity` loads on the first write. `snapshot(rows)`, required on every `SeedWriter`, reads rows back as one digest each (`md5(t::text)` over a client), `null` for a row gone |
|
|
36
36
|
| `exercise.ts` | `runExercisePass({ entities, writer, roles, seed?, overrides?, only? })` — every registered action, query and job once, each on its own committed tenant pair, one `ExerciseRecord` per primitive, the tenant proof's verdict on its `tenant`. What `x exercise` runs |
|
|
37
|
-
| `define-exercise.ts` | `defineExercise({ <name>: { actor, input?, runs? } \| { off: '<why>' } }, { actor?: <entity
|
|
37
|
+
| `define-exercise.ts` | `defineExercise({ <name>: { actor, input?, runs? } \| { off: '<why>' } }, { actor?: <entity>, factories?: { <entity name>: defineFactory(…) } })` — an app's overrides (the second argument names the entity whose rows are the actors, and the factories that build a table no generated row satisfies — a cross-column `satisfies` — keyed by entity name, unknown ones `X_DECLARATION_UNKNOWN`), `apps/web/exercise.ts`'s one export; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED`, an unknown name `X_DECLARATION_UNKNOWN` when the pass runs |
|
|
38
38
|
| `exercise-actor.ts` · `exercise-input.ts` · `exercise-report.ts` | the first `x policy explain` actor the policy allows, minted in the primitive's tenant · the input: override, then a seeded id by field stem, then the minimal draw (`@ultimat3/schema/sample`, loaded dynamically) · the room one run executes in (network sealed, mail captured, clock frozen) and its record, `overrideLine` the entry to paste |
|
|
39
39
|
| `tenant-proof.ts` | internal (`CROSS_TENANT_SCOPE` is `@ultimat3/entity`'s): `proveTenant` — B with A's ids, A's own call, B again; each B leg between two snapshots of A's rows. `withoutCrossTenant` strips `tenancy:cross` (`CROSS_TENANT_SCOPE`) from B; `publicReason` decides `public` |
|
|
40
40
|
| `tenant-canary.ts` | internal: `tenantTaint` — one tenant's rows, ids and marks (row canaries, key parts of `MIN_TAINT_ID_LENGTH` 8 or more no other tenant shares); `findTaint` — every path holding one |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.2.0",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -46,21 +46,21 @@
|
|
|
46
46
|
"test": "bun test"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ultimat3/action": "30.
|
|
50
|
-
"@ultimat3/cache": "30.
|
|
51
|
-
"@ultimat3/core": "30.
|
|
52
|
-
"@ultimat3/db": "30.
|
|
53
|
-
"@ultimat3/entity": "30.
|
|
54
|
-
"@ultimat3/http": "30.
|
|
55
|
-
"@ultimat3/i18n": "30.
|
|
56
|
-
"@ultimat3/jobs": "30.
|
|
57
|
-
"@ultimat3/mail": "30.
|
|
58
|
-
"@ultimat3/policy": "30.
|
|
59
|
-
"@ultimat3/pwa": "30.
|
|
60
|
-
"@ultimat3/query": "30.
|
|
61
|
-
"@ultimat3/realtime": "30.
|
|
62
|
-
"@ultimat3/render": "30.
|
|
63
|
-
"@ultimat3/schema": "30.
|
|
64
|
-
"@ultimat3/time": "30.
|
|
49
|
+
"@ultimat3/action": "30.2.0",
|
|
50
|
+
"@ultimat3/cache": "30.2.0",
|
|
51
|
+
"@ultimat3/core": "30.2.0",
|
|
52
|
+
"@ultimat3/db": "30.2.0",
|
|
53
|
+
"@ultimat3/entity": "30.2.0",
|
|
54
|
+
"@ultimat3/http": "30.2.0",
|
|
55
|
+
"@ultimat3/i18n": "30.2.0",
|
|
56
|
+
"@ultimat3/jobs": "30.2.0",
|
|
57
|
+
"@ultimat3/mail": "30.2.0",
|
|
58
|
+
"@ultimat3/policy": "30.2.0",
|
|
59
|
+
"@ultimat3/pwa": "30.2.0",
|
|
60
|
+
"@ultimat3/query": "30.2.0",
|
|
61
|
+
"@ultimat3/realtime": "30.2.0",
|
|
62
|
+
"@ultimat3/render": "30.2.0",
|
|
63
|
+
"@ultimat3/schema": "30.2.0",
|
|
64
|
+
"@ultimat3/time": "30.2.0"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/src/define-exercise.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ExerciseOptions } from '@ultimat3/action';
|
|
7
7
|
import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { SeedFactory } from './seed-graph-plan';
|
|
8
9
|
|
|
9
10
|
/** 39's option type, exactly: `{ actor, input?, runs? }` or `{ off: '<reason>' }`. */
|
|
10
11
|
export type ExerciseOverride = ExerciseOptions;
|
|
@@ -15,6 +16,8 @@ export interface ExerciseOverrides {
|
|
|
15
16
|
readonly entries: ReadonlyMap<string, ExerciseOverride>;
|
|
16
17
|
/** The entity an actor is a row of, by name: its id is bound to a seeded row. */
|
|
17
18
|
readonly actorEntity?: string;
|
|
19
|
+
/** By entity name: the pass's graph builds those rows with them (`seedGraph`'s `factories`). */
|
|
20
|
+
readonly factories?: Readonly<Record<string, SeedFactory>>;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** What the whole pass needs to know of the app, beside the per-primitive entries. */
|
|
@@ -25,6 +28,12 @@ export interface ExerciseAppOptions {
|
|
|
25
28
|
* writes a row that exists. Omitted, an actor's id is its role's name.
|
|
26
29
|
*/
|
|
27
30
|
readonly actor?: { readonly $name: string };
|
|
31
|
+
/**
|
|
32
|
+
* By entity name, `seedGraph`'s own option: a table whose rows a generated value cannot satisfy
|
|
33
|
+
* (an `invariant` across columns) is built by the app's `defineFactory`. An unknown name is
|
|
34
|
+
* refused when the pass runs.
|
|
35
|
+
*/
|
|
36
|
+
readonly factories?: Readonly<Record<string, SeedFactory>>;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/** `X_CONTRACT_EXERCISE_UNDECLARED`, 39's code for the same silence: an `off` with no reason. */
|
|
@@ -49,6 +58,7 @@ export function defineExercise(
|
|
|
49
58
|
kind: 'exercise-overrides',
|
|
50
59
|
entries,
|
|
51
60
|
...(app.actor === undefined ? {} : { actorEntity: app.actor.$name }),
|
|
61
|
+
...(app.factories === undefined ? {} : { factories: app.factories }),
|
|
52
62
|
});
|
|
53
63
|
}
|
|
54
64
|
|
package/src/exercise.ts
CHANGED
|
@@ -418,6 +418,9 @@ export async function runExercisePass(
|
|
|
418
418
|
graph = await seedPass(options.entities, options.writer, {
|
|
419
419
|
primitives: live.length,
|
|
420
420
|
seed: passSeed,
|
|
421
|
+
...(options.overrides?.factories === undefined
|
|
422
|
+
? {}
|
|
423
|
+
: { factories: options.overrides.factories }),
|
|
421
424
|
});
|
|
422
425
|
// In order, one at a time: the seams the room installs are the process's, not the primitive's.
|
|
423
426
|
for (const subject of chosen) {
|
|
@@ -438,6 +441,7 @@ export async function runExercisePass(
|
|
|
438
441
|
main: graph,
|
|
439
442
|
seed: passSeed,
|
|
440
443
|
room,
|
|
444
|
+
factories: options.overrides?.factories,
|
|
441
445
|
setup: async (subject, sized, tenant, self) => {
|
|
442
446
|
const full = chosen.find((one) => one.name === subject.name);
|
|
443
447
|
return full === undefined
|
package/src/scale-sweep-graph.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// every reference to one is pointed at the pass's own row in the same position. Pure.
|
|
4
4
|
|
|
5
5
|
import { seedGraph } from './seed-graph';
|
|
6
|
-
import type { SeedGraph } from './seed-graph-plan';
|
|
6
|
+
import type { SeedFactory, SeedGraph } from './seed-graph-plan';
|
|
7
7
|
import type { SeedEntity } from './seed-graph-shape';
|
|
8
8
|
|
|
9
9
|
export interface ScaleGraph {
|
|
@@ -23,12 +23,18 @@ export interface ScaleGraph {
|
|
|
23
23
|
export function scaleGraph(
|
|
24
24
|
main: SeedGraph,
|
|
25
25
|
entities: readonly SeedEntity[],
|
|
26
|
-
options: {
|
|
26
|
+
options: {
|
|
27
|
+
readonly perTable: number;
|
|
28
|
+
readonly seed: number;
|
|
29
|
+
/** The app's `defineExercise` factories: a table only they satisfy is built by them here too. */
|
|
30
|
+
readonly factories?: Readonly<Record<string, SeedFactory>> | undefined;
|
|
31
|
+
},
|
|
27
32
|
): ScaleGraph {
|
|
28
33
|
const sized = seedGraph(Object.fromEntries(entities.map((entity) => [entity.$name, entity])), {
|
|
29
34
|
tenants: 1,
|
|
30
35
|
perTable: options.perTable,
|
|
31
36
|
seed: options.seed,
|
|
37
|
+
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
32
38
|
});
|
|
33
39
|
const shared = (graph: SeedGraph) => graph.inserts.filter((insert) => insert.tenant === null);
|
|
34
40
|
const theirs = shared(main);
|
package/src/scale-sweep-run.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { testJobs } from './fixture-jobs';
|
|
|
10
10
|
import type { ScaleProof, StatementTally } from './scale-sweep';
|
|
11
11
|
import { judgeScale, SCALE_SIZES } from './scale-sweep';
|
|
12
12
|
import { scaleGraph } from './scale-sweep-graph';
|
|
13
|
-
import type { SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
13
|
+
import type { SeedFactory, SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
14
14
|
import { insertSeedGraph } from './seed-graph-plan';
|
|
15
15
|
import type { SeedEntity } from './seed-graph-shape';
|
|
16
16
|
|
|
@@ -45,6 +45,8 @@ export interface ScaleSweepOptions {
|
|
|
45
45
|
readonly seed: number;
|
|
46
46
|
readonly room: ExerciseRoom;
|
|
47
47
|
readonly setup: ScaleSetup;
|
|
48
|
+
/** `defineExercise`'s factories, handed to every sized graph as to the pass's own. */
|
|
49
|
+
readonly factories?: Readonly<Record<string, SeedFactory>> | undefined;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
type Call = { readonly shapes: readonly StatementTally[] } | { readonly failed: string };
|
|
@@ -88,6 +90,7 @@ export async function scaleSweep(
|
|
|
88
90
|
scaleGraph(options.main, options.entities, {
|
|
89
91
|
perTable,
|
|
90
92
|
seed: fnv1a(`scale:${options.seed}:${i}`) || 1,
|
|
93
|
+
factories: options.factories,
|
|
91
94
|
}),
|
|
92
95
|
);
|
|
93
96
|
try {
|
package/src/seed-graph-error.ts
CHANGED
|
@@ -27,7 +27,7 @@ export class SeedGraphUnsatisfiedError extends UltimateError {
|
|
|
27
27
|
super({
|
|
28
28
|
code: 'X_SEED_GRAPH_UNSATISFIED',
|
|
29
29
|
cause: `${input.entity}: ${input.what}`,
|
|
30
|
-
fix: `defineFactory(<the ${input.entity} entity>, { defaults: (index, ids) => ({ ${body} }) }), then seedGraph(entities, { factories: { ${name}: thatFactory } })`,
|
|
30
|
+
fix: `defineFactory(<the ${input.entity} entity>, { defaults: (index, ids) => ({ ${body} }) }), then seedGraph(entities, { factories: { ${name}: thatFactory } }) — for x exercise, defineExercise({}, { factories: { ${name}: thatFactory } }) in apps/web/exercise.ts`,
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/seed-graph-pass.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// owns a fresh tenant pair of its own. One graph also means one set of unique keys: per-primitive
|
|
4
4
|
// graphs re-inserted the shared rows and collided on their primary keys.
|
|
5
5
|
|
|
6
|
-
import { isUltimateError, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { isUltimateError, renderCauseValue, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
7
|
import { seedGraph } from './seed-graph';
|
|
8
8
|
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
9
|
-
import type { SeedGraph, SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
|
|
9
|
+
import type { SeedFactory, SeedGraph, SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
|
|
10
10
|
import { insertSeedGraph } from './seed-graph-plan';
|
|
11
11
|
import type { SeedEntity } from './seed-graph-shape';
|
|
12
12
|
|
|
@@ -50,6 +50,15 @@ export const tenantPair = (graph: SeedGraph, index: number): readonly [string, s
|
|
|
50
50
|
graph.tenants[index * TENANTS_PER_PRIMITIVE + 1] as string,
|
|
51
51
|
];
|
|
52
52
|
|
|
53
|
+
/** A factory keyed by a name no seeded entity has would build nothing, and the red would stay. */
|
|
54
|
+
const unknownFactory = (names: readonly string[], known: ReadonlySet<string>): UltimateError =>
|
|
55
|
+
new UltimateError({
|
|
56
|
+
code: 'X_DECLARATION_UNKNOWN',
|
|
57
|
+
cause: `defineExercise() factories name ${names.map((name) => renderCauseValue(name)).join(', ')}, which no seeded entity is called (seeded: ${renderCauseValue([...known].sort().join(', '))})`,
|
|
58
|
+
fix: "defineExercise({}, { factories: { '<the name entity(...) was declared with>': thatFactory } }) # in apps/web/exercise.ts; the seeded names are in the cause",
|
|
59
|
+
meta: { unknown: names, registered: [...known].sort() },
|
|
60
|
+
});
|
|
61
|
+
|
|
53
62
|
/**
|
|
54
63
|
* Plan and write the pass's graph: shared rows once, `TENANTS_PER_PRIMITIVE` tenants per
|
|
55
64
|
* primitive. Same seed, same rows. Before any primitive runs, so a failure refuses the pass.
|
|
@@ -57,11 +66,19 @@ export const tenantPair = (graph: SeedGraph, index: number): readonly [string, s
|
|
|
57
66
|
export async function seedPass(
|
|
58
67
|
entities: readonly SeedEntity[],
|
|
59
68
|
writer: SeedWriter,
|
|
60
|
-
options: {
|
|
69
|
+
options: {
|
|
70
|
+
readonly primitives: number;
|
|
71
|
+
readonly seed: number;
|
|
72
|
+
readonly factories?: Readonly<Record<string, SeedFactory>>;
|
|
73
|
+
},
|
|
61
74
|
): Promise<SeedGraph> {
|
|
75
|
+
const known = new Set(entities.map((entity) => entity.$name));
|
|
76
|
+
const unknown = Object.keys(options.factories ?? {}).filter((name) => !known.has(name));
|
|
77
|
+
if (unknown.length > 0) throw unknownFactory(unknown, known);
|
|
62
78
|
const graph = seedGraph(Object.fromEntries(entities.map((entity) => [entity.$name, entity])), {
|
|
63
79
|
tenants: Math.max(1, options.primitives) * TENANTS_PER_PRIMITIVE,
|
|
64
80
|
seed: options.seed,
|
|
81
|
+
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
65
82
|
});
|
|
66
83
|
await insertSeedGraph(graph, coded(writer));
|
|
67
84
|
return graph;
|
package/src/seed-graph-plan.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
export interface SeedFactory {
|
|
7
7
|
build(): object;
|
|
8
8
|
reset(): void;
|
|
9
|
+
/**
|
|
10
|
+
* Columns that follow from what the GRAPH chose — its keys and references, which no factory
|
|
11
|
+
* builds: `memorial_templates.scopeKey` from `ownerOrgId`. Called on each candidate row before
|
|
12
|
+
* its rules are judged; what it answers wins. A `defineFactory` with it:
|
|
13
|
+
* `{ build: () => f.build(), reset: () => f.reset(), derive: (row) => ({ … }) }`.
|
|
14
|
+
*/
|
|
15
|
+
derive?(row: Readonly<Record<string, unknown>>): object;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export interface SeedGraphOptions {
|
package/src/seed-graph-row.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface RowProblem {
|
|
|
17
17
|
readonly uniques: readonly (readonly string[])[];
|
|
18
18
|
/** Per entry of `uniques`, the keys earlier rows already hold. */
|
|
19
19
|
readonly taken: readonly ReadonlySet<string>[];
|
|
20
|
+
/** A factory's `derive`: columns that follow from the picked ones, applied before any rule. */
|
|
21
|
+
readonly derive?: ((row: Readonly<Record<string, unknown>>) => object) | undefined;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export type RowSolution =
|
|
@@ -77,7 +79,7 @@ const build = (problem: RowProblem, picks: readonly number[]): Record<string, un
|
|
|
77
79
|
problem.columns.forEach(([property, candidates], index) => {
|
|
78
80
|
row[property] = candidates[picks[index] ?? 0];
|
|
79
81
|
});
|
|
80
|
-
return row;
|
|
82
|
+
return problem.derive === undefined ? row : { ...row, ...problem.derive(row) };
|
|
81
83
|
};
|
|
82
84
|
|
|
83
85
|
/** Odometer over the varied columns, last one fastest: deterministic and exhaustive up to the cap. */
|
package/src/seed-graph-shape.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface EntityShape {
|
|
|
26
26
|
readonly references: ReadonlyMap<string, ReferenceDescription>;
|
|
27
27
|
readonly checks: readonly RowCheck[];
|
|
28
28
|
readonly uniques: readonly (readonly string[])[];
|
|
29
|
+
/**
|
|
30
|
+
* The uniques with a `where`: SQL the generator cannot evaluate, so held as total — stricter than
|
|
31
|
+
* the table — unless a factory builds every one of their columns, when the database judges them.
|
|
32
|
+
* `orgs: unique(kind) where kind = 'staff'` otherwise ran an enum out of values (`X_SEED_GRAPH_UNSATISFIED`).
|
|
33
|
+
*/
|
|
34
|
+
readonly partialUniques: readonly (readonly string[])[];
|
|
29
35
|
/**
|
|
30
36
|
* The soft-delete property, or `null`. Seeded null unless a factory builds it: a row born deleted
|
|
31
37
|
* is invisible to every read.
|
|
@@ -83,15 +89,25 @@ export function shapeOf(entity: SeedEntity): EntityShape {
|
|
|
83
89
|
holds: (row) => invariant.holds(row),
|
|
84
90
|
}),
|
|
85
91
|
);
|
|
86
|
-
// A partial unique (`where`) is treated as total: stricter than the table, never looser.
|
|
87
92
|
const uniques = dedupe([
|
|
88
93
|
entity.$primaryKey,
|
|
89
94
|
...description.columns.filter((column) => column.unique).map((c) => toProperties([c.column])),
|
|
90
95
|
...entity.$invariants
|
|
91
|
-
.filter((invariant) => invariant.kind === 'unique')
|
|
96
|
+
.filter((invariant) => invariant.kind === 'unique' && invariant.where === undefined)
|
|
92
97
|
.map((invariant) => toProperties(invariant.columns)),
|
|
93
|
-
...description.indexes
|
|
98
|
+
...description.indexes
|
|
99
|
+
.filter((index) => index.unique && index.where === null)
|
|
100
|
+
.map((i) => toProperties(i.columns)),
|
|
94
101
|
]);
|
|
102
|
+
const total = new Set(uniques.map((set) => set.join('\u0000')));
|
|
103
|
+
const partialUniques = dedupe([
|
|
104
|
+
...entity.$invariants
|
|
105
|
+
.filter((invariant) => invariant.kind === 'unique' && invariant.where !== undefined)
|
|
106
|
+
.map((invariant) => toProperties(invariant.columns)),
|
|
107
|
+
...description.indexes
|
|
108
|
+
.filter((index) => index.unique && index.where !== null)
|
|
109
|
+
.map((i) => toProperties(i.columns)),
|
|
110
|
+
]).filter((set) => !total.has(set.join('\u0000')));
|
|
95
111
|
return {
|
|
96
112
|
entity,
|
|
97
113
|
name: entity.$name,
|
|
@@ -99,6 +115,7 @@ export function shapeOf(entity: SeedEntity): EntityShape {
|
|
|
99
115
|
references: new Map(entity.$references().map((reference) => [reference.property, reference])),
|
|
100
116
|
checks,
|
|
101
117
|
uniques,
|
|
118
|
+
partialUniques,
|
|
102
119
|
softDelete: description.softDelete ? SEED_SOFT_DELETE_PROPERTY : null,
|
|
103
120
|
};
|
|
104
121
|
}
|
package/src/seed-graph.ts
CHANGED
|
@@ -135,7 +135,8 @@ export function seedGraph(
|
|
|
135
135
|
const factory = options.factories?.[name];
|
|
136
136
|
const rows: Placed[] = [];
|
|
137
137
|
placed.set(name, rows);
|
|
138
|
-
const
|
|
138
|
+
const allUniques = [...shape.uniques, ...shape.partialUniques];
|
|
139
|
+
const taken = allUniques.map(() => new Set<string>());
|
|
139
140
|
const rootProperty = roots.get(name);
|
|
140
141
|
const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
|
|
141
142
|
let ordinal = 0;
|
|
@@ -176,11 +177,19 @@ export function seedGraph(
|
|
|
176
177
|
candidates.length === 0 ? refuseEmpty(shape, property, tenant) : candidates,
|
|
177
178
|
] as const;
|
|
178
179
|
});
|
|
180
|
+
// A partial unique whose every column the factory built is the database's to judge.
|
|
181
|
+
const held = allUniques
|
|
182
|
+
.map((set, i) => [set, taken[i] as Set<string>] as const)
|
|
183
|
+
.filter(
|
|
184
|
+
([set], i) =>
|
|
185
|
+
i < shape.uniques.length || !set.every((property) => Object.hasOwn(built, property)),
|
|
186
|
+
);
|
|
179
187
|
const problem: RowProblem = {
|
|
180
188
|
columns,
|
|
181
189
|
checks: shape.checks,
|
|
182
|
-
uniques:
|
|
183
|
-
taken,
|
|
190
|
+
uniques: held.map(([set]) => set),
|
|
191
|
+
taken: held.map(([, set]) => set),
|
|
192
|
+
derive: factory?.derive?.bind(factory),
|
|
184
193
|
};
|
|
185
194
|
const solved = solveRow(problem);
|
|
186
195
|
if (!solved.ok) {
|
|
@@ -197,7 +206,7 @@ export function seedGraph(
|
|
|
197
206
|
});
|
|
198
207
|
}
|
|
199
208
|
const row = { ...solved.row };
|
|
200
|
-
|
|
209
|
+
allUniques.forEach((set, i) => {
|
|
201
210
|
const key = uniqueKey(row, set);
|
|
202
211
|
if (key !== undefined) taken[i]?.add(key);
|
|
203
212
|
});
|
package/src/subject-proof.ts
CHANGED
|
@@ -8,7 +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 type { SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
11
|
+
import type { SeedFactory, SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
12
12
|
import { insertSeedGraph } from './seed-graph-plan';
|
|
13
13
|
import type { SeedEntity } from './seed-graph-shape';
|
|
14
14
|
import { SubjectEraseIncompleteError } from './subject-proof-error';
|
|
@@ -22,6 +22,8 @@ export interface SubjectProofOptions {
|
|
|
22
22
|
/** Every stored row of one table, as the database holds it, unscoped. */
|
|
23
23
|
readonly read: (entity: SeedEntity) => Promise<readonly Stored[]>;
|
|
24
24
|
readonly seed?: number;
|
|
25
|
+
/** `defineExercise`'s factories: a table only they satisfy is built by them for the proof too. */
|
|
26
|
+
readonly factories?: Readonly<Record<string, SeedFactory>> | undefined;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface SubjectProofResult {
|
|
@@ -107,6 +109,7 @@ export async function subjectProof<I>(
|
|
|
107
109
|
const graph = seedGraph(Object.fromEntries(entities), {
|
|
108
110
|
seed: finiteOption('subjectProof', 'seed', options.seed ?? 1),
|
|
109
111
|
tenants: 2,
|
|
112
|
+
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
110
113
|
});
|
|
111
114
|
await insertSeedGraph(graph, options.writer);
|
|
112
115
|
const tenant = graph.tenants[0] ?? null;
|