@volter/twin-standard 1.0.61 → 1.0.63
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/create-pack.d.ts +1 -1
- package/dist/src/create-pack.js +7 -2
- package/dist/src/walk/behavior-journey.js +79 -2
- package/dist/src/walk/journey-kit.d.ts +23 -1
- package/dist/src/walk/journey-kit.js +1 -0
- package/package.json +2 -2
- package/src/create-pack.ts +7 -2
- package/src/walk/behavior-journey.ts +47 -2
- package/src/walk/journey-kit.ts +14 -1
|
@@ -26,7 +26,7 @@ export declare function packIndexFile(dir: string, vendor: string): string;
|
|
|
26
26
|
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
|
|
27
27
|
* (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
|
|
28
28
|
* the unit's proto is derived. */
|
|
29
|
-
export declare function serverFile(vendor: string, grpc: boolean): string;
|
|
29
|
+
export declare function serverFile(vendor: string, grpc: boolean, wire?: boolean): string;
|
|
30
30
|
/** A pack's tsconfig.json, a fixed file: what the grade's typecheck (soundness) compiles, the pack and its lanes. */
|
|
31
31
|
export declare const TSCONFIG: string;
|
|
32
32
|
/** The fixed files of a new pack, by path, for a vendor: what nobody should write by hand. */
|
package/dist/src/create-pack.js
CHANGED
|
@@ -336,10 +336,15 @@ export function packIndexFile(dir, vendor) {
|
|
|
336
336
|
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
|
|
337
337
|
* (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
|
|
338
338
|
* the unit's proto is derived. */
|
|
339
|
-
export function serverFile(vendor, grpc) {
|
|
339
|
+
export function serverFile(vendor, grpc, wire = false) {
|
|
340
340
|
const Name = pascal(vendor);
|
|
341
341
|
const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
|
|
342
342
|
const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
|
|
343
|
+
if (grpc && wire) {
|
|
344
|
+
// a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
|
|
345
|
+
// port, `wirePort`, which the manifest's sidePorts declares
|
|
346
|
+
return `${head}import { grpc, serveStream, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\nimport { create${Name}TwinStream } from './stream.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; wirePort?: number } = {}): Promise<{ port: number; wirePort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n const server = await grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n // the unit's own wire beside its API, on its side port (the manifest's sidePorts: \`wirePort\`)\n const wire = await serveStream({ port: options.wirePort ?? 0, stream: create${Name}TwinStream(options) });\n return { port: server.port, wirePort: wire.port, stop: () => { wire.stop(); server.stop(); } };\n}\n`;
|
|
347
|
+
}
|
|
343
348
|
if (grpc) {
|
|
344
349
|
return `${head}import { grpc, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n}\n`;
|
|
345
350
|
}
|
|
@@ -541,7 +546,7 @@ export async function createPack(args) {
|
|
|
541
546
|
// a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
|
|
542
547
|
// the one form a unit takes after --init (a server.ts of a wire of the pack's own, SMTP's, is never written over)
|
|
543
548
|
if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
544
|
-
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true));
|
|
549
|
+
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
|
|
545
550
|
console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
|
|
546
551
|
}
|
|
547
552
|
}
|
|
@@ -8,7 +8,7 @@ import { attributionName } from "../verdict.js";
|
|
|
8
8
|
// score-pack walks the life through it twice, on two fresh roots, and compares what they answer.
|
|
9
9
|
//
|
|
10
10
|
// The caller names the pack repository first (usePacks).
|
|
11
|
-
import { redis } from '@volter/world-core';
|
|
11
|
+
import { mysql, redis } from '@volter/world-core';
|
|
12
12
|
import { gzipSync } from 'node:zlib';
|
|
13
13
|
import { parse, visit } from 'graphql';
|
|
14
14
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
@@ -16,7 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { databaseSession, getActiveWorldStore, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActiveWorldStore, setDeliveryObserver, setInProcessTwins } from '@volter/world-core/runtime';
|
|
17
17
|
import { createHostTable } from '@volter/world-core/host-routing';
|
|
18
18
|
import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
19
|
-
import { isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
|
|
19
|
+
import { isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
|
|
20
20
|
import { lanesOf, lineShape, shapeChecker } from "./shape.js";
|
|
21
21
|
import { xmlMembers } from "./xml-view.js";
|
|
22
22
|
/** The pack repository the walk finds a vendor's pack in, set by the caller before its first walk. */
|
|
@@ -472,6 +472,79 @@ export async function walk(journey, root, collect, opts) {
|
|
|
472
472
|
opts.hooks?.after?.(step, step.connection, answered, collect.slice(before));
|
|
473
473
|
}
|
|
474
474
|
};
|
|
475
|
+
// A MySQL step speaks the pack's MySQL wire as an application's driver does (@volter/world-core/mysql's client), on the
|
|
476
|
+
// same byte-stream adapter the owned TCP listener serves.
|
|
477
|
+
const mysqlConnections = new Map();
|
|
478
|
+
const mysqlStep = async (step) => {
|
|
479
|
+
const before = collect.length;
|
|
480
|
+
let answered = { status: 0, body: undefined };
|
|
481
|
+
const link = step.link ?? 'mysql';
|
|
482
|
+
opts.hooks?.before?.(step, link, step.mysql ?? '(sign in)');
|
|
483
|
+
try {
|
|
484
|
+
let body;
|
|
485
|
+
if (step.signIn) {
|
|
486
|
+
if (!served?.stream)
|
|
487
|
+
streams ??= await vendorStream(packsDir(), journey.vendor);
|
|
488
|
+
const factory = served?.stream ?? streams?.(root, {});
|
|
489
|
+
if (!factory)
|
|
490
|
+
throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
|
|
491
|
+
let out = [];
|
|
492
|
+
const conn = factory({ write: (b) => { out.push(...b); }, end: () => { } }, '127.0.0.1');
|
|
493
|
+
await conn.settled();
|
|
494
|
+
const greeting = Uint8Array.from(out);
|
|
495
|
+
out = [];
|
|
496
|
+
const exchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
|
|
497
|
+
const signed = await mysql.mysqlSignIn(exchange, greeting, fill(step.signIn.user, captured), fill(step.signIn.password, captured), step.signIn.database ? fill(step.signIn.database, captured) : null);
|
|
498
|
+
if ('error' in signed)
|
|
499
|
+
body = { ...signed.error };
|
|
500
|
+
else
|
|
501
|
+
mysqlConnections.set(link, { exchange, close: () => conn.close() });
|
|
502
|
+
}
|
|
503
|
+
if (!body && step.mysql !== undefined) {
|
|
504
|
+
const c = mysqlConnections.get(link);
|
|
505
|
+
if (!c)
|
|
506
|
+
throw new Error(`no MySQL connection "${link}" is open`);
|
|
507
|
+
const sql = fill(step.mysql, captured);
|
|
508
|
+
const result = step.values ? await mysql.mysqlExecute(c.exchange, sql, step.values.map((v) => (v === null ? null : fill(v, captured)))) : await mysql.mysqlQuery(c.exchange, sql);
|
|
509
|
+
body = 'error' in result ? { ...result.error }
|
|
510
|
+
: 'columns' in result ? { columns: result.columns.map((col) => col.name), rows: result.rows.map((row) => Object.fromEntries(result.columns.map((col, i) => [col.name, row[i] ?? null]))) }
|
|
511
|
+
: { ...result };
|
|
512
|
+
}
|
|
513
|
+
body ??= { signedIn: true };
|
|
514
|
+
const errno = typeof body.errno === 'number' ? body.errno : undefined;
|
|
515
|
+
answered = { status: errno ?? 0, body };
|
|
516
|
+
if (opts.verbose)
|
|
517
|
+
console.log(` ${String(errno ?? 'OK').padEnd(4)} ${link.padEnd(6)} ${(step.mysql ?? '(sign in)').slice(0, 70).padEnd(70)} ${step.name}`);
|
|
518
|
+
if (step.refused !== undefined ? errno !== step.refused : errno !== undefined) {
|
|
519
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: errno !== undefined ? `MySQL refused it: ${errno} ${String(body.message)}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MySQL completed it, wanted ${step.refused}` });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
523
|
+
const value = captureAt(body, dot);
|
|
524
|
+
if (value === undefined || value === null)
|
|
525
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
526
|
+
else
|
|
527
|
+
remember(name, value);
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
for (const detail of expectFailures(step.expect ?? [], body, captured))
|
|
531
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail });
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
535
|
+
}
|
|
536
|
+
if (step.close) {
|
|
537
|
+
mysqlConnections.get(link)?.close();
|
|
538
|
+
mysqlConnections.delete(link);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
collect.push({ step: step.name, beat: 'MYSQL', detail: e.message });
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
opts.hooks?.after?.(step, link, answered, collect.slice(before));
|
|
546
|
+
}
|
|
547
|
+
};
|
|
475
548
|
// 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
|
|
476
549
|
// checked as a request's is (status, captures, expectations).
|
|
477
550
|
const gitStep = async (step) => {
|
|
@@ -824,6 +897,10 @@ export async function walk(journey, root, collect, opts) {
|
|
|
824
897
|
try {
|
|
825
898
|
for (const step of journey.steps) {
|
|
826
899
|
stepNow = step.name;
|
|
900
|
+
if (isMysql(step)) {
|
|
901
|
+
await mysqlStep(step);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
827
904
|
if (isResp(step)) {
|
|
828
905
|
await respStep(step);
|
|
829
906
|
continue;
|
|
@@ -226,7 +226,29 @@ export type Journey = {
|
|
|
226
226
|
}) => boolean;
|
|
227
227
|
steps: Step[];
|
|
228
228
|
};
|
|
229
|
-
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep;
|
|
229
|
+
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
|
|
230
|
+
/** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
|
|
231
|
+
* application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
|
|
232
|
+
* password (mysql_native_password); `mysql` sends a statement on it: a text query, or a prepared statement when
|
|
233
|
+
* `values` binds its `?` placeholders (binary parameters and rows, as Prisma sends one). Its answer is the rows, each
|
|
234
|
+
* its columns by name in MySQL's text format (`rows`, `columns`), or `{ affectedRows, insertId }`, or MySQL's refusal
|
|
235
|
+
* `{ errno, sqlstate, message }`, which fails the step unless `refused` names its errno. */
|
|
236
|
+
export type MysqlStep = {
|
|
237
|
+
name: string;
|
|
238
|
+
mysql?: string;
|
|
239
|
+
values?: Array<string | null>;
|
|
240
|
+
link?: string;
|
|
241
|
+
signIn?: {
|
|
242
|
+
user: string;
|
|
243
|
+
password: string;
|
|
244
|
+
database?: string;
|
|
245
|
+
};
|
|
246
|
+
close?: boolean;
|
|
247
|
+
refused?: number;
|
|
248
|
+
capture?: JourneyStep['capture'];
|
|
249
|
+
expect?: JourneyStep['expect'];
|
|
250
|
+
};
|
|
251
|
+
export declare const isMysql: (step: Step) => step is MysqlStep;
|
|
230
252
|
/** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
|
|
231
253
|
* Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
|
|
232
254
|
* `read` drains pushes or a blocking command's answer without sending bytes. */
|
|
@@ -197,6 +197,7 @@ export function expandFills(journey) {
|
|
|
197
197
|
});
|
|
198
198
|
return steps.some((x, i) => x !== journey.steps[i]) ? { ...journey, steps } : journey;
|
|
199
199
|
}
|
|
200
|
+
export const isMysql = (step) => 'mysql' in step || 'signIn' in step;
|
|
200
201
|
export const isResp = (step) => 'connection' in step;
|
|
201
202
|
export const isLine = (step) => 'on' in step;
|
|
202
203
|
export const isSocket = (step) => 'socket' in step;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-standard",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"yaml": "2.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@volter/world-core": "3.0.
|
|
78
|
+
"@volter/world-core": "3.0.71",
|
|
79
79
|
"@volter/world-runtime": "*",
|
|
80
80
|
"playwright": "^1.63.0"
|
|
81
81
|
},
|
package/src/create-pack.ts
CHANGED
|
@@ -324,10 +324,15 @@ export function packIndexFile(dir: string, vendor: string): string {
|
|
|
324
324
|
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
|
|
325
325
|
* (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
|
|
326
326
|
* the unit's proto is derived. */
|
|
327
|
-
export function serverFile(vendor: string, grpc: boolean): string {
|
|
327
|
+
export function serverFile(vendor: string, grpc: boolean, wire = false): string {
|
|
328
328
|
const Name = pascal(vendor);
|
|
329
329
|
const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
|
|
330
330
|
const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
|
|
331
|
+
if (grpc && wire) {
|
|
332
|
+
// a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
|
|
333
|
+
// port, `wirePort`, which the manifest's sidePorts declares
|
|
334
|
+
return `${head}import { grpc, serveStream, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\nimport { create${Name}TwinStream } from './stream.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; wirePort?: number } = {}): Promise<{ port: number; wirePort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n const server = await grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n // the unit's own wire beside its API, on its side port (the manifest's sidePorts: \`wirePort\`)\n const wire = await serveStream({ port: options.wirePort ?? 0, stream: create${Name}TwinStream(options) });\n return { port: server.port, wirePort: wire.port, stop: () => { wire.stop(); server.stop(); } };\n}\n`;
|
|
335
|
+
}
|
|
331
336
|
if (grpc) {
|
|
332
337
|
return `${head}import { grpc, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n}\n`;
|
|
333
338
|
}
|
|
@@ -499,7 +504,7 @@ export async function createPack(args: string[]): Promise<void> {
|
|
|
499
504
|
// a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
|
|
500
505
|
// the one form a unit takes after --init (a server.ts of a wire of the pack's own, SMTP's, is never written over)
|
|
501
506
|
if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
502
|
-
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true));
|
|
507
|
+
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
|
|
503
508
|
console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
|
|
504
509
|
}
|
|
505
510
|
}
|
|
@@ -9,7 +9,7 @@ import { attributionName } from '../verdict.ts';
|
|
|
9
9
|
//
|
|
10
10
|
// The caller names the pack repository first (usePacks).
|
|
11
11
|
|
|
12
|
-
import { redis } from '@volter/world-core';
|
|
12
|
+
import { mysql, redis } from '@volter/world-core';
|
|
13
13
|
import { gzipSync } from 'node:zlib';
|
|
14
14
|
import { parse, visit } from 'graphql';
|
|
15
15
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
@@ -17,7 +17,7 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { databaseSession, type DatabaseSession, getActiveWorldStore, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActiveWorldStore, setDeliveryObserver, setInProcessTwins, type InProcessTwin, type TwinStream, type TwinStreamConnection } from '@volter/world-core/runtime';
|
|
18
18
|
import { createHostTable, type HostFacts } from '@volter/world-core/host-routing';
|
|
19
19
|
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, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
|
|
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
21
|
import { lanesOf, lineShape, shapeChecker, type LineConnection, type ShapeChecker } from './shape.ts';
|
|
22
22
|
import { xmlMembers } from './xml-view.ts';
|
|
23
23
|
|
|
@@ -369,6 +369,50 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
|
|
|
369
369
|
finally { opts.hooks?.after?.(step, step.connection, answered, collect.slice(before)); }
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
+
// A MySQL step speaks the pack's MySQL wire as an application's driver does (@volter/world-core/mysql's client), on the
|
|
373
|
+
// same byte-stream adapter the owned TCP listener serves.
|
|
374
|
+
const mysqlConnections = new Map<string, { exchange: mysql.MysqlExchange; close: () => void }>();
|
|
375
|
+
const mysqlStep = async (step: MysqlStep): Promise<void> => {
|
|
376
|
+
const before = collect.length; let answered: { status: number; body: unknown } = { status: 0, body: undefined };
|
|
377
|
+
const link = step.link ?? 'mysql';
|
|
378
|
+
opts.hooks?.before?.(step as never, link, step.mysql ?? '(sign in)');
|
|
379
|
+
try {
|
|
380
|
+
let body: Record<string, unknown> | undefined;
|
|
381
|
+
if (step.signIn) {
|
|
382
|
+
if (!served?.stream) streams ??= await vendorStream(packsDir(), journey.vendor);
|
|
383
|
+
const factory = served?.stream ?? streams?.(root, {}); if (!factory) throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
|
|
384
|
+
let out: number[] = [];
|
|
385
|
+
const conn = factory({ write: (b) => { out.push(...b); }, end: () => {} }, '127.0.0.1');
|
|
386
|
+
await conn.settled();
|
|
387
|
+
const greeting = Uint8Array.from(out); out = [];
|
|
388
|
+
const exchange: mysql.MysqlExchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
|
|
389
|
+
const signed = await mysql.mysqlSignIn(exchange, greeting, fill(step.signIn.user, captured), fill(step.signIn.password, captured), step.signIn.database ? fill(step.signIn.database, captured) : null);
|
|
390
|
+
if ('error' in signed) body = { ...signed.error }; else mysqlConnections.set(link, { exchange, close: () => conn.close() });
|
|
391
|
+
}
|
|
392
|
+
if (!body && step.mysql !== undefined) {
|
|
393
|
+
const c = mysqlConnections.get(link); if (!c) throw new Error(`no MySQL connection "${link}" is open`);
|
|
394
|
+
const sql = fill(step.mysql, captured);
|
|
395
|
+
const result = step.values ? await mysql.mysqlExecute(c.exchange, sql, step.values.map((v) => (v === null ? null : fill(v, captured)))) : await mysql.mysqlQuery(c.exchange, sql);
|
|
396
|
+
body = 'error' in result ? { ...result.error }
|
|
397
|
+
: 'columns' in result ? { columns: result.columns.map((col) => col.name), rows: result.rows.map((row) => Object.fromEntries(result.columns.map((col, i) => [col.name, row[i] ?? null]))) }
|
|
398
|
+
: { ...result };
|
|
399
|
+
}
|
|
400
|
+
body ??= { signedIn: true };
|
|
401
|
+
const errno = typeof body.errno === 'number' ? body.errno : undefined;
|
|
402
|
+
answered = { status: errno ?? 0, body };
|
|
403
|
+
if (opts.verbose) console.log(` ${String(errno ?? 'OK').padEnd(4)} ${link.padEnd(6)} ${(step.mysql ?? '(sign in)').slice(0, 70).padEnd(70)} ${step.name}`);
|
|
404
|
+
if (step.refused !== undefined ? errno !== step.refused : errno !== undefined) {
|
|
405
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: errno !== undefined ? `MySQL refused it: ${errno} ${String(body.message)}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MySQL completed it, wanted ${step.refused}` });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
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); }
|
|
409
|
+
try { for (const detail of expectFailures(step.expect ?? [], body, captured)) collect.push({ step: step.name, beat: 'EXPECT', detail }); }
|
|
410
|
+
catch (e) { collect.push({ step: step.name, beat: 'CAPTURE', detail: (e as Error).message }); }
|
|
411
|
+
if (step.close) { mysqlConnections.get(link)?.close(); mysqlConnections.delete(link); }
|
|
412
|
+
} catch (e) { collect.push({ step: step.name, beat: 'MYSQL', detail: (e as Error).message }); }
|
|
413
|
+
finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
|
|
414
|
+
};
|
|
415
|
+
|
|
372
416
|
// 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
|
|
373
417
|
// checked as a request's is (status, captures, expectations).
|
|
374
418
|
const gitStep = async (step: GitStep): Promise<void> => {
|
|
@@ -602,6 +646,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
|
|
|
602
646
|
try {
|
|
603
647
|
for (const step of journey.steps) {
|
|
604
648
|
stepNow = step.name;
|
|
649
|
+
if (isMysql(step)) { await mysqlStep(step); continue; }
|
|
605
650
|
if (isResp(step)) { await respStep(step); continue; }
|
|
606
651
|
if (isLine(step)) { await speak(step); continue; }
|
|
607
652
|
if (isGit(step)) { await gitStep(step); continue; }
|
package/src/walk/journey-kit.ts
CHANGED
|
@@ -327,7 +327,20 @@ export type Journey = {
|
|
|
327
327
|
steps: Step[];
|
|
328
328
|
};
|
|
329
329
|
|
|
330
|
-
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep;
|
|
330
|
+
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
|
|
331
|
+
|
|
332
|
+
/** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
|
|
333
|
+
* application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
|
|
334
|
+
* password (mysql_native_password); `mysql` sends a statement on it: a text query, or a prepared statement when
|
|
335
|
+
* `values` binds its `?` placeholders (binary parameters and rows, as Prisma sends one). Its answer is the rows, each
|
|
336
|
+
* its columns by name in MySQL's text format (`rows`, `columns`), or `{ affectedRows, insertId }`, or MySQL's refusal
|
|
337
|
+
* `{ errno, sqlstate, message }`, which fails the step unless `refused` names its errno. */
|
|
338
|
+
export type MysqlStep = {
|
|
339
|
+
name: string; mysql?: string; values?: Array<string | null>; link?: string;
|
|
340
|
+
signIn?: { user: string; password: string; database?: string }; close?: boolean;
|
|
341
|
+
refused?: number; capture?: JourneyStep['capture']; expect?: JourneyStep['expect'];
|
|
342
|
+
};
|
|
343
|
+
export const isMysql = (step: Step): step is MysqlStep => 'mysql' in step || 'signIn' in step;
|
|
331
344
|
|
|
332
345
|
/** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
|
|
333
346
|
* Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
|