@voltro/testing 0.69.1 → 0.70.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,86 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.70.0] — 2026-09-11
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/cli** — The DevTools dashboard has a lock of its own: `VOLTRO_DASHBOARD_TOKEN`. A deployed dashboard that holds other apps' inspect tokens in `VOLTRO_DASHBOARD_APPS` refuses to boot in production without it, `/api/dashboard/config` no longer serves token values, and the proxy attaches a configured target's credentials itself.
47
+
48
+ Before this the dashboard had no door: whoever reached its URL got every configured app's inspect read AND write token from `/api/dashboard/config` in one request, and the proxy forwarded whatever bearer that browser then presented. The URL was the credential.
49
+
50
+ Three rules now, on both boot paths: the browser proves the dashboard token once (`POST /api/dashboard/login`) and gets an HttpOnly, SameSite=Strict session cookie derived from it by HMAC (12 h; a rotated lock invalidates every cookie); every other `/api/dashboard/*` route answers 401 without a session; the config route says where the targets are and whether the pod holds credentials (`hasToken`, `hasWriteToken`), never which. A dashboard with nothing to guard (no configured targets, a loopback dev registry) boots open as before — setting the token locks it anyway. `voltro secret generate dashboard` prints the variable paste-ready. `codemod: none`: no user code changes; the operator sets one variable, and the chart's `dashboardToken` renders it.
51
+
52
+ **No codemod** — this break touches no user-authored code.
53
+
54
+ ### Added
55
+
56
+ - **@voltro/protocol, @voltro/plugin-openapi, @voltro/cli, @voltro/testing** — The `publicApi` surface speaks a REST profile, chosen once in the api's `app.config.ts` (`publicApi: { profile }`) and overridable per descriptor (`publicApi.format`): `'rpc'` — the default, today's wire — `'standard'`, `'jsonapi'`, or a `RestFormat` object of the app's own, with building blocks (`dates`, `errors`, `envelope`, `pagination`, `naming`, `etag`) that override the preset. `'standard'` is the Zalando / Google AIP / Microsoft intersection: `timestampMs` as RFC 3339 (accepted inbound too), every failure as RFC 9457 Problem Details — the transport's refusals included — no envelope, a `nextCursor` as a `Link: <…>; rel="next"` header, a weak `ETag` on GET with `If-None-Match` → 304. `'jsonapi'` answers `{ data, links }` as `application/vnd.api+json`. The spec follows the profile (`format: date-time`, the `ProblemDetails` component, `Link`/`ETag` headers, snake-case names). `makeTestApp({ publicApiProfile })` measures the same wire.
57
+
58
+ A path with `{teamId}` or `:teamId` binds that segment to the input field of the same name — always, under every profile — with the remaining fields from the query string (GET) or the body; the brace form used to be no route, the colon form a `400`, so `GET /v1/teams/{teamId}` was unreachable for a projection.
59
+ - **@voltro/testing, @voltro/database** — `makeTestContext({ fieldEncryptionKey })` registers the field cipher for `.encrypted()` columns under test. Setting `VOLTRO_FIELD_ENCRYPTION_KEY` — in `process.env` or the context's `env` — did nothing here, because the harness runs no boot gate and the gate is what turns the variable into a cipher; the only working route was `setFieldCipher(makeFieldCipher(key))` from another package, which nothing pointed at. The refusal now names the option.
60
+ - **@voltro/database, @voltro/runtime** — `insertIgnoreWithOutcome` on the framework store (and on `yield* EffectStore` plus the `tx` view), with the typed helper `insertIgnoreRowOutcome`: `{ row, outcome: 'inserted' | 'ignored' }`.
61
+
62
+ `insertIgnore` returns the existing row on the ignore path, and that row is indistinguishable from one it just wrote — so a loop that plants missing rows reported every row it visited as planted unless it read the destination first, per row. The answer is decided the way `upsertWithOutcome` decides it: the id the store minted before the statement is not the id the ignore path hands back; where the id cannot decide (caller-supplied, or database-assigned) the destination is read first and the answer stays exact. Plain `insertIgnore` never pays for that read.
63
+ - **@voltro/protocol, @voltro/client, @voltro/web** — `defineMutation({ serialize: { key, maxPending? } })` — calls of one mutation to the SAME resource run in invocation order on the client, for every caller of the tag. A row lock serialises transactions, not the order a browser invoked them in: two quick saves of one snapshot could take the lock in reverse order and the older snapshot then overwrote the newer one; server-side idempotency does not see it, and `.version()` detects the conflict and replays the older write. `key` names the resource from the input; calls sharing it run one at a time, FIFO, per subject, the permit spanning connection setup, the request, replay and its acknowledgement — optimistic previews stay immediate, different keys run independently. An unknown outcome (a transport failure after the request started) blocks the key for that subject rather than let a later write pass it; a subject change drops the queue; admission beyond `maxPending` (default 32) rejects with `MutationSerialExecutionError`. `useMutation`'s `pending` now counts every concurrent call, not only the one that settled last.
64
+
65
+ Declared on the descriptor — where `concurrency` lives for workflows — rather than as a hook builder, because a hook-scoped queue orders one component's calls and lets a second component writing the same resource interleave. Not a durable outbox, a distributed lock or a cross-tab merge.
66
+ - **@voltro/cli** — API `fonts:` declarations now package validated native-readable fonts and relocatable Fontconfig artifacts during build. Dev, serve and direct production entries activate them before startups and handlers; missing or corrupt artifacts refuse boot. Native renderers no longer depend on undeclared host fonts or original workspace font paths.
67
+ - **@voltro/plugin-openapi** — Every mounted API version has an OpenAPI document of its own — `/openapi/v2.json` beside `/openapi.json`, or `?version=v2` — the same spec filtered to that version, every operation fact included; the unversioned URL keeps serving everything. With more than one version mounted the docs page shows Swagger UI's "Select a definition" dropdown: one entry per version, newest first and preselected, an expiring version named with its earliest `sunset` (`v1 — sunset 2027-03-01`), the whole document last.
68
+
69
+ Before this `v1` and `v2` shared one document and one viewer: a consumer integrating `v2` scrolled past every `v1` path, and a generator built both into one client.
70
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-openapi** — `onProcedureSurface` — a plugin hook told the app's wire-reachable rpc procedures (every query, mutation, action and stream discovery found, minus `internal`) once discovery has run, on both boot paths; the procedure counterpart of `onRestSurface`. `openapiPlugin({ includeAppProcedures: true })` documents them as `POST /rpc/<name>` operations without the app listing them: opt-in, so the spec stays REST-only unless asked, and a procedure also passed in `procedures` is documented once.
71
+
72
+ Before this `procedures` was the only way in, and filling it meant importing every descriptor module into `app.config.ts` — evaluated before discovery on every load of the config, the pattern the `onRestSurface` docs name as a boot that hung — while `voltro serve` loads no TypeScript at runtime, so a plugin could not read them later either.
73
+ - **@voltro/protocol, @voltro/plugin-openapi** — A `publicApi` projection documents itself completely: `tags` from the rpc tag's prefix (`teams.list` → `teams`; `publicApi.tags` overrides) with a top-level `tags[]` and `tagDescriptions` on the OpenAPI plugin; an `operationId` from the tag (`teamsList`; `publicApi.operationId` overrides); `security` on every operation — required, or optional under `openAccess` (`[{ bearerAuth: [] }, {}]`), `publicApi.security: false` for none — with `securitySchemes.bearerAuth` published once any projection exists; one response per `errorStatus` entry carrying the error's own schema, `422` for the union's other tags, `429` for a mounted rate limiter; `publicApi.example: { request?, response? }`; path parameters `in: path` and query parameters typed from the input.
74
+
75
+ Before this the plugin wrote `tags` only for an explicit `version`, `security` only for a `guards:` route — so after "Authorize" Swagger UI sent the token to those alone and "Try it out" on every other route ran without a key — no `operationId`, no `errorStatus` response, no example, and no query parameter at all for a GET projection.
76
+
77
+ ### Fixed
78
+
79
+ - **@voltro/protocol** — A `publicApi.path` segment that names no input field refuses the boot, naming the descriptor and the segment (`descriptor 'bookings.get' — path '/v1/bookings/:uuid' binds ':uuid', and the input declares no field of that name (fields: bookingId)`). A field the path binds may repeat in the body or query string with the same value — a PATCH carrying the whole resource — but another value answers `400` naming the field.
80
+
81
+ Before this the segment was bound silently: the route mounted, the handler never saw the value, and a body that carried the same field with another value was overwritten by the path without a word.
82
+ - **@voltro/protocol, @voltro/runtime, @voltro/plugin-openapi, @voltro/plugin-prometheus** — A plugin route answers the path it declared, byte for byte: the dispatcher refuses a request whose path is not the route's own — case-folded twins and a bare trailing slash included — with `404`, before the body is read. `PluginHttpRoute.match: 'exact'` narrows a route to its path alone, no sub-path; the default stays `'prefix'`, the contract every route that branches on its tail was written against. A REST descriptor is an exact route; the OpenAPI spec and docs routes and the Prometheus scrape route declare `'exact'`.
83
+
84
+ Before this the router matched without case and ignored a trailing slash, and every route owned its sub-paths: `/OPENAPI.JSON`, `/openapi.json/`, `/Docs` and `/Metrics` all answered, so a path guard at the ingress that named the one documented spelling covered none of the others. The docs of both plugins now say the guard can name the path as written.
85
+ - **@voltro/workflow, @voltro/cli** — A run that ended outside the engine no longer leaves its unanswered requests in the cluster journal. Cancelling a run (`timeouts.finish`, `cancelOn`, the operator's button, the bulk action) discards its unprocessed messages at once, and the staleness tick discards those of every ended run (`succeeded`, `failed`, `cancelled`) that a runner last read more than a minute ago — logged as `workflow.cluster: discarded unprocessed messages of runs that have ended`.
86
+
87
+ Before this, the `activity` request a killed executor was inside stayed unprocessed after the run was cancelled: every runner that acquired the shard re-read it every ten minutes for the rest of time, its handler waited on a latch that never opens, and the `unprocessed messages` warning was printed again on every engine start. An unprocessed `run` request of a cancelled run would have gone further and executed it. The engine's own reset deletes one request; this deletes the ended run's, and `ClusterDiagnostics.discardMessages` is the one write on that service.
88
+ - **@voltro/cli** — `voltro db encrypt-column <table>.<column> .` accepts the app directory every other `db` command takes as its last argument — accepted and not needed, the connection comes from env. It used to parse `.` as a column reference and refuse. A positional that is neither a `<table>.<column>` nor a directory is named in the refusal.
89
+ - **@voltro/runtime, @voltro/cli, @voltro/database** — Every API key has its `actors` row after the first boot on this version. Both boot paths plant, after the schema is applied, one row per `_voltro_api_keys.id` that has none — through the same `ensureActor` a new key goes through (the built-in row, or the app's `apiKeys.ensureActor` handed the issue input rebuilt from the row). Idempotent; two queries when nothing is missing; `api keys: planted actors rows for keys that had none` with the count, and a key whose row cannot be written is named in a warning rather than failing the boot.
90
+
91
+ Two release notes had asked the operator to run a loop once. The first was printed to the wrong group; the second printed `store.select(...)` for a `*.startup.ts`, and `StartupContext.store` is a `DataStore` with no fluent builder — both API pods refused to boot on it. The 0.69.1 note now says what the boot does and which line to look for. The framework tables `@voltro/database` exports (`_voltroApiKeysTable` and the others) are typed `SchemaTable` instead of `TableLike`, so `queryFor(_voltroApiKeysTable)` compiles where a hand-written read is still wanted.
92
+ - **@voltro/runtime** — Idempotent WebSocket mutations acquire and finalize their claims safely across request interruption. A closed socket cannot orphan a claim acquired by a still pending store Promise; synchronous executor failures release fresh claims too. Already-started Promise executors finish their actual write and record the result before interruption completes, preventing a replay from duplicating the write. Effect executors remain interruptible and release only after cleanup. A late interrupt never deletes the completion receipt of a successful write.
93
+
94
+ Codemod: none — no application source or configuration changes. Claim TTL, replay limits, transport guards and process-crash recovery guarantees are unchanged.
95
+ - **@voltro/cli** — Docker deployments retain optional native platform bindings until runtime tracing, and the pruner traces both conditional import and require entries so relocated API and web images can still execute native image processing.
96
+ - **@voltro/workflow, @voltro/cli** — `own` on a shard-lock holder in the stall report and the `workflow.cluster` warning is now the engine's own account (`Sharding.hasShardId`), and a holder that is this runner reads `this runner (pid N)`.
97
+
98
+ The first version compared the holder's pid with `pg_backend_pid()` of the query that asked. The advisory lock lives on the runner's reserved connection, and the pool hands every other query a different backend — so a runner's own 300 locks read as a stranger's, always, and behind a connection pooler nothing in `pg_stat_activity` could have told them apart either. The report's stated lever for a stranger, ending the holder's session, would have ended the runner. The hint now distinguishes the three cases (another runner, a session that outlived its runner, this runner re-reading a request nobody answers), and the docs say to read `own` before `pg_terminate_backend`.
99
+ - **@voltro/cli** — Production HTTP proxies release upstream resources when the browser abandons an unfinished response, including SSE, and propagate truncated upstream responses as transport failures instead of hanging. Complete responses retain payloads and keep-alive; no application configuration change is needed.
100
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — `publicApi.rateLimit` on a descriptor is that endpoint's own rule for calls through the `/v1` projection — ahead of the plugin's `rules` and `default`, one bucket per caller per endpoint, under `enabled`. A REST call now carries `surface: 'rest'` and the descriptor's `publicApi` spec on the interceptor context, so a plugin can tell a projected call from an rpc one.
101
+
102
+ The field said "rides @voltro/plugin-ratelimit when mounted" and nothing read it: three calls at `limit: 1` answered `200 200 200`, no bucket, no `onLimited`. The plugin never learned which calls came through the projection, let alone what it declared. Without the plugin mounted the spec is inert, as the comment always implied.
103
+ - **@voltro/cli** — The retention sweep arms its policies on the `_voltro_workflow_*` tables only where the workflow engine that creates those tables is composed. An api with no `*.workflow.tsx` has no engine and no such tables, and the five policies still armed on them were five failing `DELETE`s per tick, each an `ERROR` line in the database log, for the life of the deployment — not switchable off from the app. The boot line `retention: N policy(ies) armed` now lists exactly what is swept.
104
+ - **@voltro/plugin-mail** — SMTP replies are classified by SMTP rules instead of HTTP rules: 4xx replies remain retryable, permanent 5xx rejections do not trigger automatic retries (RFC 5321 §4.2.1). HTTP providers keep their existing policy. Tests exercise actual loopback connections, envelope, MIME and attachment encoding without sending external mail.
105
+
106
+ Codemod: none — corrected SMTP reply semantics require no application source or configuration migration.
107
+ - **@voltro/workflow** — The workflow engine's boot check for its cluster tables asks whether a table exists with a query that succeeds either way (`to_regclass` on postgres, `information_schema` on mysql, `OBJECT_ID` on mssql, `sqlite_master` on sqlite) instead of selecting from the table and reading the failure as "no" — which the database server logged as an `ERROR` on every first boot of a database. The probes `@effect/cluster` itself makes for its legacy tables are upstream and still fail-as-answer.
108
+ - **@voltro/cli** — `voltro dev` acquires the workflow runtime as a boot step, as `voltro serve` always has — `workflow runtime ready — N entity type(s) registered` on both paths, through one shared helper with a parity test.
109
+
110
+ A `ManagedRuntime` builds its layer on first use, and `voltro dev` never used it at boot. A fleet of dev pods therefore had NO cluster runner after a restart — no runner row, no shard locks, no re-delivery — until something asked the runtime for a service. The first thing that asked, in a deployment, was the once-per-fleet staleness tick, five minutes after the boot, in the one pod that ran it: a run orphaned by the restart waited exactly that long and was then adopted by that pod alone.
111
+ - **@voltro/cli** — `voltro baseline sync` writes its `docker/Caddyfile` with tabs, the way `caddy fmt` writes it. Caddy warned on every proxy start that the file "is not formatted", and a project that checks its generated files for drift could not fix it on its side — the next sync undid it.
112
+ - **@voltro/plugin-ratelimit** — `redisStore({ url })` binds its client on the first `consume` when nobody activated it — from `url` or the env it reads at activation — so a limit holds in the test harness, where `makeTestContext` runs no lifecycle hooks. A store with neither stays fail-open (`failOpenReason: 'unbound'`), as at activation.
113
+
114
+ Before this a suite that "checked the redis store" ran every call through an unbound store: `200 200 200` at `limit: 2`, no key in Redis, no warning — and nothing a test could read told "the limit held" from "the store was never there".
115
+ - **@voltro/cli, @voltro/i18n** — A `src/pages/[locale]/…` route renders in the locale it matched, on both web boot paths: `voltro start` and `voltro dev` hand the matched `[locale]` parameter to the same i18n resolver (also for the ISR key and for an app without catalogs), and `resolveLocale` takes it as `routeLocale` — ahead of the `voltro:locale` cookie, `Accept-Language` and the default, and only on an exact supported spelling. The build's static prerender already did this; the runtime render fell back to cookie or browser language, so `/en` answered with a German head and `html lang="de"`. In the same change `voltro dev`'s buffered SPA and ISR responses merge a page's `meta.title` into the shell the way production does — there was a second `<title>` before.
116
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — A rate-limited call on the `/v1` projection answers `429` with `Retry-After` and the `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers, the tag and fields as the body — on both boot paths and in `makeTestApp`. A `PluginErrorSchema` may declare `rest: { status, headers }`; the projection encodes a plugin's cross-cutting error under that status (`422` when it names none; the descriptor's own `errorStatus` entry wins), and the plugin declares `429` for `RateLimited`.
117
+
118
+ The plugin merged `RateLimited` into every rpc's wire union at boot and the client decoded it typed, but the REST projection encoded through the descriptor's own `error:` union only — the one surface with no typed channel to fall back on answered `500 Internal Server Error` with no `Retry-After`. The only way to a `429` was to import a plugin error into every descriptor file. A `500`'s cause now rides on the response value off the wire, so the `GET /v1/… → 500` log line names the reason on `voltro serve` too.
119
+
120
+ ---
121
+
42
122
  ## [0.69.1] — 2026-09-11
43
123
 
44
124
  ### Added
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ import { Layer } from 'effect';
15
15
  import { ParseResult } from 'effect';
16
16
  import { ProcedureDescriptor } from '@voltro/protocol';
17
17
  import { PublicApiDescriptor } from '@voltro/protocol/rest';
18
+ import { PublicApiProfileConfig } from '@voltro/protocol';
19
+ import { PublicApiSpec } from '@voltro/protocol';
18
20
  import { RelationsSpec } from '@voltro/database';
19
21
  import { RestRouteDescriptor } from '@voltro/protocol/rest';
20
22
  import { RestServeBindings } from '@voltro/protocol/rest';
@@ -344,6 +346,9 @@ export declare interface MakeTestAppOptions {
344
346
  /** HTTP idempotency binding — the same `{ store, header, ttlMs }` the serve
345
347
  * path builds from `app.config.ts`'s `idempotency` field. */
346
348
  readonly idempotency?: RestServeBindings['idempotency'];
349
+ /** The api's `publicApi:` config — the REST profile the projections speak,
350
+ * the same object `app.config.ts` declares. Absent → `'rpc'`. */
351
+ readonly publicApiProfile?: PublicApiProfileConfig;
347
352
  }
348
353
 
349
354
  export declare const makeTestContext: <RowFilterCtx = unknown>(options?: MakeTestContextOptions<RowFilterCtx>) => TestContext;
@@ -417,6 +422,15 @@ export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
417
422
  * test harness never runs the real boot gate. Merged OVER `process.env`
418
423
  * (these win). Default: just the ambient `process.env`. */
419
424
  readonly env?: Record<string, string | undefined>;
425
+ /**
426
+ * The key for `.encrypted()` columns under test. Registers the field cipher
427
+ * the boot would derive from `VOLTRO_FIELD_ENCRYPTION_KEY`, so a handler that
428
+ * writes such a column runs here instead of failing with "no field cipher is
429
+ * registered". Process-global like the boot's (`setFieldCipher`), and left
430
+ * alone when omitted — `env` alone does NOT do this: the harness runs no boot
431
+ * gate, and the gate is what turns the variable into a cipher.
432
+ */
433
+ readonly fieldEncryptionKey?: string;
420
434
  /**
421
435
  * Plugins whose rpc interceptors (`interceptMutation` / `interceptQuery` /
422
436
  * `interceptAction`) wrap every `invoke` on this context — the same list an
@@ -491,6 +505,8 @@ export declare interface MakeWorkflowRunnerOptions {
491
505
  readonly workflows?: ReadonlyArray<WorkflowEntry>;
492
506
  }
493
507
 
508
+ export declare const markRestInvocation: (ctx: TestContext, publicApi: PublicApiSpec) => void;
509
+
494
510
  /** Minimal AI mock surface. The full `mockAi({...})` helper is owned by
495
511
  * the AI package (plan 05); typed here as an optional injected interface
496
512
  * so `@voltro/testing` carries no hard `@voltro/ai` dependency. */
@@ -601,6 +617,11 @@ export declare const outboxNudgesOf: (ctx: TestContext) => ReadonlyArray<string>
601
617
 
602
618
  export { ParseResult }
603
619
 
620
+ /** The plugins a context was built with — for the REST harness, which hands
621
+ * their cross-cutting errors to the `publicApi` projection the way both boot
622
+ * paths do. */
623
+ export declare const pluginsOf: (ctx: TestContext) => ReadonlyArray<VoltroPlugin>;
624
+
604
625
  /** Any procedure executor: takes the DECODED input + a context, returns a
605
626
  * result — sync, `Promise`, or `Effect`. Mirrors the `(input, ctx) => …` shape
606
627
  * a real query / mutation / action handler has, INCLUDING the Effect form: the
@@ -669,6 +690,10 @@ export declare const reportEngineVersion: (label: string, probe: () => Promise<s
669
690
  */
670
691
  export declare const resetAfterCommit: (ctx: TestContext) => void;
671
692
 
693
+ export declare const restInvocationOf: (ctx: TestContext) => {
694
+ readonly publicApi: PublicApiSpec;
695
+ } | undefined;
696
+
672
697
  export { Row }
673
698
 
674
699
  /**
package/dist/index.js CHANGED
@@ -1,47 +1,47 @@
1
1
  import { AUTO_FILLED_COLUMNS as e, allRegisteredTables as t, changeDelete as n, changeInsert as r, changeSoftDelete as i, changeUpdate as a, clearRelationsRegistry as o, missingRequiredColumns as s, registerRelations as c } from "@voltro/database";
2
2
  import { Cause as l, Effect as u, Exit as d, Layer as f, Schema as p } from "effect";
3
3
  import { SubjectService as m, anonymousSubject as h, checkGuardsEffect as g, composeAuthStrategies as _, composeRpcInterceptors as v, eventRoute as y, systemSubject as b, tenantScopedSubject as ee } from "@voltro/protocol";
4
- import { EventBus as x, InMemoryDataStore as te, NO_ROW_FILTER as S, appValidation as ne, applyRowFilterToDescriptor as re, clearSystemStoreHandle as ie, getRowFilter as C, getSystemStoreHandle as ae, makeAppAccess as oe, makeConnectionState as se, makeDataLoader as ce, makeEffectStoreLayer as le, makeEventPublisher as ue, makeOutboxFacade as de, makeSchemaRegistry as fe, publishEvent as pe, resolveRowFilterScopeFor as me, runProvidedEffect as w, runWithDeadlockRetry as he, setSystemStoreHandle as T, wrapStoreWithMixinBehaviour as E } from "@voltro/runtime";
5
- import { installEnvSnapshot as ge } from "@voltro/env";
6
- import _e from "node:net";
7
- import { describe as ve } from "vitest";
8
- import { collectPublicApiRoutes as ye, dispatchSharedPath as be, restRoutesToHttpRoutes as xe } from "@voltro/protocol/rest";
9
- import { createLogger as Se } from "@voltro/logger";
10
- import { CurrentWorkflowRunId as Ce, inMemoryWorkflowEngineLayer as we, makeInMemoryRecorder as Te } from "@voltro/workflow";
4
+ import { EventBus as x, InMemoryDataStore as te, NO_ROW_FILTER as S, appValidation as ne, applyRowFilterToDescriptor as re, clearSystemStoreHandle as ie, getRowFilter as C, getSystemStoreHandle as ae, makeAppAccess as oe, makeConnectionState as se, makeDataLoader as ce, makeEffectStoreLayer as le, makeEventPublisher as ue, makeFieldCipher as de, makeOutboxFacade as fe, makeSchemaRegistry as pe, publishEvent as me, resolveRowFilterScopeFor as he, runProvidedEffect as ge, runWithDeadlockRetry as _e, setFieldCipher as ve, setSystemStoreHandle as w, wrapStoreWithMixinBehaviour as ye } from "@voltro/runtime";
5
+ import { installEnvSnapshot as be } from "@voltro/env";
6
+ import xe from "node:net";
7
+ import { describe as Se } from "vitest";
8
+ import { collectPublicApiRoutes as Ce, dispatchSharedPath as we, restRoutesToHttpRoutes as Te } from "@voltro/protocol/rest";
9
+ import { createLogger as Ee } from "@voltro/logger";
10
+ import { CurrentWorkflowRunId as De, inMemoryWorkflowEngineLayer as Oe, makeInMemoryRecorder as ke } from "@voltro/workflow";
11
11
  //#region src/fixtureRow.ts
12
- var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
12
+ var Ae = new Set(e), T = 0, E = () => ++T, je = (e, t) => {
13
13
  if (t.oneOf && t.oneOf.length > 0) return t.oneOf[0];
14
14
  if (t.enumValues && t.enumValues.length > 0) return t.enumValues[0];
15
15
  let n = t.unique === !0;
16
16
  switch (t.type) {
17
17
  case "text":
18
18
  case "id":
19
- case "enum": return n ? `${e}-${++D}` : e;
20
- case "reference": return t.referenceStorage === "numeric" ? n ? ++D : 0 : n ? `${e}-${++D}` : e;
19
+ case "enum": return n ? `${e}-${++T}` : e;
20
+ case "reference": return t.referenceStorage === "numeric" ? n ? ++T : 0 : n ? `${e}-${++T}` : e;
21
21
  case "integer":
22
- case "real": return n ? ++D : 0;
22
+ case "real": return n ? ++T : 0;
23
23
  case "decimal":
24
- case "bigint": return n ? String(++D) : "0";
24
+ case "bigint": return n ? String(++T) : "0";
25
25
  case "boolean": return !1;
26
26
  case "timestamp":
27
27
  case "date": return /* @__PURE__ */ new Date(0);
28
28
  default: throw Error(`fixtureRow: column '${e}' is a required '${t.type}' with no default, and fixtureRow can't synthesize a safe placeholder for that type. Pass it explicitly: fixtureRow(table, { ${e}: … }).`);
29
29
  }
30
- }, k = (e, t = {}) => {
30
+ }, D = (e, t = {}) => {
31
31
  let n = {};
32
- for (let r of s(e, t)) Ee.has(r) || (n[r] = De(r, e.fields[r]));
32
+ for (let r of s(e, t)) Ae.has(r) || (n[r] = je(r, e.fields[r]));
33
33
  return {
34
34
  ...n,
35
35
  ...t
36
36
  };
37
- }, Oe = (e) => {
37
+ }, Me = (e) => {
38
38
  let t = {};
39
- for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(O()) : r;
39
+ for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(E()) : r;
40
40
  return t;
41
- }, ke = (e) => {
41
+ }, Ne = (e) => {
42
42
  for (let [t, n] of Object.entries(e.fields)) if (n.type === "id") return t;
43
43
  return "id";
44
- }, Ae = async (e, t, n, r, i) => {
44
+ }, Pe = async (e, t, n, r, i) => {
45
45
  let a = {};
46
46
  for (let o of s(t, n)) {
47
47
  let n = t.fields[o];
@@ -49,29 +49,29 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
49
49
  let s = n.references();
50
50
  if (i.includes(s.tableName)) throw Error(`defineFactory: creating '${t.tableName}' requires '${s.tableName}' via '${o}', and that reference is cyclic (${[...i, s.tableName].join(" → ")}). Insert one side first and pass the id: create(store, { ${o}: existing.id }).`);
51
51
  let c = r[o];
52
- a[o] = (c === void 0 ? await A(e, s, {}, {}, [...i, s.tableName]) : await c.create(e, {}))[n.referenceColumn ?? ke(s)];
52
+ a[o] = (c === void 0 ? await O(e, s, {}, {}, [...i, s.tableName]) : await c.create(e, {}))[n.referenceColumn ?? Ne(s)];
53
53
  }
54
54
  return a;
55
- }, A = async (e, t, n, r, i) => {
55
+ }, O = async (e, t, n, r, i) => {
56
56
  let a = {
57
- ...await Ae(e, t, n, r, i),
57
+ ...await Pe(e, t, n, r, i),
58
58
  ...n
59
59
  };
60
- return e.insert(t.tableName, k(t, a));
61
- }, j = (e, t) => typeof e == "function" ? e(t) : e ?? {}, je = (e, t = {}) => {
60
+ return e.insert(t.tableName, D(t, a));
61
+ }, k = (e, t) => typeof e == "function" ? e(t) : e ?? {}, Fe = (e, t = {}) => {
62
62
  let n = t.traits ?? {}, r = t.associations ?? {}, i = (t) => {
63
63
  let a = (e = {}) => ({
64
- ...Oe(t),
64
+ ...Me(t),
65
65
  ...e
66
- }), o = (t = {}) => k(e, a(t));
66
+ }), o = (t = {}) => D(e, a(t));
67
67
  return {
68
68
  table: e,
69
69
  build: o,
70
- buildList: (e, t) => Array.from({ length: e }, (e, n) => o(j(t, n))),
71
- create: (t, n = {}) => A(t, e, a(n), r, [e.tableName]),
70
+ buildList: (e, t) => Array.from({ length: e }, (e, n) => o(k(t, n))),
71
+ create: (t, n = {}) => O(t, e, a(n), r, [e.tableName]),
72
72
  createList: async (t, n, i) => {
73
73
  let o = [];
74
- for (let s = 0; s < n; s++) o.push(await A(t, e, a(j(i, s)), r, [e.tableName]));
74
+ for (let s = 0; s < n; s++) o.push(await O(t, e, a(k(i, s)), r, [e.tableName]));
75
75
  return o;
76
76
  },
77
77
  with: (...r) => {
@@ -93,19 +93,19 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
93
93
  };
94
94
  };
95
95
  return i(t.defaults ?? {});
96
- }, M = Symbol.for("voltro/testing/mockClock.installed"), N = () => globalThis, Me = (e, t) => new Proxy(e, {
96
+ }, A = Symbol.for("voltro/testing/mockClock.installed"), j = () => globalThis, Ie = (e, t) => new Proxy(e, {
97
97
  construct: (e, n, r) => Reflect.construct(e, n.length === 0 ? [t()] : n, r),
98
98
  apply: () => new e(t()).toString(),
99
99
  get: (e, n, r) => n === "now" ? () => t() : Reflect.get(e, n, r)
100
- }), P = (e) => {
100
+ }), M = (e) => {
101
101
  if (typeof e == "number") return e;
102
102
  let t = e instanceof Date ? e.getTime() : Date.parse(e);
103
103
  if (Number.isNaN(t)) throw Error(`mockClock: cannot read an instant from '${String(e)}'`);
104
104
  return t;
105
- }, F = class {
105
+ }, N = class {
106
106
  currentMs;
107
107
  constructor(e = "2026-01-01T00:00:00Z") {
108
- this.currentMs = P(e);
108
+ this.currentMs = M(e);
109
109
  }
110
110
  now() {
111
111
  return this.currentMs;
@@ -114,47 +114,47 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
114
114
  return new Date(this.currentMs);
115
115
  }
116
116
  advance(e) {
117
- this.currentMs += typeof e == "number" ? e : Ie(e);
117
+ this.currentMs += typeof e == "number" ? e : Be(e);
118
118
  }
119
119
  set(e) {
120
- this.currentMs = P(e);
120
+ this.currentMs = M(e);
121
121
  }
122
122
  get installed() {
123
- return N()[M]?.clock === this;
123
+ return j()[A]?.clock === this;
124
124
  }
125
125
  install() {
126
- let e = N(), t = e[M];
126
+ let e = j(), t = e[A];
127
127
  if (t !== void 0) throw Error("mockClock: global time is already faked" + (t.clock === this ? " by this same clock" : " by another MockClock") + ". Uninstall it before installing again — a nested install restores the outer fake on the way out, which leaves the realm frozen with nothing pointing at why. `withFrozenTime` always restores, including on a throw.");
128
128
  let n = globalThis.Date;
129
- return e[M] = {
129
+ return e[A] = {
130
130
  realDate: n,
131
131
  clock: this
132
- }, globalThis.Date = Me(n, () => this.currentMs), () => {
132
+ }, globalThis.Date = Ie(n, () => this.currentMs), () => {
133
133
  this.uninstall();
134
134
  };
135
135
  }
136
136
  uninstall() {
137
- let e = N(), t = e[M];
138
- t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[M]);
137
+ let e = j(), t = e[A];
138
+ t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[A]);
139
139
  }
140
- }, Ne = (e) => typeof e == "object" && !!e && typeof e.then == "function", Pe = (e, t) => {
141
- let n = e instanceof F ? e : new F(e);
140
+ }, Le = (e) => typeof e == "object" && !!e && typeof e.then == "function", Re = (e, t) => {
141
+ let n = e instanceof N ? e : new N(e);
142
142
  n.install();
143
143
  let r = !1;
144
144
  try {
145
145
  let e = t(n);
146
- return Ne(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
146
+ return Le(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
147
147
  throw n.uninstall(), e;
148
148
  })) : e;
149
149
  } finally {
150
150
  r || n.uninstall();
151
151
  }
152
- }, Fe = (e) => u.acquireRelease(u.sync(() => {
153
- let t = e instanceof F ? e : new F(e);
152
+ }, ze = (e) => u.acquireRelease(u.sync(() => {
153
+ let t = e instanceof N ? e : new N(e);
154
154
  return t.install(), t;
155
155
  }), (e) => u.sync(() => {
156
156
  e.uninstall();
157
- })), Ie = (e) => {
157
+ })), Be = (e) => {
158
158
  let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
159
159
  if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
160
160
  let n = Number(t[1]), r = t[2];
@@ -166,7 +166,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
166
166
  case "d": return n * 864e5;
167
167
  default: throw Error(`mockClock: unknown unit '${r}'`);
168
168
  }
169
- }, I = class {
169
+ }, Ve = class {
170
170
  emitted = [];
171
171
  async emit(e, t) {
172
172
  let n = typeof e == "string" ? e : e.id ?? e.event ?? "(unnamed event)";
@@ -185,14 +185,14 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
185
185
  clear() {
186
186
  this.emitted.length = 0;
187
187
  }
188
- }, L = (e = {}) => {
188
+ }, P = (e = {}) => {
189
189
  let t = e.tenantId ?? null, n = new x({
190
190
  origin: e.origin ?? "test",
191
191
  ...e.ringSize === void 0 ? {} : { ringSize: e.ringSize }
192
192
  });
193
193
  return {
194
194
  publish: async (e, r, i) => {
195
- let a = await u.runPromise(u.either(pe({
195
+ let a = await u.runPromise(u.either(me({
196
196
  bus: n,
197
197
  tenantId: t
198
198
  }, e, r, i)));
@@ -255,7 +255,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
255
255
  n.clear();
256
256
  }
257
257
  };
258
- }, R = class {
258
+ }, F = class {
259
259
  queue;
260
260
  calls = [];
261
261
  constructor(e) {
@@ -270,36 +270,38 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
270
270
  remaining() {
271
271
  return this.queue.length;
272
272
  }
273
- }, Le = (e) => e, z = "00000000000000000000000000000000", B = /* @__PURE__ */ new WeakMap(), V = /* @__PURE__ */ new WeakMap(), Re = (e, t) => {
274
- let n = V.get(e);
273
+ }, He = (e) => e, I = "00000000000000000000000000000000", L = /* @__PURE__ */ new WeakMap(), R = /* @__PURE__ */ new WeakMap(), Ue = (e, t) => {
274
+ let n = R.get(e);
275
275
  if (n === void 0) throw Error("withRpcConnection requires a context created by makeTestContext");
276
276
  return n(t);
277
- }, H = /* @__PURE__ */ new WeakMap(), U = /* @__PURE__ */ new WeakMap(), W = /* @__PURE__ */ new WeakMap(), G = /* @__PURE__ */ new WeakMap(), K = (e, t) => {
278
- let n = W.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
277
+ }, z = /* @__PURE__ */ new WeakMap(), B = /* @__PURE__ */ new WeakMap(), V = /* @__PURE__ */ new WeakMap(), H = (e) => V.get(e) ?? [], U = /* @__PURE__ */ new WeakMap(), W = (e, t) => {
278
+ U.set(e, { publicApi: t });
279
+ }, G = (e) => U.get(e), K = /* @__PURE__ */ new WeakMap(), q = (e, t) => {
280
+ let n = V.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
279
281
  for (let e of n) {
280
282
  let t = e[r];
281
283
  typeof t == "function" && i.push(t);
282
284
  }
283
285
  return v(i);
284
- }, q = (e) => {
285
- let t = (W.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = G.get(e) ?? [], r = [...t, ...n];
286
- if (r.length !== 0) return f.mergeAll(...r);
287
286
  }, J = (e) => {
288
- let t = H.get(e);
287
+ let t = (V.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = K.get(e) ?? [], r = [...t, ...n];
288
+ if (r.length !== 0) return f.mergeAll(...r);
289
+ }, Y = (e) => {
290
+ let t = z.get(e);
289
291
  t !== void 0 && (t.length = 0);
290
- }, Y = async (e) => {
291
- let t = H.get(e);
292
+ }, X = async (e) => {
293
+ let t = z.get(e);
292
294
  if (t === void 0 || t.length === 0) return;
293
295
  let n = [...t];
294
296
  t.length = 0;
295
297
  for (let e of n) await e();
296
- }, ze = (e) => U.get(e) ?? [], X = async (e, t) => {
297
- let n = B.get(e);
298
+ }, We = (e) => B.get(e) ?? [], Z = async (e, t) => {
299
+ let n = L.get(e);
298
300
  return n === void 0 ? e.store.transactional(async (n) => t({
299
301
  ...e,
300
302
  store: n
301
303
  })) : n(t);
302
- }, Be = (e) => {
304
+ }, Ge = (e) => {
303
305
  let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
304
306
  let r = t.get(n);
305
307
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -338,7 +340,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
338
340
  return i(e, o, n.ttlMs, n.tags), o;
339
341
  }
340
342
  };
341
- }, Ve = (e) => {
343
+ }, Ke = (e) => {
342
344
  let t = /* @__PURE__ */ new Map(), n = (n) => {
343
345
  let r = t.get(n);
344
346
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -366,45 +368,45 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
366
368
  t.clear();
367
369
  }
368
370
  };
369
- }, He = async (e, t) => {
371
+ }, qe = async (e, t) => {
370
372
  let n = ae();
371
- T(e);
373
+ w(e);
372
374
  try {
373
375
  return await t();
374
376
  } finally {
375
- n === void 0 ? ie() : T(n);
377
+ n === void 0 ? ie() : w(n);
376
378
  }
377
- }, Z = /* @__PURE__ */ new Set(), Ue = (e) => {
378
- Z.has(e) || (Z.add(e), console.warn(e));
379
- }, We = (e, t, n) => {
380
- let r = Ge, i;
379
+ }, Q = /* @__PURE__ */ new Set(), Je = (e) => {
380
+ Q.has(e) || (Q.add(e), console.warn(e));
381
+ }, Ye = (e, t, n) => {
382
+ let r = Xe, i;
381
383
  return () => {
382
384
  let a = t ?? C();
383
- return t !== void 0 && C() !== void 0 && t !== C() && Ue("[voltro:testing] this test passes `rowFilter:` while a DIFFERENT filter is registered via `setRowFilter`. The option wins, so this test does not exercise your app's registration. Drop `rowFilter:` to run the real process-global one."), (i === void 0 || a !== r) && (r = a, i = He(n, () => w(me(a, e, (e) => {
385
+ return t !== void 0 && C() !== void 0 && t !== C() && Je("[voltro:testing] this test passes `rowFilter:` while a DIFFERENT filter is registered via `setRowFilter`. The option wins, so this test does not exercise your app's registration. Drop `rowFilter:` to run the real process-global one."), (i === void 0 || a !== r) && (r = a, i = qe(n, () => ge(he(a, e, (e) => {
384
386
  console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
385
387
  })))), i;
386
388
  };
387
- }, Ge = Symbol("unresolved"), Ke = (e, t) => new Proxy(e, { get: (e, n) => {
389
+ }, Xe = Symbol("unresolved"), Ze = (e, t) => new Proxy(e, { get: (e, n) => {
388
390
  if (n === "query") return async (n) => e.query(re(await t(), n));
389
391
  let r = Reflect.get(e, n, e);
390
392
  return typeof r == "function" ? r.bind(e) : r;
391
- } }), qe = (e = {}) => {
392
- if (ge({
393
+ } }), Qe = (e = {}) => {
394
+ if (be({
393
395
  ...process.env,
394
396
  ...e.env ?? {}
395
- }), e.relations !== void 0) {
397
+ }), e.fieldEncryptionKey !== void 0 && ve(de(e.fieldEncryptionKey)), e.relations !== void 0) {
396
398
  o();
397
399
  for (let t of e.relations) c(t);
398
400
  }
399
- let n = e.tables ?? t(), r = fe(n), i = new te(e.store ?? {}), a = {
401
+ let n = e.tables ?? t(), r = pe(n), i = new te(e.store ?? {}), a = {
400
402
  dataStore: i,
401
403
  schemaRegistry: r
402
404
  };
403
- T(a);
404
- let s = new F(e.clockStart), l = new I(), u = L(), d = new R(e.llmResponses ?? []), f = Be(() => s.now()), p = Ve(() => s.now()), m = (t, n, o = i, c) => {
405
+ w(a);
406
+ let s = new N(e.clockStart), l = new Ve(), u = P(), d = new F(e.llmResponses ?? []), f = Ge(() => s.now()), p = Ke(() => s.now()), m = (t, n, o = i, c) => {
405
407
  let h = {
406
408
  subject: t,
407
- traceId: z,
409
+ traceId: I,
408
410
  ...n === void 0 ? {} : { connection: n }
409
411
  }, g = c?.queued ?? [], _ = c?.nudges ?? [], v = ue({
410
412
  bus: u.bus,
@@ -414,7 +416,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
414
416
  e();
415
417
  });
416
418
  } }
417
- }), y = ce({ store: o }), b = We(t, e.rowFilter, a), x = {
419
+ }), y = ce({ store: o }), b = Ye(t, e.rowFilter, a), x = {
418
420
  clock: s,
419
421
  webhooks: l,
420
422
  events: v,
@@ -429,22 +431,22 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
429
431
  access: oe(t),
430
432
  cache: f,
431
433
  kv: p,
432
- store: E(Ke(o, b), {
434
+ store: ye(Ze(o, b), {
433
435
  subject: t,
434
436
  schemaRegistry: r,
435
437
  rowFilter: S
436
438
  }),
437
- storeForTenant: (e) => E(Ke(o, b), {
439
+ storeForTenant: (e) => ye(Ze(o, b), {
438
440
  subject: ee(t, e),
439
441
  schemaRegistry: r,
440
442
  rowFilter: S
441
443
  }),
442
- outbox: de({
444
+ outbox: fe({
443
445
  store: o,
444
446
  subject: t,
445
- traceId: z,
447
+ traceId: I,
446
448
  nudge: () => {
447
- _.push(z);
449
+ _.push(I);
448
450
  },
449
451
  afterCommit: (e) => {
450
452
  g.push(e);
@@ -475,22 +477,22 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
475
477
  nudges: _
476
478
  })
477
479
  };
478
- return H.set(x, g), V.set(x, (e) => m(t, e, o, {
480
+ return z.set(x, g), R.set(x, (e) => m(t, e, o, {
479
481
  queued: g,
480
482
  nudges: _
481
- })), U.set(x, _), W.set(x, e.plugins ?? []), G.set(x, e.layers ?? []), B.set(x, (e) => o.transactional((r) => e(m(t, n, r, {
483
+ })), B.set(x, _), V.set(x, e.plugins ?? []), K.set(x, e.layers ?? []), L.set(x, (e) => o.transactional((r) => e(m(t, n, r, {
482
484
  queued: g,
483
485
  nudges: _
484
486
  })))), x;
485
487
  };
486
488
  return m(e.subject ?? h(null), e.connection);
487
- }, Je = (e, t) => {
488
- let n = q(t);
489
+ }, $e = (e, t) => {
490
+ let n = J(t);
489
491
  return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(le(t.store)), u.provideService(m, t.request.subject));
490
- }, Ye = async (e, t, n, r) => {
492
+ }, et = async (e, t, n, r) => {
491
493
  let i = e.input, a = await p.decodeUnknownPromise(i)(n), o = e.kind, s = async (e) => {
492
494
  let n = t(a, e);
493
- return u.isEffect(n) ? w(Je(n, e)) : n;
495
+ return u.isEffect(n) ? ge($e(n, e)) : n;
494
496
  }, c = async () => {
495
497
  let t = e.guards;
496
498
  if (t !== void 0 && t.length > 0) {
@@ -498,29 +500,33 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
498
500
  if (e !== null) throw e;
499
501
  }
500
502
  if (o !== "mutation") return s(r);
501
- let n = await he(async () => X(r, async (e) => (J(r), s(e))), { delay: () => Promise.resolve() });
502
- return await Y(r), n;
503
- }, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 : K(r, f);
503
+ let n = await _e(async () => Z(r, async (e) => (Y(r), s(e))), { delay: () => Promise.resolve() });
504
+ return await X(r), n;
505
+ }, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 : q(r, f);
504
506
  if (m === void 0 || f === void 0) return await c();
505
- let h = m(u.tryPromise({
507
+ let h = u.tryPromise({
506
508
  try: () => c(),
507
509
  catch: (e) => e
508
- }), {
510
+ }), _ = G(r), v = m(h, {
509
511
  tag: e.name ?? "",
510
512
  kind: f,
511
513
  input: a,
512
514
  subject: r.request.subject,
513
515
  traceId: r.request.traceId,
514
- ...r.request.spanId === void 0 ? {} : { spanId: r.request.spanId }
515
- }), _ = await u.runPromiseExit(h);
516
- if (d.isSuccess(_)) return _.value;
517
- throw l.squash(_.cause);
518
- }, Xe = (e) => new Promise((t) => {
519
- let n = new _e.Socket(), r = (e) => {
516
+ ...r.request.spanId === void 0 ? {} : { spanId: r.request.spanId },
517
+ ..._ === void 0 ? {} : {
518
+ surface: "rest",
519
+ publicApi: _.publicApi
520
+ }
521
+ }), y = await u.runPromiseExit(v);
522
+ if (d.isSuccess(y)) return y.value;
523
+ throw l.squash(y.cause);
524
+ }, tt = (e) => new Promise((t) => {
525
+ let n = new xe.Socket(), r = (e) => {
520
526
  clearTimeout(i), n.destroy(), t(e);
521
527
  }, i = setTimeout(() => r(!1), e.timeoutMs ?? 750);
522
528
  n.once("connect", () => r(!0)), n.once("error", () => r(!1)), n.connect(e.port, e.host);
523
- }), Ze = async (e, t, n, r) => {
529
+ }), nt = async (e, t, n, r) => {
524
530
  if (typeof t != "string" || typeof n != "function" || typeof r != "function") throw TypeError(`describeIfAvailable('${e}', ...): expected (label, dependency, probe, suite) — got dependency: ${typeof t}, probe: ${typeof n}, suite: ${typeof r}. Three arguments means the suite body is being used as the probe: it would run at file scope, unconditionally, and the skip would silently do nothing.`);
525
531
  let i = !1;
526
532
  try {
@@ -528,21 +534,21 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
528
534
  } catch {
529
535
  i = !1;
530
536
  }
531
- ve.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
532
- }, Qe = async (e, t, n) => {
533
- await Ze(e, `${t.name} at ${t.host}:${t.port}`, () => Xe(t), n);
534
- }, $e = async (e, t) => {
537
+ Se.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
538
+ }, rt = async (e, t, n) => {
539
+ await nt(e, `${t.name} at ${t.host}:${t.port}`, () => tt(t), n);
540
+ }, it = async (e, t) => {
535
541
  try {
536
542
  let n = await t();
537
543
  n !== void 0 && n !== "" && console.log(`[engine] ${e}: ${n}`);
538
544
  } catch {}
539
- }, et = "test-tenant", Q = (e, t, n) => ({
545
+ }, at = "test-tenant", $ = (e, t, n) => ({
540
546
  type: e,
541
547
  id: t,
542
548
  tenantId: n.tenantId ?? "test-tenant",
543
549
  scopes: n.scopes ?? [],
544
550
  ...n.metadata === void 0 ? {} : { metadata: n.metadata }
545
- }), tt = (e, t = {}) => Q("user", e, t), nt = (e, t = {}) => Q("apiKey", e, t), rt = (e, t = {}) => Q("serviceAccount", e, t), it = (e = null) => h(e), at = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), ot = (e) => {
551
+ }), ot = (e, t = {}) => $("user", e, t), st = (e, t = {}) => $("apiKey", e, t), ct = (e, t = {}) => $("serviceAccount", e, t), lt = (e = null) => h(e), ut = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), dt = (e) => {
546
552
  let t = e.indexOf("?");
547
553
  return t === -1 ? {
548
554
  path: e,
@@ -551,18 +557,21 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
551
557
  path: e.slice(0, t),
552
558
  query: e.slice(t + 1)
553
559
  };
554
- }, st = (e, t) => {
560
+ }, ft = (e, t) => {
555
561
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0);
556
562
  return n.length === r.length && n.every((e, t) => e.startsWith(":") || e === r[t]);
557
- }, $ = (e) => {
563
+ }, pt = (e) => {
558
564
  let t = {};
559
565
  for (let [n, r] of Object.entries(e)) t[n.toLowerCase()] = r;
560
566
  return t;
561
- }, ct = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), lt = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), ut = (e) => {
562
- let { ctx: t } = e, n = ye((e.publicApi ?? []).map((e) => ({
567
+ }, mt = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), ht = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), gt = (e) => {
568
+ let { ctx: t } = e, n = Ce((e.publicApi ?? []).map((e) => ({
563
569
  descriptor: e.descriptor,
564
- invoke: (n, r) => t.withSubject(r.subject, (t) => Ye(e.descriptor, e.handler, n, t))
565
- }))), r = [...e.restRoutes ?? [], ...n], i = _(e.strategies ?? [], {
570
+ invoke: (n, r) => t.withSubject(r.subject, (t) => (e.descriptor.publicApi !== void 0 && W(t, e.descriptor.publicApi), et(e.descriptor, e.handler, n, t)))
571
+ })), {
572
+ pluginErrors: H(t).flatMap((e) => e.errorSchemas ?? []),
573
+ ...e.publicApiProfile === void 0 ? {} : { profile: e.publicApiProfile }
574
+ }), r = [...e.restRoutes ?? [], ...n], i = _(e.strategies ?? [], {
566
575
  ...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
567
576
  getStore: () => t.store
568
577
  }), a = (n, o) => {
@@ -574,11 +583,11 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
574
583
  clientId: -1
575
584
  })).subject : () => n,
576
585
  ...e.idempotency === void 0 ? {} : { idempotency: e.idempotency }
577
- }, c = xe(r, s).map((e, t) => ({
586
+ }, c = Te(r, s).map((e, t) => ({
578
587
  pattern: r[t].path,
579
588
  route: e
580
589
  })), l = async (e, n, i = {}) => {
581
- let { path: a, query: s } = ot(n), l = c.filter((e) => st(e.pattern, a)).map((e) => e.route);
590
+ let { path: a, query: s } = dt(n), l = c.filter((e) => ft(e.pattern, a)).map((e) => e.route);
582
591
  if (l.length === 0) return {
583
592
  status: 404,
584
593
  headers: {},
@@ -594,15 +603,15 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
594
603
  path: a,
595
604
  query: s,
596
605
  headers: {
597
- ...$(o),
598
- ...$(i.headers ?? {})
606
+ ...pt(o),
607
+ ...pt(i.headers ?? {})
599
608
  },
600
- rawBody: ct(i.body),
609
+ rawBody: mt(i.body),
601
610
  store: t.store
602
- }, d = await be(l, u), f = {};
611
+ }, d = await we(l, u), f = {};
603
612
  for (let [e, t] of Object.entries(d.headers ?? {})) f[e.toLowerCase()] = t;
604
613
  d.contentType !== void 0 && (f["content-type"] = d.contentType);
605
- let p = lt(d.body), m = (d.contentType ?? "").includes("json");
614
+ let p = ht(d.body), m = (d.contentType ?? "").includes("json");
606
615
  return {
607
616
  status: d.status,
608
617
  headers: f,
@@ -636,17 +645,17 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
636
645
  };
637
646
  };
638
647
  return a(void 0, {});
639
- }, dt = (e = {}) => {
648
+ }, _t = (e = {}) => {
640
649
  let t = e.id ?? "test", n = e.store ?? new Proxy({}, { get: (e, t) => {
641
650
  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.`);
642
651
  } });
643
652
  return {
644
653
  id: t,
645
- log: e.log ?? Se({ scope: `subscribe:${t}` }),
654
+ log: e.log ?? Ee({ scope: `subscribe:${t}` }),
646
655
  store: n,
647
656
  ...e.publish === void 0 ? {} : { publish: e.publish }
648
657
  };
649
- }, ft = (e) => {
658
+ }, vt = (e) => {
650
659
  let t = /* @__PURE__ */ new Map();
651
660
  for (let n of e) {
652
661
  let e = t.get(n.stepName);
@@ -672,13 +681,13 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
672
681
  });
673
682
  }
674
683
  return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
675
- }, pt = (e) => {
684
+ }, yt = (e) => {
676
685
  let t = e.workflows ?? [], n = /* @__PURE__ */ new Map(), r = 0;
677
686
  return {
678
687
  start: async (i, a) => {
679
688
  let o = t.find((e) => e.workflow.name === i);
680
689
  if (o === void 0) throw Error(`makeWorkflowRunner: no workflow named '${i}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
681
- let s = `wfrun_test_${++r}`, c = Te(), l = o.workflow.toLayer(o.execute).pipe(f.provideMerge(we)), d = /* @__PURE__ */ new Date(), p = o.workflow.execute(a).pipe(u.locally(Ce, s), u.provide(c.layer), u.provide(l), u.either), m = await u.runPromise(p), h = ft(c.readSteps()), g = /* @__PURE__ */ new Date(), _;
690
+ let s = `wfrun_test_${++r}`, c = ke(), l = o.workflow.toLayer(o.execute).pipe(f.provideMerge(Oe)), d = /* @__PURE__ */ new Date(), p = o.workflow.execute(a).pipe(u.locally(De, s), u.provide(c.layer), u.provide(l), u.either), m = await u.runPromise(p), h = vt(c.readSteps()), g = /* @__PURE__ */ new Date(), _;
682
691
  if (m._tag === "Right") _ = {
683
692
  status: "succeeded",
684
693
  output: m.right,
@@ -718,6 +727,6 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
718
727
  },
719
728
  inspect: async (e) => n.get(e) ?? null
720
729
  };
721
- }, mt = 1;
730
+ }, bt = 1;
722
731
  //#endregion
723
- export { F as MockClock, R as MockLLM, I as MockWebhooks, mt as TESTING_PRESET_VERSION, et as TEST_TENANT_ID, it as anonymous, nt as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, je as defineFactory, Ze as describeIfAvailable, Qe as describeIfReachable, k as fixtureRow, Fe as frozenTime, Ye as invoke, Xe as isTcpReachable, dt as makeSubscribeContext, ut as makeTestApp, qe as makeTestContext, pt as makeWorkflowRunner, Le as mockStore, O as nextSequence, ze as outboxNudgesOf, $e as reportEngineVersion, J as resetAfterCommit, K as rpcInterceptorFor, Y as runAfterCommit, X as runInStoreTransaction, rt as serviceAccount, q as serviceLayerFor, at as system, L as testEventBus, tt as user, Pe as withFrozenTime, Re as withRpcConnection };
732
+ export { N as MockClock, F as MockLLM, Ve as MockWebhooks, bt as TESTING_PRESET_VERSION, at as TEST_TENANT_ID, lt as anonymous, st as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Fe as defineFactory, nt as describeIfAvailable, rt as describeIfReachable, D as fixtureRow, ze as frozenTime, et as invoke, tt as isTcpReachable, _t as makeSubscribeContext, gt as makeTestApp, Qe as makeTestContext, yt as makeWorkflowRunner, W as markRestInvocation, He as mockStore, E as nextSequence, We as outboxNudgesOf, H as pluginsOf, it as reportEngineVersion, Y as resetAfterCommit, G as restInvocationOf, q as rpcInterceptorFor, X as runAfterCommit, Z as runInStoreTransaction, ct as serviceAccount, J as serviceLayerFor, ut as system, P as testEventBus, ot as user, Re as withFrozenTime, Ue as withRpcConnection };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/testing",
3
- "version": "0.69.1",
3
+ "version": "0.70.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,16 +43,16 @@
43
43
  "node": ">=24.0.0"
44
44
  },
45
45
  "dependencies": {
46
- "@voltro/database": "0.69.1",
47
- "@voltro/env": "0.69.1",
48
- "@voltro/logger": "0.69.1",
49
- "@voltro/protocol": "0.69.1",
50
- "@voltro/runtime": "0.69.1",
51
- "@voltro/workflow": "0.69.1"
46
+ "@voltro/database": "0.70.0",
47
+ "@voltro/env": "0.70.0",
48
+ "@voltro/logger": "0.70.0",
49
+ "@voltro/protocol": "0.70.0",
50
+ "@voltro/runtime": "0.70.0",
51
+ "@voltro/workflow": "0.70.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@effect/sql": "^0.52.1",
55
- "@voltro/client": "0.69.1",
55
+ "@voltro/client": "0.70.0",
56
56
  "effect": "^3.22.1",
57
57
  "react": "^19.0.0"
58
58
  },