@voltro/cli 0.55.0 → 0.57.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 (175) hide show
  1. package/CHANGELOG.md +691 -0
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/apiBuild-B83Cb2Rv.js +2 -0
  4. package/dist/{apiBuild-CMvLJM_K.js → apiBuild-DDr2aNFd.js} +119 -88
  5. package/dist/bin.js +1 -1
  6. package/dist/{build-S0QOzqPT.js → build-QKP6Bm0J.js} +308 -279
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-DNkY5kwF.js → checkCommand-BISqx1OJ.js} +1 -1
  9. package/dist/{checkCommand-fbj9GDjN.js → checkCommand-DUtMWjcR.js} +6 -6
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-SIepQtUl.js → codegen-Bth5lUTU.js} +2 -1
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-3TDJezom.js → codegenCommand-DnVuDxwT.js} +11 -11
  14. package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
  15. package/dist/{commands-BBYJ7Q3B.js → commands-CRbxgxv0.js} +35 -35
  16. package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-Bf_-Ne3P.js} +5 -5
  17. package/dist/{dataCommand-BEPPQiTl.js → dataCommand-BoBJJ-Gb.js} +3 -3
  18. package/dist/{dbCommand-DZTmOFT4.js → dbCommand-CMAIz-Bf.js} +457 -441
  19. package/dist/dbCommand-DHi_RuDl.js +2 -0
  20. package/dist/dev-Bl9HqtV7.js +3 -0
  21. package/dist/{dev-Ca_A_S9v.js → dev-DOEJXicj.js} +2623 -2485
  22. package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-BrWu67JZ.js} +524 -280
  23. package/dist/doctorCommand-DrQv9SL3.js +2 -0
  24. package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-xn2y-pJm.js} +1 -1
  25. package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-Cn5MbRDM.js} +1 -1
  26. package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
  27. package/dist/{envCommand-Bxy2fOjc.js → envCommand-UJmJIbs9.js} +8 -8
  28. package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-Db30twUy.js} +2 -2
  29. package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
  30. package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-vfkzuzEo.js} +131 -104
  31. package/dist/index.js +1 -1
  32. package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
  33. package/dist/inspect-B0hL41s0.js +2 -0
  34. package/dist/inspect-DZnan87F.js +1500 -0
  35. package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
  36. package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
  37. package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
  38. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-C5y9HyrG.js} +59 -54
  39. package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-DnbFKF6w.js} +1 -1
  40. package/dist/manifestBuild-UXrnUcXP.js +2 -0
  41. package/dist/{migrate-CGFZS-1a.js → migrate-DtC3lu7H.js} +4 -4
  42. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  43. package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-C5fuN6Z2.js} +2 -2
  44. package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-BQL_lfz6.js} +1 -1
  45. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  46. package/dist/{sdkgen-CXMwLg9n.js → sdkgen-CMUPrDjH.js} +1 -1
  47. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  48. package/dist/serveCommand-B_w-Mpb_.js +2544 -0
  49. package/dist/serveCommand-Dtb48ffg.js +2 -0
  50. package/dist/serveEntry.js +1 -1
  51. package/dist/{start-EOV7s1NZ.js → start-YaUehtDV.js} +580 -558
  52. package/dist/{start-DH7cat4-.js → start-s25GAIgn.js} +1 -1
  53. package/dist/startEntry.js +1 -1
  54. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  55. package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
  56. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-C1qKa94g.js} +69 -64
  57. package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
  58. package/dist/updateCommand-DsXEAHbd.js +2 -0
  59. package/dist/webDev-B6ZMX42w.js +2 -0
  60. package/dist/{webDev-B7vNj4Bq.js → webDev-BgdkyjP6.js} +1379 -1303
  61. package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-CYXTNvXq.js} +2 -2
  62. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  63. package/package.json +55 -19
  64. package/templates/AGENTS.core.md +20 -1
  65. package/templates/AGENTS.md +21 -2
  66. package/templates/agent-docs/_index.md +1 -1
  67. package/templates/agent-docs/_manifest.json +2 -2
  68. package/templates/agent-docs/ai.md +1 -1
  69. package/templates/agent-docs/authentication.md +34 -19
  70. package/templates/agent-docs/cli.md +170 -0
  71. package/templates/agent-docs/configuration.md +29 -4
  72. package/templates/agent-docs/data.md +145 -5
  73. package/templates/agent-docs/database/scaling.md +50 -2
  74. package/templates/agent-docs/deployment.md +75 -1
  75. package/templates/agent-docs/internationalization.md +32 -0
  76. package/templates/agent-docs/local-first-mobile.md +9 -2
  77. package/templates/agent-docs/observability.md +227 -0
  78. package/templates/agent-docs/plugins/audit.md +21 -5
  79. package/templates/agent-docs/plugins/billing.md +17 -0
  80. package/templates/agent-docs/plugins/broadcast.md +2 -1
  81. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  82. package/templates/agent-docs/plugins/row-history.md +11 -0
  83. package/templates/agent-docs/plugins.md +19 -0
  84. package/templates/agent-docs/reference.md +1 -0
  85. package/templates/agent-docs/scheduling.md +23 -0
  86. package/templates/agent-docs/schema-driven-ui.md +81 -0
  87. package/templates/agent-docs/templates/appshells.md +3 -3
  88. package/templates/agent-docs/whats-new.md +133 -77
  89. package/templates/apps/api-ai/package.json +6 -6
  90. package/templates/apps/api-auth/package.json +8 -8
  91. package/templates/apps/api-backend/package.json +7 -7
  92. package/templates/apps/api-backend-deactivation/package.json +7 -7
  93. package/templates/apps/api-backend-mail/package.json +8 -8
  94. package/templates/apps/api-backend-mariadb/package.json +9 -9
  95. package/templates/apps/api-backend-sqlite/package.json +8 -8
  96. package/templates/apps/api-backend-storage/package.json +8 -8
  97. package/templates/apps/api-cms/package.json +9 -9
  98. package/templates/apps/api-collab/package.json +8 -8
  99. package/templates/apps/api-data-advanced/package.json +8 -8
  100. package/templates/apps/api-durable/package.json +8 -8
  101. package/templates/apps/api-feature-flags/package.json +9 -9
  102. package/templates/apps/api-governance/package.json +8 -8
  103. package/templates/apps/api-kv/package.json +8 -8
  104. package/templates/apps/api-moderation/package.json +8 -8
  105. package/templates/apps/api-observability/package.json +8 -8
  106. package/templates/apps/api-ratelimit/package.json +8 -8
  107. package/templates/apps/api-rbac/package.json +8 -8
  108. package/templates/apps/api-rest/package.json +7 -7
  109. package/templates/apps/api-row-history/package.json +8 -8
  110. package/templates/apps/api-saas/package.json +11 -11
  111. package/templates/apps/api-saas-starter/package.json +10 -10
  112. package/templates/apps/api-search/package.json +8 -8
  113. package/templates/apps/api-status/package.json +8 -8
  114. package/templates/apps/api-webhooks/package.json +9 -9
  115. package/templates/apps/changelog/package.json +7 -7
  116. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  117. package/templates/apps/edge-functions/package.json +2 -2
  118. package/templates/apps/frontend-admin/package.json +7 -8
  119. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  120. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  121. package/templates/apps/frontend-app/package.json +8 -9
  122. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  123. package/templates/apps/frontend-auth/package.json +7 -8
  124. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  125. package/templates/apps/frontend-blank/package.json +6 -7
  126. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  127. package/templates/apps/frontend-cms/package.json +8 -9
  128. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  129. package/templates/apps/frontend-collab/README.md +7 -2
  130. package/templates/apps/frontend-collab/package.json +9 -10
  131. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  132. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  133. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  134. package/templates/apps/frontend-contact/package.json +7 -7
  135. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  136. package/templates/apps/frontend-dashboard/package.json +6 -7
  137. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  138. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  139. package/templates/apps/frontend-docs/package.json +8 -8
  140. package/templates/apps/frontend-i18n/package.json +6 -6
  141. package/templates/apps/frontend-landing/package.json +7 -7
  142. package/templates/apps/frontend-portal/package.json +7 -8
  143. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/package.json +7 -8
  145. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  146. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  147. package/templates/apps/frontend-spa/package.json +6 -7
  148. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr/package.json +6 -7
  150. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-ssr-api/package.json +7 -8
  152. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  153. package/templates/apps/frontend-static-blog/package.json +8 -8
  154. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  155. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  156. package/templates/apps/frontend-status/package.json +7 -8
  157. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  158. package/templates/apps/mobile-app/package.json +4 -4
  159. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  161. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  162. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  163. package/dist/apiBuild-Cl0IDx8c.js +0 -2
  164. package/dist/codegen-CN6vMM4J.js +0 -2
  165. package/dist/dbCommand-BTyBGhIA.js +0 -2
  166. package/dist/dev-DfVZaoys.js +0 -3
  167. package/dist/doctorCommand-djmqEcDC.js +0 -2
  168. package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
  169. package/dist/inspect-DCqILJ1G.js +0 -1197
  170. package/dist/inspect-DGJwpOAb.js +0 -2
  171. package/dist/manifestBuild-CJ2zvPvT.js +0 -2
  172. package/dist/serveCommand-C7IrCD58.js +0 -2445
  173. package/dist/serveCommand-Cjt5S9hD.js +0 -2
  174. package/dist/updateCommand-nnFjDbl4.js +0 -2
  175. package/dist/webDev-1XpVnYkW.js +0 -2
@@ -2346,6 +2346,38 @@ The contract, in the order it protects you:
2346
2346
 
2347
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.
2348
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
+
2349
2381
  ## See also
2350
2382
 
2351
2383
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -3988,7 +4020,7 @@ These helpers give you the secure **handler**, not schema derivation. Deriving t
3988
4020
 
3989
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._
3990
4022
 
3991
- 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.
3992
4024
 
3993
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.
3994
4026
 
@@ -4059,6 +4091,90 @@ The `on` filter narrows by operation:
4059
4091
 
4060
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.
4061
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
+
4062
4178
  ## Semantics — best-effort, fire-and-forget
4063
4179
 
4064
4180
  Subscribers are **non-durable** by design:
@@ -4132,6 +4248,8 @@ export default defineSubscriber({
4132
4248
  export default defineSubscriber({
4133
4249
  table: 'organizations',
4134
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 ?? ''),
4135
4253
  handler: async (event, ctx) => {
4136
4254
  if (!event.new) return
4137
4255
  const slug = event.new.slug as string
@@ -4248,9 +4366,17 @@ into the agent's prompt.
4248
4366
 
4249
4367
  ## Guards (the point)
4250
4368
 
4251
- - **`dedupeKey` (required)** — the same logical change acts exactly once. This is
4252
- 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
4253
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.
4254
4380
  - **`rateLimit` (optional)** — at most `limit` firings per `windowMs`.
4255
4381
  - **`costBudgetUsd` (optional)** — a per-tenant AI spend ceiling; over budget,
4256
4382
  the reaction refuses (fails closed).
@@ -4271,8 +4397,9 @@ into the agent's prompt.
4271
4397
  - Best-effort + fire-and-forget (like subscribers) — a failing act logs +
4272
4398
  continues; it can't back-pressure the change stream. Durability comes from a
4273
4399
  workflow act (an agent act is best-effort).
4274
- - `dedupeKey` is in-memory per process in v1 (it stops the self-trigger storm
4275
- 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.
4276
4403
 
4277
4404
  ## When to use what
4278
4405
 
@@ -4717,6 +4844,19 @@ available without a distributed transaction into the target system, so:
4717
4844
  **Handlers must be idempotent.** A process that dies between "the remote
4718
4845
  accepted it" and "we recorded that" will retry.
4719
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
+
4720
4860
  ## Declaring the handler
4721
4861
 
4722
4862
  One `*.outbox.ts` file per effect:
@@ -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.
@@ -336,11 +368,27 @@ A changelog table that every replica polls (`SELECT … WHERE seq > :last`) woul
336
368
  ```
337
369
 
338
370
  ```
339
- [voltro:dev] reactivity: cross-instance fan-out is OFF for dialect 'mssql'. A write on one replica
371
+ [voltro:dev] reactivity: cross-instance fan-out is OFF for dialect 'cockroach'. A write on one replica
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
+ **The line reports what is RUNNING, not what the dialect could run.** It is derived from the store's resolved change scope, so a dialect that *can* carry changes natively but has capture switched off `CDC=0`, or an app where every table is `.nonReactive()` is reported as the gap it is, naming the cause rather than the dialect:
376
+
377
+ ```
378
+ [voltro:dev] reactivity: cross-instance fan-out is OFF. 'mariadb' can carry changes natively
379
+ (binlog CDC) but change capture is not running — CDC=0, or every table is .nonReactive()…
380
+ ```
381
+
382
+ Read that line, not the dialect, when you need to know whether the N² amplification below applies to you: without a native transport there is no second delivery to suppress.
383
+
384
+ 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.
385
+
386
+ ```
387
+ [voltro:dev] reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
388
+ @voltro/plugin-broadcast (redis) carries reactivity channels
389
+ ```
390
+
391
+ 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
392
 
345
393
  ## sqlite and the memory store behind replicas
346
394
 
@@ -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
@@ -863,6 +907,28 @@ readinessProbe:
863
907
  failureThreshold: 3
864
908
  ```
865
909
 
910
+ ### Configuring them — `health` in `app.config.ts`
911
+
912
+ The paths and the answers are declarable, for **both** app types:
913
+
914
+ ```ts
915
+ export default {
916
+ type: 'web' as const,
917
+ health: {
918
+ // Default `/internal`; move it when your app owns a route there.
919
+ path: '/api/health',
920
+ liveness: () => true,
921
+ readiness: async () => catalogLoaded(),
922
+ },
923
+ }
924
+ ```
925
+
926
+ **`readiness` is the one worth writing**, because "ready" genuinely differs. For an api it is the dependency ping above. For a screen on a wall it is the opposite — keep showing the last rendered frame while the api wobbles, and count as ready precisely then. Only the app knows.
927
+
928
+ **`liveness` should stay cheap and dependency-free.** A liveness probe that consults a database restarts pods when the database is slow, which is the one thing that makes an outage worse.
929
+
930
+ The probes are answered **before routing** on every boot path, so no route and no guard can claim them. That is not a detail: on a web app `voltro dev` used to have no probe surface, and `/internal/*` fell into the page router — an SPA shell answered `200` with HTML, a page whose loader redirects answered `303`, an auth guard answered `303` to `/login`. A kubelet reads all three as PASS, which is the one property a health probe must not have. Worse, one of those pages had a loader that calls the api, so the **web** pod's readiness hung on the **api**'s reachability, once per probe interval.
931
+
866
932
  > **Run `voltro serve` in serving pods — not `voltro dev`.** `voltro dev` is the
867
933
  > local-iteration supervisor: file-watch, respawn, codegen, and a boot-time
868
934
  > auto-migrate that introspects the whole schema. It does **not** expose the
@@ -1623,7 +1689,7 @@ config format and have **not** been executed against a live account of that
1623
1689
  platform; if one drifts from what the platform ships today, the container is
1624
1690
  still right and the fix is in the wrapper.
1625
1691
 
1626
- Three properties of the image every platform relies on:
1692
+ Four properties of the image every platform relies on:
1627
1693
 
1628
1694
  - **`PORT` wins.** The port precedence is `PORT` > `--port` > `app.config.ts` —
1629
1695
  deliberately, because platforms assign through `PORT`. You never configure a
@@ -1635,6 +1701,14 @@ Three properties of the image every platform relies on:
1635
1701
  - **`/internal/readiness` flips to 200 only after the whole boot.** Use it as
1636
1702
  the health check everywhere; routing traffic on process-up instead of
1637
1703
  readiness is how a deploy serves 502s for the first seconds.
1704
+ - **The build imports the serve bundle before the image is finished, and a
1705
+ failed import fails the build.** The image-build stage has no database and no
1706
+ secrets, so it cannot require a full boot — but it does not need one: a module
1707
+ `prune-runtime` traced away, a truncated artefact, a wrong entry path or a
1708
+ missing export all fail at *import*, long before anything connects. So the
1709
+ import and a callable `runServe` are required; how far the subsequent start
1710
+ gets is reported, not required. If your build turns red at `load gate:`, the
1711
+ artefact is wrong and no amount of environment will fix it.
1638
1712
 
1639
1713
  ## Fly.io
1640
1714
 
@@ -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