@ultimat3/testing 30.2.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 +17 -17
- package/src/exercise-job-tenant.ts +76 -0
- package/src/exercise-report.ts +5 -0
- package/src/exercise-subjects.ts +119 -0
- package/src/exercise-sweeps.ts +6 -10
- package/src/exercise.ts +25 -90
- package/src/seed-graph-error.ts +5 -1
- package/src/seed-graph-pass.ts +67 -1
- package/src/seed-graph-plan.ts +23 -1
- package/src/seed-graph.ts +34 -5
- package/src/seed-writer.ts +47 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "30.
|
|
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.
|
|
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.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
|
+
}
|
package/src/exercise-report.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/exercise-sweeps.ts
CHANGED
|
@@ -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
|
|
41
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
429
|
-
|
|
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]();
|
package/src/seed-graph-error.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/seed-graph-pass.ts
CHANGED
|
@@ -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 {
|
|
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({
|
package/src/seed-graph-plan.ts
CHANGED
|
@@ -72,6 +72,12 @@ export interface SeedGraph {
|
|
|
72
72
|
/** Where `insertSeedGraph` writes. One insert per row, in order, then one update per deferred key. */
|
|
73
73
|
export interface SeedWriter {
|
|
74
74
|
insert(row: SeedRow): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Rows of ONE entity, in plan order, as one write — `insertAll`. Optional: a writer without it
|
|
77
|
+
* takes `insert` per row. With it, a graph costs one statement per table, not one per row: a
|
|
78
|
+
* pass seeding a fresh tenant pair per primitive writes primitives × tables rows.
|
|
79
|
+
*/
|
|
80
|
+
insertMany?(rows: readonly SeedRow[]): Promise<void>;
|
|
75
81
|
update(update: SeedUpdate): Promise<void>;
|
|
76
82
|
/**
|
|
77
83
|
* Each row read back by its primary key, as one digest per row in the order given — `null` for
|
|
@@ -81,8 +87,24 @@ export interface SeedWriter {
|
|
|
81
87
|
snapshot(rows: readonly SeedRow[]): Promise<readonly (string | null)[]>;
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Each run of consecutive rows of one entity. The plan writes table by table, so a run is a table's
|
|
92
|
+
* rows — and a parent table's run always lands before its children's.
|
|
93
|
+
*/
|
|
94
|
+
export function entityRuns(rows: readonly SeedRow[]): readonly (readonly SeedRow[])[] {
|
|
95
|
+
const runs: SeedRow[][] = [];
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
const last = runs[runs.length - 1];
|
|
98
|
+
if (last !== undefined && last[0]?.entity === row.entity) last.push(row);
|
|
99
|
+
else runs.push([row]);
|
|
100
|
+
}
|
|
101
|
+
return runs;
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
/** Writes the plan in order: every insert, then every deferred key. Sequential, so it replays. */
|
|
85
105
|
export async function insertSeedGraph(graph: SeedGraph, writer: SeedWriter): Promise<void> {
|
|
86
|
-
|
|
106
|
+
const { insertMany } = writer;
|
|
107
|
+
if (insertMany === undefined) for (const row of graph.inserts) await writer.insert(row);
|
|
108
|
+
else for (const run of entityRuns(graph.inserts)) await insertMany.call(writer, run);
|
|
87
109
|
for (const update of graph.updates) await writer.update(update);
|
|
88
110
|
}
|
package/src/seed-graph.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
|
|
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 =
|
|
109
|
-
|
|
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,6 +158,8 @@ export function seedGraph(
|
|
|
135
158
|
const factory = options.factories?.[name];
|
|
136
159
|
const rows: Placed[] = [];
|
|
137
160
|
placed.set(name, rows);
|
|
161
|
+
const byTenant = new Map<string | null, Placed[]>();
|
|
162
|
+
placedIn.set(name, byTenant);
|
|
138
163
|
const allUniques = [...shape.uniques, ...shape.partialUniques];
|
|
139
164
|
const taken = allUniques.map(() => new Set<string>());
|
|
140
165
|
const rootProperty = roots.get(name);
|
|
@@ -210,7 +235,11 @@ export function seedGraph(
|
|
|
210
235
|
const key = uniqueKey(row, set);
|
|
211
236
|
if (key !== undefined) taken[i]?.add(key);
|
|
212
237
|
});
|
|
213
|
-
|
|
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);
|
|
214
243
|
inserts.push({ entity: name, table: entity.$table, tenant, row: { ...row } });
|
|
215
244
|
canaries.push({
|
|
216
245
|
entity: name,
|
package/src/seed-writer.ts
CHANGED
|
@@ -42,45 +42,52 @@ const keyColumns = (entity: EntityCore): readonly string[] => {
|
|
|
42
42
|
);
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/** Joins a composite key's parts, in Postgres and here alike: no seeded key holds a unit separator. */
|
|
46
|
+
const KEY_SEPARATOR = '\u001f';
|
|
47
|
+
|
|
45
48
|
/**
|
|
46
49
|
* `md5(t::text)` per row, by primary key, through the client — the whole stored row, columns no
|
|
47
|
-
* entity declares included.
|
|
48
|
-
*
|
|
50
|
+
* entity declares included. ONE statement, a `union all` branch per table, a composite key matched
|
|
51
|
+
* through parallel arrays: the tenant proof snapshots four times a primitive, and a statement per
|
|
52
|
+
* table was primitives × tables round trips. Unscoped on purpose: the question is whether the row
|
|
53
|
+
* changed, not who may read it.
|
|
49
54
|
*/
|
|
50
55
|
async function snapshotOnClient(
|
|
51
56
|
client: DbClient,
|
|
52
57
|
rows: readonly SeedRow[],
|
|
53
58
|
entities: readonly EntityCore[],
|
|
54
59
|
): Promise<readonly (string | null)[]> {
|
|
55
|
-
const out: (string | null)[] = rows.map(() => null);
|
|
56
60
|
const byEntity = new Map<EntityCore, number[]>();
|
|
57
61
|
entities.forEach((entity, i) => {
|
|
58
|
-
|
|
62
|
+
const indexes = byEntity.get(entity);
|
|
63
|
+
if (indexes === undefined) byEntity.set(entity, [i]);
|
|
64
|
+
else indexes.push(i);
|
|
59
65
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
const keyOf = (entity: EntityCore, i: number): string =>
|
|
67
|
+
entity.$primaryKey.map((property) => String(rows[i]?.row[property])).join(KEY_SEPARATOR);
|
|
68
|
+
const branches = [...byEntity].map(([entity, indexes], at): SqlFragment => {
|
|
69
|
+
const columns = keyColumns(entity).map((column) => identifier(column));
|
|
70
|
+
// Each key column against its values, the parameter UNTYPED so Postgres types it as the
|
|
71
|
+
// column: `id::text = any(…)` read every row of a table that grows with the primitives. A
|
|
72
|
+
// composite key matches a superset here; the full key is matched below.
|
|
73
|
+
const matched = entity.$primaryKey.map(
|
|
74
|
+
(property, part) =>
|
|
75
|
+
sql`${columns[part] as SqlFragment} = any(${[...new Set(indexes.map((i) => String(rows[i]?.row[property])))]})`,
|
|
76
|
+
);
|
|
77
|
+
const texts = columns.map((column) => sql`${column}::text`);
|
|
78
|
+
return sql`select ${at}::int as e, concat_ws(${KEY_SEPARATOR}, ${join(texts)}) as k, md5(t::text) as h from ${identifier(entity.$table)} t where ${join(matched, ' and ')}`;
|
|
79
|
+
});
|
|
80
|
+
if (branches.length === 0) return [];
|
|
81
|
+
const found = await client.query<{ e: number; k: string; h: string }>(
|
|
82
|
+
join(branches, ' union all '),
|
|
83
|
+
);
|
|
84
|
+
const digests = new Map(found.map((one) => [`${one.e}${KEY_SEPARATOR}${one.k}`, one.h]));
|
|
85
|
+
const out: (string | null)[] = rows.map(() => null);
|
|
86
|
+
[...byEntity].forEach(([entity, indexes], at) => {
|
|
74
87
|
for (const i of indexes) {
|
|
75
|
-
|
|
76
|
-
(column, at) => sql`${identifier(column)}::text = ${keyAt(i, at)}`,
|
|
77
|
-
);
|
|
78
|
-
const one = await client.one<{ h: string }>(
|
|
79
|
-
sql`select md5(t::text) as h from ${table} t where ${join(terms, ' and ')}`,
|
|
80
|
-
);
|
|
81
|
-
out[i] = one?.h ?? null;
|
|
88
|
+
out[i] = digests.get(`${at}${KEY_SEPARATOR}${keyOf(entity, i)}`) ?? null;
|
|
82
89
|
}
|
|
83
|
-
}
|
|
90
|
+
});
|
|
84
91
|
return out;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -146,6 +153,20 @@ export function entitySeedWriter(options: EntitySeedWriterOptions): SeedWriter {
|
|
|
146
153
|
const { entity, repo } = await repoFor(row.entity);
|
|
147
154
|
await repo.insert({ ...row.row }, scope(entity, row));
|
|
148
155
|
},
|
|
156
|
+
async insertMany(rows) {
|
|
157
|
+
const [first] = rows;
|
|
158
|
+
if (first === undefined) return;
|
|
159
|
+
const { entity, repo } = await repoFor(first.entity);
|
|
160
|
+
// One scope when every row shares it. An insert's statement reads none — both drivers check
|
|
161
|
+
// a row's tenant column against the ACTOR, and a seed has none — so a table holding many
|
|
162
|
+
// tenants' rows is still one `insertAll`.
|
|
163
|
+
const scopes = new Set(rows.map((row) => scope(entity, row).orgId));
|
|
164
|
+
const [only] = scopes;
|
|
165
|
+
await repo.insertAll(
|
|
166
|
+
rows.map((row) => ({ ...row.row })),
|
|
167
|
+
scopes.size === 1 && only !== undefined ? { orgId: only } : {},
|
|
168
|
+
);
|
|
169
|
+
},
|
|
149
170
|
async update(update) {
|
|
150
171
|
const { entity, repo } = await repoFor(update.entity);
|
|
151
172
|
await repo.updateWhere({ ...update.key }, { ...update.patch }, scope(entity, update));
|