@voltro/protocol 0.69.0 → 0.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,94 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.69.1] — 2026-09-11
43
+
44
+ ### Added
45
+
46
+ - **@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.
47
+ - **@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.
48
+
49
+ `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.
50
+
51
+ 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.
52
+
53
+ `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.
54
+ - **@voltro/client, @voltro/web** — The client diagnostics snapshot carries `runtime` — per api, what the runtime resolved to (`scheduler`, `generation`) — outside the event ring.
55
+
56
+ `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.
57
+ - **@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.
58
+ - **@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.
59
+
60
+ 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.
61
+ - **@voltro/runtime, @voltro/voltro** — `EffectStoreOps.upsertWithOutcome` — the Effect form of `upsertRowOutcome`, on `yield* EffectStore` and on the `tx` view `transactional` hands out.
62
+
63
+ `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.
64
+
65
+ ### Changed
66
+
67
+ - **@voltro/cli** — `voltro test` exits 1 when it executed no test, on every invocation — a bare `voltro test` included.
68
+
69
+ 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.
70
+
71
+ vitest's own `--passWithNoTests` is honoured verbatim when a green run over nothing is what you want; it used to be overridden.
72
+ - **@voltro/runtime, @voltro/voltro, @voltro/cli** — `ApiKeyStore.ensureActor` receives the `issue()` input, and the built-in `apiKeys:` wiring takes an `ensureActor` of your own.
73
+
74
+ 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.
75
+
76
+ 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.
77
+
78
+ 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.
79
+
80
+ 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.
81
+ - **@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`.
82
+ - **@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.
83
+ - **@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.
84
+
85
+ `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.
86
+
87
+ 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.
88
+
89
+ **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.
90
+
91
+ `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.
92
+ - **@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.
93
+ - **@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.
94
+
95
+ ### Fixed
96
+
97
+ - **@voltro/cli** — `voltro agents-md` finds the project's `@voltro/cli` under the app it was run from, not only at the workspace root.
98
+
99
+ 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.
100
+ - **@voltro/cli** — `voltro update` prints the backfill for API keys issued before 0.69.0 to the apps that have them.
101
+
102
+ 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`.
103
+ - **@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.
104
+
105
+ 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.
106
+ - **@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.
107
+
108
+ 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.
109
+
110
+ 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.
111
+ - **@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.
112
+
113
+ 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.
114
+ - **@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.
115
+ - **@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.
116
+ - **@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.
117
+
118
+ 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.
119
+ - **@voltro/cli** — `voltro update` puts every `package.json` it bumped back, byte for byte, when the install fails without ever fetching the target version.
120
+
121
+ 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".
122
+
123
+ 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.
124
+
125
+ Nothing here reaches `--no-install`, which writes the bump on purpose and says what is left.
126
+ - **@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.
127
+
128
+ ---
129
+
42
130
  ## [0.69.0] — 2026-09-10
43
131
 
44
132
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -4099,6 +4099,17 @@ export declare interface PublicApiSpec {
4099
4099
  /** Streaming queries over request/response: 'snapshot' (default — first
4100
4100
  * snapshot, like POST /rpc) | 'sse' (Server-Sent Events of snapshot+deltas). */
4101
4101
  readonly stream?: 'snapshot' | 'sse';
4102
+ /**
4103
+ * HTTP status per DECLARED typed error tag, for the projection. A typed
4104
+ * error the handler fails with — one of the descriptor's `error:` union —
4105
+ * answers with its tag and fields as the body, and this status; a tag not
4106
+ * listed here answers `422`, the status the OpenAPI projection documents
4107
+ * for a typed error. The framework's own `ScopeError` is mapped
4108
+ * before this (`403`, or `401` for an anonymous caller) and needs no entry.
4109
+ *
4110
+ * publicApi: { errorStatus: { AccessDeniedError: 403, NotFoundError: 404 } }
4111
+ */
4112
+ readonly errorStatus?: Readonly<Record<string, number>>;
4102
4113
  }
4103
4114
 
4104
4115
  /**
@@ -4393,6 +4404,125 @@ export declare interface ResourceScopeRequest {
4393
4404
  */
4394
4405
  export declare type ResourceScopeResolver = (req: ResourceScopeRequest) => Effect.Effect<boolean, unknown>;
4395
4406
 
4407
+ declare type RestGuard = (ctx: RestRouteContext) => RestGuardRejection | undefined | Promise<RestGuardRejection | undefined>;
4408
+
4409
+ /**
4410
+ * A guard runs after input decode + ctx resolution, before the handler.
4411
+ * Returning a `RestGuardRejection` short-circuits with that status; any
4412
+ * other return (or `undefined`) lets the request proceed.
4413
+ */
4414
+ declare interface RestGuardRejection {
4415
+ readonly status: number;
4416
+ readonly message: string;
4417
+ }
4418
+
4419
+ declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
4420
+
4421
+ /** A schema-encoded REST body with an application-selected HTTP status and
4422
+ * response headers. The marker keeps ordinary domain objects unambiguous.
4423
+ * `output` still validates/encodes `body`; use `Schema.Void` with a 204 when
4424
+ * the response intentionally has no representation. */
4425
+ declare interface RestResponse<O> {
4426
+ readonly __voltroRestResponse: true;
4427
+ readonly status: number;
4428
+ readonly body: O;
4429
+ readonly headers?: Readonly<Record<string, string | ReadonlyArray<string>>>;
4430
+ }
4431
+
4432
+ /**
4433
+ * Per-call context handed to a REST route handler. Resolved by the serve
4434
+ * pipeline: `subject` from the framework's auth resolver, `headers`
4435
+ * lowercased, `store` the framework `DataStore` injected the same way
4436
+ * plugins receive it via `bindDataStore` (kept untyped here so protocol
4437
+ * stays free of the database dep).
4438
+ */
4439
+ declare interface RestRouteContext {
4440
+ readonly subject: Subject;
4441
+ readonly headers: Readonly<Record<string, string>>;
4442
+ /** Request metadata and the byte-exact body as received by the HTTP
4443
+ * transport. JSON routes normally use decoded `input.body`; `rawBody` is
4444
+ * for signature verification, multipart parsing, and other protocols where
4445
+ * parsing first would destroy information the handler must authenticate. */
4446
+ readonly method: RestMethod;
4447
+ readonly path: string;
4448
+ readonly query: string;
4449
+ readonly rawBody: Uint8Array;
4450
+ /** Framework `DataStore`. Untyped in protocol (same discipline as
4451
+ * `PluginHttpRoute` / `bindDataStore`); cast to the concrete store at
4452
+ * the call site. `undefined` only in unit tests that don't inject one. */
4453
+ readonly store: unknown;
4454
+ /** Full framework context for this authenticated request. This keeps custom
4455
+ * REST handlers on the same capability surface as query/mutation/action
4456
+ * handlers without creating a protocol-to-runtime package dependency. */
4457
+ readonly app?: unknown;
4458
+ }
4459
+
4460
+ declare interface RestRouteDescriptor<I, O> {
4461
+ readonly method: RestMethod;
4462
+ /** Absolute path, e.g. `/v1/customers`. Matches that path exactly. */
4463
+ readonly path: string;
4464
+ /** Input schema. Shape is `{ query?, params?, body? }` — the desugar
4465
+ * parses the query string, path params, and JSON body into that shape
4466
+ * before decoding. Omit for routes that take no input. The schema's
4467
+ * decoded `Type` flows to the handler's `input`; its `Encoded` is left
4468
+ * open (`any`) so any `Schema.Struct(...)` is accepted. */
4469
+ readonly input?: Schema.Schema<I, any, never>;
4470
+ /** Output schema. The handler returns the decoded `Type`; the desugar
4471
+ * encodes it to JSON for the `200` response. */
4472
+ readonly output: Schema.Schema<O, any, never>;
4473
+ readonly handler: (input: I, ctx: RestRouteContext) => Promise<O | RestResponse<O>> | O | RestResponse<O>;
4474
+ readonly summary?: string;
4475
+ readonly description?: string;
4476
+ readonly example?: RestRouteExample;
4477
+ /** Replacement hint. Sets a `Deprecation: true` response header. */
4478
+ readonly deprecated?: string;
4479
+ /** ISO date. Sets a `Sunset:` header; past the date the route returns
4480
+ * `410 Gone` with a replacement pointer. */
4481
+ readonly sunset?: string;
4482
+ /**
4483
+ * Opt-in API version. `version: 'v2'` + `path: '/customers'` mounts the route
4484
+ * at `/v2/customers` — the same `/vN/` convention the `publicApi:` projection
4485
+ * has always used (`derivePublicPath`) and the built-in `/v1/api-keys`
4486
+ * surface follows.
4487
+ *
4488
+ * OPT-IN on purpose: a route without `version` keeps its literal `path`
4489
+ * untouched. An automatic prefix would silently move every deployed route —
4490
+ * a second breaking change hiding inside a naming feature.
4491
+ *
4492
+ * Two versions of one resource are TWO descriptors: the old version is
4493
+ * ordinary code — visible, testable, deletable — carrying `deprecated` (the
4494
+ * replacement pointer) and `sunset` (the date it starts answering `410`,
4495
+ * whose body then also names this `version`). There is no transformation
4496
+ * DSL, and the rpc SOCKET is deliberately outside this: the generated client
4497
+ * is versioned with the server it was generated from (a stale browser tab
4498
+ * runs the previous client until reload — that skew window exists and is
4499
+ * documented, it is not solved by URL versioning).
4500
+ */
4501
+ readonly version?: `v${number}`;
4502
+ readonly guards?: ReadonlyArray<RestGuard>;
4503
+ /**
4504
+ * This route STREAMS (Server-Sent Events) rather than resolving one value — its
4505
+ * handler returns `sse(...)`. Declared on the descriptor, not left implicit in
4506
+ * the handler, so anything that INSPECTS routes without running them (the
4507
+ * OpenAPI generator, the inspect manifest) can tell a stream from a buffered
4508
+ * response. A spec that documents a stream as `application/json` is worse than
4509
+ * no spec, because clients are generated from it.
4510
+ */
4511
+ readonly streaming?: boolean;
4512
+ /** Per-route body cap override (bytes) — see PluginHttpRoute.maxBodyBytes. */
4513
+ readonly maxBodyBytes?: number;
4514
+ /** GET only: derive a weak ETag from the encoded response and answer a
4515
+ * matching `If-None-Match` with 304. The tag is content-derived (an md5
4516
+ * of the JSON), so it is correct across content-encodings — the
4517
+ * transport's compression varies the bytes, not the representation. */
4518
+ readonly etag?: boolean;
4519
+ }
4520
+
4521
+ declare interface RestRouteExample {
4522
+ readonly request?: unknown;
4523
+ readonly response?: unknown;
4524
+ }
4525
+
4396
4526
  /** The id of a row, addressed in op paths as `/<id>`. */
4397
4527
  export declare type RowId = string | number;
4398
4528
 
@@ -5991,6 +6121,16 @@ export declare interface VoltroPlugin {
5991
6121
  * SDK here). Returns of `undefined`/empty are no-ops.
5992
6122
  */
5993
6123
  readonly contributeObservability?: (ctx: PluginLifecycleContext) => Promise<ObservabilityContribution | undefined> | ObservabilityContribution | undefined;
6124
+ /**
6125
+ * Told the app's mounted REST surface — hand-authored `restRoutes` AND the
6126
+ * routes projected from `publicApi` annotations — once the boot has built it,
6127
+ * on both boot paths. A documentation plugin reads its API from here instead
6128
+ * of asking the app to import its own descriptors into `app.config.ts`,
6129
+ * which is where an app importing hundreds of descriptor modules before
6130
+ * discovery has hung its own boot. Synchronous: the surface is already
6131
+ * mounted when this runs, so the hook only records.
6132
+ */
6133
+ readonly onRestSurface?: (routes: ReadonlyArray<RestRouteDescriptor<any, any>>) => void;
5994
6134
  }
5995
6135
 
5996
6136
  /**
package/dist/rest.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as AST from 'effect/SchemaAST';
1
2
  import { DataStore } from '@voltro/database';
2
3
  import { Schema } from 'effect';
3
4
 
@@ -104,6 +105,22 @@ export declare const bytes: (stream: ReadableStream<Uint8Array> | (() => Readabl
104
105
  readonly headers?: Readonly<Record<string, string | ReadonlyArray<string>>>;
105
106
  }) => RestByteResponse;
106
107
 
108
+ /**
109
+ * Coerce a decoded query-string object toward the ENCODED shape of `input`.
110
+ * Field by field for a struct; the object is returned unchanged for any other
111
+ * shape (the schema then decides). Unknown keys pass through untouched.
112
+ */
113
+ export declare const coerceQueryObject: (query: unknown, input: Schema.Schema.All) => unknown;
114
+
115
+ /**
116
+ * Coerce ONE query-string value toward what `ast` (an ENCODED shape) expects.
117
+ * Only a value that reads unambiguously as the target is converted; anything
118
+ * else is left for the schema to refuse with its own message. Refinements are
119
+ * looked through to their base; a union tries each arm and takes the first
120
+ * that changed the value.
121
+ */
122
+ export declare const coerceQueryValue: (value: unknown, ast: AST.AST) => unknown;
123
+
107
124
  /**
108
125
  * Project every descriptor carrying a `publicApi` annotation into a REST
109
126
  * route, in stable (path) order. The boot layer feeds these straight into
@@ -123,6 +140,9 @@ export declare const collectPublicApiRoutes: (entries: ReadonlyArray<PublicApiEn
123
140
  */
124
141
  declare type DeclaredAccess<Input = unknown> = ReadonlyArray<AnyGuardSpec<Input> | OpenAccessSpec>;
125
142
 
143
+ /** The `_tag` literals a descriptor's `error:` union declares (empty for none). */
144
+ export declare const declaredErrorTags: (error: Schema.Schema.All | undefined) => ReadonlySet<string>;
145
+
126
146
  /**
127
147
  * Identity helper that fixes the descriptor's types (like `defineAction` /
128
148
  * `definePlugin`). Returns its argument unchanged at runtime; exists for
@@ -629,6 +649,16 @@ declare interface PolicyGuardSpec<Input = unknown> {
629
649
  readonly resource: (input: Input) => string | undefined;
630
650
  }
631
651
 
652
+ /**
653
+ * Translate a handler's failure into the response the projection promised:
654
+ * a DECLARED typed error becomes a `RestRouteError` with the tag, the fields
655
+ * and the status `errorStatus` assigns (`422` by default, the status the
656
+ * OpenAPI projection already documents for a typed error). Anything else is
657
+ * rethrown for the desugar's shared mapping (`ScopeError` → 403/401, a
658
+ * `status`-carrying error → itself, the rest → 500).
659
+ */
660
+ export declare const projectedFailure: (err: unknown, descriptor: PublicApiDescriptor, spec: PublicApiSpec) => unknown;
661
+
632
662
  /** A descriptor kind that can be projected to REST (streams are not, in v1). */
633
663
  export declare type PublicApiDescriptor = QueryProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All> | MutationProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All> | ActionProcedureDescriptor<string, Schema.Schema.Any, Schema.Schema.Any, Schema.Schema.All>;
634
664
 
@@ -650,11 +680,12 @@ export declare type PublicApiInvoke = (input: unknown, ctx: RestRouteContext) =>
650
680
  /**
651
681
  * Synthesize the public REST route for ONE descriptor. The descriptor's input
652
682
  * schema is wrapped into the REST `{ query }` (GET) / `{ body }` (other) shape
653
- * the desugar decodes, then handed UNWRAPPED to `invoke`. Output is the
654
- * descriptor's output schema verbatim (a query is drained to its first snapshot
655
- * by the serve layer — same as POST /rpc — unless `stream: 'sse'` streams it).
656
- * `scopes` become
657
- * `requireScope` guards on top of the handler's own RBAC.
683
+ * the desugar decodes — for GET behind a query-string coercion — then handed
684
+ * UNWRAPPED to `invoke`. Output is the descriptor's output schema verbatim (a
685
+ * query is drained to its first snapshot by the serve layer — same as POST
686
+ * /rpc — unless `stream: 'sse'` streams it). The guards are
687
+ * `requireAuthenticated()` unless the descriptor declares `openAccess`, then
688
+ * one `requireScope` per `scopes` entry, on top of the handler's own RBAC.
658
689
  */
659
690
  export declare const publicApiRoute: (descriptor: PublicApiDescriptor, invoke: PublicApiInvoke, subscribe?: PublicApiSubscribe) => RestRouteDescriptor<Record<string, unknown>, unknown>;
660
691
 
@@ -683,6 +714,17 @@ declare interface PublicApiSpec {
683
714
  /** Streaming queries over request/response: 'snapshot' (default — first
684
715
  * snapshot, like POST /rpc) | 'sse' (Server-Sent Events of snapshot+deltas). */
685
716
  readonly stream?: 'snapshot' | 'sse';
717
+ /**
718
+ * HTTP status per DECLARED typed error tag, for the projection. A typed
719
+ * error the handler fails with — one of the descriptor's `error:` union —
720
+ * answers with its tag and fields as the body, and this status; a tag not
721
+ * listed here answers `422`, the status the OpenAPI projection documents
722
+ * for a typed error. The framework's own `ScopeError` is mapped
723
+ * before this (`403`, or `401` for an anonymous caller) and needs no entry.
724
+ *
725
+ * publicApi: { errorStatus: { AccessDeniedError: 403, NotFoundError: 404 } }
726
+ */
727
+ readonly errorStatus?: Readonly<Record<string, number>>;
686
728
  }
687
729
 
688
730
  /**
package/dist/rest.js CHANGED
@@ -1,56 +1,145 @@
1
1
  import { F as e, P as t } from "./scopes-DVtiKKsm.js";
2
2
  import { c as n, l as r, o as i, s as a, t as o } from "./serverErrorBus-BTnTpMqj.js";
3
3
  import { Effect as s, Schema as c } from "effect";
4
+ import * as l from "effect/SchemaAST";
4
5
  //#region src/publicApi.ts
5
- var l = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), u = (e, t) => t.path ?? `/${t.version ?? "v1"}/${e.replace(/\./g, "/")}`, d = (e, t, n) => {
6
+ var u = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), d = (e, t) => t.path ?? `/${t.version ?? "v1"}/${e.replace(/\./g, "/")}`, f = (e) => {
7
+ if (l.isUnion(e)) {
8
+ let t = e.types.filter((e) => !l.isUndefinedKeyword(e));
9
+ if (t.length === 1) return t[0];
10
+ if (t.length !== e.types.length) return l.Union.make(t);
11
+ }
12
+ return e;
13
+ }, p = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i, m = (e, t) => {
14
+ let n = f(t);
15
+ if (l.isRefinement(n) || l.isTransformation(n)) return m(e, n.from);
16
+ if (l.isUnion(n)) {
17
+ for (let t of n.types) {
18
+ let n = m(e, t);
19
+ if (n !== e) return n;
20
+ }
21
+ return e;
22
+ }
23
+ if (l.isTupleType(n)) {
24
+ let t = Array.isArray(e) ? e : e === void 0 ? void 0 : [e];
25
+ if (t === void 0) return e;
26
+ let r = n.rest[0]?.type ?? n.elements[0]?.type;
27
+ return r === void 0 ? t : t.map((e) => m(e, r));
28
+ }
29
+ if (typeof e != "string") return e;
30
+ if (l.isNumberKeyword(n)) return p.test(e.trim()) ? Number(e) : e;
31
+ if (l.isBooleanKeyword(n)) return e === "true" || e !== "false" && e;
32
+ if (l.isLiteral(n)) {
33
+ let t = n.literal;
34
+ return typeof t == "number" && p.test(e.trim()) && Number(e) === t || typeof t == "boolean" && String(t) === e ? t : e;
35
+ }
36
+ return e;
37
+ }, h = (e, t) => {
38
+ if (typeof e != "object" || !e || Array.isArray(e)) return e;
39
+ let n = f(l.encodedAST(t.ast));
40
+ if (!l.isTypeLiteral(n)) return e;
41
+ let r = { ...e };
42
+ for (let e of n.propertySignatures) {
43
+ let t = String(e.name);
44
+ t in r && (r[t] = m(r[t], e.type));
45
+ }
46
+ return r;
47
+ }, g = (e) => {
48
+ let t = /* @__PURE__ */ new Set();
49
+ if (e === void 0) return t;
50
+ let n = (e) => {
51
+ if (l.isUnion(e)) {
52
+ for (let t of e.types) n(t);
53
+ return;
54
+ }
55
+ if (l.isTransformation(e)) {
56
+ n(e.from), n(e.to);
57
+ return;
58
+ }
59
+ if (l.isRefinement(e)) {
60
+ n(e.from);
61
+ return;
62
+ }
63
+ if (l.isTypeLiteral(e)) {
64
+ let n = e.propertySignatures.find((e) => e.name === "_tag");
65
+ n !== void 0 && l.isLiteral(n.type) && typeof n.type.literal == "string" && t.add(n.type.literal);
66
+ }
67
+ };
68
+ return n(e.ast), t;
69
+ }, _ = (e, t, n) => {
70
+ if (n !== void 0) try {
71
+ let r = c.encodeUnknownSync(n)(e);
72
+ if (typeof r == "object" && r) return {
73
+ error: t,
74
+ ...r
75
+ };
76
+ } catch {}
77
+ let r = { error: t };
78
+ for (let [t, n] of Object.entries(e)) t !== "stack" && t !== "_tag" && (r[t] = n);
79
+ return r;
80
+ }, v = (e, t, n) => {
81
+ if (typeof e != "object" || !e) return e;
82
+ let r = e._tag;
83
+ return typeof r != "string" || r === "ScopeError" || !g(t.error).has(r) ? e : new E(n.errorStatus?.[r] ?? 422, _(e, r, t.error));
84
+ }, y = (e, t, n) => {
6
85
  let r = e.publicApi;
7
86
  if (r === void 0) throw Error(`publicApiRoute: descriptor '${e.name}' has no publicApi annotation`);
8
- let i = l(e.kind, r), a = u(e.name, r), o = i === "GET" ? "query" : "body", s = c.Struct({ [o]: e.input }), d = (r.scopes ?? []).map((e) => m(e)), f = r.stream === "sse" && e.kind === "query" && n !== void 0, p = f ? (e, t) => x((r) => n(e[o], t, (e) => {
9
- r(S(String(e._tag ?? "message"), e));
10
- })) : (e, n) => t(e[o], n);
11
- return b({
87
+ let i = u(e.kind, r), a = d(e.name, r), o = i === "GET" ? "query" : "body", s = c.Struct({ [o]: e.input }), l = i === "GET" ? c.transform(c.Struct({ query: c.Unknown }), s, {
88
+ strict: !1,
89
+ decode: (t) => ({ query: h(t.query, e.input) }),
90
+ encode: (e) => e
91
+ }) : s, f = [...e.openAccess === void 0 ? [x()] : [], ...(r.scopes ?? []).map((e) => S(e))], p = r.stream === "sse" && e.kind === "query" && n !== void 0, m = p ? (e, t) => k((r) => n(e[o], t, (e) => {
92
+ r(A(String(e._tag ?? "message"), e));
93
+ })) : async (n, i) => {
94
+ try {
95
+ return await t(n[o], i);
96
+ } catch (t) {
97
+ throw v(t, e, r);
98
+ }
99
+ };
100
+ return O({
12
101
  method: i,
13
102
  path: a,
14
- input: s,
103
+ input: l,
15
104
  output: e.output,
16
- handler: p,
17
- ...f ? { streaming: !0 } : {},
18
- ...d.length > 0 ? { guards: d } : {},
105
+ handler: m,
106
+ ...p ? { streaming: !0 } : {},
107
+ guards: f,
19
108
  ...r.summary === void 0 ? {} : { summary: r.summary },
20
109
  ...r.description === void 0 ? {} : { description: r.description },
21
110
  ...r.deprecated === void 0 ? {} : { deprecated: r.deprecated },
22
111
  ...r.sunset === void 0 ? {} : { sunset: r.sunset }
23
112
  });
24
- }, f = (e) => e.filter((e) => e.descriptor.publicApi !== void 0).map((e) => d(e.descriptor, e.invoke, e.subscribe)).sort((e, t) => e.path.localeCompare(t.path)), p = () => (e) => {
113
+ }, b = (e) => e.filter((e) => e.descriptor.publicApi !== void 0).map((e) => y(e.descriptor, e.invoke, e.subscribe)).sort((e, t) => e.path.localeCompare(t.path)), x = () => (e) => {
25
114
  if (e.subject.type === "anonymous") return {
26
115
  status: 401,
27
116
  message: e.subject.credentialRejected ?? "Authentication required"
28
117
  };
29
- }, m = (e) => (t) => {
118
+ }, S = (e) => (t) => {
30
119
  let n = t.subject.scopes;
31
120
  if (!(n && (n.includes(e) || n.includes("admin:full")))) return {
32
121
  status: 403,
33
122
  message: `Missing required scope: ${e}`
34
123
  };
35
- }, h = (e) => (t) => {
124
+ }, C = (e) => (t) => {
36
125
  let n = t.subject.scopes;
37
126
  if (!(n && (n.includes("admin:full") || e.some((e) => n.includes(e))))) return {
38
127
  status: 403,
39
128
  message: `Missing required scope: one of ${e.join(", ")}`
40
129
  };
41
- }, g = (e, t = {}) => ({
130
+ }, w = (e, t = {}) => ({
42
131
  __voltroRestResponse: !0,
43
132
  status: t.status ?? 200,
44
133
  body: e,
45
134
  ...t.headers === void 0 ? {} : { headers: t.headers }
46
- }), _ = (e) => typeof e == "object" && !!e && e.__voltroRestResponse === !0, v = class extends Error {
135
+ }), T = (e) => typeof e == "object" && !!e && e.__voltroRestResponse === !0, E = class extends Error {
47
136
  status;
48
137
  body;
49
138
  headers;
50
139
  constructor(e, t, n) {
51
140
  super(`REST ${e}`), this.name = "RestRouteError", this.status = e, this.body = t, this.headers = n;
52
141
  }
53
- }, y = (e, t, n) => new v(e, t, n), b = (e) => {
142
+ }, D = (e, t, n) => new E(e, t, n), O = (e) => {
54
143
  if (e.version === void 0) return e;
55
144
  if (!/^v\d+$/.test(e.version)) throw Error(`defineRestRoute(${e.method} ${e.path}): version "${e.version}" must be 'v' followed by digits — 'v1', 'v2', …`);
56
145
  if ((/* @__PURE__ */ RegExp("^/v\\d+(/|$)")).test(e.path)) throw Error(`defineRestRoute(${e.method} ${e.path}): the path already starts with a version segment AND declares version: '${e.version}'. Pick one spelling — either bake the version into the path, or declare version: and keep the path bare ('/customers'). Both would mount '/${e.version}${e.path}', which is never what the author meant.`);
@@ -58,13 +147,13 @@ var l = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), u = (e, t) => t.
58
147
  ...e,
59
148
  path: `/${e.version}${e.path}`
60
149
  };
61
- }, x = (e, t = {}) => ({
150
+ }, k = (e, t = {}) => ({
62
151
  __voltroRestStream: !0,
63
152
  stream: {
64
153
  subscribe: e,
65
154
  ...t.keepAliveMs === void 0 ? {} : { keepAliveMs: t.keepAliveMs }
66
155
  }
67
- }), S = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, C = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, w = (e, t = {}) => ({
156
+ }), A = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, j = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, M = (e, t = {}) => ({
68
157
  __voltroRestBytes: !0,
69
158
  byteStream: {
70
159
  stream: e,
@@ -74,23 +163,26 @@ var l = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), u = (e, t) => t.
74
163
  ...t.contentType === void 0 ? {} : { contentType: t.contentType },
75
164
  ...t.status === void 0 ? {} : { status: t.status },
76
165
  ...t.headers === void 0 ? {} : { headers: t.headers }
77
- }), T = (e) => typeof e == "object" && !!e && e.__voltroRestBytes === !0, E = async (e) => {
166
+ }), N = (e) => typeof e == "object" && !!e && e.__voltroRestBytes === !0, P = async (e) => {
78
167
  let t = await globalThis.crypto.subtle.digest("SHA-1", new TextEncoder().encode(e));
79
168
  return [...new Uint8Array(t)].map((e) => e.toString(16).padStart(2, "0")).join("");
80
- }, D = (e, t, n) => ({
169
+ }, F = (e, t, n) => ({
81
170
  status: e,
82
171
  contentType: "application/json; charset=utf-8",
83
172
  body: JSON.stringify(t),
84
173
  ...n ? { headers: n } : {}
85
- }), O = (e) => {
174
+ }), I = (e) => {
86
175
  if (e.length === 0) return;
87
176
  let t = new TextDecoder().decode(e);
88
177
  if (t.trim() !== "") return JSON.parse(t);
89
- }, k = (e) => {
178
+ }, L = (e) => {
90
179
  let t = {};
91
- for (let [n, r] of new URLSearchParams(e)) t[n] = r;
180
+ for (let [n, r] of new URLSearchParams(e)) {
181
+ let e = t[n];
182
+ e === void 0 ? t[n] = r : Array.isArray(e) ? e.push(r) : t[n] = [e, r];
183
+ }
92
184
  return t;
93
- }, A = (e, t) => {
185
+ }, R = (e, t) => {
94
186
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0), i = {};
95
187
  for (let e = 0; e < n.length; e++) {
96
188
  let t = n[e];
@@ -104,118 +196,141 @@ var l = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), u = (e, t) => t.
104
196
  }
105
197
  }
106
198
  return i;
107
- }, j = (e, t) => ({
108
- query: k(e.query),
199
+ }, z = (e, t) => ({
200
+ query: L(e.query),
109
201
  params: t,
110
- body: O(e.rawBody)
111
- }), M = /* @__PURE__ */ new Set([
202
+ body: I(e.rawBody)
203
+ }), B = /* @__PURE__ */ new Set([
112
204
  "POST",
113
205
  "PUT",
114
206
  "PATCH",
115
207
  "DELETE"
116
- ]), N = (l, u) => {
117
- let d = l.input ? c.decodeUnknown(l.input) : void 0, f = c.encode(l.output), p = {}, m = (e) => e.replace(/\/$/, "").split("/").length, h = m(l.path);
118
- return l.deprecated !== void 0 && (p.Deprecation = "true"), l.sunset !== void 0 && (p.Sunset = l.sunset), {
208
+ ]), V = (e, n, r, i) => {
209
+ if (e instanceof E) return F(e.status, e.body, {
210
+ ...i,
211
+ ...e.headers
212
+ });
213
+ if (typeof e == "object" && e) {
214
+ let r = e;
215
+ if (typeof r.status == "number") return F(r.status, { error: typeof r.message == "string" ? r.message : "Error" }, i);
216
+ if (r._tag === "Unauthenticated" || e instanceof t) return F(401, { error: typeof r.reason == "string" ? r.reason : "Authentication required" }, i);
217
+ if (r._tag === "ScopeError") return F(n.type === "anonymous" ? 401 : 403, { error: typeof r.message == "string" ? r.message : "Forbidden" }, i);
218
+ }
219
+ return o({
220
+ error: e,
221
+ source: "rest",
222
+ name: `${r.method} ${r.path}`,
223
+ fields: { status: 500 }
224
+ }), F(500, { error: "Internal Server Error" }, i);
225
+ }, H = (o, l) => {
226
+ let u = o.input ? c.decodeUnknown(o.input) : void 0, d = c.encode(o.output), f = {}, p = (e) => e.replace(/\/$/, "").split("/").length, m = p(o.path);
227
+ return o.deprecated !== void 0 && (f.Deprecation = "true"), o.sunset !== void 0 && (f.Sunset = o.sunset), {
119
228
  method: "*",
120
- path: l.path,
121
- ...l.maxBodyBytes === void 0 ? {} : { maxBodyBytes: l.maxBodyBytes },
229
+ path: o.path,
230
+ ...o.maxBodyBytes === void 0 ? {} : { maxBodyBytes: o.maxBodyBytes },
122
231
  handle: async (c) => {
123
- if (!l.path.endsWith("/*") && m(c.path) !== h) return D(404, { error: "Not Found" }, p);
124
- let y = c.method.toUpperCase();
125
- if (!(y === l.method || y === "HEAD" && l.method === "GET")) return D(405, {
232
+ if (!o.path.endsWith("/*") && p(c.path) !== m) return F(404, { error: "Not Found" }, f);
233
+ let h = c.method.toUpperCase();
234
+ if (!(h === o.method || h === "HEAD" && o.method === "GET")) return F(405, {
126
235
  error: "Method Not Allowed",
127
- allow: l.method
236
+ allow: o.method
128
237
  }, {
129
- ...p,
130
- Allow: l.method
238
+ ...f,
239
+ Allow: o.method
131
240
  });
132
- if (l.sunset !== void 0) {
133
- let e = Date.parse(l.sunset);
134
- if (!Number.isNaN(e) && Date.now() >= e) return D(410, {
241
+ if (o.sunset !== void 0) {
242
+ let e = Date.parse(o.sunset);
243
+ if (!Number.isNaN(e) && Date.now() >= e) return F(410, {
135
244
  error: "Gone",
136
- ...l.version === void 0 ? {} : { version: l.version },
137
- ...l.deprecated === void 0 ? {} : { replacement: l.deprecated }
138
- }, p);
245
+ ...o.version === void 0 ? {} : { version: o.version },
246
+ ...o.deprecated === void 0 ? {} : { replacement: o.deprecated }
247
+ }, f);
139
248
  }
140
- let b;
141
- if (d) {
142
- let e = j(c, A(l.path, c.path)), t = await s.runPromise(d(e).pipe(s.map((e) => ({
249
+ let g;
250
+ if (u) {
251
+ let e = z(c, R(o.path, c.path)), t = await s.runPromise(u(e).pipe(s.map((e) => ({
143
252
  ok: !0,
144
253
  value: e
145
254
  })), s.catchAll((e) => s.succeed({
146
255
  ok: !1,
147
256
  cause: e
148
257
  }))));
149
- if (!t.ok) return D(400, {
258
+ if (!t.ok) return F(400, {
150
259
  error: "Invalid request",
151
260
  detail: String(t.cause)
152
- }, p);
153
- b = t.value;
261
+ }, f);
262
+ g = t.value;
154
263
  }
155
- let x;
264
+ let _;
156
265
  try {
157
- x = u.resolveSubject ? await u.resolveSubject(c.headers) : e(c.headers["x-tenant"] ?? null);
266
+ _ = l.resolveSubject ? await l.resolveSubject(c.headers) : e(c.headers["x-tenant"] ?? null);
158
267
  } catch (e) {
159
268
  if (e instanceof t || typeof e == "object" && e && e._tag === "Unauthenticated") {
160
269
  let t = e.reason;
161
- return D(401, { error: t ?? "Authentication required" }, p);
270
+ return F(401, { error: t ?? "Authentication required" }, f);
162
271
  }
163
272
  throw e;
164
273
  }
165
- let S = u.buildContext ? await u.buildContext(x, c.headers) : void 0, w = typeof S == "object" && S && "store" in S ? S.store : u.store, O = {
166
- subject: x,
274
+ let v;
275
+ try {
276
+ v = l.buildContext ? await l.buildContext(_, c.headers) : void 0;
277
+ } catch (e) {
278
+ return V(e, _, o, f);
279
+ }
280
+ let y = typeof v == "object" && v && "store" in v ? v.store : l.store, b = {
281
+ subject: _,
167
282
  headers: c.headers,
168
- store: w,
169
- method: y,
283
+ store: y,
284
+ method: h,
170
285
  path: c.path,
171
286
  query: c.query,
172
287
  rawBody: c.rawBody,
173
- ...S === void 0 ? {} : { app: S }
288
+ ...v === void 0 ? {} : { app: v }
174
289
  };
175
- if (l.guards) for (let e of l.guards) {
176
- let t = await e(O);
177
- if (t) return D(t.status, { error: t.message }, p);
290
+ if (o.guards) for (let e of o.guards) {
291
+ let t = await e(b);
292
+ if (t) return F(t.status, { error: t.message }, f);
178
293
  }
179
- let k = u.idempotency, N = k && M.has(l.method) ? c.headers[k.header.toLowerCase()] : void 0, P = k && N ? r(x.tenantId, l.method, l.path) : "";
180
- if (k && N) {
181
- let e = await i(k.store, P, N, k.ttlMs, Date.now());
182
- if (e.kind === "replay") return D(e.response.status, e.response.body, {
183
- ...p,
294
+ let x = l.idempotency, S = x && B.has(o.method) ? c.headers[x.header.toLowerCase()] : void 0, C = x && S ? r(_.tenantId, o.method, o.path) : "";
295
+ if (x && S) {
296
+ let e = await i(x.store, C, S, x.ttlMs, Date.now());
297
+ if (e.kind === "replay") return F(e.response.status, e.response.body, {
298
+ ...f,
184
299
  "Idempotency-Replayed": "true"
185
300
  });
186
- if (e.kind === "conflict") return D(409, { error: "A request with this Idempotency-Key is already being processed" }, p);
301
+ if (e.kind === "conflict") return F(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
187
302
  }
188
303
  try {
189
- let e = await l.handler(b, O);
190
- if (C(e) || T(e)) return k && N && await a(k.store, P, N), T(e) ? {
304
+ let e = await o.handler(g, b);
305
+ if (j(e) || N(e)) return x && S && await a(x.store, C, S), N(e) ? {
191
306
  status: e.status ?? 200,
192
307
  headers: {
193
- ...p,
308
+ ...f,
194
309
  ...e.headers
195
310
  },
196
311
  byteStream: e.byteStream,
197
312
  ...e.contentType === void 0 ? {} : { contentType: e.contentType }
198
313
  } : {
199
314
  status: 200,
200
- headers: p,
315
+ headers: f,
201
316
  stream: e.stream
202
317
  };
203
- let t = _(e) ? e : g(e), r = await s.runPromise(f(t.body)), i = {
204
- ...p,
318
+ let t = T(e) ? e : w(e), r = await s.runPromise(d(t.body)), i = {
319
+ ...f,
205
320
  ...t.headers
206
321
  };
207
- if (k && N && await n(k.store, P, N, {
322
+ if (x && S && await n(x.store, C, S, {
208
323
  status: t.status,
209
324
  body: r
210
- }, Date.now()), l.etag === !0 && l.method === "GET") {
211
- let e = `W/"${await E(JSON.stringify(r))}"`, n = c.headers["if-none-match"];
325
+ }, Date.now()), o.etag === !0 && o.method === "GET") {
326
+ let e = `W/"${await P(JSON.stringify(r))}"`, n = c.headers["if-none-match"];
212
327
  return n !== void 0 && n.split(",").some((t) => t.trim() === e) ? {
213
328
  status: 304,
214
329
  headers: {
215
- ...p,
330
+ ...f,
216
331
  ETag: e
217
332
  }
218
- } : D(t.status, r, {
333
+ } : F(t.status, r, {
219
334
  ...i,
220
335
  ETag: e
221
336
  });
@@ -223,34 +338,21 @@ var l = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), u = (e, t) => t.
223
338
  return t.status === 204 || t.status === 304 ? {
224
339
  status: t.status,
225
340
  headers: i
226
- } : D(t.status, r, i);
341
+ } : F(t.status, r, i);
227
342
  } catch (e) {
228
- if (k && N && await a(k.store, P, N), e instanceof v) return D(e.status, e.body, {
229
- ...p,
230
- ...e.headers
231
- });
232
- if (typeof e == "object" && e && typeof e.status == "number") {
233
- let t = e;
234
- return D(t.status, { error: t.message ?? "Error" }, p);
235
- }
236
- return o({
237
- error: e,
238
- source: "rest",
239
- name: `${l.method} ${l.path}`,
240
- fields: { status: 500 }
241
- }), D(500, { error: "Internal Server Error" }, p);
343
+ return x && S && await a(x.store, C, S), V(e, _, o, f);
242
344
  }
243
345
  }
244
346
  };
245
- }, P = (e, t = {}) => {
347
+ }, U = (e, t = {}) => {
246
348
  if (t.idempotency !== void 0) {
247
- for (let t of e) if (t.streaming === !0 && M.has(t.method)) throw Error(`REST route ${t.method} ${t.path} declares \`streaming: true\` on a method the idempotency binding claims (${[...M].join("/")}). A stream cannot complete an idempotency claim — there is no replayable body to cache — so a retried request would 409 until the claim's TTL. Either serve the stream on GET, or scope the idempotency binding away from this app's streaming routes.`);
349
+ for (let t of e) if (t.streaming === !0 && B.has(t.method)) throw Error(`REST route ${t.method} ${t.path} declares \`streaming: true\` on a method the idempotency binding claims (${[...B].join("/")}). A stream cannot complete an idempotency claim — there is no replayable body to cache — so a retried request would 409 until the claim's TTL. Either serve the stream on GET, or scope the idempotency binding away from this app's streaming routes.`);
248
350
  }
249
- return e.map((e) => N(e, t));
250
- }, F = async (e, t) => {
351
+ return e.map((e) => H(e, t));
352
+ }, W = async (e, t) => {
251
353
  let n = await e[0].handle(t);
252
354
  for (let r = 1; r < e.length && n.status === 405; r++) n = await e[r].handle(t);
253
355
  return n;
254
356
  };
255
357
  //#endregion
256
- export { v as RestRouteError, w as bytes, f as collectPublicApiRoutes, b as defineRestRoute, l as derivePublicMethod, u as derivePublicPath, F as dispatchSharedPath, T as isRestByteResponse, _ as isRestResponse, C as isRestStreamResponse, A as matchPathParams, d as publicApiRoute, h as requireAnyScope, p as requireAuthenticated, m as requireScope, g as response, y as restError, P as restRoutesToHttpRoutes, x as sse, S as sseFrame };
358
+ export { E as RestRouteError, M as bytes, h as coerceQueryObject, m as coerceQueryValue, b as collectPublicApiRoutes, g as declaredErrorTags, O as defineRestRoute, u as derivePublicMethod, d as derivePublicPath, W as dispatchSharedPath, N as isRestByteResponse, T as isRestResponse, j as isRestStreamResponse, R as matchPathParams, v as projectedFailure, y as publicApiRoute, C as requireAnyScope, x as requireAuthenticated, S as requireScope, w as response, D as restError, U as restRoutesToHttpRoutes, k as sse, A as sseFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.69.0",
3
+ "version": "0.69.1",
4
4
  "description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -59,8 +59,8 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@effect/sql": "^0.52.1",
62
- "@voltro/database": "0.69.0",
63
- "@voltro/logger": "0.69.0",
62
+ "@voltro/database": "0.69.1",
63
+ "@voltro/logger": "0.69.1",
64
64
  "jose": "^6.2.10"
65
65
  },
66
66
  "peerDependencies": {