@voltro/testing 0.44.1 → 0.45.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 CHANGED
@@ -39,6 +39,109 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.45.0] — 2026-08-21
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/cli, @voltro/voltro** — `source:` on a query is now typed against the app's own tables, so a typo or a missed rename is a compile error instead of a subscription that goes quiet.
47
+
48
+ A `source:` is matched by NAME against change events, so a name matching nothing does not break the query — it makes it permanently silent: it compiles, boots, serves its first snapshot and never updates. From the outside that reads as a feature that does nothing, with a correct write path and green tests behind it. The boot has warned about this since 0.26.0, on both paths; a warning is read once, and a rename lands in a diff where nobody is checking strings.
49
+
50
+ `voltro dev` writes `voltro-tables.generated.d.ts` beside the generated rpc group, augmenting `VoltroTableNames` with the FULL live set — app entities, plugin `extendSchema.tables` and the framework's own — from the same binding the boot audit resolves against, so the type and the warning cannot disagree about which tables exist. `source:` narrows to those names.
51
+
52
+ Nothing changes at runtime: these are still string literals, so a descriptor carrying them is as browser-loadable as before. That is what ruled out accepting the table VALUE — a descriptor is loaded value-level by the web client, and a table value drags `@voltro/database` across that boundary.
53
+
54
+ **Breaking, and filed that way after being written up as additive.** The test is not whether a symbol disappeared, it is whether code that compiled can stop: `['tasks', 'agent_messages']` was assignable and is not, which is the whole point where the name is stale and an obstacle where the source is genuinely computed. `normalizeSource`'s parameter narrowed with it. The wide shape stays public as `ReactivitySourceValue` for the computed case.
55
+
56
+ The break does NOT land at upgrade time, which is why the codemod is a written note rather than a transform: right after `voltro update` the generated file does not exist, `keyof VoltroTableNames` is `never`, `TableName` falls back to `string`, and everything compiles as before. The narrowing switches on at the next `voltro dev` — a different command, by which point the change that caused it is no longer what the reader is looking at. A transform could not have found the sites either, since the type that rejects them has not been generated yet. And the two things `tsc` flags — a stale name versus a runtime-computed one — want opposite fixes, so the mechanical one (widen the annotation) would convert every defect this surfaces back into the quiet subscription it exists to expose.
57
+
58
+ Delete the generated file and `source:` widens back to `string`.
59
+
60
+ One deliberate asymmetry, stated because it is one: runtime READERS of a descriptor's source stay wide (`ReactivitySourceValue`). Narrow where an author writes, stay wide where the framework reads — a reader that refused an unknown name would be asserting a fact it cannot check, and the first thing it would reject is the stale name it exists to report.
61
+
62
+ ### Added
63
+
64
+ - **@voltro/cli** — `voltro doctor` reports a query that eager-loads a relation and does not declare its table in `source:` — the failure that looks like a broken feature and is not.
65
+
66
+ The write lands, a reload shows it, every test of the write path is green, the name in `source:` is spelled right and the table exists. So neither the typed `source:` nor the boot audit has anything to say, and the only observer is a user watching a panel that does not move.
67
+
68
+ ```
69
+ ✗ 1 query loads a relation it does not declare:
70
+ tasks.getById: eager-loads `subTasks` from 'tasks' but does not declare
71
+ 'task_sub_tasks' in `source:` — the view will not update when 'task_sub_tasks' changes.
72
+ ```
73
+
74
+ No exception list, deliberately. An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the missing name is a fact rather than an inference. A many-to-many is reported twice when needed: adding or removing a link writes only the JUNCTION row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it. A computed `.with()` key yields nothing rather than a guess.
75
+
76
+ The general question — every table an executor reads — is NOT answered, on purpose: it needs a compose-versus-restrict judgement a scan can only infer from syntax, and a rule that guesses on a correct codebase teaches its reader to ignore it. Design in `plans/open/framework/source-completeness.md`.
77
+
78
+ Two things around it:
79
+
80
+ - **The stale-`source:` audit covered queries only, on both boot paths.** A stream carries a `source:` too, and a stale one there is the same permanently quiet subscription with a longer-lived connection behind it. Both paths now take the set from one `auditableSources`. - **`voltro codegen` writes the typed-`source:` declaration too**, from the same shared `declaredTableNames` merge the boots use. Letting it lag was the bad direction: a table added since the last `voltro dev` would make a CORRECT `source:` a type error. Both commands now say what they wrote — the narrowing has a silent no-op if the app's tsconfig does not pick the file up, so the write has to be loud enough that a reader can check.
81
+
82
+ ### Fixed
83
+
84
+ - **@voltro/database, @voltro/sql-mysql** — A binding failure names the TYPE of every value, so the culprit is read rather than guessed.
85
+
86
+ `ER_WRONG_ARGUMENTS` / 1210 reads like a count problem and often is not: a statement with twelve columns and twelve placeholders is internally consistent, and the driver is refusing one VALUE it cannot bind. Measured against a live mariadb 11.8 and mysql 8.4 (mysql2 3.22), binding to a PREPARED statement:
87
+
88
+ | value | mariadb | mysql | |---|---|---| | plain object | **1210** | accepted | | array | **1210** | accepted | | bigint | accepted | accepted | | Invalid Date | accepted | 1292 |
89
+
90
+ So the same row binds on one engine of the family and not the other — which is how a suite comes to fail on mariadb and pass on mysql in the SAME run, and why the type of each binding is the diagnosis rather than a detail.
91
+
92
+ `describeDriverError` now reports `bindings: id:string data:Object changedAt:Date …` beside the placeholder count and the statement. Types only; a value there would be row data in a log line, the same reason the statement is carried only in its placeholder form.
93
+
94
+ The types travel as a FIELD, not in a message. That is load-bearing: a failing write recorder rethrows with its own sentence, so anything said only in text is dropped exactly where it is needed. `extractDbCause` collects it like any driver field, so it survives every wrapper between the failing statement and the log.
95
+ - **@voltro/database, @voltro/testing** — A driver error now carries the two numbers a binding failure is made of.
96
+
97
+ `ER_WRONG_ARGUMENTS` / errno 1210 means the parameter count did not match the placeholder count — reproduced against mariadb 11.8 by sending one parameter for two `?` — and the message says only `Incorrect arguments to mysqld_stmt_execute`. Neither number was reachable from the error, so an investigation into one of these starts by eliminating hypotheses instead of subtracting.
98
+
99
+ `describeDriverError` reports `placeholders=N` and the statement, and the statement is carried ONLY in its placeholder form. That restriction is measured, not cautious: against mysql2 3.22 the prepared path (`execute`) leaves `?` in `err.sql` because the server did the binding, while the text path (`query`) interpolates and the same field then holds row DATA. The placeholder is the discriminator, and the form that keeps it is exactly the form 1210 arises in.
100
+
101
+ Alongside it, `reportEngineVersion` (`@voltro/testing`): a dialect suite prints the engine BUILD it ran against. A suite that is green on a developer machine and red in CI is only comparable if both name their software, and the test compose file uses moving tags — so "the same tag" is not the same build, and checking the tag locally observes what it points at today rather than what the runner resolved.
102
+ - **@voltro/database, @voltro/plugin-versioning, @voltro/plugin-flags** — A versioned table whose NAME was long enough could not be written to at all.
103
+
104
+ `id()` is `VARCHAR(64)` on mysql / mariadb and `NVARCHAR(64)` on mssql, and unbounded `TEXT` on postgres and sqlite. The versioning recorder built its history key by concatenation — `rowver_<tableName>_<rowId>_<version>`, which is `42 + len(tableName)` characters for a 32-character row id — so a 22-character table name fit and a 23-character one produced `ERROR 1406 (22001): Data too long for column 'id' at row 1`. A recorder runs on EVERY write, so this was not a refused import: it was a table nobody could write to, on three of five dialects, at a boundary no one can see when naming a table.
105
+
106
+ `derivedRowId(prefix, …parts)` (`@voltro/database`) derives a deterministic key of CONSTANT width — `rowver_<32 hex>`, 39 characters whatever goes in — joined over a `\u0000` separator so the parts stay injective (a `_`-joined key cannot tell `('a_b','c')` from `('a','b_c')`). Widening the column was the alternative and moves the wall rather than removing it; `id()` is also every user table's PK type. Nothing legible is lost: every table deriving a key this way already stores the parts in their own columns.
107
+
108
+ The same construction was in `plugin-flags` (`flag_<key>`, over an unbounded user-chosen flag key) and is fixed with it. A guard scans framework sources for an `id:` composed by interpolation and requires the helper, with an allowlist whose entries each name why their parts cannot grow — and which fails if an entry stops matching.
109
+
110
+ Also fixed: the versioning suite's live coverage was postgres-only, and postgres is one of the two dialects where that column is unbounded, so it was structurally incapable of seeing this. `@voltro/sql-mysql` is a test devDep of `@voltro/plugin-versioning` now, with a mysql+mariadb case driving an ordinary insert and update against a 33-character table name.
111
+ - **@voltro/database, @voltro/cli** — Two gaps on the `--target api` path, both about a failure that is present and unreadable.
112
+
113
+ **The driver was unreachable behind a WRAPPED rejection.** `extractDbCause` unwrapped a `FiberFailure` at the root only, so one reached through a `.cause` link stopped the walk — it carries `stack`, `message` and `name` and nothing else, which is indistinguishable from "no driver under this". That is exactly the shape a failing write recorder produces: it rethrows `new Error(<what it was doing>, { cause: err })` where `err` is the rejection its own insert made. So the same database refusal classified where no recorder runs and degraded to the bare runtime rendering where one does — which is the difference between the direct importer and an import through a running app with versioning or audit on. The walk now unwraps at every link.
114
+
115
+ **And the refusal report was never printed on that transport.** A refusal that crossed HTTP arrives as a 500 whose message embeds the `RowsRefusedError` as JSON; the CLI printed that body raw. So the operator on the transport that exists for "the database is somewhere you cannot open a shell" got the one output that has to be triaged by hand — and tallying the capped row list is how a per-table distribution gets reported that is not the real one. `--target api` now prints the same report as the direct path, `byTable` line and cap notice included.
116
+ - **@voltro/data-transfer, @voltro/cli** — Two reporting defects that made a refused import unreadable, both of the shape "the payload is present and property access is not the way to it".
117
+
118
+ **A refusal lost its tag on the mode that raises it most.** `--mode replace` runs in one transaction by default, and rolling that back needs a rejection — which the atomic wrapper obtained by throwing `new Error(Cause.pretty(cause))`, a rendering rather than the failure. From there the typed error could not come back: it was re-wrapped as a `BundleError` carrying itself as text. So `Effect.catchTag('RowsRefusedError', …)` matched nothing on the default path, `ImportError`'s union was a claim that path could not honour, and the CLI's refusal report — which branches on the tag — printed nothing at all. The typed error is thrown and passed through now; `asImportError` is exported for callers who catch the rejection rather than the effect.
119
+
120
+ **And the report read the tag off a `FiberFailure`.** What `Effect.runPromise` rejects with does not expose `_tag` by property access, so the renderer took its "not my error" branch on every direct-path run while being wired, tested and correct — the test drove the renderer with the error object, which is not the shape the call site produces. A reported refusal now also ENDS the command instead of being rethrown into `fatal unhandled cli error`: a refusal is a condition with a named cause, not a framework defect.
121
+
122
+ **An api host is no longer reported as an unreachable database.** A connect failure carries an address, a port and an errno — the same shape a database driver's carries — and one global handler renders that shape, so `--target api --api-url https://…` against a stopped instance printed `the database is not reachable at <api-host>:443 … Configured by: DB_URL` with `DB_URL` not in play. The transport names its own failure now (`InstanceUnreachable`), and the database explainer declines an endpoint whose PORT cannot be a database — judged by port because a driver reports the resolved address, so a host comparison would silence the real message for anyone naming their database by hostname.
123
+
124
+ ### Internal (no consumer-facing effect)
125
+
126
+ - **@voltro/sql-postgres** — A test teardown terminated connections its own pool was still closing, and the resulting error failed the RUN rather than any test.
127
+
128
+ `clusterColdStart` drops a per-run database, and the runners it spawned are killed with SIGKILL, so their backends never close — hence the deliberate `pg_terminate_backend` before the `DROP`. But `pool.end()` resolves once it has ASKED the pool to close, not once every socket is down, so the terminate could also land on a connection belonging to the test itself. `pg` reports that as an `error` event on the idle client, and an unhandled one takes down the process.
129
+
130
+ The shape it took on a release gate is the reason this is written down: **36 of 36 test files green, and the suite exiting 1.** Nothing points at the teardown — the failure is attributed to whichever suite happened to run last, which is a different one each time. A connection error while we are tearing the database down carries no signal, so it is handled where it arises.
131
+
132
+ Test-only; no product code changed.
133
+ - **@voltro/plugin-auth** — The TOTP skew-window test uses a fixed secret. Test-only; no product code changed, and the assertion is unchanged.
134
+
135
+ It failed once on a release gate — `expected true to be false`, meaning a code two steps outside the ±1 window verified. That is the shape of a security defect, so it was treated as one until measured:
136
+
137
+ - `TOTP_SKEW` is 1 and the verify loop checks exactly three counters, compared with `timingSafeEqual`; - `T0` is a constant and the clock is injected, so the only varying input was `generateTotpSecret()`; - over **50 000 fresh secrets**: zero collisions between the ±2 codes and the ±1 window (pure chance predicts ~0.3), zero degenerate secrets, uniform length; - **60 consecutive runs** of the file: green.
138
+
139
+ So the implementation is sound and that red was two 6-digit codes coinciding — about six in a million per run. Worth stating plainly: that makes the observed failure a one-in-167 000 event, which fits every measurement and is still remarkable. It was not reproduced.
140
+
141
+ The fix is to remove the coin flip rather than to re-run until green. A random secret buys this test nothing — the property under test is the WIDTH of the window, which does not depend on which secret is used. It only buys a rare red that costs a diagnosis cycle and teaches the reader to re-run. Pinned, so the next failure there means the window moved.
142
+
143
+ ---
144
+
42
145
  ## [0.44.1] — 2026-08-19
43
146
 
44
147
  ### Fixed
@@ -877,7 +980,7 @@ _Changes staged for the next release accumulate here (rolled up from
877
980
  It is derived from `publishConfig.exports` inside the generator's own loop — not a curated list and not a second copy of the derivation — so a package that joins the workspace is covered without anyone remembering to add it. It carries a floor (60 packages) for the reason every check in `scripts/` has one: the failure mode of a wiring check is a green line over a walk that found nothing.
878
981
 
879
982
  Verified by injecting each defect and watching it go red (missing golden, empty golden), confirming exit code 1, and confirming `--check` mutates no file. Internal: tooling only.
880
- - **@voltro/cli** — `rpcSurfaceFingerprint.ts` wrote its composite-key separator as a literal NUL byte instead of ``. Same runtime value, no behaviour change — the file's own 16 tests pass identically before and after.
983
+ - **@voltro/cli** — `rpcSurfaceFingerprint.ts` wrote its composite-key separator as a literal NUL byte instead of the `\u0000` escape. Same runtime value, no behaviour change — the file's own 16 tests pass identically before and after.
881
984
 
882
985
  It matters because of what the byte does to the FILE rather than to the hash: a source file containing a NUL is binary to every text tool, so `grep` skips it and prints nothing, which is indistinguishable from a clean file. This repo has been bitten by exactly that — a 1020-line module that every grep-based audit had silently skipped, including one searching for a string that file declares.
883
986
 
package/dist/index.d.ts CHANGED
@@ -621,6 +621,25 @@ export declare interface RecordedDelivery<P = unknown> {
621
621
  readonly n: number;
622
622
  }
623
623
 
624
+ /**
625
+ * The engine BUILD behind a target, printed once per file.
626
+ *
627
+ * A dialect suite that is green on a developer machine and red in CI is only
628
+ * comparable if both runs name the software they ran against — and the compose
629
+ * file uses moving tags (`mariadb:11`), so "the same tag" is not the same
630
+ * build. A failure took seven eliminated hypotheses partly because the first one
631
+ * — the image version — was tested by pulling the tag LOCALLY, which observes
632
+ * what the tag points at today and not what the CI runner had resolved.
633
+ *
634
+ * So: the suite says it, in both places, unconditionally. It asserts nothing —
635
+ * a version gate would be a second thing to maintain, and the value here is
636
+ * purely that a red log carries the number.
637
+ *
638
+ * Failures are swallowed on purpose. This is a diagnostic; a probe that took the
639
+ * file down would trade a useful line for a collection error.
640
+ */
641
+ export declare const reportEngineVersion: (label: string, probe: () => Promise<string | undefined>) => Promise<void>;
642
+
624
643
  /**
625
644
  * Drop queued post-commit work WITHOUT running it.
626
645
  *
package/dist/index.js CHANGED
@@ -380,11 +380,11 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
380
380
  console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
381
381
  })))), i;
382
382
  };
383
- }, Ue = Symbol("unresolved"), We = (e, t) => new Proxy(e, { get: (e, n) => {
383
+ }, Ue = Symbol("unresolved"), X = (e, t) => new Proxy(e, { get: (e, n) => {
384
384
  if (n === "query") return async (n) => e.query(re(await t(), n));
385
385
  let r = Reflect.get(e, n, e);
386
386
  return typeof r == "function" ? r.bind(e) : r;
387
- } }), Ge = (e = {}) => {
387
+ } }), We = (e = {}) => {
388
388
  if (he({
389
389
  ...process.env,
390
390
  ...e.env ?? {}
@@ -415,12 +415,12 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
415
415
  access: oe(t),
416
416
  cache: f,
417
417
  kv: p,
418
- store: T(We(n, y), {
418
+ store: T(X(n, y), {
419
419
  subject: t,
420
420
  schemaRegistry: r,
421
421
  rowFilter: x
422
422
  }),
423
- storeForTenant: (e) => T(We(n, y), {
423
+ storeForTenant: (e) => T(X(n, y), {
424
424
  subject: ee(t, e),
425
425
  schemaRegistry: r,
426
426
  rowFilter: x
@@ -456,13 +456,13 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
456
456
  })))), b;
457
457
  };
458
458
  return m(e.subject ?? h(null));
459
- }, Ke = (e, t) => {
459
+ }, Ge = (e, t) => {
460
460
  let n = G(t);
461
461
  return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(ce(t.store)), u.provideService(m, t.request.subject));
462
- }, qe = async (e, t, n, r) => {
462
+ }, Ke = async (e, t, n, r) => {
463
463
  let i = e.input, a = await p.decodeUnknownPromise(i)(n), o = e.kind, s = async (e) => {
464
464
  let n = t(a, e);
465
- return u.isEffect(n) ? C(Ke(n, e)) : n;
465
+ return u.isEffect(n) ? C(Ge(n, e)) : n;
466
466
  }, c = async () => {
467
467
  let t = e.guards;
468
468
  if (t !== void 0 && t.length > 0) {
@@ -487,7 +487,7 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
487
487
  }), _ = await u.runPromiseExit(h);
488
488
  if (d.isSuccess(_)) return _.value;
489
489
  throw l.squash(_.cause);
490
- }, X = (e) => new Promise((t) => {
490
+ }, qe = (e) => new Promise((t) => {
491
491
  let n = new ge.Socket(), r = (e) => {
492
492
  clearTimeout(i), n.destroy(), t(e);
493
493
  }, i = setTimeout(() => r(!1), e.timeoutMs ?? 750);
@@ -502,14 +502,19 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
502
502
  }
503
503
  _e.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
504
504
  }, Je = async (e, t, n) => {
505
- await Z(e, `${t.name} at ${t.host}:${t.port}`, () => X(t), n);
506
- }, Ye = "test-tenant", Q = (e, t, n) => ({
505
+ await Z(e, `${t.name} at ${t.host}:${t.port}`, () => qe(t), n);
506
+ }, Ye = async (e, t) => {
507
+ try {
508
+ let n = await t();
509
+ n !== void 0 && n !== "" && console.log(`[engine] ${e}: ${n}`);
510
+ } catch {}
511
+ }, Xe = "test-tenant", Q = (e, t, n) => ({
507
512
  type: e,
508
513
  id: t,
509
514
  tenantId: n.tenantId ?? "test-tenant",
510
515
  scopes: n.scopes ?? [],
511
516
  ...n.metadata === void 0 ? {} : { metadata: n.metadata }
512
- }), Xe = (e, t = {}) => Q("user", e, t), Ze = (e, t = {}) => Q("apiKey", e, t), Qe = (e, t = {}) => Q("serviceAccount", e, t), $e = (e = null) => h(e), et = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), tt = (e) => {
517
+ }), Ze = (e, t = {}) => Q("user", e, t), Qe = (e, t = {}) => Q("apiKey", e, t), $e = (e, t = {}) => Q("serviceAccount", e, t), et = (e = null) => h(e), tt = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), nt = (e) => {
513
518
  let t = e.indexOf("?");
514
519
  return t === -1 ? {
515
520
  path: e,
@@ -518,17 +523,17 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
518
523
  path: e.slice(0, t),
519
524
  query: e.slice(t + 1)
520
525
  };
521
- }, nt = (e, t) => {
526
+ }, rt = (e, t) => {
522
527
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0);
523
528
  return n.length === r.length && n.every((e, t) => e.startsWith(":") || e === r[t]);
524
529
  }, $ = (e) => {
525
530
  let t = {};
526
531
  for (let [n, r] of Object.entries(e)) t[n.toLowerCase()] = r;
527
532
  return t;
528
- }, rt = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), it = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), at = (e) => {
533
+ }, it = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), at = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), ot = (e) => {
529
534
  let { ctx: t } = e, n = ve((e.publicApi ?? []).map((e) => ({
530
535
  descriptor: e.descriptor,
531
- invoke: (n, r) => t.withSubject(r.subject, (t) => qe(e.descriptor, e.handler, n, t))
536
+ invoke: (n, r) => t.withSubject(r.subject, (t) => Ke(e.descriptor, e.handler, n, t))
532
537
  }))), r = [...e.restRoutes ?? [], ...n], i = _(e.strategies ?? [], {
533
538
  ...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
534
539
  getStore: () => t.store
@@ -544,7 +549,7 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
544
549
  pattern: r[t].path,
545
550
  route: e
546
551
  })), l = async (e, n, i = {}) => {
547
- let { path: a, query: s } = tt(n), l = c.filter((e) => nt(e.pattern, a)).map((e) => e.route);
552
+ let { path: a, query: s } = nt(n), l = c.filter((e) => rt(e.pattern, a)).map((e) => e.route);
548
553
  if (l.length === 0) return {
549
554
  status: 404,
550
555
  headers: {},
@@ -563,11 +568,11 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
563
568
  ...$(o),
564
569
  ...$(i.headers ?? {})
565
570
  },
566
- rawBody: rt(i.body),
571
+ rawBody: it(i.body),
567
572
  store: t.store
568
573
  }, d = await ye(l, u), f = $(d.headers ?? {});
569
574
  d.contentType !== void 0 && (f["content-type"] = d.contentType);
570
- let p = it(d.body), m = (d.contentType ?? "").includes("json");
575
+ let p = at(d.body), m = (d.contentType ?? "").includes("json");
571
576
  return {
572
577
  status: d.status,
573
578
  headers: f,
@@ -601,7 +606,7 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
601
606
  };
602
607
  };
603
608
  return a(void 0, {});
604
- }, ot = (e = {}) => {
609
+ }, st = (e = {}) => {
605
610
  let t = e.id ?? "test", n = e.store ?? new Proxy({}, { get: (e, t) => {
606
611
  throw Error(`makeSubscribeContext: this subscriber reached ctx.store.${String(t)} and the test passed no store. Pass \`{ store: makeTestContext().store }\` so the subscriber and the code under test share one — an empty stand-in would let a read of the wrong table pass.`);
607
612
  } });
@@ -611,7 +616,7 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
611
616
  store: n,
612
617
  ...e.publish === void 0 ? {} : { publish: e.publish }
613
618
  };
614
- }, st = (e) => {
619
+ }, ct = (e) => {
615
620
  let t = /* @__PURE__ */ new Map();
616
621
  for (let n of e) {
617
622
  let e = t.get(n.stepName);
@@ -637,13 +642,13 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
637
642
  });
638
643
  }
639
644
  return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
640
- }, ct = (e) => {
645
+ }, lt = (e) => {
641
646
  let t = e.workflows ?? [], n = /* @__PURE__ */ new Map(), r = 0;
642
647
  return {
643
648
  start: async (i, a) => {
644
649
  let o = t.find((e) => e.workflow.name === i);
645
650
  if (o === void 0) throw Error(`makeWorkflowRunner: no workflow named '${i}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
646
- let s = `wfrun_test_${++r}`, c = we(), l = o.workflow.toLayer(o.execute).pipe(f.provideMerge(Ce)), d = /* @__PURE__ */ new Date(), p = o.workflow.execute(a).pipe(u.locally(Se, s), u.provide(c.layer), u.provide(l), u.either), m = await u.runPromise(p), h = st(c.readSteps()), g = /* @__PURE__ */ new Date(), _;
651
+ let s = `wfrun_test_${++r}`, c = we(), l = o.workflow.toLayer(o.execute).pipe(f.provideMerge(Ce)), d = /* @__PURE__ */ new Date(), p = o.workflow.execute(a).pipe(u.locally(Se, s), u.provide(c.layer), u.provide(l), u.either), m = await u.runPromise(p), h = ct(c.readSteps()), g = /* @__PURE__ */ new Date(), _;
647
652
  if (m._tag === "Right") _ = {
648
653
  status: "succeeded",
649
654
  output: m.right,
@@ -683,6 +688,6 @@ var Te = new Set(e), E = 0, D = () => ++E, Ee = (e, t) => {
683
688
  },
684
689
  inspect: async (e) => n.get(e) ?? null
685
690
  };
686
- }, lt = 1;
691
+ }, ut = 1;
687
692
  //#endregion
688
- export { P as MockClock, L as MockLLM, F as MockWebhooks, lt as TESTING_PRESET_VERSION, Ye as TEST_TENANT_ID, $e as anonymous, Ze as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Ae as defineFactory, Z as describeIfAvailable, Je as describeIfReachable, O as fixtureRow, Pe as frozenTime, qe as invoke, X as isTcpReachable, ot as makeSubscribeContext, at as makeTestApp, Ge as makeTestContext, ct as makeWorkflowRunner, Ie as mockStore, D as nextSequence, Le as outboxNudgesOf, K as resetAfterCommit, W as rpcInterceptorFor, q as runAfterCommit, J as runInStoreTransaction, Qe as serviceAccount, G as serviceLayerFor, et as system, I as testEventBus, Xe as user, Ne as withFrozenTime };
693
+ export { P as MockClock, L as MockLLM, F as MockWebhooks, ut as TESTING_PRESET_VERSION, Xe as TEST_TENANT_ID, et as anonymous, Qe as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Ae as defineFactory, Z as describeIfAvailable, Je as describeIfReachable, O as fixtureRow, Pe as frozenTime, Ke as invoke, qe as isTcpReachable, st as makeSubscribeContext, ot as makeTestApp, We as makeTestContext, lt as makeWorkflowRunner, Ie as mockStore, D as nextSequence, Le as outboxNudgesOf, Ye as reportEngineVersion, K as resetAfterCommit, W as rpcInterceptorFor, q as runAfterCommit, J as runInStoreTransaction, $e as serviceAccount, G as serviceLayerFor, tt as system, I as testEventBus, Ze as user, Ne as withFrozenTime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/testing",
3
- "version": "0.44.1",
3
+ "version": "0.45.0",
4
4
  "description": "Test utilities for Voltro apps — deterministic clock, subject and row factories, a handler-level invoke and a request-level app harness, queued LLM responses, scoped subject/tenant runners, and a cross-dialect parity harness.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -43,15 +43,15 @@
43
43
  "node": ">=24.0.0"
44
44
  },
45
45
  "dependencies": {
46
- "@voltro/database": "0.44.1",
47
- "@voltro/env": "0.44.1",
48
- "@voltro/logger": "0.44.1",
49
- "@voltro/protocol": "0.44.1",
50
- "@voltro/runtime": "0.44.1",
51
- "@voltro/workflow": "0.44.1"
46
+ "@voltro/database": "0.45.0",
47
+ "@voltro/env": "0.45.0",
48
+ "@voltro/logger": "0.45.0",
49
+ "@voltro/protocol": "0.45.0",
50
+ "@voltro/runtime": "0.45.0",
51
+ "@voltro/workflow": "0.45.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@voltro/client": "0.44.1",
54
+ "@voltro/client": "0.45.0",
55
55
  "effect": "^3.22.0",
56
56
  "react": "^19.0.0",
57
57
  "@effect/sql": "^0.52.0"