@ultimat3/testing 30.2.0 → 30.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +11 -0
- package/src/errors.ts +2 -0
- package/src/exercise-job-tenant.ts +103 -0
- package/src/exercise-report.ts +12 -2
- package/src/exercise-setup.ts +68 -0
- package/src/exercise-subjects.ts +133 -0
- package/src/exercise-sweeps.ts +6 -10
- package/src/exercise.ts +30 -92
- package/src/factory-registry.ts +14 -2
- package/src/index.ts +1 -0
- package/src/plan-oracle.ts +5 -2
- package/src/plan-sweep-fix.ts +59 -7
- package/src/plan-sweep-report.ts +22 -3
- package/src/registry-leak-guard.ts +2 -2
- package/src/seed-graph-error.ts +5 -1
- package/src/seed-graph-order.ts +9 -5
- package/src/seed-graph-pass.ts +98 -3
- package/src/seed-graph-patterns.ts +51 -0
- package/src/seed-graph-plan.ts +23 -1
- package/src/seed-graph-values.ts +64 -5
- package/src/seed-graph.ts +41 -5
- package/src/seed-pattern-sample.ts +216 -0
- package/src/seed-writer.ts +47 -26
- package/src/subject-proof.ts +3 -1
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,13 @@ import type {
|
|
|
36
34
|
Observed,
|
|
37
35
|
} from './exercise-report';
|
|
38
36
|
import { openExerciseRoom, overrideLine } from './exercise-report';
|
|
39
|
-
import {
|
|
37
|
+
import { withExerciseSetup } from './exercise-setup';
|
|
38
|
+
import type { Subject } from './exercise-subjects';
|
|
39
|
+
import { fixedTenant, seatIn, subjectsOf } from './exercise-subjects';
|
|
40
40
|
import { actorRowsOf, attachSweeps, seated, sizedCall } from './exercise-sweeps';
|
|
41
41
|
import type { RunJobs } from './fixture-jobs';
|
|
42
42
|
import { testJobs } from './fixture-jobs';
|
|
43
|
-
import { seedPass, tenantPair } from './seed-graph-pass';
|
|
43
|
+
import { deferredMayStayNull, pairGraph, seedPass, tenantPair } from './seed-graph-pass';
|
|
44
44
|
import type { SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
45
45
|
import type { SeedEntity } from './seed-graph-shape';
|
|
46
46
|
import { tenantTaint } from './tenant-canary';
|
|
@@ -62,86 +62,6 @@ export interface ExercisePassOptions {
|
|
|
62
62
|
readonly roles: RoleGrants;
|
|
63
63
|
}
|
|
64
64
|
|
|
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
65
|
const isOff = (overrides: ExerciseOverrides | undefined, name: string): boolean => {
|
|
146
66
|
const override = overrides?.entries.get(name);
|
|
147
67
|
return override !== undefined && 'off' in override;
|
|
@@ -264,7 +184,8 @@ async function control(
|
|
|
264
184
|
draw: (at, n) => drawInput(sampler, subject.input, at, declaredRuns === undefined ? 0 : n),
|
|
265
185
|
bind: async (drawn) => {
|
|
266
186
|
const bound = bindIds(drawn, lookups[binds++ === 0 ? 0 : 1]);
|
|
267
|
-
|
|
187
|
+
const given = bindInput === undefined ? bound : await bindInput(bound, actor);
|
|
188
|
+
lastInput = seatIn(subject, given, tenant);
|
|
268
189
|
return lastInput;
|
|
269
190
|
},
|
|
270
191
|
accepts: async (input) =>
|
|
@@ -306,13 +227,16 @@ async function attack(
|
|
|
306
227
|
await using jobs = await testJobs();
|
|
307
228
|
room.begin(subject.name);
|
|
308
229
|
let output: unknown;
|
|
230
|
+
// A job's org is re-seated in B's: B cannot enqueue a payload naming A (refused at the enqueue,
|
|
231
|
+
// which proves nothing), so the attack that matters is a job of B's carrying A's ids.
|
|
232
|
+
const sent = seatIn(subject, input, tenant);
|
|
309
233
|
// Every `crossTenant()` B's call opened: a job's declared sweep is told from a leak by it alone.
|
|
310
234
|
// Loaded here, never at the top: the barrel stays entity-free (`seed-writer.ts`'s rule).
|
|
311
235
|
const { setCrossTenantObserver } = await import('@ultimat3/entity');
|
|
312
236
|
const crossings: string[] = [];
|
|
313
237
|
setCrossTenantObserver((reason) => crossings.push(reason));
|
|
314
238
|
try {
|
|
315
|
-
const answer = await subject.call(
|
|
239
|
+
const answer = await subject.call(sent, actor, tenant, jobs);
|
|
316
240
|
output = answer.output;
|
|
317
241
|
} catch {
|
|
318
242
|
// B's refusal is the answer a scoped primitive gives: only what B got back, and what B's call
|
|
@@ -374,7 +298,18 @@ async function exerciseOne(
|
|
|
374
298
|
};
|
|
375
299
|
// A's own first input, A's ids in it: B sends exactly what A legitimately would.
|
|
376
300
|
const drawn = bindIds(drawInput(sampler, subject.input, 0, 0), lookups[0]);
|
|
377
|
-
const
|
|
301
|
+
const given = bindInput === undefined ? drawn : await bindInput(drawn, actor);
|
|
302
|
+
const seat = subject.seat?.(given, tenant);
|
|
303
|
+
if (seat?.kind === 'fixed') {
|
|
304
|
+
const fixed = fixedTenant(name, seat.declared);
|
|
305
|
+
return {
|
|
306
|
+
...none,
|
|
307
|
+
outcome: 'unreached',
|
|
308
|
+
...fixed,
|
|
309
|
+
tenant: { verdict: 'unproven', reason: fixed.reason },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const input = seat === undefined ? given : seat.input;
|
|
378
313
|
let reachedWith: unknown;
|
|
379
314
|
const record = await proveTenant({
|
|
380
315
|
taint: tenantTaint(graph, tenant),
|
|
@@ -409,7 +344,8 @@ export async function runExercisePass(
|
|
|
409
344
|
const sampler = await loadSampler();
|
|
410
345
|
// Not `Math.random`: the frozen RNG answers the same sequence in every process.
|
|
411
346
|
const passSeed = options.seed ?? (crypto.getRandomValues(new Uint32Array(1))[0] as number);
|
|
412
|
-
|
|
347
|
+
// The app's setup runs inside the seal, before the first row: its routes answer every call.
|
|
348
|
+
const room = await withExerciseSetup(await openExerciseRoom(), options.overrides?.setup);
|
|
413
349
|
const live = chosen.filter((subject) => !isOff(options.overrides, subject.name));
|
|
414
350
|
const records: ExerciseRecord[] = [];
|
|
415
351
|
let graph: SeedGraph;
|
|
@@ -425,8 +361,9 @@ export async function runExercisePass(
|
|
|
425
361
|
// In order, one at a time: the seams the room installs are the process's, not the primitive's.
|
|
426
362
|
for (const subject of chosen) {
|
|
427
363
|
const index = live.indexOf(subject);
|
|
428
|
-
const
|
|
429
|
-
|
|
364
|
+
const at = Math.max(0, index);
|
|
365
|
+
const view = pairGraph(graph, at);
|
|
366
|
+
records.push(await exerciseOne(subject, options, tenantPair(graph, at), view, room, sampler));
|
|
430
367
|
}
|
|
431
368
|
} catch (error) {
|
|
432
369
|
await room[Symbol.asyncDispose]();
|
|
@@ -437,7 +374,8 @@ export async function runExercisePass(
|
|
|
437
374
|
return attachSweeps(records, {
|
|
438
375
|
subjects: chosen.filter((subject) => reached.has(subject.name)),
|
|
439
376
|
entities: options.entities,
|
|
440
|
-
|
|
377
|
+
// The sized graphs close their cycles as the pass's did: a refused deferred key stays null.
|
|
378
|
+
writer: deferredMayStayNull(options.writer),
|
|
441
379
|
main: graph,
|
|
442
380
|
seed: passSeed,
|
|
443
381
|
room,
|
package/src/factory-registry.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import type { EntityLike, Factory, FactoryIds } from './factories';
|
|
6
6
|
import { defineFactory } from './factories';
|
|
7
7
|
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
8
|
-
import {
|
|
8
|
+
import { declaredPatterns } from './seed-graph-patterns';
|
|
9
|
+
import { SEED_SOFT_DELETE_PROPERTY, type SeedEntity } from './seed-graph-shape';
|
|
9
10
|
import { candidatesFor, isSeedColumn } from './seed-graph-values';
|
|
10
11
|
|
|
11
12
|
export type EntityRegistry = Readonly<Record<string, EntityLike>>;
|
|
@@ -25,13 +26,14 @@ export function factoriesFor<TRegistry extends EntityRegistry>(
|
|
|
25
26
|
): FactoryRegistry<TRegistry> {
|
|
26
27
|
const out: Record<string, Factory<Record<string, unknown>>> = {};
|
|
27
28
|
for (const [name, entity] of Object.entries(registry)) {
|
|
29
|
+
const patterns = hasInvariants(entity) ? declaredPatterns(entity) : new Map();
|
|
28
30
|
out[name] = defineFactory(entity, {
|
|
29
31
|
...(seed === undefined ? {} : { seed }),
|
|
30
32
|
defaults: (index, ids): Record<string, unknown> => {
|
|
31
33
|
const row: Record<string, unknown> = {};
|
|
32
34
|
Object.entries(entity.$columns).forEach(([property, column], position) => {
|
|
33
35
|
row[property] = defaultFor(
|
|
34
|
-
{ table: entity.$table, property, column, position },
|
|
36
|
+
{ table: entity.$table, property, column, position, patterns: patterns.get(property) },
|
|
35
37
|
index,
|
|
36
38
|
ids,
|
|
37
39
|
);
|
|
@@ -43,12 +45,21 @@ export function factoriesFor<TRegistry extends EntityRegistry>(
|
|
|
43
45
|
return out as FactoryRegistry<TRegistry>;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** An `entity()` value, which declares rules; a hand-built `EntityLike` may declare none. */
|
|
49
|
+
const hasInvariants = (entity: object): entity is Pick<SeedEntity, '$invariants' | '$describe'> =>
|
|
50
|
+
'$invariants' in entity &&
|
|
51
|
+
Array.isArray(entity.$invariants) &&
|
|
52
|
+
'$describe' in entity &&
|
|
53
|
+
typeof entity.$describe === 'function';
|
|
54
|
+
|
|
46
55
|
/** One column of one entity: what `defaultFor` reads. The name labels a refusal, nothing more. */
|
|
47
56
|
export interface DefaultColumn {
|
|
48
57
|
readonly table: string;
|
|
49
58
|
readonly property: string;
|
|
50
59
|
readonly column: unknown;
|
|
51
60
|
readonly position: number;
|
|
61
|
+
/** The `matches()` patterns the entity holds this column to (`declaredPatterns`). */
|
|
62
|
+
readonly patterns?: readonly RegExp[] | undefined;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -68,6 +79,7 @@ export function defaultFor(target: DefaultColumn, index: number, ids: FactoryIds
|
|
|
68
79
|
ordinal: index,
|
|
69
80
|
canary: String(index),
|
|
70
81
|
position: target.position,
|
|
82
|
+
patterns: target.patterns,
|
|
71
83
|
uuid: () => ids.uuid(),
|
|
72
84
|
})
|
|
73
85
|
: [];
|
package/src/index.ts
CHANGED
package/src/plan-oracle.ts
CHANGED
|
@@ -72,9 +72,12 @@ export function planRoot(answer: unknown): unknown {
|
|
|
72
72
|
|
|
73
73
|
const IDENT = /(?:"?([A-Za-z_][\w$]*)"?\.)?"?([A-Za-z_][\w$]*)"?/g;
|
|
74
74
|
const EQUALITY = /(?<![<>!])=(?!>)/;
|
|
75
|
+
/** `IS NULL` pins one value as `=` does, and a btree serves it as an index condition. */
|
|
76
|
+
const IS_NULL = /\bIS\s+NULL\b/i;
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
|
-
* The columns of `scan` a condition names, split by how they are compared
|
|
79
|
+
* The columns of `scan` a condition names, split by how they are compared: `=` and `IS NULL` are
|
|
80
|
+
* equality, anything else a range. A conjunct qualified by
|
|
78
81
|
* another alias is a join's other side; an unqualified name counts when the table has the column.
|
|
79
82
|
*/
|
|
80
83
|
function columnsIn(
|
|
@@ -86,7 +89,7 @@ function columnsIn(
|
|
|
86
89
|
const range: string[] = [];
|
|
87
90
|
for (const condition of conditions) {
|
|
88
91
|
for (const conjunct of condition.split(/\bAND\b/)) {
|
|
89
|
-
const into = EQUALITY.test(conjunct) ? equality : range;
|
|
92
|
+
const into = EQUALITY.test(conjunct) || IS_NULL.test(conjunct) ? equality : range;
|
|
90
93
|
for (const [, qualifier, name] of conjunct.replace(/'[^']*'/g, '').matchAll(IDENT)) {
|
|
91
94
|
if (name === undefined || !columns.has(name)) continue;
|
|
92
95
|
if (qualifier !== undefined && qualifier !== scan.alias && qualifier !== scan.table)
|
package/src/plan-sweep-fix.ts
CHANGED
|
@@ -18,20 +18,66 @@ export interface PlanFinding {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Every column set the table holds unique: the primary key, unique columns, total unique rules. */
|
|
21
|
-
function uniqueKeys(entity: SeedEntity): readonly (readonly string[])[] {
|
|
21
|
+
function uniqueKeys(entity: SeedEntity, statement: string): readonly (readonly string[])[] {
|
|
22
22
|
const description = entity.$describe();
|
|
23
|
+
const holds = (where: string | null): boolean => where === null || stated(where, statement);
|
|
23
24
|
return [
|
|
24
25
|
description.primaryKey,
|
|
25
26
|
...description.columns.filter((column) => column.unique).map((column) => [column.column]),
|
|
26
27
|
...description.indexes
|
|
27
|
-
.filter((index) => index.unique && index.where
|
|
28
|
+
.filter((index) => index.unique && holds(index.where))
|
|
28
29
|
.map((index) => index.columns),
|
|
29
30
|
...description.invariants
|
|
30
|
-
.filter((rule) => rule.kind === 'unique' && rule.where
|
|
31
|
+
.filter((rule) => rule.kind === 'unique' && holds(rule.where))
|
|
31
32
|
.map((rule) => rule.columns),
|
|
32
33
|
].filter((key) => key.length > 0);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** Lower case outside literals, no identifier quotes, no parentheses, single spaces. */
|
|
37
|
+
const normalized = (sql: string): string =>
|
|
38
|
+
sql
|
|
39
|
+
.split(/('[^']*')/)
|
|
40
|
+
.map((part, at) => (at % 2 === 1 ? part : part.toLowerCase().replace(/["()]/g, ' ')))
|
|
41
|
+
.join('')
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A partial rule holds for a statement that states its predicate, conjunct for conjunct:
|
|
47
|
+
* `deleted_at is null` is in every soft-deleted entity's `one()`. Text, so a predicate the
|
|
48
|
+
* statement implies some other way is not credited — a finding, never a false pass.
|
|
49
|
+
*/
|
|
50
|
+
function stated(where: string, statement: string): boolean {
|
|
51
|
+
const text = ` ${normalized(statement)} `;
|
|
52
|
+
return normalized(where)
|
|
53
|
+
.split(/\band\b/)
|
|
54
|
+
.every((conjunct) => text.includes(` ${conjunct.trim()} `));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every non-partial index the entity declares, in column order: primary key and unique ones too. */
|
|
58
|
+
function declaredIndexes(entity: SeedEntity, statement: string): readonly (readonly string[])[] {
|
|
59
|
+
const description = entity.$describe();
|
|
60
|
+
return [
|
|
61
|
+
...uniqueKeys(entity, statement),
|
|
62
|
+
...description.indexes.filter((index) => index.where === null).map((index) => index.columns),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A walk filtered by a range is served by an index that leads with the equality columns and then
|
|
68
|
+
* a range column: it bounds the rows and the planner sorts the few left. A generic plan with sorts
|
|
69
|
+
* off still prefers the ordered walk, so the oracle cannot see it. Credited, never re-proven.
|
|
70
|
+
*/
|
|
71
|
+
const servedByRange = (keys: readonly (readonly string[])[], verdict: PlanVerdict): boolean =>
|
|
72
|
+
keys.some((key) => {
|
|
73
|
+
const next = key[verdict.equality.length];
|
|
74
|
+
return (
|
|
75
|
+
next !== undefined &&
|
|
76
|
+
verdict.range.includes(next) &&
|
|
77
|
+
key.slice(0, verdict.equality.length).every((column) => verdict.equality.includes(column))
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
35
81
|
const IDENTIFIER = /"([A-Za-z_][\w$]*)"|\b([A-Za-z_][\w$]*)\b/g;
|
|
36
82
|
|
|
37
83
|
/**
|
|
@@ -95,16 +141,22 @@ export function planFindings(
|
|
|
95
141
|
const entity = byTable.get(verdict.table);
|
|
96
142
|
if (entity === undefined) return [];
|
|
97
143
|
if (verdict.code === 'X_QUERY_UNINDEXED' && sorted.has(verdict.table)) return [];
|
|
98
|
-
const pinned = uniqueKeys(entity).some((key) =>
|
|
144
|
+
const pinned = uniqueKeys(entity, statement).some((key) =>
|
|
99
145
|
key.every((column) => verdict.equality.includes(column)),
|
|
100
146
|
);
|
|
101
147
|
if (pinned) return [];
|
|
102
148
|
const description = entity.$describe();
|
|
103
149
|
const byColumn = new Map(description.columns.map((c) => [c.column, c.property]));
|
|
104
150
|
const walked = verdict.code === 'X_QUERY_UNINDEXED' && verdict.node !== 'Seq Scan';
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
// A walk filtered by a range already has its order: what it lacks is the bound. A btree serves
|
|
152
|
+
// no order after a range column, so the index is the equality columns, then the range.
|
|
153
|
+
const ranged = walked && verdict.range.length > 0;
|
|
154
|
+
if (ranged && servedByRange(declaredIndexes(entity, statement), verdict)) return [];
|
|
155
|
+
const order = ranged
|
|
156
|
+
? []
|
|
157
|
+
: walked
|
|
158
|
+
? orderOf(statement, new Set(description.columns.map((c) => c.column)))
|
|
159
|
+
: verdict.order;
|
|
108
160
|
if (servedByPartial(entity, verdict, order)) return [];
|
|
109
161
|
const tenant = description.columns.find((c) => c.property === entity.$tenantColumn)?.column;
|
|
110
162
|
const lead = tenant !== undefined && verdict.equality.includes(tenant) ? [tenant] : [];
|
package/src/plan-sweep-report.ts
CHANGED
|
@@ -24,7 +24,25 @@ const excerpt = (statement: string): string =>
|
|
|
24
24
|
const indexLine = (finding: PlanFinding): string =>
|
|
25
25
|
`indexes: [{ on: [${finding.index.map((p) => `'${p}'`).join(', ')}] }]`;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A read that compares no column of the table — a whole-table count, an unfiltered export — is
|
|
29
|
+
* narrowed by no index: `on: []` is a line `x db gen` refuses. What ends it is saying why it may
|
|
30
|
+
* read every row, around the call or on the entity, or giving it a condition.
|
|
31
|
+
*/
|
|
32
|
+
function wholeRead(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
|
|
33
|
+
const by = senders.map(primitive).join(', ');
|
|
34
|
+
const entity = renderFixLiteral(finding.entity, '<entity>');
|
|
35
|
+
const rerun = renderFixShellArg(senders[0]?.name ?? '', '<primitive>');
|
|
36
|
+
return {
|
|
37
|
+
code: finding.code,
|
|
38
|
+
cause: `${by} sent ${renderCauseValue(excerpt(finding.statement))}, which reads ${finding.table} whole (${finding.node}) and compares no column of it: no index narrows it, so it slows with every row the table gains`,
|
|
39
|
+
fix: `x exercise ${rerun} --json # after expectedSeqScan('<why it reads every row>', fn) around the read, or scan: '<why it stays small>' in entity(${entity}), or a where clause that bounds it`,
|
|
40
|
+
at: senders[0] === undefined ? finding.entity : primitive(senders[0]),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
function planFinding(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
|
|
45
|
+
if (finding.index.length === 0) return wholeRead(finding, senders);
|
|
28
46
|
const by = senders.map(primitive).join(', ');
|
|
29
47
|
const entity = renderFixLiteral(finding.entity, '<entity>');
|
|
30
48
|
const name = renderFixShellArg(`${finding.table}-${finding.index.join('-')}-index`, '<name>');
|
|
@@ -72,9 +90,10 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
|
|
|
72
90
|
/** One line per red: `action listPosts · X_QUERY_UNINDEXED posts on [orgId, authorId]`. */
|
|
73
91
|
export function planSweepLines(records: readonly ExerciseRecord[]): readonly string[] {
|
|
74
92
|
return records.flatMap((record) => [
|
|
75
|
-
...(record.plans?.findings ?? []).map(
|
|
76
|
-
|
|
77
|
-
`${primitive(record)} · ${finding.code} ${finding.table}
|
|
93
|
+
...(record.plans?.findings ?? []).map((finding) =>
|
|
94
|
+
finding.index.length === 0
|
|
95
|
+
? `${primitive(record)} · ${finding.code} ${finding.table}, every row (no column to index)`
|
|
96
|
+
: `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
|
|
78
97
|
),
|
|
79
98
|
...(record.scale?.verdict === 'grows' ? record.scale.shapes : []).map(
|
|
80
99
|
(shape) =>
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
* | locales / catalogs | `resetCatalogs` | `@ultimat3/i18n` | yes |
|
|
40
40
|
* | permissions / roles | `restorePermissions` / `restoreRoles` | `@ultimat3/policy` | yes |
|
|
41
41
|
* | routes | `clearRoutes` | `@ultimat3/render` | **no** |
|
|
42
|
-
* | jobs | `resetJobs` | `@ultimat3/jobs` | **no** |
|
|
42
|
+
* | jobs | `resetJobs` / `restoreJobs` | `@ultimat3/jobs` | **no** — isolated per test |
|
|
43
43
|
* | tasks | `restoreTasks` | `@ultimat3/jobs` | yes (22.7) |
|
|
44
44
|
* | measurement actor | `resetMeasurementActor` | `@ultimat3/core` | yes |
|
|
45
45
|
* | actions | `resetActions` | `@ultimat3/action` | **no** |
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
*
|
|
51
51
|
* Every "no" needs the SAME two halves the two "yes" rows have: a reader and a writer in the owning
|
|
52
52
|
* package (`restorePermissions` was added to `@ultimat3/policy` for exactly this), and one line
|
|
53
|
-
* here. `@ultimat3/
|
|
53
|
+
* here. `@ultimat3/action`, `@ultimat3/query` and `@ultimat3/mail` publish a
|
|
54
54
|
* lister and a reset but no restore, so the change is theirs first and this file's second — and it
|
|
55
55
|
* has to land as ONE change, because the cost of the rest is a second edit of this same shape.
|
|
56
56
|
*/
|
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-order.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Insert order for a seed graph: every parent before its children, over the foreign keys the
|
|
2
|
-
// entities themselves resolve (`$references()
|
|
3
|
-
//
|
|
4
|
-
// no nullable edge is refused
|
|
2
|
+
// entities themselves resolve (`$references()`). A cycle is broken at a NULLABLE edge — inserted
|
|
3
|
+
// null, set by an update once its target exists — preferring a table that takes updates (never
|
|
4
|
+
// `appendOnly`); a cycle with no nullable edge is refused: no insert order can satisfy it.
|
|
5
5
|
|
|
6
6
|
import type { ReferenceDescription } from '@ultimat3/entity';
|
|
7
7
|
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
@@ -9,6 +9,8 @@ import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
|
9
9
|
export interface OrderInput {
|
|
10
10
|
readonly name: string;
|
|
11
11
|
readonly references: readonly ReferenceDescription[];
|
|
12
|
+
/** False for a table whose rows are never updated (`appendOnly`). Default true. */
|
|
13
|
+
readonly updatable?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface InsertOrder {
|
|
@@ -44,6 +46,7 @@ const reaches = (
|
|
|
44
46
|
export function insertOrder(entities: readonly OrderInput[]): InsertOrder {
|
|
45
47
|
const names = new Set(entities.map((entity) => entity.name));
|
|
46
48
|
const edges = new Map(entities.map((entity) => [entity.name, entity.references]));
|
|
49
|
+
const frozen = new Set(entities.filter((e) => e.updatable === false).map((e) => e.name));
|
|
47
50
|
const deferred = new Set<string>();
|
|
48
51
|
// A self-reference never orders anything: the row's own table is already being written.
|
|
49
52
|
const live = (from: string, reference: ReferenceDescription): boolean =>
|
|
@@ -76,9 +79,10 @@ export function insertOrder(entities: readonly OrderInput[]): InsertOrder {
|
|
|
76
79
|
!placed.has(reference.targetEntity) &&
|
|
77
80
|
reaches(edges, live, reference.targetEntity, name),
|
|
78
81
|
)
|
|
79
|
-
.map((reference) => edgeKey(name, reference.property)),
|
|
82
|
+
.map((reference) => [name, edgeKey(name, reference.property)] as const),
|
|
80
83
|
);
|
|
81
|
-
|
|
84
|
+
// Name order still decides between equals, so the same set always breaks at the same edge.
|
|
85
|
+
const edge = (breakable.find(([name]) => !frozen.has(name)) ?? breakable[0])?.[1];
|
|
82
86
|
if (edge === undefined) {
|
|
83
87
|
const cyclic = stuck.filter((name) =>
|
|
84
88
|
(edges.get(name) ?? []).some(
|
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
|
|
|
@@ -34,10 +41,43 @@ const refused = (write: SeedRow | SeedUpdate, error: unknown): unknown => {
|
|
|
34
41
|
});
|
|
35
42
|
};
|
|
36
43
|
|
|
44
|
+
const refusedRow = (error: unknown): boolean =>
|
|
45
|
+
!isUltimateError(error) || ROW_REFUSALS.has(error.code) || error.code === 'X_ENTITY_APPEND_ONLY';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A deferred key the database refuses to set is left null: only a nullable key is ever deferred
|
|
49
|
+
* (`seed-graph-order.ts`), so the row as inserted is already a valid one. The refusal is a rule no
|
|
50
|
+
* declaration carries — a trigger that refuses every UPDATE of the table. An environment failure
|
|
51
|
+
* still rejects with its own code. `onRefused` hears each update left out.
|
|
52
|
+
*/
|
|
53
|
+
export const deferredMayStayNull = (
|
|
54
|
+
writer: SeedWriter,
|
|
55
|
+
onRefused: (update: SeedUpdate) => void = () => undefined,
|
|
56
|
+
): SeedWriter => ({
|
|
57
|
+
insert: (row) => writer.insert(row),
|
|
58
|
+
...(writer.insertMany === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { insertMany: (rows: readonly SeedRow[]) => writer.insertMany?.(rows) ?? Promise.resolve() }),
|
|
61
|
+
update: (update) =>
|
|
62
|
+
writer.update(update).catch((error: unknown) => {
|
|
63
|
+
if (!refusedRow(error)) return Promise.reject(error);
|
|
64
|
+
onRefused(update);
|
|
65
|
+
}),
|
|
66
|
+
snapshot: (rows) => writer.snapshot(rows),
|
|
67
|
+
});
|
|
68
|
+
|
|
37
69
|
/** Every write coded: a row refusal names its factory, an environment failure keeps its own fix. */
|
|
38
70
|
const coded = (writer: SeedWriter): SeedWriter => ({
|
|
39
71
|
insert: (row) =>
|
|
40
72
|
writer.insert(row).catch((error: unknown) => Promise.reject(refused(row, error))),
|
|
73
|
+
...(writer.insertMany === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: {
|
|
76
|
+
insertMany: (rows: readonly SeedRow[]) =>
|
|
77
|
+
(writer.insertMany?.(rows) ?? Promise.resolve()).catch((error: unknown) =>
|
|
78
|
+
Promise.reject(refused(rows[0] as SeedRow, error)),
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
41
81
|
update: (update) =>
|
|
42
82
|
writer.update(update).catch((error: unknown) => Promise.reject(refused(update, error))),
|
|
43
83
|
// A read, not a write: nothing a database refuses here is the generated row's fault.
|
|
@@ -50,6 +90,57 @@ export const tenantPair = (graph: SeedGraph, index: number): readonly [string, s
|
|
|
50
90
|
graph.tenants[index * TENANTS_PER_PRIMITIVE + 1] as string,
|
|
51
91
|
];
|
|
52
92
|
|
|
93
|
+
interface TenantIndex {
|
|
94
|
+
readonly inserts: ReadonlyMap<string | null, readonly number[]>;
|
|
95
|
+
readonly updates: ReadonlyMap<string | null, readonly SeedUpdate[]>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const indexes = new WeakMap<SeedGraph, TenantIndex>();
|
|
99
|
+
|
|
100
|
+
const grouped = <T>(items: readonly T[], tenantOf: (item: T) => string | null) => {
|
|
101
|
+
const out = new Map<string | null, T[]>();
|
|
102
|
+
for (const item of items) {
|
|
103
|
+
const key = tenantOf(item);
|
|
104
|
+
const bucket = out.get(key);
|
|
105
|
+
if (bucket === undefined) out.set(key, [item]);
|
|
106
|
+
else bucket.push(item);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const indexOf = (graph: SeedGraph): TenantIndex => {
|
|
112
|
+
let index = indexes.get(graph);
|
|
113
|
+
if (index === undefined) {
|
|
114
|
+
const positions = graph.inserts.map((_, i) => i);
|
|
115
|
+
index = {
|
|
116
|
+
inserts: grouped(positions, (i) => (graph.inserts[i] as SeedRow).tenant),
|
|
117
|
+
updates: grouped(graph.updates, (update) => update.tenant),
|
|
118
|
+
};
|
|
119
|
+
indexes.set(graph, index);
|
|
120
|
+
}
|
|
121
|
+
return index;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What primitive `index` can see of the pass's graph: the rows of no tenant and its own pair's —
|
|
126
|
+
* the same rows, in the same order, `canaries` still aligned. Everything a primitive reads off the
|
|
127
|
+
* graph (ids to bind, its taint, its actor rows) reads this, so its cost is its pair's, never the
|
|
128
|
+
* whole pass's: a filter of every row per primitive was primitives² over the pass.
|
|
129
|
+
*/
|
|
130
|
+
export function pairGraph(graph: SeedGraph, index: number): SeedGraph {
|
|
131
|
+
const pair = tenantPair(graph, index);
|
|
132
|
+
const { inserts, updates } = indexOf(graph);
|
|
133
|
+
const at = [null, ...pair].flatMap((tenant) => inserts.get(tenant) ?? []).sort((a, b) => a - b);
|
|
134
|
+
return {
|
|
135
|
+
seed: graph.seed,
|
|
136
|
+
tenants: pair,
|
|
137
|
+
order: graph.order,
|
|
138
|
+
inserts: at.map((i) => graph.inserts[i] as SeedRow),
|
|
139
|
+
canaries: at.map((i) => graph.canaries[i] as SeedCanary),
|
|
140
|
+
updates: [null, ...pair].flatMap((tenant) => updates.get(tenant) ?? []),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
53
144
|
/** A factory keyed by a name no seeded entity has would build nothing, and the red would stay. */
|
|
54
145
|
const unknownFactory = (names: readonly string[], known: ReadonlySet<string>): UltimateError =>
|
|
55
146
|
new UltimateError({
|
|
@@ -80,6 +171,10 @@ export async function seedPass(
|
|
|
80
171
|
seed: options.seed,
|
|
81
172
|
...(options.factories === undefined ? {} : { factories: options.factories }),
|
|
82
173
|
});
|
|
83
|
-
|
|
84
|
-
|
|
174
|
+
const refused = new Set<SeedUpdate>();
|
|
175
|
+
await insertSeedGraph(graph, coded(deferredMayStayNull(writer, (update) => refused.add(update))));
|
|
176
|
+
// What was written: an update the database refused is no part of the seeded state.
|
|
177
|
+
return refused.size === 0
|
|
178
|
+
? graph
|
|
179
|
+
: { ...graph, updates: graph.updates.filter((update) => !refused.has(update)) };
|
|
85
180
|
}
|