@voltro/cli 0.54.0 → 0.56.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 +639 -2
- package/bin/voltro.mjs +24 -0
- package/dist/{apiBuild-DTWp0S_q.js → apiBuild-Bdaetr37.js} +119 -88
- package/dist/apiBuild-Vw1figjO.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
- package/dist/buildReport-52gHKgfO.js +64 -0
- package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
- package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
- package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
- package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
- package/dist/codegen-DbH7NbCR.js +2 -0
- package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
- package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
- package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
- package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
- package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
- package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
- package/dist/dbCommand-DrycGWWt.js +2 -0
- package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
- package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
- package/dist/doctorCommand-BMWs6aVm.js +2 -0
- package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
- package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
- package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
- package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
- package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
- package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DlYlUPqs.js → infoCommand-BjVXpMlP.js} +1 -1
- package/dist/inspect-CNYvNXPU.js +1484 -0
- package/dist/inspect-S2rWy1Ys.js +2 -0
- package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
- package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
- package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
- package/dist/interruptedReplace-CwnkBb2X.js +41 -0
- package/dist/interruptedReplace-qzmFI020.js +2 -0
- package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
- package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
- package/dist/manifestBuild-DIa_s6u0.js +2 -0
- package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
- package/dist/precompressAssets-YhTi1aWp.js +40 -0
- package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
- package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
- package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
- package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
- package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
- package/dist/serveCommand-BITS8Hpj.js +2 -0
- package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
- package/dist/serveEntry.js +1 -1
- package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
- package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
- package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
- package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
- package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
- package/dist/updateCommand-DsXEAHbd.js +2 -0
- package/dist/webDev-C2dRz9s5.js +2 -0
- package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
- package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
- package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
- package/package.json +67 -19
- package/templates/AGENTS.core.md +20 -1
- package/templates/AGENTS.md +21 -2
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/authentication.md +49 -6
- package/templates/agent-docs/cli.md +216 -11
- package/templates/agent-docs/data.md +209 -18
- package/templates/agent-docs/database/scaling.md +40 -1
- package/templates/agent-docs/deployment.md +53 -1
- package/templates/agent-docs/internationalization.md +32 -0
- package/templates/agent-docs/local-first-mobile.md +9 -2
- package/templates/agent-docs/observability.md +227 -0
- package/templates/agent-docs/plugins/billing.md +15 -0
- package/templates/agent-docs/plugins/broadcast.md +2 -1
- package/templates/agent-docs/plugins/ratelimit.md +6 -1
- package/templates/agent-docs/plugins/row-history.md +11 -0
- package/templates/agent-docs/plugins.md +65 -8
- package/templates/agent-docs/reference.md +5 -3
- package/templates/agent-docs/routing.md +18 -0
- package/templates/agent-docs/schema-driven-ui.md +125 -0
- package/templates/agent-docs/templates/appshells.md +3 -3
- package/templates/agent-docs/whats-new.md +408 -106
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- 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-row-history/package.json +8 -8
- 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-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
- package/templates/apps/frontend-app/package.json +8 -9
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-auth/package.json +7 -8
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
- package/templates/apps/frontend-blank/package.json +6 -7
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-cms/package.json +8 -9
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
- package/templates/apps/frontend-collab/README.md +7 -2
- package/templates/apps/frontend-collab/package.json +9 -10
- package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
- package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
- package/templates/apps/frontend-dashboard/package.json +6 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-docs/package.json +8 -8
- 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 +7 -8
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/package.json +7 -8
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-spa/package.json +6 -7
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr/package.json +6 -7
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr-api/package.json +7 -8
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
- package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/frontend-status/package.json +7 -8
- package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
- package/templates/apps/mobile-app/package.json +4 -4
- package/templates/baselines/compose/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose/docker/web.Dockerfile +55 -10
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
- package/dist/apiBuild-CeUN55uk.js +0 -2
- package/dist/codegen-DjgxEOnD.js +0 -2
- package/dist/dbCommand-CSFWs9ev.js +0 -2
- package/dist/doctorCommand-J3qu4E0Y.js +0 -2
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
- package/dist/inspect-Bd8-9wsi.js +0 -1193
- package/dist/inspect-CuoDInfZ.js +0 -2
- package/dist/interruptedReplace-C3O3M1MM.js +0 -28
- package/dist/interruptedReplace-CvmiAM9K.js +0 -2
- package/dist/manifestBuild-C4-J1-m_.js +0 -2
- package/dist/serveCommand-BiPe8BJm.js +0 -2
- package/dist/updateCommand-CIoVDKnj.js +0 -2
- package/dist/webDev-DlvZO30c.js +0 -2
|
@@ -431,6 +431,15 @@ between acting on it and learning to skim it:
|
|
|
431
431
|
recorder resolves it through the relation registry instead, target and (for a
|
|
432
432
|
many-to-many) junction alike. A write to the junction changes membership,
|
|
433
433
|
which is exactly the change a user makes.
|
|
434
|
+
- **A `crud.*` executor is watched exactly like a hand-written one** — and it is
|
|
435
|
+
the case that needs it most. `crud.list('tasks', { include: { subTasks: true } })`
|
|
436
|
+
reads a table your own file never names, so there is nothing in front of you to
|
|
437
|
+
check `source:` against. The descriptor stays yours either way: `crud.*` supplies
|
|
438
|
+
only the executor, you write the `source:` beside it. `crud.count` counts as a
|
|
439
|
+
read too — it returns a number rather than rows, but an insert changes that
|
|
440
|
+
number, so the counted table belongs in `source:` or "page 3 of 12" stops moving.
|
|
441
|
+
`crud.create` / `update` / `remove` issue no read at all and never produce a
|
|
442
|
+
finding.
|
|
434
443
|
- **A table read only to NARROW a result is not counted** — a parent reached
|
|
435
444
|
through `inSubquery(...)`, or a read the framework made to resolve your row
|
|
436
445
|
filter. Those decide which rows come back rather than contributing rows, and
|
|
@@ -733,9 +742,12 @@ is always present (the fallback stands in until the first snapshot) and `loading
|
|
|
733
742
|
is a plain boolean reporting the true state. There is nothing to narrow.
|
|
734
743
|
|
|
735
744
|
**Errors.** `loading` means **no data has arrived yet** — it is not a claim that
|
|
736
|
-
the subscription is healthy. A **cold-start** failure (nothing ever arrived)
|
|
737
|
-
|
|
738
|
-
|
|
745
|
+
the subscription is healthy. A **cold-start** failure (nothing ever arrived) is
|
|
746
|
+
its own state: `loading` is `false`, `failed` is `true`, and `error` is
|
|
747
|
+
non-optional there, so branching on `loading` alone can no longer render a
|
|
748
|
+
skeleton forever. (It used to leave `loading` true, and the type's own comment
|
|
749
|
+
predicted the consequence — the fix was to stop making `loading` mean two
|
|
750
|
+
things rather than to keep warning about it.) A
|
|
739
751
|
failure AFTER data arrived deliberately does NOT replace good data with an error
|
|
740
752
|
banner (a transient websocket hiccup would blank a working screen); those reach
|
|
741
753
|
the api's error bus instead — subscribe with `useOnRpcError` for
|
|
@@ -1119,7 +1131,13 @@ export const employeesUpdate = defineMutation({
|
|
|
1119
1131
|
target: {
|
|
1120
1132
|
table: 'employees',
|
|
1121
1133
|
op: 'update',
|
|
1122
|
-
relations: {
|
|
1134
|
+
relations: {
|
|
1135
|
+
assignedStores: {
|
|
1136
|
+
junction: 'employee_assigned_stores',
|
|
1137
|
+
anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
|
|
1138
|
+
targetColumn: 'storeId', // the junction's other reference()
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1123
1141
|
},
|
|
1124
1142
|
})
|
|
1125
1143
|
```
|
|
@@ -1127,9 +1145,7 @@ export const employeesUpdate = defineMutation({
|
|
|
1127
1145
|
After the executor succeeds, `input.assignedStores` is reconciled against the
|
|
1128
1146
|
junction via the diff-based link writer (`store.relationLinks`): missing rows
|
|
1129
1147
|
inserted, surplus rows deleted, unchanged rows untouched — so reactive
|
|
1130
|
-
subscriptions on the junction see one change per changed row.
|
|
1131
|
-
column is derived from the junction's `reference()` targets; a self-junction
|
|
1132
|
-
(both columns referencing one table) is refused by name, never guessed.
|
|
1148
|
+
subscriptions on the junction see one change per changed row.
|
|
1133
1149
|
|
|
1134
1150
|
The semantics worth knowing: an ABSENT input field leaves the links
|
|
1135
1151
|
untouched — absent is not empty; an empty array is the explicit "clear them
|
|
@@ -1137,6 +1153,34 @@ all". The row id comes from the executor's `output.id`, falling back to
|
|
|
1137
1153
|
`input.id`. The link writes go through `ctx.store`, so undo capture and
|
|
1138
1154
|
cross-table rules see them like any other write.
|
|
1139
1155
|
|
|
1156
|
+
### The same declaration drives the optimistic update
|
|
1157
|
+
|
|
1158
|
+
A junction change used to reach the browser only with the server delta — so on
|
|
1159
|
+
one submit the renamed title flipped immediately and the assigned stores sat on
|
|
1160
|
+
their old value until the roundtrip landed. It does not any more: `useMutation`
|
|
1161
|
+
reconciles the junction rows of every subscription sourced on `junction` the
|
|
1162
|
+
moment the mutation is sent, against the same `input[field]` the server will
|
|
1163
|
+
write.
|
|
1164
|
+
|
|
1165
|
+
It is a diff, not a redraw: a surviving link keeps its own row (and its real
|
|
1166
|
+
id), a surplus link disappears, and only a genuinely new link is a staged
|
|
1167
|
+
optimistic row. The patches ride the ordinary optimistic lane — reverted if the
|
|
1168
|
+
mutation fails, kept after it succeeds until the server data actually moves.
|
|
1169
|
+
Nothing is on a timer.
|
|
1170
|
+
|
|
1171
|
+
Client-side the anchor id is `input.id`; for an `insert` it is the same
|
|
1172
|
+
optimistic id the new row was stamped with, since the server's `output.id` is
|
|
1173
|
+
not knowable before the response arrives.
|
|
1174
|
+
|
|
1175
|
+
**Why you state the two columns.** The optimistic patch runs in the BROWSER, and
|
|
1176
|
+
the browser cannot import your `db/` schema — `@voltro/database` is server-only
|
|
1177
|
+
by construction — so the junction's two `reference()` columns cannot be derived
|
|
1178
|
+
there. `anchorColumn` is the one pointing at the target's own table;
|
|
1179
|
+
`targetColumn` is the other. They are not taken on trust: before it writes, the
|
|
1180
|
+
server compares your declaration against the junction's real reference columns
|
|
1181
|
+
and refuses, naming the correct pair, if they disagree. A self-junction (both
|
|
1182
|
+
columns referencing one table) is still refused by name, never guessed.
|
|
1183
|
+
|
|
1140
1184
|
## Typed Errors
|
|
1141
1185
|
|
|
1142
1186
|
```ts
|
|
@@ -2065,7 +2109,8 @@ the resume window (`reactive.resume.windowMs`, default 60 s) the server replays
|
|
|
2065
2109
|
**only the deltas the client missed** — the re-subscribe presents the last
|
|
2066
2110
|
materialised revision and the stream continues on the same revision line, so a
|
|
2067
2111
|
short offline gap costs a handful of patches instead of every row. Outside the
|
|
2068
|
-
window, for computed queries, for
|
|
2112
|
+
window, for computed queries, for a subscription whose source table a
|
|
2113
|
+
registered row filter may narrow, or whenever anything is in
|
|
2069
2114
|
doubt, the query answers with a fresh snapshot — the delta-resume wire contract
|
|
2070
2115
|
lives in [the wire protocol](/docs/data/wire-protocol#reconnect-delta-resume).
|
|
2071
2116
|
|
|
@@ -2301,6 +2346,38 @@ The contract, in the order it protects you:
|
|
|
2301
2346
|
|
|
2302
2347
|
**The boundary to keep:** if your own UI needs live data, that is a query + `useSubscription`, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.
|
|
2303
2348
|
|
|
2349
|
+
## When the api connects — `web.api.connect`
|
|
2350
|
+
|
|
2351
|
+
Every declared api opens its WebSocket at mount by default. That is `'eager'`,
|
|
2352
|
+
and it is what the framework has always done.
|
|
2353
|
+
|
|
2354
|
+
```ts
|
|
2355
|
+
// apps/web/app.config.ts
|
|
2356
|
+
web: { api: { connect: 'lazy' } }
|
|
2357
|
+
```
|
|
2358
|
+
|
|
2359
|
+
`'lazy'` defers the connection to the FIRST hook that asks for that api —
|
|
2360
|
+
`useSubscription`, `useMutation`, `useAppClient`, any of them. A page that reads
|
|
2361
|
+
no data never opens a socket.
|
|
2362
|
+
|
|
2363
|
+
Two measurements decide whether you want it. Both are from a real browser
|
|
2364
|
+
against a `voltro start`:
|
|
2365
|
+
|
|
2366
|
+
- **`interactive: 'full'` is the default, and it connected regardless.** A
|
|
2367
|
+
pre-rendered documentation page that subscribes to nothing opened a socket;
|
|
2368
|
+
pages set to `interactive: 'none'` or `'islands'` opened none. So the pages
|
|
2369
|
+
paying for a connection they never use are exactly the ordinary ones.
|
|
2370
|
+
- **An open socket keeps a dormancy-managed instance awake.** `isIdleNow`
|
|
2371
|
+
returns false while `connectedClients() > 0` (see
|
|
2372
|
+
[scale-to-zero](/docs/deployment/scale-to-zero)), so one browser tab left open
|
|
2373
|
+
on a pricing page prevents scale-to-zero for as long as it stays open.
|
|
2374
|
+
|
|
2375
|
+
`'eager'` remains the default because `'lazy'` moves WHEN a connection error
|
|
2376
|
+
surfaces — from page load to first data use — and an app that opens its socket
|
|
2377
|
+
for a side effect (a presence ping, an inspect stream) rather than through a data
|
|
2378
|
+
hook would notice the difference. If your app subscribes on every page, the two
|
|
2379
|
+
behave identically.
|
|
2380
|
+
|
|
2304
2381
|
## See also
|
|
2305
2382
|
|
|
2306
2383
|
- [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
|
|
@@ -3943,7 +4020,7 @@ These helpers give you the secure **handler**, not schema derivation. Deriving t
|
|
|
3943
4020
|
|
|
3944
4021
|
_Per-table post-commit reactivity via file convention. Default-exported defineSubscriber({ table, on, handler }) — fires AFTER commit, best-effort, fire-and-forget for async handlers._
|
|
3945
4022
|
|
|
3946
|
-
Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index,
|
|
4023
|
+
Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index, invalidate a cache, push to a worker queue, emit an external notification. The handler runs on **every replica**; when its effect must not repeat, add [`once:`](#how-often-does-it-run-once-per-replica-unless-you-say-otherwise). The file convention is parallel to `*.startup.ts` / `*.cron.tsx` / `*.webhook.tsx`: drop a file matching the suffix anywhere under `apps/<api>/`, default-export a `defineSubscriber({...})`, the framework discovers + binds it at boot.
|
|
3947
4024
|
|
|
3948
4025
|
Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work — "a row changed, now run a workflow" — reach for a [reaction](/docs/data/reactions) instead.
|
|
3949
4026
|
|
|
@@ -4014,6 +4091,90 @@ The `on` filter narrows by operation:
|
|
|
4014
4091
|
|
|
4015
4092
|
Other-table events get filtered out before your handler sees them. The matcher does this at the dispatcher level so subscribers add zero hot-path overhead to writes that don't match their table.
|
|
4016
4093
|
|
|
4094
|
+
## How often does it run? Once per replica — unless you say otherwise
|
|
4095
|
+
|
|
4096
|
+
A subscriber binds to the change stream on **every api instance**. One `INSERT`
|
|
4097
|
+
behind three replicas calls your handler three times.
|
|
4098
|
+
|
|
4099
|
+
That is the right default and not a gap. A handler that refreshes a per-process
|
|
4100
|
+
cache, warms a local index, or updates in-memory state *has* to run everywhere —
|
|
4101
|
+
a fleet-wide gate would leave every other replica stale. The default assumes the
|
|
4102
|
+
handler is **idempotent**.
|
|
4103
|
+
|
|
4104
|
+
It is the wrong default for an **effect** — a notification, a mail, a webhook, a
|
|
4105
|
+
payment — because there is nothing to make idempotent: the effect IS a write, so
|
|
4106
|
+
each run produces another one. Three replicas send three mails.
|
|
4107
|
+
|
|
4108
|
+
`once: true` is the whole answer for most handlers — the framework derives the
|
|
4109
|
+
key:
|
|
4110
|
+
|
|
4111
|
+
```ts
|
|
4112
|
+
export default defineSubscriber({
|
|
4113
|
+
table: 'absence_requests',
|
|
4114
|
+
on: ['insert'],
|
|
4115
|
+
once: true, // exactly one replica runs the handler per change
|
|
4116
|
+
handler: notifyApprovers,
|
|
4117
|
+
})
|
|
4118
|
+
```
|
|
4119
|
+
|
|
4120
|
+
It names the change by its CONTENT plus its position among content-identical
|
|
4121
|
+
repeats. That is not a detail: a fleet change carries no LSN, no commit id and no
|
|
4122
|
+
`traceId` (the last one deliberately, so a local trace is never mis-attributed to
|
|
4123
|
+
a remote write), so content is the only thing two replicas provably agree on —
|
|
4124
|
+
and `A→B`, then `B→A`, then `A→B` again has to count as three changes, not two.
|
|
4125
|
+
|
|
4126
|
+
Pass a **function** when you want to be COARSER than one-per-change: two updates
|
|
4127
|
+
that differ only in a field you do not care about are two changes to `once: true`
|
|
4128
|
+
and can be one to a key you write yourself.
|
|
4129
|
+
|
|
4130
|
+
```ts
|
|
4131
|
+
// apps/api/subscribers/notifyApprovers.subscribe.ts
|
|
4132
|
+
export default defineSubscriber({
|
|
4133
|
+
table: 'absence_requests',
|
|
4134
|
+
on: ['insert'],
|
|
4135
|
+
// Cluster-wide: exactly one replica runs the handler for each change.
|
|
4136
|
+
once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
|
|
4137
|
+
handler: async (event, ctx) => {
|
|
4138
|
+
for (const approver of await approversOf(ctx, event.new)) {
|
|
4139
|
+
await sendNotification(ctx, { toEmployeeId: approver.id })
|
|
4140
|
+
}
|
|
4141
|
+
},
|
|
4142
|
+
})
|
|
4143
|
+
```
|
|
4144
|
+
|
|
4145
|
+
**A key you write must tell two genuine changes apart.** A row id is enough for
|
|
4146
|
+
`insert` and `delete`, where a row changes state once. It is not enough for
|
|
4147
|
+
`update`: two edits to the same row produce the same id, and the second would be
|
|
4148
|
+
dropped as a duplicate of the first — an effect that silently stops happening for
|
|
4149
|
+
a row that keeps changing. Put something that moves in the key, or use
|
|
4150
|
+
`once: true`:
|
|
4151
|
+
|
|
4152
|
+
```ts
|
|
4153
|
+
once: (event) => {
|
|
4154
|
+
const row = event.new as { id?: string; updatedAt?: Date } | null
|
|
4155
|
+
return `${row?.id ?? ''}:${row?.updatedAt?.toISOString() ?? ''}`
|
|
4156
|
+
},
|
|
4157
|
+
```
|
|
4158
|
+
|
|
4159
|
+
**`once` is AT MOST once, not exactly once.** The claim is taken before the
|
|
4160
|
+
handler runs, so a replica that wins and then dies takes the event with it, and a
|
|
4161
|
+
claim that cannot be written at all (database unreachable) is taken by nobody.
|
|
4162
|
+
Both are loud in the log and neither is retried — a subscriber is best-effort by
|
|
4163
|
+
construction. When the effect must not be lost, the change stream is the wrong
|
|
4164
|
+
seam: run it inside the mutation, or start a workflow from a
|
|
4165
|
+
[reaction](/docs/data/reactions), where durability is the primitive's job.
|
|
4166
|
+
|
|
4167
|
+
The boot log says which one each subscriber got:
|
|
4168
|
+
|
|
4169
|
+
```
|
|
4170
|
+
subscriber: registered table=absence_requests on=["insert"] once=fleet
|
|
4171
|
+
subscriber: registered table=posts on=["insert","update"] once=per-replica
|
|
4172
|
+
```
|
|
4173
|
+
|
|
4174
|
+
Claims live in `_voltro_change_claims` and are swept after an hour
|
|
4175
|
+
(`VOLTRO_CHANGE_CLAIMS_TTL_HOURS`). The key is namespaced per subscriber file, so
|
|
4176
|
+
two subscribers watching one table never lock each other out.
|
|
4177
|
+
|
|
4017
4178
|
## Semantics — best-effort, fire-and-forget
|
|
4018
4179
|
|
|
4019
4180
|
Subscribers are **non-durable** by design:
|
|
@@ -4087,6 +4248,8 @@ export default defineSubscriber({
|
|
|
4087
4248
|
export default defineSubscriber({
|
|
4088
4249
|
table: 'organizations',
|
|
4089
4250
|
on: 'insert',
|
|
4251
|
+
// The POST is an effect: without `once` every replica sends one.
|
|
4252
|
+
once: (event) => String((event.new as { id?: string } | null)?.id ?? ''),
|
|
4090
4253
|
handler: async (event, ctx) => {
|
|
4091
4254
|
if (!event.new) return
|
|
4092
4255
|
const slug = event.new.slug as string
|
|
@@ -4203,9 +4366,17 @@ into the agent's prompt.
|
|
|
4203
4366
|
|
|
4204
4367
|
## Guards (the point)
|
|
4205
4368
|
|
|
4206
|
-
- **`dedupeKey` (required)** — the same logical change acts exactly once
|
|
4207
|
-
|
|
4369
|
+
- **`dedupeKey` (required)** — the same logical change acts exactly once, across
|
|
4370
|
+
the whole fleet. The key is claimed in `_voltro_change_claims` before the act
|
|
4371
|
+
runs (INSERT-wins on a UNIQUE — the same arbiter the cron scheduler uses), so
|
|
4372
|
+
two replicas seeing one change start one workflow, not two. This is also what
|
|
4373
|
+
stops a reaction whose act writes the watched table from self-triggering
|
|
4208
4374
|
forever. `defineReaction` throws at boot if it's missing.
|
|
4375
|
+
|
|
4376
|
+
The claim is taken BEFORE the act, which is what makes it a gate rather than a
|
|
4377
|
+
report — and the cost is stated rather than hidden: an act that THROWS has
|
|
4378
|
+
already consumed its key and is not re-run by a later duplicate. Durability
|
|
4379
|
+
belongs to the workflow the act starts, not to the trigger.
|
|
4209
4380
|
- **`rateLimit` (optional)** — at most `limit` firings per `windowMs`.
|
|
4210
4381
|
- **`costBudgetUsd` (optional)** — a per-tenant AI spend ceiling; over budget,
|
|
4211
4382
|
the reaction refuses (fails closed).
|
|
@@ -4226,8 +4397,9 @@ into the agent's prompt.
|
|
|
4226
4397
|
- Best-effort + fire-and-forget (like subscribers) — a failing act logs +
|
|
4227
4398
|
continues; it can't back-pressure the change stream. Durability comes from a
|
|
4228
4399
|
workflow act (an agent act is best-effort).
|
|
4229
|
-
- `dedupeKey`
|
|
4230
|
-
|
|
4400
|
+
- `dedupeKey` claims survive a restart but not forever: `_voltro_change_claims`
|
|
4401
|
+
is swept after an hour (`VOLTRO_CHANGE_CLAIMS_TTL_HOURS`). A change whose key
|
|
4402
|
+
reappears after that window acts again.
|
|
4231
4403
|
|
|
4232
4404
|
## When to use what
|
|
4233
4405
|
|
|
@@ -4672,6 +4844,19 @@ available without a distributed transaction into the target system, so:
|
|
|
4672
4844
|
**Handlers must be idempotent.** A process that dies between "the remote
|
|
4673
4845
|
accepted it" and "we recorded that" will retry.
|
|
4674
4846
|
|
|
4847
|
+
**On several replicas, that used to be the smaller reason.** Every replica runs
|
|
4848
|
+
the drain, and the drain read every pending row — so an effect was dispatched
|
|
4849
|
+
once *per replica*, on the happy path, every time. It is claimed now: a row moves
|
|
4850
|
+
`pending → delivering` in one atomic statement stamped with the claiming
|
|
4851
|
+
process, so a racing replica loses the row rather than duplicating it, and a
|
|
4852
|
+
claim whose holder stops responding is returned to the queue after its lease
|
|
4853
|
+
(`claimLeaseMs`, default 5 minutes — raise it above your slowest handler).
|
|
4854
|
+
|
|
4855
|
+
That removes the routine duplicate. It does not make delivery exactly-once, and
|
|
4856
|
+
nothing can: the process can still die between the remote accepting and the row
|
|
4857
|
+
being marked. The idempotency requirement stands — it is now about the failure
|
|
4858
|
+
case it was always meant to describe, rather than about every single delivery.
|
|
4859
|
+
|
|
4675
4860
|
## Declaring the handler
|
|
4676
4861
|
|
|
4677
4862
|
One `*.outbox.ts` file per effect:
|
|
@@ -5022,11 +5207,17 @@ A resume is declined — always with a fresh snapshot — when:
|
|
|
5022
5207
|
keeps delta-resume on every subscription whose source is not in that set —
|
|
5023
5208
|
the common case, since most filters narrow a handful of tables. Without the
|
|
5024
5209
|
declaration the framework cannot know which tables the predicate may reach
|
|
5025
|
-
and excludes them all
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5210
|
+
and excludes them all. Eager loads are excluded wholesale because a relation
|
|
5211
|
+
resolves below the seam that narrows. They reconnect with a fresh snapshot,
|
|
5212
|
+
exactly as before.
|
|
5213
|
+
|
|
5214
|
+
**Which of your queries actually got a ring** is recorded per label, since the
|
|
5215
|
+
excluded and the never-eligible look identical on the wire:
|
|
5216
|
+
`/_voltro/inspect/subscriptions` returns a `resume` array of
|
|
5217
|
+
`{ label, resumable, excluded }`, and `voltro dev` logs each verdict once under
|
|
5218
|
+
the `voltro:resume` scope. A `computed` verdict is the one worth reading first:
|
|
5219
|
+
it means the executor returns a value rather than a descriptor, so no row-filter
|
|
5220
|
+
declaration can ever change it.
|
|
5030
5221
|
|
|
5031
5222
|
**Author a live-subscribed getter to return, not throw.** A subscription is a
|
|
5032
5223
|
long-lived stream, so a getter that throws on every re-evaluation is a broken
|
|
@@ -148,6 +148,13 @@ deployments (k8s with `replicas: 3`, multi-pod ECS, etc.) need a
|
|
|
148
148
|
shared store, otherwise a write on instance A doesn't pin reads on
|
|
149
149
|
instance B.
|
|
150
150
|
|
|
151
|
+
**The boot says so now.** When replicas are configured and the RYW store falls
|
|
152
|
+
back to memory on a deployment whose environment says several replicas
|
|
153
|
+
(`POD_NAME`, `FLY_ALLOC_ID`, `K_REVISION`, … — or `REPLICA_COUNT`, which is a
|
|
154
|
+
declaration in both directions), the boot warns and names the two ways out. It
|
|
155
|
+
used to log `ryw policy 'fallback'` as though the policy were in force, and the
|
|
156
|
+
first symptom was a user reloading and seeing their own save gone.
|
|
157
|
+
|
|
151
158
|
Wire Redis:
|
|
152
159
|
|
|
153
160
|
```sh
|
|
@@ -306,6 +313,31 @@ The bus is **additive** to the inline emit path. Local reactivity must survive a
|
|
|
306
313
|
|
|
307
314
|
Because the inline path is never removed, a broker outage degrades **cross-replica** fan-out only — local reactivity keeps working, and the framework logs a warning. The bus reconnects when the broker returns.
|
|
308
315
|
|
|
316
|
+
## When the connection drops
|
|
317
|
+
|
|
318
|
+
Every cross-replica mechanism here rides a connection, and a connection that dies quietly is worse than one that fails loudly: the app keeps serving, the clients keep their sockets, and their live queries simply stop updating. So each path is required to notice, recover, and then **say that it lost something**.
|
|
319
|
+
|
|
320
|
+
**A hole is never patched — it is re-derived.** None of these transports keeps a log. Postgres queues nothing for a listener that is not there; Redis and NATS pub/sub retain nothing at all. So there is nothing to replay, and the only complete recovery is to re-run every live query. That is safe precisely because a live query is idempotent, and it is what the framework does on every one of the events below:
|
|
321
|
+
|
|
322
|
+
| What happened | How it is noticed | What you see |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| The postgres `LISTEN` connection died (failover, proxy, `pg_terminate_backend`) | A heartbeat sent through the pool goes unanswered on the LISTEN stream | `cdc: reconnecting` → `cdc: reconnected`, then every live query refreshes |
|
|
325
|
+
| A peer's serial jumped — the broker dropped messages | Per-origin serial accounting; the count is exact | `broadcast: missed N change(s) from …` |
|
|
326
|
+
| This replica could not subscribe at boot (the broker was restarting) | The subscribe is retried in the background | `broadcast: could not subscribe` → `broadcast: subscribed`, then a refresh |
|
|
327
|
+
| The Redis or NATS transport re-dialled underneath us | The driver's connection lifecycle | `broadcast: transport disconnected` → a refresh on reconnect |
|
|
328
|
+
|
|
329
|
+
Two consequences worth knowing:
|
|
330
|
+
|
|
331
|
+
- **A broker that is down at boot does not stop the boot.** The replica starts, serves, keeps local reactivity, and joins the bus when the broker returns. A crash loop across the whole fleet is the wrong answer to a broker restart — which is exactly when every replica is dialling at once.
|
|
332
|
+
- **A replica that restarts under a stable name is recognised as a new process.** A StatefulSet pod keeps its `POD_NAME`, and `VOLTRO_REPLICA_ID` is stable by definition, so the name alone cannot tell a restart from a continuation. Each publish carries a per-process epoch so peers reset their watermark instead of quietly ignoring the new process's serials.
|
|
333
|
+
|
|
334
|
+
The postgres heartbeat is idle-only: any traffic on the channel — including another replica's heartbeat — counts as proof the connection works, so a busy channel never pays for one and a fleet pays roughly one probe per idle window however many replicas it has. Tune it with:
|
|
335
|
+
|
|
336
|
+
| Variable | Default | Meaning |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `VOLTRO_CDC_HEARTBEAT_MS` | `20000` | Silence on the channel before a probe is sent |
|
|
339
|
+
| `VOLTRO_CDC_HEARTBEAT_TIMEOUT_MS` | `10000` | How long an unanswered probe may go before the consumer is declared dead |
|
|
340
|
+
|
|
309
341
|
## The honest caveat — app-mutation changes only
|
|
310
342
|
|
|
311
343
|
The bus carries changes that flow through **`ctx.store`** (the framework's mutation path). It does **not** capture **out-of-band DB writes** — a `psql` session, a cron job, or a second service writing the same database directly. Those changes never hit `store.onChange`, so they never reach the bus.
|
|
@@ -340,7 +372,14 @@ A changelog table that every replica polls (`SELECT … WHERE seq > :last`) woul
|
|
|
340
372
|
will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis / NATS) to close the gap…
|
|
341
373
|
```
|
|
342
374
|
|
|
343
|
-
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active —
|
|
375
|
+
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — and they carry **different things**. The native path carries table changes to every replica. The bus carries reactivity *channels* (`publishReactivity`), which are not database writes and so have no native transport at all.
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
[voltro:dev] reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
|
|
379
|
+
@voltro/plugin-broadcast (redis) carries reactivity channels
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
This page used to say the two paths were harmless redundancy because "the own-origin skip dedups". They were not. The own-origin skip only ever covered a replica's own publish coming back to itself — so a change the native transport had already delivered to every replica was re-published by every replica under *its own* origin, and each peer injected it again. N replicas turned one change into N² deliveries: every subscriber, every live-query wake, every plugin tap. At two replicas a `*.subscribe.ts` handler ran **four** times for one `INSERT`, twice per instance.
|
|
344
383
|
|
|
345
384
|
## sqlite and the memory store behind replicas
|
|
346
385
|
|
|
@@ -701,6 +701,50 @@ The api stays self-hosted (it must — WebSocket subscriptions + Postgres are
|
|
|
701
701
|
always-on); only the static bytes move to the edge. `POST /rpc` forwards the
|
|
702
702
|
session cookie exactly as the WS path, so SSR loaders + auth resolve identically.
|
|
703
703
|
|
|
704
|
+
## Cache headers — `voltro start` sets them, and what they say
|
|
705
|
+
|
|
706
|
+
Since 0.56.0 the production web server stamps `Cache-Control` on everything it
|
|
707
|
+
serves out of `dist/`. Before that it stamped nothing, and a browser with neither
|
|
708
|
+
`Cache-Control` nor `Last-Modified` has no freshness information at all: it
|
|
709
|
+
revalidated every content-hashed chunk on every visit — for the reference fixture
|
|
710
|
+
that is ten conditional round-trips before the page is interactive, on files whose
|
|
711
|
+
name carries their content hash.
|
|
712
|
+
|
|
713
|
+
| What | Header | Why |
|
|
714
|
+
|---|---|---|
|
|
715
|
+
| `/assets/index-7N08IhkU.js` (content-hashed) | `public, max-age=31536000, immutable` | The hash IS the version. A new build is a new URL, so there is nothing to invalidate. |
|
|
716
|
+
| `/favicon.svg`, `/robots.txt`, anything from `public/` | `public, max-age=3600` | The URL is stable across deploys, so a long life would pin a stale file. |
|
|
717
|
+
| A pre-rendered page | `public, max-age=0, must-revalidate` | The ETag turns the revalidation into a `304` with no body. |
|
|
718
|
+
| An `isr` page | `public, max-age=0, must-revalidate` | Same, unless you opt into sharing it — below. |
|
|
719
|
+
|
|
720
|
+
The defaults are deliberately safe for a SHARED cache: nothing is given an
|
|
721
|
+
`s-maxage`, because a CDN holding an HTML page past a deploy serves the previous
|
|
722
|
+
build's asset URLs and the framework has no purge hook to fix that. Two opt-ins,
|
|
723
|
+
for apps that own their CDN and can purge it:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
// apps/web/app.config.ts
|
|
727
|
+
http: {
|
|
728
|
+
cache: {
|
|
729
|
+
// Let a shared cache hold pre-rendered HTML for 60s.
|
|
730
|
+
htmlSMaxAgeSeconds: 60,
|
|
731
|
+
// Let a shared cache hold an `isr` page for its OWN `revalidate` window
|
|
732
|
+
// (plus its stale-while-revalidate). Off by default: `revalidatePath`
|
|
733
|
+
// purges the framework's cache and a CDN cannot see that purge, so an
|
|
734
|
+
// on-demand invalidation would take up to `revalidate` seconds to reach a
|
|
735
|
+
// shared copy.
|
|
736
|
+
isrShared: true,
|
|
737
|
+
// Both lifetimes are tunable; `immutableMaxAgeSeconds: 0` turns the
|
|
738
|
+
// immutable header off entirely.
|
|
739
|
+
immutableMaxAgeSeconds: 31_536_000,
|
|
740
|
+
staticMaxAgeSeconds: 3_600,
|
|
741
|
+
},
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
When you put the assets on a CDN with `voltro static`, the host's own rules apply
|
|
746
|
+
to them instead — these headers are what the CONTAINER path says.
|
|
747
|
+
|
|
704
748
|
## What the framework decides for you
|
|
705
749
|
|
|
706
750
|
- **Content-Type** per file, from its extension (including the ones generic tools
|
|
@@ -1623,7 +1667,7 @@ config format and have **not** been executed against a live account of that
|
|
|
1623
1667
|
platform; if one drifts from what the platform ships today, the container is
|
|
1624
1668
|
still right and the fix is in the wrapper.
|
|
1625
1669
|
|
|
1626
|
-
|
|
1670
|
+
Four properties of the image every platform relies on:
|
|
1627
1671
|
|
|
1628
1672
|
- **`PORT` wins.** The port precedence is `PORT` > `--port` > `app.config.ts` —
|
|
1629
1673
|
deliberately, because platforms assign through `PORT`. You never configure a
|
|
@@ -1635,6 +1679,14 @@ Three properties of the image every platform relies on:
|
|
|
1635
1679
|
- **`/internal/readiness` flips to 200 only after the whole boot.** Use it as
|
|
1636
1680
|
the health check everywhere; routing traffic on process-up instead of
|
|
1637
1681
|
readiness is how a deploy serves 502s for the first seconds.
|
|
1682
|
+
- **The build imports the serve bundle before the image is finished, and a
|
|
1683
|
+
failed import fails the build.** The image-build stage has no database and no
|
|
1684
|
+
secrets, so it cannot require a full boot — but it does not need one: a module
|
|
1685
|
+
`prune-runtime` traced away, a truncated artefact, a wrong entry path or a
|
|
1686
|
+
missing export all fail at *import*, long before anything connects. So the
|
|
1687
|
+
import and a callable `runServe` are required; how far the subsequent start
|
|
1688
|
+
gets is reported, not required. If your build turns red at `load gate:`, the
|
|
1689
|
+
artefact is wrong and no amount of environment will fix it.
|
|
1638
1690
|
|
|
1639
1691
|
## Fly.io
|
|
1640
1692
|
|
|
@@ -102,6 +102,38 @@ Locale was already agreed; the zone and the clock were each read from the ambien
|
|
|
102
102
|
|
|
103
103
|
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.
|
|
104
104
|
|
|
105
|
+
## The language picker
|
|
106
|
+
|
|
107
|
+
`<LocaleSwitcher>` writes the `voltro:locale` cookie and reloads, so the server
|
|
108
|
+
re-renders in the chosen language. It ships from `@voltro/i18n` **unstyled** — a
|
|
109
|
+
native `<select>` you style with your own CSS:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
import { useLocale, useT, LocaleSwitcher } from '@voltro/i18n'
|
|
113
|
+
|
|
114
|
+
const LOCALES = [
|
|
115
|
+
{ code: 'en', label: 'English' },
|
|
116
|
+
{ code: 'de', label: 'Deutsch' },
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
<LocaleSwitcher
|
|
120
|
+
locales={LOCALES}
|
|
121
|
+
current={useLocale()} // server + first paint agree; without it the
|
|
122
|
+
ariaLabel={useT('lang.label')} // control hydrates from the cookie after mount
|
|
123
|
+
className="my-lang-select"
|
|
124
|
+
/>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A native `<select>` on purpose: it works everywhere with no portal or
|
|
128
|
+
positioning chrome, is keyboard-accessible by default, and is SSR-safe.
|
|
129
|
+
`onChange` returning `false` suppresses the reload — for an app whose i18n
|
|
130
|
+
runtime swaps catalogs in place.
|
|
131
|
+
|
|
132
|
+
`@voltro/ui-shadcn` exports the same control pre-styled with the kit's classes.
|
|
133
|
+
Use that one **only in a kit app**: its classes exist only when your CSS entry
|
|
134
|
+
imports `@voltro/ui-shadcn/tokens.css`. Without it the control renders as a bare
|
|
135
|
+
`<select>` with dead `class` attributes — `voltro doctor` reports exactly this.
|
|
136
|
+
|
|
105
137
|
## The cookie names are exported
|
|
106
138
|
|
|
107
139
|
`LOCALE_COOKIE` and `THEME_COOKIE` come from **`@voltro/i18n`** (and from `@voltro/ui-shadcn` if you use the kit):
|
|
@@ -399,9 +399,16 @@ Two disciplines it enforces, because both fail silently when hand-rolled:
|
|
|
399
399
|
when `local` is `true`. Without that every client re-broadcasts what it
|
|
400
400
|
just received: one keystroke, one server write per open tab.
|
|
401
401
|
|
|
402
|
-
A page mounting an editor needs `renderMode = '
|
|
402
|
+
A page mounting an editor needs `renderMode = 'spa'`. The default is
|
|
403
403
|
`'static'`, which pre-renders at build time, and the editor finds no
|
|
404
|
-
`window` there.
|
|
404
|
+
`window` there. `'spa'` is skipped by the prerender and mounts on the
|
|
405
|
+
client; if the route has a layout, that layout still renders server-side
|
|
406
|
+
as an SSR shell.
|
|
407
|
+
|
|
408
|
+
This page said `'client'`, which is not one of the four render modes and
|
|
409
|
+
fails the build — see [Render modes](/docs/routing/render-modes), which
|
|
410
|
+
names that exact value as invalid. The `frontend-collab` template copied
|
|
411
|
+
the sentence and was unbuildable for as long as it existed.
|
|
405
412
|
|
|
406
413
|
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
407
414
|
metadata: the roster's value-compare push would make every caret move a
|