@voltro/testing 0.80.0 → 0.81.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,96 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.81.0] — 2026-09-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **The compose baseline gives each api app its own database** — `@voltro/cli`
47
+
48
+ `voltro baseline sync` now gives every api app in a `project.json` its own database, login and migrate service. The primary api (the app named `api`, else the first api app) keeps the project's database; every other api app connects to `<project>_<app>` with `<PROJECT>_<APP>_DB_PASSWORD`, and each api app gets `VOLTRO_API_<APP>_URL` for its sibling api apps by service name. Migration: sync, move each secondary app's own tables into its new database, and set the new password variables in production.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.81.0/03_compose-baseline-one-database-per-api-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
51
+ - **A crashed process blocks its idempotency key for 30 seconds, not a day** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/voltro`
52
+
53
+ A process that died inside an idempotent mutation left its key `in_flight` for the whole replay window — 24 hours by default — so every retry with that key was refused with `MutationInFlight` (REST: `409`), including the retry of a workflow step whose callee had simply restarted. A running claim now holds on a 30-second lease that the server renews while the handler runs, over the socket and on REST alike; a dead process stops renewing, and the next send with the key takes over and runs the mutation once — the dead call's transaction never committed. A completed response still replays for the whole window. A takeover is conditional on the claim still being stale, so a claim renewed a moment earlier is never taken from a live handler. `ctx.apis` calls treat `MutationInFlight` as no answer yet: the same key is re-sent when the callee says, until it answers or the call's deadline passes (then `ApiUnavailable`). BREAKING only for an app that implements `IdempotencyStore` itself: `claim` takes the lease, and `renew(scope, key, now)` is required.
54
+
55
+ **`voltro update` carries you across this** — codemod `0.81.0/05_idempotency-store-renews-its-lease`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
56
+ - **Each api app owns its database schema; a second app on it refuses to boot** — `@voltro/database`, `@voltro/cli`
57
+
58
+ The first boot that migrates a database records which app owns it (`_voltro_schema_owner`). A different app booting against the same database and schema now refuses — naming both apps — before it plans anything, under `voltro dev`, `voltro serve` and `VOLTRO_AUTO_MIGRATE=0` alike; `voltro db apply` refuses the same way and claims an unclaimed database — as its first write, after every other check has passed, so an apply refused for drift or a blocked operation leaves the database as it found it. `voltro data` export and import treat the owner row as the deployment's own: it never travels to another database. Two apps on one schema each diffed it against their own declarations and planned to drop the other's tables. Migration: give every api app its own database (`DB_URL`) or schema (`DB_SCHEMA`) and reach the other app's data through `apis:` and `service:` procedures; `voltro doctor` lists api apps of a project that resolve the same target. `voltro db claim --yes` hands a schema to a RENAMED app.
59
+
60
+ **`voltro update` carries you across this** — codemod `0.81.0/01_one-database-per-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
61
+ - **ReactionDefinition is the union of the table form and the query form** — `@voltro/runtime`, `@voltro/voltro`
62
+
63
+ With reactions that watch a live query's rows, `ReactionDefinition` is now `EdgeReactionDefinition | LevelReactionDefinition`. Code holding a value of that type and reading a member only the table form has — `watch.table`, `watch.on`, `guards.dedupeKey`, `when(event)` — no longer compiles: type it `EdgeReactionDefinition`, or narrow with `isLevelReaction(def)` first. `defineReaction({ … })` is unchanged: an object literal resolves to its own form, and a value typed as the union is still accepted and validated by its `watch`.
64
+
65
+ **`voltro update` carries you across this** — codemod `0.81.0/04_reaction-definition-is-a-union`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
66
+ - **Store calls on a table the app does not declare now fail** — `@voltro/runtime`, `@voltro/cli`
67
+
68
+ Every store verb — in handlers, startups, schedules and workflows — now fails with `UndeclaredTableAccess` on a table that is neither declared by the app (its `table()`s, a plugin's `extendSchema.tables`) nor framework-owned (`_voltro_*`), in every environment. A string table name used to reach another app's table past its guards, audit and tenant rules. `store.raw(sql)` is unchanged. Migration: call the owning app through `apis:` and a `service:` procedure, or declare the table if it is this app's own.
69
+
70
+ **`voltro update` carries you across this** — codemod `0.81.0/02_store-refuses-undeclared-tables`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
71
+
72
+ ### Added
73
+
74
+ - **A plugin can bring app-shaped building blocks with `sources`** — `@voltro/protocol`, `@voltro/cli`
75
+
76
+ `definePlugin({ sources: fileURLToPath(new URL('./app', import.meta.url)) })` points at a directory of the same file conventions an app uses — queries and mutations with their executors, `schema.ts` tables, `*.workflow.tsx`, `*.cron.tsx`, subscribers, reactions — and every app that installs the plugin discovers them exactly as it discovers its own: the same codegen, browser-safety guard, access gate, migrations, and both boot paths. A reusable building block (a ledger, an audit trail, an approval flow) is shared between apps of a project without copying it; each installing app gets its own copy of the block's tables in its own database. `voltro build` keys the block's modules by plugin and path, so the serve bundle finds them wherever the block lives after a deploy.
77
+ - **A reaction can watch the rows of a live query, including another app's** — `@voltro/runtime`, `@voltro/cli`
78
+
79
+ `defineReaction({ watch: { query: 'deployments.pending', api: 'cloud' }, key: (row) => row.id, act: { kind: 'workflow', workflow: 'deploy.run' } })` reacts to every ROW of a live query's current result — the set of open work — instead of to each change of a table. Every delivery is the whole set, so a row that appeared while the reacting process was down, restarting or deploying is acted on when it comes back; nothing is lost. The workflow's `idempotencyKey` makes each row's act exactly-once across every replica, so no claim table is involved and a start that failed is tried again on the next delivery or on `resync` (default 5 minutes). `resync` and `stuckAfter` take milliseconds or a duration from `'30s'` to `'8 days'`. With `api:` the query is another app's `service:` query, subscribed over `ctx.apis` — push without the watched app knowing its watcher; without it, one of this app's own queries, read through the same guards and row filter as its REST projection. A row still in the set long after its workflow started is reported as `stuck` and never restarted. `voltro_reaction_pending`, `voltro_reaction_oldest_seconds` and `voltro_reaction_connected` expose the queue per reaction. The table form (`watch: { table }`) is unchanged; `runReaction` now takes the table form's type, `EdgeReactionDefinition`.
80
+ - **Apps of one project call each other through typed service procedures** — `@voltro/voltro`, `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/cli`, `@voltro/testing`, `@voltro/workflow`
81
+
82
+ An api app can now call another app of the same project, typed, over the socket, without touching its tables. The called app declares `service: { callers: ['worker'] }` on a query, mutation or action and lists the caller under `serviceCallers: { worker: {} }`; codegen emits those procedures into `serviceGroup.generated.ts` and never into the browser's `rpcGroup.generated.ts`. The calling app binds it under `apis: { cloud: { package: '@acme/api' } }` and calls `ctx.apis.cloud.deployments.activate(input)` (Promise) or `.effect(input)` (Effect), with every input, output and typed error inferred from the callee's declarations. A service authenticates with a per-edge credential (`VOLTRO_API_<BINDING>_TOKEN` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee, `_NEXT` for rotation); `voltro dev` derives and mints it into both apps' `.env.local`, production supplies it (`voltro secret generate service`) and a missing one fails boot. A caller name is letters, digits and dashes, at most 56 characters, so its `service:<name>` identity fits an `id()` column. The called app plants an `actors` row for each accepted caller at boot (`service:<name>`, `kind: 'serviceAccount'`), so `audit()`-stamped writes name the calling app instead of failing on the foreign key. A service subject holds no scopes: it reaches exactly the procedures that name it, is refused everywhere else — framework built-ins included — and no user, API key or `admin:full` session reaches a service procedure. A write made inside a workflow step carries an idempotency key derived from the step, so a step retried after a lost reply replays instead of writing twice; an app with `serviceCallers` deduplicates mutations by default. A call without an answer fails with `ApiUnavailable` within a deadline (10 s by default); calls are measured as `voltro_api_call_duration_seconds` and `voltro_api_call_errors_total`. `voltro doctor` reports a caller listed without a procedure, a procedure naming an unlisted caller and a binding the callee does not accept. `@voltro/testing` adds `makeServiceApiMock`, which decodes every input and output through the callee's real schemas.
83
+
84
+ `apiSurface: compatible` — the replaced golden lines in `@voltro/protocol` (and the `@voltro/voltro` aggregate re-exporting it) are `AnyCheckSpec` and `DeclaredAccess` gaining the member `ServiceAccessSpec`. Every existing member is unchanged and an app keeps writing the guards it wrote; the framework is the only producer of the new member, which it normalises from `service:`. Only a switch exhaustive over guard specs needs an arm.
85
+ - **startTestApps boots several apps of one project, each service edge wired** — `@voltro/testing`
86
+
87
+ `startTestApps({ apps, edges })` from `@voltro/testing` boots the apps of one project as real `voltro` processes (`dev` by default, `serve` with `mode: 'serve'`), each on a free port — every callee before its callers, because a caller imports the contract its callee's codegen writes — and resolves once every app answers `/internal/readiness`; a cycle of edges is refused before anything starts. Every listed edge gets a freshly minted credential written to both ends, under the variables the apps read (`VOLTRO_API_<BINDING>_TOKEN` / `_URL` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee); `apps.token(caller, callee)` returns it so a test can call the callee as the caller. An app that exits or is not ready in time stops the others and the call rejects with its log. Processes rather than one process, because apps with their own tables cannot share one process's table registry.
88
+ - **makeTestApp requests carry the caller's address** — `@voltro/testing`
89
+
90
+ A route that reads `ctx.remoteAddr` (a per-client rate limit, an audit field) could not be tested per client: every `makeTestApp` request arrived without an address. A request's options now take `remoteAddr`, the address the connection comes from, defaulting to `127.0.0.1`. The route sees it as `ctx.remoteAddr`, resolved by the function the server's listener uses, and a `publicApi` procedure's plugin interceptors see the same address, as on the server. `makeTestApp({ security: { trustedProxies, clientAddressHeader } })` takes the api's own settings, so a forwarding header is believed in a test exactly when it would be in production.
91
+
92
+ `markRestInvocation` takes the request's address as an optional third argument, and `restInvocationOf` returns it beside the spec; both only gain an optional member, so existing calls and reads are unchanged.
93
+
94
+ ### Fixed
95
+
96
+ - **Codegen removes an rpc group the app no longer has procedures for** — `@voltro/cli`
97
+
98
+ An app whose last client-reachable procedure went away — deleted, or marked `internal: true` — kept its `rpcGroup.generated.ts`: codegen reported "no rpc primitives found — nothing to write" and left the old file, a typed contract for procedures the server no longer served. The next `voltro dev` or `voltro codegen` now removes it, as it already removed an empty `serviceGroup.generated.ts` and `apis.generated.ts`. A hand-written file of that name is left alone.
99
+ - **The auto-launched dashboard binds `VOLTRO_DASHBOARD_PORT`** — `@voltro/cli`
100
+
101
+ `VOLTRO_DASHBOARD_PORT` was read by `voltro dashboard` only. The dashboard that `voltro dev` and `voltro start` launch on their own — api and web app alike — always came up on `:5179`, and the launcher wrote `5179` into the child's `VOLTRO_DASHBOARD_PORT`, overriding the value the developer had set. The launcher and `voltro dashboard` now resolve the port through one reader: the variable, else `5179`. A value that is not a port (`1..65535`) is refused with a warning naming it instead of silently becoming `5179`.
102
+ - **The db pool boot line counts a connection outside the pool once** — `@voltro/cli`
103
+
104
+ With change data capture on, the line read `max=5 … + 1 = 6` and then, separately, `PLUS 1 outside the pool (CDC LISTEN consumer)`, which reads as 7 when the process holds 6. The outside connection is now named once, inside the sum it belongs to: `max=5 per replica (DB_MAX_CONNECTIONS) + 1 outside the pool (CDC LISTEN consumer) = 6 per process`.
105
+ - **A declared `utf8mb4` store reaches the workflow engine's tables** — `@voltro/database`, `@voltro/workflow`, `@voltro/cli`
106
+
107
+ `store: { charset: 'utf8mb4' }` converted every table the app declares and left the tables the framework creates without declaring them in the database default: the durable workflow engine's `cluster_*` storage, which holds every workflow call's input and result as JSON text, and the deploy runner's `_voltro_deploys` / `_voltro_migration_ops`. On a `utf8mb3` database a workflow whose input carried an emoji was refused there. Every table the framework creates is now created in the declared charset: the runner's ledgers directly, and the workflow engine's tables by the process that creates them on its first boot, while they are still empty. A workflow table a booting process finds is never rewritten at boot; tables from before the declaration are converted by `voltro db apply` (also when the plan has nothing to do, and with `--plan`) once the deploy has closed and by the `voltro dev` auto-migrate, keeping type, nullability and column CHECKs, and `voltro db plan` lists them beside the plan (under `--json` too, on the log, never in the plan file). The database's own default is left alone. The step only ever widens, and a deploy that fails and is undone never runs it.
108
+ - **The MySQL/MariaDB pool keeps its connections instead of reconnecting** — `@voltro/sql-mysql`
109
+
110
+ The driver pool was created with `maxIdle: 0`, so every connection released to it was closed within a second. A process reconnected after each second of quiet: a TCP handshake per burst, plus a TLS one where configured. Measured on MySQL and MariaDB: a query after two quiet seconds ran on a new connection. The pool now keeps the connections it opened, up to the limit.
111
+ - **Workflows started by a reaction or an incoming webhook start again** — `@voltro/cli`
112
+
113
+ A workflow started by a `*.reaction.ts` act or an incoming `*.webhook.tsx` was refused with `Workflow placement requires explicit caller identity`, under `voltro dev` and `voltro serve`: neither path named a caller, and placement refuses a start without one. Both now start as the framework itself, placed in the changed row's tenant when it has one.
114
+ - **The first boot on an empty database no longer warns about replica observations** — `@voltro/cli`
115
+
116
+ `voltro dev` started publishing this replica's observations before its auto-migrate, so the first write on an empty database hit a table that did not exist yet and printed `could not record this replica's observation — fleet views will report this replica as stale` on the first boot of every new project. The publisher now writes its first observation once the schema is applied — at once on `voltro serve`, whose fingerprint check runs before it — and never when the boot stops earlier.
117
+ - **A 405 on a shared REST path names every method the path serves** — `@voltro/protocol`
118
+
119
+ A `DELETE` to a path served by `GET` and `POST` answered `405` with `Allow: POST`: each route refused with its own method, and the last refusal won. `Allow` and the error body's `allow` now list every method declared on the path, `GET, POST`, as the REST documentation already promised.
120
+ - **A large REST surface no longer holds the boot for seconds registering routes** — `@voltro/runtime`, `@voltro/protocol`
121
+
122
+ Every REST route used to be mounted for all 34 HTTP methods the router knows, at its path and at its sub-path, and each insert compares itself against every entry already held. A public REST surface of about 1000 procedures therefore held about 34 000 router entries, and building them kept the thread busy for over a second after the port was bound — long enough for a liveness probe to time out. A path is now mounted for exactly the methods its routes declare: the same 1000 procedures hold 4 000 entries. Behaviour is unchanged. A method the path does not declare still reaches its routes and gets the precise `405` with `Allow`, a path nobody declared is still a `404`, and `HEAD` still answers from `GET`. For this, a REST route now declares its real `method` instead of `'*'` and sets the new `PluginHttpRoute.answersOtherMethods: true`. A plugin route that declares one method without that flag never sees another method, as before. A server of your own that mounts `restRoutesToHttpRoutes` output by method must hand the other methods to routes with `answersOtherMethods`, or they become `404` instead of `405`.
123
+ - **The testing guide's tsconfig example no longer sets baseUrl** — `@voltro/cli`
124
+
125
+ The `paths` alias example in the testing guide set `"baseUrl": "."`, an option TypeScript 7 removed. It now maps `paths` alone, which resolves against the tsconfig's own directory. `voltro test` still reads a `baseUrl` an older config sets.
126
+ - **voltro update lists a note's code and config lines before prose** — `@voltro/cli`
127
+
128
+ A manual note lists the lines it found in code and config first, under `── found in code or config (N lines)`. Lines in Markdown and text files follow in their own `── mentioned in prose (N lines)` block, and a note that matched only prose says `nothing in code or config matched`. Past the lines shown, the rest are named by file with a count instead of a bare `… and N more`. Before, the lines were sorted by path, and 40 lines of documentation hid a Helm env entry, the one line to change. The guide `voltro agents-md` copies into `agent-docs/` is no longer searched. It names every change of a release, so refreshing it made notes report "found" for changes the project never used.
129
+
130
+ ---
131
+
42
132
  ## [0.80.0] — 2026-09-24
43
133
 
44
134
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ import { RpcInterceptor } from '@voltro/protocol';
27
27
  import { RpcKind } from '@voltro/protocol';
28
28
  import { Schema } from 'effect';
29
29
  import { Scope } from 'effect';
30
+ import { ServiceApi } from '@voltro/protocol';
30
31
  import { Subject } from '@voltro/protocol';
31
32
  import { SubscribeContext } from '@voltro/runtime';
32
33
  import { SyncLogger } from '@voltro/logger';
@@ -223,6 +224,17 @@ export declare const fixtureRow: (table: TableLike, overrides?: Row) => Row;
223
224
  */
224
225
  export declare const frozenTime: (at: Date | number | string | MockClock) => Effect.Effect<MockClock, never, Scope.Scope>;
225
226
 
227
+ declare type HandlerFor<D> = D extends {
228
+ readonly input: infer I;
229
+ readonly output: infer O;
230
+ } ? (input: I extends {
231
+ readonly Type: infer T;
232
+ } ? T : never) => Promise<O extends {
233
+ readonly Type: infer T;
234
+ } ? T : never> | (O extends {
235
+ readonly Type: infer T;
236
+ } ? T : never) : never;
237
+
226
238
  /**
227
239
  * What `invoke` resolves to for a handler returning `Result`: an `Effect`'s
228
240
  * SUCCESS value, an awaited `Promise`, or the value itself.
@@ -287,6 +299,10 @@ export declare const invoke: <D extends Pick<ProcedureDescriptor, "input">, Resu
287
299
  /** Can we open a TCP connection to `host:port` within `timeoutMs`? */
288
300
  export declare const isTcpReachable: (target: ReachableTarget) => Promise<boolean>;
289
301
 
302
+ export declare const makeServiceApiMock: <P>(descriptors: Readonly<Record<string, MockDescriptor>>, handlers: ServiceApiMockHandlers<P>, options?: {
303
+ readonly name?: string;
304
+ }) => ServiceApiMock<P>;
305
+
290
306
  /**
291
307
  * A `SubscribeContext` typed as the real one.
292
308
  *
@@ -361,6 +377,18 @@ export declare interface MakeTestAppOptions {
361
377
  /** The api's `publicApi:` config — the REST profile the projections speak,
362
378
  * the same object `app.config.ts` declares. Absent → `'rpc'`. */
363
379
  readonly publicApiProfile?: PublicApiProfileConfig;
380
+ /**
381
+ * The api's `security` settings that decide the caller's address — the same
382
+ * fields `app.config.ts` declares. `ctx.remoteAddr` is resolved from a
383
+ * request's `remoteAddr` and headers through them, by the function the
384
+ * server's listener calls, so a forwarding header is believed here exactly
385
+ * when it would be in production. `VOLTRO_TRUSTED_PROXIES` applies as it does
386
+ * there. Absent → no proxy is trusted.
387
+ */
388
+ readonly security?: {
389
+ readonly trustedProxies?: ReadonlyArray<string>;
390
+ readonly clientAddressHeader?: string;
391
+ };
364
392
  }
365
393
 
366
394
  export declare const makeTestContext: <RowFilterCtx = unknown>(options?: MakeTestContextOptions<RowFilterCtx>) => TestContext;
@@ -369,6 +397,13 @@ export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
369
397
  /** The acting subject. Default: anonymous, no tenant. Read it back at
370
398
  * `ctx.request.subject` (the same place a live handler reads it). */
371
399
  readonly subject?: Subject;
400
+ /**
401
+ * `ctx.apis` — the apps this handler calls. Pass a fake per binding, usually
402
+ * `makeServiceApiMock<ServiceProcedures>({ … })` from `@voltro/testing`, which
403
+ * decodes every input and output against the callee's real schemas. Absent →
404
+ * the empty object, as for an app with no `apis:`.
405
+ */
406
+ readonly apis?: AppContext['apis'];
372
407
  /** Optional connection fixture, or the actual RuntimeContext.connection from
373
408
  * a listener test. Shared by transaction/subject re-scopes, never invented by
374
409
  * the harness; omitting it models an internal invocation. */
@@ -517,7 +552,7 @@ export declare interface MakeWorkflowRunnerOptions {
517
552
  readonly workflows?: ReadonlyArray<WorkflowEntry>;
518
553
  }
519
554
 
520
- export declare const markRestInvocation: (ctx: TestContext, publicApi: PublicApiSpec) => void;
555
+ export declare const markRestInvocation: (ctx: TestContext, publicApi: PublicApiSpec, remoteAddr?: string) => void;
521
556
 
522
557
  /** Minimal AI mock surface. The full `mockAi({...})` helper is owned by
523
558
  * the AI package (plan 05); typed here as an optional injected interface
@@ -560,6 +595,12 @@ export declare class MockClock {
560
595
  uninstall(): void;
561
596
  }
562
597
 
598
+ declare interface MockDescriptor {
599
+ readonly kind: string;
600
+ readonly input: unknown;
601
+ readonly output: unknown;
602
+ }
603
+
563
604
  export declare class MockLLM {
564
605
  private readonly queue;
565
606
  readonly calls: MockLLMCall[];
@@ -702,9 +743,19 @@ export declare const reportEngineVersion: (label: string, probe: () => Promise<s
702
743
  */
703
744
  export declare const resetAfterCommit: (ctx: TestContext) => void;
704
745
 
705
- export declare const restInvocationOf: (ctx: TestContext) => {
746
+ /** A context that is running ONE `publicApi`-projected request — `invoke`
747
+ * reads it to put `surface: 'rest'` and the spec on the interceptor meta,
748
+ * exactly as the serve pipeline does for a `/v1` call. Set by `makeTestApp`
749
+ * on the per-request scoped context; never on the one a test holds. */
750
+ export declare interface RestInvocation {
706
751
  readonly publicApi: PublicApiSpec;
707
- } | undefined;
752
+ /** The request's resolved address — the serve pipeline hands it to the
753
+ * interceptors of a projected call (`projectedRequest`), so a per-address
754
+ * limit reads it there and must read it here. */
755
+ readonly remoteAddr?: string | undefined;
756
+ }
757
+
758
+ export declare const restInvocationOf: (ctx: TestContext) => RestInvocation | undefined;
708
759
 
709
760
  export { Row }
710
761
 
@@ -752,6 +803,27 @@ export declare const runInStoreTransaction: <T>(ctx: TestContext, work: (txCtx:
752
803
  * `storeForTenant` view). NOT the cross-tenant `system()` below. */
753
804
  export declare const serviceAccount: (id: string, options?: TestSubjectOptions) => Subject;
754
805
 
806
+ export declare interface ServiceApiMock<P> {
807
+ /** Pass this as `makeTestContext({ apis: { <name>: mock.api } })`. */
808
+ readonly api: ServiceApi<P>;
809
+ /** Every call made, in order — optionally for one tag. */
810
+ readonly calls: (tag?: keyof P & string) => ReadonlyArray<ServiceApiMockCall>;
811
+ /** Push a new value to every open subscription of a query. */
812
+ readonly emit: (tag: keyof P & string, input: unknown, value: unknown) => void;
813
+ }
814
+
815
+ export declare interface ServiceApiMockCall {
816
+ readonly tag: string;
817
+ readonly input: unknown;
818
+ readonly idempotencyKey: string | undefined;
819
+ }
820
+
821
+ /** Per tag: what the fake callee answers. A tag with no handler answers
822
+ * `ApiUnavailable` — the honest outcome of calling an app that is not there. */
823
+ export declare type ServiceApiMockHandlers<P> = {
824
+ readonly [K in keyof P]?: HandlerFor<P[K]>;
825
+ };
826
+
755
827
  /**
756
828
  * The handler service layer for this context — every registered plugin's
757
829
  * `services:` layer, then the app's own `layers:`, merged in that order.
@@ -774,6 +846,45 @@ export declare const serviceAccount: (id: string, options?: TestSubjectOptions)
774
846
  */
775
847
  export declare const serviceLayerFor: (ctx: TestContext) => Layer.Layer<never, never, never> | undefined;
776
848
 
849
+ export declare interface StartedTestApp {
850
+ readonly name: string;
851
+ readonly port: number;
852
+ /** `http://127.0.0.1:<port>` — the app's HTTP origin. */
853
+ readonly url: string;
854
+ /** `ws://127.0.0.1:<port>/ws` — the socket a service client connects to. */
855
+ readonly wsUrl: string;
856
+ /** Everything the process printed so far. */
857
+ readonly log: () => string;
858
+ readonly stop: () => Promise<void>;
859
+ }
860
+
861
+ export declare interface StartedTestApps<Name extends string> {
862
+ readonly apps: Readonly<Record<Name, StartedTestApp>>;
863
+ /** The credential of an edge — what a test presents to call the callee AS the caller. */
864
+ readonly token: (caller: Name, callee: Name) => string;
865
+ /** Stops every app; safe to call twice. */
866
+ readonly stop: () => Promise<void>;
867
+ }
868
+
869
+ /**
870
+ * Boot the apps — every callee before its callers — wait until each answers
871
+ * `/internal/readiness`, and return their ports, logs and edge credentials. A
872
+ * process that exits or does not become ready in time stops every other one
873
+ * and rejects with its log.
874
+ */
875
+ export declare const startTestApps: <Name extends string>(options: StartTestAppsOptions<Name>) => Promise<StartedTestApps<Name>>;
876
+
877
+ export declare interface StartTestAppsOptions<Name extends string> {
878
+ readonly apps: Readonly<Record<Name, TestAppSpec>>;
879
+ readonly edges?: ReadonlyArray<TestAppEdge<Name>>;
880
+ /** `dev` (codegen, migrations, minted secrets — the default) or `serve`. */
881
+ readonly mode?: 'dev' | 'serve';
882
+ /** Path to `voltro.mjs`. Default: `@voltro/cli` as the first app resolves it. */
883
+ readonly cli?: string;
884
+ /** How long EACH app may take to answer `/internal/readiness`. Default 5 minutes. */
885
+ readonly bootTimeoutMs?: number;
886
+ }
887
+
777
888
  /**
778
889
  * The CROSS-TENANT machine actor — cron, workflows, backfills. `tenantId` is
779
890
  * `null` by construction and the tenant read-scope mixin reads that as "every
@@ -811,6 +922,22 @@ export declare interface TestApp {
811
922
  readonly paths: ReadonlyArray<string>;
812
923
  }
813
924
 
925
+ export declare interface TestAppEdge<Name extends string> {
926
+ /** The calling app (its `name`). */
927
+ readonly caller: Name;
928
+ /** The called app (its `name`). */
929
+ readonly callee: Name;
930
+ /** The caller's `apis:` key for the callee. */
931
+ readonly binding: string;
932
+ }
933
+
934
+ export declare interface TestAppSpec {
935
+ /** The app's directory (the one holding `app.config.ts`). */
936
+ readonly root: string;
937
+ /** Extra environment for this app — its `DB_URL`, feature flags, … */
938
+ readonly env?: Readonly<Record<string, string>>;
939
+ }
940
+
814
941
  /** The test context IS an `AppContext` (store + request + cache), plus the
815
942
  * deterministic doubles and the subject/tenant re-scopers. Because it
816
943
  * extends `AppContext`, you can pass it straight into an executor typed
@@ -939,6 +1066,14 @@ export declare interface TestRequestOptions {
939
1066
  * `withHeaders`) headers. Names are lowercased, as the transport delivers
940
1067
  * them. */
941
1068
  readonly headers?: Readonly<Record<string, string>>;
1069
+ /**
1070
+ * The address the connection comes from — the socket peer. With no trusted
1071
+ * proxy declared (the default) it is `ctx.remoteAddr`; behind a declared
1072
+ * proxy, `x-forwarded-for` from that peer decides, as on the server.
1073
+ * Defaults to `127.0.0.1`. Two values are two clients, which is how a route
1074
+ * test shows a per-address limit.
1075
+ */
1076
+ readonly remoteAddr?: string;
942
1077
  }
943
1078
 
944
1079
  export declare interface TestResponse {
package/dist/index.js CHANGED
@@ -1,15 +1,19 @@
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, Either as d, Exit as f, Layer as p, Schema as m } from "effect";
3
- import { SubjectService as h, anonymousSubject as g, checkGuardsEffect as _, composeAuthStrategies as v, composeRpcInterceptors as y, eventRoute as b, systemSubject as x, tenantScopedSubject as ee } from "@voltro/protocol";
4
- import { EventBus as S, InMemoryDataStore as te, NO_ROW_FILTER as C, appValidation as ne, applyRowFilterToDescriptor as re, clearSystemStoreHandle as ie, getRowFilter as w, getSystemStoreHandle as ae, makeAppAccess as oe, makeConnectionState as se, makeDataLoader as ce, makeEffectStoreLayer as T, makeEventPublisher as le, makeFieldCipher as ue, makeOutboxFacade as de, makeSchemaRegistry as fe, publishEvent as pe, resolveRowFilterScopeFor as me, resolveTransactionSubject as he, runProvidedEffect as E, runWithDeadlockRetry as ge, setFieldCipher as _e, setSystemStoreHandle as D, wrapStoreWithMixinBehaviour as O } from "@voltro/runtime";
5
- import { installEnvSnapshot as ve } from "@voltro/env";
6
- import ye from "node:net";
7
- import { describe as be } from "vitest";
8
- import { collectPublicApiRoutes as xe, dispatchSharedPath as Se, restRoutesToHttpRoutes as Ce } from "@voltro/protocol/rest";
9
- import { createLogger as we } from "@voltro/logger";
10
- import { CurrentWorkflowRunId as Te, inMemoryWorkflowEngineLayer as Ee, makeInMemoryRecorder as De } from "@voltro/workflow";
3
+ import { ApiUnavailable as h, SubjectService as g, anonymousSubject as _, checkGuardsEffect as v, composeAuthStrategies as y, composeRpcInterceptors as b, eventRoute as x, systemSubject as S, tenantScopedSubject as ee } from "@voltro/protocol";
4
+ import { EventBus as te, InMemoryDataStore as ne, NO_ROW_FILTER as C, apiBindingTokenEnv as re, apiBindingUrlEnv as ie, appValidation as ae, applyRowFilterToDescriptor as oe, clearSystemStoreHandle as se, getRowFilter as w, getSystemStoreHandle as ce, makeAppAccess as le, makeConnectionState as ue, makeDataLoader as de, makeEffectStoreLayer as fe, makeEventPublisher as pe, makeFieldCipher as me, makeOutboxFacade as he, makeSchemaRegistry as ge, mintServiceToken as _e, publishEvent as ve, resolveRequestClientAddress as ye, resolveRowFilterScopeFor as be, resolveTransactionSubject as xe, resolveTrustedProxies as Se, runProvidedEffect as T, runWithDeadlockRetry as Ce, serviceCallerTokenEnv as we, setFieldCipher as Te, setSystemStoreHandle as E, wrapStoreWithMixinBehaviour as D } from "@voltro/runtime";
5
+ import { installEnvSnapshot as Ee } from "@voltro/env";
6
+ import O from "node:net";
7
+ import { describe as De } from "vitest";
8
+ import { collectPublicApiRoutes as Oe, dispatchSharedPath as ke, restRoutesToHttpRoutes as Ae } from "@voltro/protocol/rest";
9
+ import { createLogger as je } from "@voltro/logger";
10
+ import { CurrentWorkflowRunId as Me, inMemoryWorkflowEngineLayer as Ne, makeInMemoryRecorder as Pe } from "@voltro/workflow";
11
+ import { spawn as Fe } from "node:child_process";
12
+ import { randomBytes as Ie } from "node:crypto";
13
+ import { createRequire as Le } from "node:module";
14
+ import { dirname as Re, join as ze } from "node:path";
11
15
  //#region src/fixtureRow.ts
12
- var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
16
+ var Be = new Set(e), k = 0, Ve = () => ++k, He = (e, t) => {
13
17
  if (t.oneOf && t.oneOf.length > 0) return t.oneOf[0];
14
18
  if (t.enumValues && t.enumValues.length > 0) return t.enumValues[0];
15
19
  let n = t.unique === !0;
@@ -27,38 +31,38 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
27
31
  case "date": return /* @__PURE__ */ new Date(0);
28
32
  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
33
  }
30
- }, j = (e, t = {}) => {
34
+ }, A = (e, t = {}) => {
31
35
  let n = {};
32
- for (let r of s(e, t)) Oe.has(r) || (n[r] = ke(r, e.fields[r]));
36
+ for (let r of s(e, t)) Be.has(r) || (n[r] = He(r, e.fields[r]));
33
37
  return {
34
38
  ...n,
35
39
  ...t
36
40
  };
37
- }, M = m.decodeUnknownEither(m.DateFromString), Ae = /^\d{4}-\d{2}-\d{2}$/, je = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, Me = (e, t) => {
38
- if (typeof e != "string" || !je.test(e) && !(t === "date" && Ae.test(e))) return !1;
39
- let n = e.slice(0, 10), r = M(n);
41
+ }, j = m.decodeUnknownEither(m.DateFromString), Ue = /^\d{4}-\d{2}-\d{2}$/, We = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, Ge = (e, t) => {
42
+ if (typeof e != "string" || !We.test(e) && !(t === "date" && Ue.test(e))) return !1;
43
+ let n = e.slice(0, 10), r = j(n);
40
44
  return d.isRight(r) && Number.isFinite(r.right.getTime()) && r.right.toISOString().slice(0, 10) === n;
41
- }, Ne = (e, t) => {
45
+ }, Ke = (e, t) => {
42
46
  let n = Object.entries(e.fields).filter(([, e]) => e.type === "timestamp" || e.type === "date");
43
47
  return t.map((t, r) => {
44
48
  let i = { ...t };
45
49
  for (let [a, o] of n) {
46
50
  let n = t[a];
47
51
  if (n == null || n instanceof Date && Number.isFinite(n.getTime())) continue;
48
- let s = M(n);
49
- if (!Me(n, o.type) || d.isLeft(s) || !Number.isFinite(s.right.getTime())) throw TypeError(`hydrateFixtureRows: table '${e.tableName}', row ${r}, column '${a}' requires a valid Date or ISO date-time with Z or an offset${o.type === "date" ? " (YYYY-MM-DD is also accepted as UTC midnight)" : ""}.`);
52
+ let s = j(n);
53
+ if (!Ge(n, o.type) || d.isLeft(s) || !Number.isFinite(s.right.getTime())) throw TypeError(`hydrateFixtureRows: table '${e.tableName}', row ${r}, column '${a}' requires a valid Date or ISO date-time with Z or an offset${o.type === "date" ? " (YYYY-MM-DD is also accepted as UTC midnight)" : ""}.`);
50
54
  i[a] = s.right;
51
55
  }
52
56
  return i;
53
57
  });
54
- }, Pe = (e) => {
58
+ }, qe = (e) => {
55
59
  let t = {};
56
- for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(A()) : r;
60
+ for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(Ve()) : r;
57
61
  return t;
58
- }, Fe = (e) => {
62
+ }, Je = (e) => {
59
63
  for (let [t, n] of Object.entries(e.fields)) if (n.type === "id") return t;
60
64
  return "id";
61
- }, Ie = async (e, t, n, r, i) => {
65
+ }, Ye = async (e, t, n, r, i) => {
62
66
  let a = {};
63
67
  for (let o of s(t, n)) {
64
68
  let n = t.fields[o];
@@ -66,29 +70,29 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
66
70
  let s = n.references();
67
71
  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 }).`);
68
72
  let c = r[o];
69
- a[o] = (c === void 0 ? await N(e, s, {}, {}, [...i, s.tableName]) : await c.create(e, {}))[n.referenceColumn ?? Fe(s)];
73
+ a[o] = (c === void 0 ? await M(e, s, {}, {}, [...i, s.tableName]) : await c.create(e, {}))[n.referenceColumn ?? Je(s)];
70
74
  }
71
75
  return a;
72
- }, N = async (e, t, n, r, i) => {
76
+ }, M = async (e, t, n, r, i) => {
73
77
  let a = {
74
- ...await Ie(e, t, n, r, i),
78
+ ...await Ye(e, t, n, r, i),
75
79
  ...n
76
80
  };
77
- return e.insert(t.tableName, j(t, a));
78
- }, P = (e, t) => typeof e == "function" ? e(t) : e ?? {}, Le = (e, t = {}) => {
81
+ return e.insert(t.tableName, A(t, a));
82
+ }, N = (e, t) => typeof e == "function" ? e(t) : e ?? {}, Xe = (e, t = {}) => {
79
83
  let n = t.traits ?? {}, r = t.associations ?? {}, i = (t) => {
80
84
  let a = (e = {}) => ({
81
- ...Pe(t),
85
+ ...qe(t),
82
86
  ...e
83
- }), o = (t = {}) => j(e, a(t));
87
+ }), o = (t = {}) => A(e, a(t));
84
88
  return {
85
89
  table: e,
86
90
  build: o,
87
- buildList: (e, t) => Array.from({ length: e }, (e, n) => o(P(t, n))),
88
- create: (t, n = {}) => N(t, e, a(n), r, [e.tableName]),
91
+ buildList: (e, t) => Array.from({ length: e }, (e, n) => o(N(t, n))),
92
+ create: (t, n = {}) => M(t, e, a(n), r, [e.tableName]),
89
93
  createList: async (t, n, i) => {
90
94
  let o = [];
91
- for (let s = 0; s < n; s++) o.push(await N(t, e, a(P(i, s)), r, [e.tableName]));
95
+ for (let s = 0; s < n; s++) o.push(await M(t, e, a(N(i, s)), r, [e.tableName]));
92
96
  return o;
93
97
  },
94
98
  with: (...r) => {
@@ -110,19 +114,19 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
110
114
  };
111
115
  };
112
116
  return i(t.defaults ?? {});
113
- }, F = Symbol.for("voltro/testing/mockClock.installed"), I = () => globalThis, Re = (e, t) => new Proxy(e, {
117
+ }, P = Symbol.for("voltro/testing/mockClock.installed"), F = () => globalThis, Ze = (e, t) => new Proxy(e, {
114
118
  construct: (e, n, r) => Reflect.construct(e, n.length === 0 ? [t()] : n, r),
115
119
  apply: () => new e(t()).toString(),
116
120
  get: (e, n, r) => n === "now" ? () => t() : Reflect.get(e, n, r)
117
- }), L = (e) => {
121
+ }), Qe = (e) => {
118
122
  if (typeof e == "number") return e;
119
123
  let t = e instanceof Date ? e.getTime() : Date.parse(e);
120
124
  if (Number.isNaN(t)) throw Error(`mockClock: cannot read an instant from '${String(e)}'`);
121
125
  return t;
122
- }, R = class {
126
+ }, I = class {
123
127
  currentMs;
124
128
  constructor(e = "2026-01-01T00:00:00Z") {
125
- this.currentMs = L(e);
129
+ this.currentMs = Qe(e);
126
130
  }
127
131
  now() {
128
132
  return this.currentMs;
@@ -131,47 +135,47 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
131
135
  return new Date(this.currentMs);
132
136
  }
133
137
  advance(e) {
134
- this.currentMs += typeof e == "number" ? e : He(e);
138
+ this.currentMs += typeof e == "number" ? e : nt(e);
135
139
  }
136
140
  set(e) {
137
- this.currentMs = L(e);
141
+ this.currentMs = Qe(e);
138
142
  }
139
143
  get installed() {
140
- return I()[F]?.clock === this;
144
+ return F()[P]?.clock === this;
141
145
  }
142
146
  install() {
143
- let e = I(), t = e[F];
147
+ let e = F(), t = e[P];
144
148
  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.");
145
149
  let n = globalThis.Date;
146
- return e[F] = {
150
+ return e[P] = {
147
151
  realDate: n,
148
152
  clock: this
149
- }, globalThis.Date = Re(n, () => this.currentMs), () => {
153
+ }, globalThis.Date = Ze(n, () => this.currentMs), () => {
150
154
  this.uninstall();
151
155
  };
152
156
  }
153
157
  uninstall() {
154
- let e = I(), t = e[F];
155
- t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[F]);
158
+ let e = F(), t = e[P];
159
+ t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[P]);
156
160
  }
157
- }, ze = (e) => typeof e == "object" && !!e && typeof e.then == "function", Be = (e, t) => {
158
- let n = e instanceof R ? e : new R(e);
161
+ }, $e = (e) => typeof e == "object" && !!e && typeof e.then == "function", et = (e, t) => {
162
+ let n = e instanceof I ? e : new I(e);
159
163
  n.install();
160
164
  let r = !1;
161
165
  try {
162
166
  let e = t(n);
163
- return ze(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
167
+ return $e(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
164
168
  throw n.uninstall(), e;
165
169
  })) : e;
166
170
  } finally {
167
171
  r || n.uninstall();
168
172
  }
169
- }, Ve = (e) => u.acquireRelease(u.sync(() => {
170
- let t = e instanceof R ? e : new R(e);
173
+ }, tt = (e) => u.acquireRelease(u.sync(() => {
174
+ let t = e instanceof I ? e : new I(e);
171
175
  return t.install(), t;
172
176
  }), (e) => u.sync(() => {
173
177
  e.uninstall();
174
- })), He = (e) => {
178
+ })), nt = (e) => {
175
179
  let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
176
180
  if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
177
181
  let n = Number(t[1]), r = t[2];
@@ -183,7 +187,7 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
183
187
  case "d": return n * 864e5;
184
188
  default: throw Error(`mockClock: unknown unit '${r}'`);
185
189
  }
186
- }, z = class {
190
+ }, L = class {
187
191
  emitted = [];
188
192
  async emit(e, t) {
189
193
  let n = typeof e == "string" ? e : e.id ?? e.event ?? "(unnamed event)";
@@ -202,14 +206,14 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
202
206
  clear() {
203
207
  this.emitted.length = 0;
204
208
  }
205
- }, B = (e = {}) => {
206
- let t = e.tenantId ?? null, n = new S({
209
+ }, R = (e = {}) => {
210
+ let t = e.tenantId ?? null, n = new te({
207
211
  origin: e.origin ?? "test",
208
212
  ...e.ringSize === void 0 ? {} : { ringSize: e.ringSize }
209
213
  });
210
214
  return {
211
215
  publish: async (e, r, i) => {
212
- let a = await u.runPromise(u.either(pe({
216
+ let a = await u.runPromise(u.either(ve({
213
217
  bus: n,
214
218
  tenantId: t
215
219
  }, e, r, i)));
@@ -258,7 +262,7 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
258
262
  },
259
263
  skipSerials: (e, r, i, a) => {
260
264
  let o = a?.tenantId ?? t, s = {
261
- route: b(o, e.name, r),
265
+ route: x(o, e.name, r),
262
266
  event: e.name,
263
267
  origin: "test-gap",
264
268
  n: i + 1,
@@ -272,7 +276,7 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
272
276
  n.clear();
273
277
  }
274
278
  };
275
- }, V = class {
279
+ }, z = class {
276
280
  queue;
277
281
  calls = [];
278
282
  constructor(e) {
@@ -287,38 +291,41 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
287
291
  remaining() {
288
292
  return this.queue.length;
289
293
  }
290
- }, Ue = (e) => e, H = "00000000000000000000000000000000", U = /* @__PURE__ */ new WeakMap(), W = /* @__PURE__ */ new WeakMap(), We = (e, t) => {
291
- let n = W.get(e);
294
+ }, rt = (e) => e, B = "00000000000000000000000000000000", V = /* @__PURE__ */ new WeakMap(), H = /* @__PURE__ */ new WeakMap(), it = (e, t) => {
295
+ let n = H.get(e);
292
296
  if (n === void 0) throw Error("withRpcConnection requires a context created by makeTestContext");
293
297
  return n(t);
294
- }, G = /* @__PURE__ */ new WeakMap(), K = /* @__PURE__ */ new WeakMap(), q = /* @__PURE__ */ new WeakMap(), J = (e) => q.get(e) ?? [], Y = /* @__PURE__ */ new WeakMap(), X = (e, t) => {
295
- Y.set(e, { publicApi: t });
296
- }, Ge = (e) => Y.get(e), Ke = /* @__PURE__ */ new WeakMap(), qe = (e, t) => {
297
- let n = q.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
298
+ }, U = /* @__PURE__ */ new WeakMap(), W = /* @__PURE__ */ new WeakMap(), G = /* @__PURE__ */ new WeakMap(), K = (e) => G.get(e) ?? [], q = /* @__PURE__ */ new WeakMap(), J = (e, t, n) => {
299
+ q.set(e, {
300
+ publicApi: t,
301
+ ...n === void 0 ? {} : { remoteAddr: n }
302
+ });
303
+ }, Y = (e) => q.get(e), X = /* @__PURE__ */ new WeakMap(), at = (e, t) => {
304
+ let n = G.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
298
305
  for (let e of n) {
299
306
  let t = e[r];
300
307
  typeof t == "function" && i.push(t);
301
308
  }
302
- return y(i);
303
- }, Je = (e) => {
304
- let t = (q.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = Ke.get(e) ?? [], r = [...t, ...n];
309
+ return b(i);
310
+ }, ot = (e) => {
311
+ let t = (G.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = X.get(e) ?? [], r = [...t, ...n];
305
312
  if (r.length !== 0) return p.mergeAll(...r);
306
- }, Ye = (e) => {
307
- let t = G.get(e);
313
+ }, st = (e) => {
314
+ let t = U.get(e);
308
315
  t !== void 0 && (t.length = 0);
309
316
  }, Z = async (e) => {
310
- let t = G.get(e);
317
+ let t = U.get(e);
311
318
  if (t === void 0 || t.length === 0) return;
312
319
  let n = [...t];
313
320
  t.length = 0;
314
321
  for (let e of n) await e();
315
- }, Xe = (e) => K.get(e) ?? [], Ze = async (e, t) => {
316
- let n = U.get(e);
322
+ }, ct = (e) => W.get(e) ?? [], lt = async (e, t) => {
323
+ let n = V.get(e);
317
324
  return n === void 0 ? e.store.transactional(async (n) => t({
318
325
  ...e,
319
326
  store: n
320
327
  })) : n(t);
321
- }, Qe = (e) => {
328
+ }, ut = (e) => {
322
329
  let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
323
330
  let r = t.get(n);
324
331
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -357,7 +364,7 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
357
364
  return i(e, o, n.ttlMs, n.tags), o;
358
365
  }
359
366
  };
360
- }, $e = (e) => {
367
+ }, dt = (e) => {
361
368
  let t = /* @__PURE__ */ new Map(), n = (n) => {
362
369
  let r = t.get(n);
363
370
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -385,47 +392,47 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
385
392
  t.clear();
386
393
  }
387
394
  };
388
- }, et = async (e, t) => {
389
- let n = ae();
390
- D(e);
395
+ }, ft = async (e, t) => {
396
+ let n = ce();
397
+ E(e);
391
398
  try {
392
399
  return await t();
393
400
  } finally {
394
- n === void 0 ? ie() : D(n);
401
+ n === void 0 ? se() : E(n);
395
402
  }
396
- }, tt = /* @__PURE__ */ new Set(), nt = (e) => {
397
- tt.has(e) || (tt.add(e), console.warn(e));
398
- }, rt = (e, t, n) => {
399
- let r = it, i;
403
+ }, pt = /* @__PURE__ */ new Set(), mt = (e) => {
404
+ pt.has(e) || (pt.add(e), console.warn(e));
405
+ }, ht = (e, t, n) => {
406
+ let r = gt, i;
400
407
  return () => {
401
408
  let a = t ?? w();
402
- return t !== void 0 && w() !== void 0 && t !== w() && nt("[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 = et(n, () => E(me(a, e, (e) => {
409
+ return t !== void 0 && w() !== void 0 && t !== w() && mt("[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 = ft(n, () => T(be(a, e, (e) => {
403
410
  console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
404
411
  })))), i;
405
412
  };
406
- }, it = Symbol("unresolved"), at = (e, t) => new Proxy(e, { get: (e, n) => {
407
- if (n === "query") return async (n) => e.query(re(await t(), n));
413
+ }, gt = Symbol("unresolved"), Q = (e, t) => new Proxy(e, { get: (e, n) => {
414
+ if (n === "query") return async (n) => e.query(oe(await t(), n));
408
415
  let r = Reflect.get(e, n, e);
409
416
  return typeof r == "function" ? r.bind(e) : r;
410
- } }), ot = (e = {}) => {
411
- if (ve({
417
+ } }), _t = (e = {}) => {
418
+ if (Ee({
412
419
  ...process.env,
413
420
  ...e.env ?? {}
414
- }), e.fieldEncryptionKey !== void 0 && _e(ue(e.fieldEncryptionKey)), e.relations !== void 0) {
421
+ }), e.fieldEncryptionKey !== void 0 && Te(me(e.fieldEncryptionKey)), e.relations !== void 0) {
415
422
  o();
416
423
  for (let t of e.relations) c(t);
417
424
  }
418
- let n = e.tables ?? t(), r = fe(n), i = new te(e.store ?? {}), a = {
425
+ let n = e.tables ?? t(), r = ge(n), i = new ne(e.store ?? {}), a = {
419
426
  dataStore: i,
420
427
  schemaRegistry: r
421
428
  };
422
- D(a);
423
- let s = new R(e.clockStart), l = new z(), u = B(), d = new V(e.llmResponses ?? []), f = Qe(() => s.now()), p = $e(() => s.now()), m = (t, n, o = i, c, h = !1) => {
429
+ E(a);
430
+ let s = new I(e.clockStart), l = new L(), u = R(), d = new z(e.llmResponses ?? []), f = ut(() => s.now()), p = dt(() => s.now()), m = (t, n, o = i, c, h = !1) => {
424
431
  let g = {
425
432
  subject: t,
426
- traceId: H,
433
+ traceId: B,
427
434
  ...n === void 0 ? {} : { connection: n }
428
- }, _ = c?.queued ?? [], v = c?.nudges ?? [], y = le({
435
+ }, _ = c?.queued ?? [], v = c?.nudges ?? [], y = pe({
429
436
  bus: u.bus,
430
437
  tenantId: t.tenantId ?? null,
431
438
  ...o === i ? {} : { deferToCommit: (e) => {
@@ -433,9 +440,10 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
433
440
  e();
434
441
  });
435
442
  } }
436
- }), b = ce({ store: o }), x = h ? async () => C : rt(t, e.rowFilter, a), S = {
443
+ }), b = de({ store: o }), x = h ? async () => C : ht(t, e.rowFilter, a), S = {
444
+ apis: e.apis ?? Object.freeze({}),
437
445
  transaction: async (e, r) => {
438
- let a = he(t, r?.tenantId, o !== i);
446
+ let a = xe(t, r?.tenantId, o !== i);
439
447
  if (a !== t) return m(a, n, i, void 0, !0).transaction(e);
440
448
  if (o !== i) return e(S);
441
449
  let s, c = await o.transactional(async (r) => {
@@ -445,7 +453,7 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
445
453
  }, h), a = await e(i);
446
454
  return s = i, a;
447
455
  });
448
- return s !== void 0 && (await Z(s), v.push(...Xe(s))), c;
456
+ return s !== void 0 && (await Z(s), v.push(...ct(s))), c;
449
457
  },
450
458
  clock: s,
451
459
  webhooks: l,
@@ -454,29 +462,29 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
454
462
  llm: d,
455
463
  ...e.ai === void 0 ? {} : { ai: e.ai },
456
464
  request: g,
457
- ...n === void 0 ? {} : { connectionState: se(n, o === i ? void 0 : (e) => {
465
+ ...n === void 0 ? {} : { connectionState: ue(n, o === i ? void 0 : (e) => {
458
466
  _.push(e);
459
467
  }) },
460
- validation: ne,
461
- access: oe(t),
468
+ validation: ae,
469
+ access: le(t),
462
470
  cache: f,
463
471
  kv: p,
464
- store: O(at(o, x), {
472
+ store: D(Q(o, x), {
465
473
  subject: t,
466
474
  schemaRegistry: r,
467
475
  rowFilter: C
468
476
  }),
469
- storeForTenant: (e) => O(at(o, x), {
477
+ storeForTenant: (e) => D(Q(o, x), {
470
478
  subject: ee(t, e),
471
479
  schemaRegistry: r,
472
480
  rowFilter: C
473
481
  }),
474
- outbox: de({
482
+ outbox: he({
475
483
  store: o,
476
484
  subject: t,
477
- traceId: H,
485
+ traceId: B,
478
486
  nudge: () => {
479
- v.push(H);
487
+ v.push(B);
480
488
  },
481
489
  afterCommit: (e) => {
482
490
  _.push(e);
@@ -507,37 +515,37 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
507
515
  nudges: v
508
516
  })
509
517
  };
510
- return G.set(S, _), W.set(S, (e) => m(t, e, o, {
518
+ return U.set(S, _), H.set(S, (e) => m(t, e, o, {
511
519
  queued: _,
512
520
  nudges: v
513
- })), K.set(S, v), q.set(S, e.plugins ?? []), Ke.set(S, e.layers ?? []), U.set(S, (e) => o.transactional((r) => e(m(t, n, r, {
521
+ })), W.set(S, v), G.set(S, e.plugins ?? []), X.set(S, e.layers ?? []), V.set(S, (e) => o.transactional((r) => e(m(t, n, r, {
514
522
  queued: _,
515
523
  nudges: v
516
524
  })))), S;
517
525
  };
518
- return m(e.subject ?? g(null), e.connection);
519
- }, st = (e, t) => {
520
- let n = Je(t);
521
- return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(T(t.store)), u.provideService(h, t.request.subject));
522
- }, ct = async (e, t, n, r) => {
526
+ return m(e.subject ?? _(null), e.connection);
527
+ }, vt = (e, t) => {
528
+ let n = ot(t);
529
+ return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(fe(t.store)), u.provideService(g, t.request.subject));
530
+ }, yt = async (e, t, n, r) => {
523
531
  let i = e.input, a = await m.decodeUnknownPromise(i)(n), o = e.kind, s = async (e) => {
524
532
  let n = t(a, e);
525
- return u.isEffect(n) ? E(st(n, e)) : n;
533
+ return u.isEffect(n) ? T(vt(n, e)) : n;
526
534
  }, c = async () => {
527
535
  let t = e.guards;
528
536
  if (t !== void 0 && t.length > 0) {
529
- let e = await u.runPromise(_(r.request.subject, t, a));
537
+ let e = await u.runPromise(v(r.request.subject, t, a));
530
538
  if (e !== null) throw e;
531
539
  }
532
540
  if (o !== "mutation") return s(r);
533
- let n = await ge(async () => Ze(r, async (e) => (Ye(r), s(e))), { delay: () => Promise.resolve() });
541
+ let n = await Ce(async () => lt(r, async (e) => (st(r), s(e))), { delay: () => Promise.resolve() });
534
542
  return await Z(r), n;
535
- }, d = o === "mutation" || o === "query" || o === "action" ? o : void 0, p = d === void 0 ? void 0 : qe(r, d);
543
+ }, d = o === "mutation" || o === "query" || o === "action" ? o : void 0, p = d === void 0 ? void 0 : at(r, d);
536
544
  if (p === void 0 || d === void 0) return await c();
537
545
  let h = u.tryPromise({
538
546
  try: () => c(),
539
547
  catch: (e) => e
540
- }), g = Ge(r), v = p(h, {
548
+ }), g = Y(r), _ = p(h, {
541
549
  tag: e.name ?? "",
542
550
  kind: d,
543
551
  input: a,
@@ -547,16 +555,17 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
547
555
  ...g === void 0 ? {} : {
548
556
  surface: "rest",
549
557
  publicApi: g.publicApi
550
- }
551
- }), y = await u.runPromiseExit(v);
558
+ },
559
+ ...g?.remoteAddr === void 0 ? {} : { remoteAddr: g.remoteAddr }
560
+ }), y = await u.runPromiseExit(_);
552
561
  if (f.isSuccess(y)) return y.value;
553
562
  throw l.squash(y.cause);
554
- }, lt = (e) => new Promise((t) => {
555
- let n = new ye.Socket(), r = (e) => {
563
+ }, bt = (e) => new Promise((t) => {
564
+ let n = new O.Socket(), r = (e) => {
556
565
  clearTimeout(i), n.destroy(), t(e);
557
566
  }, i = setTimeout(() => r(!1), e.timeoutMs ?? 750);
558
567
  n.once("connect", () => r(!0)), n.once("error", () => r(!1)), n.connect(e.port, e.host);
559
- }), ut = async (e, t, n, r) => {
568
+ }), xt = async (e, t, n, r) => {
560
569
  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.`);
561
570
  let i = !1;
562
571
  try {
@@ -564,21 +573,21 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
564
573
  } catch {
565
574
  i = !1;
566
575
  }
567
- be.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
568
- }, dt = async (e, t, n) => {
569
- await ut(e, `${t.name} at ${t.host}:${t.port}`, () => lt(t), n);
570
- }, ft = async (e, t) => {
576
+ De.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
577
+ }, St = async (e, t, n) => {
578
+ await xt(e, `${t.name} at ${t.host}:${t.port}`, () => bt(t), n);
579
+ }, Ct = async (e, t) => {
571
580
  try {
572
581
  let n = await t();
573
582
  n !== void 0 && n !== "" && console.log(`[engine] ${e}: ${n}`);
574
583
  } catch {}
575
- }, pt = "test-tenant", Q = (e, t, n) => ({
584
+ }, wt = "test-tenant", $ = (e, t, n) => ({
576
585
  type: e,
577
586
  id: t,
578
587
  tenantId: n.tenantId ?? "test-tenant",
579
588
  scopes: n.scopes ?? [],
580
589
  ...n.metadata === void 0 ? {} : { metadata: n.metadata }
581
- }), mt = (e, t = {}) => Q("user", e, t), ht = (e, t = {}) => Q("apiKey", e, t), gt = (e, t = {}) => Q("serviceAccount", e, t), _t = (e = null) => g(e), vt = (e = "job:test", t) => t === void 0 ? x(e) : x(e, t), yt = (e) => {
590
+ }), Tt = (e, t = {}) => $("user", e, t), Et = (e, t = {}) => $("apiKey", e, t), Dt = (e, t = {}) => $("serviceAccount", e, t), Ot = (e = null) => _(e), kt = (e = "job:test", t) => t === void 0 ? S(e) : S(e, t), At = "127.0.0.1", jt = (e) => {
582
591
  let t = e.indexOf("?");
583
592
  return t === -1 ? {
584
593
  path: e,
@@ -587,105 +596,110 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
587
596
  path: e.slice(0, t),
588
597
  query: e.slice(t + 1)
589
598
  };
590
- }, bt = (e, t) => {
599
+ }, Mt = (e, t) => {
591
600
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0);
592
601
  return n.length === r.length && n.every((e, t) => e.startsWith(":") || e === r[t]);
593
- }, $ = (e) => {
602
+ }, Nt = (e) => {
594
603
  let t = {};
595
604
  for (let [n, r] of Object.entries(e)) t[n.toLowerCase()] = r;
596
605
  return t;
597
- }, xt = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), St = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), Ct = (e) => {
598
- let { ctx: t } = e, n = xe((e.publicApi ?? []).map((e) => ({
606
+ }, Pt = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), Ft = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), It = (e) => {
607
+ let t = Se(e.security?.trustedProxies, process.env, e.security?.clientAddressHeader), { ctx: n } = e, r = Oe((e.publicApi ?? []).map((e) => ({
599
608
  descriptor: e.descriptor,
600
- invoke: (n, r) => t.withSubject(r.subject, (t) => (e.descriptor.publicApi !== void 0 && X(t, e.descriptor.publicApi), ct(e.descriptor, e.handler, n, t)))
609
+ invoke: (t, r) => n.withSubject(r.subject, (n) => (e.descriptor.publicApi !== void 0 && J(n, e.descriptor.publicApi, r.remoteAddr), yt(e.descriptor, e.handler, t, n)))
601
610
  })), {
602
- pluginErrors: J(t).flatMap((e) => e.errorSchemas ?? []),
611
+ pluginErrors: K(n).flatMap((e) => e.errorSchemas ?? []),
603
612
  ...e.publicApiProfile === void 0 ? {} : { profile: e.publicApiProfile }
604
- }), r = [...e.restRoutes ?? [], ...n], i = v(e.strategies ?? [], {
613
+ }), i = [...e.restRoutes ?? [], ...r], a = y(e.strategies ?? [], {
605
614
  ...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
606
- getStore: () => t.store
607
- }), a = (n, o) => {
608
- let s = {
609
- store: t.store,
610
- buildContext: () => t,
611
- resolveSubject: n === void 0 ? async (e) => (await i({
615
+ getStore: () => n.store
616
+ }), o = (r, s) => {
617
+ let c = {
618
+ store: n.store,
619
+ buildContext: () => n,
620
+ resolveSubject: r === void 0 ? async (e) => (await a({
612
621
  headers: e,
613
622
  clientId: -1
614
- })).subject : () => n,
623
+ })).subject : () => r,
615
624
  ...e.idempotency === void 0 ? {} : { idempotency: e.idempotency }
616
- }, c = Ce(r, s).map((e, t) => ({
617
- pattern: r[t].path,
625
+ }, l = Ae(i, c).map((e, t) => ({
626
+ pattern: i[t].path,
618
627
  route: e
619
- })), l = async (e, n, i = {}) => {
620
- let { path: a, query: s } = yt(n), l = c.filter((e) => bt(e.pattern, a)).map((e) => e.route);
621
- if (l.length === 0) return {
628
+ })), u = async (e, r, a = {}) => {
629
+ let { path: o, query: c } = jt(r), u = l.filter((e) => Mt(e.pattern, o)).map((e) => e.route);
630
+ if (u.length === 0) return {
622
631
  status: 404,
623
632
  headers: {},
624
633
  text: "",
625
634
  body: {
626
635
  error: "No route mounted for this path",
627
- mounted: r.map((e) => e.path)
636
+ mounted: i.map((e) => e.path)
628
637
  },
629
638
  stream: !1
630
639
  };
631
- let u = {
640
+ let d = {
641
+ ...Nt(s),
642
+ ...Nt(a.headers ?? {})
643
+ }, f = ye({
644
+ socketAddress: a.remoteAddr ?? At,
645
+ headers: d,
646
+ config: t
647
+ }), p = {
632
648
  method: e.toUpperCase(),
633
- path: a,
634
- query: s,
635
- headers: {
636
- ...$(o),
637
- ...$(i.headers ?? {})
638
- },
639
- rawBody: xt(i.body),
640
- store: t.store
641
- }, d = await Se(l, u), f = {};
642
- for (let [e, t] of Object.entries(d.headers ?? {})) f[e.toLowerCase()] = t;
643
- d.contentType !== void 0 && (f["content-type"] = d.contentType);
644
- let p = St(d.body), m = (d.contentType ?? "").includes("json");
649
+ path: o,
650
+ query: c,
651
+ headers: d,
652
+ rawBody: Pt(a.body),
653
+ store: n.store,
654
+ ...f === void 0 ? {} : { remoteAddr: f }
655
+ }, m = await ke(u, p), h = {};
656
+ for (let [e, t] of Object.entries(m.headers ?? {})) h[e.toLowerCase()] = t;
657
+ m.contentType !== void 0 && (h["content-type"] = m.contentType);
658
+ let g = Ft(m.body), _ = (m.contentType ?? "").includes("json");
645
659
  return {
646
- status: d.status,
647
- headers: f,
648
- text: p,
649
- body: m && p !== "" ? JSON.parse(p) : void 0,
650
- stream: d.stream !== void 0
660
+ status: m.status,
661
+ headers: h,
662
+ text: g,
663
+ body: _ && g !== "" ? JSON.parse(g) : void 0,
664
+ stream: m.stream !== void 0
651
665
  };
652
666
  };
653
667
  return {
654
- actingAs: (e) => a(e, o),
655
- withHeaders: (e) => a(n, {
656
- ...o,
668
+ actingAs: (e) => o(e, s),
669
+ withHeaders: (e) => o(r, {
670
+ ...s,
657
671
  ...e
658
672
  }),
659
- get: (e, t) => l("GET", e, t ?? {}),
660
- delete: (e, t) => l("DELETE", e, t ?? {}),
661
- post: (e, t, n) => l("POST", e, {
673
+ get: (e, t) => u("GET", e, t ?? {}),
674
+ delete: (e, t) => u("DELETE", e, t ?? {}),
675
+ post: (e, t, n) => u("POST", e, {
662
676
  ...n ?? {},
663
677
  body: t
664
678
  }),
665
- put: (e, t, n) => l("PUT", e, {
679
+ put: (e, t, n) => u("PUT", e, {
666
680
  ...n ?? {},
667
681
  body: t
668
682
  }),
669
- patch: (e, t, n) => l("PATCH", e, {
683
+ patch: (e, t, n) => u("PATCH", e, {
670
684
  ...n ?? {},
671
685
  body: t
672
686
  }),
673
- request: (e, t, n) => l(e, t, n ?? {}),
674
- paths: r.map((e) => e.path)
687
+ request: (e, t, n) => u(e, t, n ?? {}),
688
+ paths: i.map((e) => e.path)
675
689
  };
676
690
  };
677
- return a(void 0, {});
678
- }, wt = (e = {}) => {
691
+ return o(void 0, {});
692
+ }, Lt = (e = {}) => {
679
693
  let t = e.id ?? "test", n = e.store ?? new Proxy({}, { get: (e, t) => {
680
694
  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.`);
681
695
  } });
682
696
  return {
683
697
  id: t,
684
- log: e.log ?? we({ scope: `subscribe:${t}` }),
698
+ log: e.log ?? je({ scope: `subscribe:${t}` }),
685
699
  store: n,
686
700
  ...e.publish === void 0 ? {} : { publish: e.publish }
687
701
  };
688
- }, Tt = (e) => {
702
+ }, Rt = (e) => {
689
703
  let t = /* @__PURE__ */ new Map();
690
704
  for (let n of e) {
691
705
  let e = t.get(n.stepName);
@@ -711,13 +725,13 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
711
725
  });
712
726
  }
713
727
  return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
714
- }, Et = (e) => {
728
+ }, zt = (e) => {
715
729
  let t = e.workflows ?? [], n = /* @__PURE__ */ new Map(), r = 0;
716
730
  return {
717
731
  start: async (i, a) => {
718
732
  let o = t.find((e) => e.workflow.name === i);
719
733
  if (o === void 0) throw Error(`makeWorkflowRunner: no workflow named '${i}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
720
- let s = `wfrun_test_${++r}`, c = De(), l = o.workflow.toLayer(o.execute).pipe(p.provideMerge(Ee)), d = /* @__PURE__ */ new Date(), f = o.workflow.execute(a).pipe(u.locally(Te, s), u.provide(c.layer), u.provide(l)), m = (e.ctx.store === void 0 ? f : u.provide(f, T(e.ctx.store))).pipe(u.either), h = await u.runPromise(m), g = Tt(c.readSteps()), _ = /* @__PURE__ */ new Date(), v;
734
+ let s = `wfrun_test_${++r}`, c = Pe(), l = o.workflow.toLayer(o.execute).pipe(p.provideMerge(Ne)), d = /* @__PURE__ */ new Date(), f = o.workflow.execute(a).pipe(u.locally(Me, s), u.provide(c.layer), u.provide(l)), m = (e.ctx.store === void 0 ? f : u.provide(f, fe(e.ctx.store))).pipe(u.either), h = await u.runPromise(m), g = Rt(c.readSteps()), _ = /* @__PURE__ */ new Date(), v;
721
735
  if (h._tag === "Right") v = {
722
736
  status: "succeeded",
723
737
  output: h.right,
@@ -757,6 +771,154 @@ var Oe = new Set(e), k = 0, A = () => ++k, ke = (e, t) => {
757
771
  },
758
772
  inspect: async (e) => n.get(e) ?? null
759
773
  };
760
- }, Dt = 1;
774
+ }, Bt = (e, t, n = {}) => {
775
+ let r = n.name ?? "mock", i = [], a = /* @__PURE__ */ new Map(), o = (e, t) => `${e}\u0000${JSON.stringify(t)}`, s = async (n, a, o) => {
776
+ let s = e[n];
777
+ if (s === void 0) throw Error(`makeServiceApiMock: '${n}' is not a service procedure of this api`);
778
+ let c = s.input, l = m.encodeUnknownSync(c)(a), u = m.decodeUnknownSync(c)(l);
779
+ i.push({
780
+ tag: n,
781
+ input: u,
782
+ idempotencyKey: o?.idempotencyKey
783
+ });
784
+ let d = t[n];
785
+ if (d === void 0) throw new h({
786
+ api: r,
787
+ tag: n,
788
+ reason: "transport",
789
+ message: "no handler in makeServiceApiMock"
790
+ });
791
+ let f = await d(u), p = s.output;
792
+ return m.decodeUnknownSync(p)(m.encodeUnknownSync(p)(f));
793
+ }, c = (e) => {
794
+ let t = e.join("."), n = Object.assign((e, n) => s(t, e, n), {
795
+ effect: (e, n) => u.tryPromise({
796
+ try: () => s(t, e, n),
797
+ catch: (e) => e
798
+ }),
799
+ subscribe: (e, n) => {
800
+ let r = o(t, e), i = a.get(r) ?? /* @__PURE__ */ new Set();
801
+ return a.set(r, i), i.add(n.onValue), s(t, e).then(n.onValue, (e) => n.onError?.(e)), { unsubscribe: () => {
802
+ i.delete(n.onValue);
803
+ } };
804
+ }
805
+ });
806
+ return new Proxy(n, { get: (t, n) => {
807
+ if (typeof n == "string") {
808
+ if (n === "effect" || n === "subscribe") return t[n];
809
+ if (n !== "then") return c([...e, n]);
810
+ }
811
+ } });
812
+ };
813
+ return {
814
+ api: new Proxy({}, { get: (e, t) => typeof t == "string" && t !== "then" ? c([t]) : void 0 }),
815
+ calls: (e) => e === void 0 ? [...i] : i.filter((t) => t.tag === e),
816
+ emit: (t, n, r) => {
817
+ let i = e[t], s = i === void 0 ? r : m.decodeUnknownSync(i.output)(m.encodeUnknownSync(i.output)(r));
818
+ for (let e of a.get(o(t, n)) ?? []) e(s);
819
+ }
820
+ };
821
+ }, Vt = () => new Promise((e, t) => {
822
+ let n = O.createServer();
823
+ n.once("error", t), n.listen(0, "127.0.0.1", () => {
824
+ let t = n.address(), r = typeof t == "object" && t ? t.port : 0;
825
+ n.close(() => e(r));
826
+ });
827
+ }), Ht = async (e) => {
828
+ try {
829
+ return (await fetch(`http://127.0.0.1:${e}/internal/readiness`)).status === 200;
830
+ } catch {
831
+ return !1;
832
+ }
833
+ }, Ut = (e) => ze(Re(Le(ze(e, "package.json")).resolve("@voltro/cli/package.json")), "bin", "voltro.mjs"), Wt = 1e4, Gt = (e) => new Promise((t) => {
834
+ if (e.exitCode !== null || e.signalCode !== null) return t();
835
+ let n = setTimeout(() => {
836
+ e.kill("SIGKILL");
837
+ }, Wt);
838
+ e.once("exit", () => {
839
+ clearTimeout(n), t();
840
+ }), e.kill("SIGTERM");
841
+ }), Kt = async (e) => {
842
+ let t = Object.keys(e.apps);
843
+ if (t.length === 0) throw Error("startTestApps: no apps given");
844
+ let n = e.mode ?? "dev", r = e.bootTimeoutMs ?? 3e5, i = e.cli ?? Ut(e.apps[t[0]].root), a = /* @__PURE__ */ new Map();
845
+ for (let e of t) a.set(e, await Vt());
846
+ let o = /* @__PURE__ */ new Map(), s = new Map(t.map((e) => [e, {}]));
847
+ for (let n of e.edges ?? []) {
848
+ for (let e of [n.caller, n.callee]) if (!t.includes(e)) throw Error(`startTestApps: edge names '${e}', which is not among the apps (${t.join(", ")})`);
849
+ let e = _e();
850
+ o.set(`${n.caller}\u0000${n.callee}`, e), Object.assign(s.get(n.caller), {
851
+ [re(n.binding)]: e,
852
+ [ie(n.binding)]: `ws://127.0.0.1:${a.get(n.callee)}/ws`
853
+ }), Object.assign(s.get(n.callee), { [we(n.caller)]: e });
854
+ }
855
+ let c = [], l = /* @__PURE__ */ new Set(), u = (t) => {
856
+ if (!c.includes(t)) {
857
+ if (l.has(t)) throw Error(`startTestApps: the edges form a cycle through '${t}' — no app can boot first`);
858
+ l.add(t);
859
+ for (let n of e.edges ?? []) n.caller === t && u(n.callee);
860
+ l.delete(t), c.push(t);
861
+ }
862
+ };
863
+ for (let e of t) u(e);
864
+ let d = [], f = /* @__PURE__ */ new Map(), p = async () => {
865
+ await Promise.all(d.map(Gt));
866
+ };
867
+ for (let t of c) {
868
+ let o = e.apps[t], c = a.get(t), l = "", u = Fe(process.execPath, [
869
+ i,
870
+ n,
871
+ ...n === "serve" ? ["."] : []
872
+ ], {
873
+ cwd: o.root,
874
+ env: {
875
+ ...process.env,
876
+ ...n === "serve" ? { VOLTRO_SESSION_SECRET: Ie(32).toString("base64url") } : {},
877
+ ...o.env,
878
+ ...s.get(t),
879
+ PORT: String(c)
880
+ },
881
+ stdio: [
882
+ "ignore",
883
+ "pipe",
884
+ "pipe"
885
+ ]
886
+ });
887
+ d.push(u), u.stdout?.on("data", (e) => {
888
+ l += e.toString();
889
+ }), u.stderr?.on("data", (e) => {
890
+ l += e.toString();
891
+ }), u.on("exit", (e, t) => {
892
+ l += `\n[exited] code=${String(e)} signal=${String(t)}`;
893
+ });
894
+ let m = {
895
+ name: t,
896
+ port: c,
897
+ url: `http://127.0.0.1:${c}`,
898
+ wsUrl: `ws://127.0.0.1:${c}/ws`,
899
+ log: () => l,
900
+ stop: () => Gt(u)
901
+ };
902
+ f.set(t, m);
903
+ let h = Date.now() + r;
904
+ for (; !await Ht(c);) {
905
+ if (u.exitCode !== null || u.signalCode !== null || Date.now() > h) {
906
+ await p();
907
+ let e = u.exitCode !== null || u.signalCode !== null ? "exited before it was ready" : `was not ready within ${r} ms`;
908
+ throw Error(`startTestApps: '${t}' ${e}\n${m.log().slice(-4e3)}`);
909
+ }
910
+ await new Promise((e) => setTimeout(e, 250));
911
+ }
912
+ }
913
+ return {
914
+ apps: Object.fromEntries(f),
915
+ token: (e, t) => {
916
+ let n = o.get(`${e}\u0000${t}`);
917
+ if (n === void 0) throw Error(`startTestApps: no edge ${e} → ${t}`);
918
+ return n;
919
+ },
920
+ stop: p
921
+ };
922
+ }, qt = 1;
761
923
  //#endregion
762
- export { R as MockClock, V as MockLLM, z as MockWebhooks, Dt as TESTING_PRESET_VERSION, pt as TEST_TENANT_ID, _t as anonymous, ht as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Le as defineFactory, ut as describeIfAvailable, dt as describeIfReachable, j as fixtureRow, Ve as frozenTime, Ne as hydrateFixtureRows, ct as invoke, lt as isTcpReachable, wt as makeSubscribeContext, Ct as makeTestApp, ot as makeTestContext, Et as makeWorkflowRunner, X as markRestInvocation, Ue as mockStore, A as nextSequence, Xe as outboxNudgesOf, J as pluginsOf, ft as reportEngineVersion, Ye as resetAfterCommit, Ge as restInvocationOf, qe as rpcInterceptorFor, Z as runAfterCommit, Ze as runInStoreTransaction, gt as serviceAccount, Je as serviceLayerFor, vt as system, B as testEventBus, mt as user, Be as withFrozenTime, We as withRpcConnection };
924
+ export { I as MockClock, z as MockLLM, L as MockWebhooks, qt as TESTING_PRESET_VERSION, wt as TEST_TENANT_ID, Ot as anonymous, Et as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Xe as defineFactory, xt as describeIfAvailable, St as describeIfReachable, A as fixtureRow, tt as frozenTime, Ke as hydrateFixtureRows, yt as invoke, bt as isTcpReachable, Bt as makeServiceApiMock, Lt as makeSubscribeContext, It as makeTestApp, _t as makeTestContext, zt as makeWorkflowRunner, J as markRestInvocation, rt as mockStore, Ve as nextSequence, ct as outboxNudgesOf, K as pluginsOf, Ct as reportEngineVersion, st as resetAfterCommit, Y as restInvocationOf, at as rpcInterceptorFor, Z as runAfterCommit, lt as runInStoreTransaction, Dt as serviceAccount, ot as serviceLayerFor, Kt as startTestApps, kt as system, R as testEventBus, Tt as user, et as withFrozenTime, it as withRpcConnection };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/testing",
3
- "version": "0.80.0",
3
+ "version": "0.81.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.80.0",
47
- "@voltro/env": "0.80.0",
48
- "@voltro/logger": "0.80.0",
49
- "@voltro/protocol": "0.80.0",
50
- "@voltro/runtime": "0.80.0",
51
- "@voltro/workflow": "0.80.0"
46
+ "@voltro/database": "0.81.0",
47
+ "@voltro/env": "0.81.0",
48
+ "@voltro/logger": "0.81.0",
49
+ "@voltro/protocol": "0.81.0",
50
+ "@voltro/runtime": "0.81.0",
51
+ "@voltro/workflow": "0.81.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@effect/sql": "^0.52.1",
55
- "@voltro/client": "0.80.0",
55
+ "@voltro/client": "0.81.0",
56
56
  "effect": "^3.22.2",
57
57
  "react": "^19.0.0"
58
58
  },