@ultimat3/testing 30.1.0 → 30.3.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.3.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.3.0",
50
+ "@ultimat3/cache": "30.3.0",
51
+ "@ultimat3/core": "30.3.0",
52
+ "@ultimat3/db": "30.3.0",
53
+ "@ultimat3/entity": "30.3.0",
54
+ "@ultimat3/http": "30.3.0",
55
+ "@ultimat3/i18n": "30.3.0",
56
+ "@ultimat3/jobs": "30.3.0",
57
+ "@ultimat3/mail": "30.3.0",
58
+ "@ultimat3/policy": "30.3.0",
59
+ "@ultimat3/pwa": "30.3.0",
60
+ "@ultimat3/query": "30.3.0",
61
+ "@ultimat3/realtime": "30.3.0",
62
+ "@ultimat3/render": "30.3.0",
63
+ "@ultimat3/schema": "30.3.0",
64
+ "@ultimat3/time": "30.3.0"
65
65
  }
66
66
  }
@@ -0,0 +1,76 @@
1
+ // A job's input, seated in the tenant the pass enqueues it under. A job DECLARES its org as a
2
+ // function of its input (`tenant: (input) => input.orgId`), so a drawn input names a random org and
3
+ // the enqueue refuses it (`X_JOB_TENANT_MISMATCH`). The fields that function reads are found by
4
+ // reading them — a recording proxy, no field-name guess — and each is set to the seated tenant.
5
+
6
+ /** The handle's half the binder needs: `AnyJobHandle.tenantFor`, structurally. */
7
+ export interface TenantOf {
8
+ readonly name: string;
9
+ tenantFor(input: unknown): string | undefined;
10
+ }
11
+
12
+ /** `seated`: the input names the tenant. `fixed`: no field it reads moves the org it answers. */
13
+ export type JobTenantBinding =
14
+ | { readonly kind: 'seated'; readonly input: unknown }
15
+ | { readonly kind: 'fixed'; readonly declared: string | undefined };
16
+
17
+ type Path = readonly string[];
18
+
19
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
20
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
21
+ const prototype: unknown = Object.getPrototypeOf(value);
22
+ return prototype === null || prototype === Object.prototype;
23
+ };
24
+
25
+ /** What `tenantFor` answered, or `undefined` for a throw: an unreadable input names no org. */
26
+ const answer = (handle: TenantOf, input: unknown): string | undefined => {
27
+ try {
28
+ return handle.tenantFor(input);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ };
33
+
34
+ /** Every property path `tenantFor` read, at any depth of plain objects — a missing one included. */
35
+ function pathsRead(handle: TenantOf, input: unknown): readonly Path[] {
36
+ const read: Path[] = [];
37
+ const watch = (value: unknown, at: Path): unknown =>
38
+ !isPlainObject(value)
39
+ ? value
40
+ : new Proxy(value, {
41
+ get(target, key, receiver) {
42
+ if (typeof key !== 'string') return Reflect.get(target, key, receiver);
43
+ const path = [...at, key];
44
+ read.push(path);
45
+ return watch(Reflect.get(target, key, receiver), path);
46
+ },
47
+ });
48
+ answer(handle, watch(input, []));
49
+ return read;
50
+ }
51
+
52
+ /** `input` with `path` set to `value`, copied along the path only. */
53
+ function setAt(input: unknown, path: Path, value: string): unknown {
54
+ const [head, ...rest] = path;
55
+ if (head === undefined) return value;
56
+ const base = isPlainObject(input) ? input : {};
57
+ return { ...base, [head]: setAt(base[head], rest, value) };
58
+ }
59
+
60
+ /**
61
+ * `input` naming `tenant` wherever `handle.tenantFor` reads it. A `'none'` job, or one already
62
+ * naming it, comes back as given. A read path is kept only if setting it moves the answer to
63
+ * `tenant` — a field read and then ignored is left as drawn.
64
+ */
65
+ export function bindJobTenant(handle: TenantOf, input: unknown, tenant: string): JobTenantBinding {
66
+ const declared = answer(handle, input);
67
+ if (declared === tenant) return { kind: 'seated', input };
68
+ const reads = pathsRead(handle, input);
69
+ // `tenant: 'none'` is never called: no org named, none read, so none to disagree with.
70
+ if (declared === undefined && reads.length === 0) return { kind: 'seated', input };
71
+ const moving = reads.filter((path) => answer(handle, setAt(input, path, tenant)) === tenant);
72
+ const bound = moving.reduce<unknown>((value, path) => setAt(value, path, tenant), input);
73
+ return moving.length > 0 && answer(handle, bound) === tenant
74
+ ? { kind: 'seated', input: bound }
75
+ : { kind: 'fixed', declared };
76
+ }
@@ -99,6 +99,11 @@ export interface ExerciseRecord {
99
99
  readonly ms: number;
100
100
  /** For an `unreached` one: the line to paste into `defineExercise({ … })`. */
101
101
  readonly override?: string;
102
+ /**
103
+ * For an `unreached` one no override reaches: the edit that does, in place of the paste — a job
104
+ * whose `tenant(input)` names one fixed org (`exercise-subjects.ts`'s `fixedTenant`).
105
+ */
106
+ readonly fix?: string;
102
107
  /** On every record that ran — absent only on `off` and `refused`. The fields above are A's. */
103
108
  readonly tenant?: TenantProof;
104
109
  /** On a reached job only: its control's input, killed at every step boundary. */
@@ -0,0 +1,119 @@
1
+ // The primitives an exercise pass drives, each as one call: every registered action, query and job,
2
+ // loaded on demand. A job is enqueued in the tenant it is called for, its input SEATED there first
3
+ // (`exercise-job-tenant.ts`): a drawn org is a random one, and the enqueue refuses it.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import { renderCauseValue, renderFixShellArg } from '@ultimat3/core';
7
+ import type { AnyJobHandle, JobExecution } from '@ultimat3/jobs';
8
+ import { registeredJobs } from '@ultimat3/jobs';
9
+ import type { Policy } from '@ultimat3/policy';
10
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
11
+ import type { JobTenantBinding } from './exercise-job-tenant';
12
+ import { bindJobTenant } from './exercise-job-tenant';
13
+ import { withExerciseRequest } from './exercise-request';
14
+ import type { RunJobs } from './fixture-jobs';
15
+
16
+ /** `failure` set: the worker recorded one (a job's). Absent: answered, with `output`. */
17
+ export interface Answer {
18
+ readonly failure?: unknown;
19
+ readonly output?: unknown;
20
+ }
21
+
22
+ /** One primitive, as the pass drives it: the schema to draw from and the one call to make. */
23
+ export interface Subject {
24
+ readonly kind: 'action' | 'query' | 'job';
25
+ readonly name: string;
26
+ readonly input: StandardSchemaV1;
27
+ readonly policy: Policy | undefined;
28
+ /** Every permission the policy references — what `x policy explain` matches grants against. */
29
+ readonly permissions: readonly string[];
30
+ /** A job's handle: what the crash proof kills at every step boundary once the control reached it. */
31
+ readonly handle?: AnyJobHandle;
32
+ /** A job's: `input` naming `tenant` wherever its declared `tenant(input)` reads the org. */
33
+ seat?(input: unknown, tenant: string): JobTenantBinding;
34
+ /** Resolves to the failure a worker recorded on the row (a job's), or what the call answered. */
35
+ call(input: unknown, actor: Actor | null, tenant: string, jobs: RunJobs): Promise<Answer>;
36
+ }
37
+
38
+ /** `input` seated in `tenant` when the subject declares a seat and one exists; else as given. */
39
+ export const seatIn = (subject: Pick<Subject, 'seat'>, input: unknown, tenant: string): unknown => {
40
+ const seat = subject.seat?.(input, tenant);
41
+ return seat?.kind === 'seated' ? seat.input : input;
42
+ };
43
+
44
+ /**
45
+ * Why a job whose org no input field moves is not run: the pass seats every primitive in a FRESH
46
+ * tenant of its own, and a constant org is none of them. Seeding that one org instead would hand
47
+ * every such job the same tenant — no fresh pair, no tenant proof — so it is reported, never run
48
+ * in a tenant whose rows the pass did not seed. The repair is the declaration's.
49
+ */
50
+ export function fixedTenant(name: string, declared: string | undefined) {
51
+ const org = declared === undefined ? 'no org' : `org ${renderCauseValue(declared)}`;
52
+ return {
53
+ code: 'X_JOB_TENANT_MISMATCH',
54
+ reason: `its tenant(input) answers ${org} whatever its input carries, so it cannot run in the fresh tenant the pass seeded for it`,
55
+ fix: `tenant: (input) => input.orgId # in job({ name: ${JSON.stringify(name)} })'s declaration: the org as input, so every enqueuer — this pass too — names it; then: x exercise ${renderFixShellArg(name, '<name>')} --json`,
56
+ };
57
+ }
58
+
59
+ const ANSWERED: ReadonlySet<string> = new Set(['completed', 'suspended']);
60
+
61
+ const statusOfJobFailure = (run: JobExecution | undefined): string =>
62
+ run === undefined ? 'the job never ran' : (run.error ?? `the run ended ${run.outcome}`);
63
+
64
+ const jobSubject = (handle: AnyJobHandle): Subject => ({
65
+ kind: 'job',
66
+ name: handle.name,
67
+ input: handle.input,
68
+ policy: undefined,
69
+ permissions: [],
70
+ handle,
71
+ seat: (input, tenant) => bindJobTenant(handle, input, tenant),
72
+ call: async (input, actor, tenant, jobs) => {
73
+ const queued = await jobs.enqueue(handle, input, { tenantId: tenant });
74
+ const trace = await jobs.drain(actor === null ? {} : { actor });
75
+ const own = trace.executions.find((run) => run.jobId === queued.id);
76
+ // The worker's failure is a string on the row, never a throw: it is the attempt's error.
77
+ // Suspended is a body that ran and parked (`step.sleep`, `waitForEvent`): reached.
78
+ return own !== undefined && ANSWERED.has(own.outcome)
79
+ ? { output: own.result }
80
+ : { failure: statusOfJobFailure(own) };
81
+ },
82
+ });
83
+
84
+ export async function subjectsOf(): Promise<readonly Subject[]> {
85
+ // On demand, as every fixture here loads them: the barrel stays off `action` and `query`.
86
+ const { invoke, listActions } = await import('@ultimat3/action');
87
+ const { listQueries, runQuery } = await import('@ultimat3/query');
88
+ return [
89
+ ...listActions().map(
90
+ (target): Subject => ({
91
+ kind: 'action',
92
+ name: target.name,
93
+ input: target.input,
94
+ policy: target.policy,
95
+ permissions: target.describe().permissions,
96
+ call: async (input, actor) => ({
97
+ output: await withExerciseRequest(target.name, actor, () =>
98
+ invoke(target, input, { surface: 'http' }),
99
+ ),
100
+ }),
101
+ }),
102
+ ),
103
+ ...listQueries().map(
104
+ (target): Subject => ({
105
+ kind: 'query',
106
+ name: target.name,
107
+ input: target.input,
108
+ policy: target.policy,
109
+ permissions: target.describe().permissions,
110
+ call: async (input, actor) => ({
111
+ output: await withExerciseRequest(target.name, actor, () =>
112
+ runQuery(target, input, { surface: 'http' }),
113
+ ),
114
+ }),
115
+ }),
116
+ ),
117
+ ...registeredJobs().map(jobSubject),
118
+ ];
119
+ }
@@ -3,8 +3,6 @@
3
3
  // database). Both verdicts land on the record of the primitive that sent the statements.
4
4
 
5
5
  import type { Actor } from '@ultimat3/core';
6
- import type { Policy } from '@ultimat3/policy';
7
- import type { StandardSchemaV1 } from '@ultimat3/schema';
8
6
  import type { ExerciseOverrides } from './define-exercise';
9
7
  import type { ActorRows, RoleGrants } from './exercise-actor';
10
8
  import { asSeededRow, chooseActor } from './exercise-actor';
@@ -12,6 +10,8 @@ import type { Sampler } from './exercise-input';
12
10
  import { bindIds, drawInput, seededIds } from './exercise-input';
13
11
  import { overrideInput } from './exercise-override-input';
14
12
  import type { ExerciseRecord } from './exercise-report';
13
+ import type { Subject } from './exercise-subjects';
14
+ import { seatIn } from './exercise-subjects';
15
15
  import { sweepPlans } from './plan-sweep';
16
16
  import type { ScaleSweepOptions } from './scale-sweep-run';
17
17
  import { scaleSweep } from './scale-sweep-run';
@@ -37,13 +37,8 @@ export const actorRowsOf = (options: CallOptions, graph: SeedGraph): ActorRows |
37
37
  export const seated = (actor: Actor | null, tenant: string, rows: ActorRows | undefined) =>
38
38
  rows === undefined ? actor : asSeededRow(actor, tenant, rows);
39
39
 
40
- /** A primitive as a sized call needs it: its schema and its policy. */
41
- interface Callable {
42
- readonly name: string;
43
- readonly input: StandardSchemaV1;
44
- readonly policy: Policy | undefined;
45
- readonly permissions: readonly string[];
46
- }
40
+ /** A primitive as a sized call needs it: its schema, its policy, and a job's seat. */
41
+ type Callable = Pick<Subject, 'name' | 'input' | 'policy' | 'permissions' | 'seat'>;
47
42
 
48
43
  /** One call of `subject` in `tenant` of a sized graph: the control's actor and first input. */
49
44
  export async function sizedCall(
@@ -67,7 +62,8 @@ export async function sizedCall(
67
62
  const lookup = seededIds(graph, options.entities, { ...scope, self });
68
63
  const drawn = bindIds(drawInput(sampler, subject.input, 0, 0), lookup);
69
64
  const bindInput = overrideInput(subject.name, override);
70
- return { actor, input: bindInput === undefined ? drawn : await bindInput(drawn, actor) };
65
+ const given = bindInput === undefined ? drawn : await bindInput(drawn, actor);
66
+ return { actor, input: seatIn(subject, given, tenant) };
71
67
  }
72
68
 
73
69
  /**
package/src/exercise.ts CHANGED
@@ -8,10 +8,8 @@
8
8
 
9
9
  import type { Actor, ExerciseAttempt, ExerciseVerdict } from '@ultimat3/core';
10
10
  import { isUltimateError, renderThrowable, runExercise } from '@ultimat3/core';
11
- import type { AnyJobHandle, JobExecution, JobRecord } from '@ultimat3/jobs';
11
+ import type { JobExecution, JobRecord } from '@ultimat3/jobs';
12
12
  import { registeredJobs } from '@ultimat3/jobs';
13
- import type { Policy } from '@ultimat3/policy';
14
- import type { StandardSchemaV1 } from '@ultimat3/schema';
15
13
  import type { ExerciseOverride, ExerciseOverrides } from './define-exercise';
16
14
  import { assertOverridesKnown, unknownDeclaration } from './define-exercise';
17
15
  import type { RoleGrants } from './exercise-actor';
@@ -36,11 +34,12 @@ import type {
36
34
  Observed,
37
35
  } from './exercise-report';
38
36
  import { openExerciseRoom, overrideLine } from './exercise-report';
39
- import { withExerciseRequest } from './exercise-request';
37
+ import type { Subject } from './exercise-subjects';
38
+ import { fixedTenant, seatIn, subjectsOf } from './exercise-subjects';
40
39
  import { actorRowsOf, attachSweeps, seated, sizedCall } from './exercise-sweeps';
41
40
  import type { RunJobs } from './fixture-jobs';
42
41
  import { testJobs } from './fixture-jobs';
43
- import { seedPass, tenantPair } from './seed-graph-pass';
42
+ import { pairGraph, seedPass, tenantPair } from './seed-graph-pass';
44
43
  import type { SeedGraph, SeedWriter } from './seed-graph-plan';
45
44
  import type { SeedEntity } from './seed-graph-shape';
46
45
  import { tenantTaint } from './tenant-canary';
@@ -62,86 +61,6 @@ export interface ExercisePassOptions {
62
61
  readonly roles: RoleGrants;
63
62
  }
64
63
 
65
- /** One primitive, as the pass drives it: the schema to draw from and the one call to make. */
66
- interface Subject {
67
- readonly kind: ExerciseRecord['kind'];
68
- readonly name: string;
69
- readonly input: StandardSchemaV1;
70
- readonly policy: Policy | undefined;
71
- /** Every permission the policy references — what `x policy explain` matches grants against. */
72
- readonly permissions: readonly string[];
73
- /** A job's handle: what the crash proof kills at every step boundary once the control reached it. */
74
- readonly handle?: AnyJobHandle;
75
- /** Resolves to the failure a worker recorded on the row (a job's), or what the call answered. */
76
- call(input: unknown, actor: Actor | null, tenant: string, jobs: RunJobs): Promise<Answer>;
77
- }
78
-
79
- /** `failure` set: the worker recorded one (a job's). Absent: answered, with `output`. */
80
- interface Answer {
81
- readonly failure?: unknown;
82
- readonly output?: unknown;
83
- }
84
-
85
- const ANSWERED: ReadonlySet<string> = new Set(['completed', 'suspended']);
86
-
87
- const statusOfJobFailure = (run: JobExecution | undefined): string =>
88
- run === undefined ? 'the job never ran' : (run.error ?? `the run ended ${run.outcome}`);
89
-
90
- async function subjectsOf(): Promise<readonly Subject[]> {
91
- // On demand, as every fixture here loads them: the barrel stays off `action` and `query`.
92
- const { invoke, listActions } = await import('@ultimat3/action');
93
- const { listQueries, runQuery } = await import('@ultimat3/query');
94
- const job = (handle: AnyJobHandle): Subject => ({
95
- kind: 'job',
96
- name: handle.name,
97
- input: handle.input,
98
- policy: undefined,
99
- permissions: [],
100
- handle,
101
- call: async (input, actor, tenant, jobs) => {
102
- const queued = await jobs.enqueue(handle, input, { tenantId: tenant });
103
- const trace = await jobs.drain(actor === null ? {} : { actor });
104
- const own = trace.executions.find((run) => run.jobId === queued.id);
105
- // The worker's failure is a string on the row, never a throw: it is the attempt's error.
106
- // Suspended is a body that ran and parked (`step.sleep`, `waitForEvent`): reached.
107
- return own !== undefined && ANSWERED.has(own.outcome)
108
- ? { output: own.result }
109
- : { failure: statusOfJobFailure(own) };
110
- },
111
- });
112
- return [
113
- ...listActions().map(
114
- (target): Subject => ({
115
- kind: 'action',
116
- name: target.name,
117
- input: target.input,
118
- policy: target.policy,
119
- permissions: target.describe().permissions,
120
- call: async (input, actor) => ({
121
- output: await withExerciseRequest(target.name, actor, () =>
122
- invoke(target, input, { surface: 'http' }),
123
- ),
124
- }),
125
- }),
126
- ),
127
- ...listQueries().map(
128
- (target): Subject => ({
129
- kind: 'query',
130
- name: target.name,
131
- input: target.input,
132
- policy: target.policy,
133
- permissions: target.describe().permissions,
134
- call: async (input, actor) => ({
135
- output: await withExerciseRequest(target.name, actor, () =>
136
- runQuery(target, input, { surface: 'http' }),
137
- ),
138
- }),
139
- }),
140
- ),
141
- ...registeredJobs().map(job),
142
- ];
143
- }
144
-
145
64
  const isOff = (overrides: ExerciseOverrides | undefined, name: string): boolean => {
146
65
  const override = overrides?.entries.get(name);
147
66
  return override !== undefined && 'off' in override;
@@ -264,7 +183,8 @@ async function control(
264
183
  draw: (at, n) => drawInput(sampler, subject.input, at, declaredRuns === undefined ? 0 : n),
265
184
  bind: async (drawn) => {
266
185
  const bound = bindIds(drawn, lookups[binds++ === 0 ? 0 : 1]);
267
- lastInput = bindInput === undefined ? bound : await bindInput(bound, actor);
186
+ const given = bindInput === undefined ? bound : await bindInput(bound, actor);
187
+ lastInput = seatIn(subject, given, tenant);
268
188
  return lastInput;
269
189
  },
270
190
  accepts: async (input) =>
@@ -306,13 +226,16 @@ async function attack(
306
226
  await using jobs = await testJobs();
307
227
  room.begin(subject.name);
308
228
  let output: unknown;
229
+ // A job's org is re-seated in B's: B cannot enqueue a payload naming A (refused at the enqueue,
230
+ // which proves nothing), so the attack that matters is a job of B's carrying A's ids.
231
+ const sent = seatIn(subject, input, tenant);
309
232
  // Every `crossTenant()` B's call opened: a job's declared sweep is told from a leak by it alone.
310
233
  // Loaded here, never at the top: the barrel stays entity-free (`seed-writer.ts`'s rule).
311
234
  const { setCrossTenantObserver } = await import('@ultimat3/entity');
312
235
  const crossings: string[] = [];
313
236
  setCrossTenantObserver((reason) => crossings.push(reason));
314
237
  try {
315
- const answer = await subject.call(input, actor, tenant, jobs);
238
+ const answer = await subject.call(sent, actor, tenant, jobs);
316
239
  output = answer.output;
317
240
  } catch {
318
241
  // B's refusal is the answer a scoped primitive gives: only what B got back, and what B's call
@@ -374,7 +297,18 @@ async function exerciseOne(
374
297
  };
375
298
  // A's own first input, A's ids in it: B sends exactly what A legitimately would.
376
299
  const drawn = bindIds(drawInput(sampler, subject.input, 0, 0), lookups[0]);
377
- const input = bindInput === undefined ? drawn : await bindInput(drawn, actor);
300
+ const given = bindInput === undefined ? drawn : await bindInput(drawn, actor);
301
+ const seat = subject.seat?.(given, tenant);
302
+ if (seat?.kind === 'fixed') {
303
+ const fixed = fixedTenant(name, seat.declared);
304
+ return {
305
+ ...none,
306
+ outcome: 'unreached',
307
+ ...fixed,
308
+ tenant: { verdict: 'unproven', reason: fixed.reason },
309
+ };
310
+ }
311
+ const input = seat === undefined ? given : seat.input;
378
312
  let reachedWith: unknown;
379
313
  const record = await proveTenant({
380
314
  taint: tenantTaint(graph, tenant),
@@ -425,8 +359,9 @@ export async function runExercisePass(
425
359
  // In order, one at a time: the seams the room installs are the process's, not the primitive's.
426
360
  for (const subject of chosen) {
427
361
  const index = live.indexOf(subject);
428
- const pair = tenantPair(graph, Math.max(0, index));
429
- records.push(await exerciseOne(subject, options, pair, graph, room, sampler));
362
+ const at = Math.max(0, index);
363
+ const view = pairGraph(graph, at);
364
+ records.push(await exerciseOne(subject, options, tenantPair(graph, at), view, room, sampler));
430
365
  }
431
366
  } catch (error) {
432
367
  await room[Symbol.asyncDispose]();
@@ -441,6 +376,7 @@ export async function runExercisePass(
441
376
  main: graph,
442
377
  seed: passSeed,
443
378
  room,
379
+ factories: options.overrides?.factories,
444
380
  setup: async (subject, sized, tenant, self) => {
445
381
  const full = chosen.find((one) => one.name === subject.name);
446
382
  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 {
@@ -12,6 +12,8 @@ export interface SeedGraphUnsatisfiedInput {
12
12
  readonly what: string;
13
13
  /** The columns the factory has to supply. Empty when no column is to blame (a cycle). */
14
14
  readonly columns: readonly string[];
15
+ /** When no factory is the repair — a key into a `readOnly` table no row is seeded in. */
16
+ readonly fix?: string;
15
17
  }
16
18
 
17
19
  const ENTITY_PLACEHOLDER = '<the entity the cause names>';
@@ -27,7 +29,9 @@ export class SeedGraphUnsatisfiedError extends UltimateError {
27
29
  super({
28
30
  code: 'X_SEED_GRAPH_UNSATISFIED',
29
31
  cause: `${input.entity}: ${input.what}`,
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`,
32
+ fix:
33
+ input.fix ??
34
+ `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
35
  });
32
36
  }
33
37
  }
@@ -6,7 +6,14 @@
6
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 { SeedFactory, SeedGraph, SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
9
+ import type {
10
+ SeedCanary,
11
+ SeedFactory,
12
+ SeedGraph,
13
+ SeedRow,
14
+ SeedUpdate,
15
+ SeedWriter,
16
+ } from './seed-graph-plan';
10
17
  import { insertSeedGraph } from './seed-graph-plan';
11
18
  import type { SeedEntity } from './seed-graph-shape';
12
19
 
@@ -38,6 +45,14 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
38
45
  const coded = (writer: SeedWriter): SeedWriter => ({
39
46
  insert: (row) =>
40
47
  writer.insert(row).catch((error: unknown) => Promise.reject(refused(row, error))),
48
+ ...(writer.insertMany === undefined
49
+ ? {}
50
+ : {
51
+ insertMany: (rows: readonly SeedRow[]) =>
52
+ (writer.insertMany?.(rows) ?? Promise.resolve()).catch((error: unknown) =>
53
+ Promise.reject(refused(rows[0] as SeedRow, error)),
54
+ ),
55
+ }),
41
56
  update: (update) =>
42
57
  writer.update(update).catch((error: unknown) => Promise.reject(refused(update, error))),
43
58
  // A read, not a write: nothing a database refuses here is the generated row's fault.
@@ -50,6 +65,57 @@ export const tenantPair = (graph: SeedGraph, index: number): readonly [string, s
50
65
  graph.tenants[index * TENANTS_PER_PRIMITIVE + 1] as string,
51
66
  ];
52
67
 
68
+ interface TenantIndex {
69
+ readonly inserts: ReadonlyMap<string | null, readonly number[]>;
70
+ readonly updates: ReadonlyMap<string | null, readonly SeedUpdate[]>;
71
+ }
72
+
73
+ const indexes = new WeakMap<SeedGraph, TenantIndex>();
74
+
75
+ const grouped = <T>(items: readonly T[], tenantOf: (item: T) => string | null) => {
76
+ const out = new Map<string | null, T[]>();
77
+ for (const item of items) {
78
+ const key = tenantOf(item);
79
+ const bucket = out.get(key);
80
+ if (bucket === undefined) out.set(key, [item]);
81
+ else bucket.push(item);
82
+ }
83
+ return out;
84
+ };
85
+
86
+ const indexOf = (graph: SeedGraph): TenantIndex => {
87
+ let index = indexes.get(graph);
88
+ if (index === undefined) {
89
+ const positions = graph.inserts.map((_, i) => i);
90
+ index = {
91
+ inserts: grouped(positions, (i) => (graph.inserts[i] as SeedRow).tenant),
92
+ updates: grouped(graph.updates, (update) => update.tenant),
93
+ };
94
+ indexes.set(graph, index);
95
+ }
96
+ return index;
97
+ };
98
+
99
+ /**
100
+ * What primitive `index` can see of the pass's graph: the rows of no tenant and its own pair's —
101
+ * the same rows, in the same order, `canaries` still aligned. Everything a primitive reads off the
102
+ * graph (ids to bind, its taint, its actor rows) reads this, so its cost is its pair's, never the
103
+ * whole pass's: a filter of every row per primitive was primitives² over the pass.
104
+ */
105
+ export function pairGraph(graph: SeedGraph, index: number): SeedGraph {
106
+ const pair = tenantPair(graph, index);
107
+ const { inserts, updates } = indexOf(graph);
108
+ const at = [null, ...pair].flatMap((tenant) => inserts.get(tenant) ?? []).sort((a, b) => a - b);
109
+ return {
110
+ seed: graph.seed,
111
+ tenants: pair,
112
+ order: graph.order,
113
+ inserts: at.map((i) => graph.inserts[i] as SeedRow),
114
+ canaries: at.map((i) => graph.canaries[i] as SeedCanary),
115
+ updates: [null, ...pair].flatMap((tenant) => updates.get(tenant) ?? []),
116
+ };
117
+ }
118
+
53
119
  /** A factory keyed by a name no seeded entity has would build nothing, and the red would stay. */
54
120
  const unknownFactory = (names: readonly string[], known: ReadonlySet<string>): UltimateError =>
55
121
  new UltimateError({
@@ -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 {
@@ -65,6 +72,12 @@ export interface SeedGraph {
65
72
  /** Where `insertSeedGraph` writes. One insert per row, in order, then one update per deferred key. */
66
73
  export interface SeedWriter {
67
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>;
68
81
  update(update: SeedUpdate): Promise<void>;
69
82
  /**
70
83
  * Each row read back by its primary key, as one digest per row in the order given — `null` for
@@ -74,8 +87,24 @@ export interface SeedWriter {
74
87
  snapshot(rows: readonly SeedRow[]): Promise<readonly (string | null)[]>;
75
88
  }
76
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
+
77
104
  /** Writes the plan in order: every insert, then every deferred key. Sequential, so it replays. */
78
105
  export async function insertSeedGraph(graph: SeedGraph, writer: SeedWriter): Promise<void> {
79
- for (const row of graph.inserts) await writer.insert(row);
106
+ const { insertMany } = writer;
107
+ if (insertMany === undefined) for (const row of graph.inserts) await writer.insert(row);
108
+ else for (const run of entityRuns(graph.inserts)) await insertMany.call(writer, run);
80
109
  for (const update of graph.updates) await writer.update(update);
81
110
  }
@@ -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
@@ -72,9 +72,19 @@ export function seedGraph(
72
72
  // A view entity is a relation Postgres derives: no row is ever written to it, so it is no part of
73
73
  // the graph (`X_ENTITY_VIEW_READ_ONLY` otherwise) — the tables it reads from carry the rows. A
74
74
  // history table is the same from this side: its trigger writes every row, from the parent's.
75
+ // A `readOnly` table is the app's own statement that it writes no row there (a trigger refuses
76
+ // every INSERT): seeded like a view, never — a reference into one is null or refused below.
77
+ const closed = new Map(
78
+ Object.values(entities).flatMap((entity) => {
79
+ const reason = entity.$describe().readOnly;
80
+ return reason === undefined ? [] : [[entity.$name, reason] as const];
81
+ }),
82
+ );
75
83
  const tables = Object.values(entities).filter((entity) => {
76
84
  const described = entity.$describe();
77
- return described.view === undefined && described.historyOf === undefined;
85
+ return (
86
+ described.view === undefined && described.historyOf === undefined && !closed.has(entity.$name)
87
+ );
78
88
  });
79
89
  const shapes = new Map(tables.map((entity) => [entity.$name, shapeOf(entity)]));
80
90
  const { order, deferred } = insertOrder(
@@ -99,21 +109,34 @@ export function seedGraph(
99
109
  for (const factory of Object.values(options.factories ?? {})) factory.reset();
100
110
 
101
111
  const placed = new Map<string, Placed[]>();
112
+ // The same rows by tenant: a pool read per row was a filter of the whole parent table, so a pass
113
+ // seeding a tenant pair per primitive planned in primitives² time.
114
+ const placedIn = new Map<string, Map<string | null, Placed[]>>();
102
115
  const parentValues = (
103
116
  reference: ReferenceDescription,
104
117
  tenant: string | null,
105
118
  index: number,
106
119
  ): unknown[] => {
107
120
  if (!shapes.has(reference.targetEntity)) return reference.nullable ? [null] : [];
108
- const pool = (placed.get(reference.targetEntity) ?? []).filter(
109
- (parent) => tenant === null || !scoped(reference.targetEntity) || parent.tenant === tenant,
110
- );
121
+ const pool =
122
+ tenant === null || !scoped(reference.targetEntity)
123
+ ? (placed.get(reference.targetEntity) ?? [])
124
+ : (placedIn.get(reference.targetEntity)?.get(tenant) ?? []);
111
125
  const values =
112
126
  pool.length === 0 ? [] : rotate(pool, index).map((p) => p.row[reference.targetProperty]);
113
127
  return reference.nullable ? [...values, null] : values;
114
128
  };
115
129
  const refuseEmpty = (shape: EntityShape, property: string, tenant: string | null): never => {
116
130
  const reference = shape.references.get(property);
131
+ const reason = reference === undefined ? undefined : closed.get(reference.targetEntity);
132
+ if (reference !== undefined && reason !== undefined) {
133
+ throw seedGraphUnsatisfied({
134
+ entity: shape.name,
135
+ 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`,
136
+ columns: [property],
137
+ 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"`,
138
+ });
139
+ }
117
140
  throw seedGraphUnsatisfied({
118
141
  entity: shape.name,
119
142
  what:
@@ -135,7 +158,10 @@ export function seedGraph(
135
158
  const factory = options.factories?.[name];
136
159
  const rows: Placed[] = [];
137
160
  placed.set(name, rows);
138
- const taken = shape.uniques.map(() => new Set<string>());
161
+ const byTenant = new Map<string | null, Placed[]>();
162
+ placedIn.set(name, byTenant);
163
+ const allUniques = [...shape.uniques, ...shape.partialUniques];
164
+ const taken = allUniques.map(() => new Set<string>());
139
165
  const rootProperty = roots.get(name);
140
166
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
141
167
  let ordinal = 0;
@@ -176,11 +202,19 @@ export function seedGraph(
176
202
  candidates.length === 0 ? refuseEmpty(shape, property, tenant) : candidates,
177
203
  ] as const;
178
204
  });
205
+ // A partial unique whose every column the factory built is the database's to judge.
206
+ const held = allUniques
207
+ .map((set, i) => [set, taken[i] as Set<string>] as const)
208
+ .filter(
209
+ ([set], i) =>
210
+ i < shape.uniques.length || !set.every((property) => Object.hasOwn(built, property)),
211
+ );
179
212
  const problem: RowProblem = {
180
213
  columns,
181
214
  checks: shape.checks,
182
- uniques: shape.uniques,
183
- taken,
215
+ uniques: held.map(([set]) => set),
216
+ taken: held.map(([, set]) => set),
217
+ derive: factory?.derive?.bind(factory),
184
218
  };
185
219
  const solved = solveRow(problem);
186
220
  if (!solved.ok) {
@@ -197,11 +231,15 @@ export function seedGraph(
197
231
  });
198
232
  }
199
233
  const row = { ...solved.row };
200
- shape.uniques.forEach((set, i) => {
234
+ allUniques.forEach((set, i) => {
201
235
  const key = uniqueKey(row, set);
202
236
  if (key !== undefined) taken[i]?.add(key);
203
237
  });
204
- rows.push({ tenant, row, index });
238
+ const one = { tenant, row, index };
239
+ rows.push(one);
240
+ const bucketRows = byTenant.get(tenant);
241
+ if (bucketRows === undefined) byTenant.set(tenant, [one]);
242
+ else bucketRows.push(one);
205
243
  inserts.push({ entity: name, table: entity.$table, tenant, row: { ...row } });
206
244
  canaries.push({
207
245
  entity: name,
@@ -42,45 +42,52 @@ const keyColumns = (entity: EntityCore): readonly string[] => {
42
42
  );
43
43
  };
44
44
 
45
+ /** Joins a composite key's parts, in Postgres and here alike: no seeded key holds a unit separator. */
46
+ const KEY_SEPARATOR = '\u001f';
47
+
45
48
  /**
46
49
  * `md5(t::text)` per row, by primary key, through the client — the whole stored row, columns no
47
- * entity declares included. One statement per table for a one-column key, one per row for a
48
- * composite one. Unscoped on purpose: the question is whether the row changed, not who may read it.
50
+ * entity declares included. ONE statement, a `union all` branch per table, a composite key matched
51
+ * through parallel arrays: the tenant proof snapshots four times a primitive, and a statement per
52
+ * table was primitives × tables round trips. Unscoped on purpose: the question is whether the row
53
+ * changed, not who may read it.
49
54
  */
50
55
  async function snapshotOnClient(
51
56
  client: DbClient,
52
57
  rows: readonly SeedRow[],
53
58
  entities: readonly EntityCore[],
54
59
  ): Promise<readonly (string | null)[]> {
55
- const out: (string | null)[] = rows.map(() => null);
56
60
  const byEntity = new Map<EntityCore, number[]>();
57
61
  entities.forEach((entity, i) => {
58
- byEntity.set(entity, [...(byEntity.get(entity) ?? []), i]);
62
+ const indexes = byEntity.get(entity);
63
+ if (indexes === undefined) byEntity.set(entity, [i]);
64
+ else indexes.push(i);
59
65
  });
60
- for (const [entity, indexes] of byEntity) {
61
- const table = identifier(entity.$table);
62
- const columns = keyColumns(entity);
63
- const keyAt = (i: number, at: number) => String(rows[i]?.row[entity.$primaryKey[at] as string]);
64
- const [single] = columns;
65
- if (columns.length === 1 && single !== undefined) {
66
- const key = identifier(single);
67
- const found = await client.query<{ k: string; h: string }>(
68
- sql`select ${key}::text as k, md5(t::text) as h from ${table} t where ${key}::text = any(${indexes.map((i) => keyAt(i, 0))}::text[])`,
69
- );
70
- const digests = new Map(found.map((one) => [one.k, one.h]));
71
- for (const i of indexes) out[i] = digests.get(keyAt(i, 0)) ?? null;
72
- continue;
73
- }
66
+ const keyOf = (entity: EntityCore, i: number): string =>
67
+ entity.$primaryKey.map((property) => String(rows[i]?.row[property])).join(KEY_SEPARATOR);
68
+ const branches = [...byEntity].map(([entity, indexes], at): SqlFragment => {
69
+ const columns = keyColumns(entity).map((column) => identifier(column));
70
+ // Each key column against its values, the parameter UNTYPED so Postgres types it as the
71
+ // column: `id::text = any(…)` read every row of a table that grows with the primitives. A
72
+ // composite key matches a superset here; the full key is matched below.
73
+ const matched = entity.$primaryKey.map(
74
+ (property, part) =>
75
+ sql`${columns[part] as SqlFragment} = any(${[...new Set(indexes.map((i) => String(rows[i]?.row[property])))]})`,
76
+ );
77
+ const texts = columns.map((column) => sql`${column}::text`);
78
+ return sql`select ${at}::int as e, concat_ws(${KEY_SEPARATOR}, ${join(texts)}) as k, md5(t::text) as h from ${identifier(entity.$table)} t where ${join(matched, ' and ')}`;
79
+ });
80
+ if (branches.length === 0) return [];
81
+ const found = await client.query<{ e: number; k: string; h: string }>(
82
+ join(branches, ' union all '),
83
+ );
84
+ const digests = new Map(found.map((one) => [`${one.e}${KEY_SEPARATOR}${one.k}`, one.h]));
85
+ const out: (string | null)[] = rows.map(() => null);
86
+ [...byEntity].forEach(([entity, indexes], at) => {
74
87
  for (const i of indexes) {
75
- const terms: SqlFragment[] = columns.map(
76
- (column, at) => sql`${identifier(column)}::text = ${keyAt(i, at)}`,
77
- );
78
- const one = await client.one<{ h: string }>(
79
- sql`select md5(t::text) as h from ${table} t where ${join(terms, ' and ')}`,
80
- );
81
- out[i] = one?.h ?? null;
88
+ out[i] = digests.get(`${at}${KEY_SEPARATOR}${keyOf(entity, i)}`) ?? null;
82
89
  }
83
- }
90
+ });
84
91
  return out;
85
92
  }
86
93
 
@@ -146,6 +153,20 @@ export function entitySeedWriter(options: EntitySeedWriterOptions): SeedWriter {
146
153
  const { entity, repo } = await repoFor(row.entity);
147
154
  await repo.insert({ ...row.row }, scope(entity, row));
148
155
  },
156
+ async insertMany(rows) {
157
+ const [first] = rows;
158
+ if (first === undefined) return;
159
+ const { entity, repo } = await repoFor(first.entity);
160
+ // One scope when every row shares it. An insert's statement reads none — both drivers check
161
+ // a row's tenant column against the ACTOR, and a seed has none — so a table holding many
162
+ // tenants' rows is still one `insertAll`.
163
+ const scopes = new Set(rows.map((row) => scope(entity, row).orgId));
164
+ const [only] = scopes;
165
+ await repo.insertAll(
166
+ rows.map((row) => ({ ...row.row })),
167
+ scopes.size === 1 && only !== undefined ? { orgId: only } : {},
168
+ );
169
+ },
149
170
  async update(update) {
150
171
  const { entity, repo } = await repoFor(update.entity);
151
172
  await repo.updateWhere({ ...update.key }, { ...update.patch }, scope(entity, update));
@@ -8,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;