@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.
- package/README.md +3 -2
- package/client-sdks/import.mjs +2 -0
- package/client-sdks/package.json +4 -0
- package/client-sdks/pnpm-lock.yaml +609 -0
- package/dist/client-sdks/import.mjs +2 -0
- package/dist/client-sdks/package.json +4 -0
- package/dist/client-sdks/pnpm-lock.yaml +609 -0
- package/dist/src/assessment.d.ts +13 -3
- package/dist/src/assessment.js +10 -6
- package/dist/src/browser-assessment.js +16 -13
- package/dist/src/clients/harness.d.ts +25 -4
- package/dist/src/clients/harness.js +184 -22
- package/dist/src/clients/jira.js +11 -2
- package/dist/src/clients/run.js +19 -14
- package/dist/src/clients/tinybird.js +15 -6
- package/dist/src/clients/vercel.d.ts +2 -0
- package/dist/src/clients/vercel.js +73 -0
- package/dist/src/conformance.d.ts +2 -0
- package/dist/src/conformance.js +392 -184
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +3 -0
- package/dist/src/gate.js +4 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/protocol-3.js +8 -3
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/score-pack.js +6 -5
- package/dist/src/tools/vendor-examples.js +91 -80
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +182 -133
- package/dist/src/walk/journey-kit.d.ts +3 -2
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/replay.d.ts +23 -0
- package/dist/src/walk/replay.js +118 -0
- package/dist/src/walk/serve-pack.d.ts +2 -2
- package/dist/src/walk/serve-pack.js +38 -2
- package/dist/src/walk/shape.d.ts +25 -4
- package/dist/src/walk/shape.js +166 -56
- package/dist/src/walk/vendor-fetch.d.ts +7 -2
- package/dist/src/walk/vendor-fetch.js +13 -2
- package/dist/src/walk/walk-database.d.ts +9 -1
- package/dist/src/walk/walk-database.js +35 -13
- package/dist/src/walk/xml-view.d.ts +3 -2
- package/dist/src/walk/xml-view.js +5 -4
- package/package.json +4 -3
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/run.ts +17 -15
- package/src/clients/tinybird.ts +8 -6
- package/src/clients/vercel.ts +67 -0
- package/src/conformance.ts +313 -128
- package/src/consumer.ts +83 -21
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +3 -1
- package/src/gate.ts +3 -0
- package/src/lanes.ts +7 -0
- package/src/protocol-3.ts +7 -3
- package/src/spec-documents.ts +92 -3
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/score-pack.ts +7 -6
- package/src/tools/vendor-examples.ts +88 -75
- package/src/walk/behavior-journey.ts +145 -109
- package/src/walk/journey-kit.ts +7 -5
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +25 -5
- package/src/walk/shape.ts +156 -54
- package/src/walk/vendor-fetch.ts +16 -4
- package/src/walk/walk-database.ts +41 -15
- 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 {
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
let body
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
678
|
+
let answered: StepAnswer = { status: 0, body: undefined };
|
|
683
679
|
try {
|
|
684
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
832
|
-
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
package/src/walk/journey-kit.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
+
}
|