@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.
- package/CHANGELOG.md +231 -0
- package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
- package/dist/agentsMd-SDDSkyl4.js +2 -0
- package/dist/apiBuild-BYBpL7Pz.js +2 -0
- package/dist/{apiBuild-D3s7lx57.js → apiBuild-CSFI8QGq.js} +3 -3
- package/dist/appGraph-CFmFKHb2.js +2 -0
- package/dist/appGraph-yRuKjOba.js +111 -0
- package/dist/bin.js +1 -1
- package/dist/build-CPgcMQug.js +793 -0
- package/dist/checkCommand-2SbqzukH.js +2 -0
- package/dist/{checkCommand-DsKIjL6k.js → checkCommand-COmqc2cB.js} +107 -60
- package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
- package/dist/codegen-CctkDO-1.js +2 -0
- package/dist/{codegen-CrMXs4hb.js → codegen-VF479Cnb.js} +1 -1
- package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-DCdG2JN-.js} +12 -12
- package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-r7J9lIa7.js} +771 -636
- package/dist/{commands-Brp4DI8u.js → commands-Cc_nV8WI.js} +37 -36
- package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-gKvwqh.js} +3 -3
- package/dist/{dataCommand-k9mXTXin.js → dataCommand-BgpBHnlB.js} +299 -253
- package/dist/dbCommand-DNb6yeOG.js +2 -0
- package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-sHedr-NJ.js} +3 -3
- package/dist/dev--A3nsxA3.js +3 -0
- package/dist/{dev-C0cbqysu.js → dev-CRHoCEiy.js} +2258 -2219
- package/dist/doctorCommand-CqoWA2p5.js +2 -0
- package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-DtfJ3FA6.js} +314 -234
- package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-Drn7o0No.js} +1 -1
- package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-Z-jO1fWN.js} +1 -1
- package/dist/{envCommand-CDLr-yD4.js → envCommand-D4gCrrTZ.js} +15 -15
- package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-CMROeKeA.js} +2 -2
- package/dist/fileConventions-DOqD3lPS.js +34 -0
- package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-DvDUV9wq.js} +1 -1
- package/dist/frameworkTableAssembly-C6ETawPR.js +2 -0
- package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-w-XnLa3q.js} +1 -1
- package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-orsKJKUy.js → infoCommand-DXM868o_.js} +1 -1
- package/dist/inspectMetrics-CGF94puw.js +143 -0
- package/dist/{metaCommands-CfRLra0s.js → metaCommands-C6RFmF1r.js} +2 -2
- package/dist/{migrate-D3MK9BpK.js → migrate-D0F-eTlK.js} +2 -2
- package/dist/{pageConvention-cEiRxdab.js → pageConvention-CzUiSbtU.js} +1 -1
- package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DGdopOI6.js} +1 -1
- package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
- package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
- package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CcH2X1_D.js} +25 -10
- package/dist/{renderProfile-1OWWAAtx.js → renderProfile-Ck32Fzxr.js} +2 -2
- package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-BPQyCmC5.js} +1 -1
- package/dist/{sdkgen-OZhiT2vz.js → sdkgen-Se88ifTd.js} +1 -1
- package/dist/serveCommand-DLc-BznW.js +2 -0
- package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DkP3OT0W.js} +885 -868
- package/dist/serveEntry.js +1 -1
- package/dist/start-DfL3fOiN.js +3 -0
- package/dist/start-jw89Xbqy.js +1339 -0
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-BwNEDlSU.js} +1 -1
- package/dist/{test-rXFq4S76.js → test-f3amja6a.js} +1 -1
- package/dist/updateCommand-5gFVfK5q.js +2 -0
- package/dist/{updateCommand-BgH6vTtq.js → updateCommand-BMk2e4ky.js} +1 -1
- package/dist/{webDev-BbdkzMbd.js → webDev-BgWL9gKV.js} +1233 -853
- package/dist/webDev-CZbTsDcH.js +2 -0
- package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-CoIO3jbj.js} +1 -1
- package/package.json +48 -17
- package/templates/AGENTS.core.md +26 -5
- package/templates/AGENTS.md +30 -9
- package/templates/agent-docs/_index.md +4 -4
- package/templates/agent-docs/_manifest.json +11 -11
- package/templates/agent-docs/cli.md +125 -14
- package/templates/agent-docs/data.md +210 -7
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +1 -1
- package/templates/agent-docs/deployment.md +22 -3
- package/templates/agent-docs/internationalization.md +131 -13
- package/templates/agent-docs/introduction.md +46 -0
- package/templates/agent-docs/local-first-mobile.md +34 -7
- package/templates/agent-docs/plugins/ai-flows.md +1 -1
- package/templates/agent-docs/plugins/audit.md +5 -5
- package/templates/agent-docs/plugins/cdc-out.md +2 -2
- package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
- package/templates/agent-docs/plugins/storage.md +2 -2
- package/templates/agent-docs/plugins.md +29 -7
- package/templates/agent-docs/reference.md +39 -2
- package/templates/agent-docs/routing.md +341 -47
- package/templates/agent-docs/schema-driven-ui.md +78 -2
- package/templates/agent-docs/security.md +125 -8
- package/templates/agent-docs/templates/apibackends.md +14 -14
- package/templates/agent-docs/templates/overview.md +1 -1
- package/templates/agent-docs/whats-new.md +100 -29
- package/templates/apps/api-ai/package.json +6 -7
- package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
- package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
- package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
- package/templates/apps/api-row-history/template.json +6 -0
- package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
- package/templates/apps/api-saas/app.config.ts +1 -0
- package/templates/apps/api-saas/package.json +10 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
- package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +6 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +6 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/agentsMd-Bu_XQgVf.js +0 -2
- package/dist/apiBuild-BZ-plaQN.js +0 -2
- package/dist/appGraph-CM9wvUmD.js +0 -2
- package/dist/appGraph-Cv2W_wO5.js +0 -111
- package/dist/build-CFmqF39c.js +0 -721
- package/dist/checkCommand-DomvYDi1.js +0 -2
- package/dist/codegen-DiMn2KkZ.js +0 -2
- package/dist/dbCommand-Bnl7MSGC.js +0 -2
- package/dist/dev-Dz9GqA6P.js +0 -3
- package/dist/doctorCommand-BlciWUJb.js +0 -2
- package/dist/fileConventions-Cof68_BL.js +0 -33
- package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
- package/dist/inspectMetrics-CfdKLh6t.js +0 -72
- package/dist/serveCommand-Bbgg7Paq.js +0 -2
- package/dist/start-7b_bL8Uc.js +0 -3
- package/dist/start-B1nH9ahf.js +0 -1150
- package/dist/updateCommand-BweY17z-.js +0 -2
- package/dist/webDev-BcRFTsMA.js +0 -2
- package/templates/apps/api-versioning/template.json +0 -6
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
- /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
- /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-
|
|
5273
|
-
|
|
5274
|
-
|
|
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 {
|
|
5480
|
+
import { rowHistoryPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-row-history'
|
|
5278
5481
|
|
|
5279
5482
|
// Register in your app's plugin list:
|
|
5280
|
-
|
|
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-
|
|
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-
|
|
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.
|
|
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`.
|
|
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
|
|
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
|
|
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
|
-
> **
|
|
844
|
-
>
|
|
845
|
-
>
|
|
846
|
-
>
|
|
847
|
-
>
|
|
848
|
-
>
|
|
849
|
-
>
|
|
850
|
-
>
|
|
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
|
|