@volter/twin-standard 1.0.157 → 1.0.159

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 (76) hide show
  1. package/README.md +3 -2
  2. package/client-sdks/import.mjs +2 -0
  3. package/client-sdks/package.json +4 -0
  4. package/client-sdks/pnpm-lock.yaml +609 -0
  5. package/dist/client-sdks/import.mjs +2 -0
  6. package/dist/client-sdks/package.json +4 -0
  7. package/dist/client-sdks/pnpm-lock.yaml +609 -0
  8. package/dist/src/assessment.d.ts +13 -3
  9. package/dist/src/assessment.js +10 -6
  10. package/dist/src/browser-assessment.js +16 -13
  11. package/dist/src/clients/harness.d.ts +25 -4
  12. package/dist/src/clients/harness.js +184 -22
  13. package/dist/src/clients/jira.js +11 -2
  14. package/dist/src/clients/run.js +19 -14
  15. package/dist/src/clients/tinybird.js +15 -6
  16. package/dist/src/clients/vercel.d.ts +2 -0
  17. package/dist/src/clients/vercel.js +73 -0
  18. package/dist/src/conformance.d.ts +2 -0
  19. package/dist/src/conformance.js +392 -184
  20. package/dist/src/consumer.d.ts +22 -1
  21. package/dist/src/consumer.js +91 -26
  22. package/dist/src/credential-issuer-import.d.ts +3 -0
  23. package/dist/src/credential-issuer-import.js +42 -0
  24. package/dist/src/derive.js +3 -0
  25. package/dist/src/gate.js +4 -0
  26. package/dist/src/lanes.d.ts +2 -0
  27. package/dist/src/lanes.js +7 -0
  28. package/dist/src/protocol-3.js +8 -3
  29. package/dist/src/spec-documents.d.ts +4 -0
  30. package/dist/src/spec-documents.js +110 -3
  31. package/dist/src/tools/life-coverage-walk.js +38 -26
  32. package/dist/src/tools/score-pack.js +6 -5
  33. package/dist/src/tools/vendor-examples.js +91 -80
  34. package/dist/src/walk/behavior-journey.d.ts +22 -14
  35. package/dist/src/walk/behavior-journey.js +182 -133
  36. package/dist/src/walk/journey-kit.d.ts +3 -2
  37. package/dist/src/walk/journey-kit.js +6 -2
  38. package/dist/src/walk/replay.d.ts +23 -0
  39. package/dist/src/walk/replay.js +118 -0
  40. package/dist/src/walk/serve-pack.d.ts +2 -2
  41. package/dist/src/walk/serve-pack.js +38 -2
  42. package/dist/src/walk/shape.d.ts +25 -4
  43. package/dist/src/walk/shape.js +166 -56
  44. package/dist/src/walk/vendor-fetch.d.ts +7 -2
  45. package/dist/src/walk/vendor-fetch.js +13 -2
  46. package/dist/src/walk/walk-database.d.ts +9 -1
  47. package/dist/src/walk/walk-database.js +35 -13
  48. package/dist/src/walk/xml-view.d.ts +3 -2
  49. package/dist/src/walk/xml-view.js +5 -4
  50. package/package.json +4 -3
  51. package/src/assessment.ts +12 -8
  52. package/src/browser-assessment.ts +12 -5
  53. package/src/clients/harness.ts +142 -19
  54. package/src/clients/jira.ts +4 -2
  55. package/src/clients/run.ts +17 -15
  56. package/src/clients/tinybird.ts +8 -6
  57. package/src/clients/vercel.ts +67 -0
  58. package/src/conformance.ts +313 -128
  59. package/src/consumer.ts +83 -21
  60. package/src/credential-issuer-import.ts +38 -0
  61. package/src/derive.ts +3 -1
  62. package/src/gate.ts +3 -0
  63. package/src/lanes.ts +7 -0
  64. package/src/protocol-3.ts +7 -3
  65. package/src/spec-documents.ts +92 -3
  66. package/src/tools/life-coverage-walk.ts +32 -22
  67. package/src/tools/score-pack.ts +7 -6
  68. package/src/tools/vendor-examples.ts +88 -75
  69. package/src/walk/behavior-journey.ts +145 -109
  70. package/src/walk/journey-kit.ts +7 -5
  71. package/src/walk/replay.ts +103 -0
  72. package/src/walk/serve-pack.ts +25 -5
  73. package/src/walk/shape.ts +156 -54
  74. package/src/walk/vendor-fetch.ts +16 -4
  75. package/src/walk/walk-database.ts +41 -15
  76. package/src/walk/xml-view.ts +5 -4
@@ -12,16 +12,19 @@ import { attributionName } from '../verdict.ts';
12
12
  import { mongodb, mysql, redis } from '@volter/world-core';
13
13
  import { createRequire } from 'node:module';
14
14
  import { gzipSync } from 'node:zlib';
15
- import { parse, visit } from 'graphql';
16
15
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
17
16
  import { join } from 'node:path';
18
- import { databaseSession, type DatabaseSession, getActivePackAssets, getActiveWorldStore, LocalPackAssets, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActivePackAssets, setActiveWorldStore, setDeliveryObserver, setInProcessTwins, type InProcessTwin, type TwinStream, type TwinStreamConnection } from '@volter/world-core/runtime';
17
+ import { databaseSession, type DatabaseSession, getActivePackAssets, getActiveWorldStore, LocalPackAssets, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActivePackAssets, setActiveWorldStore, setDeliveryObserver, setInProcessTwins, settleEventDeliveries, worldDoorMatcher, worldNow, type InProcessTwin, type TwinStream, type TwinStreamConnection } from '@volter/world-core/runtime';
19
18
  import { createHostTable, type HostFacts } from '@volter/world-core/host-routing';
20
19
  import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
21
20
  import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, type MysqlStep, type MongoStep, isMongo, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
22
- import { lanesOf, lineShape, shapeChecker, type LineConnection, type ShapeChecker } from './shape.ts';
21
+ import { decodeResponse, leaked, lineShape, responseProblems, shapeChecker, vendorFieldsOf, type LineConnection, type ShapeChecker } from './shape.ts';
23
22
  import { walkPackDirectory } from './pack-directory.ts';
24
- import { xmlMembers } from './xml-view.ts';
23
+ import { unitsIn } from '../lanes.ts';
24
+ import { openWebSocket, readHttpResponse } from '../clients/harness.ts';
25
+ import { timestampOrderFailures, type ReplayStep } from './replay.ts';
26
+ import type { ManagedDatabaseWalk } from './walk-database.ts';
27
+ export { leaked } from './shape.ts';
25
28
 
26
29
  /** The pack repository the walk finds a vendor's pack in, set by the caller before its first walk. */
27
30
  let packsRoot: string | undefined;
@@ -134,19 +137,11 @@ const claims = (keys: string[], url: string): boolean => {
134
137
  return keys.some((key) => walkHosts[key]!(u.hostname, u.pathname));
135
138
  };
136
139
 
137
- type Answer = { status: number; body: unknown; text: string; headers: Record<string, string> };
140
+ type SentRequest = { method: string; url: string; headers: Record<string, string> };
141
+ type StepAnswer = { status: number; body: unknown; headers?: Record<string, string>; request?: SentRequest; wireBody?: unknown; sql?: ReplayStep['sql']; connection?: ReplayStep['connection'] };
142
+ type Answer = StepAnswer & { text: string; headers: Record<string, string>; request: SentRequest; wireBody: unknown; bodyError?: { message: string; cancelled: boolean } };
138
143
  export type Failure = { step: string; beat: string; detail: string };
139
144
 
140
- /** What a stored row keeps for the twin that no schema of the vendor's spec names, by path, in anything the World shows a
141
- * client: the pack's `_`-prefixed bookkeeping (GitHub's `_links` is the vendor's), and, in what no answer's SHAPE
142
- * reads (an event's delivery, a socket frame), the kernel's own `updatedAt`, which a raw row filtered on `_` alone
143
- * carries (Slack's message events once did). The kernel's `deleted` is not read there: vendors send their own
144
- * (Stripe's deleted customer's `deleted: true`). */
145
- export function leaked(v: unknown, vendorFields: Set<string>, at = '', unshaped = false): string[] {
146
- if (Array.isArray(v)) return v.flatMap((x, i) => leaked(x, vendorFields, `${at}[${i}]`, unshaped)).slice(0, 5);
147
- if (!v || typeof v !== 'object') return [];
148
- return Object.entries(v).flatMap(([k, x]) => ((k.startsWith('_') || (unshaped && k === 'updatedAt')) && !vendorFields.has(k) ? [`${at}${at ? '.' : ''}${k}`] : leaked(x, vendorFields, `${at}${at ? '.' : ''}${k}`, unshaped))).slice(0, 5);
149
- }
150
145
  /** Whether a tool_choice forbids every tool call (OpenAI's 'none', Anthropic's { type: 'none' }). */
151
146
  const forbidsTools = (choice: unknown): boolean => choice === 'none' || (!!choice && typeof choice === 'object' && (choice as { type?: unknown }).type === 'none');
152
147
  /** The tool calls an answer or frames carry, in any vendor's form: a chat message's `tool_calls`, a Responses or
@@ -160,24 +155,18 @@ function toolCallsIn(v: unknown, out: string[] = []): string[] {
160
155
  for (const [k, x] of Object.entries(o)) if (k !== 'tool_calls') toolCallsIn(x, out);
161
156
  return out;
162
157
  }
163
- /** Every field name the vendor's spec gives any resource (the derived surface), per vendor. */
158
+ /** The selected vendor's shared schema and native-error inventory, loaded before walking. */
164
159
  const fieldsByVendor = new Map<string, Set<string>>();
165
160
  function specFields(vendor: string): Set<string> {
166
- if (!fieldsByVendor.has(vendor)) {
167
- // the pack's surface and each of its lanes' (a walk reaches a lane through the pack's router)
168
- const directory = directoryFor(vendor);
169
- const files = [directory, ...lanesOf(directory)].map((dir) => join(dir, 'src', 'generated', 'surface.gen.json')).filter((f) => existsSync(f));
170
- const surfaces = files.map((f) => parseExactJson(readFileSync(f, 'utf8')) as { resources?: Array<{ fields: Array<{ name: string }> }> });
171
- fieldsByVendor.set(vendor, new Set(surfaces.flatMap((surface) => (surface.resources ?? []).flatMap((r) => r.fields.map((f) => f.name)))));
172
- }
173
161
  return fieldsByVendor.get(vendor)!;
174
162
  }
175
163
 
176
164
  /** What a caller that scores a walk observes around each step: the step, its resolved path and body,
177
- * and (after) the answer and the failures the step produced. */
165
+ * and (after) the answer and the failures the step produced. HTTP answers include the constructed request and
166
+ * decoded wire body separately from the capture view. */
178
167
  export type StepHooks = {
179
168
  before?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, body: unknown) => void;
180
- after?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, answer: { status: number; body: unknown; headers?: Record<string, string> }, failures: Failure[]) => void;
169
+ after?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, answer: StepAnswer, failures: Failure[]) => void;
181
170
  };
182
171
 
183
172
  /** One reply of a line protocol: its code and the text of each of its lines (`250-a`, `250 b`). */
@@ -202,7 +191,7 @@ function takeReplies(c: Connection): LineReply[] {
202
191
  /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root and scenario its options):
203
192
  * the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
204
193
  * its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
205
- export type ServedTwin = (root: string, options: { scenarioPath?: string; application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: (request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
194
+ export type ServedTwin = (root: string, options: { scenarioPath?: string; application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: ((request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>) & ManagedDatabaseWalk; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
206
195
 
207
196
  export async function walk(journey: Journey, root: string, collect: Failure[], opts: { beats: boolean; verbose?: boolean; hooks?: StepHooks; captured?: Captured; served?: ServedTwin }): Promise<Captured> {
208
197
  attributionName(journey.author);
@@ -220,23 +209,9 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
220
209
  const factory = await vendorFetch(journey.vendor);
221
210
  const views = await vendorAnswerViews(packsDir(), journey.vendor, dependenciesDir());
222
211
  if (!factory) { collect.push({ step: '(setup)', beat: 'ADAPTER', detail: `no create<Name>Fetch adapter resolved for ${journey.vendor}` }); return {}; }
223
- // the life's companions (`companions`): the packs its vendor calls, each over this walk's root, on the same World and
224
- // clock; reached in process by the vendor's ctx.vendorFetch and by a step to a URL their host rules claim
225
- const companions: Array<{ vendor: string; keys: string[]; fetch: InProcessTwin }> = [];
226
- for (const vendor of journey.companions ?? []) {
227
- const keys = routingKeys(vendor);
228
- const companion = vendor === journey.vendor ? undefined : await vendorFetch(vendor);
229
- if (!companion || !keys.length) { collect.push({ step: '(setup)', beat: 'ADAPTER', detail: vendor === journey.vendor ? `the life's own vendor ${vendor} is no companion of it` : !companion ? `no create<Name>Fetch adapter for the companion ${vendor}` : `the companion ${vendor} declares no host rules (pack facts) a call could reach it by` }); return {}; }
230
- companions.push({ vendor, keys, fetch: companion(root) });
231
- }
232
- const ownKeys = routingKeys(journey.vendor);
233
- /** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
234
- const companionOf = (url: string) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
235
- // the life's scenario is the World's: written into the walk's World store, which the pack reads it from as a World's
236
- // handlers/<vendor>.json is read
237
- const scenarioPath = journey.scenario ? join(directoryFor(journey.vendor), 'journeys', journey.scenario) : undefined;
238
- if (scenarioPath) getActiveWorldStore().write(scenarioPath, readFileSync(scenarioPath, 'utf8'));
239
- // the application the vendor asks (ctx.ask) is the life's stand-in, this walk's fetch's alone: its answers, in order,
212
+ // what an earlier walk captured, when this one continues it (the branch round-trip sends a life's write again)
213
+ const captured: Captured = { ...(opts.captured ?? {}) };
214
+ // the application the vendors call is the life's stand-in, shared by this walk's packs: its answers, in order,
240
215
  // by method and URL prefix, each request header it declares checked as sent; a question none takes is unreachable, as
241
216
  // in a sealed World
242
217
  const answers = [...(journey.application ?? [])];
@@ -253,13 +228,39 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
253
228
  }
254
229
  return { status: a!.status, ...(a!.headers ? { headers: a!.headers } : {}), ...(a!.bodyError === undefined ? {} : { bodyError: a!.bodyError }), body: typeof a!.body === 'string' ? a!.body : a!.body === undefined ? '' : JSON.stringify(a!.body) };
255
230
  };
231
+ // the life's companions (`companions`): the packs its vendor calls, each over this walk's root, on the same World and
232
+ // clock; reached in process by the vendor's ctx.vendorFetch and by a step to a URL their host rules claim
233
+ const companions: Array<{ vendor: string; keys: string[]; fetch: InProcessTwin; databaseDoor?: string }> = [];
234
+ for (const vendor of journey.companions ?? []) {
235
+ const keys = routingKeys(vendor);
236
+ const companion = vendor === journey.vendor ? undefined : await vendorFetch(vendor);
237
+ if (!companion || !keys.length) { collect.push({ step: '(setup)', beat: 'ADAPTER', detail: vendor === journey.vendor ? `the life's own vendor ${vendor} is no companion of it` : !companion ? `no create<Name>Fetch adapter for the companion ${vendor}` : `the companion ${vendor} declares no host rules (pack facts) a call could reach it by` }); return {}; }
238
+ companions.push({ vendor, keys, fetch: companion(root, { application }), databaseDoor: companion.managedDatabaseDoor });
239
+ }
240
+ const eventFields = new Map<string, Set<string>>();
241
+ for (const vendor of [journey.vendor, ...companions.map((companion) => companion.vendor)]) {
242
+ const directory = directoryFor(vendor);
243
+ if (!fieldsByVendor.has(vendor)) fieldsByVendor.set(vendor, await vendorFieldsOf(directory));
244
+ for (const unit of unitsIn(directory)) {
245
+ const { manifest } = await import(join(unit, 'src', 'manifest.ts')) as { manifest: { service?: string; vendor: string } };
246
+ const service = manifest.service ?? manifest.vendor;
247
+ const fields = eventFields.get(service) ?? new Set<string>();
248
+ for (const field of specFields(vendor)) fields.add(field);
249
+ eventFields.set(service, fields);
250
+ }
251
+ }
252
+ const ownKeys = routingKeys(journey.vendor);
253
+ /** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
254
+ const companionOf = (url: string) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
255
+ // the life's scenario is the World's: written into the walk's World store, which the pack reads it from as a World's
256
+ // handlers/<vendor>.json is read
257
+ const scenarioPath = journey.scenario ? join(directoryFor(journey.vendor), 'journeys', journey.scenario) : undefined;
258
+ if (scenarioPath) getActiveWorldStore().write(scenarioPath, readFileSync(scenarioPath, 'utf8'));
259
+ const shape = await shapeOf(journey.vendor);
256
260
  const served = opts.served ? await opts.served(root, { ...(scenarioPath ? { scenarioPath } : {}), application }) : undefined;
257
261
  const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
258
- const shape = await shapeOf(journey.vendor);
259
262
  const host = journey.host ?? 'twin.local';
260
263
  const origin = `${served?.scheme ?? 'http'}://${host}`;
261
- // what an earlier walk captured, when this one continues it (the branch round-trip sends a life's write again)
262
- const captured: Captured = { ...(opts.captured ?? {}) };
263
264
  // a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
264
265
  const typedValues: Record<string, unknown> = {};
265
266
  const remember = (name: string, v: unknown): void => {
@@ -280,24 +281,20 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
280
281
  // a body the client compresses or encodes as it sends it (posthog-js's gzip-js, its base64 form), captures filled first
281
282
  if (step.encode && typeof init.body === 'string') init.body = step.encode === 'gzip' ? new Uint8Array(gzipSync(Buffer.from(init.body))) : `data=${encodeURIComponent(Buffer.from(init.body).toString('base64'))}`;
282
283
  // a step may follow an absolute url an answer gave it (a hosted page's), as the application does
283
- // a twin that throws answers 500, as a server would: the walk reports the step and goes on to the next
284
- let res: Response;
285
- try {
286
- const request = new Request(/^https?:\/\//.test(path) ? path : `${origin}${path}`, init);
287
- const companion = companionOf(path);
288
- res = companion ? await companion.fetch(request) : served ? await served.fetch(request, { credentials: step.credentials ?? 'same-origin' }) : await twin(request);
289
- }
290
- catch (error) { res = new Response(`twin threw: ${error instanceof Error ? error.message : String(error)}`, { status: 500 }); }
291
- const text = await res.text();
292
- let body: unknown; try { body = parseExactJson(text); } catch { body = text; }
293
- // an XML answer (S3's) is its root element's members (xml-view.ts), as a JSON answer is its object
294
- if (typeof body === 'string' && /xml/.test(res.headers.get('content-type') ?? '')) body = xmlMembers(text) ?? text;
284
+ const companion = companionOf(path);
285
+ await ((companion?.fetch ?? twin) as ManagedDatabaseWalk).managedDatabaseUrl?.();
286
+ const request = new Request(/^https?:\/\//.test(path) ? path : `${origin}${path}`, init);
287
+ const answer = await readHttpResponse(async (request) => {
288
+ // a twin that throws answers 500, as a server would: the walk reports the step and goes on to the next
289
+ try { return companion ? await companion.fetch(request) : served ? await served.fetch(request, { credentials: step.credentials ?? 'same-origin' }) : await twin(request); }
290
+ catch (error) { if (served) throw error; return new Response(`twin threw: ${error instanceof Error ? error.message : String(error)}`, { status: 500 }); }
291
+ }, request);
292
+ const wireBody = decodeResponse(answer.text, answer.headers['content-type']);
293
+ let body = wireBody;
295
294
  // a hosted flow answers with a redirect: its target and query are what a step captures (redirect.code)
296
- const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
295
+ const location = answer.status >= 300 && answer.status < 400 ? answer.headers.location : undefined;
297
296
  if (location) body = { location, redirect: Object.fromEntries(new URL(location, origin).searchParams) };
298
- // every cookie an answer sets, joined as one header: a sign-in sets several, and a map keeps only the last
299
- const cookies = res.headers.getSetCookie();
300
- return { status: res.status, body, text, headers: { ...Object.fromEntries(res.headers), ...(cookies.length ? { 'set-cookie': cookies.join(', ') } : {}) } };
297
+ return { status: answer.status, body, wireBody, request: { method: request.method, url: request.url, headers: Object.fromEntries(request.headers) }, text: answer.text, headers: answer.headers, ...(answer.bodyError ? { bodyError: answer.bodyError } : {}) };
301
298
  };
302
299
 
303
300
  // a wait moves the World clock; the walk puts it back where it found it, so a replay starts at the same instant
@@ -592,8 +589,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
592
589
  const ws = served!.socket!(at.path, opening);
593
590
  ws.onmessage = (e) => { const text = String(e.data); try { opened.frames.push(parseExactJson(text)); } catch { opened.frames.push(text); } };
594
591
  ws.onclose = (e) => { opened.closed ??= { code: e.code, reason: e.reason }; };
595
- const up = await new Promise<boolean>((done) => { ws.onopen = () => done(true); ws.onerror = () => done(false); setTimeout(() => done(false), 5000); });
596
- if (!up) { collect.push({ step: step.name, beat: 'STATUS', detail: `no socket at ${step.open}` }); return; }
592
+ if (!await openWebSocket(ws)) { collect.push({ step: step.name, beat: 'STATUS', detail: `no socket at ${step.open}` }); return; }
597
593
  sockets.set(step.socket, opened);
598
594
  wire.set(step.socket, ws);
599
595
  }
@@ -679,9 +675,10 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
679
675
  const sqlStep = async (step: SqlStep): Promise<void> => {
680
676
  const failuresBefore = collect.length;
681
677
  opts.hooks?.before?.(step as never, 'sql', step.sql);
682
- let answered: { status: number; body: unknown } = { status: 0, body: undefined };
678
+ let answered: StepAnswer = { status: 0, body: undefined };
683
679
  try {
684
- const url = await (twin as { managedDatabaseUrl?: () => Promise<string> }).managedDatabaseUrl?.();
680
+ const database = twin as ManagedDatabaseWalk;
681
+ const url = await database.managedDatabaseUrl?.();
685
682
  if (url === undefined) { collect.push({ step: step.name, beat: 'ADAPTER', detail: `${journey.vendor} binds no managed database in this walk, so a SQL step has none to run on` }); return; }
686
683
  let results;
687
684
  try {
@@ -694,9 +691,13 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
694
691
  if (step.refused !== code) collect.push({ step: step.name, beat: 'STATUS', detail: `Postgres refused it: ${code} ${(e as Error).message}${step.refused ? ` (wanted ${step.refused})` : ''}` });
695
692
  return;
696
693
  }
697
- const last = results.at(-1) ?? { fields: [], rows: [], command: '' };
694
+ const last = results.at(-1) ?? { fields: [], fieldTypes: [], rows: [], command: '' };
698
695
  const body = { command: last.command, rows: last.rows.map((row) => Object.fromEntries(last.fields.map((field, i) => [field, row[i] ?? null]))) };
699
- answered = { status: 0, body };
696
+ // Duplicate column names have the same last-column ownership as the visible row projection.
697
+ const fieldTypes = Object.fromEntries(last.fields.map((field, i) => [field, last.fieldTypes[i]]));
698
+ const timeFields = Object.fromEntries(Object.entries(fieldTypes).filter(([, type]) => type === 1114 || type === 1184)) as Record<string, 1114 | 1184>;
699
+ answered = { status: 0, body, ...(database.managedDatabaseClock === 'wall' ? { sql: { clock: database.managedDatabaseClock, timeFields } } : {}) };
700
+ for (const detail of timestampOrderFailures(body, timeFields, step.timeOrder ?? [])) collect.push({ step: step.name, beat: 'EXPECT', detail });
700
701
  if (step.refused !== undefined) { collect.push({ step: step.name, beat: 'STATUS', detail: `Postgres completed it (${last.command}), wanted ${step.refused}` }); return; }
701
702
  for (const [name, dot] of Object.entries(step.capture ?? {})) {
702
703
  const v = captureAt(body, dot);
@@ -711,7 +712,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
711
712
  }
712
713
  };
713
714
 
714
- const pending = new Map<string, { method: string; path: string; body: unknown; headers: Record<string, string> | undefined; answer: Promise<Answer>; abort: AbortController; timer: ReturnType<typeof setTimeout> }>();
715
+ const pending = new Map<string, { method: string; path: string; body: unknown; headers: Record<string, string> | undefined; answer: Promise<Answer>; abort: AbortController }>();
715
716
  // the clock is world state, read and moved through the active World store (in memory for a walk)
716
717
  const clockFile = process.env.TWIN_WORLD_CLOCK_FILE;
717
718
  const store = getActiveWorldStore();
@@ -726,7 +727,9 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
726
727
  // what the World sends a receiver (an event's delivery) is read as the receiver reads it, for the twin's own fields
727
728
  const previousObserver = setDeliveryObserver((d) => {
728
729
  let body: unknown; try { body = parseExactJson(d.body); } catch { return; }
729
- for (const key of leaked(body, specFields(journey.vendor), '', true)) collect.push({ step: stepNow, beat: 'LEAK', detail: `the event sent to ${d.url} carries the twin's own field ${key}` });
730
+ const fields = eventFields.get(d.service);
731
+ if (!fields) { collect.push({ step: stepNow, beat: 'LEAK', detail: `the event sent to ${d.url} names no selected owner for service ${d.service}` }); return; }
732
+ for (const key of leaked(body, fields, '', true)) collect.push({ step: stepNow, beat: 'LEAK', detail: `the event sent to ${d.url} carries the twin's own field ${key}` });
730
733
  });
731
734
  const previousTwins = setInProcessTwins(Object.fromEntries([
732
735
  ...companions.flatMap((c) => c.keys.map((key) => [key, c.fetch] as const)),
@@ -734,6 +737,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
734
737
  ]));
735
738
  try {
736
739
  for (const step of journey.steps) {
740
+ await settleEventDeliveries(root);
737
741
  stepNow = step.name;
738
742
  if (isMongo(step)) { await mongoStep(step); continue; }
739
743
  if (isMysql(step)) { await mysqlStep(step); continue; }
@@ -744,7 +748,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
744
748
  if (isSql(step)) { await sqlStep(step); continue; }
745
749
  if (isWait(step)) {
746
750
  if (!clockFile || startedAt === undefined) { collect.push({ step: step.name, beat: 'WAIT', detail: 'the walk has no World clock to move' }); continue; }
747
- try { store.write(clockFile, new Date(Date.parse((store.read(clockFile) ?? '').trim()) + waitMs(step.wait)).toISOString()); } catch (e) { collect.push({ step: step.name, beat: 'WAIT', detail: (e as Error).message }); }
751
+ try { store.write(clockFile, new Date(Date.parse(worldNow()) + waitMs(step.wait)).toISOString()); } catch (e) { collect.push({ step: step.name, beat: 'WAIT', detail: (e as Error).message }); }
748
752
  continue;
749
753
  }
750
754
  let path: string;
@@ -769,8 +773,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
769
773
  // a form is signed as it is sent: its multipart type, whose parts OAuth 1.0a's base string never carries
770
774
  const sent = formed ? undefined : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
771
775
  const contentType = Object.entries(headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1] ?? (formed ? formed.contentType : step.bodyBase64 === undefined ? defaultType(body) : undefined);
772
- const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
773
- headers = { ...(headers ?? {}), authorization: oauth1Authorization(o, step.method, url, sent, contentType, Number.isNaN(instant) ? 0 : instant) };
776
+ headers = { ...(headers ?? {}), authorization: oauth1Authorization(o, step.method, url, sent, contentType, Date.parse(worldNow())) };
774
777
  }
775
778
  if (step.jwt) headers = { ...(headers ?? {}), authorization: jwtAuthorization(step.jwt, captured) };
776
779
  // an AWS SDK's request is signed as it sends it, at the World clock's instant
@@ -778,9 +781,8 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
778
781
  const url = /^https?:\/\//.test(path) ? path : `${origin}${path}`;
779
782
  // the bytes and the type sent: a form's multipart body and boundary, raw bytes, or the body's text
780
783
  const sent = formed ? formed.bytes : step.bodyBase64 !== undefined ? Uint8Array.from(Buffer.from(step.bodyBase64, 'base64')) : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
781
- const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
782
784
  const base = { ...(formed ? { 'content-type': formed.contentType } : step.bodyBase64 === undefined ? { 'content-type': defaultType(body) } : {}), ...(journey.headers ?? {}), ...(headers ?? {}) };
783
- headers = { ...(headers ?? {}), ...sigv4Headers(step.sigv4, captured, step.method, url, base, sent, Number.isNaN(instant) ? 0 : instant) };
785
+ headers = { ...(headers ?? {}), ...sigv4Headers(step.sigv4, captured, step.method, url, base, sent, Date.parse(worldNow())) };
784
786
  }
785
787
  } catch (e) { collect.push({ step: step.name, beat: 'CAPTURE', detail: (e as Error).message }); continue; }
786
788
  if (step.defer) {
@@ -788,14 +790,10 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
788
790
  collect.push({ step: step.name, beat: 'PENDING', detail: 'a deferred request needs a unique key and no response checks or state beats' }); continue;
789
791
  }
790
792
  const abort = new AbortController();
791
- const timer = setTimeout(() => abort.abort(new Error('deferred request exceeded 10 s')), 10_000);
792
- const answer = Promise.race([
793
- send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path, abort.signal),
794
- new Promise<Answer>((_, reject) => abort.signal.addEventListener('abort', () => reject(abort.signal.reason), { once: true })),
795
- ]).finally(() => clearTimeout(timer));
793
+ const answer = send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path, abort.signal);
796
794
  // A rejected body read must not become an unhandled promise while socket steps run.
797
795
  void answer.catch(() => undefined);
798
- pending.set(step.defer, { method: step.method, path, body, headers, answer, abort, timer });
796
+ pending.set(step.defer, { method: step.method, path, body, headers, answer, abort });
799
797
  continue;
800
798
  }
801
799
  const deferred = step.await ? pending.get(step.await) : undefined;
@@ -817,34 +815,56 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
817
815
  }
818
816
  const failuresBefore = collect.length;
819
817
  opts.hooks?.before?.(step, path, body);
820
- let answered: { status: number; body: unknown; headers?: Record<string, string> } = { status: 0, body: undefined };
818
+ let answered: StepAnswer = { status: 0, body: undefined };
821
819
  // every step that ran reports, however it ended: a scorer counts the failing ones too
822
820
  try {
823
821
  let res: Answer;
824
822
  if (deferred) {
825
823
  pending.delete(step.await!);
826
824
  const got = await deferred.answer.then((a) => ({ a }), (error: unknown) => ({ error }));
827
- // a body the vendor broke off, as the collect expects (`breaks`): its words are the answer
828
- if ('error' in got) { if (step.breaks === undefined || !String(got.error).includes(step.breaks)) collect.push({ step: step.name, beat: 'PENDING', detail: String(got.error) }); continue; }
829
- // over a listener, which ends a broken stream as a short body, the ordinary checks hold the part that arrived
825
+ if ('error' in got) { collect.push({ step: step.name, beat: 'PENDING', detail: String(got.error) }); continue; }
830
826
  res = got.a;
831
- } else res = await send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path);
832
- answered = { status: res.status, body: res.body, headers: res.headers };
827
+ } else {
828
+ try { res = await send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path); }
829
+ catch (error) { collect.push({ step: step.name, beat: 'CONNECTION', detail: String(error) }); continue; }
830
+ }
831
+ answered = { status: res.status, body: res.body, headers: res.headers, request: res.request, wireBody: res.wireBody };
832
+ if (step.breaks !== undefined) {
833
+ if (!res.bodyError || res.bodyError.cancelled) collect.push({ step: step.name, beat: 'PENDING', detail: 'expected a response body interrupted by the vendor; clean EOF or caller cancellation is not an interruption' });
834
+ else if (!served && !res.bodyError.message.includes(step.breaks)) collect.push({ step: step.name, beat: 'PENDING', detail: res.bodyError.message });
835
+ } else if (res.bodyError) collect.push({ step: step.name, beat: 'BODY', detail: res.bodyError.message });
833
836
  if (opts.verbose) console.log(` ${String(res.status).padEnd(4)} ${step.method.padEnd(6)} ${path.slice(0, 70).padEnd(70)} ${step.name}`);
834
837
 
838
+ // A companion's answer belongs to its own schema, including when the status is unexpected.
839
+ const stepShape = serving === journey.vendor ? shape : await shapeOf(serving);
840
+ const companion = companionOf(res.request.url);
841
+ const databaseDoor = companion ? companion.databaseDoor : factory.managedDatabaseDoor;
842
+ if (databaseDoor && res.status >= 200 && res.status < 300) {
843
+ const selectedPath = new URL(stepShape ? stepShape.pathOf(res.request.method, res.request.url, res.request.headers) : res.request.url).pathname;
844
+ const declared = worldDoorMatcher([{ method: 'POST', path: databaseDoor }])(res.request.method, selectedPath);
845
+ if (declared) {
846
+ const connection = ((companion?.fetch ?? twin) as ManagedDatabaseWalk).managedDatabaseConnection;
847
+ if (!connection || !stepShape) collect.push({ step: step.name, beat: 'ADAPTER', detail: 'The selected native credential transport omitted its connection verification capability' });
848
+ else {
849
+ try {
850
+ const verified = await connection(res.request.method, selectedPath, res.status, res.wireBody);
851
+ if (!verified) throw new Error('The selected native credential transport supplied no authentication provenance');
852
+ answered.connection = verified;
853
+ } catch (error) {
854
+ collect.push({ step: step.name, beat: 'NATIVE-CONNECTION', detail: (error as Error).message });
855
+ }
856
+ }
857
+ }
858
+ }
859
+ const problems = responseProblems(stepShape, specFields(serving), {
860
+ method: res.request.method, path: res.request.url, requestHeaders: res.request.headers,
861
+ graphqlBody: graphqlBody ? body : undefined, status: res.status, mediaType: res.headers['content-type'], body: res.wireBody, text: res.text,
862
+ });
863
+ for (const detail of problems.shape) collect.push({ step: step.name, beat: 'SHAPE', detail });
864
+ for (const key of problems.leaks) collect.push({ step: step.name, beat: 'LEAK', detail: `answers the twin's own field ${key}` });
835
865
  const wanted = step.status;
836
866
  const ok = wanted === undefined ? res.status >= 200 && res.status < 300 : res.status === wanted;
837
867
  if (!ok) { collect.push({ step: step.name, beat: 'STATUS', detail: `${step.method} ${path} answered ${res.status}${wanted ? ` (wanted ${wanted})` : ' (wanted 2xx)'}: ${res.text.slice(0, 160)}` }); continue; }
838
- // a step a companion served is checked against the companion's spec
839
- const stepShape = serving === journey.vendor ? shape : await shapeOf(serving);
840
- const answering = stepShape && res.status >= 200 && res.status < 300 && !stepShape.pinsOlder({ ...(journey.headers ?? {}), ...(headers ?? {}) }) ? stepShape.operationOf(step.method, path, { ...(journey.headers ?? {}), ...(headers ?? {}) }) : undefined;
841
- if (answering) for (const detail of stepShape!.failures(answering.op, res.body, res.headers['content-type'], new Headers({ ...(journey.headers ?? {}), ...(headers ?? {}) }).get('accept') ?? undefined, res.status, { method: step.method, responseText: res.text })) collect.push({ step: step.name, beat: 'SHAPE', detail });
842
- // what a pack keeps for itself is `_`-prefixed and never answered, under any name (Protocol 3, State)
843
- const responseFields = new Set(specFields(serving));
844
- if (body && typeof body === 'object' && typeof (body as { query?: unknown }).query === 'string' && graphqlBody) {
845
- try { visit(parse((body as { query: string }).query), { Field: (field) => { responseFields.add(field.alias?.value ?? field.name.value); } }); } catch { /* malformed documents have no selected response keys */ }
846
- }
847
- if (res.status >= 200 && res.status < 300) for (const key of leaked(res.body, responseFields)) collect.push({ step: step.name, beat: 'LEAK', detail: `answers the twin's own field ${key}` });
848
868
  // a request that forbids tools (tool_choice none) is answered without a tool call, in any vendor's form
849
869
  if (res.status >= 200 && res.status < 300 && step.body && typeof step.body === 'object' && forbidsTools((step.body as { tool_choice?: unknown }).tool_choice)) for (const call of toolCallsIn(res.body)) collect.push({ step: step.name, beat: 'TOOLS', detail: `answered a tool call (${call}) to a request whose tool_choice is none` });
850
870
 
@@ -906,21 +926,37 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
906
926
  for (const key of pending.keys()) collect.push({ step: '(teardown)', beat: 'PENDING', detail: `deferred request ${key} was never collected` });
907
927
  return captured;
908
928
  } finally {
909
- for (const request of pending.values()) { clearTimeout(request.timer); request.abort.abort(); }
910
- for (const ws of wire.values()) ws.close();
911
- const upgrade = (twin as { upgrade?: { close: (peer: Socket['peer']) => void } }).upgrade;
912
- for (const socket of sockets.values()) if (!socket.closed) upgrade?.close(socket.peer);
913
- await Promise.allSettled([...pending.values()].map((request) => request.answer));
914
- setDeliveryObserver(previousObserver);
915
- setInProcessTwins(previousTwins);
916
929
  try {
917
- if (sqlSession) await sqlSession.then((session) => session.end(), () => {});
930
+ for (const request of pending.values()) request.abort.abort();
931
+ for (const ws of wire.values()) ws.close();
932
+ const upgrade = (twin as { upgrade?: { close: (peer: Socket['peer']) => void } }).upgrade;
933
+ for (const socket of sockets.values()) if (!socket.closed) upgrade?.close(socket.peer);
918
934
  } finally {
919
- await served?.stop();
920
- // a World's managed database ends with its walk (vendor-fetch.ts), so the next World declares it at the same port
921
- await (twin as { stopDatabase?: () => Promise<void> }).stopDatabase?.();
935
+ try {
936
+ await Promise.allSettled([...pending.values()].map((request) => request.answer));
937
+ await settleEventDeliveries(root);
938
+ } finally {
939
+ setDeliveryObserver(previousObserver);
940
+ setInProcessTwins(previousTwins);
941
+ try {
942
+ if (sqlSession) await sqlSession.then((session) => session.end(), () => {});
943
+ } finally {
944
+ try {
945
+ await served?.stop();
946
+ } finally {
947
+ try {
948
+ // The selected twin's database ends with its walk.
949
+ const retired = await Promise.allSettled([twin, ...companions.map(companion => companion.fetch)]
950
+ .map(fetch => (fetch as ManagedDatabaseWalk).stopDatabase?.()));
951
+ const failures = retired.filter(result => result.status === 'rejected').map(result => result.reason);
952
+ if (failures.length) throw new AggregateError(failures, 'Walk native database retirement failed');
953
+ } finally {
954
+ if (clockFile && startedAt !== undefined) store.write(clockFile, startedAt);
955
+ }
956
+ }
957
+ }
958
+ }
922
959
  }
923
- if (clockFile && startedAt !== undefined) store.write(clockFile, startedAt);
924
960
  }
925
961
  }
926
962
 
@@ -52,7 +52,7 @@ export type JourneyStep = {
52
52
  defer?: string;
53
53
  /** Collect a previously deferred request once, with all ordinary response checks. */
54
54
  await?: string;
55
- /** A collect whose answer's body breaks off with these words (a stream the vendor ends in an error), not arriving whole. */
55
+ /** Require an interrupted body; in-process, its error also contains these words. Status and received bytes retain all checks. */
56
56
  breaks?: string;
57
57
  body?: unknown;
58
58
  /** A body of raw bytes (a gzip'd package, an image, an audio file), base64 in the journey and sent decoded: a
@@ -445,8 +445,8 @@ export const isWait = (step: Step): step is WaitStep => 'wait' in step;
445
445
  * as written. Its answer is the last statement's `{ command, rows }`, each row its
446
446
  * columns by name in Postgres's text format, which `expect` reads; a refusal fails the step with
447
447
  * Postgres's SQLSTATE, unless the step names that SQLSTATE as its `refused` (the application's own catch of it), when
448
- * its answer is `{ code, message }`. */
449
- export type SqlStep = { name: string; sql: string; values?: Array<string | null>; refused?: string; expect?: JourneyStep['expect']; capture?: JourneyStep['capture'] };
448
+ * its answer is `{ code, message }`. `timeOrder` names native timestamp columns whose rows must be nondecreasing. */
449
+ export type SqlStep = { name: string; sql: string; values?: Array<string | null>; refused?: string; timeOrder?: string[]; expect?: JourneyStep['expect']; capture?: JourneyStep['capture'] };
450
450
  export const isSql = (step: Step): step is SqlStep => 'sql' in step;
451
451
 
452
452
  /** A wait's length in milliseconds; an unreadable one throws. */
@@ -534,13 +534,15 @@ export function fill(template: string, captured: Captured, graphqlDocument = fal
534
534
  // `{encoded:name}` is a capture percent-encoded (encodeURIComponent), as a URL's path segment or query value or a form
535
535
  // field carries it: a URN a vendor's page prints encoded in its paths (LinkedIn's /rest/posts/urn%3Ali%3Ashare%3A…), a
536
536
  // sign-in's return path holding `&` that a browser posts back
537
- const encoded = basic.replace(/\{encoded:([A-Za-z0-9_.]+)\}/g, (_, k: string) => {
537
+ const encoded = basic.replace(/\{(encoded|base64|md5):([A-Za-z0-9_.]+)\}/g, (_, transform: string, k: string) => {
538
538
  if (captured[k] === undefined) throw new Error(`journey: step refers to {${k}}, which no earlier step captured`);
539
+ if (transform === 'base64') return Buffer.from(captured[k]!, 'utf8').toString('base64');
540
+ if (transform === 'md5') return digest('md5', captured[k]!, 'base64');
539
541
  return encodeURIComponent(captured[k]!);
540
542
  });
541
543
  const selections = new Set<number>();
542
544
  // Only request query documents opt into GraphQL grammar (their selection sets, found in the text the captures fill,
543
- // which `{basic:…}` and `{encoded:…}` have already filled); variables remain ordinary captured JSON data.
545
+ // which the credential and byte transforms have already filled); variables remain ordinary captured JSON data.
544
546
  if (graphqlDocument && !Object.hasOwn(captured, /^\{([A-Za-z_][A-Za-z0-9_.]*)\}$/.exec(encoded)?.[1] ?? '')) {
545
547
  try { visit(parse(encoded), { SelectionSet(node) { if (node.loc) selections.add(node.loc.start); } }); } catch { /* invalid documents still reach the vendor's own syntax error */ }
546
548
  }
@@ -0,0 +1,103 @@
1
+ /** A received step, with verified native engine provenance outside its unchanged response body. */
2
+ export type ReplayStep = {
3
+ name: string; path: string; status: number; body: unknown;
4
+ sql?: { clock: 'wall'; timeFields: Record<string, 1114 | 1184> };
5
+ connection?: { kind: 'postgres'; field: string; authenticated: true };
6
+ };
7
+
8
+ /** PostgreSQL's ISO timestamp text, retaining its six fractional digits and timezone offset. */
9
+ function timestamp(value: unknown, oid: 1114 | 1184): bigint | undefined {
10
+ if (value === 'infinity') return 10n ** 30n;
11
+ if (value === '-infinity') return -(10n ** 30n);
12
+ if (typeof value !== 'string') return undefined;
13
+ const m = /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?( BC)?$/.exec(value);
14
+ if (!m || (oid === 1184) !== (m[8] !== undefined)) return undefined;
15
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
16
+ if (!year || !month || month > 12 || !day || hour! > 23 || minute! > 59 || second! > 59) return undefined;
17
+ const astronomicalYear = m[13] ? 1 - year : year;
18
+ const leap = astronomicalYear % 4 === 0 && (astronomicalYear % 100 !== 0 || astronomicalYear % 400 === 0);
19
+ if (day > [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!) return undefined;
20
+ const zoneHour = Number(m[10] ?? 0), zoneMinute = Number(m[11] ?? 0), zoneSecond = Number(m[12] ?? 0);
21
+ if (zoneHour > 15 || zoneMinute > 59 || zoneSecond > 59) return undefined;
22
+ const adjustedYear = astronomicalYear - (month <= 2 ? 1 : 0);
23
+ const era = Math.floor(adjustedYear / 400), yearOfEra = adjustedYear - era * 400;
24
+ const dayOfYear = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
25
+ const days = era * 146097 + yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear - 719468;
26
+ const offset = (zoneHour * 3600 + zoneMinute * 60 + zoneSecond) * (m[9] === '-' ? -1 : 1);
27
+ return BigInt(days) * 86_400_000_000n + BigInt(hour! * 3600 + minute! * 60 + second! - offset) * 1_000_000n + BigInt((m[7] ?? '').padEnd(6, '0'));
28
+ }
29
+
30
+ /** A journey's declared native timestamp columns are present, valid and nondecreasing down the returned rows. */
31
+ export function timestampOrderFailures(body: unknown, timeFields: Record<string, 1114 | 1184>, columns: string[]): string[] {
32
+ if (!columns.length) return [];
33
+ const rows = body && typeof body === 'object' ? (body as { rows?: unknown }).rows : undefined;
34
+ if (!Array.isArray(rows)) return ['timestamp ordering requires SQL result rows'];
35
+ const failures: string[] = [];
36
+ for (const column of columns) {
37
+ const oid = timeFields[column];
38
+ if (oid !== 1114 && oid !== 1184) { failures.push(`${column} is not a native timestamp column`); continue; }
39
+ let previous: bigint | undefined;
40
+ for (const [i, row] of rows.entries()) {
41
+ const at = timestamp(row && typeof row === 'object' ? (row as Record<string, unknown>)[column] : undefined, oid);
42
+ if (at === undefined) failures.push(`rows.${i}.${column} is not a non-null native timestamp`);
43
+ else if (previous !== undefined && at < previous) failures.push(`rows.${i}.${column} precedes the previous row's timestamp`);
44
+ previous = at;
45
+ }
46
+ }
47
+ return failures;
48
+ }
49
+
50
+ /** Compare received traces by their selected engine declarations, retaining every undeclared byte. */
51
+ export function compareReplays(first: ReadonlyArray<ReplayStep>, second: ReadonlyArray<ReplayStep>): { equal: boolean; diagnostics: string[] } {
52
+ const diagnostics: string[] = [];
53
+ if (!first.length) diagnostics.push('the journey answered no steps');
54
+ if (first.length !== second.length) diagnostics.push(`the replays answered ${first.length} and ${second.length} steps`);
55
+ const times = new Map<1114 | 1184, Array<{ first: bigint; second: bigint; where: string }>>();
56
+ for (let i = 0; i < Math.min(first.length, second.length); i++) {
57
+ const a = first[i]!, b = second[i]!;
58
+ const where = `step ${i + 1} (${a.name})`;
59
+ if (a.name !== b.name || a.path !== b.path || a.status !== b.status || JSON.stringify(a.sql) !== JSON.stringify(b.sql) || JSON.stringify(a.connection) !== JSON.stringify(b.connection)) {
60
+ diagnostics.push(`${where}: request, status or native engine provenance differs`);
61
+ continue;
62
+ }
63
+ const fields = a.sql?.clock === 'wall' ? a.sql.timeFields : {};
64
+ const same = (left: unknown, right: unknown, path: string[]): boolean => {
65
+ if (path.length === 1 && a.connection?.kind === 'postgres' && a.connection.authenticated === true && path[0] === a.connection.field)
66
+ return typeof left === 'string' && typeof right === 'string';
67
+ const oid = path.length === 3 && path[0] === 'rows' ? fields[path[2]!] : undefined;
68
+ if ((oid === 1114 || oid === 1184) && left !== null && right !== null) {
69
+ const x = timestamp(left, oid), y = timestamp(right, oid);
70
+ if (x === undefined || y === undefined) { diagnostics.push(`${where}: ${path.join('.')} is not a valid native timestamp`); return false; }
71
+ const values = times.get(oid) ?? [];
72
+ values.push({ first: x, second: y, where: `${where}: ${path.join('.')}` });
73
+ times.set(oid, values);
74
+ return true;
75
+ }
76
+ if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, n) => same(value, right[n], [...path, String(n)]));
77
+ if (left && right && typeof left === 'object' && typeof right === 'object') {
78
+ const keys = Object.keys(left);
79
+ return JSON.stringify(keys) === JSON.stringify(Object.keys(right)) && keys.every(key => same((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key], [...path, key]));
80
+ }
81
+ return JSON.stringify(left) === JSON.stringify(right);
82
+ };
83
+ if (!same(a.body, b.body, [])) diagnostics.push(`${where}: response bytes differ`);
84
+ }
85
+ // Equal timestamps form one group: ties may split or coincide, while a strict order cannot reverse.
86
+ for (const values of times.values()) {
87
+ values.sort((a, b) => a.first < b.first ? -1 : a.first > b.first ? 1 : 0);
88
+ let greatest: { value: bigint; where: string } | undefined;
89
+ for (let i = 0; i < values.length;) {
90
+ let end = i + 1;
91
+ while (end < values.length && values[end]!.first === values[i]!.first) end++;
92
+ let next = greatest;
93
+ for (let n = i; n < end; n++) {
94
+ const at = values[n]!;
95
+ if (greatest && at.second < greatest.value) diagnostics.push(`${at.where}: timestamp order reverses relative to ${greatest.where}`);
96
+ if (!next || at.second > next.value) next = { value: at.second, where: at.where };
97
+ }
98
+ greatest = next;
99
+ i = end;
100
+ }
101
+ }
102
+ return { equal: diagnostics.length === 0, diagnostics };
103
+ }