@voltro/testing 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +139 -130
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,174 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.70.0] — 2026-09-11
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/cli** — The DevTools dashboard has a lock of its own: `VOLTRO_DASHBOARD_TOKEN`. A deployed dashboard that holds other apps' inspect tokens in `VOLTRO_DASHBOARD_APPS` refuses to boot in production without it, `/api/dashboard/config` no longer serves token values, and the proxy attaches a configured target's credentials itself.
|
|
47
|
+
|
|
48
|
+
Before this the dashboard had no door: whoever reached its URL got every configured app's inspect read AND write token from `/api/dashboard/config` in one request, and the proxy forwarded whatever bearer that browser then presented. The URL was the credential.
|
|
49
|
+
|
|
50
|
+
Three rules now, on both boot paths: the browser proves the dashboard token once (`POST /api/dashboard/login`) and gets an HttpOnly, SameSite=Strict session cookie derived from it by HMAC (12 h; a rotated lock invalidates every cookie); every other `/api/dashboard/*` route answers 401 without a session; the config route says where the targets are and whether the pod holds credentials (`hasToken`, `hasWriteToken`), never which. A dashboard with nothing to guard (no configured targets, a loopback dev registry) boots open as before — setting the token locks it anyway. `voltro secret generate dashboard` prints the variable paste-ready. `codemod: none`: no user code changes; the operator sets one variable, and the chart's `dashboardToken` renders it.
|
|
51
|
+
|
|
52
|
+
**No codemod** — this break touches no user-authored code.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **@voltro/protocol, @voltro/plugin-openapi, @voltro/cli, @voltro/testing** — The `publicApi` surface speaks a REST profile, chosen once in the api's `app.config.ts` (`publicApi: { profile }`) and overridable per descriptor (`publicApi.format`): `'rpc'` — the default, today's wire — `'standard'`, `'jsonapi'`, or a `RestFormat` object of the app's own, with building blocks (`dates`, `errors`, `envelope`, `pagination`, `naming`, `etag`) that override the preset. `'standard'` is the Zalando / Google AIP / Microsoft intersection: `timestampMs` as RFC 3339 (accepted inbound too), every failure as RFC 9457 Problem Details — the transport's refusals included — no envelope, a `nextCursor` as a `Link: <…>; rel="next"` header, a weak `ETag` on GET with `If-None-Match` → 304. `'jsonapi'` answers `{ data, links }` as `application/vnd.api+json`. The spec follows the profile (`format: date-time`, the `ProblemDetails` component, `Link`/`ETag` headers, snake-case names). `makeTestApp({ publicApiProfile })` measures the same wire.
|
|
57
|
+
|
|
58
|
+
A path with `{teamId}` or `:teamId` binds that segment to the input field of the same name — always, under every profile — with the remaining fields from the query string (GET) or the body; the brace form used to be no route, the colon form a `400`, so `GET /v1/teams/{teamId}` was unreachable for a projection.
|
|
59
|
+
- **@voltro/testing, @voltro/database** — `makeTestContext({ fieldEncryptionKey })` registers the field cipher for `.encrypted()` columns under test. Setting `VOLTRO_FIELD_ENCRYPTION_KEY` — in `process.env` or the context's `env` — did nothing here, because the harness runs no boot gate and the gate is what turns the variable into a cipher; the only working route was `setFieldCipher(makeFieldCipher(key))` from another package, which nothing pointed at. The refusal now names the option.
|
|
60
|
+
- **@voltro/database, @voltro/runtime** — `insertIgnoreWithOutcome` on the framework store (and on `yield* EffectStore` plus the `tx` view), with the typed helper `insertIgnoreRowOutcome`: `{ row, outcome: 'inserted' | 'ignored' }`.
|
|
61
|
+
|
|
62
|
+
`insertIgnore` returns the existing row on the ignore path, and that row is indistinguishable from one it just wrote — so a loop that plants missing rows reported every row it visited as planted unless it read the destination first, per row. The answer is decided the way `upsertWithOutcome` decides it: the id the store minted before the statement is not the id the ignore path hands back; where the id cannot decide (caller-supplied, or database-assigned) the destination is read first and the answer stays exact. Plain `insertIgnore` never pays for that read.
|
|
63
|
+
- **@voltro/protocol, @voltro/client, @voltro/web** — `defineMutation({ serialize: { key, maxPending? } })` — calls of one mutation to the SAME resource run in invocation order on the client, for every caller of the tag. A row lock serialises transactions, not the order a browser invoked them in: two quick saves of one snapshot could take the lock in reverse order and the older snapshot then overwrote the newer one; server-side idempotency does not see it, and `.version()` detects the conflict and replays the older write. `key` names the resource from the input; calls sharing it run one at a time, FIFO, per subject, the permit spanning connection setup, the request, replay and its acknowledgement — optimistic previews stay immediate, different keys run independently. An unknown outcome (a transport failure after the request started) blocks the key for that subject rather than let a later write pass it; a subject change drops the queue; admission beyond `maxPending` (default 32) rejects with `MutationSerialExecutionError`. `useMutation`'s `pending` now counts every concurrent call, not only the one that settled last.
|
|
64
|
+
|
|
65
|
+
Declared on the descriptor — where `concurrency` lives for workflows — rather than as a hook builder, because a hook-scoped queue orders one component's calls and lets a second component writing the same resource interleave. Not a durable outbox, a distributed lock or a cross-tab merge.
|
|
66
|
+
- **@voltro/cli** — API `fonts:` declarations now package validated native-readable fonts and relocatable Fontconfig artifacts during build. Dev, serve and direct production entries activate them before startups and handlers; missing or corrupt artifacts refuse boot. Native renderers no longer depend on undeclared host fonts or original workspace font paths.
|
|
67
|
+
- **@voltro/plugin-openapi** — Every mounted API version has an OpenAPI document of its own — `/openapi/v2.json` beside `/openapi.json`, or `?version=v2` — the same spec filtered to that version, every operation fact included; the unversioned URL keeps serving everything. With more than one version mounted the docs page shows Swagger UI's "Select a definition" dropdown: one entry per version, newest first and preselected, an expiring version named with its earliest `sunset` (`v1 — sunset 2027-03-01`), the whole document last.
|
|
68
|
+
|
|
69
|
+
Before this `v1` and `v2` shared one document and one viewer: a consumer integrating `v2` scrolled past every `v1` path, and a generator built both into one client.
|
|
70
|
+
- **@voltro/protocol, @voltro/cli, @voltro/plugin-openapi** — `onProcedureSurface` — a plugin hook told the app's wire-reachable rpc procedures (every query, mutation, action and stream discovery found, minus `internal`) once discovery has run, on both boot paths; the procedure counterpart of `onRestSurface`. `openapiPlugin({ includeAppProcedures: true })` documents them as `POST /rpc/<name>` operations without the app listing them: opt-in, so the spec stays REST-only unless asked, and a procedure also passed in `procedures` is documented once.
|
|
71
|
+
|
|
72
|
+
Before this `procedures` was the only way in, and filling it meant importing every descriptor module into `app.config.ts` — evaluated before discovery on every load of the config, the pattern the `onRestSurface` docs name as a boot that hung — while `voltro serve` loads no TypeScript at runtime, so a plugin could not read them later either.
|
|
73
|
+
- **@voltro/protocol, @voltro/plugin-openapi** — A `publicApi` projection documents itself completely: `tags` from the rpc tag's prefix (`teams.list` → `teams`; `publicApi.tags` overrides) with a top-level `tags[]` and `tagDescriptions` on the OpenAPI plugin; an `operationId` from the tag (`teamsList`; `publicApi.operationId` overrides); `security` on every operation — required, or optional under `openAccess` (`[{ bearerAuth: [] }, {}]`), `publicApi.security: false` for none — with `securitySchemes.bearerAuth` published once any projection exists; one response per `errorStatus` entry carrying the error's own schema, `422` for the union's other tags, `429` for a mounted rate limiter; `publicApi.example: { request?, response? }`; path parameters `in: path` and query parameters typed from the input.
|
|
74
|
+
|
|
75
|
+
Before this the plugin wrote `tags` only for an explicit `version`, `security` only for a `guards:` route — so after "Authorize" Swagger UI sent the token to those alone and "Try it out" on every other route ran without a key — no `operationId`, no `errorStatus` response, no example, and no query parameter at all for a GET projection.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **@voltro/protocol** — A `publicApi.path` segment that names no input field refuses the boot, naming the descriptor and the segment (`descriptor 'bookings.get' — path '/v1/bookings/:uuid' binds ':uuid', and the input declares no field of that name (fields: bookingId)`). A field the path binds may repeat in the body or query string with the same value — a PATCH carrying the whole resource — but another value answers `400` naming the field.
|
|
80
|
+
|
|
81
|
+
Before this the segment was bound silently: the route mounted, the handler never saw the value, and a body that carried the same field with another value was overwritten by the path without a word.
|
|
82
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/plugin-openapi, @voltro/plugin-prometheus** — A plugin route answers the path it declared, byte for byte: the dispatcher refuses a request whose path is not the route's own — case-folded twins and a bare trailing slash included — with `404`, before the body is read. `PluginHttpRoute.match: 'exact'` narrows a route to its path alone, no sub-path; the default stays `'prefix'`, the contract every route that branches on its tail was written against. A REST descriptor is an exact route; the OpenAPI spec and docs routes and the Prometheus scrape route declare `'exact'`.
|
|
83
|
+
|
|
84
|
+
Before this the router matched without case and ignored a trailing slash, and every route owned its sub-paths: `/OPENAPI.JSON`, `/openapi.json/`, `/Docs` and `/Metrics` all answered, so a path guard at the ingress that named the one documented spelling covered none of the others. The docs of both plugins now say the guard can name the path as written.
|
|
85
|
+
- **@voltro/workflow, @voltro/cli** — A run that ended outside the engine no longer leaves its unanswered requests in the cluster journal. Cancelling a run (`timeouts.finish`, `cancelOn`, the operator's button, the bulk action) discards its unprocessed messages at once, and the staleness tick discards those of every ended run (`succeeded`, `failed`, `cancelled`) that a runner last read more than a minute ago — logged as `workflow.cluster: discarded unprocessed messages of runs that have ended`.
|
|
86
|
+
|
|
87
|
+
Before this, the `activity` request a killed executor was inside stayed unprocessed after the run was cancelled: every runner that acquired the shard re-read it every ten minutes for the rest of time, its handler waited on a latch that never opens, and the `unprocessed messages` warning was printed again on every engine start. An unprocessed `run` request of a cancelled run would have gone further and executed it. The engine's own reset deletes one request; this deletes the ended run's, and `ClusterDiagnostics.discardMessages` is the one write on that service.
|
|
88
|
+
- **@voltro/cli** — `voltro db encrypt-column <table>.<column> .` accepts the app directory every other `db` command takes as its last argument — accepted and not needed, the connection comes from env. It used to parse `.` as a column reference and refuse. A positional that is neither a `<table>.<column>` nor a directory is named in the refusal.
|
|
89
|
+
- **@voltro/runtime, @voltro/cli, @voltro/database** — Every API key has its `actors` row after the first boot on this version. Both boot paths plant, after the schema is applied, one row per `_voltro_api_keys.id` that has none — through the same `ensureActor` a new key goes through (the built-in row, or the app's `apiKeys.ensureActor` handed the issue input rebuilt from the row). Idempotent; two queries when nothing is missing; `api keys: planted actors rows for keys that had none` with the count, and a key whose row cannot be written is named in a warning rather than failing the boot.
|
|
90
|
+
|
|
91
|
+
Two release notes had asked the operator to run a loop once. The first was printed to the wrong group; the second printed `store.select(...)` for a `*.startup.ts`, and `StartupContext.store` is a `DataStore` with no fluent builder — both API pods refused to boot on it. The 0.69.1 note now says what the boot does and which line to look for. The framework tables `@voltro/database` exports (`_voltroApiKeysTable` and the others) are typed `SchemaTable` instead of `TableLike`, so `queryFor(_voltroApiKeysTable)` compiles where a hand-written read is still wanted.
|
|
92
|
+
- **@voltro/runtime** — Idempotent WebSocket mutations acquire and finalize their claims safely across request interruption. A closed socket cannot orphan a claim acquired by a still pending store Promise; synchronous executor failures release fresh claims too. Already-started Promise executors finish their actual write and record the result before interruption completes, preventing a replay from duplicating the write. Effect executors remain interruptible and release only after cleanup. A late interrupt never deletes the completion receipt of a successful write.
|
|
93
|
+
|
|
94
|
+
Codemod: none — no application source or configuration changes. Claim TTL, replay limits, transport guards and process-crash recovery guarantees are unchanged.
|
|
95
|
+
- **@voltro/cli** — Docker deployments retain optional native platform bindings until runtime tracing, and the pruner traces both conditional import and require entries so relocated API and web images can still execute native image processing.
|
|
96
|
+
- **@voltro/workflow, @voltro/cli** — `own` on a shard-lock holder in the stall report and the `workflow.cluster` warning is now the engine's own account (`Sharding.hasShardId`), and a holder that is this runner reads `this runner (pid N)`.
|
|
97
|
+
|
|
98
|
+
The first version compared the holder's pid with `pg_backend_pid()` of the query that asked. The advisory lock lives on the runner's reserved connection, and the pool hands every other query a different backend — so a runner's own 300 locks read as a stranger's, always, and behind a connection pooler nothing in `pg_stat_activity` could have told them apart either. The report's stated lever for a stranger, ending the holder's session, would have ended the runner. The hint now distinguishes the three cases (another runner, a session that outlived its runner, this runner re-reading a request nobody answers), and the docs say to read `own` before `pg_terminate_backend`.
|
|
99
|
+
- **@voltro/cli** — Production HTTP proxies release upstream resources when the browser abandons an unfinished response, including SSE, and propagate truncated upstream responses as transport failures instead of hanging. Complete responses retain payloads and keep-alive; no application configuration change is needed.
|
|
100
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — `publicApi.rateLimit` on a descriptor is that endpoint's own rule for calls through the `/v1` projection — ahead of the plugin's `rules` and `default`, one bucket per caller per endpoint, under `enabled`. A REST call now carries `surface: 'rest'` and the descriptor's `publicApi` spec on the interceptor context, so a plugin can tell a projected call from an rpc one.
|
|
101
|
+
|
|
102
|
+
The field said "rides @voltro/plugin-ratelimit when mounted" and nothing read it: three calls at `limit: 1` answered `200 200 200`, no bucket, no `onLimited`. The plugin never learned which calls came through the projection, let alone what it declared. Without the plugin mounted the spec is inert, as the comment always implied.
|
|
103
|
+
- **@voltro/cli** — The retention sweep arms its policies on the `_voltro_workflow_*` tables only where the workflow engine that creates those tables is composed. An api with no `*.workflow.tsx` has no engine and no such tables, and the five policies still armed on them were five failing `DELETE`s per tick, each an `ERROR` line in the database log, for the life of the deployment — not switchable off from the app. The boot line `retention: N policy(ies) armed` now lists exactly what is swept.
|
|
104
|
+
- **@voltro/plugin-mail** — SMTP replies are classified by SMTP rules instead of HTTP rules: 4xx replies remain retryable, permanent 5xx rejections do not trigger automatic retries (RFC 5321 §4.2.1). HTTP providers keep their existing policy. Tests exercise actual loopback connections, envelope, MIME and attachment encoding without sending external mail.
|
|
105
|
+
|
|
106
|
+
Codemod: none — corrected SMTP reply semantics require no application source or configuration migration.
|
|
107
|
+
- **@voltro/workflow** — The workflow engine's boot check for its cluster tables asks whether a table exists with a query that succeeds either way (`to_regclass` on postgres, `information_schema` on mysql, `OBJECT_ID` on mssql, `sqlite_master` on sqlite) instead of selecting from the table and reading the failure as "no" — which the database server logged as an `ERROR` on every first boot of a database. The probes `@effect/cluster` itself makes for its legacy tables are upstream and still fail-as-answer.
|
|
108
|
+
- **@voltro/cli** — `voltro dev` acquires the workflow runtime as a boot step, as `voltro serve` always has — `workflow runtime ready — N entity type(s) registered` on both paths, through one shared helper with a parity test.
|
|
109
|
+
|
|
110
|
+
A `ManagedRuntime` builds its layer on first use, and `voltro dev` never used it at boot. A fleet of dev pods therefore had NO cluster runner after a restart — no runner row, no shard locks, no re-delivery — until something asked the runtime for a service. The first thing that asked, in a deployment, was the once-per-fleet staleness tick, five minutes after the boot, in the one pod that ran it: a run orphaned by the restart waited exactly that long and was then adopted by that pod alone.
|
|
111
|
+
- **@voltro/cli** — `voltro baseline sync` writes its `docker/Caddyfile` with tabs, the way `caddy fmt` writes it. Caddy warned on every proxy start that the file "is not formatted", and a project that checks its generated files for drift could not fix it on its side — the next sync undid it.
|
|
112
|
+
- **@voltro/plugin-ratelimit** — `redisStore({ url })` binds its client on the first `consume` when nobody activated it — from `url` or the env it reads at activation — so a limit holds in the test harness, where `makeTestContext` runs no lifecycle hooks. A store with neither stays fail-open (`failOpenReason: 'unbound'`), as at activation.
|
|
113
|
+
|
|
114
|
+
Before this a suite that "checked the redis store" ran every call through an unbound store: `200 200 200` at `limit: 2`, no key in Redis, no warning — and nothing a test could read told "the limit held" from "the store was never there".
|
|
115
|
+
- **@voltro/cli, @voltro/i18n** — A `src/pages/[locale]/…` route renders in the locale it matched, on both web boot paths: `voltro start` and `voltro dev` hand the matched `[locale]` parameter to the same i18n resolver (also for the ISR key and for an app without catalogs), and `resolveLocale` takes it as `routeLocale` — ahead of the `voltro:locale` cookie, `Accept-Language` and the default, and only on an exact supported spelling. The build's static prerender already did this; the runtime render fell back to cookie or browser language, so `/en` answered with a German head and `html lang="de"`. In the same change `voltro dev`'s buffered SPA and ISR responses merge a page's `meta.title` into the shell the way production does — there was a second `<title>` before.
|
|
116
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — A rate-limited call on the `/v1` projection answers `429` with `Retry-After` and the `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers, the tag and fields as the body — on both boot paths and in `makeTestApp`. A `PluginErrorSchema` may declare `rest: { status, headers }`; the projection encodes a plugin's cross-cutting error under that status (`422` when it names none; the descriptor's own `errorStatus` entry wins), and the plugin declares `429` for `RateLimited`.
|
|
117
|
+
|
|
118
|
+
The plugin merged `RateLimited` into every rpc's wire union at boot and the client decoded it typed, but the REST projection encoded through the descriptor's own `error:` union only — the one surface with no typed channel to fall back on answered `500 Internal Server Error` with no `Retry-After`. The only way to a `429` was to import a plugin error into every descriptor file. A `500`'s cause now rides on the response value off the wire, so the `GET /v1/… → 500` log line names the reason on `voltro serve` too.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## [0.69.1] — 2026-09-11
|
|
123
|
+
|
|
124
|
+
### Added
|
|
125
|
+
|
|
126
|
+
- **@voltro/cli** — `voltro dev` and `voltro build` refuse a published artefact. A publish step writes `.framework/dist/.voltro-artifact.json` into a package that ships its build and no source; both commands look for it before loading the config or booting anything and exit 1 with the reason. An image built without the dev toolchain is a hindrance; this is the rule — a toolchain installed by hand into that image still cannot develop or rebuild the package. A developer's own tree never carries the marker, built or not, so nothing changes there.
|
|
127
|
+
- **@voltro/database, @voltro/cli** — `voltro db plan` names the tables on which the live and the declared fingerprint part — in the JSON record, and on a TTY for an empty plan.
|
|
128
|
+
|
|
129
|
+
`fingerprintLive` hashes the whole live introspection, every table included; `fingerprintDeclared` hashes the declaration. A plan of zero operations says the schemas agree, and the two hashes can still differ — over a table the diff never compares (`cluster_*`, an undeclared `_voltro_*`, a soft-drop snapshot, a name in `VOLTRO_DB_IGNORE_TABLES`), or over a detail the diff calls equal that introspection reports differently from how it was declared. The TTY printed a caveat under the two; the JSON record a migrate job writes carried none, so `0 operations` under two different hashes read as a contradiction and raised the question whether the computation had changed.
|
|
130
|
+
|
|
131
|
+
The plan carries `fingerprintDivergence: { outsideDiff, diffEquivalent }` now, both lists of table names, and the record carries it with a one-line `fingerprintNote`. On a TTY the fingerprint line lists them, and an empty plan prints it only when the two part somewhere, so a clean schema stays a one-liner. The earlier wording — "a live hash never equals a declared one" — was wrong as a rule: a schema whose every detail introspects back as declared hashes the same on both sides, and does.
|
|
132
|
+
|
|
133
|
+
`voltro serve` is not part of this: its boot check compares the declared fingerprint against the one the last `voltro db apply` RECORDED, never the live hash.
|
|
134
|
+
- **@voltro/client, @voltro/web** — The client diagnostics snapshot carries `runtime` — per api, what the runtime resolved to (`scheduler`, `generation`) — outside the event ring.
|
|
135
|
+
|
|
136
|
+
`runtime.resolved` is recorded when the runtime is built, which makes it one of the first events of a session, and the first events are exactly what a `clear()` before a measurement or the ring's eviction removes. An export taken after either carried every reconnect event and no statement about which scheduler produced them; a reader checking whether the yield channel was in use had to inspect the bundle by hand. The snapshot keeps the last resolved record per api in its own field now, and `clear()` leaves it in place — it describes the runtime, not the timeline.
|
|
137
|
+
- **@voltro/plugin-openapi, @voltro/protocol, @voltro/cli** — `openapiPlugin` documents the app's own REST surface as the framework mounts it — every `restRoutes` descriptor and every `publicApi` projection — without the app listing them (`includeAppRoutes`, default `true`). A plugin can implement the new `onRestSurface(routes)` hook and is told the mounted routes on both boot paths once they exist. Importing descriptor modules into `app.config.ts` to build that list is no longer needed, which is the path on which a boot had hung.
|
|
138
|
+
- **@voltro/workflow, @voltro/cli** — A `run-stalled` report on the durable engine carries the engine's view of the run — its journal messages, whether each was processed and last read, and who holds the lock on its shard — and the staleness tick warns about unprocessed messages no runner has read for the engine's own re-read window.
|
|
139
|
+
|
|
140
|
+
The engine's recovery for a run whose process died is real and silent: a runner that acquires a shard re-delivers the shard's unprocessed messages, and re-reads any whose `last_read` is older than ten minutes. The state with no symptom is the one where no runner acquires the shard — a postgres advisory lock held by a session that is not a live runner (a backend the server has not reaped, a pooled connection that outlived its client) — and the run then sits `running` with nothing moving while every process reports healthy. `ClusterDiagnostics` (provided by `workflowEngineLayer`) reads that state: `messagesFor(executionId)`, `unreadMessages()`, `shardLockHolders(shardIds)` (postgres advisory holders from `pg_locks` + `pg_stat_activity`, row leases from `cluster_locks`), `runners()`. Both boot paths attach it to the stall report as `cluster` and log `workflow.cluster: unprocessed messages no runner has read for the re-read window` with the holders, so the report names the pid to terminate — or the lease mode (`VOLTRO_WORKFLOW_SHARD_LOCK=row`) that bounds the wait.
|
|
141
|
+
- **@voltro/runtime, @voltro/voltro** — `EffectStoreOps.upsertWithOutcome` — the Effect form of `upsertRowOutcome`, on `yield* EffectStore` and on the `tx` view `transactional` hands out.
|
|
142
|
+
|
|
143
|
+
`upsertRowOutcome` required `store.upsertWithOutcome`, which `ctx.store` provides and `EffectStoreOps` did not declare, so an Effect handler running its ingest inside `transactional` — the place the outcome was built for — kept a pre-read to report `created`. The operation answers `{ row, outcome: 'inserted' | 'updated' }` on the `StoreError` channel like every other op; a hand-written `DataStore` without it is refused with the same message the typed helper gives.
|
|
144
|
+
|
|
145
|
+
### Changed
|
|
146
|
+
|
|
147
|
+
- **@voltro/cli** — `voltro test` exits 1 when it executed no test, on every invocation — a bare `voltro test` included.
|
|
148
|
+
|
|
149
|
+
A filter that matched nothing and an explicitly named directory with no tests already failed. The bare command was the exception, kept as "a fresh app with no tests yet shouldn't fail its pipeline" — and that is the one exception a CI cannot see: a tree whose tests were all misnamed, moved or deleted printed green forever, with `if voltro test` seeing nothing. Every scaffold ships tests, so nothing needs the exception. The refusal for a stale `rpcGroup.generated.ts` already exited 1; this closes the remaining route to a green line over zero tests.
|
|
150
|
+
|
|
151
|
+
vitest's own `--passWithNoTests` is honoured verbatim when a green run over nothing is what you want; it used to be overridden.
|
|
152
|
+
- **@voltro/runtime, @voltro/voltro, @voltro/cli** — `ApiKeyStore.ensureActor` receives the `issue()` input, and the built-in `apiKeys:` wiring takes an `ensureActor` of your own.
|
|
153
|
+
|
|
154
|
+
0.69.0 made issuing a key plant its `actors` row through a required `ensureActor({ id, displayName })`, and its note said the framework stores need nothing from you. Both were written for the BUILT-IN `actors`. An app may declare its own — it takes precedence over the built-in table — and that table need not carry `kind` or `displayName` at all and may require columns the hook never saw: which kind of key this is, whom it belongs to, which team it acts for. All of that is in the `issue()` input (`metadata`, `onBehalfOf`) and nowhere else, so the only complete row was one written AFTER `issue()` returned — and a hook that wrote a minimal row first collided with it on the table's own unique keys.
|
|
155
|
+
|
|
156
|
+
The hook receives `input` now — verbatim, and on a rotation the input the rotation rebuilt from the old row, so ownership survives rotation there too. The two framework stores read `id` and `displayName` and ignore the rest; a hand-written store can build the complete row before the key exists. Additive for an implementor: a function taking the narrower object is still assignable.
|
|
157
|
+
|
|
158
|
+
For an app that declares its own `actors` and uses the built-in wiring, the 0.69.0 write (`{ id, kind: 'apiKey', displayName }`) does not fit that table, and since 0.69.0 that meant `issue()` failed at the actor write. `apiKeys: { ensureActor }` in the config replaces exactly that write, on both boot paths.
|
|
159
|
+
|
|
160
|
+
A note under an already-crossed version is never seen again, so the corrected guidance is re-issued as codemod `0.69.1/01_ensure-actor-receives-the-issue-input`, gated on a hand-written `ApiKeyStore` OR an app-declared `actors` — the second group is affected without ever having written the first, and the 0.69.0 gate did not reach it.
|
|
161
|
+
- **@voltro/protocol** — A `GET` projection coerces the query string toward the descriptor's input before decoding: `?limit=5` reaches a `Schema.Number` as `5`, `?archived=true` a `Schema.Boolean` as `true`, and a repeated parameter a `Schema.Array` as its elements; a value that does not read as the target is left for the schema's own `400`. A projection of a procedure without an `openAccess` decision now carries `requireAuthenticated()` — an anonymous caller answers `401` before the handler runs, where it used to reach the handler and surface that refusal as a `500`. A typed error the handler fails with, one of the descriptor's declared `error:` variants, answers with its tag and fields as the body under the status `publicApi.errorStatus` assigns it (`422` when unlisted); an undeclared error stays a `500`.
|
|
162
|
+
- **@voltro/cli** — `voltro dashboard` has no dev fallback any more. A dashboard tree without a build used to be served by `voltro dev` — right for the monorepo source layout, and exactly the mode a deployed dashboard must never enter: a package that lacked its build would have booted a Vite dev server with a freshly minted inspect token on a public hostname, with a `mode: 'dev'` log line as the only sign. The command now refuses a tree without a build in every environment and says where a source tree is served from (`voltro dev <path>`, which the auto-launcher already picks on its own). Removed rather than gated: a gate is one environment variable away from open. The dashboard image is built to have no such door at all — installed without optional dependencies, so the build and dev toolchain is absent, and started through the bin's in-process start path.
|
|
163
|
+
- **@voltro/database, @voltro/cli** — A declared snapshot and the live snapshot of a database the planner has converged on hash the SAME — the fingerprint canonicalises by the planner's own equivalence rules, and the declared snapshot carries the widths the emitter renders.
|
|
164
|
+
|
|
165
|
+
`fingerprint.ts` promised that two snapshots of identical observable shape produce identical fingerprints. Measured on a MariaDB: a plan of zero operations under two different hashes, and practically every table hashing apart from its own declaration. The planner decided "nothing to do" through helpers the fingerprint never saw — `restrict` folded into `noAction` on every foreign key (MySQL reports RESTRICT, SQL Server NO ACTION), `now` against `current_timestamp(6)` on every timestamp, a boolean default against the engine's `0`/`1`, `oneOf` compared as a set — and hashed the raw fields instead. And the emitter renders widths the declaration never spells out: `VARCHAR(255)` for a `oneOf` text, `VARCHAR(64)` for a typeid `id()`, a bounded key on mssql for indexed text; introspection reports them as `maxLength`, the declared snapshot carried nothing.
|
|
166
|
+
|
|
167
|
+
Those rules live in one module now (`columnEquivalence.ts`), imported by the planner and the fingerprint alike — a second copy is how the two disagreed. The fingerprint hashes the canonical column: references through `normRef`, defaults as the comparable token, `oneOf` sorted, a postgres BIGSERIAL's implicit sequence as the no-default it is to a numeric id, a soft-drop snapshot column left out as the planner leaves it out. `declaredSnapshot(tables, dialect)` materialises the width `sqlType` renders — the same function that renders CREATE TABLE, so the DDL is byte-for-byte what it was; an explicit `.maxLength(n)` is never touched, and a `text()` that renders unbounded stays unbounded. Verified against the database that showed it: the six remaining `diffEquivalent` tables after the first pass were all boolean defaults, and none remain.
|
|
168
|
+
|
|
169
|
+
**What this changes for a running deployment: the declared fingerprint of every schema with a foreign key, a default or a `oneOf` moves once.** A `voltro dev` boot runs the plan, finds nothing, and records the new value on its own. `voltro serve` compares the declared fingerprint against the last one `voltro db apply` recorded, so the pre-deploy `voltro db apply` your pipeline already runs is what records it — an apply that finds nothing to do records exactly for this case. `voltro db drift` reports the live side moved once too, until that apply re-baselines it.
|
|
170
|
+
|
|
171
|
+
`voltro db squash` now passes the dialect to the declared snapshot it fingerprints; it was the one caller that did not, and its hash disagreed with the boot's.
|
|
172
|
+
- **@voltro/cli** — A production `voltro start` no longer reads `src/`. `voltro build` writes the route tree beside the SSR bundle (`dist/server/pagesTree.json`, the same walk the bundle's lazy page loaders were generated from), and `start` boots from it; the font head was already baked into the built shell. So a deployed web app is `.framework/` plus `app.config.ts` and its `node_modules` — the TypeScript source can stay out of the image and out of a published app package. Only an unbuilt tree (the Vite middleware fallback that is not a production path) still walks `src/pages`. A production start that finds an SSR bundle without the tree beside it refuses with the instruction to rebuild, rather than quietly falling back to the source.
|
|
173
|
+
- **@voltro/cli** — `voltro start` drains on SIGTERM the way `voltro serve` does: readiness flips to `503`, the long-lived inspect streams are ended (their client reconnects), the listener closes and in-flight requests finish inside 60% of `VOLTRO_SHUTDOWN_GRACE_MS`, then the SSR loader, cache backend and registry entry close and the process exits `0` with a `drained in <ms>` line. Before this the web path closed its listener and exited: a render in flight when the signal landed was cut, and the docs described a drain with an env var (`SHUTDOWN_GRACE_MS`) that nothing read. The docs now name the real variable and the real order.
|
|
174
|
+
|
|
175
|
+
### Fixed
|
|
176
|
+
|
|
177
|
+
- **@voltro/cli** — `voltro agents-md` finds the project's `@voltro/cli` under the app it was run from, not only at the workspace root.
|
|
178
|
+
|
|
179
|
+
Its provenance line looked in `<root>/node_modules/@voltro/cli` alone, so a pnpm-strict install (`apps/<name>/node_modules/@voltro/cli`) reported `project has no resolvable @voltro/cli … installed: null` — from a command that was running that very cli. The candidates are now the directory the command started in and every directory up to the root, the root, then every discovered app, in that order.
|
|
180
|
+
- **@voltro/cli** — `voltro update` prints the backfill for API keys issued before 0.69.0 to the apps that have them.
|
|
181
|
+
|
|
182
|
+
The 0.69.0 note `05_api-key-store-plants-its-actor` carried the instruction — one `actors` row per existing key — in its last paragraph, and gated the whole note on a hand-written `ApiKeyStore`. An app on the framework store, which is the app that holds such keys, saw `1 skipped (own check found nothing)`. A note under an already-crossed version is never printed again, so the paragraph is re-issued under `0.69.1/02_existing-api-keys-get-their-actors-row`, gated on using api keys at all (`apiKeys:` in the config, `apiKeyStrategy`, or an `ApiKeyStore`), with the loop that plants the rows idempotently through `insertIgnore`.
|
|
183
|
+
- **@voltro/workflow, @voltro/cli** — `onFailure` fires for a run whose body failed — the route the documentation listed first and the one that was not wired.
|
|
184
|
+
|
|
185
|
+
The three flow-control routes into the failure signal (an expired `timeouts.start`, a `timeouts.finish` cancellation, a dead-lettered intent) reached the handler through the drainer's `onAbandoned`; the run recorder's terminal hook only released the admission lease. So a deployment saw its `onFailure` workflow run for a finish-timeout cancellation and stay silent for the runs that had actually failed. The recorder's `onTerminal` now carries the payload and the error on `failed`, and both boot paths build their hook with `makeWorkflowTerminalHook`: release the lease, then — for a workflow that declares `onFailure` — start it with the same `WorkflowFailureReport` the other routes send (`reason: 'the run failed with <errorTag>'`, `runId`, `executionId`, the payload). A workflow that declares nothing stays exactly as loud as before.
|
|
186
|
+
- **@voltro/cli, @voltro/protocol** — A `publicApi` projection — and every hand-written `restRoutes` entry — now runs under the app's row filter. Both boot paths built the REST request's context from the subject alone, so for an app that registered `setRowFilter` the context builder's unfiltered-read guard refused every REST request before its handler ran: `500` on every route, in development and production alike. The shared REST surface now resolves the row filter for each request (`withRowFilter`, the same spine the rpc runners walk) before the context exists, and the context builder runs inside the route's failure mapping rather than escaping it as a transport defect.
|
|
187
|
+
|
|
188
|
+
Behind that refusal sat a second defect the refusal had been hiding: the one-shot read of a projected query went through the raw store, so once the request had a row filter it still returned every row of the table — the filter was resolved and never applied to the read. The one-shot runner now reads through the request-scoped store the context builder wraps (row filter and tenant scope applied), the store the handler itself would read from. The subscription and socket arms were already filtered.
|
|
189
|
+
|
|
190
|
+
The REST failure mapping is one function for every place a route can fail: `RestRouteError` and `{ status }` errors keep their status; the framework's `ScopeError` answers `403`, or `401` for an anonymous caller; `Unauthenticated` answers `401`; everything else is the reported `500`. A repeated query parameter (`?tag=a&tag=b`) reaches the input as an array of every value instead of the last one.
|
|
191
|
+
- **@voltro/workflow, @voltro/cli** — `timeouts.finish` cancels at the deadline plus at most one drain tick — not at whatever start next happened to wake an idle drainer.
|
|
192
|
+
|
|
193
|
+
The bound is enforced by the admission drainer's tick, and the drainer disarms when nothing is pending. A run admitted straight through writes nothing to the queue, so with an empty queue the drainer slept while the run passed its bound and cancelled it only when an unrelated arrival woke it — measured as cancellations landing tens of minutes late by amounts that depended on traffic. Two changes: the finish sweep reports the earliest deadline among the running runs it saw, and `drainTick` folds it into `nextDueAt`, the instant the drainer arms for; and a run STARTING wakes the drainer in an app that declares a finish bound, so the tick that follows reads the new deadline. Both boot paths, through the shared wiring.
|
|
194
|
+
- **@voltro/cli** — The `helm` baseline (`voltro baseline set helm`) probed `/healthz` on the api and `/` on the web — the first answers 404 on every Voltro boot path, so an api pod could never become ready; the second is answered by the SPA shell with 200 for anything, so a web pod could never become unready. Both deployments now probe what the framework answers before routing — `<path>/startup`, `<path>/readiness`, `<path>/liveness` — with a `probes` block per app (path, periods, thresholds) and a `shutdown` block that renders the `preStop` sleep, `terminationGracePeriodSeconds` and `VOLTRO_SHUTDOWN_GRACE_MS` together, since they are one budget. `PORT` is set from the chart's port so the Service and the listener agree.
|
|
195
|
+
- **@voltro/cli** — `/_voltro/inspect/stream` under `voltro serve` no longer refuses a non-loopback `Host`. The DNS-rebinding host guard is a `voltro dev` measure — the dev api binds every interface and has no hostname of its own — and the JSON inspect door has only ever applied it there. The live stream's shared wiring applied it on every boot path, so behind a Kubernetes Service, a docker network name or an ingress host every JSON endpoint answered the dashboard's bearer with 200 while the stream answered the same bearer with `401 forbidden host` — a refusal that reads as a token problem and is not one. The stream now takes the guard from its door: on in dev, off in serve, where the surface is fail-closed on the token alone.
|
|
196
|
+
- **@voltro/cli** — `voltro dev` stops, with exit code `3`, when the project's installed `@voltro/cli` is no longer the one it is running — instead of restarting on the old one and reporting it as complete.
|
|
197
|
+
|
|
198
|
+
The supervisor respawns the api child from its own entry, resolved once at boot. Swap `node_modules` under a running `voltro dev` (a `voltro update`, an install that bumps the cli, a volume replaced under a pod) and every restart after that booted the cli the supervisor started from, printing `restart complete — api ready` for an api composed by code the disk no longer had; nothing in the log said which cli built it. The supervisor cannot re-exec itself, so on the next file change it compares what the root installs with its own version and stops on a difference, naming both and the path — under a restart policy the process comes back on the installed version, in a terminal `voltro dev` is started again. The ready line now carries `cli=<version>`, and a first boot on a cli the project does not install is warned about rather than stopped, because a workspace binary against a checkout is legitimate.
|
|
199
|
+
- **@voltro/cli** — `voltro update` puts every `package.json` it bumped back, byte for byte, when the install fails without ever fetching the target version.
|
|
200
|
+
|
|
201
|
+
The bump is written before the install runs — that is what the install resolves against — and an install can fail for a reason the tree had no part in. A lockstep release lands on the registry one package at a time, so the moment the FIRST `@voltro/*` package is there the latest version resolves to the new release; an update started in that minute bumped every manifest and then met `NO_MATCHING_VERSION` on a sibling that was seconds from landing. What it left behind was the worst of the three states: manifests saying the new version, lockfile and `node_modules` saying the old one, and `voltro update` itself reading the manifests as "already there".
|
|
202
|
+
|
|
203
|
+
Which state a failure leaves is now measured off the tree, not read off the exit code. A bumped package present in `node_modules` at the target version means the install fetched the release and failed after — a postinstall, a peer — so the bump stands and the message names the codemods as the missing half, as before. Nothing at the target version means the install never got that far, and the originals go back. The message says which of the two happened, and for the first names the publish window as the likely cause: wait a minute, re-run.
|
|
204
|
+
|
|
205
|
+
Nothing here reaches `--no-install`, which writes the bump on purpose and says what is left.
|
|
206
|
+
- **@voltro/cli** — `voltro logs` and the dev log buffer keep the error a record was written with. `log.error(message, fields, err)` under `voltro serve` printed `cause` on stdout; the dev capture dropped it, so a route that threw showed `… threw path=/v1/x` and nothing else on any level. The record now carries the error's message and first stack frames, and the text renderer prints them under the line. `voltro dev` also warns when `VOLTRO_SESSION_SECRET` is shorter than the floor `voltro serve` refuses, instead of accepting in silence what production will reject.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
42
210
|
## [0.69.0] — 2026-09-10
|
|
43
211
|
|
|
44
212
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { Layer } from 'effect';
|
|
|
15
15
|
import { ParseResult } from 'effect';
|
|
16
16
|
import { ProcedureDescriptor } from '@voltro/protocol';
|
|
17
17
|
import { PublicApiDescriptor } from '@voltro/protocol/rest';
|
|
18
|
+
import { PublicApiProfileConfig } from '@voltro/protocol';
|
|
19
|
+
import { PublicApiSpec } from '@voltro/protocol';
|
|
18
20
|
import { RelationsSpec } from '@voltro/database';
|
|
19
21
|
import { RestRouteDescriptor } from '@voltro/protocol/rest';
|
|
20
22
|
import { RestServeBindings } from '@voltro/protocol/rest';
|
|
@@ -344,6 +346,9 @@ export declare interface MakeTestAppOptions {
|
|
|
344
346
|
/** HTTP idempotency binding — the same `{ store, header, ttlMs }` the serve
|
|
345
347
|
* path builds from `app.config.ts`'s `idempotency` field. */
|
|
346
348
|
readonly idempotency?: RestServeBindings['idempotency'];
|
|
349
|
+
/** The api's `publicApi:` config — the REST profile the projections speak,
|
|
350
|
+
* the same object `app.config.ts` declares. Absent → `'rpc'`. */
|
|
351
|
+
readonly publicApiProfile?: PublicApiProfileConfig;
|
|
347
352
|
}
|
|
348
353
|
|
|
349
354
|
export declare const makeTestContext: <RowFilterCtx = unknown>(options?: MakeTestContextOptions<RowFilterCtx>) => TestContext;
|
|
@@ -417,6 +422,15 @@ export declare interface MakeTestContextOptions<RowFilterCtx = unknown> {
|
|
|
417
422
|
* test harness never runs the real boot gate. Merged OVER `process.env`
|
|
418
423
|
* (these win). Default: just the ambient `process.env`. */
|
|
419
424
|
readonly env?: Record<string, string | undefined>;
|
|
425
|
+
/**
|
|
426
|
+
* The key for `.encrypted()` columns under test. Registers the field cipher
|
|
427
|
+
* the boot would derive from `VOLTRO_FIELD_ENCRYPTION_KEY`, so a handler that
|
|
428
|
+
* writes such a column runs here instead of failing with "no field cipher is
|
|
429
|
+
* registered". Process-global like the boot's (`setFieldCipher`), and left
|
|
430
|
+
* alone when omitted — `env` alone does NOT do this: the harness runs no boot
|
|
431
|
+
* gate, and the gate is what turns the variable into a cipher.
|
|
432
|
+
*/
|
|
433
|
+
readonly fieldEncryptionKey?: string;
|
|
420
434
|
/**
|
|
421
435
|
* Plugins whose rpc interceptors (`interceptMutation` / `interceptQuery` /
|
|
422
436
|
* `interceptAction`) wrap every `invoke` on this context — the same list an
|
|
@@ -491,6 +505,8 @@ export declare interface MakeWorkflowRunnerOptions {
|
|
|
491
505
|
readonly workflows?: ReadonlyArray<WorkflowEntry>;
|
|
492
506
|
}
|
|
493
507
|
|
|
508
|
+
export declare const markRestInvocation: (ctx: TestContext, publicApi: PublicApiSpec) => void;
|
|
509
|
+
|
|
494
510
|
/** Minimal AI mock surface. The full `mockAi({...})` helper is owned by
|
|
495
511
|
* the AI package (plan 05); typed here as an optional injected interface
|
|
496
512
|
* so `@voltro/testing` carries no hard `@voltro/ai` dependency. */
|
|
@@ -601,6 +617,11 @@ export declare const outboxNudgesOf: (ctx: TestContext) => ReadonlyArray<string>
|
|
|
601
617
|
|
|
602
618
|
export { ParseResult }
|
|
603
619
|
|
|
620
|
+
/** The plugins a context was built with — for the REST harness, which hands
|
|
621
|
+
* their cross-cutting errors to the `publicApi` projection the way both boot
|
|
622
|
+
* paths do. */
|
|
623
|
+
export declare const pluginsOf: (ctx: TestContext) => ReadonlyArray<VoltroPlugin>;
|
|
624
|
+
|
|
604
625
|
/** Any procedure executor: takes the DECODED input + a context, returns a
|
|
605
626
|
* result — sync, `Promise`, or `Effect`. Mirrors the `(input, ctx) => …` shape
|
|
606
627
|
* a real query / mutation / action handler has, INCLUDING the Effect form: the
|
|
@@ -669,6 +690,10 @@ export declare const reportEngineVersion: (label: string, probe: () => Promise<s
|
|
|
669
690
|
*/
|
|
670
691
|
export declare const resetAfterCommit: (ctx: TestContext) => void;
|
|
671
692
|
|
|
693
|
+
export declare const restInvocationOf: (ctx: TestContext) => {
|
|
694
|
+
readonly publicApi: PublicApiSpec;
|
|
695
|
+
} | undefined;
|
|
696
|
+
|
|
672
697
|
export { Row }
|
|
673
698
|
|
|
674
699
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
1
|
import { AUTO_FILLED_COLUMNS as e, allRegisteredTables as t, changeDelete as n, changeInsert as r, changeSoftDelete as i, changeUpdate as a, clearRelationsRegistry as o, missingRequiredColumns as s, registerRelations as c } from "@voltro/database";
|
|
2
2
|
import { Cause as l, Effect as u, Exit as d, Layer as f, Schema as p } from "effect";
|
|
3
3
|
import { SubjectService as m, anonymousSubject as h, checkGuardsEffect as g, composeAuthStrategies as _, composeRpcInterceptors as v, eventRoute as y, systemSubject as b, tenantScopedSubject as ee } from "@voltro/protocol";
|
|
4
|
-
import { EventBus as x, InMemoryDataStore as te, NO_ROW_FILTER as S, appValidation as ne, applyRowFilterToDescriptor as re, clearSystemStoreHandle as ie, getRowFilter as C, getSystemStoreHandle as ae, makeAppAccess as oe, makeConnectionState as se, makeDataLoader as ce, makeEffectStoreLayer as le, makeEventPublisher as ue,
|
|
5
|
-
import { installEnvSnapshot as
|
|
6
|
-
import
|
|
7
|
-
import { describe as
|
|
8
|
-
import { collectPublicApiRoutes as
|
|
9
|
-
import { createLogger as
|
|
10
|
-
import { CurrentWorkflowRunId as
|
|
4
|
+
import { EventBus as x, InMemoryDataStore as te, NO_ROW_FILTER as S, appValidation as ne, applyRowFilterToDescriptor as re, clearSystemStoreHandle as ie, getRowFilter as C, getSystemStoreHandle as ae, makeAppAccess as oe, makeConnectionState as se, makeDataLoader as ce, makeEffectStoreLayer as le, makeEventPublisher as ue, makeFieldCipher as de, makeOutboxFacade as fe, makeSchemaRegistry as pe, publishEvent as me, resolveRowFilterScopeFor as he, runProvidedEffect as ge, runWithDeadlockRetry as _e, setFieldCipher as ve, setSystemStoreHandle as w, wrapStoreWithMixinBehaviour as ye } from "@voltro/runtime";
|
|
5
|
+
import { installEnvSnapshot as be } from "@voltro/env";
|
|
6
|
+
import xe from "node:net";
|
|
7
|
+
import { describe as Se } from "vitest";
|
|
8
|
+
import { collectPublicApiRoutes as Ce, dispatchSharedPath as we, restRoutesToHttpRoutes as Te } from "@voltro/protocol/rest";
|
|
9
|
+
import { createLogger as Ee } from "@voltro/logger";
|
|
10
|
+
import { CurrentWorkflowRunId as De, inMemoryWorkflowEngineLayer as Oe, makeInMemoryRecorder as ke } from "@voltro/workflow";
|
|
11
11
|
//#region src/fixtureRow.ts
|
|
12
|
-
var
|
|
12
|
+
var Ae = new Set(e), T = 0, E = () => ++T, je = (e, t) => {
|
|
13
13
|
if (t.oneOf && t.oneOf.length > 0) return t.oneOf[0];
|
|
14
14
|
if (t.enumValues && t.enumValues.length > 0) return t.enumValues[0];
|
|
15
15
|
let n = t.unique === !0;
|
|
16
16
|
switch (t.type) {
|
|
17
17
|
case "text":
|
|
18
18
|
case "id":
|
|
19
|
-
case "enum": return n ? `${e}-${++
|
|
20
|
-
case "reference": return t.referenceStorage === "numeric" ? n ? ++
|
|
19
|
+
case "enum": return n ? `${e}-${++T}` : e;
|
|
20
|
+
case "reference": return t.referenceStorage === "numeric" ? n ? ++T : 0 : n ? `${e}-${++T}` : e;
|
|
21
21
|
case "integer":
|
|
22
|
-
case "real": return n ? ++
|
|
22
|
+
case "real": return n ? ++T : 0;
|
|
23
23
|
case "decimal":
|
|
24
|
-
case "bigint": return n ? String(++
|
|
24
|
+
case "bigint": return n ? String(++T) : "0";
|
|
25
25
|
case "boolean": return !1;
|
|
26
26
|
case "timestamp":
|
|
27
27
|
case "date": return /* @__PURE__ */ new Date(0);
|
|
28
28
|
default: throw Error(`fixtureRow: column '${e}' is a required '${t.type}' with no default, and fixtureRow can't synthesize a safe placeholder for that type. Pass it explicitly: fixtureRow(table, { ${e}: … }).`);
|
|
29
29
|
}
|
|
30
|
-
},
|
|
30
|
+
}, D = (e, t = {}) => {
|
|
31
31
|
let n = {};
|
|
32
|
-
for (let r of s(e, t))
|
|
32
|
+
for (let r of s(e, t)) Ae.has(r) || (n[r] = je(r, e.fields[r]));
|
|
33
33
|
return {
|
|
34
34
|
...n,
|
|
35
35
|
...t
|
|
36
36
|
};
|
|
37
|
-
},
|
|
37
|
+
}, Me = (e) => {
|
|
38
38
|
let t = {};
|
|
39
|
-
for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(
|
|
39
|
+
for (let [n, r] of Object.entries(e)) t[n] = typeof r == "function" ? r(E()) : r;
|
|
40
40
|
return t;
|
|
41
|
-
},
|
|
41
|
+
}, Ne = (e) => {
|
|
42
42
|
for (let [t, n] of Object.entries(e.fields)) if (n.type === "id") return t;
|
|
43
43
|
return "id";
|
|
44
|
-
},
|
|
44
|
+
}, Pe = async (e, t, n, r, i) => {
|
|
45
45
|
let a = {};
|
|
46
46
|
for (let o of s(t, n)) {
|
|
47
47
|
let n = t.fields[o];
|
|
@@ -49,29 +49,29 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
49
49
|
let s = n.references();
|
|
50
50
|
if (i.includes(s.tableName)) throw Error(`defineFactory: creating '${t.tableName}' requires '${s.tableName}' via '${o}', and that reference is cyclic (${[...i, s.tableName].join(" → ")}). Insert one side first and pass the id: create(store, { ${o}: existing.id }).`);
|
|
51
51
|
let c = r[o];
|
|
52
|
-
a[o] = (c === void 0 ? await
|
|
52
|
+
a[o] = (c === void 0 ? await O(e, s, {}, {}, [...i, s.tableName]) : await c.create(e, {}))[n.referenceColumn ?? Ne(s)];
|
|
53
53
|
}
|
|
54
54
|
return a;
|
|
55
|
-
},
|
|
55
|
+
}, O = async (e, t, n, r, i) => {
|
|
56
56
|
let a = {
|
|
57
|
-
...await
|
|
57
|
+
...await Pe(e, t, n, r, i),
|
|
58
58
|
...n
|
|
59
59
|
};
|
|
60
|
-
return e.insert(t.tableName,
|
|
61
|
-
},
|
|
60
|
+
return e.insert(t.tableName, D(t, a));
|
|
61
|
+
}, k = (e, t) => typeof e == "function" ? e(t) : e ?? {}, Fe = (e, t = {}) => {
|
|
62
62
|
let n = t.traits ?? {}, r = t.associations ?? {}, i = (t) => {
|
|
63
63
|
let a = (e = {}) => ({
|
|
64
|
-
...
|
|
64
|
+
...Me(t),
|
|
65
65
|
...e
|
|
66
|
-
}), o = (t = {}) =>
|
|
66
|
+
}), o = (t = {}) => D(e, a(t));
|
|
67
67
|
return {
|
|
68
68
|
table: e,
|
|
69
69
|
build: o,
|
|
70
|
-
buildList: (e, t) => Array.from({ length: e }, (e, n) => o(
|
|
71
|
-
create: (t, n = {}) =>
|
|
70
|
+
buildList: (e, t) => Array.from({ length: e }, (e, n) => o(k(t, n))),
|
|
71
|
+
create: (t, n = {}) => O(t, e, a(n), r, [e.tableName]),
|
|
72
72
|
createList: async (t, n, i) => {
|
|
73
73
|
let o = [];
|
|
74
|
-
for (let s = 0; s < n; s++) o.push(await
|
|
74
|
+
for (let s = 0; s < n; s++) o.push(await O(t, e, a(k(i, s)), r, [e.tableName]));
|
|
75
75
|
return o;
|
|
76
76
|
},
|
|
77
77
|
with: (...r) => {
|
|
@@ -93,19 +93,19 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
93
93
|
};
|
|
94
94
|
};
|
|
95
95
|
return i(t.defaults ?? {});
|
|
96
|
-
},
|
|
96
|
+
}, A = Symbol.for("voltro/testing/mockClock.installed"), j = () => globalThis, Ie = (e, t) => new Proxy(e, {
|
|
97
97
|
construct: (e, n, r) => Reflect.construct(e, n.length === 0 ? [t()] : n, r),
|
|
98
98
|
apply: () => new e(t()).toString(),
|
|
99
99
|
get: (e, n, r) => n === "now" ? () => t() : Reflect.get(e, n, r)
|
|
100
|
-
}),
|
|
100
|
+
}), M = (e) => {
|
|
101
101
|
if (typeof e == "number") return e;
|
|
102
102
|
let t = e instanceof Date ? e.getTime() : Date.parse(e);
|
|
103
103
|
if (Number.isNaN(t)) throw Error(`mockClock: cannot read an instant from '${String(e)}'`);
|
|
104
104
|
return t;
|
|
105
|
-
},
|
|
105
|
+
}, N = class {
|
|
106
106
|
currentMs;
|
|
107
107
|
constructor(e = "2026-01-01T00:00:00Z") {
|
|
108
|
-
this.currentMs =
|
|
108
|
+
this.currentMs = M(e);
|
|
109
109
|
}
|
|
110
110
|
now() {
|
|
111
111
|
return this.currentMs;
|
|
@@ -114,47 +114,47 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
114
114
|
return new Date(this.currentMs);
|
|
115
115
|
}
|
|
116
116
|
advance(e) {
|
|
117
|
-
this.currentMs += typeof e == "number" ? e :
|
|
117
|
+
this.currentMs += typeof e == "number" ? e : Be(e);
|
|
118
118
|
}
|
|
119
119
|
set(e) {
|
|
120
|
-
this.currentMs =
|
|
120
|
+
this.currentMs = M(e);
|
|
121
121
|
}
|
|
122
122
|
get installed() {
|
|
123
|
-
return
|
|
123
|
+
return j()[A]?.clock === this;
|
|
124
124
|
}
|
|
125
125
|
install() {
|
|
126
|
-
let e =
|
|
126
|
+
let e = j(), t = e[A];
|
|
127
127
|
if (t !== void 0) throw Error("mockClock: global time is already faked" + (t.clock === this ? " by this same clock" : " by another MockClock") + ". Uninstall it before installing again — a nested install restores the outer fake on the way out, which leaves the realm frozen with nothing pointing at why. `withFrozenTime` always restores, including on a throw.");
|
|
128
128
|
let n = globalThis.Date;
|
|
129
|
-
return e[
|
|
129
|
+
return e[A] = {
|
|
130
130
|
realDate: n,
|
|
131
131
|
clock: this
|
|
132
|
-
}, globalThis.Date =
|
|
132
|
+
}, globalThis.Date = Ie(n, () => this.currentMs), () => {
|
|
133
133
|
this.uninstall();
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
uninstall() {
|
|
137
|
-
let e =
|
|
138
|
-
t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[
|
|
137
|
+
let e = j(), t = e[A];
|
|
138
|
+
t !== void 0 && t.clock === this && (globalThis.Date = t.realDate, delete e[A]);
|
|
139
139
|
}
|
|
140
|
-
},
|
|
141
|
-
let n = e instanceof
|
|
140
|
+
}, Le = (e) => typeof e == "object" && !!e && typeof e.then == "function", Re = (e, t) => {
|
|
141
|
+
let n = e instanceof N ? e : new N(e);
|
|
142
142
|
n.install();
|
|
143
143
|
let r = !1;
|
|
144
144
|
try {
|
|
145
145
|
let e = t(n);
|
|
146
|
-
return
|
|
146
|
+
return Le(e) ? (r = !0, e.then((e) => (n.uninstall(), e), (e) => {
|
|
147
147
|
throw n.uninstall(), e;
|
|
148
148
|
})) : e;
|
|
149
149
|
} finally {
|
|
150
150
|
r || n.uninstall();
|
|
151
151
|
}
|
|
152
|
-
},
|
|
153
|
-
let t = e instanceof
|
|
152
|
+
}, ze = (e) => u.acquireRelease(u.sync(() => {
|
|
153
|
+
let t = e instanceof N ? e : new N(e);
|
|
154
154
|
return t.install(), t;
|
|
155
155
|
}), (e) => u.sync(() => {
|
|
156
156
|
e.uninstall();
|
|
157
|
-
})),
|
|
157
|
+
})), Be = (e) => {
|
|
158
158
|
let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
|
|
159
159
|
if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
|
|
160
160
|
let n = Number(t[1]), r = t[2];
|
|
@@ -166,7 +166,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
166
166
|
case "d": return n * 864e5;
|
|
167
167
|
default: throw Error(`mockClock: unknown unit '${r}'`);
|
|
168
168
|
}
|
|
169
|
-
},
|
|
169
|
+
}, Ve = class {
|
|
170
170
|
emitted = [];
|
|
171
171
|
async emit(e, t) {
|
|
172
172
|
let n = typeof e == "string" ? e : e.id ?? e.event ?? "(unnamed event)";
|
|
@@ -185,14 +185,14 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
185
185
|
clear() {
|
|
186
186
|
this.emitted.length = 0;
|
|
187
187
|
}
|
|
188
|
-
},
|
|
188
|
+
}, P = (e = {}) => {
|
|
189
189
|
let t = e.tenantId ?? null, n = new x({
|
|
190
190
|
origin: e.origin ?? "test",
|
|
191
191
|
...e.ringSize === void 0 ? {} : { ringSize: e.ringSize }
|
|
192
192
|
});
|
|
193
193
|
return {
|
|
194
194
|
publish: async (e, r, i) => {
|
|
195
|
-
let a = await u.runPromise(u.either(
|
|
195
|
+
let a = await u.runPromise(u.either(me({
|
|
196
196
|
bus: n,
|
|
197
197
|
tenantId: t
|
|
198
198
|
}, e, r, i)));
|
|
@@ -255,7 +255,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
255
255
|
n.clear();
|
|
256
256
|
}
|
|
257
257
|
};
|
|
258
|
-
},
|
|
258
|
+
}, F = class {
|
|
259
259
|
queue;
|
|
260
260
|
calls = [];
|
|
261
261
|
constructor(e) {
|
|
@@ -270,36 +270,38 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
270
270
|
remaining() {
|
|
271
271
|
return this.queue.length;
|
|
272
272
|
}
|
|
273
|
-
},
|
|
274
|
-
let n =
|
|
273
|
+
}, He = (e) => e, I = "00000000000000000000000000000000", L = /* @__PURE__ */ new WeakMap(), R = /* @__PURE__ */ new WeakMap(), Ue = (e, t) => {
|
|
274
|
+
let n = R.get(e);
|
|
275
275
|
if (n === void 0) throw Error("withRpcConnection requires a context created by makeTestContext");
|
|
276
276
|
return n(t);
|
|
277
|
-
},
|
|
278
|
-
|
|
277
|
+
}, z = /* @__PURE__ */ new WeakMap(), B = /* @__PURE__ */ new WeakMap(), V = /* @__PURE__ */ new WeakMap(), H = (e) => V.get(e) ?? [], U = /* @__PURE__ */ new WeakMap(), W = (e, t) => {
|
|
278
|
+
U.set(e, { publicApi: t });
|
|
279
|
+
}, G = (e) => U.get(e), K = /* @__PURE__ */ new WeakMap(), q = (e, t) => {
|
|
280
|
+
let n = V.get(e) ?? [], r = t === "mutation" ? "interceptMutation" : t === "query" ? "interceptQuery" : "interceptAction", i = [];
|
|
279
281
|
for (let e of n) {
|
|
280
282
|
let t = e[r];
|
|
281
283
|
typeof t == "function" && i.push(t);
|
|
282
284
|
}
|
|
283
285
|
return v(i);
|
|
284
|
-
}, q = (e) => {
|
|
285
|
-
let t = (W.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = G.get(e) ?? [], r = [...t, ...n];
|
|
286
|
-
if (r.length !== 0) return f.mergeAll(...r);
|
|
287
286
|
}, J = (e) => {
|
|
288
|
-
let t =
|
|
287
|
+
let t = (V.get(e) ?? []).map((e) => e.services).filter((e) => e !== void 0), n = K.get(e) ?? [], r = [...t, ...n];
|
|
288
|
+
if (r.length !== 0) return f.mergeAll(...r);
|
|
289
|
+
}, Y = (e) => {
|
|
290
|
+
let t = z.get(e);
|
|
289
291
|
t !== void 0 && (t.length = 0);
|
|
290
|
-
},
|
|
291
|
-
let t =
|
|
292
|
+
}, X = async (e) => {
|
|
293
|
+
let t = z.get(e);
|
|
292
294
|
if (t === void 0 || t.length === 0) return;
|
|
293
295
|
let n = [...t];
|
|
294
296
|
t.length = 0;
|
|
295
297
|
for (let e of n) await e();
|
|
296
|
-
},
|
|
297
|
-
let n =
|
|
298
|
+
}, We = (e) => B.get(e) ?? [], Z = async (e, t) => {
|
|
299
|
+
let n = L.get(e);
|
|
298
300
|
return n === void 0 ? e.store.transactional(async (n) => t({
|
|
299
301
|
...e,
|
|
300
302
|
store: n
|
|
301
303
|
})) : n(t);
|
|
302
|
-
},
|
|
304
|
+
}, Ge = (e) => {
|
|
303
305
|
let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
|
|
304
306
|
let r = t.get(n);
|
|
305
307
|
return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
|
|
@@ -338,7 +340,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
338
340
|
return i(e, o, n.ttlMs, n.tags), o;
|
|
339
341
|
}
|
|
340
342
|
};
|
|
341
|
-
},
|
|
343
|
+
}, Ke = (e) => {
|
|
342
344
|
let t = /* @__PURE__ */ new Map(), n = (n) => {
|
|
343
345
|
let r = t.get(n);
|
|
344
346
|
return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
|
|
@@ -366,45 +368,45 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
366
368
|
t.clear();
|
|
367
369
|
}
|
|
368
370
|
};
|
|
369
|
-
},
|
|
371
|
+
}, qe = async (e, t) => {
|
|
370
372
|
let n = ae();
|
|
371
|
-
|
|
373
|
+
w(e);
|
|
372
374
|
try {
|
|
373
375
|
return await t();
|
|
374
376
|
} finally {
|
|
375
|
-
n === void 0 ? ie() :
|
|
377
|
+
n === void 0 ? ie() : w(n);
|
|
376
378
|
}
|
|
377
|
-
},
|
|
378
|
-
|
|
379
|
-
},
|
|
380
|
-
let r =
|
|
379
|
+
}, Q = /* @__PURE__ */ new Set(), Je = (e) => {
|
|
380
|
+
Q.has(e) || (Q.add(e), console.warn(e));
|
|
381
|
+
}, Ye = (e, t, n) => {
|
|
382
|
+
let r = Xe, i;
|
|
381
383
|
return () => {
|
|
382
384
|
let a = t ?? C();
|
|
383
|
-
return t !== void 0 && C() !== void 0 && t !== C() &&
|
|
385
|
+
return t !== void 0 && C() !== void 0 && t !== C() && Je("[voltro:testing] this test passes `rowFilter:` while a DIFFERENT filter is registered via `setRowFilter`. The option wins, so this test does not exercise your app's registration. Drop `rowFilter:` to run the real process-global one."), (i === void 0 || a !== r) && (r = a, i = qe(n, () => ge(he(a, e, (e) => {
|
|
384
386
|
console.error("[voltro:testing] row filter failed to load — reads refused for this subject", e);
|
|
385
387
|
})))), i;
|
|
386
388
|
};
|
|
387
|
-
},
|
|
389
|
+
}, Xe = Symbol("unresolved"), Ze = (e, t) => new Proxy(e, { get: (e, n) => {
|
|
388
390
|
if (n === "query") return async (n) => e.query(re(await t(), n));
|
|
389
391
|
let r = Reflect.get(e, n, e);
|
|
390
392
|
return typeof r == "function" ? r.bind(e) : r;
|
|
391
|
-
} }),
|
|
392
|
-
if (
|
|
393
|
+
} }), Qe = (e = {}) => {
|
|
394
|
+
if (be({
|
|
393
395
|
...process.env,
|
|
394
396
|
...e.env ?? {}
|
|
395
|
-
}), e.relations !== void 0) {
|
|
397
|
+
}), e.fieldEncryptionKey !== void 0 && ve(de(e.fieldEncryptionKey)), e.relations !== void 0) {
|
|
396
398
|
o();
|
|
397
399
|
for (let t of e.relations) c(t);
|
|
398
400
|
}
|
|
399
|
-
let n = e.tables ?? t(), r =
|
|
401
|
+
let n = e.tables ?? t(), r = pe(n), i = new te(e.store ?? {}), a = {
|
|
400
402
|
dataStore: i,
|
|
401
403
|
schemaRegistry: r
|
|
402
404
|
};
|
|
403
|
-
|
|
404
|
-
let s = new
|
|
405
|
+
w(a);
|
|
406
|
+
let s = new N(e.clockStart), l = new Ve(), u = P(), d = new F(e.llmResponses ?? []), f = Ge(() => s.now()), p = Ke(() => s.now()), m = (t, n, o = i, c) => {
|
|
405
407
|
let h = {
|
|
406
408
|
subject: t,
|
|
407
|
-
traceId:
|
|
409
|
+
traceId: I,
|
|
408
410
|
...n === void 0 ? {} : { connection: n }
|
|
409
411
|
}, g = c?.queued ?? [], _ = c?.nudges ?? [], v = ue({
|
|
410
412
|
bus: u.bus,
|
|
@@ -414,7 +416,7 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
414
416
|
e();
|
|
415
417
|
});
|
|
416
418
|
} }
|
|
417
|
-
}), y = ce({ store: o }), b =
|
|
419
|
+
}), y = ce({ store: o }), b = Ye(t, e.rowFilter, a), x = {
|
|
418
420
|
clock: s,
|
|
419
421
|
webhooks: l,
|
|
420
422
|
events: v,
|
|
@@ -429,22 +431,22 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
429
431
|
access: oe(t),
|
|
430
432
|
cache: f,
|
|
431
433
|
kv: p,
|
|
432
|
-
store:
|
|
434
|
+
store: ye(Ze(o, b), {
|
|
433
435
|
subject: t,
|
|
434
436
|
schemaRegistry: r,
|
|
435
437
|
rowFilter: S
|
|
436
438
|
}),
|
|
437
|
-
storeForTenant: (e) =>
|
|
439
|
+
storeForTenant: (e) => ye(Ze(o, b), {
|
|
438
440
|
subject: ee(t, e),
|
|
439
441
|
schemaRegistry: r,
|
|
440
442
|
rowFilter: S
|
|
441
443
|
}),
|
|
442
|
-
outbox:
|
|
444
|
+
outbox: fe({
|
|
443
445
|
store: o,
|
|
444
446
|
subject: t,
|
|
445
|
-
traceId:
|
|
447
|
+
traceId: I,
|
|
446
448
|
nudge: () => {
|
|
447
|
-
_.push(
|
|
449
|
+
_.push(I);
|
|
448
450
|
},
|
|
449
451
|
afterCommit: (e) => {
|
|
450
452
|
g.push(e);
|
|
@@ -475,22 +477,22 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
475
477
|
nudges: _
|
|
476
478
|
})
|
|
477
479
|
};
|
|
478
|
-
return
|
|
480
|
+
return z.set(x, g), R.set(x, (e) => m(t, e, o, {
|
|
479
481
|
queued: g,
|
|
480
482
|
nudges: _
|
|
481
|
-
})),
|
|
483
|
+
})), B.set(x, _), V.set(x, e.plugins ?? []), K.set(x, e.layers ?? []), L.set(x, (e) => o.transactional((r) => e(m(t, n, r, {
|
|
482
484
|
queued: g,
|
|
483
485
|
nudges: _
|
|
484
486
|
})))), x;
|
|
485
487
|
};
|
|
486
488
|
return m(e.subject ?? h(null), e.connection);
|
|
487
|
-
},
|
|
488
|
-
let n =
|
|
489
|
+
}, $e = (e, t) => {
|
|
490
|
+
let n = J(t);
|
|
489
491
|
return (n === void 0 ? e : u.provide(e, n)).pipe(u.provide(le(t.store)), u.provideService(m, t.request.subject));
|
|
490
|
-
},
|
|
492
|
+
}, et = async (e, t, n, r) => {
|
|
491
493
|
let i = e.input, a = await p.decodeUnknownPromise(i)(n), o = e.kind, s = async (e) => {
|
|
492
494
|
let n = t(a, e);
|
|
493
|
-
return u.isEffect(n) ?
|
|
495
|
+
return u.isEffect(n) ? ge($e(n, e)) : n;
|
|
494
496
|
}, c = async () => {
|
|
495
497
|
let t = e.guards;
|
|
496
498
|
if (t !== void 0 && t.length > 0) {
|
|
@@ -498,29 +500,33 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
498
500
|
if (e !== null) throw e;
|
|
499
501
|
}
|
|
500
502
|
if (o !== "mutation") return s(r);
|
|
501
|
-
let n = await
|
|
502
|
-
return await
|
|
503
|
-
}, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 :
|
|
503
|
+
let n = await _e(async () => Z(r, async (e) => (Y(r), s(e))), { delay: () => Promise.resolve() });
|
|
504
|
+
return await X(r), n;
|
|
505
|
+
}, f = o === "mutation" || o === "query" || o === "action" ? o : void 0, m = f === void 0 ? void 0 : q(r, f);
|
|
504
506
|
if (m === void 0 || f === void 0) return await c();
|
|
505
|
-
let h =
|
|
507
|
+
let h = u.tryPromise({
|
|
506
508
|
try: () => c(),
|
|
507
509
|
catch: (e) => e
|
|
508
|
-
}), {
|
|
510
|
+
}), _ = G(r), v = m(h, {
|
|
509
511
|
tag: e.name ?? "",
|
|
510
512
|
kind: f,
|
|
511
513
|
input: a,
|
|
512
514
|
subject: r.request.subject,
|
|
513
515
|
traceId: r.request.traceId,
|
|
514
|
-
...r.request.spanId === void 0 ? {} : { spanId: r.request.spanId }
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
516
|
+
...r.request.spanId === void 0 ? {} : { spanId: r.request.spanId },
|
|
517
|
+
..._ === void 0 ? {} : {
|
|
518
|
+
surface: "rest",
|
|
519
|
+
publicApi: _.publicApi
|
|
520
|
+
}
|
|
521
|
+
}), y = await u.runPromiseExit(v);
|
|
522
|
+
if (d.isSuccess(y)) return y.value;
|
|
523
|
+
throw l.squash(y.cause);
|
|
524
|
+
}, tt = (e) => new Promise((t) => {
|
|
525
|
+
let n = new xe.Socket(), r = (e) => {
|
|
520
526
|
clearTimeout(i), n.destroy(), t(e);
|
|
521
527
|
}, i = setTimeout(() => r(!1), e.timeoutMs ?? 750);
|
|
522
528
|
n.once("connect", () => r(!0)), n.once("error", () => r(!1)), n.connect(e.port, e.host);
|
|
523
|
-
}),
|
|
529
|
+
}), nt = async (e, t, n, r) => {
|
|
524
530
|
if (typeof t != "string" || typeof n != "function" || typeof r != "function") throw TypeError(`describeIfAvailable('${e}', ...): expected (label, dependency, probe, suite) — got dependency: ${typeof t}, probe: ${typeof n}, suite: ${typeof r}. Three arguments means the suite body is being used as the probe: it would run at file scope, unconditionally, and the skip would silently do nothing.`);
|
|
525
531
|
let i = !1;
|
|
526
532
|
try {
|
|
@@ -528,21 +534,21 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
528
534
|
} catch {
|
|
529
535
|
i = !1;
|
|
530
536
|
}
|
|
531
|
-
|
|
532
|
-
},
|
|
533
|
-
await
|
|
534
|
-
},
|
|
537
|
+
Se.skipIf(!i)(i ? e : `${e} [SKIPPED: needs ${t}]`, r);
|
|
538
|
+
}, rt = async (e, t, n) => {
|
|
539
|
+
await nt(e, `${t.name} at ${t.host}:${t.port}`, () => tt(t), n);
|
|
540
|
+
}, it = async (e, t) => {
|
|
535
541
|
try {
|
|
536
542
|
let n = await t();
|
|
537
543
|
n !== void 0 && n !== "" && console.log(`[engine] ${e}: ${n}`);
|
|
538
544
|
} catch {}
|
|
539
|
-
},
|
|
545
|
+
}, at = "test-tenant", $ = (e, t, n) => ({
|
|
540
546
|
type: e,
|
|
541
547
|
id: t,
|
|
542
548
|
tenantId: n.tenantId ?? "test-tenant",
|
|
543
549
|
scopes: n.scopes ?? [],
|
|
544
550
|
...n.metadata === void 0 ? {} : { metadata: n.metadata }
|
|
545
|
-
}),
|
|
551
|
+
}), ot = (e, t = {}) => $("user", e, t), st = (e, t = {}) => $("apiKey", e, t), ct = (e, t = {}) => $("serviceAccount", e, t), lt = (e = null) => h(e), ut = (e = "job:test", t) => t === void 0 ? b(e) : b(e, t), dt = (e) => {
|
|
546
552
|
let t = e.indexOf("?");
|
|
547
553
|
return t === -1 ? {
|
|
548
554
|
path: e,
|
|
@@ -551,18 +557,21 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
551
557
|
path: e.slice(0, t),
|
|
552
558
|
query: e.slice(t + 1)
|
|
553
559
|
};
|
|
554
|
-
},
|
|
560
|
+
}, ft = (e, t) => {
|
|
555
561
|
let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0);
|
|
556
562
|
return n.length === r.length && n.every((e, t) => e.startsWith(":") || e === r[t]);
|
|
557
|
-
},
|
|
563
|
+
}, pt = (e) => {
|
|
558
564
|
let t = {};
|
|
559
565
|
for (let [n, r] of Object.entries(e)) t[n.toLowerCase()] = r;
|
|
560
566
|
return t;
|
|
561
|
-
},
|
|
562
|
-
let { ctx: t } = e, n =
|
|
567
|
+
}, mt = (e) => e === void 0 ? /* @__PURE__ */ new Uint8Array() : new TextEncoder().encode(JSON.stringify(e)), ht = (e) => e === void 0 ? "" : typeof e == "string" ? e : new TextDecoder().decode(e), gt = (e) => {
|
|
568
|
+
let { ctx: t } = e, n = Ce((e.publicApi ?? []).map((e) => ({
|
|
563
569
|
descriptor: e.descriptor,
|
|
564
|
-
invoke: (n, r) => t.withSubject(r.subject, (t) =>
|
|
565
|
-
}))
|
|
570
|
+
invoke: (n, r) => t.withSubject(r.subject, (t) => (e.descriptor.publicApi !== void 0 && W(t, e.descriptor.publicApi), et(e.descriptor, e.handler, n, t)))
|
|
571
|
+
})), {
|
|
572
|
+
pluginErrors: H(t).flatMap((e) => e.errorSchemas ?? []),
|
|
573
|
+
...e.publicApiProfile === void 0 ? {} : { profile: e.publicApiProfile }
|
|
574
|
+
}), r = [...e.restRoutes ?? [], ...n], i = _(e.strategies ?? [], {
|
|
566
575
|
...e.anonymousTenantRequired === void 0 ? {} : { anonymousTenantRequired: e.anonymousTenantRequired },
|
|
567
576
|
getStore: () => t.store
|
|
568
577
|
}), a = (n, o) => {
|
|
@@ -574,11 +583,11 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
574
583
|
clientId: -1
|
|
575
584
|
})).subject : () => n,
|
|
576
585
|
...e.idempotency === void 0 ? {} : { idempotency: e.idempotency }
|
|
577
|
-
}, c =
|
|
586
|
+
}, c = Te(r, s).map((e, t) => ({
|
|
578
587
|
pattern: r[t].path,
|
|
579
588
|
route: e
|
|
580
589
|
})), l = async (e, n, i = {}) => {
|
|
581
|
-
let { path: a, query: s } =
|
|
590
|
+
let { path: a, query: s } = dt(n), l = c.filter((e) => ft(e.pattern, a)).map((e) => e.route);
|
|
582
591
|
if (l.length === 0) return {
|
|
583
592
|
status: 404,
|
|
584
593
|
headers: {},
|
|
@@ -594,15 +603,15 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
594
603
|
path: a,
|
|
595
604
|
query: s,
|
|
596
605
|
headers: {
|
|
597
|
-
|
|
598
|
-
|
|
606
|
+
...pt(o),
|
|
607
|
+
...pt(i.headers ?? {})
|
|
599
608
|
},
|
|
600
|
-
rawBody:
|
|
609
|
+
rawBody: mt(i.body),
|
|
601
610
|
store: t.store
|
|
602
|
-
}, d = await
|
|
611
|
+
}, d = await we(l, u), f = {};
|
|
603
612
|
for (let [e, t] of Object.entries(d.headers ?? {})) f[e.toLowerCase()] = t;
|
|
604
613
|
d.contentType !== void 0 && (f["content-type"] = d.contentType);
|
|
605
|
-
let p =
|
|
614
|
+
let p = ht(d.body), m = (d.contentType ?? "").includes("json");
|
|
606
615
|
return {
|
|
607
616
|
status: d.status,
|
|
608
617
|
headers: f,
|
|
@@ -636,17 +645,17 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
636
645
|
};
|
|
637
646
|
};
|
|
638
647
|
return a(void 0, {});
|
|
639
|
-
},
|
|
648
|
+
}, _t = (e = {}) => {
|
|
640
649
|
let t = e.id ?? "test", n = e.store ?? new Proxy({}, { get: (e, t) => {
|
|
641
650
|
throw Error(`makeSubscribeContext: this subscriber reached ctx.store.${String(t)} and the test passed no store. Pass \`{ store: makeTestContext().store }\` so the subscriber and the code under test share one — an empty stand-in would let a read of the wrong table pass.`);
|
|
642
651
|
} });
|
|
643
652
|
return {
|
|
644
653
|
id: t,
|
|
645
|
-
log: e.log ??
|
|
654
|
+
log: e.log ?? Ee({ scope: `subscribe:${t}` }),
|
|
646
655
|
store: n,
|
|
647
656
|
...e.publish === void 0 ? {} : { publish: e.publish }
|
|
648
657
|
};
|
|
649
|
-
},
|
|
658
|
+
}, vt = (e) => {
|
|
650
659
|
let t = /* @__PURE__ */ new Map();
|
|
651
660
|
for (let n of e) {
|
|
652
661
|
let e = t.get(n.stepName);
|
|
@@ -672,13 +681,13 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
672
681
|
});
|
|
673
682
|
}
|
|
674
683
|
return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
|
|
675
|
-
},
|
|
684
|
+
}, yt = (e) => {
|
|
676
685
|
let t = e.workflows ?? [], n = /* @__PURE__ */ new Map(), r = 0;
|
|
677
686
|
return {
|
|
678
687
|
start: async (i, a) => {
|
|
679
688
|
let o = t.find((e) => e.workflow.name === i);
|
|
680
689
|
if (o === void 0) throw Error(`makeWorkflowRunner: no workflow named '${i}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
|
|
681
|
-
let s = `wfrun_test_${++r}`, c =
|
|
690
|
+
let s = `wfrun_test_${++r}`, c = ke(), l = o.workflow.toLayer(o.execute).pipe(f.provideMerge(Oe)), d = /* @__PURE__ */ new Date(), p = o.workflow.execute(a).pipe(u.locally(De, s), u.provide(c.layer), u.provide(l), u.either), m = await u.runPromise(p), h = vt(c.readSteps()), g = /* @__PURE__ */ new Date(), _;
|
|
682
691
|
if (m._tag === "Right") _ = {
|
|
683
692
|
status: "succeeded",
|
|
684
693
|
output: m.right,
|
|
@@ -718,6 +727,6 @@ var Ee = new Set(e), D = 0, O = () => ++D, De = (e, t) => {
|
|
|
718
727
|
},
|
|
719
728
|
inspect: async (e) => n.get(e) ?? null
|
|
720
729
|
};
|
|
721
|
-
},
|
|
730
|
+
}, bt = 1;
|
|
722
731
|
//#endregion
|
|
723
|
-
export {
|
|
732
|
+
export { N as MockClock, F as MockLLM, Ve as MockWebhooks, bt as TESTING_PRESET_VERSION, at as TEST_TENANT_ID, lt as anonymous, st as apiKey, n as changeDelete, r as changeInsert, i as changeSoftDelete, a as changeUpdate, Fe as defineFactory, nt as describeIfAvailable, rt as describeIfReachable, D as fixtureRow, ze as frozenTime, et as invoke, tt as isTcpReachable, _t as makeSubscribeContext, gt as makeTestApp, Qe as makeTestContext, yt as makeWorkflowRunner, W as markRestInvocation, He as mockStore, E as nextSequence, We as outboxNudgesOf, H as pluginsOf, it as reportEngineVersion, Y as resetAfterCommit, G as restInvocationOf, q as rpcInterceptorFor, X as runAfterCommit, Z as runInStoreTransaction, ct as serviceAccount, J as serviceLayerFor, ut as system, P as testEventBus, ot as user, Re as withFrozenTime, Ue as withRpcConnection };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "Test utilities for Voltro apps — deterministic clock, subject and row factories, a handler-level invoke and a request-level app harness, queued LLM responses, scoped subject/tenant runners, and a cross-dialect parity harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -43,16 +43,16 @@
|
|
|
43
43
|
"node": ">=24.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@voltro/database": "0.
|
|
47
|
-
"@voltro/env": "0.
|
|
48
|
-
"@voltro/logger": "0.
|
|
49
|
-
"@voltro/protocol": "0.
|
|
50
|
-
"@voltro/runtime": "0.
|
|
51
|
-
"@voltro/workflow": "0.
|
|
46
|
+
"@voltro/database": "0.70.0",
|
|
47
|
+
"@voltro/env": "0.70.0",
|
|
48
|
+
"@voltro/logger": "0.70.0",
|
|
49
|
+
"@voltro/protocol": "0.70.0",
|
|
50
|
+
"@voltro/runtime": "0.70.0",
|
|
51
|
+
"@voltro/workflow": "0.70.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@effect/sql": "^0.52.1",
|
|
55
|
-
"@voltro/client": "0.
|
|
55
|
+
"@voltro/client": "0.70.0",
|
|
56
56
|
"effect": "^3.22.1",
|
|
57
57
|
"react": "^19.0.0"
|
|
58
58
|
},
|