@volter/twin-standard 1.0.147 → 1.0.149

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/src/protocol-3.ts CHANGED
@@ -88,7 +88,7 @@ function filesUnder(dir: string, base = dir): string[] {
88
88
  if (run.status !== 0 || run.error) {
89
89
  throw new Error(`git ls-files failed: directory=${JSON.stringify(top)} status=${run.status} signal=${JSON.stringify(run.signal)} error=${JSON.stringify(run.error?.message ?? null)} stderr=${JSON.stringify(String(run.stderr ?? ''))}`);
90
90
  }
91
- files = [...new Set(String(run.stdout).split('\0').filter(Boolean))];
91
+ files = [...new Set(String(run.stdout).split('\0').filter(f => f && !f.split('/').includes('.metadata_never_index')))];
92
92
  listing?.set(top, files);
93
93
  }
94
94
  const under = rel(top, dir);
@@ -100,6 +100,8 @@ function allFilesUnder(dir: string, base = dir, excludeArtifacts = false): strin
100
100
  if (!existsSync(dir)) return [];
101
101
  const out: string[] = [];
102
102
  for (const name of readdirSync(dir)) {
103
+ // macOS directory bookkeeping is not a pack source or release output.
104
+ if (name === '.metadata_never_index') continue;
103
105
  const path = join(dir, name);
104
106
  if (excludeArtifacts && isPackArtifact(rel(base, path))) continue;
105
107
  if (statSync(path).isDirectory()) out.push(...allFilesUnder(path, base, excludeArtifacts));
@@ -275,7 +277,9 @@ const criteria: Criterion[] = [
275
277
  }
276
278
  // the pack's own path and the scratch directory are this checkout's, not the pack's: the verdict names neither, so
277
279
  // a standing written from one checkout reads the same from any other
278
- if (run.status !== 0) return fail(`derive-pack refused: ${String(run.stderr || run.stdout).split(unit.dir).join('.').split(tmp).join('<out>').trim().split('\n').slice(-2).join(' ').slice(0, 200)}`);
280
+ if (run.status === null || run.signal !== null || run.error) return { pass: false, cacheable: false,
281
+ reason: `derive-pack interrupted: status=${run.status} signal=${run.signal} error=${run.error?.message ?? ''}` };
282
+ if (run.status !== 0) return { ...fail(`derive-pack refused: ${String(run.stderr || run.stdout).split(unit.dir).join('.').split(tmp).join('<out>').trim().split('\n').slice(-2).join(' ').slice(0, 200)}`), cacheable: run.status === 2 };
279
283
  return changed.length ? fail(`derive-pack writes differently: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` and ${changed.length - 5} more` : ''}`) : pass(`${filesUnder(gen).length} files reproduced`);
280
284
  },
281
285
  },
@@ -452,7 +456,7 @@ const criteria: Criterion[] = [
452
456
  },
453
457
  {
454
458
  id: 'kernel-contract', section: 'form',
455
- asks: "every file of src/ imports the kernel through its root, the pack contract, and nothing past it: no `@volter/world-core/runtime` or other entry (cli.ts alone takes args and lifecycle), no node builtin, no other package but @volter/world-ui",
459
+ asks: "every file of src/ imports the kernel through its root, the pack contract, and nothing past it: no `@volter/world-core/runtime` or other entry (cli.ts alone takes args and lifecycle), no node builtin, no other package but @volter/world-ui and the generated GraphQL library loader",
456
460
  check(unit) {
457
461
  const src = join(unit.dir, 'src');
458
462
  if (!existsSync(src)) return fail('src/ is missing');
@@ -460,7 +464,29 @@ const criteria: Criterion[] = [
460
464
  for (const f of filesUnder(src).filter((x) => /\.tsx?$/.test(x) && !TEST_FILE.test(x) && !x.startsWith('generated/'))) {
461
465
  // the file's imports, re-exports and dynamic imports as TypeScript reads them (a string that only reads like one,
462
466
  // an error message's "from '<name>'", is none)
463
- const specifiers = ts.preProcessFile(readFileSync(join(src, f), 'utf8'), true, true).importedFiles.map((i) => i.fileName);
467
+ let text = readFileSync(join(src, f), 'utf8');
468
+ if (f === 'fetch.ts') {
469
+ // The generated fetch supplies the kernel wire with the pack-local parser library.
470
+ // Exempt only that exact factory part; other static or dynamic imports still count.
471
+ const source = ts.createSourceFile(f, text, ts.ScriptTarget.Latest, true);
472
+ const loaders: Array<{ start: number; end: number }> = [];
473
+ const visit = (node: ts.Node): void => {
474
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword &&
475
+ node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]!) && node.arguments[0]!.text === 'graphql') {
476
+ const arrow = node.parent, property = arrow.parent;
477
+ const graphql = property.parent?.parent, factory = graphql?.parent?.parent;
478
+ if (ts.isArrowFunction(arrow) && arrow.parameters.length === 0 && arrow.body === node &&
479
+ ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'loadLibrary' &&
480
+ graphql && ts.isPropertyAssignment(graphql) && ts.isIdentifier(graphql.name) && graphql.name.text === 'graphql' &&
481
+ factory && ts.isCallExpression(factory) && ts.isIdentifier(factory.expression) && factory.expression.text === 'createPackFetch' &&
482
+ factory.arguments[0] === graphql.parent) loaders.push({ start: node.getStart(source), end: node.end });
483
+ }
484
+ ts.forEachChild(node, visit);
485
+ };
486
+ visit(source);
487
+ for (const loader of loaders.sort((a, b) => b.start - a.start)) text = text.slice(0, loader.start) + 'undefined' + text.slice(loader.end);
488
+ }
489
+ const specifiers = ts.preProcessFile(text, true, true).importedFiles.map((i) => i.fileName);
464
490
  for (const from of specifiers) {
465
491
  if (from.startsWith('.')) continue;
466
492
  if (from === '@volter/world-core' || from === '@volter/world-ui') continue;
@@ -530,13 +556,13 @@ const criteria: Criterion[] = [
530
556
  const manifestPath = join(unit.dir, 'src', 'manifest.ts');
531
557
  const statesPath = join(unit.dir, 'src', 'semantics', 'states.ts');
532
558
  let manifest: { resources?: Record<string, { state?: unknown; notState?: unknown }> } | undefined;
533
- try { manifest = ((await import(manifestPath)) as { manifest?: typeof manifest }).manifest; } catch (e) { return fail(`manifest.ts does not load: ${(e as Error).message.slice(0, 120)}`); }
559
+ try { manifest = ((await import(manifestPath)) as { manifest?: typeof manifest }).manifest; } catch (e) { return { ...fail(`manifest.ts does not load: ${(e as Error).message.slice(0, 120)}`), cacheable: false }; }
534
560
  const resources = Object.entries(manifest?.resources ?? {});
535
561
  const ruled = resources.filter(([, r]) => (r.state && Object.keys(r.state as object).length) || (Array.isArray(r.notState) && r.notState.length));
536
562
  if (!ruled.length) return pass('the pack declares no state machine and rules no candidate');
537
563
  if (!existsSync(statesPath)) return fail(`${ruled.length} resources carry states or rulings, and there is no semantics/states.ts`);
538
564
  let states: Record<string, { state?: unknown; notState?: unknown }> | undefined;
539
- try { states = ((await import(statesPath)) as { states?: typeof states }).states; } catch (e) { return fail(`semantics/states.ts does not load: ${(e as Error).message.slice(0, 120)}`); }
565
+ try { states = ((await import(statesPath)) as { states?: typeof states }).states; } catch (e) { return { ...fail(`semantics/states.ts does not load: ${(e as Error).message.slice(0, 120)}`), cacheable: false }; }
540
566
  if (!states) return fail('semantics/states.ts exports no `states`');
541
567
  const off = ruled.filter(([name, r]) => (r.state !== undefined && r.state !== states![name]?.state) || (r.notState !== undefined && r.notState !== states![name]?.notState)).map(([name]) => name);
542
568
  return off.length ? fail(`resources whose states or rulings are not states.ts's own: ${off.join(', ')}`) : pass(`${ruled.length} resources, their states and rulings from states.ts`);
@@ -1,8 +1,9 @@
1
1
  // A vendor that publishes no spec of its API but ships its own client: the API as that client calls it. Tinybird
2
2
  // publishes no OpenAPI of its platform API (its reference pages omit endpoints its own CLI calls: /v0/tags,
3
3
  // /v0/sql_tables), and its Python client (`tinybird/client.py`, class TinyB, in tinybird-cli on PyPI) makes every
4
- // request through `_req(url, method=..., data=...)`. Each such call, read from the client's syntax tree (the pack's
5
- // `spec/extract-client-ops.py`), is vendored as data in `spec/client-ops.json`: its method, its URL as the client
4
+ // request through `_req(url, method=..., data=...)`. Each such call, read from the client's syntax tree, is vendored as
5
+ // data in `spec/client-ops.json` (the program that read it is not in the pack: a pack keeps no converter in its
6
+ // `spec/`, Creating a pack, step 2): its method, its URL as the client
6
7
  // builds it (a label is the Python expression the client interpolates), the query keys it sends (literal, or the keys
7
8
  // of the dict it urlencodes) and its body's keys, each typed by the client's own annotation of the value (`null`
8
9
  // where the client does not say). A spec patch corrects the table as it corrects a JSON spec (derive-pack): what the
@@ -48,3 +48,84 @@ export function fromRedisCommands(files: Record<string, RedisCommandFile>, versi
48
48
  commands.sort((a, b) => a.id.localeCompare(b.id));
49
49
  return { format: 'command-table', version, commands };
50
50
  }
51
+
52
+ // ── A command language whose commands are DOCUMENTS ─────────────────────────────────────────────────────────────
53
+ // A command of MongoDB's wire protocol is not words with an arity: it is a document whose first key names the
54
+ // command and whose other keys are named fields, some of them lists of statements (an update's `updates`) with
55
+ // fields of their own, and every command also carries the fields the layers under it add (the session's, the
56
+ // connection's). The vendor publishes no table of these that a pack may derive from (its reference and its driver
57
+ // specifications are licensed NonCommercial); its own client is Apache-2.0 and builds every one of them. So the
58
+ // table is the client's statement: the client's syntax tree is read by a program (as Tinybird's Python client's is,
59
+ // spec-ir-client.ts) and what it read is the pack's vendored document, `spec/command-builders.json`; this reads that
60
+ // data into the command table. The program is kept beside that document in the pack's `spec/`, as the table's
61
+ // provenance, and is never a kernel script (Creating a pack, step 2).
62
+ //
63
+ // What the table says and does not: which commands a client can send and, for each, every field, its declared
64
+ // type, and the condition the client sends it under. It says nothing of what a server does with them. A field's
65
+ // condition is recorded, never evaluated: `always`; `given` (the condition tests only the option itself);
66
+ // `wire-version` (it depends on what the server's hello says); `other`, kept as the client wrote it.
67
+
68
+ export type FieldCondition = { form: 'always' | 'given' | 'wire-version' | 'other'; text?: string };
69
+ /** A field of a command document: where the client sets it and under what condition. `by` names a helper that sets
70
+ * it; `from` the builder it was read in, where that is not the command's own (a base class, a subclass). */
71
+ export type CommandField = { name: string; type: string; optional?: boolean; sentWhen: FieldCondition; source: { file: string; line: number }; by?: string; from?: string };
72
+ /** A command that is a document. `statements`: for a field that is a list of statements, the fields of one. */
73
+ export type DocumentCommandDef = { id: string; fields: CommandField[]; statements?: Record<string, CommandField[]> };
74
+ export type DocumentCommandTableSpec = { format: 'command-table'; language: 'document'; version: string; source: { package: string; commit: string; licence: string }; commands: DocumentCommandDef[]; envelope: CommandField[] };
75
+
76
+ type BuilderField = CommandField & { typeFrom?: string; value?: string; also?: unknown[]; spreadUnder?: string };
77
+ type Builder = { builder: string; kind: 'command' | 'statement'; file: string; line: number; command: string | null; class?: string; extends?: string; addsTo?: string; fields: BuilderField[] };
78
+ export type DriverCommandBuilders = {
79
+ format: 'driver-command-builders';
80
+ driver: { package: string; version: string; commit: string; licence: string };
81
+ builders: Builder[];
82
+ envelope: Array<{ layer: string; file: string; line: number; fields: BuilderField[] }>;
83
+ };
84
+
85
+ const fieldOf = (f: BuilderField, from?: string): CommandField => ({
86
+ name: f.name, type: f.type, ...(f.optional ? { optional: true } : {}), sentWhen: f.sentWhen, source: f.source,
87
+ ...(f.by ? { by: f.by } : {}), ...(from ? { from } : {}),
88
+ });
89
+
90
+ /** The command table of a client's command builders: each command with its own fields, the fields its subclasses
91
+ * add, the fields a base class adds to the commands that descend from it, and the fields of its statements. A builder this cannot place is
92
+ * an error naming it; a table with a silent hole is never returned. */
93
+ export function fromDriverCommandBuilders(doc: DriverCommandBuilders): DocumentCommandTableSpec {
94
+ const named = doc.builders.filter((b) => b.kind === 'command' && b.command !== null);
95
+ const classOf = new Map(named.filter((b) => b.class).map((b) => [b.class!, b.command!]));
96
+ const commands = new Map<string, DocumentCommandDef>();
97
+ const add = (id: string, fields: BuilderField[], from?: string): void => {
98
+ const held = commands.get(id) ?? { id, fields: [] };
99
+ for (const f of fields) if (!held.fields.some((h) => h.name === f.name)) held.fields.push(fieldOf(f, from));
100
+ commands.set(id, held);
101
+ };
102
+ for (const b of named) add(b.command!, b.fields);
103
+ // what a subclass adds to its parent's document, and what the base of every command adds to all of them
104
+ const everyCommand: Builder[] = [];
105
+ for (const b of doc.builders.filter((b) => b.kind === 'command' && b.command === null)) {
106
+ if (b.addsTo === undefined) throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) names no command and adds to none`);
107
+ const parent = classOf.get(b.addsTo);
108
+ if (parent !== undefined) add(parent, b.fields, b.builder);
109
+ else if (b.class === b.addsTo) everyCommand.push(b);
110
+ else throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) adds to ${b.addsTo}, which builds no named command`);
111
+ }
112
+ // the base's fields go to the commands whose class descends from it, and to no other (a getMore is not one)
113
+ const parentOf = new Map(doc.builders.filter((b) => b.class && b.extends).map((b) => [b.class!, b.extends!]));
114
+ const descends = (cls: string | undefined, base: string): boolean => { for (let at = cls, hops = 0; at !== undefined && hops < 16; at = parentOf.get(at), hops += 1) if (at === base) return true; return false; };
115
+ for (const b of everyCommand) for (const n of named) if (descends(n.extends, b.class!)) add(n.command!, b.fields, b.builder);
116
+ // a statement builder's fields belong to the command of its file that carries the statement list
117
+ for (const b of doc.builders.filter((b) => b.kind === 'statement')) {
118
+ const owner = named.find((n) => n.file === b.file && n.fields.some((f) => f.value === 'this.statements'));
119
+ const list = owner?.fields.find((f) => f.value === 'this.statements');
120
+ if (!owner || !list) throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) builds a statement and no command of its file carries a statement list`);
121
+ (commands.get(owner.command!)!.statements ??= {})[list.name] = b.fields.map((f) => fieldOf(f));
122
+ }
123
+ const envelope: CommandField[] = [];
124
+ for (const layer of doc.envelope) for (const f of layer.fields) if (!envelope.some((h) => h.name === f.name)) envelope.push(fieldOf(f, layer.layer));
125
+ if (commands.size === 0) throw new Error('command-builders: no command was read');
126
+ return {
127
+ format: 'command-table', language: 'document', version: doc.driver.version,
128
+ source: { package: doc.driver.package, commit: doc.driver.commit, licence: doc.driver.licence },
129
+ commands: [...commands.values()].sort((a, b) => a.id.localeCompare(b.id)), envelope,
130
+ };
131
+ }
package/src/types.ts CHANGED
@@ -29,6 +29,8 @@ export interface ScoreReport {
29
29
 
30
30
  export interface CriterionResult {
31
31
  pass: boolean;
32
+ /** An interrupted check is not a verdict about unchanged source. */
33
+ cacheable?: boolean;
32
34
  /** why it failed, or what it measured when it passed */
33
35
  reason: string;
34
36
  }
@@ -9,7 +9,8 @@ import { attributionName } from '../verdict.ts';
9
9
  //
10
10
  // The caller names the pack repository first (usePacks).
11
11
 
12
- import { mysql, redis } from '@volter/world-core';
12
+ import { mongodb, mysql, redis } from '@volter/world-core';
13
+ import { createRequire } from 'node:module';
13
14
  import { gzipSync } from 'node:zlib';
14
15
  import { parse, visit } from 'graphql';
15
16
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -17,7 +18,7 @@ import { join } from 'node:path';
17
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';
18
19
  import { createHostTable, type HostFacts } from '@volter/world-core/host-routing';
19
20
  import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
20
- import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, type MysqlStep, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
21
+ 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';
21
22
  import { lanesOf, lineShape, shapeChecker, type LineConnection, type ShapeChecker } from './shape.ts';
22
23
  import { walkPackDirectory } from './pack-directory.ts';
23
24
  import { xmlMembers } from './xml-view.ts';
@@ -446,6 +447,61 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
446
447
  finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
447
448
  };
448
449
 
450
+ // A MongoDB step speaks the pack's MongoDB wire as an application's driver does (@volter/world-core/mongodb's client), on
451
+ // the same byte-stream adapter the owned TCP listener serves. The document codec is BSON, the library the pack
452
+ // installs: the walk loads it from the pack's own dependencies, so a step is encoded by the library the pack decodes with.
453
+ const mongoLinks = new Map<string, { client: ReturnType<typeof mongodb.mongoClient>; database: string; close: () => void }>();
454
+ type Bson = { serialize(document: Record<string, unknown>): Uint8Array; deserialize(bytes: Uint8Array, options: Record<string, unknown>): Record<string, unknown>; EJSON: { deserialize(value: unknown, options: { relaxed: boolean }): Record<string, unknown>; serialize(value: unknown, options: { relaxed: boolean }): Record<string, unknown> } };
455
+ let bson: Bson | undefined;
456
+ const bareNumber = (value: unknown, at = 'mongo'): string | undefined => {
457
+ if (typeof value === 'number') return at;
458
+ if (Array.isArray(value)) { for (const [i, item] of value.entries()) { const found = bareNumber(item, `${at}[${i}]`); if (found) return found; } return undefined; }
459
+ if (value && typeof value === 'object') for (const [key, item] of Object.entries(value)) { const found = bareNumber(item, `${at}.${key}`); if (found) return found; }
460
+ return undefined;
461
+ };
462
+ const mongoStep = async (step: MongoStep): Promise<void> => {
463
+ const before = collect.length; let answered: { status: number; body: unknown } = { status: 0, body: undefined };
464
+ const link = step.link ?? 'mongo';
465
+ opts.hooks?.before?.(step as never, link, step.mongo);
466
+ try {
467
+ if (!bson) {
468
+ const pack = walkPackDirectory(packsDir(), journey.vendor, dependenciesDir());
469
+ if (!pack) throw new Error(`no pack directory for ${journey.vendor} to load its BSON library from`);
470
+ bson = createRequire(join(pack, 'package.json'))('bson') as Bson;
471
+ }
472
+ const codec: mongodb.MongoCodec = { serialize: (document) => bson!.serialize(document), deserialize: (bytes) => bson!.deserialize(bytes, { promoteValues: false, promoteLongs: false, bsonRegExp: true }) };
473
+ let held = mongoLinks.get(link);
474
+ if (!held) {
475
+ if (!served?.stream) streams ??= await vendorStream(packsDir(), journey.vendor, dependenciesDir());
476
+ const factory = served?.stream ?? streams?.(root, {}); if (!factory) throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
477
+ let out: number[] = [];
478
+ const conn = factory({ write: (b) => { out.push(...b); }, end: () => {} }, '127.0.0.1');
479
+ const exchange: mongodb.MongoExchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
480
+ held = { client: mongodb.mongoClient(exchange, codec), database: 'test', close: () => conn.close() };
481
+ mongoLinks.set(link, held);
482
+ }
483
+ if (step.database) held.database = fill(step.database, captured);
484
+ const written = fillJson(step.mongo, captured);
485
+ const bare = bareNumber(written);
486
+ if (bare) throw new Error(`${bare} is a bare JSON number: write its kind ({ "$numberInt": "…" }, $numberLong or $numberDouble)`);
487
+ const reply = await held.client.command(held.database, bson.EJSON.deserialize(written, { relaxed: false }));
488
+ const body = bson.EJSON.serialize(reply, { relaxed: false });
489
+ const ok = (body.ok as Record<string, unknown> | undefined), failed = !ok || Number(Object.values(ok)[0]) !== 1;
490
+ const code = failed ? Number(Object.values((body.code as Record<string, unknown> | undefined) ?? { none: Number.NaN })[0]) : undefined;
491
+ answered = { status: code ?? 0, body };
492
+ if (opts.verbose) console.log(` ${String(code ?? 'OK').padEnd(5)} ${link.padEnd(6)} ${String(Object.keys(step.mongo)[0] ?? '').padEnd(20)} ${step.name}`);
493
+ if (step.refused !== undefined ? code !== step.refused : failed) {
494
+ collect.push({ step: step.name, beat: 'STATUS', detail: failed ? `MongoDB refused it: ${code} ${String(body.codeName ?? '')} ${String(body.errmsg ?? '')}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MongoDB completed it, wanted ${step.refused}` });
495
+ return;
496
+ }
497
+ for (const [name, dot] of Object.entries(step.capture ?? {})) { const value = captureAt(body, dot); if (value === undefined || value === null) collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` }); else remember(name, value); }
498
+ try { for (const detail of expectFailures(step.expect ?? [], body, captured)) collect.push({ step: step.name, beat: 'EXPECT', detail }); }
499
+ catch (e) { collect.push({ step: step.name, beat: 'CAPTURE', detail: (e as Error).message }); }
500
+ if (step.close) { mongoLinks.get(link)?.close(); mongoLinks.delete(link); }
501
+ } catch (e) { collect.push({ step: step.name, beat: 'MONGO', detail: (e as Error).message }); }
502
+ finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
503
+ };
504
+
449
505
  // A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
450
506
  // checked as a request's is (status, captures, expectations).
451
507
  const gitStep = async (step: GitStep): Promise<void> => {
@@ -679,6 +735,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
679
735
  try {
680
736
  for (const step of journey.steps) {
681
737
  stepNow = step.name;
738
+ if (isMongo(step)) { await mongoStep(step); continue; }
682
739
  if (isMysql(step)) { await mysqlStep(step); continue; }
683
740
  if (isResp(step)) { await respStep(step); continue; }
684
741
  if (isLine(step)) { await speak(step); continue; }
@@ -327,7 +327,7 @@ export type Journey = {
327
327
  steps: Step[];
328
328
  };
329
329
 
330
- export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
330
+ export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep | MongoStep;
331
331
 
332
332
  /** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
333
333
  * application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
@@ -342,6 +342,19 @@ export type MysqlStep = {
342
342
  };
343
343
  export const isMysql = (step: Step): step is MysqlStep => 'mysql' in step || 'signIn' in step;
344
344
 
345
+ /** A step on a pack's MongoDB wire, sent as an application's driver sends it (`@volter/world-core/mongodb`'s client)
346
+ * on a named `link`, opened by the first step that names it. `mongo` is ONE command document, written in
347
+ * canonical extended JSON so that what a document holds is written as it is: an ObjectId as `{ "$oid": … }`, a date
348
+ * as `{ "$date": … }`, and a number with its kind (`{ "$numberInt": "1" }`, `$numberLong`, `$numberDouble`); a bare
349
+ * JSON number is refused, because JSON does not say which of three kinds it is. `database` is the database it is
350
+ * sent to (the link's last one, else `test`). Its answer is the reply document in the same form. A reply whose
351
+ * `ok` is 0 fails the step unless `refused` names its `code`. */
352
+ export type MongoStep = {
353
+ name: string; mongo: Record<string, unknown>; database?: string; link?: string; close?: boolean;
354
+ refused?: number; capture?: JourneyStep['capture']; expect?: JourneyStep['expect']; example?: string; exampleDiffers?: Record<string, string>;
355
+ };
356
+ export const isMongo = (step: Step): step is MongoStep => 'mongo' in step;
357
+
345
358
  /** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
346
359
  * Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
347
360
  * `read` drains pushes or a blocking command's answer without sending bytes. */