@voltro/cli 0.50.0 → 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.
- package/CHANGELOG.md +160 -0
- package/dist/{apiBuild-CFYXo06U.js → apiBuild-CPDHXF72.js} +2 -2
- package/dist/apiBuild-GDKuGOMV.js +2 -0
- package/dist/appGraph-CFmFKHb2.js +2 -0
- package/dist/appGraph-yRuKjOba.js +111 -0
- package/dist/bin.js +1 -1
- package/dist/{build-CnSH-zru.js → build-DETLZAFt.js} +271 -240
- package/dist/{checkCommand-OL41BMIb.js → checkCommand-CWcnDArJ.js} +1 -1
- package/dist/{checkCommand-Do3SQbLx.js → checkCommand-DNuPiWMc.js} +56 -46
- package/dist/{codegenCommand-BJzaRz6q.js → codegenCommand-BNBHcNNj.js} +2 -2
- package/dist/{codemodRunner-De3k0pRd.js → codemodRunner-BDVixlSw.js} +278 -136
- package/dist/{commands-By23Ljny.js → commands-B1OiS9bX.js} +24 -23
- package/dist/{dashboardCommand-DN1rAABE.js → dashboardCommand-C-vvPY1B.js} +5 -5
- package/dist/{dataCommand-B93TUBIx.js → dataCommand-C1GxXW5q.js} +458 -384
- package/dist/dbCommand-C27HIsGE.js +2 -0
- package/dist/{dbCommand-Bf7Cy0mn.js → dbCommand-If4Y1xQ-.js} +1 -1
- package/dist/{dev-BWwE0QjK.js → dev-CK522MV5.js} +1 -1
- package/dist/{dev-1yu42ubu.js → dev-kdAg9Q7l.js} +1246 -1246
- package/dist/doctorCommand-BK4l18eG.js +2 -0
- package/dist/{doctorCommand-y-EbP8st.js → doctorCommand-nKmeW78u.js} +42 -26
- package/dist/{doctorSourceRelations-Dd48LPvO.js → doctorSourceRelations-c5mlN5HI.js} +66 -34
- package/dist/{dormancyCommand-Dc7KRNCG.js → dormancyCommand-CY3wa_SW.js} +1 -1
- package/dist/{embeddingsCommand-kPs132bB.js → embeddingsCommand-BDLIgje_.js} +1 -1
- package/dist/{envCommand-E12zSt51.js → envCommand-C6V_xVlT.js} +13 -13
- package/dist/{evolveCommand-B3nagPVc.js → evolveCommand-D3c4DSfN.js} +2 -2
- package/dist/{generateCommand-CcyvH2ve.js → generateCommand-DTNPGjxo.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{infoCommand-Cq0Yz98u.js → infoCommand-BnRFEF1o.js} +1 -1
- package/dist/{inspect-B7U7Cl_Z.js → inspect-CtL_xTbu.js} +1 -0
- package/dist/inspect-CuGDYES0.js +2 -0
- package/dist/manifestBuild-CPjhvM62.js +2 -0
- package/dist/{manifestBuild-BK42hu0k.js → manifestBuild-CuU1VrSm.js} +2 -2
- package/dist/{migrate-D3MK9BpK.js → migrate-DehuBakM.js} +1 -1
- package/dist/{runtimeTrace-BpLUcrfy.js → runtimeTrace-ZsBU7Tkx.js} +1 -1
- package/dist/{sdkgen-dl4-WKF6.js → sdkgen-O4XqWOjM.js} +1 -1
- package/dist/serveCommand-BRnPCxVd.js +2 -0
- package/dist/{serveCommand-8CTzQCY0.js → serveCommand-DdiYNBBu.js} +4 -4
- package/dist/serveEntry.js +1 -1
- package/dist/{start-7HcSIKEp.js → start-BLNmWkLa.js} +331 -327
- package/dist/{start-DvlYDyAr.js → start-Dzicuyw8.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/{updateCommand-i8JbrLAA.js → updateCommand-Bs322Q78.js} +1 -1
- package/dist/updateCommand-eXB35SEv.js +2 -0
- package/dist/{webDev-B4IEMB0n.js → webDev-B-ubQEMX.js} +283 -224
- package/dist/webDev-DposiF3j.js +2 -0
- package/dist/{webhooksCommand-B0bdyuQ4.js → webhooksCommand-FLYY9IXh.js} +1 -1
- package/package.json +41 -17
- package/templates/AGENTS.core.md +15 -5
- package/templates/AGENTS.md +16 -6
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/cli.md +92 -3
- package/templates/agent-docs/data.md +36 -0
- package/templates/agent-docs/internationalization.md +131 -13
- package/templates/agent-docs/whats-new.md +73 -27
- package/templates/apps/api-ai/package.json +7 -7
- 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 +10 -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-saas/package.json +11 -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-versioning/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-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -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/apiBuild-BWo8KI6n.js +0 -2
- package/dist/appGraph-KGDPTuTy.js +0 -110
- package/dist/appGraph-zuMGKVYX.js +0 -2
- package/dist/dbCommand-Bnl7MSGC.js +0 -2
- package/dist/doctorCommand-BmovAwb7.js +0 -2
- package/dist/inspect-DUze25t0.js +0 -2
- package/dist/manifestBuild-j0n109tt.js +0 -2
- package/dist/serveCommand-C_WI4TTG.js +0 -2
- package/dist/updateCommand-D91-TZ7E.js +0 -2
- package/dist/webDev-B79ji8M5.js +0 -2
|
@@ -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-
|
|
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.
|
|
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",
|
|
@@ -694,6 +694,30 @@
|
|
|
694
694
|
"title": "`InterruptedReplace.tables` is `number | null`",
|
|
695
695
|
"kind": "manual"
|
|
696
696
|
},
|
|
697
|
+
{
|
|
698
|
+
"version": "0.51.0",
|
|
699
|
+
"id": "0.51.0/01_computed-source-keeps-its-names",
|
|
700
|
+
"title": "A COMPUTED `source:` can keep its typed names — do not widen the whole registry",
|
|
701
|
+
"kind": "manual"
|
|
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
|
+
},
|
|
697
721
|
{
|
|
698
722
|
"version": "0.6.0",
|
|
699
723
|
"id": "0.6.0/01_no-dev-session-secret",
|
|
@@ -769,22 +793,22 @@
|
|
|
769
793
|
"@effect/platform-node": "^0.108.0",
|
|
770
794
|
"@effect/sql": "^0.52.0",
|
|
771
795
|
"@effect/workflow": "^0.19.0",
|
|
772
|
-
"@voltro/ai": "0.
|
|
773
|
-
"@voltro/cache": "0.
|
|
774
|
-
"@voltro/data-transfer": "0.
|
|
775
|
-
"@voltro/database": "0.
|
|
776
|
-
"@voltro/env": "0.
|
|
777
|
-
"@voltro/kv": "0.
|
|
778
|
-
"@voltro/logger": "0.
|
|
779
|
-
"@voltro/plugin-auth": "0.
|
|
780
|
-
"@voltro/plugin-broadcast": "0.
|
|
781
|
-
"@voltro/plugin-mail": "0.
|
|
782
|
-
"@voltro/plugin-storage": "0.
|
|
783
|
-
"@voltro/plugin-webhooks": "0.
|
|
784
|
-
"@voltro/protocol": "0.
|
|
785
|
-
"@voltro/runtime": "0.
|
|
786
|
-
"@voltro/serverless": "0.
|
|
787
|
-
"@voltro/workflow": "0.
|
|
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",
|
|
788
812
|
"chokidar": "^5.0.0",
|
|
789
813
|
"ioredis": "^5.11.1",
|
|
790
814
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -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
|
|
408
|
-
never import a write hook
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
package/templates/AGENTS.md
CHANGED
|
@@ -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
|
|
408
|
-
never import a write hook
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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.
|
|
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.
|
|
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. |
|
|
@@ -290,6 +290,19 @@ carries its own guards, or it is a UI-affordance scope that `useCan` reads to
|
|
|
290
290
|
hide a button and no server check backs on purpose. All three are fine. Not
|
|
291
291
|
knowing which is not.
|
|
292
292
|
|
|
293
|
+
`rbac/unguarded-mutation` flags a mutation that declares neither a guard nor an
|
|
294
|
+
`openAccess:` reason. It skips **`internal: true`** procedures: those are in no
|
|
295
|
+
rpc group and on no route, so "any caller who can reach the rpc surface" names a
|
|
296
|
+
surface that does not exist — and neither remedy applies either, since a guard
|
|
297
|
+
would protect nothing and `openAccess` is refused outright on an internal
|
|
298
|
+
descriptor. This is the same `isWireReachable` predicate the boot access gate and
|
|
299
|
+
all three rpc-group assemblies use, so `check` and `voltro doctor` cannot answer
|
|
300
|
+
the question differently.
|
|
301
|
+
|
|
302
|
+
A manifest that does not carry the field at all — an api older than it — is read
|
|
303
|
+
as *reachable*, not as internal. The rule stays loud rather than going quiet on
|
|
304
|
+
the apps least able to notice.
|
|
305
|
+
|
|
293
306
|
#### Declared vs OBSERVED — reconciled against reality
|
|
294
307
|
|
|
295
308
|
A query's `source` and a mutation's `targets` are not documentation: the
|
|
@@ -323,6 +336,30 @@ Three things about that output are deliberate:
|
|
|
323
336
|
about the runs that happened, not a proof about the ones that didn't, and
|
|
324
337
|
`check`'s exit code gates CI. They are always warnings.
|
|
325
338
|
|
|
339
|
+
A fourth thing is not visible in that output and matters more than any of the
|
|
340
|
+
three: **the recorder only knows what ran.** A boot is not a run. An idle dev
|
|
341
|
+
instance that started, served nothing and stopped produces
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
observed: 0/2755 procedures exercised (0%)
|
|
345
|
+
2753 unexercised — no recording, so nothing is claimed about them
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
which is the honest answer and also a useless one — the section says nothing
|
|
349
|
+
about 99.9% of the surface. If you want this as a CI gate, the recording pass has
|
|
350
|
+
to be a run that actually *calls* the procedures. `voltro e2e` is one: it spawns
|
|
351
|
+
`voltro dev` for the api, which turns recording on, and then drives the specs. A
|
|
352
|
+
harness of your own that boots the app in its own process needs
|
|
353
|
+
`VOLTRO_OBSERVE_GRAPH=1` set before anything is imported. Either way, booting the
|
|
354
|
+
app and then running `check` measures nothing, and the report will not pretend
|
|
355
|
+
otherwise.
|
|
356
|
+
|
|
357
|
+
It is still worth wiring up at low coverage, because the findings are per
|
|
358
|
+
procedure and do not need company. One deployment had exactly one procedure
|
|
359
|
+
observed, and that one produced a real defect: a mutation upserting
|
|
360
|
+
`push_subscriptions` with no declared target while two queries read that table as
|
|
361
|
+
their `source`, so a registered device never appeared in any running subscription.
|
|
362
|
+
|
|
326
363
|
No file → the section is skipped silently. This is not derived by parsing your
|
|
327
364
|
handlers: a static pass over code that reaches the store through shared helpers,
|
|
328
365
|
behind conditionals, has a long tail of both false positives and false negatives
|
|
@@ -3337,10 +3374,17 @@ voltro data restore ./backups/2026-07-01 --assets # pg_restore / mariadb /
|
|
|
3337
3374
|
|
|
3338
3375
|
These shell out to the vendor tools resolved from your `DB_DIALECT` + connection env. They produce a dialect-native artifact (`db.dump`, `db.sql`, `db.sqlite`, `db.bacpac`) that is lossless and point-in-time consistent for **same-dialect** restore — the right tool for disaster recovery. Secrets are passed via the tools' environment variables (`PGPASSWORD`, `MYSQL_PWD`), never on the command line, where the tool supports it. The named tool must be installed and on `PATH`.
|
|
3339
3376
|
|
|
3377
|
+
On **mariadb** the MariaDB-named binaries (`mariadb-dump`, `mariadb`) are preferred and Oracle's (`mysqldump`, `mysql`) are the fallback — with the reason carried into the failure, because the error that fallback produces (`Unknown table 'COLUMN_STATISTICS' in information_schema`, 1109) names a table nobody asked for. Two things worth knowing before you go and install the MariaDB client package:
|
|
3378
|
+
|
|
3379
|
+
- Do **not** reach for `--column-statistics=0`. That flag does not exist on `mariadb-dump`, so it patches the wrong client and breaks the right one.
|
|
3380
|
+
- A MariaDB **12.x** client requires TLS by default. Running it *by hand* against a server without TLS fails with `TLS/SSL error: SSL is required` (2026) and needs `--skip-ssl`. These commands are not affected — it is the first manual call after installing that trips.
|
|
3381
|
+
|
|
3340
3382
|
### `--assets` — the blobs are not in the dump
|
|
3341
3383
|
|
|
3342
3384
|
A vendor dump contains rows. Your blobs are in object storage, and no `pg_dump` has ever seen them. So a rows-only backup restores a database whose rows reference objects that are not there — and the reference and the object are checked at different times, which is why that state is discovered by a user, months later, rather than by the restore.
|
|
3343
3385
|
|
|
3386
|
+
The backend it reads is the one your app **configured** — `storagePlugin({ provider: s3(…) })` if you installed it, the `STORAGE_*` env otherwise. (The commands used to resolve the env default unconditionally, so an app that configured its provider in code had its export, import and backup reading a different backend than the rest of it.)
|
|
3387
|
+
|
|
3344
3388
|
`--assets` captures them alongside the dump, through the same content-addressed pipeline `voltro data export --assets` uses: each blob is streamed (never buffered whole), stored under `assets/<sha256>` so identical content is stored once, and listed in `assets/index.ndjson`. `restore --assets` streams them back and **re-hashes on the way**, so a corrupted artifact can never silently overwrite good bytes.
|
|
3345
3389
|
|
|
3346
3390
|
```
|
|
@@ -3353,28 +3397,73 @@ backups/2026-07-01/
|
|
|
3353
3397
|
|
|
3354
3398
|
Three refusals, each for a belief that is otherwise acted on silently:
|
|
3355
3399
|
|
|
3356
|
-
- **`backup --assets` with no storage provider configured → refused.** There is nothing to capture, and a flag that is accepted and ignored lets you build a rollback story on an artifact that does not contain what you asked for.
|
|
3400
|
+
- **`backup --assets` with no storage provider configured → refused.** There is nothing to capture, and a flag that is accepted and ignored lets you build a rollback story on an artifact that does not contain what you asked for. "Configured" means one of the two things a person actually did: installed `storagePlugin(...)`, or set `STORAGE_PROVIDER`. An in-memory provider nobody asked for is not a decision — and until recently it was what this check saw, which is why the refusal never fired and `--assets` wrote artifacts stamped as carrying blobs that held none.
|
|
3357
3401
|
- **`restore --assets` on a rows-only backup → refused.** You believe the blobs are in there. Restoring the rows anyway produces exactly the dangling state this exists to prevent.
|
|
3358
3402
|
- **restore *without* `--assets` on a backup that HAS them → warned, not refused.** Restoring rows without blobs is legitimate (a schema drill, a lower environment), and refusing it would push people at `--force`.
|
|
3359
3403
|
|
|
3404
|
+
**A reference the provider cannot resolve is reported, not fatal.** A row in `_voltro_storage_refs` can point at an object that was deleted, or that never arrived because an earlier import ran without `--assets`. That is a fact about your data, and no backup can put back bytes that are not there — so the capture records the key, steps over it, and the run says how many:
|
|
3405
|
+
|
|
3406
|
+
```
|
|
3407
|
+
warn 177 of 178 blob reference(s) point at objects the storage provider does not have;
|
|
3408
|
+
they are NOT in this backup and no restore can bring them back.
|
|
3409
|
+
```
|
|
3410
|
+
|
|
3411
|
+
Aborting on the first one made `--assets` unusable for exactly the deployment that needed it: 178 references, one resolvable, and the run stopped at the second — leaving an `assets/` directory with a single blob, **no `voltro-backup-stamp.json`** (the writer never got that far), and nothing anywhere saying 177 objects had been skipped. The stamp is now written on every path, including the one where the asset phase fails, because it describes the **dump** and the dump is already on disk and correct. Without it, `restore` greeted an artifact this tool had written minutes earlier with *"an older/handmade backup. Cannot verify dialect or schema version."*
|
|
3412
|
+
|
|
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
|
+
|
|
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
|
+
|
|
3360
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.
|
|
3361
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
|
+
|
|
3362
3431
|
### A restore that is interrupted refuses the next boot
|
|
3363
3432
|
|
|
3364
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.
|
|
3365
3434
|
|
|
3366
3435
|
This is the counterpart to `--allow-live`, and it guards from the other side: `--allow-live` asks you not to restore over a running instance, and this says *this database is mid-restore, do not serve it*. A half-restored database looks exactly like a normal one from the inside — every query answers, nothing errors.
|
|
3367
3436
|
|
|
3368
|
-
|
|
3437
|
+
**The restore artifact can erase the marker, and this table said otherwise.** It read "postgres, mysql, mariadb: drops only the objects the dump names — the marker survives". The reasoning is right and the premise was wrong: a native dump names the *whole* database, `_voltro_replace_in_progress` included, and a mysql-family restore writes `DROP TABLE IF EXISTS` in front of each table. The table sorts early, so the guard was removed near the *start* of the window it covers. Measured downstream: one row before the restore, zero after, twice.
|
|
3438
|
+
|
|
3439
|
+
Two changes, covering different dumps:
|
|
3440
|
+
|
|
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.
|
|
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.
|
|
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.
|
|
3369
3454
|
|
|
3370
3455
|
| dialect | shape | effect |
|
|
3371
3456
|
|---|---|---|
|
|
3372
|
-
| postgres, mysql, mariadb |
|
|
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 |
|
|
3373
3458
|
| sqlite, turso | whole-file replacement — made **atomic** (temp file + rename) | there is no half-restored state to catch; a killed restore leaves the live file untouched |
|
|
3374
3459
|
| mssql | `sqlpackage /Action:Import` replaces the database | a *failed* import is the one case not covered here — verify with `--drill` |
|
|
3375
3460
|
|
|
3376
3461
|
Clear a marker deliberately with `voltro data clear-replace-marker --yes` once you have decided the current state is correct.
|
|
3377
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
|
+
|
|
3378
3467
|
### Both directions are in the history
|
|
3379
3468
|
|
|
3380
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:
|
|
@@ -1700,6 +1700,32 @@ That matters because the failure it replaces is silent. A `source:` is matched b
|
|
|
1700
1700
|
|
|
1701
1701
|
The file is generated, so **commit it** like the rpc group and let `voltro dev` rewrite it. Before the first run — and in a project that never generates it — `source:` is plain `string` again, which is exactly the previous behaviour; there is no configuration and nothing to opt into.
|
|
1702
1702
|
|
|
1703
|
+
The error carries a suggestion when the name is close to a real one — `Did you mean '"error_logs"'?` — so a rename usually resolves without leaving the editor.
|
|
1704
|
+
|
|
1705
|
+
#### A COMPUTED `source:` can keep its names
|
|
1706
|
+
|
|
1707
|
+
A generic reader — the table arrives in `input`, the caller picks it out of a registry — is computed, and yet every name it can produce is known. The obvious derivation is not assignable:
|
|
1708
|
+
|
|
1709
|
+
```ts
|
|
1710
|
+
source: Object.keys(JUNCTION_REGISTRY), // string[] — ✗
|
|
1711
|
+
```
|
|
1712
|
+
|
|
1713
|
+
The tempting exit is a cast, or annotating `ReactivitySourceValue` (the wide `string | ReactivityChannel` shape, exported from `@voltro/protocol`). Both compile at once and take that **entire** set of tables out of the check permanently. Keep the literal's keys instead:
|
|
1714
|
+
|
|
1715
|
+
```ts
|
|
1716
|
+
const REGISTRY = { … } as const satisfies Readonly<Record<string, JunctionMeta>>
|
|
1717
|
+
|
|
1718
|
+
// consumers that index with a plain string still get the wide type
|
|
1719
|
+
export const JUNCTION_REGISTRY: Readonly<Record<string, JunctionMeta>> = REGISTRY
|
|
1720
|
+
|
|
1721
|
+
// … and the source list keeps its literal names
|
|
1722
|
+
export const JUNCTION_TABLE_NAMES = Object.keys(REGISTRY) as ReadonlyArray<keyof typeof REGISTRY>
|
|
1723
|
+
```
|
|
1724
|
+
|
|
1725
|
+
The part that is easy to get wrong: an **annotation widens the keys back**, even when the literal carries `as const`. A registry written as `export const R: Readonly<Record<string, Meta>> = { … } as const` has `keyof typeof R === string`, and nothing about it looks wrong — the annotation is checked against the literal and then replaces its type. `satisfies` checks without replacing. That is the whole reason for the two-line split above.
|
|
1726
|
+
|
|
1727
|
+
Reach for `ReactivitySourceValue` when there genuinely is no key set to keep — a name read from a config file, or assembled at runtime. Not when recovering one takes two lines.
|
|
1728
|
+
|
|
1703
1729
|
Two things it deliberately does not narrow. A **plugin's** route `source:` stays `string`: a plugin ships against many apps and cannot know any of their tables. And nothing that READS a descriptor's source at runtime narrows either — a reader that refused an unknown name would reject the stale name it exists to report.
|
|
1704
1730
|
|
|
1705
1731
|
**It does not check the other direction — except for relations.** A `source:` that omits a table the query genuinely reads is silent: the name is right, the table exists, and nothing has an opinion. That is the failure that costs a user report — they type, the row lands, and the panel does not move.
|
|
@@ -1714,6 +1740,16 @@ Two things it deliberately does not narrow. A **plugin's** route `source:` stays
|
|
|
1714
1740
|
|
|
1715
1741
|
An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the rule has no heuristic and no exception list. A **many-to-many** wants the junction table too, and says so separately: adding or removing a link writes only the junction row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it.
|
|
1716
1742
|
|
|
1743
|
+
Nested relations resolve in the **same** pass: a relation loaded under `.with({ subTasks: { with: { watchers: true } } })` is looked up on `task_sub_tasks`, not on the query's base, so both levels are reported at once rather than one per run.
|
|
1744
|
+
|
|
1745
|
+
The one thing it still cannot see is a base table it cannot find — neither declared in `source:` nor written as `database.<name>` / `.select('<name>')`. There is then no table to resolve the relations against, and the query would otherwise report clean while every relation on it is unchecked. So doctor says so:
|
|
1746
|
+
|
|
1747
|
+
```
|
|
1748
|
+
⚠ 1 query could not be fully checked — the base table is neither declared nor readable from the executor:
|
|
1749
|
+
reports.byKind: `blocked` resolved against no table
|
|
1750
|
+
declare the base table in `source:` and re-run — relations under it are unchecked until then.
|
|
1751
|
+
```
|
|
1752
|
+
|
|
1717
1753
|
What doctor deliberately does NOT do is derive every table an executor reads. That needs a judgement — does this read **compose** the result or merely **restrict** it? — and only composing reads belong in `source:`; a restricting one re-running on every unrelated write puts a hundred lists back on the wire. A scan has to infer that from syntax, and a rule that guesses on a correct codebase teaches you to ignore it.
|
|
1718
1754
|
|
|
1719
1755
|
So: when a live view does not update, check the writing table is in the reading query's `source:` before anything else.
|
|
@@ -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
|
|