@voltro/protocol 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,64 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.79.0] — 2026-09-23
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **Probes answer on the app port from the first moment, on every command** — `@voltro/cli`, `@voltro/runtime`, `@voltro/voltro`
47
+
48
+ `voltro dev`, `voltro serve` and `voltro start` now bind their app port as the FIRST thing they do once they know it, and answer `/internal/liveness` (200), `/internal/readiness` (503 until the app serves, then 200 with the dependency ping) and `/internal/startup` (JSON `{ phase, ready, elapsedMs, … }`, 503 → 200) there through the whole boot — a slow database connect, a fingerprint check or a cold migrate included. Every other request and every WebSocket upgrade gets `503 + Retry-After: 1` until ready, then the app serves on the same socket and the probe paths keep answering before routing. There is ONE server and ONE port, so a probe path means the same thing on every command; `voltro dev` no longer binds a second probe server on app port + 1 and no longer reads `VOLTRO_DEV_HEALTH_PORT`. Migration: point every probe of a pod running `voltro dev` at the app port and delete the variable — the codemod prints the exact steps and finds the variable in compose, Helm and CI files.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.79.0/01_probes-on-the-app-port`. 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.79.0).
51
+ - **A rejected credential is refused on the default tenant policy too** — `@voltro/cli`, `@voltro/protocol`, `@voltro/voltro`
52
+
53
+ A request whose credential a strategy rejects (an expired or malformed token, a revoked session or key) now ends with `Unauthenticated` (`credential rejected: <reason>`) on every anonymous-tenant policy. Until now that held under `auth: { anonymousTenantId }` and `auth: { anonymousTenantRequired: true }`, but not for an app that sets neither. There, a procedure without a guard served the caller as an anonymous one, and an open query answered public data to a token that had just been refused. A caller who sends no credential is unaffected: it still resolves to the tenant its `x-tenant` header names, or to the development tenant. `composeAuthStrategies` gains `anonymousTenantDefault` for that default tenant. Unlike a `fallback`, it does not take over the rejected-credential decision. A client that kept sending a stale cookie or token and relied on the anonymous answer now receives `Unauthenticated`, which the error bus reports and `wireAuthRefresh` acts on.
54
+
55
+ **`voltro update` carries you across this** — codemod `0.79.0/03_rejected-credentials-refused-on-the-default-policy`. 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.79.0).
56
+
57
+ ### Added
58
+
59
+ - **A 1366 over a 4-byte character says which config field fixes it** — `@voltro/database`
60
+
61
+ When MySQL or MariaDB refuse a write with `Incorrect string value: '\xF0…'` (errno 1366 over a four-byte UTF-8 lead byte — an emoji into a `utf8mb3` column), the store's `detail:` line now carries a `hint:` naming the column and the remedy: declare `store: { dialect, charset: 'utf8mb4' }` in `app.config.ts` and run `voltro db plan`. A 1366 for any other reason gets no advice about character sets.
62
+ - **The store's character set is declared once and every table follows it** — `@voltro/database`, `@voltro/cli`, `@voltro/devtools-ui`
63
+
64
+ On MySQL and MariaDB the framework's `CREATE TABLE` named no character set, so a `text()` column inherited whatever the database was created with while a `json()` column did not (MariaDB's `JSON` is `LONGTEXT CHARACTER SET utf8mb4`, MySQL's has none) — and on a `utf8mb3` database one table refused an emoji in its text column with errno 1366 and took it in its json column. `app.config.ts` now takes `store: { dialect: 'mariadb' | 'mysql', charset: 'utf8mb4' | 'utf8mb3' }`: every declared table is created with it as its default and, when a live table's string columns are stored otherwise, converted to it by a new `convert-charset` plan operation — a table rewrite that drops and re-creates the foreign keys touching the table, so a pair bound by a constraint converts together instead of one side failing with errno 150. Widening to `utf8mb4` is `safe`; narrowing to `utf8mb3` is `lossy` and waits for the destructive opt-in. json columns keep the engine's own charset (neither engine lets it be declared). Unset, nothing changes. A charset on any other dialect is refused at boot, and a `DB_DIALECT` override off the mysql family drops it with a logged line rather than silently.
65
+
66
+ The public signatures that changed only gain optional trailing parameters (`declaredSnapshot`, `emitNamespacedSchemaSql`, `emitNamespaceProvisionDdl`, the schema-emit options) and one union member (`convert-charset` in the migration operation kind), so existing calls compile unchanged. Only an exhaustive `switch` over that operation kind would need a new case.
67
+ - **The plan and the doctor name tables whose text cannot hold what json can** — `@voltro/cli`, `@voltro/database`
68
+
69
+ Without a declared store charset, `voltro db plan` prints — under the plan — the MySQL / MariaDB tables whose text columns are stored in a character set narrower than their json columns' `utf8mb4`, and the tables whose string columns are not all in one character set (a column converted by hand). `voltro doctor` has the same section, reading `information_schema`, with three outcomes that never print alike: not applicable off the mysql family, not checked with the reason when no database is reachable, and the findings — against the declaration when one is set. A healthy `utf8mb4` database reports nothing; in `--json` the section is `storeCharset`.
70
+
71
+ ### Fixed
72
+
73
+ - **A handler's own `Unauthenticated` reaches the client typed, on any procedure** — `@voltro/protocol`, `@voltro/cli`, `@voltro/runtime`
74
+
75
+ `Unauthenticated` and `ScopeError` are now members of every procedure's wire error union — queries, mutations, actions, streams and workflow starts, whether or not the descriptor declares a guard — and `Unauthenticated` of every event's. They used to ride only a procedure with an enforced `guards:` entry, on the reasoning that an open procedure cannot produce them. It can: the auth middleware refuses an open procedure with a rejected credential exactly as it refuses a guarded one, and a handler may refuse on its own with `assertAuthenticated` or `requireScope`. On a procedure without a guard the server judged that handler-side refusal undeclared and collapsed it, so the browser received `{ _tag: 'InternalError', message: 'Unauthenticated: …' }` — the typed error's name inside a defect that no `_tag` match and no credential-refresh policy could see — and the generated client had no schema to decode the middleware's own refusal with. Both ends now carry the same union, so the value arrives as `{ _tag: 'Unauthenticated' }` on the failure channel over `POST /rpc` and over the WebSocket, and a gRPC surface maps it to `UNAUTHENTICATED` rather than `FAILED_PRECONDITION`. Measured under `voltro dev` and `voltro serve` with a validly signed, expired JWT driven through the real client over the socket.
76
+ - **`voltro build` walks an api app's module closure once, not once per handler file** — `@voltro/cli`
77
+
78
+ Each api artefact (`apiEntry.js`, the serve bundle) is preceded by a closure-discovery pass that only reads esbuild's metafile. It used to hand esbuild every discovery file as its own entry point, and without code splitting esbuild links every entry point separately, each on its own copy of the module graph, all at once. On a synthetic api app of 6,747 modules (4,514 handler files) that probe alone peaked at 4.3 GiB in the esbuild service — ten times the bundle it precedes — and it ran twice per build, so the whole build peaked at 5–6 GiB and could be refused by a shared host with less free memory than that. The probe now links one generated entry that imports every discovery file; the closure it finds is the same set. Measured on the same app the build's peak is under 2 GiB (esbuild 1.9 GiB, Node 0.5 GiB), at 3,048 modules 1.0 GiB, at 1,047 modules 0.9 GiB. The build page documents the expectation.
79
+ - **The file-migration `phase` rewrite reaches aliased, held and nested specs** — `@voltro/cli`
80
+
81
+ For a project crossing 0.78.0, the `0.78.0/01_file-migration-phase` rewrite now also finds `migration` imported under another name, a spec held in a local `const`, and migrations in subdirectories of `migrations/`. All three are shapes the runner discovers, and the rewrite used to skip them without a word. A spec built outside the call (by a helper, or imported) is not rewritten, because the change would land in code other callers share. Instead the rewrite names it by file and line. `migration()` still refuses a spec without a phase when the file loads.
82
+ - **A subscription refused in-band reaches the error bus and the refresh policy** — `@voltro/client`
83
+
84
+ A guard's refusal on a subscription travels in-band — a `{ _tag: 'error' }` event on that one stream, so a sibling subscription on the shared socket is not stalled. The subscription cache set the snapshot's error from it and told nobody else: the api's error bus, which `wireAuthRefresh` and `useOnRpcError` listen on, heard only streams that failed outright. So an expired credential on a read never asked the host for a fresh token and never reached an error reporter. The in-band value is now emitted to the bus with `source: 'subscription'` and `kind: 'handler'`, the same event a failed stream produces.
85
+ - **`voltro support collect` no longer fails on a temporary directory under src/** — `@voltro/cli`
86
+
87
+ The conflict-key inventory in `voltro support collect` found its files with a glob that skips dot-directories, then loaded them through a call that walked the directories again. So a temporary directory that an editor, a test run or a build tool creates and removes under `src/` could fail the whole collection with `Directory not found`, and an unreadable dot-directory failed it with `EACCES`. Only the files the glob returned are loaded now. A file removed between the two steps is skipped.
88
+ - **Tenant namespaces on MySQL / MariaDB provision without USE and take the charset** — `@voltro/database`, `@voltro/cli`
89
+
90
+ Provisioning a tenant namespace on the mysql family emitted `USE <database>` followed by unqualified DDL. MySQL 8.4 refuses `USE` in the prepared protocol (errno 1295), so under `tenancy.isolation: 'namespace'` no tenant could be provisioned on MySQL at all; on MariaDB the `USE` outlived its transaction on the pooled connection, which then served its next unqualified statement in the tenant database. The tenant DDL is now database-qualified, as the store's namespace view already was. The declared store charset reaches the tenant too: a new tenant database is created with it as its default and its tables carry it. A tenant provisioned before the declaration is named per namespace by `voltro db plan` and `voltro doctor` rather than silently left as it was.
91
+ - **`voltro update` moves the `@voltro/*` pins in `pnpm-workspace.yaml` too** — `@voltro/cli`
92
+
93
+ `voltro update` used to bump only the package.json files. A pnpm workspace that also names framework versions in `pnpm-workspace.yaml` was left behind in two ways. `overrides:` entries held the tree on the old release. And `minimumReleaseAgeExclude:` entries (`'@voltro/<pkg>@<version>'`, which admit a release younger than pnpm's release-age window) went stale, so pnpm refused the new release for that whole window. Both blocks now move with the bump. An entry moves only when it names exactly the version being left; an entry at any other version is printed as `kept`. A block written as a flow list is named rather than rewritten. The packages named there count for `--wait`, and the file is restored along with the package.json files when an install fails before anything lands.
94
+ - **`voltro update --wait` waits up to 45 minutes, longer than a release holds** — `@voltro/cli`
95
+
96
+ `voltro update --wait` polls the registry until every package it bumps is published at the target version, `@voltro/dashboard` included. It used to give up after 20 minutes. A release, though, holds `latest` for up to 30 minutes after its other packages finish uploading while `@voltro/dashboard` is still being built. So an update that started polling at the first upload could fail after a release that went on to complete normally. The ceiling is now 45 minutes, which covers the release's hold plus its upload phase. The update still reports the late packages while it waits, and on timeout it still restores the bumps byte for byte.
97
+
98
+ ---
99
+
42
100
  ## [0.78.0] — 2026-09-22
43
101
 
44
102
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -844,6 +844,15 @@ export declare const composeAuthStrategies: (strategies: ReadonlyArray<AuthStrat
844
844
  * function owns the anonymous subject).
845
845
  */
846
846
  readonly anonymousTenantId?: string;
847
+ /**
848
+ * The tenant an anonymous caller resolves to when its request names none
849
+ * in `x-tenant`. The header still wins when present — this only replaces
850
+ * the `null` tenant. It exists so a boot path can supply a default tenant
851
+ * without a `fallback`: a `fallback` owns the rejected-credential decision,
852
+ * this does not, so a presented-and-rejected credential is still refused.
853
+ * Ignored when `anonymousTenantId` or `fallback` is set.
854
+ */
855
+ readonly anonymousTenantDefault?: string;
847
856
  /**
848
857
  * The subject's memberships — the ONE source, strategy-independent. Read
849
858
  * only when a stored tenant selection exists for the subject and names a
@@ -2193,7 +2202,7 @@ export declare const eventToRpc: <Name extends string, Key extends Schema.Schema
2193
2202
  /** `buffer` — evicted before this subscriber could be served.
2194
2203
  * `resume` — the re-attach asked for a serial older than the ring. */
2195
2204
  reason: Schema.Literal<["buffer", "resume"]>;
2196
- }>]>, Schema.Schema.Any | Schema.Schema<never, never, unknown>>, typeof Schema.Never, never>;
2205
+ }>]>, Schema.Schema.Any>, typeof Schema.Never, never>;
2197
2206
 
2198
2207
  /**
2199
2208
  * The WEBHOOK audience of a declared event.
@@ -2408,16 +2417,9 @@ export declare const hasEffectiveScope: (subject: Subject, scope: string) => boo
2408
2417
  /**
2409
2418
  * Does this procedure carry a guard that can actually REFUSE?
2410
2419
  *
2411
- * A declared `openAccess:` is an access decision, not a check — nothing can fail
2412
- * with a `ScopeError`, so it neither widens the wire union nor makes a decode
2413
- * failure worth annotating.
2414
- *
2415
- * One function because it now has two readers, and this repo's per-seam scar is
2416
- * exactly a predicate that got copied: `withGuardError` decides the wire error
2417
- * union, and `strictInput`'s label decides whether a decode failure says the
2418
- * guard did not run. Two copies would eventually disagree about `openAccess:`,
2419
- * and the disagreement would surface as a procedure that says "guarded" while
2420
- * declaring no `ScopeError` — or the reverse.
2420
+ * A declared `openAccess:` is an access decision, not a check — no guard runs,
2421
+ * so a decode failure on an open procedure must not say "the guard did not
2422
+ * run" as if one had been skipped. `strictInput`'s label is the reader.
2421
2423
  */
2422
2424
  export declare const hasEnforcedGuard: (guards: DeclaredAccess | undefined) => boolean;
2423
2425
 
@@ -6824,7 +6826,7 @@ export declare interface WebSocketGatewayRoute {
6824
6826
  * - the `requiresApproval` refusals, for the same reason.
6825
6827
  *
6826
6828
  * The consumer diagnosed it as "the merge only happens on the streaming path".
6827
- * The merge happens on BOTH — `withGuardError` is called by every lifter. What
6829
+ * The merge happens on BOTH — `withAccessErrors` is called by every lifter. What
6828
6830
  * differs is that a QUERY is delivered through `wireErrorFromCause`, which
6829
6831
  * preserves the tag and never consults a declared set, while a mutation goes
6830
6832
  * through the check above. Their observation was exact and their fix — declaring
@@ -7852,8 +7854,8 @@ export declare const WorkflowUpdateResultSchema: Schema.Struct<{
7852
7854
  /**
7853
7855
  * The error union a WORKFLOW START puts on the wire — the workflow's own
7854
7856
  * `error:` plus what the framework can refuse the start with before the engine
7855
- * is asked: the guard refusals (when it declares an enforced guard) and any
7856
- * plugin error the `interceptWorkflow` chain may fail with. Read by the two
7857
+ * is asked: the two access refusals (see `withAccessErrors`) and any plugin
7858
+ * error the `interceptWorkflow` chain may fail with. Read by the two
7857
7859
  * boot paths (the rpc group AND `bindMutation`'s declared set) and by
7858
7860
  * `workflowToRpc` for the generated client — one function, so a `ScopeError`
7859
7861
  * on a start decodes as the typed failure it is rather than crossing as the
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as e, A as t, B as n, C as r, D as i, E as a, F as o, G as s, H as c, I as l, J as ee, K as te, L as ne, M as re, N as ie, O as ae, P as oe, Q as se, R as ce, S as le, T as ue, U as de, V as fe, W as pe, X as me, Y as he, Z as ge, _ as u, a as _e, b as d, c as ve, d as ye, f as be, g as xe, h as Se, i as Ce, j as we, k as Te, l as Ee, m as De, n as Oe, o as f, p as ke, q as Ae, r as je, s as Me, t as Ne, u as Pe, v as Fe, w as Ie, x as Le, y as Re, z as ze } from "./scopes-D2dnEr4U.js";
1
+ import { $ as e, A as t, B as n, C as r, D as i, E as a, F as o, G as s, H as c, I as l, J as ee, K as te, L as ne, M as re, N as ie, O as ae, P as oe, Q as se, R as ce, S as le, T as ue, U as de, V as fe, W as pe, X as me, Y as he, Z as ge, _ as u, a as _e, b as d, c as ve, d as ye, f as be, g as xe, h as Se, i as Ce, j as we, k as Te, l as Ee, m as De, n as Oe, o as f, p as ke, q as Ae, r as je, s as Me, t as Ne, u as Pe, v as Fe, w as Ie, x as Le, y as Re, z as ze } from "./scopes-BbIyym8y.js";
2
2
  import { C as Be, D as Ve, E as He, S as Ue, T as We, _ as Ge, a as Ke, b as qe, c as Je, d as Ye, f as Xe, g as Ze, h as Qe, i as $e, l as et, m as tt, n as nt, o as rt, p as it, r as at, s as ot, t as st, u as ct, v as lt, w as ut, x as dt, y as ft } from "./restFormat-ChSVyufA.js";
3
3
  import { Context as pt, Layer as mt, Schema as p } from "effect";
4
4
  import { Rpc as m } from "@effect/rpc";
@@ -365,13 +365,12 @@ var gt = p.Union(p.String, p.Number), h = p.Record({
365
365
  overridesPlugin: e.overridesPlugin,
366
366
  guards: O(e.name, e.guards, e.openAccess),
367
367
  openAccess: e.openAccess
368
- }), M = (e, t) => t && t.length > 0 ? p.Union(e, ...t) : e, N = (e) => e !== void 0 && e.some((e) => !u(e)), nn = (e, t) => N(t) ? p.Union(e, f, l) : e, rn = (e) => p.Union(e, Tt), an = (e) => p.Union(e, Dt, Ot), on = (e, t) => t === void 0 ? e : p.Union(e, It), P = (e, t) => {
369
- if (t === "stream") return e.error;
370
- let n = nn(e.error, e.guards);
371
- if (t === "query") return n;
368
+ }), M = (e, t) => t && t.length > 0 ? p.Union(e, ...t) : e, N = (e) => e !== void 0 && e.some((e) => !u(e)), nn = (e) => p.Union(e, f, l), rn = (e) => p.Union(e, Tt), an = (e) => p.Union(e, Dt, Ot), on = (e, t) => t === void 0 ? e : p.Union(e, It), P = (e, t) => {
369
+ let n = nn(e.error);
370
+ if (t === "stream" || t === "query") return n;
372
371
  let r = t === "mutation" ? p.Union(rn(n), dt, ht) : n;
373
372
  return on(an(t === "mutation" && e.idempotencyScope === "connection" ? p.Union(r, qe) : r), e.requiresApproval);
374
- }, sn = (e, t) => M(nn(e.errorSchema, e.guards), t), cn = (e, t) => ({
373
+ }, sn = (e, t) => M(nn(e.errorSchema), t), cn = (e, t) => ({
375
374
  ...e,
376
375
  name: t
377
376
  }), ln = (e, t) => m.make(e.name, {
@@ -501,7 +500,7 @@ var gt = p.Union(p.String, p.Number), h = p.Record({
501
500
  key: e,
502
501
  resume: p.optional(p.Array(Cn))
503
502
  }), Tn = (e, t) => {
504
- let n = e.guards !== void 0 && e.guards.some((e) => !u(e)) ? p.Union(f, l) : p.Never, r = t && t.length > 0 ? p.Union(n, ...t) : n;
503
+ let n = e.guards !== void 0 && e.guards.some((e) => !u(e)) ? p.Union(f, l) : l, r = t && t.length > 0 ? p.Union(n, ...t) : n;
505
504
  return m.make(e.name, {
506
505
  payload: x(wn(e.key), e.name, N(e.guards)),
507
506
  success: Sn(e.payload),
package/dist/rest.js CHANGED
@@ -1,4 +1,4 @@
1
- import { I as e, L as t } from "./scopes-D2dnEr4U.js";
1
+ import { I as e, L as t } from "./scopes-BbIyym8y.js";
2
2
  import { C as n, S as r, T as i, _ as a, a as o, c as s, d as c, f as l, i as u, l as d, n as f, o as p, r as m, s as h, t as g, u as _, v, w as y } from "./restFormat-ChSVyufA.js";
3
3
  import { Effect as b, ParseResult as x, Schema as S } from "effect";
4
4
  import * as C from "effect/SchemaAST";
@@ -300,7 +300,7 @@ var i = (e) => e.type === "user" || e.type === "apiKey" || e.type === "serviceAc
300
300
  }
301
301
  if (u !== void 0) throw new w({ reason: `credential rejected: ${u}` });
302
302
  if (t?.anonymousTenantId !== void 0) return { subject: _(t.anonymousTenantId) };
303
- let d = l.headers["x-tenant"] ?? null;
303
+ let d = l.headers["x-tenant"] ?? t?.anonymousTenantDefault ?? null;
304
304
  if (d === null && t?.anonymousTenantRequired === !0) throw new w({ reason: "tenant required (x-tenant header missing)" });
305
305
  return { subject: _(d) };
306
306
  };
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { E as e, J as t, P as n } from "./scopes-D2dnEr4U.js";
1
+ import { E as e, J as t, P as n } from "./scopes-BbIyym8y.js";
2
2
  import { Schema as r } from "effect";
3
3
  import { createHmac as i, timingSafeEqual as a } from "node:crypto";
4
4
  import { createLogger as o } from "@voltro/logger";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.78.0",
3
+ "version": "0.79.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",
@@ -65,8 +65,8 @@
65
65
  "dependencies": {
66
66
  "@effect/experimental": "^0.61.1",
67
67
  "@effect/sql": "^0.52.1",
68
- "@voltro/database": "0.78.0",
69
- "@voltro/logger": "0.78.0",
68
+ "@voltro/database": "0.79.0",
69
+ "@voltro/logger": "0.79.0",
70
70
  "jose": "^6.2.12"
71
71
  },
72
72
  "peerDependencies": {