@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.
Files changed (173) hide show
  1. package/CHANGELOG.md +639 -2
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/{apiBuild-DTWp0S_q.js → apiBuild-Bdaetr37.js} +119 -88
  4. package/dist/apiBuild-Vw1figjO.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
  9. package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
  14. package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
  15. package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
  16. package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
  17. package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
  18. package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
  19. package/dist/dbCommand-DrycGWWt.js +2 -0
  20. package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
  21. package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
  22. package/dist/doctorCommand-BMWs6aVm.js +2 -0
  23. package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
  24. package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
  25. package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
  26. package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
  27. package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
  28. package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
  29. package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
  30. package/dist/index.js +1 -1
  31. package/dist/{infoCommand-DlYlUPqs.js → infoCommand-BjVXpMlP.js} +1 -1
  32. package/dist/inspect-CNYvNXPU.js +1484 -0
  33. package/dist/inspect-S2rWy1Ys.js +2 -0
  34. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  35. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  36. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  37. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  38. package/dist/interruptedReplace-qzmFI020.js +2 -0
  39. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
  40. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
  41. package/dist/manifestBuild-DIa_s6u0.js +2 -0
  42. package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
  43. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  44. package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
  45. package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
  46. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  47. package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
  48. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  49. package/dist/serveCommand-BITS8Hpj.js +2 -0
  50. package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
  51. package/dist/serveEntry.js +1 -1
  52. package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
  53. package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
  54. package/dist/startEntry.js +1 -1
  55. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  56. package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
  57. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
  58. package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
  59. package/dist/updateCommand-DsXEAHbd.js +2 -0
  60. package/dist/webDev-C2dRz9s5.js +2 -0
  61. package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
  62. package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
  63. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  64. package/package.json +67 -19
  65. package/templates/AGENTS.core.md +20 -1
  66. package/templates/AGENTS.md +21 -2
  67. package/templates/agent-docs/_index.md +1 -1
  68. package/templates/agent-docs/_manifest.json +1 -1
  69. package/templates/agent-docs/authentication.md +49 -6
  70. package/templates/agent-docs/cli.md +216 -11
  71. package/templates/agent-docs/data.md +209 -18
  72. package/templates/agent-docs/database/scaling.md +40 -1
  73. package/templates/agent-docs/deployment.md +53 -1
  74. package/templates/agent-docs/internationalization.md +32 -0
  75. package/templates/agent-docs/local-first-mobile.md +9 -2
  76. package/templates/agent-docs/observability.md +227 -0
  77. package/templates/agent-docs/plugins/billing.md +15 -0
  78. package/templates/agent-docs/plugins/broadcast.md +2 -1
  79. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  80. package/templates/agent-docs/plugins/row-history.md +11 -0
  81. package/templates/agent-docs/plugins.md +65 -8
  82. package/templates/agent-docs/reference.md +5 -3
  83. package/templates/agent-docs/routing.md +18 -0
  84. package/templates/agent-docs/schema-driven-ui.md +125 -0
  85. package/templates/agent-docs/templates/appshells.md +3 -3
  86. package/templates/agent-docs/whats-new.md +408 -106
  87. package/templates/apps/api-ai/package.json +6 -6
  88. package/templates/apps/api-auth/package.json +8 -8
  89. package/templates/apps/api-backend/package.json +7 -7
  90. package/templates/apps/api-backend-deactivation/package.json +7 -7
  91. package/templates/apps/api-backend-mail/package.json +8 -8
  92. package/templates/apps/api-backend-mariadb/package.json +9 -9
  93. package/templates/apps/api-backend-sqlite/package.json +8 -8
  94. package/templates/apps/api-backend-storage/package.json +8 -8
  95. package/templates/apps/api-cms/package.json +9 -9
  96. package/templates/apps/api-collab/package.json +8 -8
  97. package/templates/apps/api-data-advanced/package.json +8 -8
  98. package/templates/apps/api-durable/package.json +8 -8
  99. package/templates/apps/api-feature-flags/package.json +9 -9
  100. package/templates/apps/api-governance/package.json +8 -8
  101. package/templates/apps/api-kv/package.json +8 -8
  102. package/templates/apps/api-moderation/package.json +8 -8
  103. package/templates/apps/api-observability/package.json +8 -8
  104. package/templates/apps/api-ratelimit/package.json +8 -8
  105. package/templates/apps/api-rbac/package.json +8 -8
  106. package/templates/apps/api-rest/package.json +7 -7
  107. package/templates/apps/api-row-history/package.json +8 -8
  108. package/templates/apps/api-saas/package.json +11 -11
  109. package/templates/apps/api-saas-starter/package.json +10 -10
  110. package/templates/apps/api-search/package.json +8 -8
  111. package/templates/apps/api-status/package.json +8 -8
  112. package/templates/apps/api-webhooks/package.json +9 -9
  113. package/templates/apps/changelog/package.json +7 -7
  114. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  115. package/templates/apps/edge-functions/package.json +2 -2
  116. package/templates/apps/frontend-admin/package.json +7 -8
  117. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  118. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  119. package/templates/apps/frontend-app/package.json +8 -9
  120. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  121. package/templates/apps/frontend-auth/package.json +7 -8
  122. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  123. package/templates/apps/frontend-blank/package.json +6 -7
  124. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  125. package/templates/apps/frontend-cms/package.json +8 -9
  126. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  127. package/templates/apps/frontend-collab/README.md +7 -2
  128. package/templates/apps/frontend-collab/package.json +9 -10
  129. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  130. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  131. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  132. package/templates/apps/frontend-contact/package.json +7 -7
  133. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  134. package/templates/apps/frontend-dashboard/package.json +6 -7
  135. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  136. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  137. package/templates/apps/frontend-docs/package.json +8 -8
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +7 -7
  140. package/templates/apps/frontend-portal/package.json +7 -8
  141. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  142. package/templates/apps/frontend-saas/package.json +7 -8
  143. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  145. package/templates/apps/frontend-spa/package.json +6 -7
  146. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  147. package/templates/apps/frontend-ssr/package.json +6 -7
  148. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr-api/package.json +7 -8
  150. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-static-blog/package.json +8 -8
  152. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  154. package/templates/apps/frontend-status/package.json +7 -8
  155. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  156. package/templates/apps/mobile-app/package.json +4 -4
  157. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  158. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  159. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  161. package/dist/apiBuild-CeUN55uk.js +0 -2
  162. package/dist/codegen-DjgxEOnD.js +0 -2
  163. package/dist/dbCommand-CSFWs9ev.js +0 -2
  164. package/dist/doctorCommand-J3qu4E0Y.js +0 -2
  165. package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
  166. package/dist/inspect-Bd8-9wsi.js +0 -1193
  167. package/dist/inspect-CuoDInfZ.js +0 -2
  168. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  169. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  170. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  171. package/dist/serveCommand-BiPe8BJm.js +0 -2
  172. package/dist/updateCommand-CIoVDKnj.js +0 -2
  173. 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
- leaves `loading` true *and* sets `error`, so a component that branches on
738
- `loading` alone renders a skeleton forever; check `error` to break out of it. A
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: { assignedStores: 'employee_assigned_stores' },
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. The anchor
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 row-filtered apps, or whenever anything is in
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, emit an external notification, invalidate a cache, push to a worker queue. 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.
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. This is
4207
- what stops a reaction whose act writes the watched table from self-triggering
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` is in-memory per process in v1 (it stops the self-trigger storm
4230
- within a run); a durable cross-restart dedupe table is a follow-up.
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, which is what a deployment measured as one filter over
5026
- 4 tables costing the feature on all 173 of their queries. Eager loads are
5027
- excluded wholesale because a relation resolves below the seam that narrows.
5028
- They reconnect with a fresh
5029
- snapshot, exactly as before.
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 — the own-origin skip dedups, so there's no double-emit. The native path is the primary; the bus is harmless redundancy.
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
- Three properties of the image every platform relies on:
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 = 'client'`. The default is
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