@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 +90 -0
- package/dist/index.d.ts +138 -3
- package/dist/index.js +342 -180
- package/package.json +8 -8
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
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import { EventBus as
|
|
5
|
-
import { installEnvSnapshot as
|
|
6
|
-
import
|
|
7
|
-
import { describe as
|
|
8
|
-
import { collectPublicApiRoutes as
|
|
9
|
-
import { createLogger as
|
|
10
|
-
import { CurrentWorkflowRunId as
|
|
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
|
|
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
|
-
},
|
|
34
|
+
}, A = (e, t = {}) => {
|
|
31
35
|
let n = {};
|
|
32
|
-
for (let r of s(e, t))
|
|
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
|
-
},
|
|
38
|
-
if (typeof e != "string" || !
|
|
39
|
-
let n = e.slice(0, 10), r =
|
|
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
|
-
},
|
|
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 =
|
|
49
|
-
if (!
|
|
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
|
-
},
|
|
58
|
+
}, qe = (e) => {
|
|
55
59
|
let t = {};
|
|
56
|
-
for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(
|
|
60
|
+
for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(Ve()) : r;
|
|
57
61
|
return t;
|
|
58
|
-
},
|
|
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
|
-
},
|
|
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
|
|
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
|
-
},
|
|
76
|
+
}, M = async (e, t, n, r, i) => {
|
|
73
77
|
let a = {
|
|
74
|
-
...await
|
|
78
|
+
...await Ye(e, t, n, r, i),
|
|
75
79
|
...n
|
|
76
80
|
};
|
|
77
|
-
return e.insert(t.tableName,
|
|
78
|
-
},
|
|
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
|
-
...
|
|
85
|
+
...qe(t),
|
|
82
86
|
...e
|
|
83
|
-
}), o = (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(
|
|
88
|
-
create: (t, n = {}) =>
|
|
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
|
|
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
|
-
},
|
|
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
|
-
}),
|
|
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
|
-
},
|
|
126
|
+
}, I = class {
|
|
123
127
|
currentMs;
|
|
124
128
|
constructor(e = "2026-01-01T00:00:00Z") {
|
|
125
|
-
this.currentMs =
|
|
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 :
|
|
138
|
+
this.currentMs += typeof e == "number" ? e : nt(e);
|
|
135
139
|
}
|
|
136
140
|
set(e) {
|
|
137
|
-
this.currentMs =
|
|
141
|
+
this.currentMs = Qe(e);
|
|
138
142
|
}
|
|
139
143
|
get installed() {
|
|
140
|
-
return
|
|
144
|
+
return F()[P]?.clock === this;
|
|
141
145
|
}
|
|
142
146
|
install() {
|
|
143
|
-
let e =
|
|
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[
|
|
150
|
+
return e[P] = {
|
|
147
151
|
realDate: n,
|
|
148
152
|
clock: this
|
|
149
|
-
}, globalThis.Date =
|
|
153
|
+
}, globalThis.Date = Ze(n, () => this.currentMs), () => {
|
|
150
154
|
this.uninstall();
|
|
151
155
|
};
|
|
152
156
|
}
|
|
153
157
|
uninstall() {
|
|
154
|
-
let e =
|
|
155
|
-
t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[
|
|
158
|
+
let e = F(), t = e[P];
|
|
159
|
+
t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[P]);
|
|
156
160
|
}
|
|
157
|
-
},
|
|
158
|
-
let n = e instanceof
|
|
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
|
|
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
|
-
},
|
|
170
|
-
let t = e instanceof
|
|
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
|
-
})),
|
|
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
|
-
},
|
|
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
|
-
},
|
|
206
|
-
let t = e.tenantId ?? null, n = new
|
|
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(
|
|
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:
|
|
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
|
-
},
|
|
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
|
-
},
|
|
291
|
-
let n =
|
|
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
|
-
},
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
303
|
-
},
|
|
304
|
-
let t = (
|
|
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
|
-
},
|
|
307
|
-
let t =
|
|
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 =
|
|
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
|
-
},
|
|
316
|
-
let n =
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
389
|
-
let n =
|
|
390
|
-
|
|
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 ?
|
|
401
|
+
n === void 0 ? se() : E(n);
|
|
395
402
|
}
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
},
|
|
399
|
-
let r =
|
|
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() &&
|
|
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
|
-
},
|
|
407
|
-
if (n === "query") return async (n) => e.query(
|
|
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
|
-
} }),
|
|
411
|
-
if (
|
|
417
|
+
} }), _t = (e = {}) => {
|
|
418
|
+
if (Ee({
|
|
412
419
|
...process.env,
|
|
413
420
|
...e.env ?? {}
|
|
414
|
-
}), e.fieldEncryptionKey !== 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 =
|
|
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
|
-
|
|
423
|
-
let s = new
|
|
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:
|
|
433
|
+
traceId: B,
|
|
427
434
|
...n === void 0 ? {} : { connection: n }
|
|
428
|
-
}, _ = c?.queued ?? [], v = c?.nudges ?? [], y =
|
|
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 =
|
|
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 =
|
|
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(...
|
|
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:
|
|
465
|
+
...n === void 0 ? {} : { connectionState: ue(n, o === i ? void 0 : (e) => {
|
|
458
466
|
_.push(e);
|
|
459
467
|
}) },
|
|
460
|
-
validation:
|
|
461
|
-
access:
|
|
468
|
+
validation: ae,
|
|
469
|
+
access: le(t),
|
|
462
470
|
cache: f,
|
|
463
471
|
kv: p,
|
|
464
|
-
store:
|
|
472
|
+
store: D(Q(o, x), {
|
|
465
473
|
subject: t,
|
|
466
474
|
schemaRegistry: r,
|
|
467
475
|
rowFilter: C
|
|
468
476
|
}),
|
|
469
|
-
storeForTenant: (e) =>
|
|
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:
|
|
482
|
+
outbox: he({
|
|
475
483
|
store: o,
|
|
476
484
|
subject: t,
|
|
477
|
-
traceId:
|
|
485
|
+
traceId: B,
|
|
478
486
|
nudge: () => {
|
|
479
|
-
v.push(
|
|
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
|
|
518
|
+
return U.set(S, _), H.set(S, (e) => m(t, e, o, {
|
|
511
519
|
queued: _,
|
|
512
520
|
nudges: v
|
|
513
|
-
})),
|
|
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 ??
|
|
519
|
-
},
|
|
520
|
-
let n =
|
|
521
|
-
return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(
|
|
522
|
-
},
|
|
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) ?
|
|
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(
|
|
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
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
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
|
-
},
|
|
555
|
-
let n = new
|
|
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
|
-
}),
|
|
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
|
-
|
|
568
|
-
},
|
|
569
|
-
await
|
|
570
|
-
},
|
|
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
|
-
},
|
|
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
|
-
}),
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
598
|
-
let { ctx:
|
|
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: (
|
|
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:
|
|
611
|
+
pluginErrors: K(n).flatMap((e) => e.errorSchemas ?? []),
|
|
603
612
|
...e.publicApiProfile === void 0 ? {} : { profile: e.publicApiProfile }
|
|
604
|
-
}),
|
|
613
|
+
}), i = [...e.restRoutes ?? [], ...r], a = y(e.strategies ?? [], {
|
|
605
614
|
...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
|
|
606
|
-
getStore: () =>
|
|
607
|
-
}),
|
|
608
|
-
let
|
|
609
|
-
store:
|
|
610
|
-
buildContext: () =>
|
|
611
|
-
resolveSubject:
|
|
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 : () =>
|
|
623
|
+
})).subject : () => r,
|
|
615
624
|
...e.idempotency === void 0 ? {} : { idempotency: e.idempotency }
|
|
616
|
-
},
|
|
617
|
-
pattern:
|
|
625
|
+
}, l = Ae(i, c).map((e, t) => ({
|
|
626
|
+
pattern: i[t].path,
|
|
618
627
|
route: e
|
|
619
|
-
})),
|
|
620
|
-
let { path:
|
|
621
|
-
if (
|
|
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:
|
|
636
|
+
mounted: i.map((e) => e.path)
|
|
628
637
|
},
|
|
629
638
|
stream: !1
|
|
630
639
|
};
|
|
631
|
-
let
|
|
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:
|
|
634
|
-
query:
|
|
635
|
-
headers:
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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:
|
|
647
|
-
headers:
|
|
648
|
-
text:
|
|
649
|
-
body:
|
|
650
|
-
stream:
|
|
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) =>
|
|
655
|
-
withHeaders: (e) =>
|
|
656
|
-
...
|
|
668
|
+
actingAs: (e) => o(e, s),
|
|
669
|
+
withHeaders: (e) => o(r, {
|
|
670
|
+
...s,
|
|
657
671
|
...e
|
|
658
672
|
}),
|
|
659
|
-
get: (e, t) =>
|
|
660
|
-
delete: (e, t) =>
|
|
661
|
-
post: (e, t, n) =>
|
|
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) =>
|
|
679
|
+
put: (e, t, n) => u("PUT", e, {
|
|
666
680
|
...n ?? {},
|
|
667
681
|
body: t
|
|
668
682
|
}),
|
|
669
|
-
patch: (e, t, n) =>
|
|
683
|
+
patch: (e, t, n) => u("PATCH", e, {
|
|
670
684
|
...n ?? {},
|
|
671
685
|
body: t
|
|
672
686
|
}),
|
|
673
|
-
request: (e, t, n) =>
|
|
674
|
-
paths:
|
|
687
|
+
request: (e, t, n) => u(e, t, n ?? {}),
|
|
688
|
+
paths: i.map((e) => e.path)
|
|
675
689
|
};
|
|
676
690
|
};
|
|
677
|
-
return
|
|
678
|
-
},
|
|
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 ??
|
|
698
|
+
log: e.log ?? je({ scope: `subscribe:${t}` }),
|
|
685
699
|
store: n,
|
|
686
700
|
...e.publish === void 0 ? {} : { publish: e.publish }
|
|
687
701
|
};
|
|
688
|
-
},
|
|
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
|
-
},
|
|
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 =
|
|
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
|
-
},
|
|
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 {
|
|
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.
|
|
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.
|
|
47
|
-
"@voltro/env": "0.
|
|
48
|
-
"@voltro/logger": "0.
|
|
49
|
-
"@voltro/protocol": "0.
|
|
50
|
-
"@voltro/runtime": "0.
|
|
51
|
-
"@voltro/workflow": "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.
|
|
55
|
+
"@voltro/client": "0.81.0",
|
|
56
56
|
"effect": "^3.22.2",
|
|
57
57
|
"react": "^19.0.0"
|
|
58
58
|
},
|