@voltro/cli 0.50.1 → 0.51.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 (103) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/{apiBuild-D3s7lx57.js → apiBuild-CPDHXF72.js} +2 -2
  3. package/dist/apiBuild-GDKuGOMV.js +2 -0
  4. package/dist/appGraph-CFmFKHb2.js +2 -0
  5. package/dist/appGraph-yRuKjOba.js +111 -0
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-CFmqF39c.js → build-DETLZAFt.js} +271 -240
  8. package/dist/{checkCommand-DomvYDi1.js → checkCommand-CWcnDArJ.js} +1 -1
  9. package/dist/{checkCommand-DsKIjL6k.js → checkCommand-DNuPiWMc.js} +29 -28
  10. package/dist/{codegenCommand-C0S27nbB.js → codegenCommand-BNBHcNNj.js} +2 -2
  11. package/dist/{codemodRunner-DwY8Rjbl.js → codemodRunner-BDVixlSw.js} +278 -191
  12. package/dist/{commands-Brp4DI8u.js → commands-B1OiS9bX.js} +24 -23
  13. package/dist/{dashboardCommand-bbLfc9FW.js → dashboardCommand-C-vvPY1B.js} +5 -5
  14. package/dist/{dataCommand-k9mXTXin.js → dataCommand-C1GxXW5q.js} +279 -233
  15. package/dist/dbCommand-C27HIsGE.js +2 -0
  16. package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-If4Y1xQ-.js} +1 -1
  17. package/dist/{dev-Dz9GqA6P.js → dev-CK522MV5.js} +1 -1
  18. package/dist/{dev-C0cbqysu.js → dev-kdAg9Q7l.js} +1246 -1246
  19. package/dist/doctorCommand-BK4l18eG.js +2 -0
  20. package/dist/{doctorCommand-DyViaaCP.js → doctorCommand-nKmeW78u.js} +16 -16
  21. package/dist/{dormancyCommand-DMZPA_HW.js → dormancyCommand-CY3wa_SW.js} +1 -1
  22. package/dist/{embeddingsCommand-CMxRPkg6.js → embeddingsCommand-BDLIgje_.js} +1 -1
  23. package/dist/{envCommand-CDLr-yD4.js → envCommand-C6V_xVlT.js} +13 -13
  24. package/dist/{evolveCommand-Ct4NesL3.js → evolveCommand-D3c4DSfN.js} +2 -2
  25. package/dist/{generateCommand-BDdOxhnv.js → generateCommand-DTNPGjxo.js} +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/{infoCommand-orsKJKUy.js → infoCommand-BnRFEF1o.js} +1 -1
  28. package/dist/{migrate-D3MK9BpK.js → migrate-DehuBakM.js} +1 -1
  29. package/dist/{runtimeTrace-Bo8jWsNe.js → runtimeTrace-ZsBU7Tkx.js} +1 -1
  30. package/dist/{sdkgen-OZhiT2vz.js → sdkgen-O4XqWOjM.js} +1 -1
  31. package/dist/serveCommand-BRnPCxVd.js +2 -0
  32. package/dist/{serveCommand-YbD5VgfV.js → serveCommand-DdiYNBBu.js} +3 -3
  33. package/dist/serveEntry.js +1 -1
  34. package/dist/{start-B1nH9ahf.js → start-BLNmWkLa.js} +331 -327
  35. package/dist/{start-7b_bL8Uc.js → start-Dzicuyw8.js} +1 -1
  36. package/dist/startEntry.js +1 -1
  37. package/dist/{updateCommand-BgH6vTtq.js → updateCommand-Bs322Q78.js} +1 -1
  38. package/dist/updateCommand-eXB35SEv.js +2 -0
  39. package/dist/{webDev-BbdkzMbd.js → webDev-B-ubQEMX.js} +282 -223
  40. package/dist/webDev-DposiF3j.js +2 -0
  41. package/dist/{webhooksCommand-DjE6u0r4.js → webhooksCommand-FLYY9IXh.js} +1 -1
  42. package/package.json +35 -17
  43. package/templates/AGENTS.core.md +15 -5
  44. package/templates/AGENTS.md +16 -6
  45. package/templates/agent-docs/_index.md +1 -1
  46. package/templates/agent-docs/cli.md +29 -0
  47. package/templates/agent-docs/internationalization.md +131 -13
  48. package/templates/agent-docs/whats-new.md +75 -27
  49. package/templates/apps/api-ai/package.json +7 -7
  50. package/templates/apps/api-auth/package.json +8 -8
  51. package/templates/apps/api-backend/package.json +7 -7
  52. package/templates/apps/api-backend-deactivation/package.json +7 -7
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/package.json +9 -9
  55. package/templates/apps/api-backend-sqlite/package.json +8 -8
  56. package/templates/apps/api-backend-storage/package.json +8 -8
  57. package/templates/apps/api-cms/package.json +10 -10
  58. package/templates/apps/api-collab/package.json +8 -8
  59. package/templates/apps/api-data-advanced/package.json +8 -8
  60. package/templates/apps/api-durable/package.json +8 -8
  61. package/templates/apps/api-feature-flags/package.json +9 -9
  62. package/templates/apps/api-governance/package.json +8 -8
  63. package/templates/apps/api-kv/package.json +8 -8
  64. package/templates/apps/api-moderation/package.json +8 -8
  65. package/templates/apps/api-observability/package.json +8 -8
  66. package/templates/apps/api-ratelimit/package.json +8 -8
  67. package/templates/apps/api-rbac/package.json +8 -8
  68. package/templates/apps/api-rest/package.json +7 -7
  69. package/templates/apps/api-saas/package.json +11 -11
  70. package/templates/apps/api-saas-starter/package.json +10 -10
  71. package/templates/apps/api-search/package.json +8 -8
  72. package/templates/apps/api-status/package.json +8 -8
  73. package/templates/apps/api-versioning/package.json +8 -8
  74. package/templates/apps/api-webhooks/package.json +9 -9
  75. package/templates/apps/changelog/package.json +6 -6
  76. package/templates/apps/edge-functions/package.json +2 -2
  77. package/templates/apps/frontend-admin/package.json +8 -8
  78. package/templates/apps/frontend-app/package.json +9 -9
  79. package/templates/apps/frontend-auth/package.json +8 -8
  80. package/templates/apps/frontend-blank/package.json +7 -7
  81. package/templates/apps/frontend-cms/package.json +9 -9
  82. package/templates/apps/frontend-collab/package.json +10 -10
  83. package/templates/apps/frontend-contact/package.json +7 -7
  84. package/templates/apps/frontend-dashboard/package.json +7 -7
  85. package/templates/apps/frontend-docs/package.json +7 -7
  86. package/templates/apps/frontend-i18n/package.json +6 -6
  87. package/templates/apps/frontend-landing/package.json +7 -7
  88. package/templates/apps/frontend-portal/package.json +8 -8
  89. package/templates/apps/frontend-saas/package.json +8 -8
  90. package/templates/apps/frontend-spa/package.json +7 -7
  91. package/templates/apps/frontend-ssr/package.json +7 -7
  92. package/templates/apps/frontend-ssr-api/package.json +8 -8
  93. package/templates/apps/frontend-static-blog/package.json +6 -6
  94. package/templates/apps/frontend-status/package.json +8 -8
  95. package/templates/apps/mobile-app/package.json +4 -4
  96. package/dist/apiBuild-BZ-plaQN.js +0 -2
  97. package/dist/appGraph-CM9wvUmD.js +0 -2
  98. package/dist/appGraph-Cv2W_wO5.js +0 -111
  99. package/dist/dbCommand-Bnl7MSGC.js +0 -2
  100. package/dist/doctorCommand-BlciWUJb.js +0 -2
  101. package/dist/serveCommand-Bbgg7Paq.js +0 -2
  102. package/dist/updateCommand-BweY17z-.js +0 -2
  103. package/dist/webDev-BcRFTsMA.js +0 -2
@@ -0,0 +1,2 @@
1
+ import { _ as e, y as t } from "./webDev-B-ubQEMX.js";
2
+ export { e as tryRunWebServe, t as walkPagesTree };
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-Dz9GqA6P.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-CK522MV5.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.50.1",
3
+ "version": "0.51.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -700,6 +700,24 @@
700
700
  "title": "A COMPUTED `source:` can keep its typed names — do not widen the whole registry",
701
701
  "kind": "manual"
702
702
  },
703
+ {
704
+ "version": "0.51.0",
705
+ "id": "0.51.0/02_restore-refuses-without-marker",
706
+ "title": "`voltro data restore` refuses when it cannot write the in-progress marker",
707
+ "kind": "manual"
708
+ },
709
+ {
710
+ "version": "0.51.0",
711
+ "id": "0.51.0/03_restore-assets-returns-counts",
712
+ "title": "`restoreAssetsFromCas` returns `{ count, objects }`, not a number",
713
+ "kind": "manual"
714
+ },
715
+ {
716
+ "version": "0.51.0",
717
+ "id": "0.51.0/04_i18n-timezone-prop",
718
+ "title": "`<I18nProvider>`: `timeZone` is its own prop, not an `intlConfig` member",
719
+ "kind": "transform"
720
+ },
703
721
  {
704
722
  "version": "0.6.0",
705
723
  "id": "0.6.0/01_no-dev-session-secret",
@@ -775,22 +793,22 @@
775
793
  "@effect/platform-node": "^0.108.0",
776
794
  "@effect/sql": "^0.52.0",
777
795
  "@effect/workflow": "^0.19.0",
778
- "@voltro/ai": "0.50.1",
779
- "@voltro/cache": "0.50.1",
780
- "@voltro/data-transfer": "0.50.1",
781
- "@voltro/database": "0.50.1",
782
- "@voltro/env": "0.50.1",
783
- "@voltro/kv": "0.50.1",
784
- "@voltro/logger": "0.50.1",
785
- "@voltro/plugin-auth": "0.50.1",
786
- "@voltro/plugin-broadcast": "0.50.1",
787
- "@voltro/plugin-mail": "0.50.1",
788
- "@voltro/plugin-storage": "0.50.1",
789
- "@voltro/plugin-webhooks": "0.50.1",
790
- "@voltro/protocol": "0.50.1",
791
- "@voltro/runtime": "0.50.1",
792
- "@voltro/serverless": "0.50.1",
793
- "@voltro/workflow": "0.50.1",
796
+ "@voltro/ai": "0.51.0",
797
+ "@voltro/cache": "0.51.0",
798
+ "@voltro/data-transfer": "0.51.0",
799
+ "@voltro/database": "0.51.0",
800
+ "@voltro/env": "0.51.0",
801
+ "@voltro/kv": "0.51.0",
802
+ "@voltro/logger": "0.51.0",
803
+ "@voltro/plugin-auth": "0.51.0",
804
+ "@voltro/plugin-broadcast": "0.51.0",
805
+ "@voltro/plugin-mail": "0.51.0",
806
+ "@voltro/plugin-storage": "0.51.0",
807
+ "@voltro/plugin-webhooks": "0.51.0",
808
+ "@voltro/protocol": "0.51.0",
809
+ "@voltro/runtime": "0.51.0",
810
+ "@voltro/serverless": "0.51.0",
811
+ "@voltro/workflow": "0.51.0",
794
812
  "chokidar": "^5.0.0",
795
813
  "ioredis": "^5.11.1",
796
814
  "tinyglobby": "^0.2.17",
@@ -404,11 +404,21 @@ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were th
404
404
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
405
405
  promise, and `voltro doctor` enforces all of them:
406
406
 
407
- - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
408
- never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
409
- what lets a caller render it ten thousand times in a list, reuse it across
410
- features, and prerender it without reading its source. Lift the write into the
411
- owning `*.component.tsx` and pass a handler down.
407
+ - `*.component.ui.tsx` may read (`useT`, `useTFn`, `useCan`, `usePermissions`,
408
+ the `@voltro/i18n` formatters) but must never import a write hook
409
+ (`useMutation`, `useAction`, `useUpload`, …). That is what lets a caller render
410
+ it ten thousand times in a list, reuse it across features, and prerender it
411
+ without reading its source. Lift the write into the owning `*.component.tsx`
412
+ and pass a handler down.
413
+
414
+ **Reading is a recommendation, not a grudging allowance — call the hook, do
415
+ not take a prop.** Threading `t` / `formatDate` / `useCan` through props is
416
+ prop-drilling: it grows the prop surface with every formatter, has to be
417
+ restated in every intermediate signature and every test, and does NOT make the
418
+ component more reusable — it makes every call site worse. The one argument for
419
+ the prop, "it renders without a provider", is already paid for the moment any
420
+ test mounts one. Pick one way per component head: a `t` prop beside a
421
+ formatter hook is the worst of both.
412
422
  - `*.internal.ts` is the promise that refactoring inside that directory breaks
413
423
  nobody. An import from another subtree revokes it.
414
424
  - `*.types.ts` having no runtime export is what makes importing it free and makes
@@ -404,11 +404,21 @@ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were th
404
404
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
405
405
  promise, and `voltro doctor` enforces all of them:
406
406
 
407
- - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
408
- never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
409
- what lets a caller render it ten thousand times in a list, reuse it across
410
- features, and prerender it without reading its source. Lift the write into the
411
- owning `*.component.tsx` and pass a handler down.
407
+ - `*.component.ui.tsx` may read (`useT`, `useTFn`, `useCan`, `usePermissions`,
408
+ the `@voltro/i18n` formatters) but must never import a write hook
409
+ (`useMutation`, `useAction`, `useUpload`, …). That is what lets a caller render
410
+ it ten thousand times in a list, reuse it across features, and prerender it
411
+ without reading its source. Lift the write into the owning `*.component.tsx`
412
+ and pass a handler down.
413
+
414
+ **Reading is a recommendation, not a grudging allowance — call the hook, do
415
+ not take a prop.** Threading `t` / `formatDate` / `useCan` through props is
416
+ prop-drilling: it grows the prop surface with every formatter, has to be
417
+ restated in every intermediate signature and every test, and does NOT make the
418
+ component more reusable — it makes every call site worse. The one argument for
419
+ the prop, "it renders without a provider", is already paid for the moment any
420
+ test mounts one. Pick one way per component head: a `t` prop beside a
421
+ formatter hook is the worst of both.
412
422
  - `*.internal.ts` is the promise that refactoring inside that directory breaks
413
423
  nobody. An import from another subtree revokes it.
414
424
  - `*.types.ts` having no runtime export is what makes importing it free and makes
@@ -707,7 +717,7 @@ each plugin's own README.
707
717
 
708
718
  | Topic | Open | Summary |
709
719
  |---|---|---|
710
- | **What's new in 0.50.1** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
720
+ | **What's new in 0.51.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
711
721
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
712
722
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
713
723
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.50.1** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.51.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -3412,8 +3412,22 @@ Aborting on the first one made `--assets` unusable for exactly the deployment th
3412
3412
 
3413
3413
  Only a genuine *not found* is treated this way. A 403 from a rotated credential or a 5xx from a backend outage still fails the capture — calling those "the object is gone" would turn a recoverable outage into a backup that quietly contains nothing.
3414
3414
 
3415
+ **Three numbers, because they answer three questions.** `_voltro_storage_refs` holds one row per *reference*, several of which legitimately name one *key*, and the content-addressed store keeps one body per distinct *object*:
3416
+
3417
+ ```
3418
+ 57 reference(s) → 16 key(s) → 16 object(s), 65476 byte(s) under assets/
3419
+ ```
3420
+
3421
+ The stamp carries all three (`references`, `count`, `objects`, with `totalBytes` and `objectBytes` beside them). It used to carry only the reference count under the name `count`, so a stamp read `57` over a directory holding 16 files — and anyone answering *"are all the blobs there?"* after a restore compared the two and found a 3.5× gap that was not one. A key named by several references is also fetched once now, rather than downloaded and hashed once per row.
3422
+
3415
3423
  Resume is per blob key, so re-running a `--assets` capture that was interrupted transfers only what is missing. The **dump itself has no resume** — a vendor artifact is one opaque file with no offset to restart from. If you need a resumable, chunkable, observable transfer, that is the logical path (`export` / `import`), and it is why the logical path exists.
3416
3424
 
3425
+ ### The stamp's skew warning compares the backup against the TARGET
3426
+
3427
+ `restore` reads `voltro-backup-stamp.json` before touching anything and warns when the backup's schema fingerprint differs from the target's. That warning used to say the difference was against "what this code declares", and it was not — the value it compares against is the target database's *live* schema, read by introspection at restore time. Bringing a target to the backup's shape makes the warning disappear while the declared fingerprint is a third value entirely, which is how the mislabel was caught. The comparison was always the useful one; only the sentence was wrong, and it sent readers looking for a code change where a database differed.
3428
+
3429
+ The same distinction shows up in `voltro db plan`, which prints `live … · declared …` rather than `from → to` for the same reason: **a hash of a live database never equals the hash of the declaration it came from.** Introspection cannot recover everything a declaration carries — generated expressions, `maxLength`, sensitivity markers — so the two are not comparable and are not meant to match. The plan's operation list is what says whether they agree; `0 operations` under two different fingerprints means they do.
3430
+
3417
3431
  ### A restore that is interrupted refuses the next boot
3418
3432
 
3419
3433
  `restore` writes one row into `_voltro_replace_in_progress` **before** the first destructive statement and removes it **after** the last write — the blobs included. Its presence at boot is a refusal naming the artifact that was going in.
@@ -3427,6 +3441,17 @@ Two changes, covering different dumps:
3427
3441
  - **A backup taken by `voltro data backup` excludes the marker table** (`--exclude-table` / `--ignore-table`). It can no longer carry the thing that erases the guard on the way back in.
3428
3442
  - **`restore` writes the marker back after the tool exits**, on the failing path as well as the succeeding one. That covers dumps taken before this version and dumps made by hand. If it was removed and rewritten you get a warning saying so; if it could not be rewritten you get an error, because the guard is then off for that run and nothing will stop the next boot.
3429
3443
 
3444
+ **A restore that cannot write the marker at all is refused.** Two things can prevent it — the bookkeeping store will not open (wrong credentials, an unreachable database, a missing env var, no `app.config.ts` from here), or the table is not there yet — and both mean the same thing to you: this restore would run with no guard. The refusal names which one it was:
3445
+
3446
+ ```
3447
+ ✗ refusing to restore: the in-progress marker cannot be written.
3448
+ reason: bookkeeping is unavailable: connect ECONNREFUSED 127.0.0.1:5432
3449
+ ```
3450
+
3451
+ This used to be a silent hole rather than a refusal, and worse than silent. The failure to open the store was caught and discarded, and the discarded value guarded *every* branch below it — including the refusal that would have reported the guard missing. So a restore ran on and, over a database with zero marker rows, printed *"the next boot will REFUSE, by design"*. The next boot did not refuse, and `voltro data clear-replace-marker` had nothing to clear. A restore is the operation you run against a target that is already unwell, so the precaution was falling away exactly when it was needed.
3452
+
3453
+ **`--no-marker`** restores without the guard, deliberately. It warns every time and names the reason the marker was unavailable. It exists because the accidental way did: if going unguarded is ever right, it should be something you typed.
3454
+
3430
3455
  | dialect | shape | effect |
3431
3456
  |---|---|---|
3432
3457
  | postgres, mysql, mariadb | the dump names the whole database, so the restore drops the marker table too | our backups exclude it; for any other dump the marker is written back after the tool exits |
@@ -3435,6 +3460,10 @@ Two changes, covering different dumps:
3435
3460
 
3436
3461
  Clear a marker deliberately with `voltro data clear-replace-marker --yes` once you have decided the current state is correct.
3437
3462
 
3463
+ **`_voltro_data_transfers` is excluded for the same reason, one table over.** The restore opens its own run row there *before* the tool starts; a dump that carried the table dropped it mid-flight, and the update recording the outcome then wrote into a table that no longer held the row. The visible result was that a failed native restore did not appear in `voltro data transfers` at all — only the `backup` row the dump had brought over from the *source* database. The command that answers "did the restore finish" could not see the run asking.
3464
+
3465
+ Exactly those two tables are excluded, and the line is deliberate: a native restore into the same deployment *should* bring the migration ledger, the stored plans, the CDC offsets and the schedule claims — they describe the data being restored. These two describe the *restore*, and a record of an operation must not be overwritten by the operation it records.
3466
+
3438
3467
  ### Both directions are in the history
3439
3468
 
3440
3469
  `backup` and `restore` write a row to the same `_voltro_data_transfers` record `import` and `export` use, so `voltro data transfers` answers "did last night's backup finish" from the instance that ran it:
@@ -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