@voltro/cli 0.55.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 (165) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/{apiBuild-CMvLJM_K.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-S0QOzqPT.js → build-CI36wL4R.js} +337 -312
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-fbj9GDjN.js → checkCommand-CAwFXrxA.js} +6 -6
  9. package/dist/{checkCommand-DNkY5kwF.js → checkCommand-D0QV_zM_.js} +1 -1
  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-CidbQzbv.js} +10 -10
  14. package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
  15. package/dist/{commands-BBYJ7Q3B.js → commands-CWjfThXv.js} +35 -35
  16. package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-BekcY5Ls.js} +3 -3
  17. package/dist/{dataCommand-BEPPQiTl.js → dataCommand-2pccgbIy.js} +3 -3
  18. package/dist/{dbCommand-DZTmOFT4.js → dbCommand-DpK_vQET.js} +457 -441
  19. package/dist/dbCommand-DrycGWWt.js +2 -0
  20. package/dist/{dev-DfVZaoys.js → dev-B9Gz0k85.js} +1 -1
  21. package/dist/{dev-Ca_A_S9v.js → dev-Dw263KPu.js} +2603 -2482
  22. package/dist/doctorCommand-BMWs6aVm.js +2 -0
  23. package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-aR_bFmIi.js} +353 -251
  24. package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-eXTQMbHU.js} +1 -1
  25. package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-CTmiQvwa.js} +1 -1
  26. package/dist/{envCommand-Bxy2fOjc.js → envCommand-BDUgV7EM.js} +12 -12
  27. package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-YV8qW1LU.js} +2 -2
  28. package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
  29. package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-DNOFXfEQ.js} +92 -84
  30. package/dist/index.js +1 -1
  31. package/dist/{infoCommand-EmM3jPKD.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/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
  38. package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-CEkjfpwc.js} +1 -1
  39. package/dist/manifestBuild-DIa_s6u0.js +2 -0
  40. package/dist/{migrate-CGFZS-1a.js → migrate-SICulyz1.js} +2 -2
  41. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  42. package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-6HxEkNDG.js} +2 -2
  43. package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-DgYMc09E.js} +1 -1
  44. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  45. package/dist/{sdkgen-CXMwLg9n.js → sdkgen-PY-umd6O.js} +1 -1
  46. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  47. package/dist/serveCommand-BITS8Hpj.js +2 -0
  48. package/dist/{serveCommand-C7IrCD58.js → serveCommand-DIJ3ma76.js} +910 -870
  49. package/dist/serveEntry.js +1 -1
  50. package/dist/{start-EOV7s1NZ.js → start-B9NGB8gn.js} +575 -546
  51. package/dist/{start-DH7cat4-.js → start-BFQQkL1i.js} +1 -1
  52. package/dist/startEntry.js +1 -1
  53. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  54. package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
  55. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
  56. package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
  57. package/dist/updateCommand-DsXEAHbd.js +2 -0
  58. package/dist/webDev-C2dRz9s5.js +2 -0
  59. package/dist/{webDev-B7vNj4Bq.js → webDev-C53hJdcL.js} +1240 -1271
  60. package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-uuPu8qQX.js} +1 -1
  61. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  62. package/package.json +55 -19
  63. package/templates/AGENTS.core.md +20 -1
  64. package/templates/AGENTS.md +21 -2
  65. package/templates/agent-docs/_index.md +1 -1
  66. package/templates/agent-docs/_manifest.json +1 -1
  67. package/templates/agent-docs/cli.md +122 -0
  68. package/templates/agent-docs/data.md +145 -5
  69. package/templates/agent-docs/database/scaling.md +40 -1
  70. package/templates/agent-docs/deployment.md +53 -1
  71. package/templates/agent-docs/internationalization.md +32 -0
  72. package/templates/agent-docs/local-first-mobile.md +9 -2
  73. package/templates/agent-docs/observability.md +227 -0
  74. package/templates/agent-docs/plugins/billing.md +15 -0
  75. package/templates/agent-docs/plugins/broadcast.md +2 -1
  76. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  77. package/templates/agent-docs/plugins/row-history.md +11 -0
  78. package/templates/agent-docs/plugins.md +19 -0
  79. package/templates/agent-docs/templates/appshells.md +3 -3
  80. package/templates/agent-docs/whats-new.md +432 -73
  81. package/templates/apps/api-ai/package.json +6 -6
  82. package/templates/apps/api-auth/package.json +8 -8
  83. package/templates/apps/api-backend/package.json +7 -7
  84. package/templates/apps/api-backend-deactivation/package.json +7 -7
  85. package/templates/apps/api-backend-mail/package.json +8 -8
  86. package/templates/apps/api-backend-mariadb/package.json +9 -9
  87. package/templates/apps/api-backend-sqlite/package.json +8 -8
  88. package/templates/apps/api-backend-storage/package.json +8 -8
  89. package/templates/apps/api-cms/package.json +9 -9
  90. package/templates/apps/api-collab/package.json +8 -8
  91. package/templates/apps/api-data-advanced/package.json +8 -8
  92. package/templates/apps/api-durable/package.json +8 -8
  93. package/templates/apps/api-feature-flags/package.json +9 -9
  94. package/templates/apps/api-governance/package.json +8 -8
  95. package/templates/apps/api-kv/package.json +8 -8
  96. package/templates/apps/api-moderation/package.json +8 -8
  97. package/templates/apps/api-observability/package.json +8 -8
  98. package/templates/apps/api-ratelimit/package.json +8 -8
  99. package/templates/apps/api-rbac/package.json +8 -8
  100. package/templates/apps/api-rest/package.json +7 -7
  101. package/templates/apps/api-row-history/package.json +8 -8
  102. package/templates/apps/api-saas/package.json +11 -11
  103. package/templates/apps/api-saas-starter/package.json +10 -10
  104. package/templates/apps/api-search/package.json +8 -8
  105. package/templates/apps/api-status/package.json +8 -8
  106. package/templates/apps/api-webhooks/package.json +9 -9
  107. package/templates/apps/changelog/package.json +7 -7
  108. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  109. package/templates/apps/edge-functions/package.json +2 -2
  110. package/templates/apps/frontend-admin/package.json +7 -8
  111. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  112. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  113. package/templates/apps/frontend-app/package.json +8 -9
  114. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  115. package/templates/apps/frontend-auth/package.json +7 -8
  116. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  117. package/templates/apps/frontend-blank/package.json +6 -7
  118. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  119. package/templates/apps/frontend-cms/package.json +8 -9
  120. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  121. package/templates/apps/frontend-collab/README.md +7 -2
  122. package/templates/apps/frontend-collab/package.json +9 -10
  123. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  124. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  125. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  126. package/templates/apps/frontend-contact/package.json +7 -7
  127. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  128. package/templates/apps/frontend-dashboard/package.json +6 -7
  129. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  130. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  131. package/templates/apps/frontend-docs/package.json +8 -8
  132. package/templates/apps/frontend-i18n/package.json +6 -6
  133. package/templates/apps/frontend-landing/package.json +7 -7
  134. package/templates/apps/frontend-portal/package.json +7 -8
  135. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  136. package/templates/apps/frontend-saas/package.json +7 -8
  137. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  138. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  139. package/templates/apps/frontend-spa/package.json +6 -7
  140. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  141. package/templates/apps/frontend-ssr/package.json +6 -7
  142. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  143. package/templates/apps/frontend-ssr-api/package.json +7 -8
  144. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  145. package/templates/apps/frontend-static-blog/package.json +8 -8
  146. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  147. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  148. package/templates/apps/frontend-status/package.json +7 -8
  149. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  150. package/templates/apps/mobile-app/package.json +4 -4
  151. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  152. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  153. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  154. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  155. package/dist/apiBuild-Cl0IDx8c.js +0 -2
  156. package/dist/codegen-CN6vMM4J.js +0 -2
  157. package/dist/dbCommand-BTyBGhIA.js +0 -2
  158. package/dist/doctorCommand-djmqEcDC.js +0 -2
  159. package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
  160. package/dist/inspect-DCqILJ1G.js +0 -1197
  161. package/dist/inspect-DGJwpOAb.js +0 -2
  162. package/dist/manifestBuild-CJ2zvPvT.js +0 -2
  163. package/dist/serveCommand-Cjt5S9hD.js +0 -2
  164. package/dist/updateCommand-nnFjDbl4.js +0 -2
  165. package/dist/webDev-1XpVnYkW.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-DfVZaoys.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-B9Gz0k85.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
@@ -1,5 +1,5 @@
1
1
  import { n as e } from "./cliOutput-D1tSBoRM.js";
2
- import { n as t, o as n, r, t as i } from "./inspectFetch-EMuhTG_9.js";
2
+ import { n as t, o as n, r, t as i } from "./inspectFetch-BU1NyzxV.js";
3
3
  import { ansi as a } from "@voltro/logger";
4
4
  //#region src/workflowsCmd.ts
5
5
  var o = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -766,6 +766,42 @@
766
766
  "title": "WidgetKind gained 'rich-text' — total widget registries need one new entry",
767
767
  "kind": "manual"
768
768
  },
769
+ {
770
+ "version": "0.56.0",
771
+ "id": "0.56.0/01_inspect_answers_are_observations",
772
+ "title": "inspect answers are `Observation`s — the payload is `.data`",
773
+ "kind": "manual"
774
+ },
775
+ {
776
+ "version": "0.56.0",
777
+ "id": "0.56.0/02_broadcast_gap_is_one_object",
778
+ "title": "attachBroadcastBus's onGap now receives a single BroadcastGap object",
779
+ "kind": "manual"
780
+ },
781
+ {
782
+ "version": "0.56.0",
783
+ "id": "0.56.0/03_outbox_drain_needs_an_identity",
784
+ "title": "drainOutbox now requires claimedBy — the fleet-wide exactly-once gate",
785
+ "kind": "manual"
786
+ },
787
+ {
788
+ "version": "0.56.0",
789
+ "id": "0.56.0/04_reaction_dedupe_is_one_claim",
790
+ "title": "runReaction's dedupe is a single atomic claim",
791
+ "kind": "manual"
792
+ },
793
+ {
794
+ "version": "0.56.0",
795
+ "id": "0.56.0/05_plugin_bind_context_claims_a_change",
796
+ "title": "PluginBindContext requires claimChange; change identity moved to @voltro/database",
797
+ "kind": "manual"
798
+ },
799
+ {
800
+ "version": "0.56.0",
801
+ "id": "0.56.0/06_serve_is_api_only",
802
+ "title": "`voltro serve` on a WEB app now refuses — use `voltro start`",
803
+ "kind": "manual"
804
+ },
769
805
  {
770
806
  "version": "0.6.0",
771
807
  "id": "0.6.0/01_no-dev-session-secret",
@@ -848,24 +884,24 @@
848
884
  "@effect/platform-node": "^0.108.0",
849
885
  "@effect/sql": "^0.52.0",
850
886
  "@effect/workflow": "^0.19.0",
851
- "@voltro/ai": "0.55.0",
852
- "@voltro/cache": "0.55.0",
853
- "@voltro/client": "0.55.0",
854
- "@voltro/content": "0.55.0",
855
- "@voltro/data-transfer": "0.55.0",
856
- "@voltro/database": "0.55.0",
857
- "@voltro/env": "0.55.0",
858
- "@voltro/kv": "0.55.0",
859
- "@voltro/logger": "0.55.0",
860
- "@voltro/plugin-auth": "0.55.0",
861
- "@voltro/plugin-broadcast": "0.55.0",
862
- "@voltro/plugin-mail": "0.55.0",
863
- "@voltro/plugin-storage": "0.55.0",
864
- "@voltro/plugin-webhooks": "0.55.0",
865
- "@voltro/protocol": "0.55.0",
866
- "@voltro/runtime": "0.55.0",
867
- "@voltro/serverless": "0.55.0",
868
- "@voltro/workflow": "0.55.0",
887
+ "@voltro/ai": "0.56.0",
888
+ "@voltro/cache": "0.56.0",
889
+ "@voltro/client": "0.56.0",
890
+ "@voltro/content": "0.56.0",
891
+ "@voltro/data-transfer": "0.56.0",
892
+ "@voltro/database": "0.56.0",
893
+ "@voltro/env": "0.56.0",
894
+ "@voltro/kv": "0.56.0",
895
+ "@voltro/logger": "0.56.0",
896
+ "@voltro/plugin-auth": "0.56.0",
897
+ "@voltro/plugin-broadcast": "0.56.0",
898
+ "@voltro/plugin-mail": "0.56.0",
899
+ "@voltro/plugin-storage": "0.56.0",
900
+ "@voltro/plugin-webhooks": "0.56.0",
901
+ "@voltro/protocol": "0.56.0",
902
+ "@voltro/runtime": "0.56.0",
903
+ "@voltro/serverless": "0.56.0",
904
+ "@voltro/workflow": "0.56.0",
869
905
  "chokidar": "^5.0.0",
870
906
  "ioredis": "^5.11.1",
871
907
  "tinyglobby": "^0.2.17",
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
142
142
  notify-helper at the tail of the mutation body instead makes the reactivity
143
143
  invisible — you can only find it by reading every executor. Both are
144
144
  best-effort, so genuinely critical delivery still belongs in a workflow.
145
+ **A subscriber runs on EVERY replica.** That is right for a handler that
146
+ refreshes an index or drops a process-local cache, and wrong for one whose
147
+ effect IS a write someone receives — two pods send two mails. When the
148
+ handler is an effect, add `once:` (a deterministic key per change) and
149
+ exactly one replica runs it:
150
+ ```ts
151
+ defineSubscriber({
152
+ table: 'absence_requests', on: ['insert'],
153
+ once: true,
154
+ handler: notifyApprovers,
155
+ })
156
+ ```
157
+ `once: true` derives the key from the change (content + its position among
158
+ content-identical repeats) — prefer it. A function is for when you want to be
159
+ COARSER than one-per-change; a key you write must tell two genuine changes
160
+ apart, and a row id does not for `update`. `once` is AT MOST once — a replica
161
+ that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
162
+ gives its act the same fleet-wide gate.
145
163
  4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
146
164
  (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
147
165
  and it is MAINTAINED on write rather than recomputed. `read({ where })`
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
217
235
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
218
236
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
219
237
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
238
+ | a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
220
239
  | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
221
240
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
222
241
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
358
377
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
359
378
  | `*.trigger.tsx` | domain event → workflow |
360
379
  | `*.cron.tsx` | scheduled job (single file) |
361
- | `*.subscribe.ts` | best-effort post-commit reaction to a table |
380
+ | `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
362
381
  | `*.reaction.tsx` | standing reactive agent |
363
382
  | `*.aggregate.ts` | scheduled materialised query |
364
383
  | `*.startup.tsx` | run-once boot hook holding a resource |
@@ -142,6 +142,24 @@ line below replaces something real apps write by hand hundreds of times.
142
142
  notify-helper at the tail of the mutation body instead makes the reactivity
143
143
  invisible — you can only find it by reading every executor. Both are
144
144
  best-effort, so genuinely critical delivery still belongs in a workflow.
145
+ **A subscriber runs on EVERY replica.** That is right for a handler that
146
+ refreshes an index or drops a process-local cache, and wrong for one whose
147
+ effect IS a write someone receives — two pods send two mails. When the
148
+ handler is an effect, add `once:` (a deterministic key per change) and
149
+ exactly one replica runs it:
150
+ ```ts
151
+ defineSubscriber({
152
+ table: 'absence_requests', on: ['insert'],
153
+ once: true,
154
+ handler: notifyApprovers,
155
+ })
156
+ ```
157
+ `once: true` derives the key from the change (content + its position among
158
+ content-identical repeats) — prefer it. A function is for when you want to be
159
+ COARSER than one-per-change; a key you write must tell two genuine changes
160
+ apart, and a row id does not for `update`. `once` is AT MOST once — a replica
161
+ that wins and dies takes the event with it. `defineReaction`'s `dedupeKey`
162
+ gives its act the same fleet-wide gate.
145
163
  4. **A roll-up / counter recomputed on every read?** → **`defineAggregate`**
146
164
  (`*.aggregate.ts`). Add `incremental:` for `count|sum|avg|min|max` group-bys
147
165
  and it is MAINTAINED on write rather than recomputed. `read({ where })`
@@ -217,6 +235,7 @@ line below replaces something real apps write by hand hundreds of times.
217
235
  | `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
218
236
  | 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
219
237
  | a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
238
+ | a subscriber that sends a mail / notification / webhook | the same, plus **`once:`** — without it, one per replica |
220
239
  | `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
221
240
  | a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
222
241
  | `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
@@ -358,7 +377,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
358
377
  | `*.workflow.tsx` + `*.workflow.server.tsx` | durable multi-step work |
359
378
  | `*.trigger.tsx` | domain event → workflow |
360
379
  | `*.cron.tsx` | scheduled job (single file) |
361
- | `*.subscribe.ts` | best-effort post-commit reaction to a table |
380
+ | `*.subscribe.ts` | best-effort post-commit reaction to a table (every replica; `once:` for one) |
362
381
  | `*.reaction.tsx` | standing reactive agent |
363
382
  | `*.aggregate.ts` | scheduled materialised query |
364
383
  | `*.startup.tsx` | run-once boot hook holding a resource |
@@ -730,7 +749,7 @@ each plugin's own README.
730
749
 
731
750
  | Topic | Open | Summary |
732
751
  |---|---|---|
733
- | **What's new in 0.55.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
752
+ | **What's new in 0.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
734
753
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
735
754
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
736
755
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.55.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.56.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -206,7 +206,7 @@
206
206
  "group": null,
207
207
  "description": "OpenTelemetry tracing in Voltro — the auto-emitted spans for every primitive, span attributes and nesting, the three enabling modes (console / OTLP / buffer), and adding your own spans with Effect.withSpan.",
208
208
  "path": "agent-docs/observability.md",
209
- "files": 4
209
+ "files": 5
210
210
  },
211
211
  {
212
212
  "id": "plugins",
@@ -1441,6 +1441,86 @@ Type mapping is faithful: `string → String`, `integer → Int`, `number → Do
1441
1441
 
1442
1442
  **Scope — this is the SDK code generator, not a native runtime.** Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push **sender** (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with `swiftc` / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.
1443
1443
 
1444
+ ## Serving: asset prefix, pre-compression, sourcemaps, keep-alive
1445
+
1446
+ Four knobs that decide what leaves the container. All are `voltro start`
1447
+ concerns; none change what your code does.
1448
+
1449
+ ```ts
1450
+ // apps/web/app.config.ts
1451
+ web: {
1452
+ // Serve `assets/` from a CDN. Becomes vite's `base`, so every emitted URL —
1453
+ // entry, modulepreloads, CSS, images, fonts — is written with the prefix at
1454
+ // BUILD time. Pre-rendered HTML still comes from the app; upload
1455
+ // `dist/assets/` to the prefix on deploy. Filenames are content-hashed, so a
1456
+ // previous deploy's assets stay valid for a visitor mid-navigation.
1457
+ assetPrefix: 'https://cdn.example.com/_assets',
1458
+
1459
+ // Emit `.map` files WITHOUT a `//# sourceMappingURL` comment, so nothing in
1460
+ // the shipped JS points at them. For `plugin-sentry`: upload them in your
1461
+ // deploy step and DELETE them before the image is built. `voltro start`
1462
+ // refuses to serve a `.map` regardless, so a forgotten delete is not a leak.
1463
+ sourcemaps: 'hidden',
1464
+ },
1465
+ http: {
1466
+ // Node hangs up an idle keep-alive connection after 5s; every proxy in front
1467
+ // holds one longer, and the request that lands in that window comes back as
1468
+ // a 502. Default 72000 clears nginx-60/ALB-60; raise it above YOUR proxy's
1469
+ // idle timeout. `headersTimeoutMs` must exceed it and is derived if omitted.
1470
+ keepAliveTimeoutMs: 72_000,
1471
+ },
1472
+ ```
1473
+
1474
+ **Pre-compression is automatic.** `voltro build` writes `.br` (quality 11) and
1475
+ `.gz` beside every content-hashed asset over 1 KB, and `voltro start` serves the
1476
+ variant when the client accepts it. Measured on one 321 KB chunk, three requests:
1477
+
1478
+ | | time | bytes |
1479
+ |---|---:|---:|
1480
+ | compressed per request (before) | 5.6 / 5.0 / 4.6 ms | 100 665 |
1481
+ | pre-compressed (now) | 1.6 / 1.7 ms | **86 083** |
1482
+
1483
+ Faster *and* smaller: a build can afford brotli q11 where a per-request path
1484
+ cannot. Both encodings of one asset carry the same `ETag` — it is computed over
1485
+ the uncompressed file, so a shared cache sees one representation.
1486
+
1487
+ **A cross-origin api gets a `preconnect`.** When an api's `wsUrl` is on another
1488
+ host, the shell carries `<link rel="preconnect" href="…" crossorigin>` so DNS +
1489
+ TCP + TLS overlap the bundle download instead of following it. Same-origin apis
1490
+ are skipped — the browser already has that connection.
1491
+
1492
+ ## `voltro serve <appDir>` — API apps only
1493
+
1494
+ `voltro serve` is the production server for an **API** app. On a **web** app it
1495
+ refuses and points you at `voltro start`:
1496
+
1497
+ ```text
1498
+ `voltro serve` is the production server for an API app — this is a WEB app.
1499
+
1500
+ production voltro start .
1501
+ development voltro dev .
1502
+ ```
1503
+
1504
+ That is a refusal, not a missing feature. `serve` used to build a web app a
1505
+ second time and hand it to `vite preview`, and both halves were wrong:
1506
+
1507
+ - **In production it never ran.** The launcher's serve fast path requires
1508
+ `.framework/dist-api/serveBundle/serveEntry.js` — an artefact a web build does
1509
+ not produce — so a web app exited 1 pointing at a path that cannot exist,
1510
+ directly after a `voltro build` that had just succeeded.
1511
+ - **Below production it destroyed the build.** That second build had no
1512
+ `@tailwindcss/vite`, no image pipeline and no per-page islands entries. With
1513
+ Tailwind it aborted; without it, it succeeded — and since both builds write
1514
+ `.framework/dist`, which Vite empties, it deleted `dist/server`, the island
1515
+ shells and every pre-rendered page. `voltro start` could then not boot at all.
1516
+
1517
+ So the table is:
1518
+
1519
+ | app | development | production |
1520
+ |---|---|---|
1521
+ | web | `voltro dev` | `voltro start` |
1522
+ | api | `voltro dev` | `voltro serve` |
1523
+
1444
1524
  ## `voltro doctor` — preflight a production serve
1445
1525
 
1446
1526
  Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
@@ -1775,6 +1855,7 @@ It covers both halves of the stack:
1775
1855
  | server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
1776
1856
  | server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
1777
1857
  | server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
1858
+ | server | a `*.subscribe.ts` handler that writes or publishes, with no `once:` | `once: true` on `defineSubscriber` |
1778
1859
  | server | `hasMore` + `limit + 1` | `paginateById` |
1779
1860
  | server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
1780
1861
  | client | per-field `useState` + a submit flag | `useFormBinding` |
@@ -1801,6 +1882,28 @@ already uses `.one()`, stays silent.
1801
1882
 
1802
1883
  Two of them are worth spelling out, because their advice is not one-line:
1803
1884
 
1885
+ **The subscriber rule is the second half of the mutation-tail rule.** That one
1886
+ moves an effect OUT of a mutation and into a subscriber, which is right — and
1887
+ lands it on a channel every replica listens to. `store.onChange` is a broadcast:
1888
+ correct for a READER (a cache drop, an index refresh, a live query must run
1889
+ everywhere) and a multiplier for an EFFECT, because there is nothing to make
1890
+ idempotent — the effect IS the write, so each run produces another one. One
1891
+ `INSERT` behind two replicas therefore writes two notification rows, and four
1892
+ with a broadcast bus in front.
1893
+
1894
+ So the rule fires when a handler WRITES (`ctx.store.insert` / `update` /
1895
+ `upsert` / …), publishes (`ctx.publish`), or calls a `notify` / `sendWebhook` /
1896
+ `sendMail`-shaped helper, and the subscriber declares no `once:`. Any `once:`
1897
+ silences it — `true` or a key function — because the question is whether the
1898
+ decision was made, not which way. It reads the handler through the AST, so
1899
+ `handler: notifyApprovers` naming a function in the same file is judged exactly
1900
+ like an inline arrow; a handler IMPORTED from another module is not judged at
1901
+ all, since its body is not in the file being read.
1902
+
1903
+ It stays quiet on a reader on purpose. `once:` on a cache-warming subscriber
1904
+ would silence it on every replica but one, which is worse than the repetition it
1905
+ removes — only the handler's author knows which of the two they wrote.
1906
+
1804
1907
  **The credential-column rule skips names that aren't credentials.** A name ending
1805
1908
  in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
1806
1909
  `vault` are all left alone:
@@ -4548,6 +4651,25 @@ prints only because your tree matched it.
4548
4651
 
4549
4652
  Codemods that span multiple versions run in order (e.g. upgrading `0.2.0 → 0.4.0` runs the `0.3.0` and `0.4.0` codemods in sequence).
4550
4653
 
4654
+ ### When nothing changed, read WHICH nothing
4655
+
4656
+ Two states end an update with no diff, and they mean opposite things: the jump
4657
+ ships no codemods, or it ships some and every one of them decided your project
4658
+ is not affected. The summary names which:
4659
+
4660
+ ```text
4661
+ codemods: 2 ship for this jump; none matched your project.
4662
+ · 0.55.0/01_target-relations-declare-columns — its own check found nothing to change
4663
+ · 0.55.0/02_widget-kind-gained-rich-text — its own check found nothing to change
4664
+ ```
4665
+
4666
+ `none ship for this jump` is the first. The second lists the ids, because a
4667
+ codemod's own check is a predicate that can be wrong — and a gate that reads
4668
+ the wrong files answers "does not apply" for a project that is fully affected.
4669
+ If you recognise a subject in that list as something your app *does* use, that
4670
+ is a bug in the check rather than a fact about your code; the CHANGELOG entry
4671
+ for the version says what each one looks for.
4672
+
4551
4673
  ## The database is separate
4552
4674
 
4553
4675
  `voltro update` does **not** touch your database. Framework-owned `_voltro_*` tables (workflow runs, schedules, …) are reconciled by the declarative differ, not by codemods: when a release changes one of those tables, your next `voltro db apply` (or `voltro dev` boot, which auto-applies) picks up the change.
@@ -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.
@@ -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