@ultimat3/testing 29.3.2 → 30.0.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.
Files changed (93) hide show
  1. package/CLAUDE.md +56 -63
  2. package/README.md +281 -6
  3. package/package.json +27 -16
  4. package/src/ax-node.ts +96 -0
  5. package/src/cassette-errors.ts +91 -0
  6. package/src/cassette-format.ts +175 -0
  7. package/src/cassette-record.ts +120 -0
  8. package/src/cassette-secret-scan.ts +77 -0
  9. package/src/cassette.ts +166 -0
  10. package/src/cdp-browser.ts +5 -3
  11. package/src/cdp-e2e-page.ts +91 -3
  12. package/src/cdp-e2e-session.ts +18 -1
  13. package/src/define-exercise.ts +82 -0
  14. package/src/drill-budgets.ts +54 -0
  15. package/src/drill-errors.ts +45 -0
  16. package/src/drill-fleet.ts +251 -0
  17. package/src/drill-jobs.ts +79 -0
  18. package/src/drill-load.ts +166 -0
  19. package/src/drill-probe.ts +68 -0
  20. package/src/drill-run.ts +144 -0
  21. package/src/drill-services.ts +70 -0
  22. package/src/drill-sync.ts +108 -0
  23. package/src/drill-verdict.ts +256 -0
  24. package/src/drill-writes.ts +123 -0
  25. package/src/drills.ts +109 -0
  26. package/src/e2e-a11y-run.ts +189 -0
  27. package/src/e2e-a11y-tabs.ts +113 -0
  28. package/src/e2e-a11y.ts +153 -0
  29. package/src/e2e-driver.ts +46 -10
  30. package/src/e2e-page.ts +28 -3
  31. package/src/e2e-run.ts +1 -0
  32. package/src/e2e-spawn.ts +3 -2
  33. package/src/errors.ts +43 -0
  34. package/src/exercise-actor.ts +124 -0
  35. package/src/exercise-crash.ts +46 -0
  36. package/src/exercise-input.ts +182 -0
  37. package/src/exercise-override-input.ts +34 -0
  38. package/src/exercise-report.ts +282 -0
  39. package/src/exercise-request.ts +38 -0
  40. package/src/exercise-sweeps.ts +100 -0
  41. package/src/exercise.ts +448 -0
  42. package/src/factories.ts +10 -7
  43. package/src/factory-registry.ts +49 -15
  44. package/src/fake-identity-provider.ts +191 -0
  45. package/src/fault-proxy.ts +342 -0
  46. package/src/file-boundary.ts +4 -1
  47. package/src/fixture-jobs.ts +3 -1
  48. package/src/fixture-statements.ts +4 -1
  49. package/src/free-port.ts +59 -0
  50. package/src/index.ts +148 -3
  51. package/src/island-dom.ts +5 -0
  52. package/src/isolated-plugins.ts +1 -1
  53. package/src/job-crash-proof.ts +249 -0
  54. package/src/job-sim-driver.ts +164 -0
  55. package/src/job-sim-effects.ts +142 -0
  56. package/src/job-sim-errors.ts +72 -0
  57. package/src/job-sim-schedule.ts +154 -0
  58. package/src/job-sim-world.ts +198 -0
  59. package/src/job-simulate.ts +179 -0
  60. package/src/mock-route.ts +81 -0
  61. package/src/module-mock-boundary.ts +85 -0
  62. package/src/module-mock-error.ts +28 -0
  63. package/src/plan-oracle.ts +164 -0
  64. package/src/plan-sweep-fix.ts +123 -0
  65. package/src/plan-sweep-report.ts +84 -0
  66. package/src/plan-sweep.ts +148 -0
  67. package/src/preload.ts +24 -4
  68. package/src/registry-snapshot.ts +1 -1
  69. package/src/repro-stand-in.ts +67 -0
  70. package/src/repro.ts +158 -0
  71. package/src/request-log.ts +64 -0
  72. package/src/reusable-database.ts +1 -1
  73. package/src/scale-sweep-graph.ts +82 -0
  74. package/src/scale-sweep-run.ts +134 -0
  75. package/src/scale-sweep.ts +74 -0
  76. package/src/sealed-network.ts +63 -46
  77. package/src/seed-graph-error.ts +50 -0
  78. package/src/seed-graph-order.ts +98 -0
  79. package/src/seed-graph-pass.ts +68 -0
  80. package/src/seed-graph-plan.ts +81 -0
  81. package/src/seed-graph-row.ts +134 -0
  82. package/src/seed-graph-shape.ts +104 -0
  83. package/src/seed-graph-values.ts +135 -0
  84. package/src/seed-graph.ts +250 -0
  85. package/src/seed-writer.ts +180 -0
  86. package/src/subject-proof-error.ts +23 -0
  87. package/src/subject-proof.ts +180 -0
  88. package/src/template-db-roles.ts +51 -0
  89. package/src/template-db.ts +8 -2
  90. package/src/tenant-canary.ts +120 -0
  91. package/src/tenant-proof-report.ts +62 -0
  92. package/src/tenant-proof.ts +177 -0
  93. package/src/test-types.ts +12 -3
@@ -0,0 +1,179 @@
1
+ // `simulateJobs`: the jobs a test names, run by N workers over one queue, every driver call
2
+ // interleaved by a seeded picker and faulted by it — kill, lost answer, lapsed lease, duplicate
3
+ // claim, clock jump. Each seed runs twice and must trace the same bytes; then the app's own
4
+ // invariant judges what happened. Off the automatic pass: only the author knows the invariant.
5
+
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import { assert, finiteCount, finiteOption, renderThrowable } from '@ultimat3/core';
8
+ import type { AnyJobHandle } from '@ultimat3/jobs';
9
+ import { advanceClock, frozenNow } from './determinism';
10
+ import type { SimEffect } from './job-sim-effects';
11
+ import { JobSimInvariantError, JobSimNondeterministicError } from './job-sim-errors';
12
+ import type { JobFaultKind } from './job-sim-schedule';
13
+ import { ALL_FAULTS, simScheduler } from './job-sim-schedule';
14
+ import { openSimWorld } from './job-sim-world';
15
+
16
+ /** `x test job --seed <n>` hands its seed to the test process under this name: one seed, replayed. */
17
+ export const JOB_SIM_SEED_ENV = 'ULTIMATE_JOB_SIM_SEED';
18
+
19
+ export interface SimulatedJob {
20
+ readonly handle: AnyJobHandle;
21
+ readonly input: unknown;
22
+ }
23
+
24
+ /** One row as the simulation left it. */
25
+ export interface SimulatedRun {
26
+ readonly job: string;
27
+ /** `job#1`: the order it was queued in, the same on every run of a seed. */
28
+ readonly id: string;
29
+ readonly state: string;
30
+ readonly attempt: number;
31
+ readonly lastError?: string;
32
+ }
33
+
34
+ export interface SimulationState {
35
+ readonly seed: number;
36
+ readonly effects: readonly SimEffect[];
37
+ readonly runs: readonly SimulatedRun[];
38
+ readonly faults: readonly string[];
39
+ }
40
+
41
+ export interface SimulateJobsOptions {
42
+ /** The first seed. Default 1; `ULTIMATE_JOB_SIM_SEED` in the environment runs that one alone. */
43
+ readonly seed?: number;
44
+ readonly seeds?: number;
45
+ readonly jobs: readonly SimulatedJob[];
46
+ readonly workers?: number;
47
+ readonly faults?: readonly JobFaultKind[];
48
+ /** The chance any one driver call carries a fault. Default 0.05. */
49
+ readonly faultRate?: number;
50
+ readonly context?: () => Ctx;
51
+ /** `false` or a throw is a violation; an `expect` inside reads as one. */
52
+ invariant(state: SimulationState): unknown;
53
+ }
54
+
55
+ export interface SimulationReport {
56
+ readonly seeds: readonly number[];
57
+ }
58
+
59
+ const MAX_ROUNDS = 100;
60
+ /** Passes one worker takes per round while another is still busy: how a reclaim races a run. */
61
+ const PASSES_PER_ROUND = 4;
62
+
63
+ /** The seed `x test job --seed <n>` asked to replay, or none. */
64
+ function replaySeed(): number | undefined {
65
+ const raw = Bun.env[JOB_SIM_SEED_ENV];
66
+ if (raw === undefined || raw === '') return undefined;
67
+ const seed = Number(raw);
68
+ return finiteCount('simulateJobs', JOB_SIM_SEED_ENV, seed);
69
+ }
70
+
71
+ async function runSeed(seed: number, options: SimulateJobsOptions) {
72
+ const scheduler = simScheduler({
73
+ seed,
74
+ faults: options.faults ?? ALL_FAULTS,
75
+ faultRate: options.faultRate ?? 0.05,
76
+ });
77
+ await using world = await openSimWorld({
78
+ seed,
79
+ decide: scheduler.decide,
80
+ ...(options.context === undefined ? {} : { context: options.context }),
81
+ });
82
+ for (const one of options.jobs) await world.enqueue(one.handle, one.input);
83
+ let spawned = 0;
84
+ const spawn = (): string => {
85
+ spawned += 1;
86
+ return `w${spawned}`;
87
+ };
88
+ let alive = Array.from({ length: options.workers ?? 2 }, spawn);
89
+ for (let round = 0; round < MAX_ROUNDS; round += 1) {
90
+ const busy = new Set<string>();
91
+ let ran = 0;
92
+ const loop = async (worker: string): Promise<void> => {
93
+ busy.add(worker);
94
+ for (let pass = 0; pass < PASSES_PER_ROUND && !world.sim.isDead(worker); pass += 1) {
95
+ const executions = await world.tick(worker);
96
+ ran += executions.length;
97
+ for (const run of executions) {
98
+ scheduler.trace.push(`ran ${worker} ${world.name(run.jobId)} ${run.outcome}`);
99
+ }
100
+ if (executions.length === 0 && [...busy].every((one) => one === worker)) break;
101
+ }
102
+ busy.delete(worker);
103
+ };
104
+ await scheduler.run(world, alive.map(loop));
105
+ // A killed process is replaced, as an orchestrator restarts it: the fleet keeps its size.
106
+ alive = alive.map((worker) => (world.sim.isDead(worker) ? spawn() : worker));
107
+ if (ran > 0) continue;
108
+ const due = await world.nextDue();
109
+ if (due === undefined) break;
110
+ advanceClock(Math.max(1, due - frozenNow().getTime()));
111
+ }
112
+ const runs = (await world.rows()).map(
113
+ (row): SimulatedRun => ({
114
+ job: row.name,
115
+ id: world.name(row.id),
116
+ state: row.state,
117
+ attempt: row.attempt,
118
+ ...(row.lastError === undefined ? {} : { lastError: row.lastError }),
119
+ }),
120
+ );
121
+ const effects = world.effects();
122
+ const trace = [
123
+ ...scheduler.trace,
124
+ ...effects.map((effect) => `effect ${effect.worker ?? '-'} ${effect.key}`),
125
+ ...runs.map((run) => `row ${run.id} ${run.job} ${run.state} ${run.attempt}`),
126
+ ];
127
+ return { trace, state: { seed, effects, runs, faults: [...scheduler.planted] } };
128
+ }
129
+
130
+ function firstDifference(a: readonly string[], b: readonly string[]): number {
131
+ const length = Math.max(a.length, b.length);
132
+ for (let i = 0; i < length; i += 1) if (a[i] !== b[i]) return i;
133
+ return -1;
134
+ }
135
+
136
+ export async function simulateJobs(options: SimulateJobsOptions): Promise<SimulationReport> {
137
+ const seeds = finiteCount('simulateJobs', 'seeds', options.seeds ?? 20, 1);
138
+ finiteCount('simulateJobs', 'workers', options.workers ?? 2, 1);
139
+ const rate = finiteOption('simulateJobs', 'faultRate', options.faultRate ?? 0.05);
140
+ assert(
141
+ rate >= 0 && rate <= 1,
142
+ `simulateJobs faultRate is ${rate}, and a chance is between 0 and 1`,
143
+ 'simulateJobs({ …, faultRate: 0.05 })',
144
+ );
145
+ const first = finiteCount('simulateJobs', 'seed', options.seed ?? 1);
146
+ const replay = replaySeed();
147
+ const list = replay === undefined ? Array.from({ length: seeds }, (_, i) => first + i) : [replay];
148
+ for (const seed of list) {
149
+ const once = await runSeed(seed, options);
150
+ const twice = await runSeed(seed, options);
151
+ const at = firstDifference(once.trace, twice.trace);
152
+ if (at !== -1) {
153
+ throw new JobSimNondeterministicError({
154
+ seed,
155
+ at,
156
+ first: once.trace[at] ?? '<end of trace>',
157
+ second: twice.trace[at] ?? '<end of trace>',
158
+ });
159
+ }
160
+ let verdict: unknown;
161
+ try {
162
+ verdict = options.invariant(once.state);
163
+ } catch (error) {
164
+ throw new JobSimInvariantError({
165
+ seed,
166
+ faults: once.state.faults,
167
+ reason: renderThrowable(error),
168
+ });
169
+ }
170
+ if (verdict === false) {
171
+ throw new JobSimInvariantError({
172
+ seed,
173
+ faults: once.state.faults,
174
+ reason: 'the invariant answered false',
175
+ });
176
+ }
177
+ }
178
+ return { seeds: list };
179
+ }
@@ -0,0 +1,81 @@
1
+ // What `mockFetch` registers and whether a call reaches it: the verb a match spells, the answers a
2
+ // list gives in order, and the name a pinned dial carries. `sealed-network.ts` owns when it is asked.
3
+
4
+ import { assert, peerAddressClass } from '@ultimat3/core';
5
+
6
+ export type MockHandler = (request: Request) => Response | Promise<Response>;
7
+
8
+ export interface MockRoute {
9
+ /** Exact URL, or a prefix ending in `*`, or a RegExp. */
10
+ readonly match: string | RegExp;
11
+ /** The one verb answered, uppercase. Absent answers every verb. */
12
+ readonly method?: string;
13
+ readonly handler: MockHandler;
14
+ }
15
+
16
+ /** `POST https://…`, the spelling `x shot` states use. A URL holds no space, so nothing else fits. */
17
+ const VERB_PREFIX = /^([A-Z]+) (.+)$/;
18
+
19
+ /** One handler over a list: the last answer repeats, and a clone means a repeat still has a body. */
20
+ const inOrder = (match: string | RegExp, answers: readonly (Response | Error)[]): MockHandler => {
21
+ const spelled = typeof match === 'string' ? `'${match}'` : String(match);
22
+ assert(
23
+ answers.length > 0,
24
+ `mockFetch(${spelled}) was handed an empty answer list, so no call could be answered`,
25
+ `mockFetch(${spelled}, [new Response('{}')]) — one answer at least`,
26
+ );
27
+ const pending = [...answers];
28
+ return () => {
29
+ const answer = pending.length > 1 ? pending.shift() : pending[0];
30
+ if (answer instanceof Error) throw answer;
31
+ return (answer as Response).clone();
32
+ };
33
+ };
34
+
35
+ export function mockRoute(
36
+ match: string | RegExp,
37
+ answer: MockHandler | readonly (Response | Error)[],
38
+ ): MockRoute {
39
+ const handler = typeof answer === 'function' ? answer : inOrder(match, answer);
40
+ const verb = typeof match === 'string' ? VERB_PREFIX.exec(match) : null;
41
+ if (verb?.[1] === undefined || verb[2] === undefined) return { match, handler };
42
+ return { match: verb[2], method: verb[1], handler };
43
+ }
44
+
45
+ const urlMatches = (match: string | RegExp, url: string): boolean => {
46
+ if (match instanceof RegExp) {
47
+ // A `g`/`y` pattern's `test` resumes at `lastIndex`: without the reset, every other call missed.
48
+ match.lastIndex = 0;
49
+ return match.test(url);
50
+ }
51
+ if (match.endsWith('*')) return url.startsWith(match.slice(0, -1));
52
+ return match === url;
53
+ };
54
+
55
+ /**
56
+ * A pinned dial is an address literal carrying the name it stands for in `host`, so a mock of the
57
+ * name is the mock of that dial. Only a literal: a named URL's host header names nothing it dials.
58
+ */
59
+ const namedUrl = (url: string, headers: Readonly<Record<string, string>>): string | undefined => {
60
+ const host = headers['host'];
61
+ if (host === undefined || !URL.canParse(url)) return undefined;
62
+ const named = new URL(url);
63
+ if (peerAddressClass(named.hostname) === undefined) return undefined;
64
+ named.host = host;
65
+ return named.href;
66
+ };
67
+
68
+ /** Whether `route` answers this call. */
69
+ export const routeMatches = (
70
+ route: MockRoute,
71
+ call: {
72
+ readonly method: string;
73
+ readonly url: string;
74
+ readonly headers: Readonly<Record<string, string>>;
75
+ },
76
+ ): boolean => {
77
+ if (route.method !== undefined && route.method !== call.method) return false;
78
+ if (urlMatches(route.match, call.url)) return true;
79
+ const named = namedUrl(call.url, call.headers);
80
+ return named !== undefined && urlMatches(route.match, named);
81
+ };
@@ -0,0 +1,85 @@
1
+ // `mock.module` undone at the file boundary. Bun keeps a module mock for the life of the process,
2
+ // past `mock.restore()` and past the file, so on a shared (`--no-isolate`) worker the NEXT file read
3
+ // the fake — a verdict that depended on which file the worker ran first. The preload wraps the call:
4
+ // a file's first mock of a module snapshots the real namespace, and the boundary mocks it back.
5
+
6
+ import { beforeEach, jest, mock, vi } from 'bun:test';
7
+ // why: Bun ships no path-join primitive; a relative specifier is resolved against its caller.
8
+ import { dirname, join } from 'node:path';
9
+ import { inTestFile, onFileBoundary } from './file-boundary';
10
+ import type { UnrestorableMock } from './module-mock-error';
11
+ import { ModuleMockUnrestorableError } from './module-mock-error';
12
+
13
+ type MockModule = (id: string, factory: () => unknown) => unknown;
14
+
15
+ /** The calling file, from a stack captured above `wrapper`: `at fn (/path/a.test.ts:3:5)`. */
16
+ export function callerFile(stack: string | undefined): string | undefined {
17
+ const frame = stack?.split('\n')[1];
18
+ const match =
19
+ frame === undefined ? null : /\(?((?:[A-Za-z]:)?[/\\][^()]*?):\d+:\d+\)?\s*$/.exec(frame);
20
+ return match?.[1];
21
+ }
22
+
23
+ let installed = false;
24
+
25
+ /** Called by the preload once per process; idempotent. */
26
+ export function installModuleMockBoundary(): void {
27
+ if (installed) return;
28
+ installed = true;
29
+ const original = mock.module.bind(mock) as MockModule;
30
+ const real = new Map<string, Readonly<Record<string, unknown>>>();
31
+ const unrestorable: UnrestorableMock[] = [];
32
+ let inherited: UnrestorableMock[] = [];
33
+
34
+ const wrapper: MockModule = (id, factory) => {
35
+ const holder: { stack?: string } = {};
36
+ Error.captureStackTrace(holder, wrapper);
37
+ const file = callerFile(holder.stack) ?? process.cwd();
38
+ const dir = dirname(file);
39
+ let url = id.startsWith('./') || id.startsWith('../') ? join(dir, id) : id;
40
+ let missing: unknown;
41
+ try {
42
+ url = Bun.resolveSync(id, dir);
43
+ } catch (reason) {
44
+ // A virtual module is legal to mock; with no real one there is nothing to put back.
45
+ missing = reason;
46
+ }
47
+ // A preload's mock is every file's environment, never one file's to undo.
48
+ const tracked = real.has(url) || unrestorable.some((each) => each.url === url);
49
+ if (inTestFile() && !tracked) {
50
+ if (missing !== undefined) unrestorable.push({ file, url, reason: missing });
51
+ else {
52
+ try {
53
+ // why: `require` is the one synchronous load; the snapshot must exist before the fake does.
54
+ real.set(url, { ...(require(url) as Record<string, unknown>) });
55
+ } catch (reason) {
56
+ unrestorable.push({ file, url, reason });
57
+ }
58
+ }
59
+ }
60
+ // The resolved path, so the mock and the boundary's restore name one module.
61
+ return original(url, factory);
62
+ };
63
+
64
+ for (const host of [mock, vi, jest] as const) {
65
+ Object.defineProperty(host, host === mock ? 'module' : 'mock', {
66
+ value: wrapper,
67
+ writable: true,
68
+ configurable: true,
69
+ });
70
+ }
71
+
72
+ onFileBoundary(() => {
73
+ for (const [url, namespace] of real) original(url, () => namespace);
74
+ real.clear();
75
+ inherited = [...inherited, ...unrestorable.splice(0)];
76
+ });
77
+
78
+ // The boundary's hooks cannot fail a run, so the next file's first test carries the verdict.
79
+ beforeEach(() => {
80
+ if (inherited.length === 0) return;
81
+ const mocks = inherited;
82
+ inherited = [];
83
+ throw new ModuleMockUnrestorableError(mocks);
84
+ });
85
+ }
@@ -0,0 +1,28 @@
1
+ // `X_TEST_MODULE_MOCK_UNRESTORABLE`'s message: which file mocked which module whose real source
2
+ // could not be loaded for its snapshot. The CODE is registered in `errors.ts`; the boundary that
3
+ // raises it is `module-mock-boundary.ts`. Every value here is uncontrolled, so it is rendered.
4
+
5
+ import { renderCauseValue, renderThrowable, UltimateError } from '@ultimat3/core';
6
+
7
+ export interface UnrestorableMock {
8
+ /** The test file that called `mock.module`. */
9
+ readonly file: string;
10
+ /** The module it mocked, as resolved (or as written, when it did not resolve). */
11
+ readonly url: string;
12
+ /** Why the real module could not be snapshotted. */
13
+ readonly reason: unknown;
14
+ }
15
+
16
+ export class ModuleMockUnrestorableError extends UltimateError {
17
+ constructor(mocks: readonly UnrestorableMock[]) {
18
+ const each = mocks.map(
19
+ (mock) =>
20
+ `${renderCauseValue(mock.file)} mocked ${renderCauseValue(mock.url)}, whose real module failed to load (${renderThrowable(mock.reason)})`,
21
+ );
22
+ super({
23
+ code: 'X_TEST_MODULE_MOCK_UNRESTORABLE',
24
+ cause: `${each.join('; ')} — so nothing could be put back, and this file on the same worker saw the fake`,
25
+ fix: 'x test unit --isolate # or hand the mocked dependency to the code under test as a parameter, so no module is mocked',
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,164 @@
1
+ // The plan oracle: one `explain (format json)` plan, taken with sequential scans, bitmap scans and
2
+ // sorts disabled, in; the nodes no index could replace, out. Under those settings the planner picks
3
+ // a scan or a sort only when nothing else answers, so the verdict reads the schema, not the rows.
4
+ // Pure: JSON in, verdicts out (plan 101, 40-next-query-plans).
5
+
6
+ /** What a red node is: a whole-table read, or rows sorted for a limited read. */
7
+ export type PlanVerdictCode = 'X_QUERY_UNINDEXED' | 'X_QUERY_SORT_UNSERVED';
8
+
9
+ export interface PlanVerdict {
10
+ readonly code: PlanVerdictCode;
11
+ /** `Seq Scan`, `Index Scan`, `Index Only Scan`, `Sort`. */
12
+ readonly node: string;
13
+ /** The physical table the node reads, or the one under the sort. */
14
+ readonly table: string;
15
+ /** Physical columns the statement compares by equality on that table, in the order seen. */
16
+ readonly equality: readonly string[];
17
+ /** Physical columns it compares any other way (a range, a pattern). */
18
+ readonly range: readonly string[];
19
+ /** The sort's columns on that table, in order. Empty for a scan. */
20
+ readonly order: readonly string[];
21
+ }
22
+
23
+ interface PlanNode {
24
+ readonly type: string;
25
+ readonly table: string | undefined;
26
+ readonly alias: string | undefined;
27
+ readonly conditions: readonly string[];
28
+ readonly sortKey: readonly string[];
29
+ readonly indexCond: boolean;
30
+ readonly filter: boolean;
31
+ readonly children: readonly PlanNode[];
32
+ }
33
+
34
+ const SCANS: ReadonlySet<string> = new Set(['Seq Scan', 'Index Scan', 'Index Only Scan']);
35
+ /** Conditions a join or a recheck carries for the scans beneath it. */
36
+ const CONDITION_KEYS = ['Index Cond', 'Filter', 'Hash Cond', 'Merge Cond', 'Join Filter'] as const;
37
+
38
+ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
39
+ typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ ? (value as Readonly<Record<string, unknown>>)
41
+ : undefined;
42
+
43
+ const text = (value: unknown): string | undefined =>
44
+ typeof value === 'string' ? value : undefined;
45
+
46
+ function nodeOf(value: unknown): PlanNode | undefined {
47
+ const raw = record(value);
48
+ const type = text(raw?.['Node Type']);
49
+ if (raw === undefined || type === undefined) return undefined;
50
+ const plans = Array.isArray(raw['Plans']) ? raw['Plans'] : [];
51
+ const sortKey = Array.isArray(raw['Sort Key'])
52
+ ? raw['Sort Key'].flatMap((k) => text(k) ?? [])
53
+ : [];
54
+ return {
55
+ type,
56
+ table: text(raw['Relation Name']),
57
+ alias: text(raw['Alias']),
58
+ conditions: CONDITION_KEYS.flatMap((key) => text(raw[key]) ?? []),
59
+ sortKey,
60
+ indexCond: text(raw['Index Cond']) !== undefined,
61
+ filter: text(raw['Filter']) !== undefined,
62
+ children: plans.flatMap((child) => nodeOf(child) ?? []),
63
+ };
64
+ }
65
+
66
+ /** The root node of `explain (format json)`'s answer: `[{ "Plan": { … } }]`, or the bare plan. */
67
+ export function planRoot(answer: unknown): unknown {
68
+ const parsed = typeof answer === 'string' ? (JSON.parse(answer) as unknown) : answer;
69
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
70
+ return record(first)?.['Plan'] ?? first;
71
+ }
72
+
73
+ const IDENT = /(?:"?([A-Za-z_][\w$]*)"?\.)?"?([A-Za-z_][\w$]*)"?/g;
74
+ const EQUALITY = /(?<![<>!])=(?!>)/;
75
+
76
+ /**
77
+ * The columns of `scan` a condition names, split by how they are compared. A conjunct qualified by
78
+ * another alias is a join's other side; an unqualified name counts when the table has the column.
79
+ */
80
+ function columnsIn(
81
+ conditions: readonly string[],
82
+ scan: PlanNode,
83
+ columns: ReadonlySet<string>,
84
+ ): { readonly equality: string[]; readonly range: string[] } {
85
+ const equality: string[] = [];
86
+ const range: string[] = [];
87
+ for (const condition of conditions) {
88
+ for (const conjunct of condition.split(/\bAND\b/)) {
89
+ const into = EQUALITY.test(conjunct) ? equality : range;
90
+ for (const [, qualifier, name] of conjunct.replace(/'[^']*'/g, '').matchAll(IDENT)) {
91
+ if (name === undefined || !columns.has(name)) continue;
92
+ if (qualifier !== undefined && qualifier !== scan.alias && qualifier !== scan.table)
93
+ continue;
94
+ if (!equality.includes(name) && !range.includes(name)) into.push(name);
95
+ }
96
+ }
97
+ }
98
+ return { equality, range: range.filter((name) => !equality.includes(name)) };
99
+ }
100
+
101
+ /** The first scan beneath `node` (the node itself included) that reads one of `tables`. */
102
+ const scanBeneath = (node: PlanNode, tables: PlanTableColumns): PlanNode | undefined =>
103
+ node.table !== undefined && SCANS.has(node.type) && tables.has(node.table)
104
+ ? node
105
+ : node.children.reduce<PlanNode | undefined>(
106
+ (found, child) => found ?? scanBeneath(child, tables),
107
+ undefined,
108
+ );
109
+
110
+ /** Physical table → its physical columns: only these tables are judged. */
111
+ export type PlanTableColumns = ReadonlyMap<string, ReadonlySet<string>>;
112
+
113
+ /**
114
+ * Every red node of one plan. `Seq Scan` on a judged table; an index scan with no `Index Cond`,
115
+ * unless a `Limit` above it stops it early and no `Filter` makes it walk on; a `Sort` under a
116
+ * `Limit`. A join's conditions are handed down, so the scan of its inner side names its key.
117
+ */
118
+ export function judgePlan(answer: unknown, tables: PlanTableColumns): readonly PlanVerdict[] {
119
+ const root = nodeOf(planRoot(answer));
120
+ if (root === undefined) return [];
121
+ const verdicts: PlanVerdict[] = [];
122
+ const walk = (node: PlanNode, limited: boolean, inherited: readonly string[]): void => {
123
+ const conditions = [...inherited, ...node.conditions];
124
+ const columns = node.table === undefined ? undefined : tables.get(node.table);
125
+ if (node.table !== undefined && columns !== undefined && SCANS.has(node.type)) {
126
+ const unbounded = node.type === 'Seq Scan' || (!node.indexCond && (!limited || node.filter));
127
+ if (unbounded) {
128
+ const found = columnsIn(conditions, node, columns);
129
+ verdicts.push({
130
+ code: 'X_QUERY_UNINDEXED',
131
+ node: node.type,
132
+ table: node.table,
133
+ ...found,
134
+ order: [],
135
+ });
136
+ }
137
+ }
138
+ if (node.type === 'Sort' && limited) {
139
+ const scan = scanBeneath(node, tables);
140
+ const scanColumns = scan?.table === undefined ? undefined : tables.get(scan.table);
141
+ if (scan?.table !== undefined && scanColumns !== undefined) {
142
+ const found = columnsIn([...conditions, ...scan.conditions], scan, scanColumns);
143
+ const order = columnsIn(node.sortKey, scan, scanColumns);
144
+ verdicts.push({
145
+ code: 'X_QUERY_SORT_UNSERVED',
146
+ node: node.type,
147
+ table: scan.table,
148
+ equality: found.equality,
149
+ range: [],
150
+ order: [...order.equality, ...order.range],
151
+ });
152
+ }
153
+ }
154
+ const below = limited || node.type === 'Limit';
155
+ // A join's conditions name both sides: each child keeps only the conjuncts about itself.
156
+ const handed =
157
+ node.type.includes('Join') || node.type === 'Nested Loop' || node.type === 'Hash'
158
+ ? conditions
159
+ : [];
160
+ for (const child of node.children) walk(child, below, handed);
161
+ };
162
+ walk(root, false, []);
163
+ return verdicts;
164
+ }
@@ -0,0 +1,123 @@
1
+ // From the oracle's red nodes to what an author pastes: the entity, and the `indexes: [{ on }]`
2
+ // entry by property. Pure. Also where a red node is argued away by the declaration itself: a
3
+ // lookup that pins a unique key reads one row whatever the planner walked to find it.
4
+
5
+ import type { PlanVerdict, PlanVerdictCode } from './plan-oracle';
6
+ import type { SeedEntity } from './seed-graph-shape';
7
+
8
+ export interface PlanFinding {
9
+ readonly code: PlanVerdictCode;
10
+ /** The statement as sent, whitespace collapsed, parameters still `$n`. */
11
+ readonly statement: string;
12
+ /** `Seq Scan`, `Index Scan`, `Sort`. */
13
+ readonly node: string;
14
+ readonly entity: string;
15
+ readonly table: string;
16
+ /** The `indexes: [{ on }]` entry, by property: tenant, equality, order, range. */
17
+ readonly index: readonly string[];
18
+ }
19
+
20
+ /** Every column set the table holds unique: the primary key, unique columns, total unique rules. */
21
+ function uniqueKeys(entity: SeedEntity): readonly (readonly string[])[] {
22
+ const description = entity.$describe();
23
+ return [
24
+ description.primaryKey,
25
+ ...description.columns.filter((column) => column.unique).map((column) => [column.column]),
26
+ ...description.indexes
27
+ .filter((index) => index.unique && index.where === null)
28
+ .map((index) => index.columns),
29
+ ...description.invariants
30
+ .filter((rule) => rule.kind === 'unique' && rule.where === null)
31
+ .map((rule) => rule.columns),
32
+ ].filter((key) => key.length > 0);
33
+ }
34
+
35
+ const IDENTIFIER = /"([A-Za-z_][\w$]*)"|\b([A-Za-z_][\w$]*)\b/g;
36
+
37
+ /**
38
+ * A partial index the planner may use once it sees the bound value: its predicate compares only
39
+ * columns the statement compares by equality (`where "state" = 'pending'` against `state = $1`),
40
+ * and every column it holds is one the statement filters or orders by, the first among them. A
41
+ * generic plan cannot match a predicate to a parameter, so the oracle sees a scan; Postgres plans
42
+ * such a statement per value and keeps the index. Credited, never re-proven.
43
+ */
44
+ function servedByPartial(entity: SeedEntity, verdict: PlanVerdict, order: readonly string[]) {
45
+ const description = entity.$describe();
46
+ const columns = new Set(description.columns.map((column) => column.column));
47
+ const used = new Set([...verdict.equality, ...order, ...verdict.range]);
48
+ return description.indexes.some((index) => {
49
+ if (index.where === null) return false;
50
+ const predicate = [...index.where.matchAll(IDENTIFIER)].flatMap(([, quoted, bare]) => {
51
+ const name = quoted ?? bare;
52
+ return name !== undefined && columns.has(name) ? [name] : [];
53
+ });
54
+ const lead = index.columns[0];
55
+ return (
56
+ predicate.length > 0 &&
57
+ predicate.every((column) => verdict.equality.includes(column)) &&
58
+ index.columns.every((column) => used.has(column)) &&
59
+ lead !== undefined &&
60
+ (verdict.equality.includes(lead) || order[0] === lead)
61
+ );
62
+ });
63
+ }
64
+
65
+ const ORDER_BY = /\border by\s+(.+?)(?:\s+limit\b|\s+offset\b|\s+for\b|$)/gi;
66
+
67
+ /**
68
+ * The outermost `order by`'s columns, read off the text: an index walk avoids a sort and carries no
69
+ * sort key of its own, yet the index that ends the walk has to hold the order too.
70
+ */
71
+ function orderOf(statement: string, columns: ReadonlySet<string>): readonly string[] {
72
+ const clauses = [...statement.matchAll(ORDER_BY)];
73
+ const last = clauses[clauses.length - 1]?.[1] ?? '';
74
+ return [...last.matchAll(IDENTIFIER)].flatMap(([, quoted, bare]) => {
75
+ const name = quoted ?? bare;
76
+ return name !== undefined && columns.has(name) ? [name] : [];
77
+ });
78
+ }
79
+
80
+ /**
81
+ * The findings one statement's verdicts make. A verdict whose equality columns pin a unique key is
82
+ * dropped: one row at most. A table's sort verdict makes its scan verdict redundant — the index it
83
+ * names leads with the same equality columns. The tenant column leads the index only when the
84
+ * statement compares it: a read across tenants is served by no index that leads with one.
85
+ */
86
+ export function planFindings(
87
+ statement: string,
88
+ verdicts: readonly PlanVerdict[],
89
+ byTable: ReadonlyMap<string, SeedEntity>,
90
+ ): readonly PlanFinding[] {
91
+ const sorted = new Set(
92
+ verdicts.filter((v) => v.code === 'X_QUERY_SORT_UNSERVED').map((v) => v.table),
93
+ );
94
+ return verdicts.flatMap((verdict): PlanFinding[] => {
95
+ const entity = byTable.get(verdict.table);
96
+ if (entity === undefined) return [];
97
+ if (verdict.code === 'X_QUERY_UNINDEXED' && sorted.has(verdict.table)) return [];
98
+ const pinned = uniqueKeys(entity).some((key) =>
99
+ key.every((column) => verdict.equality.includes(column)),
100
+ );
101
+ if (pinned) return [];
102
+ const description = entity.$describe();
103
+ const byColumn = new Map(description.columns.map((c) => [c.column, c.property]));
104
+ const walked = verdict.code === 'X_QUERY_UNINDEXED' && verdict.node !== 'Seq Scan';
105
+ const order = walked
106
+ ? orderOf(statement, new Set(description.columns.map((c) => c.column)))
107
+ : verdict.order;
108
+ if (servedByPartial(entity, verdict, order)) return [];
109
+ const tenant = description.columns.find((c) => c.property === entity.$tenantColumn)?.column;
110
+ const lead = tenant !== undefined && verdict.equality.includes(tenant) ? [tenant] : [];
111
+ const columns = [...new Set([...lead, ...verdict.equality, ...order, ...verdict.range])];
112
+ return [
113
+ {
114
+ code: verdict.code,
115
+ statement,
116
+ node: verdict.node,
117
+ entity: entity.$name,
118
+ table: verdict.table,
119
+ index: columns.flatMap((column) => byColumn.get(column) ?? []),
120
+ },
121
+ ];
122
+ });
123
+ }