@ultimat3/testing 30.3.0 → 30.5.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 +4 -4
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/define-exercise.ts +11 -0
- package/src/errors.ts +6 -2
- package/src/exercise-crash.ts +1 -0
- package/src/exercise-defect.ts +71 -0
- package/src/exercise-job-tenant.ts +29 -2
- package/src/exercise-report.ts +13 -3
- package/src/exercise-setup.ts +68 -0
- package/src/exercise-subjects.ts +19 -5
- package/src/exercise.ts +21 -4
- package/src/factory-registry.ts +14 -2
- package/src/index.ts +4 -0
- package/src/job-crash-proof.ts +28 -4
- package/src/job-sim-effects.ts +7 -2
- package/src/job-sim-errors.ts +36 -5
- package/src/job-sim-world.ts +4 -1
- package/src/mock-route.ts +21 -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/sealed-dns.ts +86 -0
- package/src/sealed-network.ts +48 -7
- package/src/seed-graph-order.ts +9 -5
- package/src/seed-graph-pass.ts +31 -2
- package/src/seed-graph-patterns.ts +51 -0
- package/src/seed-graph-values.ts +72 -8
- package/src/seed-graph.ts +7 -0
- package/src/seed-pattern-sample.ts +216 -0
- package/src/subject-proof.ts +3 -1
package/src/job-crash-proof.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface CrashDivergence {
|
|
|
33
33
|
readonly effect: string;
|
|
34
34
|
readonly clean: number;
|
|
35
35
|
readonly killed: number;
|
|
36
|
+
/** The step whose body made the effect, when every occurrence was made inside that one step. */
|
|
37
|
+
readonly inside?: string;
|
|
36
38
|
readonly error: JobCrashDivergedError;
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -155,8 +157,22 @@ async function runOnce<I>(
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
+
* The one step whose body made every occurrence of `key` in both runs, or `undefined` when any
|
|
161
|
+
* occurrence was made between steps (or in two different steps).
|
|
162
|
+
*/
|
|
163
|
+
function insideOf(key: string, runs: readonly Ran[]): string | undefined {
|
|
164
|
+
const steps = new Set(
|
|
165
|
+
runs.flatMap((run) => run.effects.filter((one) => one.key === key).map((one) => one.inside)),
|
|
166
|
+
);
|
|
167
|
+
const [only] = steps;
|
|
168
|
+
return steps.size === 1 ? only : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Effects the killed run made more often than the reference — or less often, when both ended alike,
|
|
173
|
+
* neither unwound and the effect is made between steps. Inside a step body that did not complete,
|
|
174
|
+
* fewer is a kill that spent one of the step's attempts (the queue's lease accounting), not a call
|
|
175
|
+
* the crash skipped. An unwound run is also held to the reference's compensators: each once, last
|
|
160
176
|
* in first out, for exactly the forward steps that completed in it.
|
|
161
177
|
*/
|
|
162
178
|
function compare(reference: Ran, killed: Ran): readonly [string, number, number][] {
|
|
@@ -169,7 +185,9 @@ function compare(reference: Ran, killed: Ran): readonly [string, number, number]
|
|
|
169
185
|
for (const key of keys) {
|
|
170
186
|
const c = before.get(key) ?? 0;
|
|
171
187
|
const k = after.get(key) ?? 0;
|
|
172
|
-
if (k > c || (k < c && alike
|
|
188
|
+
if (k > c || (k < c && alike && insideOf(key, [reference, killed]) === undefined)) {
|
|
189
|
+
out.push([key, c, k]);
|
|
190
|
+
}
|
|
173
191
|
}
|
|
174
192
|
if (unwound) {
|
|
175
193
|
const expected = reference.compensated.filter((name) =>
|
|
@@ -222,19 +240,25 @@ export async function crashProof<I>(
|
|
|
222
240
|
: clean;
|
|
223
241
|
for (const [effect, c, k] of compare(reference, killed)) {
|
|
224
242
|
const after = afterOf(effect);
|
|
243
|
+
const inside = insideOf(effect, [reference, killed]);
|
|
244
|
+
const where = {
|
|
245
|
+
...(after === undefined ? {} : { after }),
|
|
246
|
+
...(inside === undefined ? {} : { inside }),
|
|
247
|
+
};
|
|
225
248
|
divergences.push({
|
|
226
249
|
boundary: label,
|
|
227
250
|
mode,
|
|
228
251
|
effect,
|
|
229
252
|
clean: c,
|
|
230
253
|
killed: k,
|
|
254
|
+
...(inside === undefined ? {} : { inside }),
|
|
231
255
|
error: new JobCrashDivergedError({
|
|
232
256
|
job: handle.name,
|
|
233
257
|
boundary: label,
|
|
234
258
|
effect,
|
|
235
259
|
clean: c,
|
|
236
260
|
killed: k,
|
|
237
|
-
...
|
|
261
|
+
...where,
|
|
238
262
|
}),
|
|
239
263
|
});
|
|
240
264
|
}
|
package/src/job-sim-effects.ts
CHANGED
|
@@ -18,15 +18,17 @@ export interface SimEffect {
|
|
|
18
18
|
readonly key: string;
|
|
19
19
|
/** The worker whose body made it; absent for the harness. */
|
|
20
20
|
readonly worker?: string;
|
|
21
|
-
/** The last step that worker's run had
|
|
21
|
+
/** The last step that worker's run had COMPLETED when the effect happened. */
|
|
22
22
|
readonly after?: string;
|
|
23
|
+
/** The step whose body made it (`@ultimat3/jobs`' `currentStep()`); absent between steps. */
|
|
24
|
+
readonly inside?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface EffectLog extends AsyncDisposable {
|
|
26
28
|
/** The worker a call in flight belongs to: what `run` scoped it to. */
|
|
27
29
|
readonly scope: AsyncContext<string>;
|
|
28
30
|
record(key: string): void;
|
|
29
|
-
/** A worker's run
|
|
31
|
+
/** A worker's run completed this step — what the next effect it makes is placed after. */
|
|
30
32
|
stepWritten(worker: string, step: string): void;
|
|
31
33
|
effects(): readonly SimEffect[];
|
|
32
34
|
clear(): void;
|
|
@@ -52,6 +54,7 @@ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): stri
|
|
|
52
54
|
|
|
53
55
|
export async function openEffectLog(options: EffectLogOptions): Promise<EffectLog> {
|
|
54
56
|
const { setRowObserver } = await import('@ultimat3/entity');
|
|
57
|
+
const { currentStep } = await import('@ultimat3/jobs');
|
|
55
58
|
const mail = await import('@ultimat3/mail');
|
|
56
59
|
const scope = asyncContext<string>('the job simulation');
|
|
57
60
|
const log: SimEffect[] = [];
|
|
@@ -61,10 +64,12 @@ export async function openEffectLog(options: EffectLogOptions): Promise<EffectLo
|
|
|
61
64
|
const worker = scope.get();
|
|
62
65
|
if (worker !== undefined && options.isDead(worker)) return;
|
|
63
66
|
const after = worker === undefined ? undefined : lastStep.get(worker);
|
|
67
|
+
const inside = currentStep();
|
|
64
68
|
log.push({
|
|
65
69
|
key,
|
|
66
70
|
...(worker === undefined ? {} : { worker }),
|
|
67
71
|
...(after === undefined ? {} : { after }),
|
|
72
|
+
...(inside === undefined ? {} : { inside }),
|
|
68
73
|
});
|
|
69
74
|
};
|
|
70
75
|
|
package/src/job-sim-errors.ts
CHANGED
|
@@ -17,18 +17,49 @@ export interface CrashDivergedInput {
|
|
|
17
17
|
readonly effect: string;
|
|
18
18
|
readonly clean: number;
|
|
19
19
|
readonly killed: number;
|
|
20
|
-
/** The last step
|
|
20
|
+
/** The last step completed before the effect, in the clean run. */
|
|
21
21
|
readonly after?: string;
|
|
22
|
+
/** The step whose body made the effect, when it was made inside one. */
|
|
23
|
+
readonly inside?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Where an effect was made, as the crash proof's two reporters phrase it — this error and the CLI's
|
|
28
|
+
* finding (`@ultimat3/cli`'s `exercise-findings.ts`) — so the two cannot name different places.
|
|
29
|
+
* `remedy` is prose for a `fix:`'s comment, after the call it pastes.
|
|
30
|
+
*/
|
|
31
|
+
export function crashEffectPlace(where: {
|
|
32
|
+
readonly after?: string | undefined;
|
|
33
|
+
readonly inside?: string | undefined;
|
|
34
|
+
}): { readonly place: string; readonly remedy: string } {
|
|
35
|
+
if (where.inside !== undefined) {
|
|
36
|
+
const step = renderCauseValue(where.inside);
|
|
37
|
+
return {
|
|
38
|
+
place: `it runs inside step ${step}, whose body runs again until the step completes`,
|
|
39
|
+
remedy: `make the body of step ${step} idempotent: key its external call on \`\${jobId}:${where.inside}\`, or move the effect into a step of its own`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const at =
|
|
43
|
+
where.after === undefined
|
|
44
|
+
? 'before the first step'
|
|
45
|
+
: `after step ${renderCauseValue(where.after)}`;
|
|
46
|
+
return {
|
|
47
|
+
place: `it runs outside every step, ${at}`,
|
|
48
|
+
remedy: `wrap the effect made ${at} in step.run('<name>', async () => …); key an external call on \`\${jobId}:<step>\``,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const howOften = (killed: number, clean: number): string =>
|
|
53
|
+
killed > clean ? 'so the retry makes it again' : 'so the crash skipped it';
|
|
54
|
+
|
|
24
55
|
export class JobCrashDivergedError extends UltimateError {
|
|
25
56
|
constructor(input: CrashDivergedInput) {
|
|
26
|
-
const
|
|
27
|
-
|
|
57
|
+
const { place, remedy } = crashEffectPlace(input);
|
|
58
|
+
const name = input.inside ?? `${input.boundary}-effect`;
|
|
28
59
|
super({
|
|
29
60
|
code: 'X_JOB_CRASH_DIVERGED',
|
|
30
|
-
cause: `job ${renderCauseValue(input.job)} killed at ${renderCauseValue(input.boundary)} made ${renderCauseValue(input.effect)} ${input.killed}× against ${input.clean}× in a clean run:
|
|
31
|
-
fix: `step.run(${renderFixLiteral(
|
|
61
|
+
cause: `job ${renderCauseValue(input.job)} killed at ${renderCauseValue(input.boundary)} made ${renderCauseValue(input.effect)} ${input.killed}× against ${input.clean}× in a clean run: ${place}, ${howOften(input.killed, input.clean)}`,
|
|
62
|
+
fix: `step.run(${renderFixLiteral(name, '<name>')}, async () => …) # in job(${renderFixLiteral(input.job, '<job>')}), for ${renderFixLiteral(input.effect, '<effect>')}: ${remedy}; then: x test job --filter <the test file>`,
|
|
32
63
|
meta: { ...input },
|
|
33
64
|
});
|
|
34
65
|
}
|
package/src/job-sim-world.ts
CHANGED
|
@@ -86,7 +86,10 @@ export async function openSimWorld(options: SimWorldOptions): Promise<SimWorld>
|
|
|
86
86
|
decide: (op) => options.decide(op),
|
|
87
87
|
landed: (op, answer) => {
|
|
88
88
|
remember(answer);
|
|
89
|
-
|
|
89
|
+
// Completed only: a `failed` or `started` record is not a step the body has moved past.
|
|
90
|
+
if (op.call === 'put' && op.step !== undefined && op.status === 'completed') {
|
|
91
|
+
log?.stepWritten(op.worker, op.step);
|
|
92
|
+
}
|
|
90
93
|
if ((op.call === 'enqueue' || op.call === 'enqueueMany') && log !== undefined) {
|
|
91
94
|
const results = (Array.isArray(answer) ? answer : [answer]) as readonly EnqueueResult[];
|
|
92
95
|
results.forEach((result, i) => {
|
package/src/mock-route.ts
CHANGED
|
@@ -79,3 +79,24 @@ export const routeMatches = (
|
|
|
79
79
|
const named = namedUrl(call.url, call.headers);
|
|
80
80
|
return named !== undefined && urlMatches(route.match, named);
|
|
81
81
|
};
|
|
82
|
+
|
|
83
|
+
/** A RegExp's literal text: a `.` in a host is `\.` in its source. */
|
|
84
|
+
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether `route` answers some URL on `hostname` — what makes the sealed resolver answer the name.
|
|
88
|
+
* A string names its host outright; a RegExp when it matches the host's origin or spells the host
|
|
89
|
+
* in its source (`/api\.vendor\.example\/v1/`), since no reader can enumerate what a pattern matches.
|
|
90
|
+
*/
|
|
91
|
+
export const routeNamesHost = (route: MockRoute, hostname: string): boolean => {
|
|
92
|
+
const host = hostname.toLowerCase();
|
|
93
|
+
if (route.match instanceof RegExp) {
|
|
94
|
+
const pattern = route.match;
|
|
95
|
+
const origins = [`https://${host}/`, `http://${host}/`];
|
|
96
|
+
if (origins.some((origin) => urlMatches(pattern, origin))) return true;
|
|
97
|
+
const source = pattern.source.toLowerCase();
|
|
98
|
+
return source.includes(escaped(host)) || source.includes(host);
|
|
99
|
+
}
|
|
100
|
+
const literal = route.match.endsWith('*') ? route.match.slice(0, -1) : route.match;
|
|
101
|
+
return URL.canParse(literal) && new URL(literal).hostname.toLowerCase() === host;
|
|
102
|
+
};
|
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
|
*/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The resolver half of the sealed network: `Bun.dns.lookup`, `dns.lookup` and `dns.promises.lookup`.
|
|
2
|
+
// Only the patching lives here; WHAT a name resolves to is `sealed-network.ts`'s one gate, handed in,
|
|
3
|
+
// so a host `fetch` would answer by mock resolves and a host it would refuse does not. A named ESM
|
|
4
|
+
// import (`import { lookup } from 'node:dns'`) binds before the seal and is NOT covered (measured).
|
|
5
|
+
|
|
6
|
+
// why: `dns.lookup` and `dns.promises.lookup` are what an app's own resolver calls; Bun ships both.
|
|
7
|
+
import nodeDns from 'node:dns';
|
|
8
|
+
|
|
9
|
+
/** One lookup's verdict: the addresses to answer, a refusal to raise, or `real` for the resolver. */
|
|
10
|
+
export type DnsVerdict =
|
|
11
|
+
| { readonly answer: readonly string[] }
|
|
12
|
+
| { readonly refused: Error }
|
|
13
|
+
| { readonly real: true };
|
|
14
|
+
|
|
15
|
+
export type DnsGate = (hostname: string) => DnsVerdict;
|
|
16
|
+
|
|
17
|
+
interface LookupEntry {
|
|
18
|
+
readonly address: string;
|
|
19
|
+
readonly family: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const familyOf = (address: string): number => (address.includes(':') ? 6 : 4);
|
|
23
|
+
|
|
24
|
+
const entriesOf = (addresses: readonly string[]): LookupEntry[] =>
|
|
25
|
+
addresses.map((address) => ({ address, family: familyOf(address) }));
|
|
26
|
+
|
|
27
|
+
type NodeCallback = (error: unknown, address?: unknown, family?: number) => void;
|
|
28
|
+
|
|
29
|
+
/** Node's `(hostname, options?, callback)`; `options.all` answers the list, a number is a family. */
|
|
30
|
+
const wantsAll = (options: unknown): boolean =>
|
|
31
|
+
typeof options === 'object' && options !== null && 'all' in options && options.all === true;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Patch the three resolvers and return the uninstaller, which puts back exactly what was there.
|
|
35
|
+
* Assigned through `Reflect.set`: each is a writable data property the types declare a function.
|
|
36
|
+
*/
|
|
37
|
+
export function installDnsSeal(gate: DnsGate): () => void {
|
|
38
|
+
const originalBun = Reflect.get(Bun.dns, 'lookup') as typeof Bun.dns.lookup;
|
|
39
|
+
const originalNode = Reflect.get(nodeDns, 'lookup') as (...args: unknown[]) => void;
|
|
40
|
+
const originalPromises = Reflect.get(nodeDns.promises, 'lookup') as (
|
|
41
|
+
...args: unknown[]
|
|
42
|
+
) => Promise<unknown>;
|
|
43
|
+
|
|
44
|
+
const sealedBun = async (hostname: string, options?: unknown): Promise<unknown> => {
|
|
45
|
+
const verdict = gate(hostname);
|
|
46
|
+
if ('refused' in verdict) throw verdict.refused;
|
|
47
|
+
if ('answer' in verdict) return entriesOf(verdict.answer).map((one) => ({ ...one, ttl: 0 }));
|
|
48
|
+
return Reflect.apply(originalBun, Bun.dns, [hostname, options]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const sealedNode = (hostname: string, ...rest: unknown[]): void => {
|
|
52
|
+
const verdict = gate(hostname);
|
|
53
|
+
if ('real' in verdict) {
|
|
54
|
+
Reflect.apply(originalNode, nodeDns, [hostname, ...rest]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const callback = rest.findLast((one) => typeof one === 'function') as NodeCallback | undefined;
|
|
58
|
+
const options = rest.length > 1 ? rest[0] : undefined;
|
|
59
|
+
// A real lookup answers on a later tick, and a caller may rely on it: never synchronously.
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
if ('refused' in verdict) return callback?.(verdict.refused);
|
|
62
|
+
const entries = entriesOf(verdict.answer);
|
|
63
|
+
if (wantsAll(options)) return callback?.(null, entries);
|
|
64
|
+
return callback?.(null, entries[0]?.address, entries[0]?.family);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const sealedPromises = async (hostname: string, options?: unknown): Promise<unknown> => {
|
|
69
|
+
const verdict = gate(hostname);
|
|
70
|
+
if ('refused' in verdict) throw verdict.refused;
|
|
71
|
+
if ('real' in verdict) {
|
|
72
|
+
return Reflect.apply(originalPromises, nodeDns.promises, [hostname, options]);
|
|
73
|
+
}
|
|
74
|
+
const entries = entriesOf(verdict.answer);
|
|
75
|
+
return wantsAll(options) ? entries : entries[0];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
Reflect.set(Bun.dns, 'lookup', sealedBun);
|
|
79
|
+
Reflect.set(nodeDns, 'lookup', sealedNode);
|
|
80
|
+
Reflect.set(nodeDns.promises, 'lookup', sealedPromises);
|
|
81
|
+
return () => {
|
|
82
|
+
Reflect.set(Bun.dns, 'lookup', originalBun);
|
|
83
|
+
Reflect.set(nodeDns, 'lookup', originalNode);
|
|
84
|
+
Reflect.set(nodeDns.promises, 'lookup', originalPromises);
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/sealed-network.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// Sealed network
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `fetch` here, `WebSocket` and `Bun.connect` through `sealed-sockets.ts`, all behind one gate.
|
|
1
|
+
// Sealed network: egress a test did not mock or allow fails with the URL and the line that fixes
|
|
2
|
+
// it, so nothing reaches the internet unreproducibly. `fetch` here, `WebSocket`/`Bun.connect` via
|
|
3
|
+
// `sealed-sockets.ts`, name resolution via `sealed-dns.ts` — one gate, one set of mocks and allows.
|
|
5
4
|
|
|
6
5
|
import { isSelfOrigin, peerAddressClass } from '@ultimat3/core';
|
|
7
6
|
import { NetworkOfflineError, NetworkRaceError, NetworkSealedError } from './errors';
|
|
8
7
|
import type { MockHandler, MockRoute } from './mock-route';
|
|
9
|
-
import { mockRoute, routeMatches } from './mock-route';
|
|
8
|
+
import { mockRoute, routeMatches, routeNamesHost } from './mock-route';
|
|
10
9
|
import type { LoggedRequest } from './request-log';
|
|
11
10
|
import { hasStreamBody, headersOf, isOpenEnded, readBody, requestProbe } from './request-log';
|
|
11
|
+
import type { DnsVerdict } from './sealed-dns';
|
|
12
|
+
import { installDnsSeal } from './sealed-dns';
|
|
12
13
|
import { installSocketSeal } from './sealed-sockets';
|
|
13
14
|
|
|
14
15
|
export type FetchLike = typeof globalThis.fetch;
|
|
@@ -26,6 +27,8 @@ interface SealState {
|
|
|
26
27
|
original: FetchLike | undefined;
|
|
27
28
|
/** `installSocketSeal`'s undo, held beside `original` so one unseal restores all three dials. */
|
|
28
29
|
restoreSockets: (() => void) | undefined;
|
|
30
|
+
/** `installDnsSeal`'s undo, likewise. */
|
|
31
|
+
restoreDns: (() => void) | undefined;
|
|
29
32
|
network: NetworkState;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -35,9 +38,17 @@ const state: SealState = {
|
|
|
35
38
|
log: [],
|
|
36
39
|
original: undefined,
|
|
37
40
|
restoreSockets: undefined,
|
|
41
|
+
restoreDns: undefined,
|
|
38
42
|
network: 'online',
|
|
39
43
|
};
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* What a mocked host resolves to: one `public` address (`classifyAddress`), so an SSRF screen
|
|
47
|
+
* passes it, and never a host network — a screen refuses those. Never dialled: a dial pinned to it
|
|
48
|
+
* carries the name in `Host`, which is how the mock answers it (`mock-route.ts`'s `namedUrl`).
|
|
49
|
+
*/
|
|
50
|
+
export const MOCKED_HOST_ADDRESS = '11.11.11.11';
|
|
51
|
+
|
|
41
52
|
const urlOf = (input: RequestInfo | URL): string => {
|
|
42
53
|
if (typeof input === 'string') return input;
|
|
43
54
|
if (input instanceof URL) return input.toString();
|
|
@@ -82,12 +93,40 @@ const socketRefusal = (url: string, method: string): Error | undefined => {
|
|
|
82
93
|
return new NetworkSealedError({ url, method, allowed: [...state.allowed], transport: 'socket' });
|
|
83
94
|
};
|
|
84
95
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
96
|
+
/** A host the allow-list names, whatever port it was allowed on: a lookup carries no port. */
|
|
97
|
+
const isAllowedName = (hostname: string): boolean =>
|
|
98
|
+
[...state.allowed].some((host) => safeHost(`https://${host}`)?.split(':')[0] === hostname);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The resolver's gate, in `fetch`'s order: offline refuses every name, this machine and an allowed
|
|
102
|
+
* host resolve for real, a host a mock answers resolves to `MOCKED_HOST_ADDRESS`, the rest is
|
|
103
|
+
* `X_TEST_NETWORK_SEALED` — the code `fetch` would have raised a moment later.
|
|
104
|
+
*/
|
|
105
|
+
const dnsVerdict = (hostname: string): DnsVerdict => {
|
|
106
|
+
const name = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
107
|
+
const url = `dns://${name}`;
|
|
108
|
+
if (state.network !== 'online') {
|
|
109
|
+
return { refused: new NetworkOfflineError({ url, method: 'LOOKUP', mode: state.network }) };
|
|
110
|
+
}
|
|
111
|
+
if (isThisMachine(name) || peerAddressClass(name) !== undefined || isAllowedName(name)) {
|
|
112
|
+
return { real: true };
|
|
113
|
+
}
|
|
114
|
+
if (state.mocks.some((route) => routeNamesHost(route, name))) {
|
|
115
|
+
return { answer: [MOCKED_HOST_ADDRESS] };
|
|
116
|
+
}
|
|
117
|
+
const allowed = [...state.allowed];
|
|
118
|
+
return {
|
|
119
|
+
refused: new NetworkSealedError({ url, method: 'LOOKUP', allowed, transport: 'dns' }),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Replace global fetch, `WebSocket`, `Bun.connect` and the resolvers. Idempotent: sealing twice
|
|
124
|
+
* keeps the one set of originals around. */
|
|
87
125
|
export function sealNetwork(): void {
|
|
88
126
|
if (state.original !== undefined) return;
|
|
89
127
|
state.original = globalThis.fetch;
|
|
90
128
|
state.restoreSockets = installSocketSeal(socketRefusal);
|
|
129
|
+
state.restoreDns = installDnsSeal(dnsVerdict);
|
|
91
130
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
92
131
|
const url = urlOf(input);
|
|
93
132
|
const method = methodOf(input, init);
|
|
@@ -151,6 +190,8 @@ export function unsealNetwork(): void {
|
|
|
151
190
|
state.original = undefined;
|
|
152
191
|
state.restoreSockets?.();
|
|
153
192
|
state.restoreSockets = undefined;
|
|
193
|
+
state.restoreDns?.();
|
|
194
|
+
state.restoreDns = undefined;
|
|
154
195
|
}
|
|
155
196
|
|
|
156
197
|
/** Whether the patch is installed. The `network` fixture seals before going offline, so that
|
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(
|