@nanobpm/nano-workforce 0.188.1 → 0.189.0
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/CHANGELOG.md +6 -0
- package/app/agentic/cockpit/mount.test.ts +50 -0
- package/app/agentic/cockpit/supply-render.test.ts +20 -0
- package/app/agentic/cockpit/supply-render.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +19 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/contracts.ts +24 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/mcpToolSurface.ts +7 -1
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/openapi.yaml +71 -1
- package/operations/enrolAgenticWorker.test.ts +84 -0
- package/operations/enrolAgenticWorker.ts +67 -7
- package/operations/getAgenticRegistry.ts +1 -1
- package/operations/getAgenticSupply.test.ts +80 -0
- package/operations/getAgenticSupply.ts +15 -3
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/test/worldDb.ts +6 -0
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
14
14
|
import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
|
|
15
15
|
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
16
|
+
import {
|
|
17
|
+
HarnessProtocolRegistry,
|
|
18
|
+
isStaleProtocol,
|
|
19
|
+
minHarnessProtocol,
|
|
20
|
+
staleHarnessPolicy,
|
|
21
|
+
} from "../app/harnessProtocol.ts";
|
|
16
22
|
import { envVar } from "../app/version.ts";
|
|
17
23
|
import type { EnrolResult } from "../nano-generated/api-io.d.ts";
|
|
18
24
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -73,6 +79,22 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
73
79
|
app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
|
|
74
80
|
return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
|
|
75
81
|
}
|
|
82
|
+
// The harness-protocol enrolment attribute (issue #802) — a non-negative integer the harness
|
|
83
|
+
// advertises declaring which machine-readable artifacts it emits (AgentInstance, transcript flush,
|
|
84
|
+
// result envelope). A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the
|
|
85
|
+
// type here: a non-integer / negative value would corrupt the persisted staleness gate.
|
|
86
|
+
if (
|
|
87
|
+
body.harnessProtocol !== undefined &&
|
|
88
|
+
(typeof body.harnessProtocol !== "number" ||
|
|
89
|
+
!Number.isInteger(body.harnessProtocol) ||
|
|
90
|
+
body.harnessProtocol < 0)
|
|
91
|
+
) {
|
|
92
|
+
app.log.warn("enrolAgenticWorker rejected: non-integer harnessProtocol");
|
|
93
|
+
return {
|
|
94
|
+
status: 400,
|
|
95
|
+
body: { error: "`harnessProtocol` must be a non-negative integer when provided" },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
// Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
|
|
78
100
|
// may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
|
|
@@ -102,19 +124,57 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// Harness-protocol enrolment gate (issue #802): record the advertised protocol per instance so the
|
|
128
|
+
// app can expose it in getAgenticSupply / the registry and gate on it. Recorded even on a downgrade
|
|
129
|
+
// (a re-enrol WITHOUT a version clears a stale-healthy value to absent → stale). Like durable-resume
|
|
130
|
+
// it needs a non-blank `instance` and is best-effort — a registry write hiccup must not fail enrol.
|
|
131
|
+
if (app.data && instanceKey) {
|
|
132
|
+
try {
|
|
133
|
+
await new HarnessProtocolRegistry(app.data).recordEnrolment(instanceKey, body.harnessProtocol);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
app.log.warn("enrolAgenticWorker: harness-protocol record failed", { instance: instanceKey, err: String(err) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Derive staleness from the just-advertised protocol against the configured minimum (absent version =
|
|
140
|
+
// stale, the #802 signature). Under the `refuse` policy a stale harness is handed an EMPTY SERVE set
|
|
141
|
+
// so it wins no job leases — a REGISTER→SERVE gate, never an engine/job-protocol change. Under the
|
|
142
|
+
// default `flag` policy the SERVE set is unchanged (no routing regression); the worker is only
|
|
143
|
+
// flagged so the cockpit can surface it for drain.
|
|
144
|
+
const harnessStale = isStaleProtocol(body.harnessProtocol, minHarnessProtocol());
|
|
145
|
+
const refuseRouting = harnessStale && staleHarnessPolicy() === "refuse";
|
|
146
|
+
if (refuseRouting) {
|
|
147
|
+
app.log.warn("enrolAgenticWorker: refusing routing for a stale harness", {
|
|
148
|
+
instance: body.instance,
|
|
149
|
+
harnessProtocol: body.harnessProtocol,
|
|
150
|
+
minHarnessProtocol: minHarnessProtocol(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
const result: EnrolResult = {
|
|
106
|
-
serve: [...resolved.serve],
|
|
107
|
-
roles:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
serve: refuseRouting ? [] : [...resolved.serve],
|
|
156
|
+
roles: refuseRouting
|
|
157
|
+
? []
|
|
158
|
+
: resolved.roles.map((role) => {
|
|
159
|
+
const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
|
|
160
|
+
if (role.weight !== undefined) out.weight = role.weight;
|
|
161
|
+
return out;
|
|
162
|
+
}),
|
|
112
163
|
demandVersion: resolved.demandVersion,
|
|
113
164
|
leaseTtl: resolved.leaseTtl,
|
|
165
|
+
// Always surface the staleness verdict so the caller (and the cockpit) can see a stale harness even
|
|
166
|
+
// when it advertised no version at all.
|
|
167
|
+
harnessStale,
|
|
114
168
|
};
|
|
115
169
|
if (body.instance !== undefined) result.instance = body.instance;
|
|
116
170
|
if (body.durableResume !== undefined) result.durableResume = body.durableResume;
|
|
171
|
+
if (body.harnessProtocol !== undefined) result.harnessProtocol = body.harnessProtocol;
|
|
117
172
|
|
|
118
|
-
app.log.info("agentic enrol resolved", {
|
|
173
|
+
app.log.info("agentic enrol resolved", {
|
|
174
|
+
instance: body.instance,
|
|
175
|
+
serve: result.serve,
|
|
176
|
+
family: capability.family,
|
|
177
|
+
harnessStale,
|
|
178
|
+
});
|
|
119
179
|
return { status: 200, body: result };
|
|
120
180
|
});
|
|
@@ -20,6 +20,6 @@ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
|
|
|
20
20
|
app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
|
|
21
21
|
return { status: 401, body: { error: "unauthorized" } };
|
|
22
22
|
}
|
|
23
|
-
const report = await computeRegistryReport(app.log);
|
|
23
|
+
const report = await computeRegistryReport(app.log, app.data);
|
|
24
24
|
return { status: 200, body: toWireReport(report) };
|
|
25
25
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
|
|
6
6
|
// exactly as the presence family is exercised, so the singleton the operation reads is the live one.
|
|
7
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { test } from "node:test";
|
|
9
10
|
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
10
11
|
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
@@ -39,6 +40,19 @@ function memData(db: SqliteDb): DataLayer {
|
|
|
39
40
|
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Wrap an existing DatabaseSync as a SqliteDb (so the presence store and a raw insert share one db). */
|
|
44
|
+
function memSqliteOver(db: DatabaseSync): SqliteDb {
|
|
45
|
+
return {
|
|
46
|
+
exec: (sql) => db.exec(sql),
|
|
47
|
+
run: (sql, params = []) => {
|
|
48
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
49
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
50
|
+
},
|
|
51
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
52
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
43
57
|
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
44
58
|
const transport: ChannelTransport = {
|
|
@@ -259,3 +273,69 @@ test("#738 drift: the supply advertises the producer's instance-scoped stream id
|
|
|
259
273
|
await hub.close();
|
|
260
274
|
}
|
|
261
275
|
});
|
|
276
|
+
|
|
277
|
+
// Harness staleness (issue #802): the supply report flags a worker whose harness protocol is below the
|
|
278
|
+
// minimum / absent, joined by instance from the app's harness-protocol registry.
|
|
279
|
+
test("#802: flags harnessStale per worker, joining the harness-protocol registry by instance", async () => {
|
|
280
|
+
const raw = new DatabaseSync(":memory:");
|
|
281
|
+
raw.exec("PRAGMA foreign_keys = ON;");
|
|
282
|
+
raw.exec(readFileSync(new URL("../db/migrations/107_worker_harness_protocol.sql", import.meta.url), "utf8"));
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
// wk-a advertised a healthy protocol (>= min 1); wk-a's presence row is minted below.
|
|
285
|
+
raw.prepare("INSERT INTO worker_harness_protocol (instance, harness_protocol, updated_at) VALUES (?, ?, ?)").run("wk-a", 2, now);
|
|
286
|
+
|
|
287
|
+
// A data layer providing BOTH the presence `source().db` handle and the RAD `table()` surface over
|
|
288
|
+
// the SAME db, so the presence family and the harness registry read one store.
|
|
289
|
+
const sqlite = memSqliteOver(raw);
|
|
290
|
+
const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
291
|
+
const table = (name: string, pk = "id") => ({
|
|
292
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
293
|
+
async find(where: any = {}): Promise<any[]> {
|
|
294
|
+
const keys = Object.keys(where);
|
|
295
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
|
|
296
|
+
return raw.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => where[k])) as any[];
|
|
297
|
+
},
|
|
298
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
299
|
+
async findOne(where: any = {}): Promise<any> {
|
|
300
|
+
return (await this.find(where))[0];
|
|
301
|
+
},
|
|
302
|
+
_pk: pk,
|
|
303
|
+
});
|
|
304
|
+
const data = {
|
|
305
|
+
source: () => ({ db: sqlite }),
|
|
306
|
+
table,
|
|
307
|
+
// The raw-SQL surface the harness registry's bounded `WHERE instance IN (…)` read binds to, over
|
|
308
|
+
// the SAME db as `table`/presence.
|
|
309
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
310
|
+
open: () => ({ query: async (sql: string, params: any[] = []) => raw.prepare(sql).all(...params) as any[] }),
|
|
311
|
+
} as unknown as DataLayer;
|
|
312
|
+
|
|
313
|
+
const transport = memTransport();
|
|
314
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
315
|
+
await family.mount({ hub, registry: hub.registry, transport: transport.transport as never, data, log: noopLog() });
|
|
316
|
+
const healthyConn = fakeConn("c1", "leafA");
|
|
317
|
+
transport.connect(healthyConn.conn);
|
|
318
|
+
await flush();
|
|
319
|
+
healthyConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: {} } });
|
|
320
|
+
const staleConn = fakeConn("c2", "leafA");
|
|
321
|
+
transport.connect(staleConn.conn);
|
|
322
|
+
await flush();
|
|
323
|
+
// wk-b registers but has NO harness-protocol row → absent = stale.
|
|
324
|
+
staleConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-b", capability: {} } });
|
|
325
|
+
await flush();
|
|
326
|
+
|
|
327
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
328
|
+
try {
|
|
329
|
+
const res = (await handler(input(), withData)) as { status: number; body: { workers: Array<Record<string, unknown>> } };
|
|
330
|
+
assertEquals(res.status, 200);
|
|
331
|
+
const byInstance = new Map(res.body.workers.map((w) => [w.instance, w]));
|
|
332
|
+
assertEquals(byInstance.get("wk-a")?.harnessStale, false, "healthy protocol is not stale");
|
|
333
|
+
assertEquals(byInstance.get("wk-a")?.harnessProtocol, 2);
|
|
334
|
+
assertEquals(byInstance.get("wk-b")?.harnessStale, true, "no recorded protocol = stale");
|
|
335
|
+
assertEquals("harnessProtocol" in (byInstance.get("wk-b") ?? {}), false, "no protocol echoed for a stale worker");
|
|
336
|
+
} finally {
|
|
337
|
+
family.teardown?.();
|
|
338
|
+
await hub.close();
|
|
339
|
+
raw.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
24
24
|
import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
25
25
|
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
26
|
+
import { assessWorkers, type HarnessAssessment } from "../app/harnessProtocol.ts";
|
|
26
27
|
import { envVar } from "../app/version.ts";
|
|
27
28
|
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
28
29
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -36,7 +37,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
|
36
37
|
// (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
|
|
37
38
|
// it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
|
|
38
39
|
// (the exact stream the producer writes) even before any transcript lands.
|
|
39
|
-
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
|
|
40
|
+
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined, harness: HarnessAssessment | undefined): AgenticSupplyWorker {
|
|
40
41
|
const out: AgenticSupplyWorker = {
|
|
41
42
|
instance: w.instance,
|
|
42
43
|
identity: w.identity,
|
|
@@ -44,9 +45,13 @@ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSu
|
|
|
44
45
|
jobKeys: [...w.jobKeys],
|
|
45
46
|
live: w.live,
|
|
46
47
|
staleMs: w.staleMs,
|
|
48
|
+
// Harness staleness (issue #802) — an absent registry entry / unmounted data layer reads as stale
|
|
49
|
+
// (fail loud). Distinct from the liveness `staleMs` heartbeat grade above.
|
|
50
|
+
harnessStale: harness?.stale ?? true,
|
|
47
51
|
};
|
|
48
52
|
if (w.family !== undefined) out.family = w.family;
|
|
49
53
|
if (w.host !== undefined) out.host = w.host;
|
|
54
|
+
if (harness?.harnessProtocol !== undefined) out.harnessProtocol = harness.harnessProtocol;
|
|
50
55
|
return out;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -82,11 +87,18 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
|
82
87
|
// process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
|
|
83
88
|
const correlation = currentCorrelation();
|
|
84
89
|
const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
|
|
90
|
+
// Harness-staleness (issue #802): assess every visible worker's advertised protocol against the
|
|
91
|
+
// configured minimum — the ONE canonical staleness derivation (no second heuristic). Best-effort:
|
|
92
|
+
// an unmounted data layer / read failure reads as stale (fail loud).
|
|
93
|
+
const harness = await assessWorkers(app.data, snapshot.workers.map((w) => w.instance));
|
|
85
94
|
const report: AgenticSupplyReport = {
|
|
86
95
|
count: snapshot.count,
|
|
87
96
|
generatedAt: new Date().toISOString(),
|
|
88
|
-
workers: snapshot.workers.map((w) => toWorker(w, claims)),
|
|
89
|
-
leaves: snapshot.leaves.map((leaf) => ({
|
|
97
|
+
workers: snapshot.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
98
|
+
leaves: snapshot.leaves.map((leaf) => ({
|
|
99
|
+
token: leaf.token,
|
|
100
|
+
workers: leaf.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
101
|
+
})),
|
|
90
102
|
correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
|
|
91
103
|
};
|
|
92
104
|
return { status: 200, body: report };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -84,6 +84,10 @@ function workerView(worker, staleAfterMs, byJobKey) {
|
|
|
84
84
|
correlations,
|
|
85
85
|
liveness: liveness(worker, staleAfterMs),
|
|
86
86
|
staleMs: worker.staleMs,
|
|
87
|
+
// Fail loud: default a missing harness verdict to STALE (mirrors the typed cockpit view and the
|
|
88
|
+
// server's fail-loud assessment) so an older/cached response can't hide a stale/unknown worker.
|
|
89
|
+
harnessStale: worker.harnessStale ?? true,
|
|
90
|
+
...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}),
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -141,6 +145,7 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
141
145
|
row.setAttribute("data-worker", worker.instance);
|
|
142
146
|
row.setAttribute("data-liveness", worker.liveness);
|
|
143
147
|
row.setAttribute("data-stream", worker.stream);
|
|
148
|
+
row.setAttribute("data-harness-stale", String(worker.harnessStale));
|
|
144
149
|
|
|
145
150
|
const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
|
|
146
151
|
nameCell.appendChild(dot(doc, worker.liveness));
|
|
@@ -162,6 +167,19 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
162
167
|
if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
|
|
163
168
|
nameCell.appendChild(drill);
|
|
164
169
|
}
|
|
170
|
+
// A stale harness silently swallows machine-readable artifacts (issue #802) — surface it as a
|
|
171
|
+
// distinct badge (mirrors app/agentic/cockpit/supply-render.ts).
|
|
172
|
+
if (worker.harnessStale) {
|
|
173
|
+
const badge = el(
|
|
174
|
+
doc,
|
|
175
|
+
"span",
|
|
176
|
+
"cockpit-supply-harness-stale",
|
|
177
|
+
worker.harnessProtocol === undefined ? "stale harness" : `stale harness (v${worker.harnessProtocol})`,
|
|
178
|
+
);
|
|
179
|
+
badge.setAttribute("data-harness-stale", "true");
|
|
180
|
+
badge.setAttribute("title", "Harness protocol below the configured minimum (or none advertised); jobs may dead-end.");
|
|
181
|
+
nameCell.appendChild(badge);
|
|
182
|
+
}
|
|
165
183
|
row.appendChild(nameCell);
|
|
166
184
|
|
|
167
185
|
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
package/test/worldDb.ts
CHANGED
|
@@ -69,12 +69,18 @@ function gateway(db: DatabaseSync, name: string, pk: string) {
|
|
|
69
69
|
* that decorates `table` on the returned source sees its decoration inside the transaction too. */
|
|
70
70
|
type MemDataSource = {
|
|
71
71
|
table: (name: string, pk?: string) => ReturnType<typeof gateway>;
|
|
72
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
72
73
|
tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
function openDataSource(db: DatabaseSync): MemDataSource {
|
|
76
77
|
const ds: MemDataSource = {
|
|
77
78
|
table: (name, pk = "id") => gateway(db, name, pk),
|
|
79
|
+
// The row-returning raw-SQL surface (mirrors the runtime `DataSource.query`) so tests exercise a
|
|
80
|
+
// handler's bounded `WHERE … IN (…)` reads against genuine SQLite rather than a mock.
|
|
81
|
+
async query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
82
|
+
return db.prepare(sql).all(...params.map(coerce)) as T[];
|
|
83
|
+
},
|
|
78
84
|
async tx(fn) {
|
|
79
85
|
db.exec("BEGIN");
|
|
80
86
|
try {
|