@volter/twin-standard 1.0.148 → 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/dist/src/assessment.d.ts +1 -0
- package/dist/src/conformance.d.ts +0 -2
- package/dist/src/conformance.js +37 -2
- package/dist/src/create-pack.js +7 -1
- package/dist/src/derive.js +21 -13
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +47 -4
- package/dist/src/grade.d.ts +1 -0
- package/dist/src/grade.js +1 -1
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pack-facts.js +11 -2
- package/dist/src/pack-files.js +2 -1
- package/dist/src/protocol-3.js +10 -4
- package/dist/src/spec-ir-client.js +3 -2
- package/dist/src/spec-ir-commands.d.ts +73 -0
- package/dist/src/spec-ir-commands.js +63 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/src/walk/behavior-journey.js +100 -2
- package/dist/src/walk/journey-kit.d.ts +21 -1
- package/dist/src/walk/journey-kit.js +1 -0
- package/package.json +2 -2
- package/src/conformance.ts +34 -2
- package/src/create-pack.ts +8 -2
- package/src/derive.ts +21 -14
- package/src/gate.ts +40 -6
- package/src/grade.ts +2 -2
- package/src/pack-facts.ts +10 -3
- package/src/pack-files.ts +2 -1
- package/src/protocol-3.ts +8 -4
- package/src/spec-ir-client.ts +3 -2
- package/src/spec-ir-commands.ts +81 -0
- package/src/types.ts +2 -0
- package/src/walk/behavior-journey.ts +59 -2
- package/src/walk/journey-kit.ts +14 -1
package/src/spec-ir-commands.ts
CHANGED
|
@@ -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; }
|
package/src/walk/journey-kit.ts
CHANGED
|
@@ -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. */
|