@voltro/cli 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.
Files changed (170) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  3. package/dist/agentsMd-SDDSkyl4.js +2 -0
  4. package/dist/apiBuild-BYBpL7Pz.js +2 -0
  5. package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
  6. package/dist/appGraph-CFmFKHb2.js +2 -0
  7. package/dist/appGraph-yRuKjOba.js +111 -0
  8. package/dist/bin.js +1 -1
  9. package/dist/build-CPgcMQug.js +793 -0
  10. package/dist/checkCommand-2SbqzukH.js +2 -0
  11. package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
  12. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  13. package/dist/codegen-CctkDO-1.js +2 -0
  14. package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
  15. package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
  16. package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
  17. package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
  18. package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
  19. package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
  20. package/dist/dbCommand-DNb6yeOG.js +2 -0
  21. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
  22. package/dist/dev--A3nsxA3.js +3 -0
  23. package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
  24. package/dist/doctorCommand-CqoWA2p5.js +2 -0
  25. package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
  26. package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
  27. package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
  28. package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
  29. package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
  30. package/dist/fileConventions-DOqD3lPS.js +34 -0
  31. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
  32. package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
  33. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
  34. package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
  37. package/dist/inspectMetrics-CGF94puw.js +143 -0
  38. package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
  39. package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
  40. package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
  41. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
  42. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  43. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  44. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
  45. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
  46. package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
  47. package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
  48. package/dist/serveCommand-DLc-BznW.js +2 -0
  49. package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/start-DfL3fOiN.js +3 -0
  52. package/dist/start-jw89Xbqy.js +1339 -0
  53. package/dist/startEntry.js +1 -1
  54. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
  55. package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
  56. package/dist/updateCommand-5gFVfK5q.js +2 -0
  57. package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
  58. package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
  59. package/dist/webDev-CZbTsDcH.js +2 -0
  60. package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
  61. package/package.json +48 -17
  62. package/templates/AGENTS.core.md +26 -5
  63. package/templates/AGENTS.md +30 -9
  64. package/templates/agent-docs/_index.md +4 -4
  65. package/templates/agent-docs/_manifest.json +11 -11
  66. package/templates/agent-docs/cli.md +125 -14
  67. package/templates/agent-docs/data.md +210 -7
  68. package/templates/agent-docs/database/schema.md +1 -1
  69. package/templates/agent-docs/database/seedsdialects.md +1 -1
  70. package/templates/agent-docs/deployment.md +22 -3
  71. package/templates/agent-docs/internationalization.md +131 -13
  72. package/templates/agent-docs/introduction.md +46 -0
  73. package/templates/agent-docs/local-first-mobile.md +34 -7
  74. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  75. package/templates/agent-docs/plugins/audit.md +5 -5
  76. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  77. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  78. package/templates/agent-docs/plugins/storage.md +2 -2
  79. package/templates/agent-docs/plugins.md +29 -7
  80. package/templates/agent-docs/reference.md +39 -2
  81. package/templates/agent-docs/routing.md +341 -47
  82. package/templates/agent-docs/schema-driven-ui.md +78 -2
  83. package/templates/agent-docs/security.md +125 -8
  84. package/templates/agent-docs/templates/apibackends.md +14 -14
  85. package/templates/agent-docs/templates/overview.md +1 -1
  86. package/templates/agent-docs/whats-new.md +100 -29
  87. package/templates/apps/api-ai/package.json +6 -7
  88. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  89. package/templates/apps/api-auth/package.json +8 -8
  90. package/templates/apps/api-backend/package.json +7 -7
  91. package/templates/apps/api-backend-deactivation/package.json +7 -7
  92. package/templates/apps/api-backend-mail/package.json +8 -8
  93. package/templates/apps/api-backend-mariadb/package.json +9 -9
  94. package/templates/apps/api-backend-sqlite/package.json +8 -8
  95. package/templates/apps/api-backend-storage/package.json +8 -8
  96. package/templates/apps/api-cms/package.json +9 -10
  97. package/templates/apps/api-collab/package.json +8 -8
  98. package/templates/apps/api-data-advanced/package.json +8 -8
  99. package/templates/apps/api-durable/package.json +8 -8
  100. package/templates/apps/api-feature-flags/package.json +9 -9
  101. package/templates/apps/api-governance/package.json +8 -8
  102. package/templates/apps/api-kv/package.json +8 -8
  103. package/templates/apps/api-moderation/package.json +8 -8
  104. package/templates/apps/api-observability/package.json +8 -8
  105. package/templates/apps/api-ratelimit/package.json +8 -8
  106. package/templates/apps/api-rbac/package.json +8 -8
  107. package/templates/apps/api-rest/package.json +7 -7
  108. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  109. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  110. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  111. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  112. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  113. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  114. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  115. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  116. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  117. package/templates/apps/api-row-history/template.json +6 -0
  118. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  119. package/templates/apps/api-saas/app.config.ts +1 -0
  120. package/templates/apps/api-saas/package.json +10 -11
  121. package/templates/apps/api-saas-starter/package.json +10 -10
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/package.json +8 -8
  124. package/templates/apps/api-webhooks/package.json +9 -9
  125. package/templates/apps/changelog/package.json +6 -6
  126. package/templates/apps/edge-functions/package.json +2 -2
  127. package/templates/apps/frontend-admin/package.json +8 -8
  128. package/templates/apps/frontend-app/package.json +9 -9
  129. package/templates/apps/frontend-auth/package.json +8 -8
  130. package/templates/apps/frontend-blank/package.json +7 -7
  131. package/templates/apps/frontend-cms/package.json +9 -9
  132. package/templates/apps/frontend-collab/package.json +10 -10
  133. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  134. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  135. package/templates/apps/frontend-contact/package.json +7 -7
  136. package/templates/apps/frontend-dashboard/package.json +7 -7
  137. package/templates/apps/frontend-docs/package.json +6 -7
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +6 -7
  140. package/templates/apps/frontend-portal/package.json +8 -8
  141. package/templates/apps/frontend-saas/package.json +8 -8
  142. package/templates/apps/frontend-spa/package.json +7 -7
  143. package/templates/apps/frontend-ssr/package.json +7 -7
  144. package/templates/apps/frontend-ssr-api/package.json +8 -8
  145. package/templates/apps/frontend-static-blog/package.json +6 -6
  146. package/templates/apps/frontend-status/package.json +8 -8
  147. package/templates/apps/mobile-app/package.json +4 -4
  148. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  149. package/dist/apiBuild-BZ-plaQN.js +0 -2
  150. package/dist/appGraph-CM9wvUmD.js +0 -2
  151. package/dist/appGraph-Cv2W_wO5.js +0 -111
  152. package/dist/build-CFmqF39c.js +0 -721
  153. package/dist/checkCommand-DomvYDi1.js +0 -2
  154. package/dist/codegen-DiMn2KkZ.js +0 -2
  155. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  156. package/dist/dev-Dz9GqA6P.js +0 -3
  157. package/dist/doctorCommand-BlciWUJb.js +0 -2
  158. package/dist/fileConventions-Cof68_BL.js +0 -33
  159. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  160. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  161. package/dist/serveCommand-Bbgg7Paq.js +0 -2
  162. package/dist/start-7b_bL8Uc.js +0 -3
  163. package/dist/start-B1nH9ahf.js +0 -1150
  164. package/dist/updateCommand-BweY17z-.js +0 -2
  165. package/dist/webDev-BcRFTsMA.js +0 -2
  166. package/templates/apps/api-versioning/template.json +0 -6
  167. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  168. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  169. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  170. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -2195,6 +2195,35 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
2195
2195
  budgeted here is the matcher and re-query CPU each node spends on ITS OWN
2196
2196
  clients.
2197
2197
 
2198
+ ## Raw WebSocket gateways — `defineWebSocket`
2199
+
2200
+ Everything above rides the framework's subscription protocol, and that stays the answer for app realtime — live queries, optimistic patches, reconnect. A **gateway** exists for the other case: a FOREIGN protocol that needs a socket the framework does not speak — a Yjs provider, a legacy device fleet, an MQTT-over-WS bridge. It mounts its own upgrade path beside the rpc socket, in a `*.ws.ts` file discovered on **both** boot paths:
2201
+
2202
+ ```ts
2203
+ // gateways/yjs.ws.ts
2204
+ import { defineWebSocket } from '@voltro/protocol'
2205
+
2206
+ export default defineWebSocket({
2207
+ path: '/gateways/yjs',
2208
+ auth: 'subject', // REQUIRED, no default: 'subject' | 'public'
2209
+ onConnection: ({ send, close, onMessage, subject, headers, path }) => {
2210
+ const doc = attachDoc(subject!.id)
2211
+ onMessage((data) => doc.applyUpdate(data)) // binary-safe frames
2212
+ const stop = doc.onUpdate((update) => send(update))
2213
+ return () => { stop(); doc.release() } // teardown
2214
+ },
2215
+ })
2216
+ ```
2217
+
2218
+ The contract, in the order it protects you:
2219
+
2220
+ - **`auth` is mandatory and has no default.** `'subject'` runs the SAME auth chain as rpc/SSR *before* the upgrade — an unauthenticated caller gets `401` while the request is still plain http, and the connection is bound to the credential's expiry: when it lapses, the socket closes with application code `4001`, so a foreign client can re-auth and reconnect. `'public'` is a deliberate, written-down decision (a device fleet with protocol-level auth of its own).
2221
+ - **Every gateway path is origin-checked at upgrade** — cross-origin means `403`, which closes cross-site WebSocket hijacking for your protocol exactly as for the framework's socket.
2222
+ - **`onConnection({ send, close, onMessage, subject, headers, path })`** may return a teardown function — it runs on client disconnect, on credential expiry, and on server shutdown, so whatever the handler opened cannot outlive the socket.
2223
+ - A plain GET on a gateway path answers `426 Upgrade Required`; two gateways declaring one path refuse the boot.
2224
+
2225
+ **The boundary to keep:** if your own UI needs live data, that is a query + `useSubscription`, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.
2226
+
2198
2227
  ## See also
2199
2228
 
2200
2229
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -2940,6 +2969,44 @@ const requireApiKey: RestGuard = (ctx) =>
2940
2969
  ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }
2941
2970
  ```
2942
2971
 
2972
+ ## Methods — PATCH, HEAD and OPTIONS are first-class
2973
+
2974
+ `method:` accepts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`, on REST routes and plugin HTTP routes alike. Two details:
2975
+
2976
+ - **HEAD is admitted wherever GET is** (RFC 9110): a `HEAD` request runs the GET route's whole pipeline — method gate, guards, handler — and the transport drops the body. You never declare a second route for it.
2977
+ - **A wrong method is still a precise `405`**, with an `Allow:` header naming exactly the methods mounted on that path — including when several routes share one path.
2978
+
2979
+ ## Body limits — `maxBodyBytes`
2980
+
2981
+ Every HTTP body read is capped at 8 MiB by default — `POST /rpc` (as it always was), plugin routes, REST routes and incoming webhooks. The app-wide cap is `http.maxBodyBytes` in `app.config.ts` (env override `VOLTRO_MAX_BODY_BYTES`); a route that legitimately takes more declares its own:
2982
+
2983
+ ```ts
2984
+ export default defineRestRoute({
2985
+ method: 'POST',
2986
+ path: '/v1/import',
2987
+ maxBodyBytes: 64 * 1024 * 1024, // this route only — the app cap stays 8 MiB
2988
+ // …
2989
+ })
2990
+ ```
2991
+
2992
+ The same per-route override exists on an incoming webhook's handler (`maxBodyBytes`) — fat provider payloads are the normal case there, not the exception. Two details:
2993
+
2994
+ - **Routes that share one PATH share one body read.** The body is read once for the whole group, so the widest `maxBodyBytes` override in the group applies to the group.
2995
+ - An oversized body answers `413` whether it announces itself (`Content-Length`) or arrives chunked — the counter cuts it at the cap and never buffers past it.
2996
+
2997
+ ## Conditional GET — `etag: true`
2998
+
2999
+ ```ts
3000
+ export default defineRestRoute({
3001
+ method: 'GET',
3002
+ path: '/v1/customers',
3003
+ etag: true, // GET only — ignored elsewhere
3004
+ // …
3005
+ })
3006
+ ```
3007
+
3008
+ The route stamps a **weak, content-derived** `ETag` (`W/"<sha-1 of the encoded output>"`) on every `200`, and answers a matching `If-None-Match` with `304 Not Modified` — the tag, no body. Weak on purpose: the transport may vary the BYTES per content-encoding, but the representation is the same. (`voltro start` does the equivalent for the web app's HTML on its own — `If-None-Match` answers `304` for buffered `200`s, with weak `W/"md5"` tags over the *uncompressed* body; `no-store` responses excepted.)
3009
+
2943
3010
  ## Idempotency (`Idempotency-Key`)
2944
3011
 
2945
3012
  Set `idempotency: true` in `app.config.ts` and every mutating REST request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries an `Idempotency-Key` header is deduplicated:
@@ -2970,6 +3037,56 @@ This is the Stripe-style contract — the **client** opts in by sending the head
2970
3037
  - **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
2971
3038
  - **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
2972
3039
 
3040
+ ## API versions — opt-in `version:` + the sunset flow
3041
+
3042
+ A route that will evolve declares its version instead of baking it into the
3043
+ path; `version: 'v2'` mounts under `/v2/…`:
3044
+
3045
+ ```ts
3046
+ // v2 — the current shape
3047
+ export const listCustomers = defineRestRoute({
3048
+ method: 'GET',
3049
+ path: '/customers',
3050
+ version: 'v2',
3051
+ output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
3052
+ handler: async (_i, ctx) => ({ data: await ctx.store.query(customers), nextCursor: null }),
3053
+ })
3054
+
3055
+ // v1 — still mounted, deprecated, and gone on a date
3056
+ export const listCustomersV1 = defineRestRoute({
3057
+ method: 'GET',
3058
+ path: '/customers',
3059
+ version: 'v1',
3060
+ deprecated: 'GET /v2/customers', // Deprecation header + replacement pointer
3061
+ sunset: '2027-03-01', // Sunset header; 410 Gone from this date
3062
+ output: Schema.Struct({ customers: Schema.Array(Customer) }),
3063
+ handler: async (_i, ctx) => ({ customers: await ctx.store.query(customers) }),
3064
+ })
3065
+ ```
3066
+
3067
+ Two versions are **two descriptors** — the old one is ordinary code (visible,
3068
+ testable, deletable), not an entry in a transformation DSL. While it lives,
3069
+ responses carry `Deprecation: true` + `Sunset:`; past the date it answers
3070
+ `410 Gone` with `{ version: 'v1', replacement: 'GET /v2/customers' }`. Then
3071
+ you delete it. `version` is opt-in: a route without it keeps its literal path
3072
+ (no auto-prefix), and declaring `version:` on a path that already starts with
3073
+ `/vN/` is refused at definition — both spellings at once is never intended.
3074
+ `publicApi` projections version the same way (`spec.version`, default `v1`),
3075
+ and the OpenAPI doc groups each version's operations under a version tag with
3076
+ `x-voltro-api-version` — one document, the `/vN/` paths already separate them.
3077
+
3078
+ **URI versioning only, on purpose.** Header- and media-type-versioning (the
3079
+ NestJS options) are not supported: the OpenAPI document, cache keys and plain
3080
+ `curl` are all path-shaped, and a version a URL cannot express is a version a
3081
+ cached response cannot vary on. If an edge must accept `Accept-Version:`
3082
+ headers, rewrite them to the path prefix at the proxy.
3083
+
3084
+ **And the rpc socket is deliberately outside this.** The generated client is
3085
+ versioned with the server it was generated from — there is no `/v2` for
3086
+ `useMutation`. Honest edge: a browser tab that stayed open across your deploy
3087
+ runs the PREVIOUS client until reload; that skew window exists, it is small,
3088
+ and URL versioning would not remove it.
3089
+
2973
3090
  ## Projecting an existing procedure — `publicApi`
2974
3091
 
2975
3092
  You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
@@ -3026,6 +3143,45 @@ Same guarantees as the WebSocket path, because it is the same code: the declarat
3026
3143
 
3027
3144
  For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
3028
3145
 
3146
+ ## Binary downloads — `bytes()`
3147
+
3148
+ A handler that serves a file, an export or any non-JSON body returns `bytes(stream, options)` — imported beside `defineRestRoute` / `sse`:
3149
+
3150
+ ```ts
3151
+ import { defineRestRoute, bytes, requireScope } from '@voltro/protocol/rest'
3152
+
3153
+ export default defineRestRoute({
3154
+ method: 'GET',
3155
+ path: '/v1/exports/:id',
3156
+ guards: [requireScope('exports:read')],
3157
+ handler: async ({ params }, ctx) => {
3158
+ const file = await locateExport(params.id)
3159
+ // Lazy thunk form — the source is opened only when the response streams.
3160
+ return bytes(() => openExportStream(file), {
3161
+ contentType: 'application/zip',
3162
+ contentLength: file.size,
3163
+ contentDisposition: `attachment; filename="${file.name}"`,
3164
+ })
3165
+ },
3166
+ })
3167
+ ```
3168
+
3169
+ - The first argument is a web `ReadableStream<Uint8Array>` — or the **lazy thunk form** `() => ReadableStream`, which defers opening the source until the response actually streams.
3170
+ - The server **pipes without buffering** — a body larger than the heap is fine (the guarantee is exercised with a 256-MiB stream), and byte streams are **never compressed**.
3171
+ - Everything before the handler still runs — method gate, sunset, input decode, guards — so a streaming route is exactly as gated as a buffered one.
3172
+ - On a plugin HTTP route the same shape is `PluginHttpRouteResult.byteStream`.
3173
+
3174
+ ### Idempotency × streams — decided
3175
+
3176
+ `streaming: true` on a method the idempotency binding claims (`POST`/`PUT`/`PATCH`/`DELETE`) is a **mount error**: a stream cannot cache a replayable body, so the idempotency claim could never complete — every retry would `409` until the TTL lapsed. The refusal names the two ways out: serve the stream on `GET`, or keep the idempotency binding away from the app's streaming routes. A handler that returns a stream *without* declaring `streaming: true` is caught at runtime instead — the claim is **released** so a retry re-processes.
3177
+
3178
+ ## No multipart parser — a declared boundary
3179
+
3180
+ There is **no multipart parser** on REST or webhook routes — `multipart/form-data` against `/form/*` answers `415`, and a REST handler never sees parsed file parts. That boundary is deliberate, and this list of alternatives is complete:
3181
+
3182
+ - **File uploads** ride [`@voltro/plugin-storage`](/docs/plugins/storage)'s upload routes — a binary PUT plus a resumable, chunked upload with signed tickets. That is the sanctioned file path, not a workaround.
3183
+ - **A provider that delivers webhooks as multipart** (the Mailgun-inbound class) needs, today, either a small parser proxy in front of the endpoint or the provider's JSON delivery mode where it offers one.
3184
+
3029
3185
  ## REST route vs Action
3030
3186
 
3031
3187
  Both are unary request/response. Pick by transport + audience:
@@ -4464,7 +4620,7 @@ rather than letting whichever loaded last silently win.
4464
4620
  ## Retries, backoff, dead-letter
4465
4621
 
4466
4622
  | | |
4467
- |---|---|
4623
+ | --- | --- |
4468
4624
  | Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
4469
4625
  | Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
4470
4626
  | Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
@@ -4530,7 +4686,7 @@ delivery-history screen renders. So every attempt appends a row to
4530
4686
  `_voltro_outbox_attempts`:
4531
4687
 
4532
4688
  | column | |
4533
- |---|---|
4689
+ | --- | --- |
4534
4690
  | `outboxId` | the entry this attempt belongs to |
4535
4691
  | `effect` | denormalised — the history stays readable after the entry is purged |
4536
4692
  | `attempt` | 1-indexed, monotonic across the entry's whole life |
@@ -4626,6 +4782,20 @@ bound.
4626
4782
  - **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
4627
4783
  is built for reverse-ETL with per-pipe ordering.
4628
4784
 
4785
+ ## There is no generic job queue — take X for Y
4786
+
4787
+ Voltro deliberately ships no `defineJob` primitive (priorities, worker pools, a
4788
+ BullMQ equivalent). The outbox, [workflows](/docs/workflows/overview) and
4789
+ [schedules](/docs/scheduling/overview) cover the cases between them, and a third
4790
+ durability primitive would drift from both. What to reach for instead:
4791
+
4792
+ | you want… | take |
4793
+ | --- | --- |
4794
+ | a concurrency-limited worker pool | workflows + [declarative flow control](/docs/workflows/declarative-flow-control) — `concurrency` / `throttle` bound how many runs execute at once |
4795
+ | true priority scheduling (high-priority work overtakes queued low-priority work) | does not exist as a primitive — a workflow draining **your own queue table** in your priority order is the honest build |
4796
+ | a delayed / scheduled message | `delayMs` on `enqueue` (above) for a one-off delayed effect; a [schedule](/docs/scheduling/overview) for recurring time-based work; workflow [`sleep`](/docs/workflows/sleep) for a pause inside a durable process |
4797
+ | exactly-once delivery | does not exist — delivery is at-least-once everywhere, which is the strongest guarantee available without a distributed transaction into the target; **idempotent handlers are mandatory** (see above) |
4798
+
4629
4799
  ## See also
4630
4800
 
4631
4801
  - [Mutations](/docs/data/mutations) — the transaction boundary this rides
@@ -5183,6 +5353,38 @@ const program = Effect.gen(function* () {
5183
5353
  )
5184
5354
  ```
5185
5355
 
5356
+ ### ISR revalidation on publish
5357
+
5358
+ A content type can declare which ISR routes fall when its content is
5359
+ published or unpublished — `publish()`/`unpublish()` fire
5360
+ [`revalidatePath` / `revalidateTag`](/docs/routing/render-modes#on-demand-revalidation)
5361
+ for each entry after the write commits, reaching every `voltro start`
5362
+ replica:
5363
+
5364
+ ```ts
5365
+ import { defineContentType, Schema } from '@voltro/cms'
5366
+
5367
+ const blogPost = defineContentType({
5368
+ name: 'blogPost',
5369
+ displayName: 'Blog post',
5370
+ pluralName: 'Blog posts',
5371
+ fields: {
5372
+ title: Schema.String.pipe(Schema.maxLength(200)),
5373
+ body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
5374
+ },
5375
+ revalidate: {
5376
+ paths: ['/blog/[slug]', '/blog'],
5377
+ tags: ['blog'],
5378
+ },
5379
+ })
5380
+ ```
5381
+
5382
+ You don't need this on postgres for the plain publish case: a route declaring
5383
+ `cacheInvalidatesOn: ['blogPost_published']` is already dropped by CDC when
5384
+ the published table changes. Declare `revalidate` for what CDC can't see —
5385
+ non-postgres dialects, routes whose loaders read the content indirectly, or
5386
+ tag fanout across several routes.
5387
+
5186
5388
  ## The engine, standalone
5187
5389
 
5188
5390
  The validation/derivation engine is pure and exported on its own (also on
@@ -5269,15 +5471,16 @@ list; `mediaFields(type)` lists the top-level media field names.
5269
5471
  ## Versioning content
5270
5472
 
5271
5473
  `@voltro/cms` ships no parallel revision system — the derived tables are
5272
- ordinary database tables, so `@voltro/plugin-versioning` gives full row history
5273
- + time-travel with no new machinery. List the derived table names in the
5274
- plugin's `tables` option, then read a timeline or restore a snapshot:
5474
+ ordinary database tables, so `@voltro/plugin-row-history` gives full row history
5475
+ plus time-travel with no new machinery. The plugin records every table by default
5476
+ narrow it with `include:` (pass the derived table handles) or `exclude:` if you
5477
+ only want content history — then read a timeline or restore a snapshot:
5275
5478
 
5276
5479
  ```ts no-check
5277
- import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
5480
+ import { rowHistoryPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-row-history'
5278
5481
 
5279
5482
  // Register in your app's plugin list:
5280
- versioningPlugin({})
5483
+ rowHistoryPlugin({})
5281
5484
 
5282
5485
  const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
5283
5486
  await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
@@ -712,7 +712,7 @@ yield* ctx.store.update('documents', input.id, { title: input.title, version: in
712
712
 
713
713
  **Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
714
714
 
715
- **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-versioning`](/docs/plugins/versioning) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
715
+ **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-row-history`](/docs/plugins/row-history) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
716
716
 
717
717
  **Three details worth knowing:**
718
718
 
@@ -543,7 +543,7 @@ and cannot be engineered away:
543
543
  - **`old` is null on an oversized update, and pk-only on an oversized delete.**
544
544
  There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
545
545
  row from a search index, an analytics mirror or a CDC stream; it is not a
546
- record of what the row contained, and `@voltro/plugin-versioning` writes
546
+ record of what the row contained, and `@voltro/plugin-row-history` writes
547
547
  `data: null` for one rather than a fabricated empty snapshot.
548
548
  - **`'unrecovered'` means the content is gone.** No retry can bring it back —
549
549
  it was never delivered. Subscriptions are unaffected (they re-query); taps
@@ -312,11 +312,15 @@ cause.
312
312
  When one api process isn't enough:
313
313
 
314
314
  1. Run multiple api containers behind the reverse proxy. The proxy's load-balancing default (round-robin) is fine for HTTP — for WebSocket, use sticky sessions (Caddy: `lb_policy ip_hash`).
315
- 2. Install `@voltro/plugin-cluster` in `app.config.ts.plugins`. It sets up cross-instance subscription invalidation via Postgres NOTIFY (or Redis if you set `CLUSTER_TRANSPORT=redis`).
315
+ 2. Cross-instance subscription invalidation: on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC) this is built in — nothing to install. On any other dialect, or when you prefer a broker, add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) with a Redis or NATS provider to `app.config.ts.plugins` so every replica sees every change.
316
316
  3. Workflows: `@effect/cluster` shards work across instances by workflow ID. No further config — every instance pulls from the shared workflow queue.
317
317
 
318
318
  For multi-region: you need to run Postgres logical replication between regions yourself (or move to Voltro Cloud which handles it).
319
319
 
320
+ ### Scaling is replicas, not a service split
321
+
322
+ Voltro deliberately ships no microservice transports — no `@MessagePattern`-style service-to-service RPC, no broker-backed internal messaging layer. Splitting one app into services would contradict the architecture thesis this whole page rests on: **one monolith process, scaled by running more replicas of it**, with `@effect/cluster` sharding durable work across instances by workflow ID. Every capability that a service split usually buys already has a first-class path: an external system boundary is [REST routes](/docs/data/rest-routes) + [OpenAPI](/docs/plugins/openapi) (`@voltro/plugin-openapi`), and a reliable outbound side effect is the [transactional outbox](/docs/data/outbox). If you find yourself wanting an internal message bus between "services", the answer is more replicas of the same image — not a second process shape.
323
+
320
324
  ## Backups
321
325
 
322
326
  Postgres is the source of truth. Use your provider's backup features (Neon PITR, RDS snapshots, `pg_dump` on a cron). Object-storage assets back up via the provider's lifecycle policies.
@@ -1008,6 +1012,7 @@ indexed query instead of scanning every table). Force a full re-introspect with
1008
1012
 
1009
1013
  ```sh
1010
1014
  VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
1015
+ VOLTRO_MAX_BODY_BYTES=8388608 # default 8 MiB
1011
1016
  ```
1012
1017
 
1013
1018
  A declared `Content-Length` over the cap is refused up front, so an honest client
@@ -1401,7 +1406,7 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1401
1406
  - [ ] Liveness / readiness probes point at `/internal/liveness` + `/internal/readiness`
1402
1407
  - [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
1403
1408
  - [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
1404
- - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
1409
+ - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` + `VOLTRO_MAX_BODY_BYTES` (plugin routes/webhooks) sane; ingress caps body size + per-IP rate
1405
1410
  - [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
1406
1411
  - [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
1407
1412
  - [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
@@ -1759,7 +1764,7 @@ and the drill is the part people skip.
1759
1764
  ## Which one
1760
1765
 
1761
1766
  | | scale-to-zero | managed DB | rolling deploys | cost floor |
1762
- |---|---|---|---|---|
1767
+ | --- | --- | --- | --- | --- |
1763
1768
  | Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
1764
1769
  | Railway | usage-based sleep | built-in | yes | ~0 idle |
1765
1770
  | Render | paid plans only | built-in | yes | fixed/instance |
@@ -1768,3 +1773,17 @@ and the drill is the part people skip.
1768
1773
  An api that owns cron schedules should not scale to zero. An api with bursty
1769
1774
  traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
1770
1775
  the boring answer — one always-on instance — is also the cheapest to operate.
1776
+
1777
+ ## Why there is no edge-SSR adapter
1778
+
1779
+ Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
1780
+ Node-first (`renderToPipeableStream` into a Node stream, `voltro start` as a
1781
+ long-running Node HTTP server), not a Workers/edge runtime — so there is no
1782
+ Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
1783
+ still gets first-class use where it fits the model: isolated
1784
+ [`*.serverless.ts` functions](/docs/deployment/serverless-functions)
1785
+ (`@voltro/serverless`, with Cloudflare / Scaleway / Node adapters) for
1786
+ request-shaped work at the edge, and
1787
+ [static / ISR pages](/docs/deployment/static-sites) served from a CDN for
1788
+ everything that does not need a per-request render. If a page must render per
1789
+ request, it renders in the container.
@@ -36,6 +36,13 @@ export default {
36
36
  // supported one. MUST be in `locales`. Pick your source-of-truth
37
37
  // language — typically English.
38
38
  defaultLocale: 'en' as const,
39
+ // The zone every date/time formatter renders in. Omit it and each
40
+ // runtime uses its own — the pod's on the server, the viewer's in the
41
+ // browser — which is a hydration mismatch on every SSR timestamp.
42
+ // An IANA name pins one zone for everyone; 'viewer' resolves it per
43
+ // request from the `voltro:tz` cookie.
44
+ timeZone: 'viewer' as const,
45
+ defaultTimeZone: 'UTC' as const,
39
46
  }
40
47
  ```
41
48
 
@@ -79,6 +86,18 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
79
86
 
80
87
  `<html lang>` carries the same resolved locale — the value the `<I18nProvider>` renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses.
81
88
 
89
+ ### The same contract carries the render zone and the render clock
90
+
91
+ `lang` is one of three answers the server decides and publishes so the client does not form its own:
92
+
93
+ | attribute | what it carries |
94
+ |---|---|
95
+ | `lang` | the resolved locale |
96
+ | `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
97
+ | `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
98
+
99
+ Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
100
+
82
101
  See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
83
102
 
84
103
  ## The cookie names are exported
@@ -675,7 +694,19 @@ formatDate(order.createdAt, { dateStyle: 'medium' })
675
694
  formatDate(order.createdAt, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' })
676
695
  ```
677
696
 
678
- `(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`. **Omit `timeZone` and the viewer's own zone is used** — which is what a multi-timezone app wants. Pass one only when the value genuinely belongs to a fixed zone (a store's opening hours, a scheduled broadcast). Pinning a global zone across the whole app is the anti-pattern this replaces.
697
+ `(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`.
698
+
699
+ **The zone comes from the provider, which you configure once** — `timeZone` in the web `app.config.ts` (see [Timezones under SSR](#timezones-under-ssr-the-setting-that-is-not-a-preference)). Pass `timeZone` in the options only when the value genuinely belongs to a fixed zone regardless of who is looking (a store's opening hours, a scheduled broadcast); that overrides the provider for one call.
700
+
701
+ **With `timeZone` unset in `app.config.ts` there is no zone at all, and each runtime falls back to its own.** On a server-rendered page that is the pod's zone for the markup and the viewer's for the hydration render — a mismatch on every timestamp, and a different calendar day across midnight. `useTimeZone()` returns `undefined` in exactly that state, so you can assert on it.
702
+
703
+ ### `useTimeZone`
704
+
705
+ ```tsx
706
+ const timeZone = useTimeZone() // 'Europe/Berlin' — or undefined when none is pinned
707
+ ```
708
+
709
+ `undefined` is a real answer and worth branching on: it means nobody decided, so the server and the browser are each using their own zone. It is not the same as `'UTC'`.
679
710
 
680
711
  ### `useRelativeTime`
681
712
 
@@ -688,7 +719,9 @@ relativeTime(comment.postedAt, { numeric: 'always' }) // "1 day ago" instead of
688
719
  relativeTime(comment.postedAt, { now: renderedAt }) // measure against a fixed base
689
720
  ```
690
721
 
691
- `(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests; the default base is `Date.now()`.
722
+ `(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests.
723
+
724
+ **Hydration-safe by default.** Without an explicit `now`, the base is the server's render instant for the server render *and* the hydration pass that has to match it — published as `<html data-voltro-now>` — then the live clock once hydration commits. So "3 minutes ago" cannot become "4 minutes ago" between the HTML and the first client render just because the network was slow, which is what a plain `Date.now()` base does whenever a unit boundary falls in the gap.
692
725
 
693
726
  It picks the **largest unit that fits**, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the `second` unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. `numeric: 'auto'` is the default, so English gets "yesterday" rather than "1 day ago".
694
727
 
@@ -721,7 +754,7 @@ For a component that needs several at once, without stacking five hook calls:
721
754
  import { useFormatters } from '@voltro/i18n'
722
755
 
723
756
  function ActivityRow({ entry }: { readonly entry: Entry }) {
724
- const { locale, formatDate, relativeTime, formatNumber, plural } = useFormatters()
757
+ const { locale, timeZone, formatDate, relativeTime, formatNumber, plural } = useFormatters()
725
758
 
726
759
  return (
727
760
  <li lang={locale}>
@@ -735,7 +768,77 @@ function ActivityRow({ entry }: { readonly entry: Entry }) {
735
768
  }
736
769
  ```
737
770
 
738
- It returns the active `locale` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.
771
+ It returns the active `locale` and `timeZone` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.
772
+
773
+ ## Timezones under SSR — the setting that is not a preference
774
+
775
+ A formatter is deterministic given the value, the locale, the zone and the clock. The provider supplies all four, and the two beyond locale are the ones that differ between the server and the browser:
776
+
777
+ | | Where it came from before | What that means under SSR |
778
+ |---|---|---|
779
+ | **locale** | the provider, both sides | agreed already — the server publishes `<html lang>` and the client reads the attribute rather than `navigator.languages` |
780
+ | **zone** | the runtime | the POD on the server (UTC on a container with no `TZ`), the VIEWER's machine in the browser |
781
+ | **clock** | `Date.now()` | two numbers, differing by the network latency |
782
+
783
+ So a server-rendered timestamp was a hydration mismatch (React error #418) waiting for a wide enough offset or a slow enough connection, and across midnight it was a different calendar **day**. The fix is the one the locale already used: **the server decides, publishes its answer, and the client reads the answer instead of forming its own.**
784
+
785
+ ### Configure it once
786
+
787
+ ```ts
788
+ // apps/<project>/web/app.config.ts
789
+ export default {
790
+ type: 'web' as const,
791
+ name: 'myApp',
792
+ port: 5191,
793
+ locales: ['de', 'en'] as const,
794
+ defaultLocale: 'de' as const,
795
+
796
+ timeZone: 'Europe/Berlin' as const, // one zone for every viewer
797
+ // …or:
798
+ // timeZone: 'viewer' as const, // resolve per request, per user
799
+ // defaultTimeZone: 'UTC' as const, // before the viewer's zone is known
800
+ }
801
+ ```
802
+
803
+ 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.
804
+
805
+ `timeZone` requires `locales`, because the zone rides the `<I18nProvider>` the framework generates from it.
806
+
807
+ ### `timeZone: 'viewer'` — how the server learns the zone
808
+
809
+ Through the `voltro:tz` cookie, which has two writers and wants both:
810
+
811
+ 1. **The framework's script**, injected into `<head>`, seeds it from `Intl.DateTimeFormat().resolvedOptions().timeZone` when the cookie is absent. From the second request onward the server renders in the browser's zone with no login and no app code. It never overwrites an existing value and never reloads the page.
812
+ 2. **Your app, at login** — overwriting it with the zone you hold for the signed-in user. That is the authoritative one: a profile field or an identity provider's `timeZone` claim beats the machine a user happens to be sitting at.
813
+
814
+ Write it from `middleware.ts`, which runs per request and can return cookies:
815
+
816
+ ```ts
817
+ // apps/<project>/web/middleware.ts
818
+ import { defineMiddleware } from '@voltro/web/middleware'
819
+ import { TIMEZONE_COOKIE, isSupportedTimeZone } from '@voltro/i18n'
820
+
821
+ export const userTimeZone = defineMiddleware({
822
+ run: async (req) => {
823
+ const zone = await zoneForSession(req.cookies) // your session → the user's own zone
824
+ if (!isSupportedTimeZone(zone) || req.cookies[TIMEZONE_COOKIE] === zone) return undefined
825
+ return {
826
+ setCookies: [
827
+ { name: TIMEZONE_COOKIE, value: zone, path: '/', maxAge: 31_536_000, sameSite: 'lax' as const },
828
+ ],
829
+ }
830
+ },
831
+ })
832
+ ```
833
+
834
+ A cookie set here is applied to the jar the SAME render reads, so the zone takes
835
+ effect on the response that sets it rather than the one after.
836
+
837
+ Validate before you write. An unusable zone is dropped on the way in (a stale cookie, a typo in the config, a runtime with a trimmed ICU) rather than forwarded — `Intl.DateTimeFormat` throws on an unknown zone, and one bad value would otherwise degrade every timestamp in the app to a raw `Date` string.
838
+
839
+ ### Prerendered pages
840
+
841
+ A `renderMode: 'static'` page is one artefact for every viewer, so `'viewer'` cannot mean the viewer there — it resolves to `defaultTimeZone`. The build publishes that value and its own build instant, so the markup and the first client render still agree; a relative time in a prerendered page corrects itself in one frame after mount rather than mismatching.
739
842
 
740
843
  ## Formatters vs. ICU in the catalog
741
844
 
@@ -750,7 +853,8 @@ For dates, numbers and relative time the hooks are the blessed path; reach for `
750
853
 
751
854
  - **Don't write `(s)`, `count === 1 ? 'x' : 'xs'`, or a `+ 's'` suffix.** It is wrong in most languages and cannot be fixed by a translator. Use `plural` / `usePlural` or ICU in the catalog.
752
855
  - **Don't hand-roll "X minutes ago".** `useRelativeTime` is one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong.
753
- - **Don't pin a global `timeZone` / locale for the whole app.** The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
856
+ - **Don't pass `timeZone` at every call site to work around a missing config.** Set `timeZone` in `app.config.ts` once. A per-call convention is a rule every new call has to remember, and the ones that forget are invisible until a viewer in another zone reads a wrong date.
857
+ - **Don't call `Intl.DateTimeFormat().resolvedOptions().timeZone` in a component to "fix" SSR.** It is the viewer's true zone and therefore the wrong value: the server could not know it, so the server did not render with it, and using it on the client guarantees the mismatch. Let the server decide and publish — that is what `timeZone: 'viewer'` does.
754
858
  - **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
755
859
  - **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.
756
860
 
@@ -840,14 +944,28 @@ program.pipe(withTimezone('Europe/Berlin'))
840
944
  `currentTimezone` never fails — an absent context is the documented `UTC`
841
945
  default, so call sites don't handle a missing-service error.
842
946
 
843
- > **What ships today is the SEAM, not the wiring.** `@voltro/datetime/context`
844
- > exports `currentTimezone`, `withTimezone` and `resolvedTimezoneLayer`, and you
845
- > provide the layer yourself. The framework does **not** yet install it per
846
- > request, and there is **no `useTimezone()` hook** in the web layer both are
847
- > planned to land in `@voltro/runtime` + `@voltro/web`. Until then: resolve the
848
- > zone yourself (`resolveTimezone`) and provide `resolvedTimezoneLayer` around
849
- > the work that needs it, or pass an explicit `timeZone` argument. Without a
850
- > provided layer every call reads the `UTC` default.
947
+ > **Two zones, and only one of them is wired. Keep them apart.**
948
+ >
949
+ > The **render zone** what a date LOOKS like in the UI — is wired end to end.
950
+ > Set `timeZone` in the web `app.config.ts`, and the framework resolves it per
951
+ > request, publishes it on the document, and every `@voltro/i18n` formatter on
952
+ > both sides of the hydration boundary uses it. Read it with `useTimeZone()`
953
+ > from `@voltro/i18n`. See
954
+ > [Formatting Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference).
955
+ >
956
+ > The **server-side compute zone** — what `startOfDay` or a workflow's "same
957
+ > time tomorrow" resolves against inside a handler — is still the SEAM only.
958
+ > `@voltro/datetime/context` exports `currentTimezone`, `withTimezone` and
959
+ > `resolvedTimezoneLayer`; the framework does **not** install the layer per
960
+ > request, so resolve the zone yourself (`resolveTimezone`) and provide
961
+ > `resolvedTimezoneLayer` around the work that needs it, or pass an explicit
962
+ > `timeZone` argument. Without a provided layer every call reads the `UTC`
963
+ > default.
964
+ >
965
+ > Rendering a date correctly does NOT give a route handler the user's zone, and
966
+ > a handler that has it does NOT change what the browser renders. They are
967
+ > separate values today and a `timeZone` in `app.config.ts` configures the
968
+ > first one only.
851
969
 
852
970
  ## Arithmetic — the DST split is in the names
853
971