@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.
@@ -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
- * Effects the killed run made more often than the reference — or less often, when both ended alike
159
- * and neither unwound. An unwound run is also held to the reference's compensators: each once, last
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)) out.push([key, c, k]);
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
- ...(after === undefined ? {} : { after }),
261
+ ...where,
238
262
  }),
239
263
  });
240
264
  }
@@ -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 written when the effect happened. */
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 wrote this step — what the next effect it makes is placed after. */
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
 
@@ -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 written before the effect, in the clean run. */
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 where =
27
- input.after === undefined ? 'before the first step' : `after step "${input.after}"`;
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: it runs outside every step, ${where}, so the retry makes it again`,
31
- fix: `step.run(${renderFixLiteral(`${input.boundary}-effect`, '<name>')}, async () => …) # in job(${renderFixLiteral(input.job, '<job>')}): wrap the ${renderFixLiteral(input.effect, '<effect>')} made ${where}; key an external call on \`\${jobId}:<step>\`; then: x test job --filter <the test file>`,
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
  }
@@ -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
- if (op.call === 'put' && op.step !== undefined) log?.stepWritten(op.worker, op.step);
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
+ };
@@ -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. A conjunct qualified by
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)
@@ -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 === null)
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 === null)
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
- const order = walked
106
- ? orderOf(statement, new Set(description.columns.map((c) => c.column)))
107
- : verdict.order;
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] : [];
@@ -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
- (finding) =>
77
- `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
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/jobs`, `@ultimat3/action`, `@ultimat3/query` and `@ultimat3/mail` publish a
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
+ }
@@ -1,14 +1,15 @@
1
- // Sealed network. Any egress a test did not explicitly mock or allow fails the test with the URL
2
- // and the line that fixes it. A test that quietly reaches the internet is a test that fails in CI
3
- // for reasons nobody can reproduce — so the default is "nothing gets out". Three dials are sealed:
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
- /** Replace global fetch, `WebSocket` and `Bun.connect`. Idempotent: sealing twice keeps the one
86
- * set of originals around. */
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
@@ -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()`, the record `relationsOf` is built from). A cycle is
3
- // broken at a NULLABLE edge — inserted null, set by an update once its target exists; a cycle with
4
- // no nullable edge is refused, because no insert order can satisfy it.
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
- const edge = breakable[0];
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(