@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
@@ -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
@@ -416,3 +416,230 @@ curl "http://localhost:4000/_voltro/inspect/timeline/replay?table=todos&seq=42"
416
416
  - The inspect surface is a `voltro dev` / DevTools concern; out-of-band DB writes
417
417
  (not in the app's ChangeEvent stream) aren't recorded, and production should
418
418
  use OTLP for forensics.
419
+
420
+
421
+
422
+ ---
423
+
424
+ <!-- source: en/observability/inspect-scope.md -->
425
+ ## What an inspect answer is about
426
+
427
+ _Every /_voltro/inspect/* answer is an Observation — it says which scope it describes, which replica answered, and how complete it is. Reading the envelope, the four scopes, fleetSize, and ?scope=fleet._
428
+
429
+ Every 2xx `/_voltro/inspect/*` answer is an **Observation**: the payload plus
430
+ what a reader needs in order to act on it.
431
+
432
+ ```json
433
+ {
434
+ "data": { "…": "the payload" },
435
+ "scope": { "kind": "process" },
436
+ "origin": {
437
+ "replicaId": "api-7d9f-x2k",
438
+ "instanceId": "api-7d9f-x2k@1787893389882.k3f9aa",
439
+ "startedAt": 1787893389882,
440
+ "version": "0.56.0",
441
+ "bootPath": "serve"
442
+ },
443
+ "completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" },
444
+ "capturedAt": 1787893390411
445
+ }
446
+ ```
447
+
448
+ ## Why the payload alone was not enough
449
+
450
+ `/subscriptions` answers with the subscriptions of the **one process that
451
+ received the request**. `/schedules` answers with the whole fleet's, read from
452
+ the shared database. Both used to be plain JSON, so on a multi-replica
453
+ deployment the first is an unlabelled sample and reads exactly like the second.
454
+
455
+ On **one** replica the difference is invisible — and one replica is every
456
+ development environment, every e2e run and every template. So the environment
457
+ in which the two look identical is the one everybody builds and tests in, and
458
+ the difference only appears in production, where nobody can go and read the
459
+ source to settle it.
460
+
461
+ ## The four scopes
462
+
463
+ | `scope.kind` | What it means | Examples |
464
+ |---|---|---|
465
+ | `process` | True of `origin` and of nothing else. Another replica answers differently. | `/subscriptions`, `/metrics`, `/logs`, `/cache`, `/events`, `/traces`, `/cluster` |
466
+ | `shared-store` | Read from storage every replica shares — any of them would answer the same. | `/schedules`, `/workflows/*`, `/migrations`, `/database`, `/data/*` |
467
+ | `fleet` | Assembled from more than one process. `completeness` says who answered. | `/members`, `/subscriptions?scope=fleet` |
468
+ | `declaration` | From the source tree. Identical on every replica **of one version** — and different across a rolling deploy. | `/app`, `/routes`, `/manifest`, `/env`, `/rpc` |
469
+
470
+ `declaration` is its own kind rather than "fleet" on purpose: during a rolling
471
+ deploy your fleet genuinely runs two versions, and `origin.version` is how you
472
+ see which one answered.
473
+
474
+ ## `fleetSize` — the label that needs no aggregation
475
+
476
+ A `process`-scoped answer carries how many replicas exist:
477
+
478
+ ```json
479
+ "completeness": { "complete": false, "fleetSize": 3, "reason": "process-scoped: this is 1 of 3 replicas" }
480
+ ```
481
+
482
+ So a plain `curl` states that it is a fraction, and of what. Query a few times
483
+ and union by `origin.instanceId` — which you can now do, because the answer
484
+ says which process produced it.
485
+
486
+ **When the field is missing, it means "could not tell", not "one".** If the app
487
+ runs no membership registry, `fleetSize` is **absent** and `complete` is
488
+ `false` with a reason. Reporting `1` there would tell you that you are seeing
489
+ the whole fleet.
490
+
491
+ ## `?scope=fleet`
492
+
493
+ ```sh
494
+ curl -s -H "authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
495
+ 'localhost:4000/_voltro/inspect/subscriptions?scope=fleet' | jq .
496
+ ```
497
+
498
+ Each replica publishes its counters into `_voltro_replica_observations` on a
499
+ timer, so **any** replica can answer by reading rather than by asking the
500
+ others. The answer is `fleet`-scoped and says what it is missing:
501
+
502
+ ```json
503
+ {
504
+ "data": { "resume": [
505
+ { "replicaId": "api-a", "version": "0.56.0", "ageMs": 4021, "payload": { "…": "counters" } },
506
+ { "replicaId": "api-b", "version": "0.56.0", "ageMs": 9114, "payload": { "…": "counters" } }
507
+ ] },
508
+ "scope": { "kind": "fleet", "assembledBy": "api-a", "assembledAt": 1787893390411 },
509
+ "completeness": {
510
+ "complete": false, "responded": 2, "expected": 3, "missing": ["api-c"],
511
+ "reason": "1 replica(s) have written nothing readable"
512
+ }
513
+ }
514
+ ```
515
+
516
+ Three properties are deliberate:
517
+
518
+ - **A replica that has written nothing is `missing`, not absent.** Dropping it
519
+ would make a partial answer look complete — the same unlabelled sample, one
520
+ level up and more expensive, because now you believe you asked everybody.
521
+ - **A stale row is reported with its `ageMs`, not filtered out.** Removing it
522
+ hides that the answer is partial; keeping it unmarked presents fiction as
523
+ current.
524
+ - **Mixed versions are named** in `completeness.versions` when the responders
525
+ disagree. A rolling deploy spans two shapes, and averaging them silently is
526
+ wrong in a way nothing downstream can detect.
527
+
528
+ **If there is no shared store, the request is refused with `501`** and a reason
529
+ — never answered with this replica's own numbers. Handing back a sample to
530
+ someone who asked for the fleet in writing is exactly the failure the envelope
531
+ exists to prevent.
532
+
533
+ ## `?replica=<id>` — asking one named replica
534
+
535
+ ```sh
536
+ # this replica's own answer (the default)
537
+ curl -s … /_voltro/inspect/subscriptions
538
+
539
+ # every replica's counters, assembled from the shared store
540
+ curl -s … '/_voltro/inspect/subscriptions?scope=fleet'
541
+
542
+ # ONE named replica, asked through the one you can reach
543
+ curl -s … '/_voltro/inspect/subscriptions?replica=api-7d9f-x2k'
544
+ ```
545
+
546
+ The answer comes back with **that** replica's `origin`, process-scoped: the
547
+ proxy does not launder whose answer it is.
548
+
549
+ The address is looked up in `_voltro_replica_observations` — **the caller names
550
+ an ID, never a URL**, and an id we do not know produces a `404` with no request
551
+ leaving the process. That is what keeps this from being an SSRF primitive.
552
+ "unknown replica" and "that replica published no reachable address" give the
553
+ same message on purpose: telling them apart would tell a caller which ids
554
+ exist.
555
+
556
+ A replica publishes an address only when it is genuinely reachable by a peer.
557
+ An unset `POD_IP` falls back to `127.0.0.1`, which is a shrug rather than a
558
+ statement, so it is recorded as **not** reachable and no peer will try it. Set
559
+ `VOLTRO_INSPECT_ADVERTISE_HOST` to declare one — including `127.0.0.1`, when
560
+ the peers really are on this machine.
561
+
562
+ A proxied request carries a hop header and is always answered locally, so
563
+ `?replica=` cannot cycle. A peer that does not answer inside a short deadline
564
+ becomes a `504` naming it, because a diagnostic that hangs is worse than one
565
+ that says no.
566
+
567
+ ## Writes are never fleet-addressable
568
+
569
+ `?scope=fleet` and `?replica=` exist only for reads. A mutating endpoint
570
+ (`/invoke`, `/seeds/run`, `/data/import`, `/migrations/rollback`,
571
+ `/agent/call`) rejects them: fanning a destructive operation out across a fleet
572
+ is not something an accidental query parameter should be able to ask for.
573
+
574
+ ## Reading it from your own tooling
575
+
576
+ ```sh
577
+ # the payload
578
+ curl -s … /_voltro/inspect/subscriptions | jq .data.resume
579
+
580
+ # is this the whole picture?
581
+ curl -s … /_voltro/inspect/subscriptions | jq '.completeness | {complete, fleetSize}'
582
+
583
+ # which pod answered?
584
+ curl -s … /_voltro/inspect/subscriptions | jq -r .origin.replicaId
585
+ ```
586
+
587
+ `capturedAt` is the **origin's** clock. Do not compare it with another
588
+ replica's — replica clocks disagree, which is why the fleet view reports an
589
+ `ageMs` computed against one reader's instant rather than a timestamp you are
590
+ invited to subtract.
591
+
592
+ ## The live stream
593
+
594
+ `/_voltro/inspect/stream` (SSE) carries `origin` on **every event**, not on a
595
+ handshake. A consumer that connects late — a reconnect, a second tab, a `curl`
596
+ piped into `jq` — never sees a handshake, and this is a live stream from
597
+ whichever replica the connection landed on. Without the per-event stamp, a tail
598
+ on a three-replica fleet shows a third of it continuously, with nothing on the
599
+ wire to say so.
600
+
601
+ `ts` is that replica's clock. Do not order two origins by it.
602
+
603
+ **It is mounted on both boot paths.** `voltro serve` used to answer `404` here
604
+ — the stream was wired for `voltro dev` and nowhere else — so every live view in
605
+ the dashboard worked in development and was dead in production. Both paths now
606
+ mount it through one builder.
607
+
608
+ **Authenticating it needs no special step from you, and one from the
609
+ dashboard.** `EventSource` accepts no headers, so a browser cannot attach a
610
+ bearer to an SSE request the way it does to every other inspect call. The
611
+ dashboard passes the app's token through a same-origin cookie scoped to its own
612
+ proxy path, cleared the moment the stream opens; the proxy moves it into an
613
+ `Authorization` header. Nothing about the token ever appears in a URL, and the
614
+ app receives a header like any other caller.
615
+
616
+ ## In the dashboards
617
+
618
+ Both dashboards unwrap `.data` in their fetch layer and keep the envelope. A
619
+ page whose data is process-local renders a notice saying which replica it is
620
+ showing and how many exist; a page whose answer is complete renders nothing,
621
+ because a banner over a complete answer teaches people to ignore banners.
622
+
623
+ ### The Fleet panel
624
+
625
+ Both dashboards ship a **Fleet** page — the self-hosted DevTools under
626
+ `/apps/<id>/fleet`, the hosted console under the app's *Fleet* tab. One page,
627
+ one shared component, two transports.
628
+
629
+ It renders **three** populations, and the two after the first are the point:
630
+
631
+ - the replicas that **answered**, each with its version, how long ago it
632
+ published, and whether a peer can reach it;
633
+ - the replicas that are **silent** — membership knows them and they have
634
+ published nothing readable. Shown as their own section rather than omitted,
635
+ because "not shown" and "not there" look identical and mean opposite things;
636
+ - the replicas whose answer is **stale**, with the age. Filtering them would
637
+ make a partial answer look complete; leaving them unmarked would present old
638
+ numbers as current.
639
+
640
+ A **mixed-version** note appears when the responders disagree — a rolling
641
+ deploy is in flight and the numbers span two shapes.
642
+
643
+ The completeness banner is phrased as a ratio (*"3 of 5 replicas answered"*)
644
+ rather than a count, because a count invites the reader to believe that is the
645
+ fleet.
@@ -239,6 +239,21 @@ All three route through the existing `BillingService.reportUsage` (a local per-p
239
239
 
240
240
  **Idempotency + multi-instance.** The self-scheduled flush is per-period idempotent (`markReported` makes a re-flush a no-op) AND **cluster-coordinated by default**: each replica self-schedules, but an INSERT-wins claim on the flush window (`_voltro_billing_flush_claims`) means exactly one replica flushes a given window — so two replicas never double-push it, with no extra wiring. Set `flushIntervalMs: 0` to disable the timer entirely and drive `billing.flushUsage()` from your own `*.cron.tsx` instead.
241
241
 
242
+ **A `cdc` meter accrues once fleet-wide, not once per replica.** The change tap it
243
+ rides is delivered to EVERY replica — that is what makes a `changeScope: 'fleet'`
244
+ store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
245
+ and accrual is an increment on a shared counter, so a tenant on two pods used to
246
+ be invoiced twice. Each change is now claimed in `_voltro_change_claims` before it
247
+ accrues, through the same INSERT-wins arbiter behind
248
+ [`defineSubscriber({ once })`](/docs/data/subscribers) and a reaction's
249
+ `dedupeKey`. Nothing to configure; the boot log warns loudly if a deployment ever
250
+ runs the tap ungated, because an over-counted meter is indistinguishable from a
251
+ correct one by looking at the number.
252
+
253
+ The claim key names the CHANGE, not the row: two genuine edits to one row share an
254
+ id, so an id-keyed meter on `op: 'update'` would count the first and drop every one
255
+ after it.
256
+
242
257
  **Boundaries (v1).** Metering captures writes the framework observes through `ctx.store` — the bulk helpers (`updateMany` / `deleteMany`) emit per-row ChangeEvents that ARE counted, but a single bulk SQL escape-hatch write isn't. The meter is best-effort post-commit telemetry, not a financial ledger of record.
243
258
 
244
259
  ## Plans, seats & the billed amount
@@ -51,7 +51,8 @@ The bus is **additive** to the inline emit path:
51
51
 
52
52
  - It publishes `{ origin, event }` on the `<namespace>:changes` channel.
53
53
  - It injects remote events into every other replica's store, skipping its own origin so there's no double-emit.
54
- - A broker outage degrades cross-replica fan-out only — local reactivity keeps working.
54
+ - A broker outage degrades cross-replica fan-out only — local reactivity keeps working, **including a broker that is down at boot**. The replica starts, serves, and joins the bus when the broker returns; the time it spent unsubscribed is then reported as a gap and every live query re-runs. A crash loop across the fleet is the wrong answer to a broker restart, which is exactly the moment every replica is dialling at once.
55
+ - A restarted peer is recognised as a **new process**, not the same one continuing. Each publish carries a per-process epoch, because a replica id survives a restart (`POD_NAME` on a StatefulSet, `VOLTRO_REPLICA_ID` by definition) and a serial does not.
55
56
 
56
57
  The plugin declares the `network:outbound:*` permission. The boot banner names the resolved tier (cross-instance via redis/nats, or off for the dialect when no broker is configured).
57
58
 
@@ -215,7 +215,12 @@ updates under concurrency), so it can't reuse a plain get/set cache. Three
215
215
  stores ship, all fail OPEN on backend errors (degrade to no-limit, never to 500s):
216
216
 
217
217
  - **Memory** *(default)* — single-process, zero-config. Perfect for dev and
218
- single-instance deploys; not shared across replicas.
218
+ single-instance deploys; not shared across replicas. **Which means every limit
219
+ is multiplied by the replica count**: `100/min` on five pods is 500/min, and a
220
+ limiter is usually the thing standing between an endpoint and abuse. The plugin
221
+ warns at boot when the environment says several replicas (`POD_NAME`,
222
+ `FLY_ALLOC_ID`, `K_REVISION`, … — or `REPLICA_COUNT`, which is a declaration in
223
+ both directions) and the store is still the process-local one.
219
224
  - **Postgres** — multi-node correct. Runs on the framework's **already-open
220
225
  `SqlClient`** — the same connection pool the app itself uses — so it reads no
221
226
  `DB_*`/`PG_*` env of its own and never opens a second pool. The plugin binds
@@ -138,6 +138,17 @@ Version numbers are 1-based in **both** timings, so switching `timing` does not
138
138
 
139
139
  **The refusal takes the row with it.** When the trail's insert fails, the write it covers is rolled back — including a write made OUTSIDE any transaction of your own. That has not always been true: the row's statement committed on its own and the trail ran as a second statement afterwards, so a failing trail left a committed row behind a write that reported failure. Anything that retried that write then met its own row and reported a duplicate key for a row nobody wrote twice. A table with recorders is written inside a transaction now, on every SQL dialect, so "the mutation fails with it" means what it says.
140
140
 
141
+ **Post-commit records once fleet-wide, not once per replica.** The change tap it
142
+ rides is delivered to EVERY replica — that is what makes a `changeScope: 'fleet'`
143
+ store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
144
+ and it used to record on each of them. It did not surface as a conflict either:
145
+ versions are numbered `MAX(version) + 1`, so two replicas both computed version 1,
146
+ one won the primary key, and the loser's retry re-read MAX, got 2, and appended a
147
+ second entry. Three replicas produced versions `1, 2, 3` for one change — not
148
+ merely doubled, *mis-ordered*, and `selectAsOf` reads `version`. Each change is now
149
+ claimed fleet-wide before it is recorded, keyed on the change rather than the row.
150
+ Nothing to configure; `'in-transaction'` never had this.
151
+
141
152
  **Why the default is still `'post-commit'`.** In-transaction makes `_voltro_row_history` a hard dependency of every write path it covers: its availability becomes your write path's availability, and every covered write holds its locks longer. Post-commit loses at worst *one entry*; in-transaction can, at worst, stop writes to the covered tables entirely. For a compliance trail the second trade is the right one — for the undo / time-travel use this plugin also serves, it is not.
142
153
 
143
154
  ### Under CDC, and inside a transaction
@@ -1130,6 +1130,25 @@ framework's ALREADY-OPEN handles so it never rebuilds them:
1130
1130
  the SAME pool the app's store uses. Run raw SQL through it instead of
1131
1131
  standing up your OWN `ManagedRuntime` + pool from env. `undefined` on
1132
1132
  the in-memory store (no SQL engine) — guard with `if (ctx.sql)`.
1133
+ - **`ctx.claimChange(scope, key)`** — claim ONE change fleet-wide; `true` means
1134
+ this replica may run the effect. The counterpart of `scheduleCoordinated` for
1135
+ the OTHER thing that fires on every replica: a change tap. `onChangeEvent` is
1136
+ delivered to every replica — that is what makes a `changeScope: 'fleet'` store
1137
+ cross-instance — so a tap that COUNTS or SENDS multiplies by the replica count.
1138
+ `@voltro/plugin-billing` accrued a usage unit per metered row change and a
1139
+ tenant on two pods was invoiced twice.
1140
+
1141
+ `scope` namespaces the key (use your plugin's name); `key` must name the
1142
+ CHANGE. Derive it with `changeDigest` + `OccurrenceCounter` from
1143
+ `@voltro/database` rather than inventing one — a fleet change carries no LSN,
1144
+ no commit id and no `traceId`, so content plus its position among
1145
+ content-identical repeats is the only thing two replicas provably agree on. A
1146
+ row id is not enough: two genuine updates to one row share it.
1147
+
1148
+ AT MOST once — the claim is taken before the effect, so a replica that wins and
1149
+ dies takes the change with it, and a claim that cannot be written is taken by
1150
+ nobody. Fail-closed, like the rate slot and the budget guard.
1151
+
1133
1152
  - **`ctx.scheduleCoordinated(name, intervalMs, effect)`** — run a periodic
1134
1153
  task on ONLY ONE replica per tick, cluster-coordinated via the same
1135
1154
  claim-table exactly-once gate the cron scheduler uses. Replaces the
@@ -81,7 +81,7 @@ A contact form, a newsletter signup, a theme toggle — anything that needs JS
81
81
 
82
82
  ```tsx
83
83
  // src/components/SignupForm.island.tsx
84
- import { island } from '@voltro/web'
84
+ import { island } from '@voltro/web/islands'
85
85
  const SignupForm = () => { /* … */ }
86
86
  export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
87
87
  ```
@@ -971,7 +971,7 @@ The post list ships zero JavaScript, but the post page wants a thin reading-prog
971
971
  // src/components/ReadingProgress.island.tsx
972
972
  import type { ReactNode } from 'react'
973
973
  import { useEffect, useState } from 'react'
974
- import { island } from '@voltro/web'
974
+ import { island } from '@voltro/web/islands'
975
975
 
976
976
  function ReadingProgress(): ReactNode {
977
977
  const [pct, setPct] = useState(0)
@@ -1180,7 +1180,7 @@ The form is an island with `hydrate: 'visible'` — it's below the fold, so hydr
1180
1180
  // src/components/ContactForm.island.tsx
1181
1181
  import type { FormEvent, ReactNode } from 'react'
1182
1182
  import { useState } from 'react'
1183
- import { island } from '@voltro/web'
1183
+ import { island } from '@voltro/web/islands'
1184
1184
  import { CONTACT_ENDPOINT } from '../config'
1185
1185
 
1186
1186
  type Status =