@voltro/testing 0.6.0 → 0.7.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 +20 -0
- package/dist/index.d.ts +74 -6
- package/dist/index.js +89 -71
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,26 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.7.0] — 2026-07-20
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/runtime** — Row-level security: a `load` failure is now retried, and then surfaces as an **error** instead of an empty result. `setRowFilter({ load, predicate })` resolves `load` once per request; previously ANY failure was answered with a predicate matching zero rows. A downstream app reported the consequence correctly — for shared/team visibility `load` must read the store, so one transient DB blip denied every constrained table for that request and the whole UI rendered empty. Two distinct defects, fixed separately, because conflating them was the original mistake. **A transient failure should never reach the decision:** `load` had no retry at all, so a reaped connection was answered as if it were an authorization fact. It now runs under a bounded `retry` schedule — `DEFAULT_ROW_FILTER_RETRY`, three attempts backing off exponentially from 20ms, so ~60ms worst case — configurable per filter with your own `Schedule`, or `retry: false` for one attempt. **And refusal was expressed as an empty result, which is a lie:** "we could not determine your visibility" and "you may see nothing" are different facts, and only one of them is a fact. An empty list is byte-identical to legitimate emptiness, so the user reads "you have no tickets", the operator reads a healthy 200, and the outage is invisible to both — the most misleading outcome available. The module header used to defend this ("an empty result rather than a 500 on every page"); every constrained page IS broken, and saying so is the correct behavior. `onLoadError` now defaults to `'fail'`, raising the exported typed `RowFilterUnavailable`; `onLoadError: 'deny'` keeps the old degradation for apps that have looked at the screen and genuinely prefer an empty list to an error state, with the `onError` reporter still firing so the choice is not silent. The app asked for `onLoadError: 'fallthrough'` — fail OPEN to the handler's own check. Deliberately not implemented, and not planned. Serving unfiltered rows when the authorization filter is unavailable leaks data precisely when the system is under stress and nobody is reading dashboards, and it is only safe if every handler still carries the check that row filters exist to replace. Fail-closed is retained under both policies; a test asserts neither branch can ever yield an unfiltered read. **Migration.** `resolveRowFilterScope` gains an error channel — `Effect<RowFilterScope, RowFilterUnavailable>` instead of an infallible Effect — so direct callers (a custom entrypoint, a test harness) must handle it; TypeScript points at each one. Apps that want the previous behavior add `onLoadError: 'deny'`, but should decide that rather than default into it. Tests asserting "a broken load yields an empty result" now fail with `RowFilterUnavailable`, which is the fix working. Subscriptions changed shape too: a re-resolve that fails mid-delivery now REVOKES the subscription and emits a typed error frame — following the existing withdrawn-guard precedent — because an empty snapshot on a live subscription reads to a client as "every row you could see was just deleted". The same failure at subscribe time aborts the subscribe instead of opening a stream on a fabricated snapshot, and unwinds the matcher + dependent-table registration it had already made (previously leaked on any subscribe-time throw).
|
|
47
|
+
- **@voltro/testing, @voltro/runtime** — `@voltro/testing` now mirrors the runtime store's POLICY path, not only its DATA path. An adopting app found three places where the harness diverged, each of which made a class of rule untestable in-repo while leaving the suite green. **`invoke` runs Effect-mode handlers.** The framework's contract is "async OR Effect, your choice per handler", and every production runner honours it (`Effect.isEffect(result) ? … : …`). `invoke` only awaited the executor's return value, so an Effect-mode handler handed back its own un-run `EffectPrimitive`: nothing executed, nothing was written, and a test asserting on the "result" asserted on a description of work. The Effect now runs through `runProvidedEffect` — the same function the serve entrypoints use — so a handler failing with a typed error REJECTS WITH THAT ERROR rather than an opaque FiberFailure, and `EffectStore` + `SubjectService` are provided over the context the handler is actually given. Guards, the input decode, the transactional wrap, the deadlock replay, `afterCommit` and the plugin interceptor chain apply identically to both modes. **`makeTestContext({ relations: [spec] })`** registers `relations()` specs. `relations()` is pure — it returns a spec, it does not register one — and production registration is a boot step (`voltro dev` → `registerDiscoveredRelations`), so under the harness an eager load failed with "no relations registered" no matter what the test imported. The option REPLACES the process-global registry with exactly the specs given (the registry is a `Symbol.for` global; additive registration would throw `duplicate relation` on the second `makeTestContext` in a file and leak the first test's relations into the second). Omitting it touches the registry not at all. **The harness store applies row-level security.** After `setRowFilter(...)` a `makeTestContext` read of a constrained table returned the unfiltered set, so "user A cannot see user B's row" could not be asserted at all. `ctx.store` now resolves the filter for its subject and AND-merges it into every read — fluent builders and descriptor reads alike, not bypassed by `.unscoped()`, bypassed for a `system` subject. A new `rowFilter:` option passes a filter directly for tests that would rather not write to a process global. Resolution goes through the runtime's new `resolveRowFilterScopeFor` (`resolveRowFilterScope` is now that function applied to the registered filter), so the retry schedule, the system bypass and the `onLoadError` policy are the runtime's single definition rather than a second copy in the harness. **Breaking, and how to migrate** (`voltro update` prints this): delete any `Effect.isEffect(out) ? await Effect.runPromise(out) : out` shim around an `invoke` call — it is dead code that also destroyed your typed errors. `ProcedureExecutor<Input, Output>` gained an `Effect<Output, E>` arm and an optional third parameter `E` (default `never`); `invoke` now infers from the executor's whole return type instead of taking `Output` as its second type parameter, so an explicit `invoke<typeof d, Note>(…)` type-argument list must be dropped in favour of inference. Handlers themselves keep compiling — what changes is the type of a CALL.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **@voltro/plugin-auth** — `subjectFromUser(user, { metadata })` now carries arbitrary app metadata onto the Subject. Previously the helper set the metadata slot ONLY when `memberships` were supplied, so an app whose Subject must carry a provider credential — an Atlassian PAT captured at login and read back as `subject.metadata.jiraToken` by `@voltro/plugin-atlassian`'s `credentialsResolver`, say — could not adopt the helper at all: calling it silently dropped the credential, and building the Subject by hand was the only way to keep it. The framework shipped both halves of that gap itself. `metadata` MERGES with the memberships projection rather than replacing it. **Precedence when a `memberships` key appears in both:** the dedicated `memberships` option wins — it is the specific, typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the switch-tenant menu read, so letting a free-form bag shadow it would break tenant switching in a way nothing type-checks. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option still has NO `metadata` key (not an empty object). The later stamps compose unchanged: the sign-in / sign-up / magic-link / passkey handlers spread the existing slot before adding `sessionId`, and the password strategy does the same for `provider`, so keys set through this option survive every login path — unless a caller names a key `sessionId` or `provider`, which those merges overwrite by design. **`handleSwitchTenant` now carries that metadata across a switch**, via a new optional `metadata` on `SwitchTenantInput` (the built-in `/switch-tenant` route passes the caller's `subject.metadata` for you). Without this the option above would have been a trap rather than a feature: a switch rebuilds the Subject from the user record, so an app parking a provider credential in the slot would lose it the first time a user changed tenant — staying authenticated while every call to the provider began failing, with no signal at the point that caused it. `memberships` is deliberately NOT carried: it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there. Covered by a test that fails on the exact assertion when the carry is removed. `SwitchTenantInput` is now exported too — every sibling handler input (`SignInInput`, `MfaVerifyInput`, …) already was, and this one had simply been forgotten, so an app calling `handleSwitchTenant` directly could not name its argument type. Additive: the option is optional and callers that pass neither get byte-identical Subjects. `packages/plugin-auth/etc/plugin-auth.api.md` gains one line and changes none.
|
|
52
|
+
- **@voltro/database** — `timestampMs` and `timestampMsOrNull` — the timestamp wire mapping as STANDALONE field schemas, for hand-written `Schema.Struct` outputs. `Date` in the handler, epoch-ms `number` on the wire; `timestampMsOrNull` is the `.nullable()` column's variant, so a "never archived" row stays `null` instead of becoming `new Date(null)` (1970-01-01, which renders as a plausible date rather than as nothing). ```ts output: Schema.Struct({ id: Schema.String, addedAt: timestampMs, // Date in the handler, epoch ms on the wire archivedAt: timestampMsOrNull, seenAt: Schema.optional(timestampMs), }) ``` This closes the half of the problem `rowSchema(table)` left open. `rowSchema` only helps a handler returning a RAW FULL TABLE ROW, and real handlers overwhelmingly return a COMPUTED struct assembled across several tables — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table to derive from. That is exactly where the hand-written `Date → epoch` converters accumulate: the app that reported the original gap has ~228 of them, all in shaped outputs, and found zero clean applications for whole-row `rowSchema`. Single-sourced, not a parallel declaration: `columnSchema` now READS `timestampMs` for `timestamp()` / `date()` columns, so the derived-row and hand-written-struct paths are the same schema by identity and cannot drift into different wire representations. `rowSchema.test.ts` asserts that identity rather than asserting both merely produce a number. There is deliberately no `timestampMsOptional` — an absent field is `Schema.optional(timestampMs)`, which composes without a third export. Both exports live in the browser-safe `@voltro/database` main entry (pure `effect/Schema`, no driver, no `node:*`), which is what a descriptor's `output` needs. Additive: two new exports, no existing declaration changed. `packages/database/etc/database.api.md` gains two entries and changes none.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test during a full local gate run, where 78 packages build in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
|
|
57
|
+
- **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script. Re-run with `node scripts/smoke-package-managers.mjs`.
|
|
58
|
+
- **@voltro/cli** — Three fixes to `voltro update`, all reported by an app upgrading a pnpm workspace. **The bump is now LOCKSTEP across the whole workspace.** `voltro update` in `apps/api` bumped only that `package.json`, leaving the sibling web app and shared `packages/*` on the previous version — an api on 0.6.0 and a web client on 0.5.0 disagree about the generated rpcGroup types and the session cookie shape, and that disagreement surfaces as a runtime decode error, not a build error. When the app sits inside a workspace (`pnpm-workspace.yaml`, or a `workspaces` field in an ancestor `package.json`, found by the same bounded upward walk that resolves the package manager and stops at the first `.git`), every member `package.json` that declares `@voltro/*` is bumped to the target together, and the install runs **once at the workspace root** — running it inside `apps/api` corrupts a pnpm/yarn workspace's layout. Each file that will be bumped is listed in the plan output and in `--dry-run`. A standalone (non-workspace) project is unchanged: its own `package.json`, its own install, in place. The codemod re-exec now also looks for the installed `voltro` bin at the workspace root, since npm and yarn hoist it there. **A failed install now says that the codemods were skipped.** It previously printed only "install failed — package.json was bumped; fix the install and re-run", never mentioning codemods, so a user could boot on target-version code with source shaped for the old one and no signal as to why. The codemods for a jump ship *inside* the target version, which a failed install did not put on disk, so running them is impossible rather than merely undesirable — the fix is the message. It now states plainly that no codemods were applied, why, and prints the exact copy-pasteable recovery command with the concrete versions: `voltro update --codemods-only --from <from> --to <to>`. **`--help` / `-h` is answered before every guard.** `voltro update --help` on a dirty tree printed "working tree is not clean" — at precisely the moment the user was trying to discover `--dry-run` and `--codemods-only`. Help is documentation, not an operation, so it is now handled first, ahead of the `package.json` check, the `@voltro/*`-deps check and the clean-tree guard, and lists every flag (`--to`, `--from`, `--root`, `--dry-run`, `--force`, `--exact`, `--codemods-only`). `voltro doctor --help` had the same shape — it fell through to the preflight and reported on the tree instead — and gets the same treatment. `voltro help`'s `update` line now names `--from` and `--codemods-only` too.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
42
62
|
## [0.6.0] — 2026-07-19
|
|
43
63
|
|
|
44
64
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { InspectedWorkflow } from '@voltro/workflow';
|
|
|
5
5
|
import { Layer } from 'effect';
|
|
6
6
|
import { ParseResult } from 'effect';
|
|
7
7
|
import { ProcedureDescriptor } from '@voltro/protocol';
|
|
8
|
+
import { RelationsSpec } from '@voltro/database';
|
|
8
9
|
import { Row } from '@voltro/database';
|
|
10
|
+
import { RowFilter } from '@voltro/runtime';
|
|
9
11
|
import { RpcInterceptor } from '@voltro/protocol';
|
|
10
12
|
import { RpcKind } from '@voltro/protocol';
|
|
11
13
|
import { Schema } from 'effect';
|
|
@@ -21,6 +23,19 @@ import { VoltroPlugin } from '@voltro/protocol';
|
|
|
21
23
|
* ergonomic without an `any` in the public type. */
|
|
22
24
|
declare type ErasedWorkflowEffect = Effect.Effect<unknown, unknown, never>;
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What `invoke` resolves to for a handler returning `Result`: an `Effect`'s
|
|
28
|
+
* SUCCESS value, an awaited `Promise`, or the value itself.
|
|
29
|
+
*
|
|
30
|
+
* Inferring from the executor's whole return type — rather than making `Output`
|
|
31
|
+
* a naked type parameter that also appears inside `Effect<Output, E>` — is what
|
|
32
|
+
* keeps `invoke(d, () => Effect.succeed('x'), …)` resolving to `string` instead
|
|
33
|
+
* of to `Effect<string>`: with two inference sites for one parameter TypeScript
|
|
34
|
+
* would pick the bare candidate and hand the caller back the un-run Effect in
|
|
35
|
+
* the TYPE as well as at runtime.
|
|
36
|
+
*/
|
|
37
|
+
export declare type HandlerOutput<Result> = Result extends Effect.Effect<infer A, infer _E, infer _R> ? A : Awaited<Result>;
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Run `executor` as the dispatcher would: enforce the descriptor's `guards:`
|
|
26
41
|
* against `ctx.request.subject`, decode `rawInput` through the descriptor's
|
|
@@ -54,17 +69,48 @@ declare type ErasedWorkflowEffect = Effect.Effect<unknown, unknown, never>;
|
|
|
54
69
|
* expect(await ctx.store.select('notes').all()).toHaveLength(0)
|
|
55
70
|
* ```
|
|
56
71
|
*/
|
|
57
|
-
export declare const invoke: <D extends Pick<ProcedureDescriptor, "input">,
|
|
72
|
+
export declare const invoke: <D extends Pick<ProcedureDescriptor, "input">, Result>(descriptor: D, executor: (input: Schema.Schema.Type<D["input"]>, ctx: TestContext) => Result, rawInput: unknown, ctx: TestContext) => Promise<HandlerOutput<Result>>;
|
|
58
73
|
|
|
59
|
-
export declare const makeTestContext: (options?: MakeTestContextOptions) => TestContext;
|
|
74
|
+
export declare const makeTestContext: <RowFilterCtx = unknown>(options?: MakeTestContextOptions<RowFilterCtx>) => TestContext;
|
|
60
75
|
|
|
61
|
-
export declare interface MakeTestContextOptions {
|
|
76
|
+
export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
|
|
62
77
|
/** The acting subject. Default: anonymous, no tenant. Read it back at
|
|
63
78
|
* `ctx.request.subject` (the same place a live handler reads it). */
|
|
64
79
|
readonly subject?: Subject;
|
|
65
80
|
/** Tables to wire into the store's schema. Default: all globally
|
|
66
81
|
* registered tables (whatever the test imported). */
|
|
67
82
|
readonly tables?: ReadonlyArray<TableLike>;
|
|
83
|
+
/**
|
|
84
|
+
* `relations(...)` specs to register for this context — the harness's stand-in
|
|
85
|
+
* for the boot sweep, so `.with({ ... })` eager loads resolve under test.
|
|
86
|
+
*
|
|
87
|
+
* Needed because `relations()` is PURE: it RETURNS a spec, it does not
|
|
88
|
+
* register one. In production `voltro dev` discovers `*.relations.ts` and
|
|
89
|
+
* feeds each export to `registerDiscoveredRelations`; a unit test runs no
|
|
90
|
+
* boot, so importing the module registers nothing and the first eager load
|
|
91
|
+
* fails with "no relations registered". Hand the specs over instead:
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* import { teamRelations } from '../db/teams.relations'
|
|
95
|
+
* const ctx = makeTestContext({ relations: [teamRelations] })
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* SEMANTICS, because the registry is process-global (a `Symbol.for` map shared
|
|
99
|
+
* by every copy of `@voltro/database`) and that global is a leak waiting to
|
|
100
|
+
* happen: passing `relations` REPLACES the registry with exactly these specs
|
|
101
|
+
* (`clearRelationsRegistry()` then `registerRelations` each). It is a
|
|
102
|
+
* declaration of what this context's relations ARE, not an addition to
|
|
103
|
+
* whatever a previous test left behind. That is what makes two
|
|
104
|
+
* `makeTestContext({ relations: [...] })` calls in one file independent —
|
|
105
|
+
* additive registration would make the second call throw
|
|
106
|
+
* `duplicate relation '…'` on a re-registered spec and would silently carry
|
|
107
|
+
* the first test's relations into the second's store.
|
|
108
|
+
*
|
|
109
|
+
* Omitting the option touches the registry NOT AT ALL, so a suite that calls
|
|
110
|
+
* `registerRelations` itself (or one whose app registers at import time) keeps
|
|
111
|
+
* working unchanged.
|
|
112
|
+
*/
|
|
113
|
+
readonly relations?: ReadonlyArray<RelationsSpec>;
|
|
68
114
|
/** Seed rows keyed by table name (use `mockStore({...})`). */
|
|
69
115
|
readonly store?: Record<string, ReadonlyArray<Row>>;
|
|
70
116
|
/** Frozen clock start. Default 2026-01-01T00:00:00Z. */
|
|
@@ -97,6 +143,24 @@ export declare interface MakeTestContextOptions {
|
|
|
97
143
|
* for a single handler call, and are ignored.
|
|
98
144
|
*/
|
|
99
145
|
readonly plugins?: ReadonlyArray<VoltroPlugin>;
|
|
146
|
+
/**
|
|
147
|
+
* Row-level security for this context, WITHOUT touching the process global.
|
|
148
|
+
*
|
|
149
|
+
* `ctx.store` applies the app's row filter either way: with this option
|
|
150
|
+
* omitted the harness resolves whatever `setRowFilter(...)` registered, so a
|
|
151
|
+
* test that boots its app's real filter needs no option at all. Passing one
|
|
152
|
+
* here overrides the global FOR THIS CONTEXT — which is what a test usually
|
|
153
|
+
* wants, because `setRowFilter` is process-global: registered in one test it
|
|
154
|
+
* silently constrains every later test in the same worker, and forgetting the
|
|
155
|
+
* `afterEach` that clears it produces a failure in an unrelated file.
|
|
156
|
+
*
|
|
157
|
+
* Resolution goes through `resolveRowFilterScopeFor` — the same function
|
|
158
|
+
* `resolveRowFilterScope` (and therefore the serve pipeline) is built on — so
|
|
159
|
+
* the system-subject bypass, the `retry` schedule, and the `onLoadError`
|
|
160
|
+
* decision are inherited rather than re-implemented. A filter whose `load`
|
|
161
|
+
* fails refuses here exactly as it refuses in production.
|
|
162
|
+
*/
|
|
163
|
+
readonly rowFilter?: RowFilter<RowFilterCtx>;
|
|
100
164
|
}
|
|
101
165
|
|
|
102
166
|
export declare const makeWorkflowRunner: (opts: MakeWorkflowRunnerOptions) => WorkflowRunner;
|
|
@@ -171,9 +235,13 @@ export declare const outboxNudgesOf: (ctx: TestContext) => ReadonlyArray<string>
|
|
|
171
235
|
export { ParseResult }
|
|
172
236
|
|
|
173
237
|
/** Any procedure executor: takes the DECODED input + a context, returns a
|
|
174
|
-
* result
|
|
175
|
-
* query / mutation / action handler has
|
|
176
|
-
|
|
238
|
+
* result — sync, `Promise`, or `Effect`. Mirrors the `(input, ctx) => …` shape
|
|
239
|
+
* a real query / mutation / action handler has, INCLUDING the Effect form: the
|
|
240
|
+
* framework's contract is "async or Effect, your choice per handler", and the
|
|
241
|
+
* dispatcher runs both (`Effect.isEffect(result) ? … : …` in `servePipeline`).
|
|
242
|
+
* `E` is the handler's typed error channel; it defaults to `never` for the
|
|
243
|
+
* common `Effect.gen` that only succeeds. */
|
|
244
|
+
export declare type ProcedureExecutor<Input, Output, E = never> = (input: Input, ctx: TestContext) => Output | Promise<Output> | Effect.Effect<Output, E>;
|
|
177
245
|
|
|
178
246
|
/**
|
|
179
247
|
* Drop queued post-commit work WITHOUT running it.
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Cause as e, Effect as t, Exit as n, Layer as r, Schema as i } from "effect";
|
|
2
|
-
import {
|
|
3
|
-
import { InMemoryDataStore as
|
|
4
|
-
import { allRegisteredTables as
|
|
5
|
-
import { installEnvSnapshot as
|
|
6
|
-
import { CurrentWorkflowRunId as
|
|
2
|
+
import { SubjectService as a, anonymousSubject as o, checkGuardsEffect as s, composeRpcInterceptors as c } from "@voltro/protocol";
|
|
3
|
+
import { InMemoryDataStore as l, applyRowFilterToDescriptor as u, getRowFilter as d, makeAppAccess as f, makeDataLoader as p, makeEffectStoreLayer as m, makeOutboxFacade as h, makeSchemaRegistry as g, resolveRowFilterScopeFor as _, runProvidedEffect as v, runWithDeadlockRetry as y, wrapStoreWithMixinBehaviour as b } from "@voltro/runtime";
|
|
4
|
+
import { allRegisteredTables as x, clearRelationsRegistry as S, registerRelations as C } from "@voltro/database";
|
|
5
|
+
import { installEnvSnapshot as w } from "@voltro/env";
|
|
6
|
+
import { CurrentWorkflowRunId as T, inMemoryWorkflowEngineLayer as E, makeInMemoryRecorder as D } from "@voltro/workflow";
|
|
7
7
|
//#region src/mockClock.ts
|
|
8
|
-
var
|
|
8
|
+
var O = class {
|
|
9
9
|
currentMs;
|
|
10
10
|
constructor(e = /* @__PURE__ */ new Date("2026-01-01T00:00:00Z")) {
|
|
11
11
|
this.currentMs = typeof e == "number" ? e : e.getTime();
|
|
@@ -17,9 +17,9 @@ var b = class {
|
|
|
17
17
|
return new Date(this.currentMs);
|
|
18
18
|
}
|
|
19
19
|
advance(e) {
|
|
20
|
-
this.currentMs += typeof e == "number" ? e :
|
|
20
|
+
this.currentMs += typeof e == "number" ? e : k(e);
|
|
21
21
|
}
|
|
22
|
-
},
|
|
22
|
+
}, k = (e) => {
|
|
23
23
|
let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
|
|
24
24
|
if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
|
|
25
25
|
let n = Number(t[1]), r = t[2];
|
|
@@ -31,7 +31,7 @@ var b = class {
|
|
|
31
31
|
case "d": return n * 864e5;
|
|
32
32
|
default: throw Error(`mockClock: unknown unit '${r}'`);
|
|
33
33
|
}
|
|
34
|
-
},
|
|
34
|
+
}, A = class {
|
|
35
35
|
sent = [];
|
|
36
36
|
send(e) {
|
|
37
37
|
this.sent.push({
|
|
@@ -45,7 +45,7 @@ var b = class {
|
|
|
45
45
|
clear() {
|
|
46
46
|
this.sent.length = 0;
|
|
47
47
|
}
|
|
48
|
-
},
|
|
48
|
+
}, j = class {
|
|
49
49
|
queue;
|
|
50
50
|
calls = [];
|
|
51
51
|
constructor(e) {
|
|
@@ -60,29 +60,29 @@ var b = class {
|
|
|
60
60
|
remaining() {
|
|
61
61
|
return this.queue.length;
|
|
62
62
|
}
|
|
63
|
-
},
|
|
64
|
-
let n =
|
|
63
|
+
}, M = (e) => e, N = "00000000000000000000000000000000", P = /* @__PURE__ */ new WeakMap(), F = /* @__PURE__ */ new WeakMap(), I = /* @__PURE__ */ new WeakMap(), L = /* @__PURE__ */ new WeakMap(), R = (e, t) => {
|
|
64
|
+
let n = L.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
|
|
65
65
|
for (let e of n) {
|
|
66
66
|
let t = e[r];
|
|
67
67
|
typeof t == "function" && i.push(t);
|
|
68
68
|
}
|
|
69
|
-
return
|
|
70
|
-
},
|
|
71
|
-
let t =
|
|
69
|
+
return c(i);
|
|
70
|
+
}, z = (e) => {
|
|
71
|
+
let t = F.get(e);
|
|
72
72
|
t !== void 0 && (t.length = 0);
|
|
73
|
-
},
|
|
74
|
-
let t =
|
|
73
|
+
}, B = async (e) => {
|
|
74
|
+
let t = F.get(e);
|
|
75
75
|
if (t === void 0 || t.length === 0) return;
|
|
76
76
|
let n = [...t];
|
|
77
77
|
t.length = 0;
|
|
78
78
|
for (let e of n) await e();
|
|
79
|
-
},
|
|
80
|
-
let n =
|
|
79
|
+
}, V = (e) => I.get(e) ?? [], H = async (e, t) => {
|
|
80
|
+
let n = P.get(e);
|
|
81
81
|
return n === void 0 ? e.store.transactional(async (n) => t({
|
|
82
82
|
...e,
|
|
83
83
|
store: n
|
|
84
84
|
})) : n(t);
|
|
85
|
-
},
|
|
85
|
+
}, U = (e) => {
|
|
86
86
|
let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
|
|
87
87
|
let r = t.get(n);
|
|
88
88
|
return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
|
|
@@ -121,7 +121,7 @@ var b = class {
|
|
|
121
121
|
return i(e, o, n.ttlMs, n.tags), o;
|
|
122
122
|
}
|
|
123
123
|
};
|
|
124
|
-
},
|
|
124
|
+
}, W = (e) => {
|
|
125
125
|
let t = /* @__PURE__ */ new Map(), n = (n) => {
|
|
126
126
|
let r = t.get(n);
|
|
127
127
|
return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
|
|
@@ -149,73 +149,91 @@ var b = class {
|
|
|
149
149
|
t.clear();
|
|
150
150
|
}
|
|
151
151
|
};
|
|
152
|
-
},
|
|
153
|
-
|
|
152
|
+
}, G = (e, t) => {
|
|
153
|
+
let n = K, r;
|
|
154
|
+
return () => {
|
|
155
|
+
let i = t ?? d();
|
|
156
|
+
return (r === void 0 || i !== n) && (n = i, r = v(_(i, e, (e) => {
|
|
157
|
+
console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
|
|
158
|
+
}))), r;
|
|
159
|
+
};
|
|
160
|
+
}, K = Symbol("unresolved"), q = (e, t) => new Proxy(e, { get: (e, n) => {
|
|
161
|
+
if (n === "query") return async (n) => e.query(u(await t(), n));
|
|
162
|
+
let r = Reflect.get(e, n, e);
|
|
163
|
+
return typeof r == "function" ? r.bind(e) : r;
|
|
164
|
+
} }), J = (e = {}) => {
|
|
165
|
+
if (w({
|
|
154
166
|
...process.env,
|
|
155
167
|
...e.env ?? {}
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
let
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
}), e.relations !== void 0) {
|
|
169
|
+
S();
|
|
170
|
+
for (let t of e.relations) C(t);
|
|
171
|
+
}
|
|
172
|
+
let t = g(e.tables ?? x()), n = new l(e.store ?? {}), r = new O(e.clockStart), i = new A(), a = new j(e.llmResponses ?? []), s = U(() => r.now()), c = W(() => r.now()), u = (o, l = n, d) => {
|
|
173
|
+
let m = {
|
|
174
|
+
subject: o,
|
|
175
|
+
traceId: N
|
|
176
|
+
}, g = d?.queued ?? [], _ = d?.nudges ?? [], v = p({ store: l }), y = G(o, e.rowFilter), x = {
|
|
162
177
|
clock: r,
|
|
163
178
|
email: i,
|
|
164
|
-
llm:
|
|
179
|
+
llm: a,
|
|
165
180
|
...e.ai === void 0 ? {} : { ai: e.ai },
|
|
166
|
-
request:
|
|
167
|
-
access:
|
|
181
|
+
request: m,
|
|
182
|
+
access: f(o),
|
|
168
183
|
cache: s,
|
|
169
|
-
kv:
|
|
170
|
-
store:
|
|
171
|
-
subject:
|
|
184
|
+
kv: c,
|
|
185
|
+
store: b(q(l, y), {
|
|
186
|
+
subject: o,
|
|
172
187
|
schemaRegistry: t
|
|
173
188
|
}),
|
|
174
|
-
outbox:
|
|
175
|
-
store:
|
|
176
|
-
subject:
|
|
177
|
-
traceId:
|
|
189
|
+
outbox: h({
|
|
190
|
+
store: l,
|
|
191
|
+
subject: o,
|
|
192
|
+
traceId: N,
|
|
178
193
|
nudge: () => {
|
|
179
|
-
|
|
194
|
+
_.push(N);
|
|
180
195
|
},
|
|
181
196
|
afterCommit: (e) => {
|
|
182
197
|
g.push(e);
|
|
183
198
|
}
|
|
184
199
|
}),
|
|
185
|
-
load:
|
|
186
|
-
loadMany:
|
|
187
|
-
withSubject: (e, t) => Promise.resolve(t(
|
|
200
|
+
load: v.load,
|
|
201
|
+
loadMany: v.loadMany,
|
|
202
|
+
withSubject: (e, t) => Promise.resolve(t(u(e, l, {
|
|
188
203
|
queued: g,
|
|
189
|
-
nudges:
|
|
204
|
+
nudges: _
|
|
190
205
|
}))),
|
|
191
|
-
withTenant: (e, t) => Promise.resolve(t(
|
|
192
|
-
...
|
|
206
|
+
withTenant: (e, t) => Promise.resolve(t(u({
|
|
207
|
+
...o,
|
|
193
208
|
tenantId: e
|
|
194
|
-
},
|
|
209
|
+
}, l, {
|
|
195
210
|
queued: g,
|
|
196
|
-
nudges:
|
|
211
|
+
nudges: _
|
|
197
212
|
})))
|
|
198
213
|
};
|
|
199
|
-
return
|
|
214
|
+
return F.set(x, g), I.set(x, _), L.set(x, e.plugins ?? []), P.set(x, (e) => l.transactional((t) => e(u(o, t, {
|
|
200
215
|
queued: g,
|
|
201
|
-
nudges:
|
|
202
|
-
})))),
|
|
216
|
+
nudges: _
|
|
217
|
+
})))), x;
|
|
203
218
|
};
|
|
204
|
-
return
|
|
205
|
-
},
|
|
206
|
-
let l = r.input, u = await i.decodeUnknownPromise(l)(
|
|
219
|
+
return u(e.subject ?? o(null));
|
|
220
|
+
}, Y = (e, n) => e.pipe(t.provide(m(n.store)), t.provideService(a, n.request.subject)), X = async (r, a, o, c) => {
|
|
221
|
+
let l = r.input, u = await i.decodeUnknownPromise(l)(o), d = r.kind, f = async (e) => {
|
|
222
|
+
let n = a(u, e);
|
|
223
|
+
return t.isEffect(n) ? v(Y(n, e)) : n;
|
|
224
|
+
}, p = async () => {
|
|
207
225
|
let e = r.guards;
|
|
208
226
|
if (e !== void 0 && e.length > 0) {
|
|
209
|
-
let n = await t.runPromise(
|
|
227
|
+
let n = await t.runPromise(s(c.request.subject, e, u));
|
|
210
228
|
if (n !== null) throw n;
|
|
211
229
|
}
|
|
212
|
-
if (d !== "mutation") return
|
|
213
|
-
let n = await
|
|
214
|
-
return await
|
|
215
|
-
}, m = d === "mutation" || d === "query" || d === "action" ? d : void 0, h = m === void 0 ? void 0 :
|
|
216
|
-
if (h === void 0 || m === void 0) return
|
|
230
|
+
if (d !== "mutation") return f(c);
|
|
231
|
+
let n = await y(async () => (z(c), H(c, async (e) => f(e))), { delay: () => Promise.resolve() });
|
|
232
|
+
return await B(c), n;
|
|
233
|
+
}, m = d === "mutation" || d === "query" || d === "action" ? d : void 0, h = m === void 0 ? void 0 : R(c, m);
|
|
234
|
+
if (h === void 0 || m === void 0) return await p();
|
|
217
235
|
let g = h(t.tryPromise({
|
|
218
|
-
try: () =>
|
|
236
|
+
try: () => p(),
|
|
219
237
|
catch: (e) => e
|
|
220
238
|
}), {
|
|
221
239
|
tag: r.name ?? "",
|
|
@@ -227,7 +245,7 @@ var b = class {
|
|
|
227
245
|
}), _ = await t.runPromiseExit(g);
|
|
228
246
|
if (n.isSuccess(_)) return _.value;
|
|
229
247
|
throw e.squash(_.cause);
|
|
230
|
-
},
|
|
248
|
+
}, Z = (e) => {
|
|
231
249
|
let t = /* @__PURE__ */ new Map();
|
|
232
250
|
for (let n of e) {
|
|
233
251
|
let e = t.get(n.stepName);
|
|
@@ -253,14 +271,14 @@ var b = class {
|
|
|
253
271
|
});
|
|
254
272
|
}
|
|
255
273
|
return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
|
|
256
|
-
},
|
|
274
|
+
}, Q = (e) => {
|
|
257
275
|
let n = e.workflows ?? [], i = /* @__PURE__ */ new Map(), a = 0;
|
|
258
276
|
return {
|
|
259
277
|
start: async (o, s) => {
|
|
260
278
|
let c = n.find((e) => e.workflow.name === o);
|
|
261
279
|
if (c === void 0) throw Error(`makeWorkflowRunner: no workflow named '${o}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
|
|
262
|
-
let l = `wfrun_test_${++a}`, u =
|
|
263
|
-
if (m._tag === "Right")
|
|
280
|
+
let l = `wfrun_test_${++a}`, u = D(), d = c.workflow.toLayer(c.execute).pipe(r.provideMerge(E)), f = /* @__PURE__ */ new Date(), p = c.workflow.execute(s).pipe(t.locally(T, l), t.provide(u.layer), t.provide(d), t.either), m = await t.runPromise(p), h = Z(u.readSteps()), g = /* @__PURE__ */ new Date(), _;
|
|
281
|
+
if (m._tag === "Right") _ = {
|
|
264
282
|
status: "succeeded",
|
|
265
283
|
output: m.right,
|
|
266
284
|
error: null,
|
|
@@ -269,7 +287,7 @@ var b = class {
|
|
|
269
287
|
};
|
|
270
288
|
else {
|
|
271
289
|
let e = m.left;
|
|
272
|
-
|
|
290
|
+
_ = {
|
|
273
291
|
status: "failed",
|
|
274
292
|
output: null,
|
|
275
293
|
error: {
|
|
@@ -284,9 +302,9 @@ var b = class {
|
|
|
284
302
|
id: l,
|
|
285
303
|
executionId: l,
|
|
286
304
|
name: o,
|
|
287
|
-
status:
|
|
305
|
+
status: _.status,
|
|
288
306
|
input: s,
|
|
289
|
-
output:
|
|
307
|
+
output: _.output,
|
|
290
308
|
subject: e.ctx.request.subject,
|
|
291
309
|
source: "test-runner",
|
|
292
310
|
steps: h,
|
|
@@ -295,10 +313,10 @@ var b = class {
|
|
|
295
313
|
traceId: null,
|
|
296
314
|
parentExecutionId: null,
|
|
297
315
|
parentClosePolicy: null
|
|
298
|
-
}),
|
|
316
|
+
}), _;
|
|
299
317
|
},
|
|
300
318
|
inspect: async (e) => i.get(e) ?? null
|
|
301
319
|
};
|
|
302
|
-
},
|
|
320
|
+
}, $ = 1;
|
|
303
321
|
//#endregion
|
|
304
|
-
export {
|
|
322
|
+
export { O as MockClock, A as MockEmail, j as MockLLM, $ as TESTING_PRESET_VERSION, X as invoke, J as makeTestContext, Q as makeWorkflowRunner, M as mockStore, V as outboxNudgesOf, z as resetAfterCommit, R as rpcInterceptorFor, B as runAfterCommit, H as runInStoreTransaction };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Test utilities for Voltro apps — deterministic clock, captured emails, queued LLM responses, scoped subject/tenant runners, and a cross-dialect parity harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -42,16 +42,16 @@
|
|
|
42
42
|
"node": ">=24.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@voltro/database": "0.
|
|
46
|
-
"@voltro/env": "0.
|
|
47
|
-
"@voltro/protocol": "0.
|
|
48
|
-
"@voltro/runtime": "0.
|
|
49
|
-
"@voltro/workflow": "0.
|
|
45
|
+
"@voltro/database": "0.7.0",
|
|
46
|
+
"@voltro/env": "0.7.0",
|
|
47
|
+
"@voltro/protocol": "0.7.0",
|
|
48
|
+
"@voltro/runtime": "0.7.0",
|
|
49
|
+
"@voltro/workflow": "0.7.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"effect": "^3.21.4",
|
|
53
53
|
"react": "^19.0.0",
|
|
54
|
-
"@voltro/client": "0.
|
|
54
|
+
"@voltro/client": "0.7.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"react": {
|