@voltro/protocol 0.50.1 → 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 +231 -0
- package/dist/index.d.ts +84 -2
- package/dist/index.js +97 -92
- package/dist/rest.d.ts +79 -3
- package/dist/rest.js +81 -36
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,237 @@ _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
|
+
|
|
169
|
+
## [0.51.0] — 2026-08-24
|
|
170
|
+
|
|
171
|
+
### ⚠ BREAKING
|
|
172
|
+
|
|
173
|
+
- **@voltro/data-transfer, @voltro/cli** — The asset counts reported references as if they were blobs. `_voltro_storage_refs` holds one row per reference and several rows legitimately name one key, so a capture of 57 rows over 16 keys wrote `count: 57` into the stamp beside an `assets/` directory holding 16 files, and the restore reported "57 blob(s) restored" while 16 objects appeared. Nothing was lost; what was lost is the ability to check. Anyone answering "are all the blobs there?" after a restore compared the stamp's number against one they counted and found a 3.5x gap that was not one.
|
|
174
|
+
|
|
175
|
+
A key named by several references is now fetched once rather than downloaded, hashed and discarded once per row, and the three numbers are stated separately: `references` (rows enumerated), `count` (distinct keys), `objects` (distinct sha256 bodies), with `totalBytes` and `objectBytes` beside them. The stamp's existing fields keep their names and now mean what a reader always took them for; the new ones are optional, so a stamp written before them still parses.
|
|
176
|
+
|
|
177
|
+
**Breaking on one export.** `restoreAssetsFromCas` returns `{ count, objects }` instead of a bare `number` — one number could not answer both questions, which is the defect. `count` is what the old value was, so the migration that changes nothing is `.count`.
|
|
178
|
+
|
|
179
|
+
**`voltro update` carries you across this** — codemod `0.51.0/03_restore-assets-returns-counts`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.51.0).
|
|
180
|
+
- **@voltro/i18n** — `<I18nProvider>` takes `timeZone` as its own prop, and `intlConfig` no longer accepts one.
|
|
181
|
+
|
|
182
|
+
`intlConfig` exists to forward props to react-intl UNMODIFIED, and for every other member of `IntlConfig` that is the right shape. `timeZone` is the one member this package's own formatters read — and they did not read it: `useFormatDate` built `Intl.DateTimeFormat` itself and took only the locale from the provider. So a zone passed through `intlConfig` configured `<T>`'s ICU dates and NOT the `useFormatDate()` beside them. Measured under one provider, one instant (`2026-08-24T23:30:00Z`), `locale: 'de'`, `intlConfig: { timeZone: 'Europe/Berlin' }`, process zone UTC: react-intl rendered `25.08.26, 01:30` and the hook rendered `24.08.26, 23:30`. A different hour, and a different day.
|
|
183
|
+
|
|
184
|
+
The zone has to be a prop this package can see. It is validated once at the provider (an unusable zone — a stale cookie, a typo, a runtime with a trimmed ICU — is dropped, because `Intl` THROWS on an unknown zone and `useFormatDate` catches, which would degrade every timestamp in the app to a raw `Date` string). It is what the new `useTimeZone()` reports. And it is what the framework fills per request.
|
|
185
|
+
|
|
186
|
+
**Migration:** `intlConfig={{ timeZone: 'Europe/Berlin' }}` → `timeZone="Europe/Berlin"`. The codemod does it, including the case where the zone was the bag's only member. An `intlConfig` naming a variable is reported by file and line rather than guessed at — the property would otherwise stop being read with nothing red anywhere.
|
|
187
|
+
- **@voltro/cli** — A native restore whose bookkeeping store would not open ran anyway, with no in-progress marker and no word about it. `nativeBookkeeping` was `try { … } catch { return undefined }`, and that `undefined` guarded every branch below — including the refusal for a marker that could not be written. So the failure removed the precaution AND the sentence that would have reported it missing, and `markerState` was never computed, defaulting to "held".
|
|
188
|
+
|
|
189
|
+
What it produced was worse than silence: a failed restore printed "This database is now in an unknown state and the next boot will REFUSE, by design" over a database with zero marker rows. The next boot did not refuse, and `voltro data clear-replace-marker` had nothing to clear. The trigger is not exotic — wrong credentials, an unreachable database, a missing env var, no `app.config.ts` from here — and a restore is the operation you run against a target that is already unwell, so the guard fell away exactly when it was needed.
|
|
190
|
+
|
|
191
|
+
The reason now travels instead of being caught and dropped. A restore that cannot write the marker REFUSES and names which of the two reasons it was (the store would not open, or the table is not there); those were two separate refusals and are now one, because they are one decision for the operator. `--no-marker` is the deliberate way past it and warns every time. And the `recorded:` line no longer reports a local failure as a property of the target — it says where the failure was.
|
|
192
|
+
|
|
193
|
+
**This changes an exit code.** A restore that could not write the marker used to exit 0; it now exits 1. Two invocations are affected — the bookkeeping store will not open, or there is no `app.config.ts` from the working directory — and the second surprises people, because `restore` reads its target from the environment and so looks like it needs no project. It does not, for the dump; it needs one for the marker. `--no-marker` is the deliberate way through and warns every time. The note ships under `reach: 'beyond-source'` because the affected invocations live in cron entries, CI jobs and runbooks rather than in TypeScript.
|
|
194
|
+
|
|
195
|
+
**`voltro update` carries you across this** — codemod `0.51.0/02_restore-refuses-without-marker`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.51.0).
|
|
196
|
+
|
|
197
|
+
### Added
|
|
198
|
+
|
|
199
|
+
- **@voltro/i18n, @voltro/cli, @voltro/voltro** — `timeZone` in the web `app.config.ts` — the zone every date/time formatter renders in, resolved per request and published so the client agrees.
|
|
200
|
+
|
|
201
|
+
A formatter is deterministic given the value, the locale, the zone and the clock. The locale already came from the provider and was already agreed across the hydration boundary — the server publishes it as `<html lang>` and the client reads that attribute rather than `navigator.languages`, precisely because the browser's own answer can differ from what the server saw. The zone had no such source. `Intl` fell back to the zone of whichever runtime was formatting: the pod on the server (UTC on a container with no `TZ`), the viewer's machine in the browser. Every server-rendered timestamp was therefore a hydration mismatch waiting for a wide enough offset, and across midnight it was a different calendar day.
|
|
202
|
+
|
|
203
|
+
timeZone: 'Europe/Berlin' // one zone for every viewer timeZone: 'viewer' // per request, from the `voltro:tz` cookie defaultTimeZone: 'UTC' // before the viewer's zone is known
|
|
204
|
+
|
|
205
|
+
Whatever it resolves to is stamped on the document as `<html data-voltro-tz>`, and the generated client entry reads that attribute. Both sides then format against one value — which is the property that removes the mismatch, whether or not the value is the viewer's true zone: being wrong together is repairable after mount, being different is not.
|
|
206
|
+
|
|
207
|
+
Under `'viewer'` the framework injects a script that seeds `voltro:tz` from the browser when the cookie is absent, so the server renders in the viewer's zone from the second request with no login. It never overwrites an existing value — the APP is the authoritative writer, at login, from the zone it holds for the signed-in user (`TIMEZONE_COOKIE` and `isSupportedTimeZone` are exported for that). Unset, nothing changes: each runtime keeps using its own zone, and `useTimeZone()` returns `undefined` to say so.
|
|
208
|
+
|
|
209
|
+
This is the RENDER zone. The server-side compute zone — what `startOfDay` resolves against inside a handler — is still `@voltro/datetime/context`'s seam, unwired.
|
|
210
|
+
|
|
211
|
+
**`apiSurface: compatible`** covers the two golden lines that moved, and they are the same change twice: `makeSsgWrap`'s returned wrapper, and the `wraps` record on the SSG shell input, each gained an OPTIONAL second parameter (the per-request zone + render instant; the wrapper is built once per locale, so they cannot live in the factory). A function with an optional extra parameter is assignable wherever the one-parameter type was expected, so no call site that compiled stops compiling — and both are the framework's SSG bridge, documented as never imported by app code. Everything else this release adds to these packages is a pure addition; the one genuine break in `@voltro/i18n` is the `timeZone` prop, which has its own entry and its own codemod.
|
|
212
|
+
|
|
213
|
+
### Changed
|
|
214
|
+
|
|
215
|
+
- **@voltro/cli** — A `BREAKING` entry's changelog footer now tells a reader who pins versions by hand how to print the codemod's note without upgrading anything:
|
|
216
|
+
|
|
217
|
+
voltro update --codemods-only --from <your current version> --dry-run
|
|
218
|
+
|
|
219
|
+
The footer used to stop at the codemod's id, which is enough for anyone who runs `voltro update` and nothing at all for anyone who does not. A deployment said so plainly: they pin every `@voltro/*` version from their own container scripts, have never run the command, and `CHANGELOG.md` out of the tarball is the only channel anything reaches them through. So a note deliberately filed under an unreached version — our one mechanism for correcting guidance that can no longer be corrected in place — reached that population not at all, and the id told them a fix existed without telling them what it was.
|
|
220
|
+
|
|
221
|
+
No new surface: every flag in that invocation is already parsed, which is what lets `check-message-apis.mjs` verify the line rather than trust it.
|
|
222
|
+
- **@voltro/cli** — Two fingerprint labels now say what they compare.
|
|
223
|
+
|
|
224
|
+
The restore's skew warning said the backup's schema "differs from what this code declares". It does not: the value it compares against is the TARGET database's live schema, read by introspection at restore time. Bringing a target to the backup's shape makes the warning disappear while the declared fingerprint is a third value entirely, which is how the mislabel was caught. The comparison is the useful one and is unchanged; the sentence sent readers looking for a code change where a database differed.
|
|
225
|
+
|
|
226
|
+
`voltro db plan` prints `fingerprint: live … · declared …` instead of `from … → to …`, plus a line saying the two are not meant to match. A hash of a live database never equals the hash of the declaration it came from — introspection cannot recover generated expressions, `maxLength` or sensitivity markers — which is why `db drift` keeps a separate live baseline. Printed as `from → to`, `0 operations` under two differing hashes read as a contradiction.
|
|
227
|
+
|
|
228
|
+
### Fixed
|
|
229
|
+
|
|
230
|
+
- **@voltro/cli** — `voltro check` reported a reactivity CHANNEL as a missing table, at `error` severity — so it set the exit code:
|
|
231
|
+
|
|
232
|
+
✗ error reference/dangling-source query(presence.list) reads table 'channel:presence' which does not exist fix: declare a 'channel:presence.entity.ts' table or fix the query's source
|
|
233
|
+
|
|
234
|
+
A `source:` entry is a table name OR a channel's routing key (`channel:<name>`), and every rule resolved entries against the table set. The advice cannot be followed — a channel exists precisely because no table is meant — and because it is an error rather than a warning, `voltro check` could not be a CI gate for any app that uses a channel. That includes an app whose only channel comes from `@voltro/plugin-presence`, whose own `presence.list` declares one: a first-party feature meeting a rule that did not know about it, inside a first-party plugin.
|
|
235
|
+
|
|
236
|
+
Channels are filtered in the ONE helper every table rule reads, rather than at each rule, because a per-rule filter is how the next rule joins without one. The same cause was live one rule over: `observed/declared-but-unobserved` reported "declares source 'channel:presence' but never read it while running" for every exercised procedure that declares a channel. Both are covered, each with a negative control — a filter that dropped the whole source list would have silenced the rules instead of narrowing them.
|
|
237
|
+
- **@voltro/data-transfer** — A native dump no longer carries `_voltro_data_transfers`, for the same reason it stopped carrying the in-progress marker one release ago. The restore opens its own run row there BEFORE the tool runs; the dump then dropped the table mid-flight, and the update recording the outcome wrote into a table that no longer held the row. Measured downstream: after a deliberately failed native restore, `voltro data transfers` showed no restore at all — only the `backup` row the dump had carried over from the SOURCE database. The command that answers "did the restore finish" could not see the run asking the question.
|
|
238
|
+
|
|
239
|
+
Exactly two tables are excluded and the line is deliberate: a native restore into the same deployment should bring the migration ledger, the stored plans, the CDC offsets and the schedule claims, because they describe the data being restored. These two describe the RESTORE, and a record of an operation must not be overwritten by the operation it records. Covered per dialect against real servers and real vendor tools, including a non-vacuity check that a table which SHOULD travel still does.
|
|
240
|
+
- **@voltro/i18n, @voltro/cli** — `useRelativeTime` used `Date.now()` as its base, which under SSR is two different numbers. The server rendered at T and wrote "3 minutes ago" into the HTML; the browser hydrated at T+Δ and rendered "4 minutes ago" whenever a unit boundary fell in the gap. The gap is network latency, so it reproduced on a slow connection and never on the developer's machine, and it had nothing to do with timezones — a correctly zoned app hit it just the same.
|
|
241
|
+
|
|
242
|
+
The server states its render instant (`<html data-voltro-now>`, `renderedAt` on the provider), the first client render uses that same number, and the clock goes live once hydration commits. Server markup and hydration markup are therefore identical BY CONSTRUCTION — the property `await.tsx` and `deferred.ts` already hold, rather than `suppressHydrationWarning`, which would hide a real mismatch along with this one. An explicit `{ now }` still wins.
|
|
243
|
+
|
|
244
|
+
The mount state lives in the provider, not in the hook: a table of ten thousand rows would otherwise pay a state hook and a passive effect each to learn one fact that is true for the whole document. An app that never renders on the server publishes no stamp and takes no second render pass.
|
|
245
|
+
- **@voltro/cli** — `closeNativeRun` writes the transfer row BACK when the restore's own artefact dropped the table it lives in, instead of issuing an `UPDATE` that matches nothing and returning happily. Excluding `_voltro_data_transfers` from our own dumps shortens that window; it does nothing for a dump taken before that change, for a hand-made one, or for mssql and sqlite, whose restores have no per-table exclusion at all. The write-back covers every dialect and every artefact, which is why it is the rule and the exclusion is the optimisation.
|
|
246
|
+
|
|
247
|
+
The row is read back rather than trusted — an update that matched nothing is indistinguishable from one that matched — and a read that itself fails writes nothing, because a duplicate row invented on a guess is its own defect in a history somebody reads under pressure.
|
|
248
|
+
- **@voltro/cli** — A prerendered page shipped the shell's baked `lang="en"` whatever locale it was rendered in.
|
|
249
|
+
|
|
250
|
+
`voltro dev` and `voltro start` both set `<html lang>` per request; the prerender never did. So a `/de/...` artefact — rendered with the German catalog, handed `locale: 'de'` in its `meta` — served `<html lang="en">`. That attribute is what a screen reader pronounces in, what Chrome offers to translate FROM, and what hyphenation uses, so the failure was silent to whoever shipped it and loud only to the people it excluded. The same shape as the 0.30.0 cookie-name drift, one document path over.
|
|
251
|
+
|
|
252
|
+
It surfaced while giving the zone somewhere to travel: `<html lang>` was set by four hand-written copies of one `.replace(/<html…/)` and by nothing in the prerender, and adding a second attribute to that arrangement is how the next one reaches three paths out of five. There is one `applyDocumentAttrs` now, and the prerender is one of its callers — which fixes the locale as a side effect of having somewhere to put the zone.
|
|
253
|
+
|
|
254
|
+
### Internal (no consumer-facing effect)
|
|
255
|
+
|
|
256
|
+
- **@voltro/cli** — `voltro data backup --assets` / `restore --assets` are now driven against a REAL S3 API (MinIO in the test stack), over the network, with a real backup and a real restore into a second bucket and the bytes compared.
|
|
257
|
+
|
|
258
|
+
The asset half rests on one field: a provider must map "there is no object at that key" to `status === 404`, and nothing looser — a 403 from a rotated credential is also non-transient, and calling that "the object is gone" turns a recoverable outage into a backup that quietly contains nothing. That mapping was measured against memory, filesystem and database live, and against the s3/azure SDK error SHAPES constructed. A constructed shape is a claim about an SDK, not about a round trip: nothing in it exercises signing, path-style addressing, or what the SDK actually raises when a server answers `NoSuchKey`. Two deployments listed exactly this as the gap they could not close either.
|
|
259
|
+
|
|
260
|
+
Both directions are covered against the real server: a dangling reference is stepped over and reported, and a bad credential fails the capture rather than being read as a missing object.
|
|
261
|
+
|
|
262
|
+
The native dialect lane also stops being silent about mssql. It was absent from the array entirely — an absent lane and a covered one look identical from the outside — and it is now listed with a written reason for why it does not register here (`sqlpackage` is a separate Microsoft download on a .NET runtime, absent from `mcr.microsoft.com/mssql-tools`). It is registered rather than skipped-forever, because a skip present on every healthy run teaches readers to ignore skip lines; and an assertion fails if any lane drops out WITHOUT a written reason, or if a reason names a lane that is in fact running.
|
|
263
|
+
- **@voltro/cli, @voltro/plugin-storage** — Coverage for the data commands, at the level the defects actually live.
|
|
264
|
+
|
|
265
|
+
`dataDirectFlags.e2e.test.ts` drives every DIRECT-target flag of `voltro data export` / `import` through the real binary against a real sqlite database, and carries the same `DATA_FLAGS`-driven self-check the native suite has: a new direct flag has to be driven there or the file goes red. The api-only flags are listed explicitly with the reason they are not here, and that list is asserted against `DATA_FLAGS` so it cannot become a place to hide an untested flag.
|
|
266
|
+
|
|
267
|
+
`missingObjectIs404.test.ts` pins the contract the dangling-reference skip rests on: every provider maps "no object at that key" to `status === 404`, and nothing looser. Five providers, three of them live, s3 and azure through their SDK's real error shapes — which read DIFFERENT fields (`$metadata.httpStatusCode` vs a bare `statusCode`), so a mapping copied from one to the other would turn dangling references back into hard capture failures on that backend alone.
|
|
268
|
+
|
|
269
|
+
`codegenFeatureTables.integration.test.ts` measures the count a report was about: `voltro codegen` must carry the tables a `*.cron.tsx` contributes, which the entity walk cannot see. The structural guards beside it were TRUE while that count was wrong. `frameworkSourceTypo.integration.test.ts` measures what catches a misspelled `_voltro_*` source given that the type deliberately does not — `voltro check` reports it as a dangling source and exits 1, with a negative control so the check is not merely flagging every framework name.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
42
273
|
## [0.50.1] — 2026-08-24
|
|
43
274
|
|
|
44
275
|
### Added
|
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
|
-
|
|
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
|
-
}),
|
|
381
|
+
}), G = (e) => d.Struct({
|
|
382
382
|
key: e,
|
|
383
383
|
resume: d.optional(d.Array(kt))
|
|
384
|
-
}),
|
|
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(
|
|
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
|
-
},
|
|
392
|
+
}, jt = class extends d.TaggedError()("EventPayloadInvalid", {
|
|
393
393
|
event: d.String,
|
|
394
394
|
message: d.String
|
|
395
|
-
}) {},
|
|
395
|
+
}) {}, Mt = class extends d.TaggedError()("EventKeyInvalid", {
|
|
396
396
|
event: d.String,
|
|
397
397
|
message: d.String
|
|
398
|
-
}) {},
|
|
398
|
+
}) {}, Nt = class extends d.TaggedError()("EventPayloadTooLarge", {
|
|
399
399
|
event: d.String,
|
|
400
400
|
bytes: d.Number,
|
|
401
401
|
limit: d.Number
|
|
402
|
-
}) {},
|
|
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
|
-
},
|
|
406
|
+
}, Ft = "\0", It = (e, t, n) => [
|
|
407
407
|
e ?? "~",
|
|
408
408
|
t,
|
|
409
|
-
|
|
410
|
-
].join("\0"),
|
|
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
|
-
},
|
|
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
|
-
},
|
|
420
|
+
}, Ht = (e) => {
|
|
421
421
|
if (e.length !== 0) return (t, n) => e.reduceRight((e, t) => t(e, n), t);
|
|
422
|
-
},
|
|
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
|
-
},
|
|
428
|
+
}, Wt = (e, t, n) => {
|
|
429
429
|
if (!t) return { ok: !0 };
|
|
430
|
-
let r =
|
|
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 (!
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 =
|
|
455
|
-
return !n || t.major !== n.major ? !1 :
|
|
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 =
|
|
459
|
-
return !n || t.major !== n.major || t.minor !== n.minor ? !1 :
|
|
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 =
|
|
463
|
+
let e = n[1], r = K(n[2]);
|
|
464
464
|
if (!r) return !1;
|
|
465
|
-
let i =
|
|
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 =
|
|
472
|
-
return r ?
|
|
473
|
-
},
|
|
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
|
-
}),
|
|
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(
|
|
484
|
-
}),
|
|
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:
|
|
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(
|
|
504
|
-
}),
|
|
503
|
+
parentClosePolicy: d.NullOr(Kt)
|
|
504
|
+
}), Yt = d.Struct({
|
|
505
505
|
tag: d.optional(d.String),
|
|
506
|
-
status: d.optional(
|
|
506
|
+
status: d.optional(J),
|
|
507
507
|
limit: d.optional(d.Number)
|
|
508
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}),
|
|
555
|
+
}), nn = d.Struct({ eventId: d.String }), Z = d.Struct({
|
|
556
556
|
workflowName: d.String,
|
|
557
557
|
executionId: d.String
|
|
558
|
-
}),
|
|
558
|
+
}), rn = d.Struct({
|
|
559
559
|
id: d.String,
|
|
560
560
|
signalName: d.String,
|
|
561
561
|
payload: d.optional(d.Unknown)
|
|
562
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}),
|
|
572
|
+
}), sn = z({
|
|
573
573
|
name: "__voltro.workflow.run",
|
|
574
574
|
source: "_voltro_workflow_runs",
|
|
575
|
-
input:
|
|
576
|
-
output: d.Array(
|
|
577
|
-
}),
|
|
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:
|
|
581
|
-
output: d.Array(
|
|
582
|
-
}),
|
|
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:
|
|
586
|
-
output: d.Array(
|
|
587
|
-
}),
|
|
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:
|
|
591
|
-
output: d.Array(
|
|
592
|
-
}),
|
|
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:
|
|
596
|
-
output: d.Array(
|
|
597
|
-
}),
|
|
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:
|
|
601
|
-
output: d.Array(
|
|
602
|
-
}),
|
|
600
|
+
input: nn,
|
|
601
|
+
output: d.Array($t)
|
|
602
|
+
}), pn = V({
|
|
603
603
|
name: "__voltro.workflow.cancel",
|
|
604
|
-
input:
|
|
604
|
+
input: Z,
|
|
605
605
|
output: d.Struct({ ok: d.Boolean })
|
|
606
|
-
}),
|
|
606
|
+
}), mn = V({
|
|
607
607
|
name: "__voltro.workflow.resume",
|
|
608
|
-
input:
|
|
608
|
+
input: Z,
|
|
609
609
|
output: d.Struct({ ok: d.Boolean })
|
|
610
|
-
}),
|
|
610
|
+
}), hn = V({
|
|
611
611
|
name: "__voltro.workflow.signal",
|
|
612
|
-
input:
|
|
612
|
+
input: rn,
|
|
613
613
|
output: d.Struct({ eventId: d.String })
|
|
614
|
-
}),
|
|
614
|
+
}), gn = V({
|
|
615
615
|
name: "__voltro.workflow.update",
|
|
616
|
-
input:
|
|
617
|
-
output:
|
|
618
|
-
}),
|
|
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
|
-
}),
|
|
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
|
-
}) {},
|
|
629
|
-
name:
|
|
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(
|
|
632
|
+
output: d.Array(bn),
|
|
633
633
|
openAccess: "subject-scoped by construction: lists only the calling subject's own undoable actions"
|
|
634
|
-
}),
|
|
635
|
-
name:
|
|
634
|
+
}), En = B({
|
|
635
|
+
name: vn,
|
|
636
636
|
input: d.Struct({ invocationId: d.String }),
|
|
637
637
|
output: d.Struct({ ok: d.Boolean }),
|
|
638
|
-
error:
|
|
638
|
+
error: wn,
|
|
639
639
|
openAccess: "subject-scoped by construction: undo is per-actor — another subject's invocation fails typed with UndoForbidden"
|
|
640
|
-
}),
|
|
641
|
-
name:
|
|
640
|
+
}), Dn = B({
|
|
641
|
+
name: yn,
|
|
642
642
|
input: d.Struct({ invocationId: d.String }),
|
|
643
643
|
output: d.Struct({ ok: d.Boolean }),
|
|
644
|
-
error:
|
|
644
|
+
error: wn,
|
|
645
645
|
openAccess: "subject-scoped by construction: redo is per-actor — another subject's invocation fails typed with UndoForbidden"
|
|
646
|
-
}),
|
|
646
|
+
}), On = class extends d.TaggedError()("TenantScopeViolation", {
|
|
647
647
|
table: d.String,
|
|
648
648
|
reason: d.String
|
|
649
|
-
}) {},
|
|
649
|
+
}) {}, kn = class extends d.TaggedError()("StoreOperationFailed", {
|
|
650
650
|
operation: d.String,
|
|
651
651
|
table: d.String,
|
|
652
652
|
cause: d.String
|
|
653
|
-
}) {},
|
|
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
|
-
}) {},
|
|
660
|
+
}) {}, jn = class extends d.TaggedError()("TenantRowNotFound", {
|
|
661
661
|
table: d.String,
|
|
662
662
|
id: d.String,
|
|
663
663
|
reason: d.String
|
|
664
|
-
}) {},
|
|
664
|
+
}) {}, Mn = class extends d.TaggedError()("ServerOnlyColumnWrite", {
|
|
665
665
|
table: d.String,
|
|
666
666
|
columns: d.Array(d.String)
|
|
667
|
-
}) {},
|
|
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
|
-
},
|
|
685
|
-
name:
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
}),
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
70
|
-
query:
|
|
88
|
+
}, T = (e, t) => ({
|
|
89
|
+
query: C(e.query),
|
|
71
90
|
params: t,
|
|
72
|
-
body:
|
|
73
|
-
}),
|
|
91
|
+
body: S(e.rawBody)
|
|
92
|
+
}), E = /* @__PURE__ */ new Set([
|
|
74
93
|
"POST",
|
|
75
94
|
"PUT",
|
|
76
95
|
"PATCH",
|
|
77
96
|
"DELETE"
|
|
78
|
-
]),
|
|
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
|
-
|
|
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
|
|
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
|
|
120
|
+
let m;
|
|
99
121
|
if (u) {
|
|
100
|
-
let 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
|
|
129
|
+
if (!t.ok) return x(400, {
|
|
108
130
|
error: "Invalid request",
|
|
109
131
|
detail: String(t.cause)
|
|
110
132
|
}, f);
|
|
111
|
-
|
|
133
|
+
m = t.value;
|
|
112
134
|
}
|
|
113
|
-
let
|
|
114
|
-
subject:
|
|
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(
|
|
120
|
-
if (t) return
|
|
141
|
+
let t = await e(g);
|
|
142
|
+
if (t) return x(t.status, { error: t.message }, f);
|
|
121
143
|
}
|
|
122
|
-
let
|
|
123
|
-
if (
|
|
124
|
-
let e = await i(
|
|
125
|
-
if (e.kind === "replay") return
|
|
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
|
|
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(
|
|
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
|
|
139
|
-
|
|
165
|
+
let r = await o.runPromise(d(e));
|
|
166
|
+
if (v && S && await t(v.store, C, S, {
|
|
140
167
|
status: 200,
|
|
141
|
-
body:
|
|
142
|
-
}, Date.now()),
|
|
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 (
|
|
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
|
|
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
|
-
}),
|
|
193
|
+
}), x(500, { error: "Internal Server Error" }, f);
|
|
154
194
|
}
|
|
155
195
|
}
|
|
156
196
|
};
|
|
157
|
-
},
|
|
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,
|
|
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.
|
|
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.
|
|
58
|
-
"@voltro/logger": "0.
|
|
57
|
+
"@voltro/database": "0.52.0",
|
|
58
|
+
"@voltro/logger": "0.52.0",
|
|
59
59
|
"jose": "^6.2.8"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|