@voltro/protocol 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,133 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.52.0] — 2026-08-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/plugin-storage, @voltro/plugin-ratelimit** — The HTTP surface is complete — seven gaps closed, each proven against the real listener.
47
+
48
+ **Binary byte streams.** REST handlers return `bytes(stream, { contentType, contentLength?, contentDisposition? })` (lazy thunk form defers opening the source); plugin routes return `byteStream`. Piped, never buffered (a 256 MiB export streams with bounded heap), never compressed. Idempotency × stream is DECIDED: `streaming: true` on an idempotent method with an idempotency binding is a mount error (a stream cannot cache a replayable body — retries would 409 until the TTL); an undeclared stream releases its claim at return. Storage's full-object download rides the generic path now.
49
+
50
+ **Negotiated compression + conditional GET.** brotli/gzip negotiated with a compressible-type allowlist, 1 KiB threshold, `Vary: Accept-Encoding` always on compressible types — on the api's buffered responses AND `voltro start`'s HTML (`http.compression.{enabled,minBytes}`). The BREACH position is structural: `POST /rpc` responses are NEVER compressed. ISR keeps ONE uncompressed entry and compresses per hit. `voltro start` answers `If-None-Match` with 304 (weak md5 tags over the uncompressed body); REST routes can declare `etag: true` (GET, weak SHA-1 content tag).
51
+
52
+ **Raw WebSocket gateways.** `defineWebSocket({ path, auth, onConnection })` in a `*.ws.ts` file — for FOREIGN protocols (a Yjs provider, a device fleet); app realtime stays the subscription protocol. `auth` is required with no default: `'subject'` runs the same chain as rpc/SSR BEFORE the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code 4001); `'public'` is a written-down decision. Every gateway path joins the upgrade origin guard (cross-site WebSocket hijacking → 403). Teardown at construction; plain GET → 426; duplicate paths refuse the boot.
53
+
54
+ **Body caps everywhere.** The 8 MiB cap used to guard only `/rpc`; plugin routes read uncapped and webhooks read uncapped AND UTF-8-round-tripped (corrupting binary bodies — fixed, proven byte-for-byte). `http.maxBodyBytes` (env `VOLTRO_MAX_BODY_BYTES`), per-route overrides on `defineRestRoute` and webhook handlers; a shared path takes its group's widest override. 413 for both request shapes.
55
+
56
+ **Full method unions.** PATCH/HEAD/OPTIONS are first-class on plugin and REST routes; HEAD is admitted wherever GET is (RFC 9110) with the transport dropping the body.
57
+
58
+ **BREAKING — the interceptor chain is fail-closed.** A throwing `onHttpRequest` interceptor is a 500 + a log line now; it used to be swallowed, which let a crashed security gate silently stop guarding. The manual codemod tells interceptor authors where the decision lives: propagate (a gate) or catch-and-degrade-loudly (protection with a dependency) — plugin-ratelimit's httpShield now does the latter, so a Redis outage cannot become a self-inflicted API outage. No app-authored call sites change shape, hence `apiSurface: compatible` — the break is the error POLARITY of one plugin-author hook, carried by the manual note.
59
+
60
+ **Middleware response headers + CSP nonce.** `middleware.ts` can return `responseHeaders` (applied on every render response shape, both boot paths; prerendered static files are the documented proxy-side limit) and `cspNonce` — the framework stamps every script tag of that render (React's own included) while the policy header stays the middleware's. `isr` + `cspNonce` is refused loudly: a cached nonce is a lie the browser enforces.
61
+
62
+ Deliberate limits: no multipart parser on REST/webhook routes (the storage upload routes are the sanctioned file path); static-file response headers belong at the proxy.
63
+ - **@voltro/plugin-row-history, @voltro/cli** — `@voltro/plugin-versioning` is renamed to `@voltro/plugin-row-history` — the name now says what it does.
64
+
65
+ The old name collided head-on with API versioning (versioned REST routes, `/v1` → `/v2`, sunset flow), which `defineRestRoute` now supports as a first-class `version:` field. What this plugin does is row history + time travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions` over `_voltro_row_history`); every comparison table that filed it under API versioning was reading the name, not the feature.
66
+
67
+ Renamed with it: the factory (`versioningPlugin` → `rowHistoryPlugin`), the options type (`VersioningPluginOptions` → `RowHistoryPluginOptions`), and the default instance alias (`versioning` → `row-history` — the inspect endpoint path and boot-log lines; an explicit `alias:` you passed is untouched). The table name (`_voltro_row_history`) and `VOLTRO_ROW_HISTORY_TTL_HOURS` already carried the new name: no data movement, no env change, no migration.
68
+
69
+ The codemod rewrites imports, the factory call sites and the options-type references, and prints the one step it cannot do — swapping the dependency in package.json.
70
+
71
+ ### Added
72
+
73
+ - **@voltro/cli** — `voltro doctor` now reports every declared `@voltro/*` dependency with no import site — the residue a migration off a framework package leaves in `package.json`, where it keeps getting installed, walked by `voltro update`, and read as evidence the package is in use, its breaking-change notes included.
74
+
75
+ Scoped to `@voltro/*` deliberately (third-party packages have too many legitimate no-import shapes), and three states are distinguished and printed: exempt by name with a reason (`@voltro/cli` is the binary, `@voltro/devtools` is mounted by `voltro dev`, `@voltro/sql-*` drivers are loaded from config); not-measurable-yet for `@voltro/client`/`@voltro/web` on a tree where codegen has never run (a missing measurement, not a dead dependency); and unimported — advisory, never fatal. A mention in a comment or an error string does not count as an import, and a commented-out import counts least of all: it is the artefact the rule exists to see past. Also in `voltro doctor --json` under `unimportedDeps`, `null` when no `package.json` could be read.
76
+ - **@voltro/web, @voltro/cli** — Islands now save real bytes — every `interactive: 'islands'` page gets its OWN browser entry.
77
+
78
+ Measured on the reference fixture (pinned in `bundle-budget.json`, 2026-08-25): an islands page's first load is **59.6 KB gz** against **181.9 KB gz** for a fully hydrated page — react + the island runtime + that page's islands, no router, no Effect runtime, no subscription cache. The bundle-budget gate pins a hard <70 KB bound AND the ratio (<50 % of a full page), so a regression that re-couples the entries fails loudly.
79
+
80
+ - **`@voltro/web/islands`** is the new react-only subpath — `island()` and the hydration runtime import only react + react-dom/client. Importing the `@voltro/web` BARREL (or `@voltro/i18n`) anywhere in an island's import graph is now a BUILD error naming file and specifier: an island hydrates provider-less, router hooks and `useT()` throw there anyway, and the barrel would pull the Effect runtime into the slim entry. - **The build finds each page's islands** through its relative import graph (transitively, through components in between) and emits one shell + entry per islands page. `interactive` must be a source LITERAL to select the slim entry — a computed value ships the full entry as before, and the build says so. - **All three paths**: `voltro build` (SSG renders into the per-page shell, main-shell stylesheets folded in), `voltro start` (ssr/isr islands routes serve their shell), `voltro dev` (same mechanism on demand — violations fire in dev, not first in CI). Fixed on the way: a STATIC islands page fell through dev's render gate to the SPA fallback and loaded the full app entry — dev now server-renders it like production. - **Framework islands**: an island importing `@voltro/client` (`useSubscription`, …) is detected — that page's entry boots the rpc client and wraps each island root in `VoltroRuntimeProvider`, so the island receives live data. Presentational pages never pay for the client core. - **`hydrate: 'only'`** (Astro's `client:only`): the server renders an empty placeholder — a browser-only lib touching `window` in render no longer crashes the SSR pass — and the client mounts fresh with `createRoot`. - **Island props are declared lossy where they are**: props cross an HTML attribute as JSON, so a `Date` arrives as an ISO string and `Map`/`Set`/ functions do not arrive at all — dev warns naming the island and prop. - The per-island `manualChunks` rule is gone: with per-page entries as additional rollup inputs it MERGED the shared react modules into the island group (a second-React-instance shape); per-island chunks were also redundant — the entry already scopes to the page's islands, and they were statically preloaded anyway.
81
+
82
+ Limits: an islands page reached via SPA navigation from a full page runs in the already-loaded app bundle (the saving applies to hard loads of the islands page); a page's islands share one entry (hydrate strategies control WHEN each hydrates, not when it downloads).
83
+
84
+ Why the golden churn is compatible: `hydrate: 'only'` widens a union, `hydrateIslandsOnPage` gains an optional options argument, and the subpath is a new export.
85
+ - **@voltro/ui, @voltro/client, @voltro/web, @voltro/cli** — Forms without JavaScript — `<AutoForm>` on a server-rendered page now works with JS disabled, end to end.
86
+
87
+ The form always renders `action="/form/<mutationTag>"` + `method="post"`; with JS alive, `onSubmit` intercepts exactly as before (optimistic rpc path unchanged). The `/form/*` endpoint is mounted by the WEB listener on BOTH boot paths (`voltro dev` and `voltro start`, one shared builder):
88
+
89
+ - **Origin-checked at the door** with the same `classifyRequestOrigin` the api's rpc listener runs — a cross-origin form POST is a 403 before a byte of the body is parsed. (The server-side forward reaches the api as a no-browser-origin request, so the web listener's check is the one that guards this surface.) - **One validation path.** The urlencoded body maps through the schema-driven `formDataToInput` (checkbox present/absent → true/false, `''` on number/date omits the field — never a silent 0 —, repeated keys → arrays, non-numeric strings pass through RAW so the decode fails honestly instead of minting NaN, unknown keys dropped) and validates with the SAME `validateFields` the client-side submit runs — byte-identical field errors. - **PRG on success**: 303 back to the submitting page, or to `<AutoForm redirectTo>` (same-origin relative paths only — anything else is refused, a hidden field must not become an open redirect). Reloading the redirected-to page cannot resubmit. - **422 re-render on validation failure**: the referring page renders in the same response with field errors + submitted values in the SAME error UI (`role="alert"`, aria unchanged), `cache-control: no-store`, bypassing the ISR cache in both directions. An rpc refusal after valid input (guard, server error) renders as a form-level error. The flash also embeds as a JSON script, so a page whose bundle arrives late hydrates to the identical state. - **Valid submits forward server-side over `POST /rpc`** with the request's cookie — auth middleware, guards and the rpc interceptor chain run identically to every other mutation.
90
+
91
+ New `<AutoForm>` props: `formKey` (several forms per page — the 422 re-render re-fills only the submitted one), `redirectTo`, and `action={false}` for purely static deploys where `/form/*` does not exist. Headless: `useFormBinding` gains `flash` + `formError`, `@voltro/web` gains `useFormFlash(formKey)`.
92
+
93
+ Also fixed on the way: a required `Schema.Boolean` field with no default used to block the JS submit as "missing" while its checkbox rendered visibly unchecked — boolean fields now seed `false`, agreeing with what the user sees (and with the no-JS mapping).
94
+
95
+ Deliberate limits: the no-JS error re-render needs an `ssr`/`isr` page (a static page cannot be re-rendered with request state; a minimal error page is the fallback); a purely static deploy has no `/form/*` endpoint (use `action={false}`); file uploads stay JS-only (multipart → 415). On an ssr page pass `schema` explicitly — descriptor resolution is a client-runtime feature and the SSR render would otherwise show no fields.
96
+
97
+ Bundle note: the `serverContext` chunk group is renamed `serverRequest` by the context's move to @voltro/client — identical 190 B gz, re-pinned in `bundle-budget.json` (fresh full measurement 2026-08-25: firstLoad 185,494 B gz, was 185,309 — +185 B from the form-flash read in the request context).
98
+
99
+ Why the golden churn is compatible: every addition is a new export or an optional prop; `FormBinding.formError` is a new member of the hook's RETURN type (nothing in the public API accepts a caller-built `FormBinding`), and `ServerRequestContextValue.formFlash` is optional.
100
+ - **@voltro/runtime, @voltro/cli, @voltro/cms, @voltro/plugin-broadcast** — On-demand ISR revalidation — the third invalidation axis next to time (`revalidate`) and CDC (`cacheInvalidatesOn`). Server code in the api process calls `revalidatePath('/blog/[slug]')` / `revalidateTable('posts')` / `revalidateTag('pricing')` (exported from `@voltro/runtime`; callable from mutations, actions, webhook receivers and REST routes) and the matching ISR cache entries fall on EVERY `voltro start` replica — including on dialects with no CDC at all, which is the case this exists for.
101
+
102
+ Transport is dialect-shaped, either or both: on postgres a `pg_notify` rides the SAME LISTEN connection the CDC invalidator already holds (no broker needed); everywhere else the broadcast broker carries it (`BROADCAST_URL` on both deployments; channel namespaced by `VOLTRO_BROADCAST_NAMESPACE` — deliberately env-derived, because this channel pairs an api with its WEB app and no shared name is derivable). A web process with isr routes and neither transport warns loudly at boot; under `voltro dev` the calls are documented debug-logged no-ops. Tags share ONE mechanism with tables — `cacheInvalidatesOn` accepts free strings, so `revalidateTag` is the same sink under another name.
103
+
104
+ Correctness edges built in: `revalidatePath` against a `static` route is a NAMED error on the web process (never a silent no-op); a purge landing while an SWR refresh or miss fill renders is guarded by a per-key generation counter on BOTH cache backends — the pre-purge page cannot be written back with a full TTL, and a refused write also suppresses the postgres backend's fire-and-forget upsert so no replica resurrects a deleted row. A content type declares `revalidate: { paths, tags }` and `publish()`/`unpublish()` fire them after commit.
105
+
106
+ Proven end-to-end (`scripts/revalidate-e2e.mjs`): 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
107
+
108
+ `apiSurface: compatible` — additive only: the new `@voltro/runtime` revalidation exports, an optional `revalidate` on `ContentTypeSpec`, the optional `onRevalidate`/`revalidateChannel` on the CDC invalidator options, a widened `BroadcastChannelKind`, and `IsrCache.set`'s new optional generation guard (plus `generation()`).
109
+ - **@voltro/protocol, @voltro/plugin-openapi** — `defineRestRoute` takes an opt-in `version:` — versioned REST APIs with a sunset flow.
110
+
111
+ `version: 'v2'` + `path: '/customers'` mounts the route at `/v2/customers`, the same `/vN/` convention the `publicApi:` projection and the built-in `/v1/api-keys` surface already use. The path is normalised ONCE at definition time, so every consumer — the mount, path-param matching, the idempotency scope, tracing spans, the OpenAPI generator — sees the mounted path and none can disagree.
112
+
113
+ Deliberate edges:
114
+
115
+ - **Opt-in, no auto-prefix.** A route without `version:` keeps its literal path untouched — an automatic prefix would silently move every deployed route. A path that already starts with `/vN/` AND declares `version:` is refused loudly at definition (both spellings at once is never what the author meant). - **Two versions are two descriptors.** The old version is ordinary code — visible, testable, deletable — carrying `deprecated:` (the replacement pointer) and `sunset:` (the date it starts answering `410 Gone`; the 410 body now also names which `version` died). - **One OpenAPI document for every version.** The `/vN/` prefix already separates the paths; each versioned operation is grouped under a version tag and carries `x-voltro-api-version` for tooling. No `?version=` filtered spec — a second document shape for information the paths already state. - The rpc socket stays outside URL versioning on purpose: the generated client is versioned with the server it was generated from. A stale browser tab runs the previous client until reload — that skew window exists and is documented, not solved by URLs.
116
+ - **@voltro/web, @voltro/cli** — Opt-in View Transitions for SPA navigations. `router.viewTransitions: true` in a web `app.config.ts` runs every route swap — `<Link>` clicks, `navigate(...)`, back/forward — through `document.startViewTransition`; individual navigations override the default in either direction with `navigate(to, { transition })` / `<Link transition>`.
117
+
118
+ The visual swap is the router's deferred-navigation commit, flushed synchronously inside the transition callback — by that point the target's lazy chunk and loaders have settled, so the flushed tree renders with data in hand. Three deliberate behaviors: `defer()` fields (and an explicit `Pending` skeleton's settled content) resolve AFTER the transition as ordinary updates, never a second animation; navigating while a transition is animating skips the running one (last navigation wins, nothing queues); overlay/dialog state changes never trigger one — a root snapshot would cross-fade the whole viewport for a one-layer change.
119
+
120
+ Fallback is exact: a browser without the API, and any user with `prefers-reduced-motion: reduce`, gets today's untransitioned swap — same timing, nothing to feature-detect. Styling is plain `::view-transition-*` CSS (no animation DSL); cross-document transitions for static/MPA pages are a one-line `@view-transition` CSS opt-in with no framework involvement.
121
+
122
+ Proven in a real chromium (`scripts/browser-view-transitions.mjs`): called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
123
+
124
+ `apiSurface: compatible` — additive only: a new optional `viewTransitions` on `RouterProps`, optional `transition` on `NavigateOptions`/`LinkProps`, and the optional `router` block on the web app config.
125
+ - **@voltro/web, @voltro/cli** — Schema-typed search params — the query-string half of the URL is now part of the type graph.
126
+
127
+ A page declares its contract once:
128
+
129
+ ```ts
130
+ export const searchParams = Schema.Struct({
131
+ q: Schema.optionalWith(Schema.String, { default: () => '' }),
132
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
133
+ })
134
+ ```
135
+
136
+ and gets, end to end:
137
+
138
+ - **Typed reads** — `useSearchParams(searchParams)` returns the decoded, defaulted shape, SSR-aware; the zero-arg call keeps returning the raw `URLSearchParams`. Decoding is TOTAL: an invalid query falls back to the schema's defaults instead of crashing a render; only a schema that cannot even decode `{}` (a required field with no default) throws, naming the fix. - **Typed links** — the generated `routes` builder brands the route's URL with the schema's shape through a TYPE-ONLY page import (zero value edges: code-splitting is untouched, pinned by test), and `withQuery` type-checks against it — a misspelt key or wrong value type is a compile error. The link-side encode is canonical and schema-free (strings/numbers/booleans, arrays as repeated keys); a roundtrip test pins that it produces exactly what the schema's decode accepts. Deliberately ONE generic signature rather than overloads: with overloads, a wrong key would silently fall through to the permissive untyped form and the compile error would never fire. - **Typed writes** — `useSetSearchParams(searchParams)` returns the typed setter: its object form replaces the query (same semantics as the untyped form), and its updater form receives the CURRENT decoded params, so keeping `?filter` across a page flip is one explicit spread — `setParams((p) => ({ ...p, page: p.page + 1 }))` — instead of a hand-rolled merge. - **A fail-closed isr gate** — `renderMode: 'isr'` plus a `searchParams` export is refused at boot: the isr cache is keyed by path (+tenant+locale), not query, so the first variant would be cached for every query — and the gate catches the re-exported spelling (`export { searchParams } from …`, the mirror-route pattern) too, not only the local declaration. - **A doctor rule** — a page that exports the schema but keeps reading the query with zero-arg `useSearchParams()` is flagged with the typed spelling.
139
+
140
+ Deliberate limits: array fields decode a single occurrence as a one-element array (link shape stays stable); `siblingApps` routes stay untyped (their schemas live in a foreign compile graph); `static` pages see the defaults at build time and decode live on the client.
141
+
142
+ Why the golden churn is compatible: `VoltroRouteUrl` gains a type parameter with a DEFAULT (`<TSearch = unknown>`), so every existing bare `VoltroRouteUrl` spelling still compiles, and the new brand member is an OPTIONAL phantom property of type `unknown` — assignability in both directions is unchanged. `withQuery`'s parameter for the untyped case is strictly WIDER than before (adds `boolean` and array values); no call that compiled stops compiling.
143
+
144
+ ### Fixed
145
+
146
+ - **@voltro/cli** — `voltro agents-md`'s copied `agent-docs/` mirror no longer keeps orphaned modules across a re-seed.
147
+
148
+ The copy fallback (projects where `@voltro/cli` isn't resolvable) merged into an existing `agent-docs/` directory: when a module was renamed upstream (`plugins/versioning.md` → `plugins/row-history.md`), a `--force` re-seed brought the new file and left the old one sitting beside it — a stale generated doc teaching a package name that no longer exists, which is exactly the claim≠code drift the guide exists to prevent. The mirror is wholly framework-owned, so a re-seed now replaces it (the generator prunes its own output dir the same way).
149
+ - **@voltro/cli** — `voltro check`'s observed-diff footer now accounts for every declared procedure, and no result line states a verdict without the count it is a verdict about.
150
+
151
+ Two defects, both found by readers doing arithmetic on the output:
152
+
153
+ - The diff computes TWO kinds of blindness — a procedure that never ran, and one that ran with no table access recorded — and the printer named only the first. The printed counts came out short of the total, with no way to tell an unshown category from a defect in `check` itself. The buckets are now derived from the result type, so a third one cannot be added without the label map failing to compile, and a partition that does not close prints as such instead of quietly under-counting. - `no declared/observed mismatches` was a bare verdict sitting under its denominator, and was quotable — and quoted — without it, as a clean bill of health for a surface where almost nothing had been exercised. Every result line now carries its scope (`no declared/observed mismatch among the 12 that ran`), and at zero coverage the section reports the ABSENCE of a comparison (`nothing was compared — a declaration is only checked against a procedure that RAN`) rather than the absence of findings. The two are different facts and only one of them is evidence.
154
+ - **@voltro/cli** — An `isr` render no longer sees the requesting visitor's credentials — in `voltro dev` and `voltro start` alike.
155
+
156
+ An isr page's HTML is cached under tenant+locale and served to every visitor inside the revalidate window, but the render itself ran with the FULL request: loaders received the session cookie, and `ctx.query` was bound to it. A loader that read subject-scoped data on an isr page therefore cached the first visitor's data and served it to everyone — cache poisoning by construction.
157
+
158
+ The render boundary is fail-closed now: before an isr render runs, the cookie jar (except `voltro:locale`), the `authorization` header and every `x-voltro-*` header are stripped, for the loaders, `ctx.query` AND the `useServerRequest()` snapshot. What survives is exactly what the cache key and locale resolution read: `x-tenant`, `accept-language`, and the locale cookie — so a `de` visitor's fill still lands under the `de` key. One shared helper (`isrCredentialStrip.ts`), called by both boot paths, so dev renders isr anonymously exactly as production does — a page can no longer look personalised in dev and silently serve shared HTML in production.
159
+
160
+ Behavioural consequence, on purpose: a subject-reading loader on an isr page now gets the anonymous answer. A page whose loader needs the signed-in subject belongs on `renderMode: 'ssr'`.
161
+ - **@voltro/cli, @voltro/runtime** — **`voltro serve` registers plugin rpc routes again — every plugin-contributed procedure answered `Unknown request tag` in production while `voltro dev` registered all of them.** The serve path mirrored dev's plugin-route block by hand and mirrored exactly half of it: the collision check ran (so nothing warned) and the returned routes were discarded as a bare expression statement, never reaching the buckets the rpc registry is built from. Every plugin route was indistinguishable from a tag that never existed — `useUpload`'s storage tags, presence, every inspect-less plugin rpc — while the app's own procedures answered normally, so the registry looked alive.
162
+
163
+ Three changes, each aimed at the way this stayed invisible:
164
+
165
+ - Both boot paths now call ONE shared builder (`mergePluginRoutesInto`) whose buckets are required parameters — a returned list can be discarded by a statement that typechecks; a function you cannot call without handing it the sinks cannot have its effect dropped. A reachability test drives a real socket with a plugin tag and an invented-tag control, and a source pin keeps the helper pair from being reassembled by hand in either path. - The boot line `plugin routes registered` prints WHENEVER plugins are installed, count included — zero is a finding, and silence is how this shipped. `voltro check` against a running server now also diffs source-declared tags against the live registry (`declared vs live:`), and its offline manifest includes plugin routes, which it previously did not. - Every `Defect` frame the server sends is now also a server log line (`rpc defect sent to client`, ws and http rpc). The defect string used to exist only inside the WebSocket frame — visible in whoever's browser console, invisible to the operator whose server produced it.
166
+
167
+ ---
168
+
42
169
  ## [0.51.0] — 2026-08-24
43
170
 
44
171
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -1623,6 +1623,13 @@ export declare const defineStream: <const Name extends string, Input extends Sch
1623
1623
  readonly openAccess?: string;
1624
1624
  }) => StreamProcedureDescriptor<Name, Input, Element, Error>;
1625
1625
 
1626
+ /**
1627
+ * Declare a raw WebSocket gateway (`*.ws.ts` default export). Validation at
1628
+ * DEFINITION time — a bad path fails the boot that discovers it, not the
1629
+ * first client.
1630
+ */
1631
+ export declare const defineWebSocket: (route: WebSocketGatewayRoute) => WebSocketGatewayRoute;
1632
+
1626
1633
  export declare interface DeleteTarget<Input = unknown> extends NestedTargetFields<Input> {
1627
1634
  readonly table: string;
1628
1635
  readonly op: 'delete';
@@ -2027,6 +2034,11 @@ export declare const finishIdempotent: (store: IdempotencyStore, scope: string,
2027
2034
  /** A route rendered for humans — logs, the inspect surface, the dashboard. */
2028
2035
  export declare const formatEventRoute: (route: string) => string;
2029
2036
 
2037
+ /** Close code a gateway connection receives when its credential expires —
2038
+ * the same session-expiry contract the rpc socket has (SEC-16), spelled as
2039
+ * an application close code so foreign clients can reauth + reconnect. */
2040
+ export declare const GATEWAY_CREDENTIAL_EXPIRED_CLOSE_CODE = 4001;
2041
+
2030
2042
  /** The currently-registered policy-guard resolver, or `undefined`. */
2031
2043
  export declare const getPolicyGuardResolver: () => PolicyGuardResolver | undefined;
2032
2044
 
@@ -2897,12 +2909,19 @@ export declare interface PluginErrorSchema {
2897
2909
 
2898
2910
  /** A public raw-HTTP route a plugin serves on the framework listener. */
2899
2911
  export declare interface PluginHttpRoute {
2900
- /** HTTP method, or `'*'` for any (the handler decides). */
2901
- readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'DELETE';
2912
+ /** HTTP method, or `'*'` for any (the handler decides). PATCH/HEAD/OPTIONS
2913
+ * are first-class the REST desugar used to mount `'*'` partly BECAUSE
2914
+ * this union lacked PATCH; that reason is gone (the `'*'` mount remains
2915
+ * for its other job: one dispatcher per shared path + a precise 405). */
2916
+ readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
2902
2917
  /** Absolute path prefix, e.g. `/_voltro/storage`. Matches the path AND
2903
2918
  * any sub-path (`/_voltro/storage/abc123`). */
2904
2919
  readonly path: string;
2905
2920
  readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
2921
+ /** Per-route body cap override (bytes) — wins over the listener's shared
2922
+ * `maxBodyBytes`. NOTE: routes SHARING a path share one body read, so the
2923
+ * widest override on the path's group applies to the whole group. */
2924
+ readonly maxBodyBytes?: number;
2906
2925
  /**
2907
2926
  * Opt this route's path OUT of the listener's cross-site origin check.
2908
2927
  *
@@ -2941,6 +2960,21 @@ export declare interface PluginHttpRoute {
2941
2960
  readonly originGuard?: 'exempt';
2942
2961
  }
2943
2962
 
2963
+ /**
2964
+ * A binary streaming body — the download/export shape. The serve layer pipes
2965
+ * the Web ReadableStream to the socket without buffering, so a response
2966
+ * larger than the heap is fine; the LAZY thunk form defers opening the
2967
+ * source (a provider connection, a file handle) until the response actually
2968
+ * streams.
2969
+ */
2970
+ export declare interface PluginHttpRouteByteStream {
2971
+ readonly stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
2972
+ /** Declared up front when known — lets the client render progress. */
2973
+ readonly contentLength?: number;
2974
+ /** e.g. `attachment; filename="export.zip"`. */
2975
+ readonly contentDisposition?: string;
2976
+ }
2977
+
2944
2978
  export declare interface PluginHttpRouteRequest {
2945
2979
  readonly method: string;
2946
2980
  /** Path WITHOUT query string. */
@@ -3052,6 +3086,12 @@ export declare interface PluginHttpRouteResult {
3052
3086
  /** Stream the response (SSE) instead of sending `body`. See
3053
3087
  * {@link PluginHttpRouteStream}. */
3054
3088
  readonly stream?: PluginHttpRouteStream;
3089
+ /** Stream a BINARY response (a download, an export) instead of sending
3090
+ * `body` — see {@link PluginHttpRouteByteStream}. Never buffered by the
3091
+ * serve layer; never compressed (flush timing + Content-Length are the
3092
+ * contract). Takes precedence over `body`; do not set both `stream` and
3093
+ * `byteStream`. */
3094
+ readonly byteStream?: PluginHttpRouteByteStream;
3055
3095
  }
3056
3096
 
3057
3097
  /**
@@ -5216,6 +5256,48 @@ export declare interface VoltroPlugin {
5216
5256
  export declare interface VoltroTableNames {
5217
5257
  }
5218
5258
 
5259
+ /** What a gateway's connection handler receives. Transport-agnostic on
5260
+ * purpose — the runtime adapts the platform socket to this. */
5261
+ export declare interface WebSocketGatewayConnection {
5262
+ /** Send a text or binary frame. */
5263
+ readonly send: (data: string | Uint8Array) => void;
5264
+ /** Close the connection (application close codes 4000-4999 are yours). */
5265
+ readonly close: (code?: number, reason?: string) => void;
5266
+ /** Register a message listener (binary-safe; text arrives as bytes). */
5267
+ readonly onMessage: (listener: (data: Uint8Array) => void) => void;
5268
+ /** The authenticated subject — `null` only on an `auth: 'public'` route. */
5269
+ readonly subject: Subject | null;
5270
+ /** Lowercased request headers of the upgrade. */
5271
+ readonly headers: Readonly<Record<string, string>>;
5272
+ /** The mounted path. */
5273
+ readonly path: string;
5274
+ }
5275
+
5276
+ export declare interface WebSocketGatewayRoute {
5277
+ /** Absolute upgrade path (`/gateways/yjs`). Must not collide with the rpc
5278
+ * socket (`/ws` or the configured `transport.wsPath`) or `/rpc`. */
5279
+ readonly path: `/${string}`;
5280
+ /**
5281
+ * REQUIRED, no default: who may connect.
5282
+ * - `'subject'` — the upgrade resolves a Subject through the SAME auth
5283
+ * chain as rpc/SSR (cookie/bearer); an unauthenticated upgrade is a 401
5284
+ * BEFORE any socket exists, and the connection closes with
5285
+ * {@link GATEWAY_CREDENTIAL_EXPIRED_CLOSE_CODE} when the credential
5286
+ * expires.
5287
+ * - `'public'` — deliberately unauthenticated (a device fleet with its own
5288
+ * protocol-level auth). A decision somebody wrote down, not a default.
5289
+ */
5290
+ readonly auth: 'subject' | 'public';
5291
+ /**
5292
+ * Runs once per accepted connection. The returned function is the
5293
+ * connection's TEARDOWN — taken at construction (the `startOutboxRunner`
5294
+ * rule): it runs on client disconnect, on credential expiry, and on
5295
+ * server shutdown, so whatever the handler opened cannot outlive the
5296
+ * socket.
5297
+ */
5298
+ readonly onConnection: (connection: WebSocketGatewayConnection) => void | (() => void) | Promise<void | (() => void)>;
5299
+ }
5300
+
5219
5301
  /**
5220
5302
  * The error union a procedure ACTUALLY puts on the wire — `descriptor.error`
5221
5303
  * plus everything the framework can produce for it before or around the
package/dist/index.js CHANGED
@@ -378,56 +378,56 @@ var We = d.Union(d.String, d.Number), p = d.Record({
378
378
  }), Dt = d.Struct({ _tag: d.Literal("attached") }), Ot = (e) => d.Union(Dt, Tt(e), Et), kt = d.Struct({
379
379
  origin: d.String,
380
380
  n: d.Number
381
- }), At = (e) => d.Struct({
381
+ }), G = (e) => d.Struct({
382
382
  key: e,
383
383
  resume: d.optional(d.Array(kt))
384
- }), jt = (e, t) => {
384
+ }), At = (e, t) => {
385
385
  let n = e.guards !== void 0 && e.guards.some((e) => !l(e)) ? d.Union(c, s) : d.Never, r = t && t.length > 0 ? d.Union(n, ...t) : n;
386
386
  return f.make(e.name, {
387
- payload: b(At(e.key), e.name, U(e.guards)),
387
+ payload: b(G(e.key), e.name, U(e.guards)),
388
388
  success: Ot(e.payload),
389
389
  error: r,
390
390
  stream: !0
391
391
  });
392
- }, Mt = class extends d.TaggedError()("EventPayloadInvalid", {
392
+ }, jt = class extends d.TaggedError()("EventPayloadInvalid", {
393
393
  event: d.String,
394
394
  message: d.String
395
- }) {}, Nt = class extends d.TaggedError()("EventKeyInvalid", {
395
+ }) {}, Mt = class extends d.TaggedError()("EventKeyInvalid", {
396
396
  event: d.String,
397
397
  message: d.String
398
- }) {}, Pt = class extends d.TaggedError()("EventPayloadTooLarge", {
398
+ }) {}, Nt = class extends d.TaggedError()("EventPayloadTooLarge", {
399
399
  event: d.String,
400
400
  bytes: d.Number,
401
401
  limit: d.Number
402
- }) {}, Ft = (e) => {
402
+ }) {}, Pt = (e) => {
403
403
  if (typeof e != "object" || !e) return JSON.stringify(e) ?? "null";
404
404
  let t = Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t));
405
405
  return JSON.stringify(t);
406
- }, It = "\0", Lt = (e, t, n) => [
406
+ }, Ft = "\0", It = (e, t, n) => [
407
407
  e ?? "~",
408
408
  t,
409
- Ft(n)
410
- ].join("\0"), Rt = (e) => {
409
+ Pt(n)
410
+ ].join("\0"), Lt = (e) => {
411
411
  let [t = "~", n = "", r = ""] = e.split("\0");
412
412
  return {
413
413
  tenantId: t === "~" ? null : t,
414
414
  event: n,
415
415
  key: r
416
416
  };
417
- }, zt = (e) => e.split("\0").join(" · "), Bt = (e) => e, Vt = (e) => e, Ht = (e) => {
417
+ }, Rt = (e) => e.split("\0").join(" · "), zt = (e) => e, Bt = (e) => e, Vt = (e) => {
418
418
  let t = e.alias?.trim(), n = e.instance?.trim(), r = t !== void 0 && t !== "" ? t : e.base;
419
419
  return n !== void 0 && n !== "" ? `${r}#${n}` : r;
420
- }, Ut = (e) => {
420
+ }, Ht = (e) => {
421
421
  if (e.length !== 0) return (t, n) => e.reduceRight((e, t) => t(e, n), t);
422
- }, Wt = (e, t) => {
422
+ }, Ut = (e, t) => {
423
423
  let n = He.GenericTag(e);
424
424
  return {
425
425
  Tag: n,
426
426
  Live: Ue.succeed(n, t)
427
427
  };
428
- }, Gt = (e, t, n) => {
428
+ }, Wt = (e, t, n) => {
429
429
  if (!t) return { ok: !0 };
430
- let r = G(n);
430
+ let r = K(n);
431
431
  if (!r) return {
432
432
  ok: !1,
433
433
  reason: `cannot parse runningVersion "${n}"`
@@ -435,12 +435,12 @@ var We = d.Union(d.String, d.Number), p = d.Record({
435
435
  let i = t.trim();
436
436
  if (i === "*" || i === "") return { ok: !0 };
437
437
  let a = i.split(/\s+/).filter((e) => e.length > 0);
438
- for (let i of a) if (!Kt(i, r)) return {
438
+ for (let i of a) if (!Gt(i, r)) return {
439
439
  ok: !1,
440
440
  reason: `plugin "${e}" requires framework ${t}, running ${n}`
441
441
  };
442
442
  return { ok: !0 };
443
- }, G = (e) => {
443
+ }, K = (e) => {
444
444
  let t = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?$/.exec(e.trim());
445
445
  return t ? {
446
446
  major: Number(t[1]),
@@ -448,44 +448,44 @@ var We = d.Union(d.String, d.Number), p = d.Record({
448
448
  patch: Number(t[3]),
449
449
  pre: t[4] ?? ""
450
450
  } : null;
451
- }, K = (e, t) => e.major === t.major ? e.minor === t.minor ? e.patch === t.patch ? e.pre && !t.pre ? -1 : !e.pre && t.pre ? 1 : e.pre && t.pre ? e.pre < t.pre ? -1 : +(e.pre > t.pre) : 0 : e.patch - t.patch : e.minor - t.minor : e.major - t.major, Kt = (e, t) => {
451
+ }, q = (e, t) => e.major === t.major ? e.minor === t.minor ? e.patch === t.patch ? e.pre && !t.pre ? -1 : !e.pre && t.pre ? 1 : e.pre && t.pre ? e.pre < t.pre ? -1 : +(e.pre > t.pre) : 0 : e.patch - t.patch : e.minor - t.minor : e.major - t.major, Gt = (e, t) => {
452
452
  if (e === "*") return !0;
453
453
  if (e.startsWith("^")) {
454
- let n = G(e.slice(1));
455
- return !n || t.major !== n.major ? !1 : K(t, n) >= 0;
454
+ let n = K(e.slice(1));
455
+ return !n || t.major !== n.major ? !1 : q(t, n) >= 0;
456
456
  }
457
457
  if (e.startsWith("~")) {
458
- let n = G(e.slice(1));
459
- return !n || t.major !== n.major || t.minor !== n.minor ? !1 : K(t, n) >= 0;
458
+ let n = K(e.slice(1));
459
+ return !n || t.major !== n.major || t.minor !== n.minor ? !1 : q(t, n) >= 0;
460
460
  }
461
461
  let n = /^(>=|<=|>|<)(.+)$/.exec(e);
462
462
  if (n) {
463
- let e = n[1], r = G(n[2]);
463
+ let e = n[1], r = K(n[2]);
464
464
  if (!r) return !1;
465
- let i = K(t, r);
465
+ let i = q(t, r);
466
466
  if (e === ">=") return i >= 0;
467
467
  if (e === "<=") return i <= 0;
468
468
  if (e === ">") return i > 0;
469
469
  if (e === "<") return i < 0;
470
470
  }
471
- let r = G(e);
472
- return r ? K(t, r) === 0 : !1;
473
- }, q = d.Literal("running", "succeeded", "failed", "cancelled", "suspended"), qt = d.Literal("cancel", "terminate", "abandon"), Jt = d.Struct({
471
+ let r = K(e);
472
+ return r ? q(t, r) === 0 : !1;
473
+ }, J = d.Literal("running", "succeeded", "failed", "cancelled", "suspended"), Kt = d.Literal("cancel", "terminate", "abandon"), qt = d.Struct({
474
474
  mode: d.String,
475
475
  dueAt: d.NullOr(d.Number),
476
476
  retryAfterMs: d.NullOr(d.Number),
477
477
  intentId: d.NullOr(d.String)
478
- }), Yt = d.Struct({
478
+ }), Jt = d.Struct({
479
479
  id: d.String,
480
480
  workflowName: d.String,
481
481
  executionId: d.NullOr(d.String),
482
482
  status: d.Literal("running", "queued", "dropped", "skipped"),
483
- deferral: d.optional(Jt)
484
- }), J = d.Struct({
483
+ deferral: d.optional(qt)
484
+ }), Y = d.Struct({
485
485
  id: d.String,
486
486
  tag: d.String,
487
487
  executionId: d.String,
488
- status: q,
488
+ status: J,
489
489
  payload: d.Unknown,
490
490
  workflowVersion: d.NullOr(d.String),
491
491
  workflowPatches: d.NullOr(d.Unknown),
@@ -500,12 +500,12 @@ var We = d.Union(d.String, d.Number), p = d.Record({
500
500
  durationMs: d.NullOr(d.Number),
501
501
  traceId: d.NullOr(d.String),
502
502
  parentExecutionId: d.NullOr(d.String),
503
- parentClosePolicy: d.NullOr(qt)
504
- }), Xt = d.Struct({
503
+ parentClosePolicy: d.NullOr(Kt)
504
+ }), Yt = d.Struct({
505
505
  tag: d.optional(d.String),
506
- status: d.optional(q),
506
+ status: d.optional(J),
507
507
  limit: d.optional(d.Number)
508
- }), Zt = d.Struct({
508
+ }), Xt = d.Struct({
509
509
  id: d.String,
510
510
  runId: d.String,
511
511
  stepName: d.String,
@@ -520,7 +520,7 @@ var We = d.Union(d.String, d.Number), p = d.Record({
520
520
  startedAt: d.Date,
521
521
  completedAt: d.NullOr(d.Date),
522
522
  durationMs: d.NullOr(d.Number)
523
- }), Qt = d.Struct({
523
+ }), Zt = d.Struct({
524
524
  id: d.String,
525
525
  runId: d.String,
526
526
  eventType: d.String,
@@ -528,7 +528,7 @@ var We = d.Union(d.String, d.Number), p = d.Record({
528
528
  occurredAt: d.Date,
529
529
  stepName: d.NullOr(d.String),
530
530
  attempt: d.NullOr(d.Number)
531
- }), $t = d.Struct({
531
+ }), Qt = d.Struct({
532
532
  id: d.String,
533
533
  name: d.String,
534
534
  payload: d.Unknown,
@@ -536,7 +536,7 @@ var We = d.Union(d.String, d.Number), p = d.Record({
536
536
  subject: d.NullOr(d.Unknown),
537
537
  traceId: d.NullOr(d.String),
538
538
  occurredAt: d.Date
539
- }), en = d.Struct({
539
+ }), $t = d.Struct({
540
540
  id: d.String,
541
541
  eventId: d.String,
542
542
  eventName: d.String,
@@ -549,122 +549,122 @@ var We = d.Union(d.String, d.Number), p = d.Record({
549
549
  errorMessage: d.NullOr(d.String),
550
550
  createdAt: d.Date,
551
551
  completedAt: d.NullOr(d.Date)
552
- }), tn = d.Struct({ id: d.String }), Y = d.Struct({ runId: d.String }), nn = d.Struct({
552
+ }), en = d.Struct({ id: d.String }), X = d.Struct({ runId: d.String }), tn = d.Struct({
553
553
  name: d.optional(d.String),
554
554
  limit: d.optional(d.Number)
555
- }), rn = d.Struct({ eventId: d.String }), X = d.Struct({
555
+ }), nn = d.Struct({ eventId: d.String }), Z = d.Struct({
556
556
  workflowName: d.String,
557
557
  executionId: d.String
558
- }), an = d.Struct({
558
+ }), rn = d.Struct({
559
559
  id: d.String,
560
560
  signalName: d.String,
561
561
  payload: d.optional(d.Unknown)
562
- }), on = d.Struct({
562
+ }), an = d.Struct({
563
563
  id: d.String,
564
564
  updateName: d.String,
565
565
  payload: d.optional(d.Unknown),
566
566
  timeoutMs: d.optional(d.Number)
567
- }), sn = d.Struct({
567
+ }), on = d.Struct({
568
568
  eventId: d.String,
569
569
  updateId: d.String,
570
570
  completedEventId: d.String,
571
571
  result: d.Unknown
572
- }), cn = z({
572
+ }), sn = z({
573
573
  name: "__voltro.workflow.run",
574
574
  source: "_voltro_workflow_runs",
575
- input: tn,
576
- output: d.Array(J)
577
- }), ln = z({
575
+ input: en,
576
+ output: d.Array(Y)
577
+ }), cn = z({
578
578
  name: "__voltro.workflow.runs",
579
579
  source: "_voltro_workflow_runs",
580
- input: Xt,
581
- output: d.Array(J)
582
- }), un = z({
580
+ input: Yt,
581
+ output: d.Array(Y)
582
+ }), ln = z({
583
583
  name: "__voltro.workflow.run.steps",
584
584
  source: "_voltro_workflow_run_steps",
585
- input: Y,
586
- output: d.Array(Zt)
587
- }), dn = z({
585
+ input: X,
586
+ output: d.Array(Xt)
587
+ }), un = z({
588
588
  name: "__voltro.workflow.run.events",
589
589
  source: "_voltro_workflow_run_events",
590
- input: Y,
591
- output: d.Array(Qt)
592
- }), fn = z({
590
+ input: X,
591
+ output: d.Array(Zt)
592
+ }), dn = z({
593
593
  name: "__voltro.workflow.domainEvents",
594
594
  source: "_voltro_workflow_events",
595
- input: nn,
596
- output: d.Array($t)
597
- }), pn = z({
595
+ input: tn,
596
+ output: d.Array(Qt)
597
+ }), fn = z({
598
598
  name: "__voltro.workflow.event.deliveries",
599
599
  source: "_voltro_workflow_event_deliveries",
600
- input: rn,
601
- output: d.Array(en)
602
- }), mn = V({
600
+ input: nn,
601
+ output: d.Array($t)
602
+ }), pn = V({
603
603
  name: "__voltro.workflow.cancel",
604
- input: X,
604
+ input: Z,
605
605
  output: d.Struct({ ok: d.Boolean })
606
- }), hn = V({
606
+ }), mn = V({
607
607
  name: "__voltro.workflow.resume",
608
- input: X,
608
+ input: Z,
609
609
  output: d.Struct({ ok: d.Boolean })
610
- }), gn = V({
610
+ }), hn = V({
611
611
  name: "__voltro.workflow.signal",
612
- input: an,
612
+ input: rn,
613
613
  output: d.Struct({ eventId: d.String })
614
- }), _n = V({
614
+ }), gn = V({
615
615
  name: "__voltro.workflow.update",
616
- input: on,
617
- output: sn
618
- }), vn = "__voltro.undo.log", yn = "__voltro.undo.apply", bn = "__voltro.undo.redo", xn = d.Struct({
616
+ input: an,
617
+ output: on
618
+ }), _n = "__voltro.undo.log", vn = "__voltro.undo.apply", yn = "__voltro.undo.redo", bn = d.Struct({
619
619
  id: d.String,
620
620
  tag: d.String,
621
621
  label: d.NullOr(d.String),
622
622
  undone: d.Boolean,
623
623
  crossesAction: d.Boolean,
624
624
  createdAt: d.String
625
- }), Sn = class extends d.TaggedError()("UndoNotFound", { invocationId: d.String }) {}, Cn = class extends d.TaggedError()("UndoForbidden", { invocationId: d.String }) {}, wn = class extends d.TaggedError()("UndoConflict", {
625
+ }), xn = class extends d.TaggedError()("UndoNotFound", { invocationId: d.String }) {}, Sn = class extends d.TaggedError()("UndoForbidden", { invocationId: d.String }) {}, Cn = class extends d.TaggedError()("UndoConflict", {
626
626
  invocationId: d.String,
627
627
  reason: d.Literal("conflict", "action")
628
- }) {}, Tn = d.Union(Sn, Cn, wn), En = z({
629
- name: vn,
628
+ }) {}, wn = d.Union(xn, Sn, Cn), Tn = z({
629
+ name: _n,
630
630
  source: "_voltro_undo_log",
631
631
  input: d.Struct({ limit: d.optional(d.Number) }),
632
- output: d.Array(xn),
632
+ output: d.Array(bn),
633
633
  openAccess: "subject-scoped by construction: lists only the calling subject's own undoable actions"
634
- }), Dn = B({
635
- name: yn,
634
+ }), En = B({
635
+ name: vn,
636
636
  input: d.Struct({ invocationId: d.String }),
637
637
  output: d.Struct({ ok: d.Boolean }),
638
- error: Tn,
638
+ error: wn,
639
639
  openAccess: "subject-scoped by construction: undo is per-actor — another subject's invocation fails typed with UndoForbidden"
640
- }), On = B({
641
- name: bn,
640
+ }), Dn = B({
641
+ name: yn,
642
642
  input: d.Struct({ invocationId: d.String }),
643
643
  output: d.Struct({ ok: d.Boolean }),
644
- error: Tn,
644
+ error: wn,
645
645
  openAccess: "subject-scoped by construction: redo is per-actor — another subject's invocation fails typed with UndoForbidden"
646
- }), kn = class extends d.TaggedError()("TenantScopeViolation", {
646
+ }), On = class extends d.TaggedError()("TenantScopeViolation", {
647
647
  table: d.String,
648
648
  reason: d.String
649
- }) {}, An = class extends d.TaggedError()("StoreOperationFailed", {
649
+ }) {}, kn = class extends d.TaggedError()("StoreOperationFailed", {
650
650
  operation: d.String,
651
651
  table: d.String,
652
652
  cause: d.String
653
- }) {}, jn = class extends d.TaggedError()("TableValidationFailed", {
653
+ }) {}, An = class extends d.TaggedError()("TableValidationFailed", {
654
654
  table: d.String,
655
655
  summary: d.String,
656
656
  issues: d.Array(d.Struct({
657
657
  path: d.String,
658
658
  message: d.String
659
659
  }))
660
- }) {}, Mn = class extends d.TaggedError()("TenantRowNotFound", {
660
+ }) {}, jn = class extends d.TaggedError()("TenantRowNotFound", {
661
661
  table: d.String,
662
662
  id: d.String,
663
663
  reason: d.String
664
- }) {}, Nn = class extends d.TaggedError()("ServerOnlyColumnWrite", {
664
+ }) {}, Mn = class extends d.TaggedError()("ServerOnlyColumnWrite", {
665
665
  table: d.String,
666
666
  columns: d.Array(d.String)
667
- }) {}, Pn = class extends d.TaggedError()("ConstraintViolation", {
667
+ }) {}, Nn = class extends d.TaggedError()("ConstraintViolation", {
668
668
  kind: d.Literal("foreignKey", "foreignKeyInUse", "unique", "notNull", "check"),
669
669
  table: d.String,
670
670
  operation: d.String,
@@ -681,14 +681,14 @@ var We = d.Union(d.String, d.Number), p = d.Record({
681
681
  case "check": return `check constraint ${t}: the row does not satisfy it`;
682
682
  }
683
683
  }
684
- }, Fn = "__voltro.approvals.pending", Z = "__voltro.approvals.decide", In = z({
685
- name: Fn,
684
+ }, Pn = "__voltro.approvals.pending", Fn = "__voltro.approvals.decide", In = z({
685
+ name: Pn,
686
686
  source: "_voltro_approvals",
687
687
  input: d.Struct({ limit: d.optional(d.Number) }),
688
688
  output: d.Array(M),
689
689
  openAccess: "the answer is scoped to the caller — a row appears only if they requested it or hold its recorded approver scopes, so an anonymous caller sees nothing"
690
690
  }), Ln = B({
691
- name: Z,
691
+ name: Fn,
692
692
  input: d.Struct({
693
693
  approvalId: d.String,
694
694
  decision: d.Literal("approve", "reject"),
@@ -755,6 +755,11 @@ var We = d.Union(d.String, d.Number), p = d.Record({
755
755
  }), Qn = (e) => {
756
756
  let t = e instanceof Date ? e.getTime() : typeof e == "number" ? e : typeof e == "string" ? new Date(e).getTime() : 0;
757
757
  return Number.isNaN(t) ? 0 : t;
758
- }, $n = 1;
758
+ }, $n = 4001, er = /* @__PURE__ */ new Set(["/rpc", "/ws"]), tr = (e) => {
759
+ if (!e.path.startsWith("/")) throw Error(`defineWebSocket: path must be absolute, got '${e.path}'`);
760
+ if (er.has(e.path)) throw Error(`defineWebSocket: '${e.path}' is the framework's own socket surface — mount the gateway elsewhere`);
761
+ if (e.auth !== "subject" && e.auth !== "public") throw Error(`defineWebSocket: auth must be 'subject' or 'public' (explicitly — an unauthenticated socket has to be a decision somebody wrote down), got '${String(e.auth)}'`);
762
+ return e;
763
+ }, nr = 1;
759
764
  //#endregion
760
- export { Oe as ADMIN_SCOPE, Ce as APIKEY_ISSUE_ORG_SCOPE, Ee as APIKEY_ISSUE_OTHER_SCOPE, ve as APIKEY_ISSUE_SELF_SCOPE, Z as APPROVALS_DECIDE_TAG, Fn as APPROVALS_PENDING_TAG, j as ApprovalDecisionErrors, A as ApprovalErrors, E as ApprovalExpired, O as ApprovalForbidden, C as ApprovalNotFound, D as ApprovalNotPending, T as ApprovalRejected, S as ApprovalRequired, w as ApprovalSelfApproval, k as ApprovalUnavailable, Ae as AuthMiddleware, x as BusinessRuleViolation, Rn as CONNECTIONS_LIST_TAG, Vn as CONNECTION_DISCONNECT_TAG, zn as CONNECTION_START_TAG, Bn as CONNECTION_SUBMIT_TOKEN_TAG, qn as ConnectionHandshakeFailed, ce as ConnectionInfo, i as ConnectionInfoMiddleware, Q as ConnectionKind, Kn as ConnectionKindMismatch, Wn as ConnectionNotDeclared, Un as ConnectionState, Hn as ConnectionStatus, Gn as ConnectionSubjectRequired, Pn as ConstraintViolation, r as DEFAULT_SCOPE_CACHE_MAX_ENTRIES, ie as DEFAULT_SCOPE_CACHE_TTL_MS, It as EVENT_ROUTE_SEP, Nt as EventKeyInvalid, Mt as EventPayloadInvalid, Pt as EventPayloadTooLarge, be as IMPERSONATION_METADATA_KEY, St as MAX_EVENT_ENVELOPE_BYTES, $n as PROTOCOL_VERSION, M as PendingApproval, N as REACTIVITY_CHANNEL_PREFIX, c as ScopeError, Nn as ServerOnlyColumnWrite, An as StoreOperationFailed, e as Subject, ye as SubjectIdentity, re as SubjectService, jn as TableValidationFailed, Mn as TenantRowNotFound, kn as TenantScopeViolation, yn as UNDO_APPLY_TAG, vn as UNDO_LOG_TAG, bn as UNDO_REDO_TAG, s as Unauthenticated, wn as UndoConflict, Cn as UndoForbidden, xn as UndoLogEntry, Sn as UndoNotFound, X as WorkflowControlInputSchema, $t as WorkflowDomainEventRowSchema, nn as WorkflowDomainEventsInputSchema, rn as WorkflowEventDeliveriesInputSchema, en as WorkflowEventDeliveryRowSchema, qt as WorkflowParentClosePolicySchema, Qt as WorkflowRunEventRowSchema, Yt as WorkflowRunHandleSchema, tn as WorkflowRunRefSchema, J as WorkflowRunRowSchema, q as WorkflowRunStatusSchema, Zt as WorkflowRunStepRowSchema, Y as WorkflowRunTableRefSchema, Xt as WorkflowRunsInputSchema, an as WorkflowSignalInputSchema, Jt as WorkflowStartDeferralSchema, on as WorkflowUpdateInputSchema, sn as WorkflowUpdateResultSchema, _t as actionToRpc, we as advisoryResourceGuardWarning, ae as anonymousSubject, Ye as applyRowPatch, a as applyScopeDecision, Ln as approvalsDecideDescriptor, In as approvalsPendingQueryDescriptor, te as assertAuthenticated, ze as beginIdempotent, Gt as checkFrameworkCompat, De as checkGuards, me as checkGuardsEffect, ne as composeAuthStrategies, Ut as composeRpcInterceptors, Zn as connectionDisconnectDescriptor, Yn as connectionStartDescriptor, Xn as connectionSubmitTokenDescriptor, Jn as connectionsListQueryDescriptor, nt as declaredReactivityChannelKeys, V as defineAction, wt as defineEvent, B as defineMutation, Bt as definePlugin, Vt as definePluginRoute, Wt as definePluginService, z as defineQuery, dt as defineStream, qe as diffRows, xe as effectiveScopes, Ft as encodeEventKey, yt as errorTag, Dt as eventAttached, Tt as eventEnvelope, Et as eventGap, kt as eventResumePoint, Lt as eventRoute, Ot as eventStreamEvent, At as eventSubscribeInput, jt as eventToRpc, Ie as failIdempotent, ke as findAdvisoryResourceGuards, Pe as finishIdempotent, zt as formatEventRoute, he as getPolicyGuardResolver, ge as getResourceScopeResolver, ut as hasAccessDecision, oe as hasCallbackRoutes, Te as hasEffectiveScope, U as hasEnforcedGuard, Se as hasScope, g as idToPath, Re as idempotencyScope, y as inputLabel, Ct as isEventDescriptor, Je as isIdKeyed, l as isOpenAccess, _e as isPolicyCheck, lt as isPolicyGuard, et as isReactivityChannel, tt as isReactivityChannelKey, Ne as isSystemSubject, st as isWireReachable, t as makeScopeCache, Be as memoryIdempotencyStore, fe as missingAccessDecision, gt as mutationToRpc, xt as normalizeDescriptor, it as normalizeSource, u as openAccessSpec, Rt as parseEventRoute, Ge as pathToId, Ht as pluginInstanceName, ot as publishReactivity, Ve as publishServerError, ht as queryToRpc, ue as rawImpersonationMark, $e as reactivityChannel, Me as requireScope, m as rowPatchOpSchema, h as rowPatchSchema, ee as scopeCacheKey, pe as setEffectiveScopes, je as setPolicyGuardResolver, se as setResourceScopeResolver, rt as sourceKeys, vt as streamToRpc, b as strictInput, le as subjectIdentity, n as subjectScopes, Le as subscribeServerErrors, v as subscriptionEvent, de as systemSubject, o as tenantScopedSubject, bt as toRpc, Qn as tsMs, at as undeclaredChannelKeys, Dn as undoApplyDescriptor, En as undoLogQueryDescriptor, On as undoRedoDescriptor, W as wireErrorUnion, mn as workflowCancelDescriptor, fn as workflowDomainEventsQueryDescriptor, pn as workflowEventDeliveriesQueryDescriptor, hn as workflowResumeDescriptor, dn as workflowRunEventsQueryDescriptor, cn as workflowRunQueryDescriptor, un as workflowRunStepsQueryDescriptor, ln as workflowRunsQueryDescriptor, gn as workflowSignalDescriptor, _n as workflowUpdateDescriptor, Fe as wsMutationIdempotencyScope };
765
+ export { Oe as ADMIN_SCOPE, Ce as APIKEY_ISSUE_ORG_SCOPE, Ee as APIKEY_ISSUE_OTHER_SCOPE, ve as APIKEY_ISSUE_SELF_SCOPE, Fn as APPROVALS_DECIDE_TAG, Pn as APPROVALS_PENDING_TAG, j as ApprovalDecisionErrors, A as ApprovalErrors, E as ApprovalExpired, O as ApprovalForbidden, C as ApprovalNotFound, D as ApprovalNotPending, T as ApprovalRejected, S as ApprovalRequired, w as ApprovalSelfApproval, k as ApprovalUnavailable, Ae as AuthMiddleware, x as BusinessRuleViolation, Rn as CONNECTIONS_LIST_TAG, Vn as CONNECTION_DISCONNECT_TAG, zn as CONNECTION_START_TAG, Bn as CONNECTION_SUBMIT_TOKEN_TAG, qn as ConnectionHandshakeFailed, ce as ConnectionInfo, i as ConnectionInfoMiddleware, Q as ConnectionKind, Kn as ConnectionKindMismatch, Wn as ConnectionNotDeclared, Un as ConnectionState, Hn as ConnectionStatus, Gn as ConnectionSubjectRequired, Nn as ConstraintViolation, r as DEFAULT_SCOPE_CACHE_MAX_ENTRIES, ie as DEFAULT_SCOPE_CACHE_TTL_MS, Ft as EVENT_ROUTE_SEP, Mt as EventKeyInvalid, jt as EventPayloadInvalid, Nt as EventPayloadTooLarge, $n as GATEWAY_CREDENTIAL_EXPIRED_CLOSE_CODE, be as IMPERSONATION_METADATA_KEY, St as MAX_EVENT_ENVELOPE_BYTES, nr as PROTOCOL_VERSION, M as PendingApproval, N as REACTIVITY_CHANNEL_PREFIX, c as ScopeError, Mn as ServerOnlyColumnWrite, kn as StoreOperationFailed, e as Subject, ye as SubjectIdentity, re as SubjectService, An as TableValidationFailed, jn as TenantRowNotFound, On as TenantScopeViolation, vn as UNDO_APPLY_TAG, _n as UNDO_LOG_TAG, yn as UNDO_REDO_TAG, s as Unauthenticated, Cn as UndoConflict, Sn as UndoForbidden, bn as UndoLogEntry, xn as UndoNotFound, Z as WorkflowControlInputSchema, Qt as WorkflowDomainEventRowSchema, tn as WorkflowDomainEventsInputSchema, nn as WorkflowEventDeliveriesInputSchema, $t as WorkflowEventDeliveryRowSchema, Kt as WorkflowParentClosePolicySchema, Zt as WorkflowRunEventRowSchema, Jt as WorkflowRunHandleSchema, en as WorkflowRunRefSchema, Y as WorkflowRunRowSchema, J as WorkflowRunStatusSchema, Xt as WorkflowRunStepRowSchema, X as WorkflowRunTableRefSchema, Yt as WorkflowRunsInputSchema, rn as WorkflowSignalInputSchema, qt as WorkflowStartDeferralSchema, an as WorkflowUpdateInputSchema, on as WorkflowUpdateResultSchema, _t as actionToRpc, we as advisoryResourceGuardWarning, ae as anonymousSubject, Ye as applyRowPatch, a as applyScopeDecision, Ln as approvalsDecideDescriptor, In as approvalsPendingQueryDescriptor, te as assertAuthenticated, ze as beginIdempotent, Wt as checkFrameworkCompat, De as checkGuards, me as checkGuardsEffect, ne as composeAuthStrategies, Ht as composeRpcInterceptors, Zn as connectionDisconnectDescriptor, Yn as connectionStartDescriptor, Xn as connectionSubmitTokenDescriptor, Jn as connectionsListQueryDescriptor, nt as declaredReactivityChannelKeys, V as defineAction, wt as defineEvent, B as defineMutation, zt as definePlugin, Bt as definePluginRoute, Ut as definePluginService, z as defineQuery, dt as defineStream, tr as defineWebSocket, qe as diffRows, xe as effectiveScopes, Pt as encodeEventKey, yt as errorTag, Dt as eventAttached, Tt as eventEnvelope, Et as eventGap, kt as eventResumePoint, It as eventRoute, Ot as eventStreamEvent, G as eventSubscribeInput, At as eventToRpc, Ie as failIdempotent, ke as findAdvisoryResourceGuards, Pe as finishIdempotent, Rt as formatEventRoute, he as getPolicyGuardResolver, ge as getResourceScopeResolver, ut as hasAccessDecision, oe as hasCallbackRoutes, Te as hasEffectiveScope, U as hasEnforcedGuard, Se as hasScope, g as idToPath, Re as idempotencyScope, y as inputLabel, Ct as isEventDescriptor, Je as isIdKeyed, l as isOpenAccess, _e as isPolicyCheck, lt as isPolicyGuard, et as isReactivityChannel, tt as isReactivityChannelKey, Ne as isSystemSubject, st as isWireReachable, t as makeScopeCache, Be as memoryIdempotencyStore, fe as missingAccessDecision, gt as mutationToRpc, xt as normalizeDescriptor, it as normalizeSource, u as openAccessSpec, Lt as parseEventRoute, Ge as pathToId, Vt as pluginInstanceName, ot as publishReactivity, Ve as publishServerError, ht as queryToRpc, ue as rawImpersonationMark, $e as reactivityChannel, Me as requireScope, m as rowPatchOpSchema, h as rowPatchSchema, ee as scopeCacheKey, pe as setEffectiveScopes, je as setPolicyGuardResolver, se as setResourceScopeResolver, rt as sourceKeys, vt as streamToRpc, b as strictInput, le as subjectIdentity, n as subjectScopes, Le as subscribeServerErrors, v as subscriptionEvent, de as systemSubject, o as tenantScopedSubject, bt as toRpc, Qn as tsMs, at as undeclaredChannelKeys, En as undoApplyDescriptor, Tn as undoLogQueryDescriptor, Dn as undoRedoDescriptor, W as wireErrorUnion, pn as workflowCancelDescriptor, dn as workflowDomainEventsQueryDescriptor, fn as workflowEventDeliveriesQueryDescriptor, mn as workflowResumeDescriptor, un as workflowRunEventsQueryDescriptor, sn as workflowRunQueryDescriptor, ln as workflowRunStepsQueryDescriptor, cn as workflowRunsQueryDescriptor, hn as workflowSignalDescriptor, gn as workflowUpdateDescriptor, Fe as wsMutationIdempotencyScope };
package/dist/rest.d.ts CHANGED
@@ -74,6 +74,12 @@ declare interface AnyApprovalPolicy {
74
74
  /** A guard is either a scope check or a relationship check. */
75
75
  declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<Input>;
76
76
 
77
+ export declare const bytes: (stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>), options?: {
78
+ readonly contentType?: string;
79
+ readonly contentLength?: number;
80
+ readonly contentDisposition?: string;
81
+ }) => RestByteResponse;
82
+
77
83
  /**
78
84
  * Project every descriptor carrying a `publicApi` annotation into a REST
79
85
  * route, in stable (path) order. The boot layer feeds these straight into
@@ -248,6 +254,8 @@ declare interface InsertTarget<Input = unknown, Row = unknown, Item = Record<str
248
254
  readonly shapeItem?: ((input: Input, optimisticId: string) => Item) | undefined;
249
255
  }
250
256
 
257
+ export declare const isRestByteResponse: (v: unknown) => v is RestByteResponse;
258
+
251
259
  export declare const isRestStreamResponse: (v: unknown) => v is RestStreamResponse;
252
260
 
253
261
  /** Extract `:name` path params by aligning the route PATTERN with the request
@@ -349,12 +357,19 @@ declare interface OpenAccessSpec {
349
357
 
350
358
  /** A public raw-HTTP route a plugin serves on the framework listener. */
351
359
  declare interface PluginHttpRoute {
352
- /** HTTP method, or `'*'` for any (the handler decides). */
353
- readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'DELETE';
360
+ /** HTTP method, or `'*'` for any (the handler decides). PATCH/HEAD/OPTIONS
361
+ * are first-class the REST desugar used to mount `'*'` partly BECAUSE
362
+ * this union lacked PATCH; that reason is gone (the `'*'` mount remains
363
+ * for its other job: one dispatcher per shared path + a precise 405). */
364
+ readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
354
365
  /** Absolute path prefix, e.g. `/_voltro/storage`. Matches the path AND
355
366
  * any sub-path (`/_voltro/storage/abc123`). */
356
367
  readonly path: string;
357
368
  readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
369
+ /** Per-route body cap override (bytes) — wins over the listener's shared
370
+ * `maxBodyBytes`. NOTE: routes SHARING a path share one body read, so the
371
+ * widest override on the path's group applies to the whole group. */
372
+ readonly maxBodyBytes?: number;
358
373
  /**
359
374
  * Opt this route's path OUT of the listener's cross-site origin check.
360
375
  *
@@ -393,6 +408,21 @@ declare interface PluginHttpRoute {
393
408
  readonly originGuard?: 'exempt';
394
409
  }
395
410
 
411
+ /**
412
+ * A binary streaming body — the download/export shape. The serve layer pipes
413
+ * the Web ReadableStream to the socket without buffering, so a response
414
+ * larger than the heap is fine; the LAZY thunk form defers opening the
415
+ * source (a provider connection, a file handle) until the response actually
416
+ * streams.
417
+ */
418
+ declare interface PluginHttpRouteByteStream {
419
+ readonly stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
420
+ /** Declared up front when known — lets the client render progress. */
421
+ readonly contentLength?: number;
422
+ /** e.g. `attachment; filename="export.zip"`. */
423
+ readonly contentDisposition?: string;
424
+ }
425
+
396
426
  declare interface PluginHttpRouteRequest {
397
427
  readonly method: string;
398
428
  /** Path WITHOUT query string. */
@@ -504,6 +534,12 @@ declare interface PluginHttpRouteResult {
504
534
  /** Stream the response (SSE) instead of sending `body`. See
505
535
  * {@link PluginHttpRouteStream}. */
506
536
  readonly stream?: PluginHttpRouteStream;
537
+ /** Stream a BINARY response (a download, an export) instead of sending
538
+ * `body` — see {@link PluginHttpRouteByteStream}. Never buffered by the
539
+ * serve layer; never compressed (flush timing + Content-Length are the
540
+ * contract). Takes precedence over `body`; do not set both `stream` and
541
+ * `byteStream`. */
542
+ readonly byteStream?: PluginHttpRouteByteStream;
507
543
  }
508
544
 
509
545
  /**
@@ -739,6 +775,19 @@ export declare const requireAnyScope: (scopes: ReadonlyArray<string>) => RestGua
739
775
  */
740
776
  export declare const requireScope: (scope: string) => RestGuard;
741
777
 
778
+ /** A BINARY streaming response (download/export). The serve layer pipes the
779
+ * ReadableStream without buffering — a body larger than the heap is fine.
780
+ * The LAZY thunk form defers opening the source until the response streams. */
781
+ export declare interface RestByteResponse {
782
+ readonly __voltroRestBytes: true;
783
+ readonly byteStream: {
784
+ readonly stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
785
+ readonly contentLength?: number;
786
+ readonly contentDisposition?: string;
787
+ };
788
+ readonly contentType?: string;
789
+ }
790
+
742
791
  export declare type RestGuard = (ctx: RestRouteContext) => RestGuardRejection | undefined | Promise<RestGuardRejection | undefined>;
743
792
 
744
793
  /**
@@ -751,7 +800,7 @@ export declare interface RestGuardRejection {
751
800
  readonly message: string;
752
801
  }
753
802
 
754
- export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
803
+ export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
755
804
 
756
805
  /**
757
806
  * Per-call context handed to a REST route handler. Resolved by the serve
@@ -791,6 +840,26 @@ export declare interface RestRouteDescriptor<I, O> {
791
840
  /** ISO date. Sets a `Sunset:` header; past the date the route returns
792
841
  * `410 Gone` with a replacement pointer. */
793
842
  readonly sunset?: string;
843
+ /**
844
+ * Opt-in API version. `version: 'v2'` + `path: '/customers'` mounts the route
845
+ * at `/v2/customers` — the same `/vN/` convention the `publicApi:` projection
846
+ * has always used (`derivePublicPath`) and the built-in `/v1/api-keys`
847
+ * surface follows.
848
+ *
849
+ * OPT-IN on purpose: a route without `version` keeps its literal `path`
850
+ * untouched. An automatic prefix would silently move every deployed route —
851
+ * a second breaking change hiding inside a naming feature.
852
+ *
853
+ * Two versions of one resource are TWO descriptors: the old version is
854
+ * ordinary code — visible, testable, deletable — carrying `deprecated` (the
855
+ * replacement pointer) and `sunset` (the date it starts answering `410`,
856
+ * whose body then also names this `version`). There is no transformation
857
+ * DSL, and the rpc SOCKET is deliberately outside this: the generated client
858
+ * is versioned with the server it was generated from (a stale browser tab
859
+ * runs the previous client until reload — that skew window exists and is
860
+ * documented, it is not solved by URL versioning).
861
+ */
862
+ readonly version?: `v${number}`;
794
863
  readonly guards?: ReadonlyArray<RestGuard>;
795
864
  /**
796
865
  * This route STREAMS (Server-Sent Events) rather than resolving one value — its
@@ -801,6 +870,13 @@ export declare interface RestRouteDescriptor<I, O> {
801
870
  * no spec, because clients are generated from it.
802
871
  */
803
872
  readonly streaming?: boolean;
873
+ /** Per-route body cap override (bytes) — see PluginHttpRoute.maxBodyBytes. */
874
+ readonly maxBodyBytes?: number;
875
+ /** GET only: derive a weak ETag from the encoded response and answer a
876
+ * matching `If-None-Match` with 304. The tag is content-derived (an md5
877
+ * of the JSON), so it is correct across content-encodings — the
878
+ * transport's compression varies the bytes, not the representation. */
879
+ readonly etag?: boolean;
804
880
  }
805
881
 
806
882
  export declare interface RestRouteExample {
package/dist/rest.js CHANGED
@@ -33,26 +33,45 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
33
33
  status: 403,
34
34
  message: `Missing required scope: one of ${e.join(", ")}`
35
35
  };
36
- }, m = (e) => e, h = (e, t = {}) => ({
36
+ }, m = (e) => {
37
+ if (e.version === void 0) return e;
38
+ if (!/^v\d+$/.test(e.version)) throw Error(`defineRestRoute(${e.method} ${e.path}): version "${e.version}" must be 'v' followed by digits — 'v1', 'v2', …`);
39
+ 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.`);
40
+ return {
41
+ ...e,
42
+ path: `/${e.version}${e.path}`
43
+ };
44
+ }, h = (e, t = {}) => ({
37
45
  __voltroRestStream: !0,
38
46
  stream: {
39
47
  subscribe: e,
40
48
  ...t.keepAliveMs === void 0 ? {} : { keepAliveMs: t.keepAliveMs }
41
49
  }
42
- }), g = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, _ = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, v = (e, t, n) => ({
50
+ }), g = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, _ = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, v = (e, t = {}) => ({
51
+ __voltroRestBytes: !0,
52
+ byteStream: {
53
+ stream: e,
54
+ ...t.contentLength === void 0 ? {} : { contentLength: t.contentLength },
55
+ ...t.contentDisposition === void 0 ? {} : { contentDisposition: t.contentDisposition }
56
+ },
57
+ ...t.contentType === void 0 ? {} : { contentType: t.contentType }
58
+ }), y = (e) => typeof e == "object" && !!e && e.__voltroRestBytes === !0, b = async (e) => {
59
+ let t = await globalThis.crypto.subtle.digest("SHA-1", new TextEncoder().encode(e));
60
+ return [...new Uint8Array(t)].map((e) => e.toString(16).padStart(2, "0")).join("");
61
+ }, x = (e, t, n) => ({
43
62
  status: e,
44
63
  contentType: "application/json; charset=utf-8",
45
64
  body: JSON.stringify(t),
46
65
  ...n ? { headers: n } : {}
47
- }), y = (e) => {
66
+ }), S = (e) => {
48
67
  if (e.length === 0) return;
49
68
  let t = new TextDecoder().decode(e);
50
69
  if (t.trim() !== "") return JSON.parse(t);
51
- }, b = (e) => {
70
+ }, C = (e) => {
52
71
  let t = {};
53
72
  for (let [n, r] of new URLSearchParams(e)) t[n] = r;
54
73
  return t;
55
- }, x = (e, t) => {
74
+ }, w = (e, t) => {
56
75
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0), i = {};
57
76
  for (let e = 0; e < n.length; e++) {
58
77
  let t = n[e];
@@ -66,22 +85,24 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
66
85
  }
67
86
  }
68
87
  return i;
69
- }, S = (e, t) => ({
70
- query: b(e.query),
88
+ }, T = (e, t) => ({
89
+ query: C(e.query),
71
90
  params: t,
72
- body: y(e.rawBody)
73
- }), C = /* @__PURE__ */ new Set([
91
+ body: S(e.rawBody)
92
+ }), E = /* @__PURE__ */ new Set([
74
93
  "POST",
75
94
  "PUT",
76
95
  "PATCH",
77
96
  "DELETE"
78
- ]), w = (c, l) => {
97
+ ]), D = (c, l) => {
79
98
  let u = c.input ? s.decodeUnknown(c.input) : void 0, d = s.encode(c.output), f = {};
80
99
  return c.deprecated !== void 0 && (f.Deprecation = "true"), c.sunset !== void 0 && (f.Sunset = c.sunset), {
81
100
  method: "*",
82
101
  path: c.path,
102
+ ...c.maxBodyBytes === void 0 ? {} : { maxBodyBytes: c.maxBodyBytes },
83
103
  handle: async (s) => {
84
- if (s.method.toUpperCase() !== c.method) return v(405, {
104
+ let p = s.method.toUpperCase();
105
+ if (!(p === c.method || p === "HEAD" && c.method === "GET")) return x(405, {
85
106
  error: "Method Not Allowed",
86
107
  allow: c.method
87
108
  }, {
@@ -90,74 +111,98 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
90
111
  });
91
112
  if (c.sunset !== void 0) {
92
113
  let e = Date.parse(c.sunset);
93
- if (!Number.isNaN(e) && Date.now() >= e) return v(410, {
114
+ if (!Number.isNaN(e) && Date.now() >= e) return x(410, {
94
115
  error: "Gone",
116
+ ...c.version === void 0 ? {} : { version: c.version },
95
117
  ...c.deprecated === void 0 ? {} : { replacement: c.deprecated }
96
118
  }, f);
97
119
  }
98
- let p;
120
+ let m;
99
121
  if (u) {
100
- let e = S(s, x(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
122
+ let e = T(s, w(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
101
123
  ok: !0,
102
124
  value: e
103
125
  })), o.catchAll((e) => o.succeed({
104
126
  ok: !1,
105
127
  cause: e
106
128
  }))));
107
- if (!t.ok) return v(400, {
129
+ if (!t.ok) return x(400, {
108
130
  error: "Invalid request",
109
131
  detail: String(t.cause)
110
132
  }, f);
111
- p = t.value;
133
+ m = t.value;
112
134
  }
113
- let m = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), h = {
114
- subject: m,
135
+ let h = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), g = {
136
+ subject: h,
115
137
  headers: s.headers,
116
138
  store: l.store
117
139
  };
118
140
  if (c.guards) for (let e of c.guards) {
119
- let t = await e(h);
120
- if (t) return v(t.status, { error: t.message }, f);
141
+ let t = await e(g);
142
+ if (t) return x(t.status, { error: t.message }, f);
121
143
  }
122
- let g = l.idempotency, y = g && C.has(c.method) ? s.headers[g.header.toLowerCase()] : void 0, b = g && y ? r(m.tenantId, c.method, c.path) : "";
123
- if (g && y) {
124
- let e = await i(g.store, b, y, g.ttlMs, Date.now());
125
- if (e.kind === "replay") return v(e.response.status, e.response.body, {
144
+ let v = l.idempotency, S = v && E.has(c.method) ? s.headers[v.header.toLowerCase()] : void 0, C = v && S ? r(h.tenantId, c.method, c.path) : "";
145
+ if (v && S) {
146
+ let e = await i(v.store, C, S, v.ttlMs, Date.now());
147
+ if (e.kind === "replay") return x(e.response.status, e.response.body, {
126
148
  ...f,
127
149
  "Idempotency-Replayed": "true"
128
150
  });
129
- if (e.kind === "conflict") return v(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
151
+ if (e.kind === "conflict") return x(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
130
152
  }
131
153
  try {
132
- let e = await c.handler(p, h);
133
- if (_(e)) return {
154
+ let e = await c.handler(m, g);
155
+ if (_(e) || y(e)) return v && S && await n(v.store, C, S), y(e) ? {
156
+ status: 200,
157
+ headers: f,
158
+ byteStream: e.byteStream,
159
+ ...e.contentType === void 0 ? {} : { contentType: e.contentType }
160
+ } : {
134
161
  status: 200,
135
162
  headers: f,
136
163
  stream: e.stream
137
164
  };
138
- let n = await o.runPromise(d(e));
139
- return g && y && await t(g.store, b, y, {
165
+ let r = await o.runPromise(d(e));
166
+ if (v && S && await t(v.store, C, S, {
140
167
  status: 200,
141
- body: n
142
- }, Date.now()), v(200, n, f);
168
+ body: r
169
+ }, Date.now()), c.etag === !0 && c.method === "GET") {
170
+ let e = `W/"${await b(JSON.stringify(r))}"`, t = s.headers["if-none-match"];
171
+ return t !== void 0 && t.split(",").some((t) => t.trim() === e) ? {
172
+ status: 304,
173
+ headers: {
174
+ ...f,
175
+ ETag: e
176
+ }
177
+ } : x(200, r, {
178
+ ...f,
179
+ ETag: e
180
+ });
181
+ }
182
+ return x(200, r, f);
143
183
  } catch (e) {
144
- if (g && y && await n(g.store, b, y), typeof e == "object" && e && typeof e.status == "number") {
184
+ if (v && S && await n(v.store, C, S), typeof e == "object" && e && typeof e.status == "number") {
145
185
  let t = e;
146
- return v(t.status, { error: t.message ?? "Error" }, f);
186
+ return x(t.status, { error: t.message ?? "Error" }, f);
147
187
  }
148
188
  return a({
149
189
  error: e,
150
190
  source: "rest",
151
191
  name: `${c.method} ${c.path}`,
152
192
  fields: { status: 500 }
153
- }), v(500, { error: "Internal Server Error" }, f);
193
+ }), x(500, { error: "Internal Server Error" }, f);
154
194
  }
155
195
  }
156
196
  };
157
- }, T = (e, t = {}) => e.map((e) => w(e, t)), E = async (e, t) => {
197
+ }, O = (e, t = {}) => {
198
+ if (t.idempotency !== void 0) {
199
+ for (let t of e) if (t.streaming === !0 && E.has(t.method)) throw Error(`REST route ${t.method} ${t.path} declares \`streaming: true\` on a method the idempotency binding claims (${[...E].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.`);
200
+ }
201
+ return e.map((e) => D(e, t));
202
+ }, k = async (e, t) => {
158
203
  let n = await e[0].handle(t);
159
204
  for (let r = 1; r < e.length && n.status === 405; r++) n = await e[r].handle(t);
160
205
  return n;
161
206
  };
162
207
  //#endregion
163
- export { d as collectPublicApiRoutes, m as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, E as dispatchSharedPath, _ as isRestStreamResponse, x as matchPathParams, u as publicApiRoute, p as requireAnyScope, f as requireScope, T as restRoutesToHttpRoutes, h as sse, g as sseFrame };
208
+ export { v as bytes, d as collectPublicApiRoutes, m as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, k as dispatchSharedPath, y as isRestByteResponse, _ as isRestStreamResponse, w as matchPathParams, u as publicApiRoute, p as requireAnyScope, f as requireScope, O as restRoutesToHttpRoutes, h as sse, g as sseFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
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",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@effect/sql": "^0.52.0",
57
- "@voltro/database": "0.51.0",
58
- "@voltro/logger": "0.51.0",
57
+ "@voltro/database": "0.52.0",
58
+ "@voltro/logger": "0.52.0",
59
59
  "jose": "^6.2.8"
60
60
  },
61
61
  "peerDependencies": {