@ultimat3/testing 30.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "30.1.0",
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.1.0",
50
- "@ultimat3/cache": "30.1.0",
51
- "@ultimat3/core": "30.1.0",
52
- "@ultimat3/db": "30.1.0",
53
- "@ultimat3/entity": "30.1.0",
54
- "@ultimat3/http": "30.1.0",
55
- "@ultimat3/i18n": "30.1.0",
56
- "@ultimat3/jobs": "30.1.0",
57
- "@ultimat3/mail": "30.1.0",
58
- "@ultimat3/policy": "30.1.0",
59
- "@ultimat3/pwa": "30.1.0",
60
- "@ultimat3/query": "30.1.0",
61
- "@ultimat3/realtime": "30.1.0",
62
- "@ultimat3/render": "30.1.0",
63
- "@ultimat3/schema": "30.1.0",
64
- "@ultimat3/time": "30.1.0"
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/exercise.ts CHANGED
@@ -441,6 +441,7 @@ export async function runExercisePass(
441
441
  main: graph,
442
442
  seed: passSeed,
443
443
  room,
444
+ factories: options.overrides?.factories,
444
445
  setup: async (subject, sized, tenant, self) => {
445
446
  const full = chosen.find((one) => one.name === subject.name);
446
447
  return full === undefined
@@ -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: { readonly perTable: number; readonly seed: number },
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);
@@ -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 {
@@ -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 {
@@ -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. */
@@ -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.filter((index) => index.unique).map((i) => toProperties(i.columns)),
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 taken = shape.uniques.map(() => new Set<string>());
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: shape.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
- shape.uniques.forEach((set, i) => {
209
+ allUniques.forEach((set, i) => {
201
210
  const key = uniqueKey(row, set);
202
211
  if (key !== undefined) taken[i]?.add(key);
203
212
  });
@@ -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;