@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.
- package/CHANGELOG.md +497 -0
- package/bin/voltro.mjs +24 -0
- package/dist/{apiBuild-CMvLJM_K.js → apiBuild-Bdaetr37.js} +119 -88
- package/dist/apiBuild-Vw1figjO.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-S0QOzqPT.js → build-CI36wL4R.js} +337 -312
- package/dist/buildReport-52gHKgfO.js +64 -0
- package/dist/{checkCommand-fbj9GDjN.js → checkCommand-CAwFXrxA.js} +6 -6
- package/dist/{checkCommand-DNkY5kwF.js → checkCommand-D0QV_zM_.js} +1 -1
- package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
- package/dist/{codegen-SIepQtUl.js → codegen-Bth5lUTU.js} +2 -1
- package/dist/codegen-DbH7NbCR.js +2 -0
- package/dist/{codegenCommand-3TDJezom.js → codegenCommand-CidbQzbv.js} +10 -10
- package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
- package/dist/{commands-BBYJ7Q3B.js → commands-CWjfThXv.js} +35 -35
- package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-BekcY5Ls.js} +3 -3
- package/dist/{dataCommand-BEPPQiTl.js → dataCommand-2pccgbIy.js} +3 -3
- package/dist/{dbCommand-DZTmOFT4.js → dbCommand-DpK_vQET.js} +457 -441
- package/dist/dbCommand-DrycGWWt.js +2 -0
- package/dist/{dev-DfVZaoys.js → dev-B9Gz0k85.js} +1 -1
- package/dist/{dev-Ca_A_S9v.js → dev-Dw263KPu.js} +2603 -2482
- package/dist/doctorCommand-BMWs6aVm.js +2 -0
- package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-aR_bFmIi.js} +353 -251
- package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-eXTQMbHU.js} +1 -1
- package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-CTmiQvwa.js} +1 -1
- package/dist/{envCommand-Bxy2fOjc.js → envCommand-BDUgV7EM.js} +12 -12
- package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-YV8qW1LU.js} +2 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
- package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-DNOFXfEQ.js} +92 -84
- package/dist/index.js +1 -1
- package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
- package/dist/inspect-CNYvNXPU.js +1484 -0
- package/dist/inspect-S2rWy1Ys.js +2 -0
- package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
- package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
- package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
- package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
- package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-CEkjfpwc.js} +1 -1
- package/dist/manifestBuild-DIa_s6u0.js +2 -0
- package/dist/{migrate-CGFZS-1a.js → migrate-SICulyz1.js} +2 -2
- package/dist/precompressAssets-YhTi1aWp.js +40 -0
- package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-6HxEkNDG.js} +2 -2
- package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-DgYMc09E.js} +1 -1
- package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
- package/dist/{sdkgen-CXMwLg9n.js → sdkgen-PY-umd6O.js} +1 -1
- package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
- package/dist/serveCommand-BITS8Hpj.js +2 -0
- package/dist/{serveCommand-C7IrCD58.js → serveCommand-DIJ3ma76.js} +910 -870
- package/dist/serveEntry.js +1 -1
- package/dist/{start-EOV7s1NZ.js → start-B9NGB8gn.js} +575 -546
- package/dist/{start-DH7cat4-.js → start-BFQQkL1i.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
- package/dist/{test-DO27-x2P.js → test-jipIQ5Mx.js} +1 -1
- package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
- package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
- package/dist/updateCommand-DsXEAHbd.js +2 -0
- package/dist/webDev-C2dRz9s5.js +2 -0
- package/dist/{webDev-B7vNj4Bq.js → webDev-C53hJdcL.js} +1240 -1271
- package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-uuPu8qQX.js} +1 -1
- package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
- package/package.json +55 -19
- package/templates/AGENTS.core.md +20 -1
- package/templates/AGENTS.md +21 -2
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/cli.md +122 -0
- package/templates/agent-docs/data.md +145 -5
- package/templates/agent-docs/database/scaling.md +40 -1
- package/templates/agent-docs/deployment.md +53 -1
- package/templates/agent-docs/internationalization.md +32 -0
- package/templates/agent-docs/local-first-mobile.md +9 -2
- package/templates/agent-docs/observability.md +227 -0
- package/templates/agent-docs/plugins/billing.md +15 -0
- package/templates/agent-docs/plugins/broadcast.md +2 -1
- package/templates/agent-docs/plugins/ratelimit.md +6 -1
- package/templates/agent-docs/plugins/row-history.md +11 -0
- package/templates/agent-docs/plugins.md +19 -0
- package/templates/agent-docs/templates/appshells.md +3 -3
- package/templates/agent-docs/whats-new.md +432 -73
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
- package/templates/apps/frontend-app/package.json +8 -9
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-auth/package.json +7 -8
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
- package/templates/apps/frontend-blank/package.json +6 -7
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-cms/package.json +8 -9
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
- package/templates/apps/frontend-collab/README.md +7 -2
- package/templates/apps/frontend-collab/package.json +9 -10
- package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
- package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
- package/templates/apps/frontend-dashboard/package.json +6 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +7 -8
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/package.json +7 -8
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
- package/templates/apps/frontend-spa/package.json +6 -7
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr/package.json +6 -7
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-ssr-api/package.json +7 -8
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
- package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
- package/templates/apps/frontend-status/package.json +7 -8
- package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
- package/templates/apps/mobile-app/package.json +4 -4
- package/templates/baselines/compose/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose/docker/web.Dockerfile +55 -10
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
- package/dist/apiBuild-Cl0IDx8c.js +0 -2
- package/dist/codegen-CN6vMM4J.js +0 -2
- package/dist/dbCommand-BTyBGhIA.js +0 -2
- package/dist/doctorCommand-djmqEcDC.js +0 -2
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +0 -2
- package/dist/inspect-DCqILJ1G.js +0 -1197
- package/dist/inspect-DGJwpOAb.js +0 -2
- package/dist/manifestBuild-CJ2zvPvT.js +0 -2
- package/dist/serveCommand-Cjt5S9hD.js +0 -2
- package/dist/updateCommand-nnFjDbl4.js +0 -2
- 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
|
-
|
|
1670
|
+
Four properties of the image every platform relies on:
|
|
1627
1671
|
|
|
1628
1672
|
- **`PORT` wins.** The port precedence is `PORT` > `--port` > `app.config.ts` —
|
|
1629
1673
|
deliberately, because platforms assign through `PORT`. You never configure a
|
|
@@ -1635,6 +1679,14 @@ Three properties of the image every platform relies on:
|
|
|
1635
1679
|
- **`/internal/readiness` flips to 200 only after the whole boot.** Use it as
|
|
1636
1680
|
the health check everywhere; routing traffic on process-up instead of
|
|
1637
1681
|
readiness is how a deploy serves 502s for the first seconds.
|
|
1682
|
+
- **The build imports the serve bundle before the image is finished, and a
|
|
1683
|
+
failed import fails the build.** The image-build stage has no database and no
|
|
1684
|
+
secrets, so it cannot require a full boot — but it does not need one: a module
|
|
1685
|
+
`prune-runtime` traced away, a truncated artefact, a wrong entry path or a
|
|
1686
|
+
missing export all fail at *import*, long before anything connects. So the
|
|
1687
|
+
import and a callable `runServe` are required; how far the subsequent start
|
|
1688
|
+
gets is reported, not required. If your build turns red at `load gate:`, the
|
|
1689
|
+
artefact is wrong and no amount of environment will fix it.
|
|
1638
1690
|
|
|
1639
1691
|
## Fly.io
|
|
1640
1692
|
|
|
@@ -102,6 +102,38 @@ Locale was already agreed; the zone and the clock were each read from the ambien
|
|
|
102
102
|
|
|
103
103
|
See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
|
|
104
104
|
|
|
105
|
+
## The language picker
|
|
106
|
+
|
|
107
|
+
`<LocaleSwitcher>` writes the `voltro:locale` cookie and reloads, so the server
|
|
108
|
+
re-renders in the chosen language. It ships from `@voltro/i18n` **unstyled** — a
|
|
109
|
+
native `<select>` you style with your own CSS:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
import { useLocale, useT, LocaleSwitcher } from '@voltro/i18n'
|
|
113
|
+
|
|
114
|
+
const LOCALES = [
|
|
115
|
+
{ code: 'en', label: 'English' },
|
|
116
|
+
{ code: 'de', label: 'Deutsch' },
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
<LocaleSwitcher
|
|
120
|
+
locales={LOCALES}
|
|
121
|
+
current={useLocale()} // server + first paint agree; without it the
|
|
122
|
+
ariaLabel={useT('lang.label')} // control hydrates from the cookie after mount
|
|
123
|
+
className="my-lang-select"
|
|
124
|
+
/>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A native `<select>` on purpose: it works everywhere with no portal or
|
|
128
|
+
positioning chrome, is keyboard-accessible by default, and is SSR-safe.
|
|
129
|
+
`onChange` returning `false` suppresses the reload — for an app whose i18n
|
|
130
|
+
runtime swaps catalogs in place.
|
|
131
|
+
|
|
132
|
+
`@voltro/ui-shadcn` exports the same control pre-styled with the kit's classes.
|
|
133
|
+
Use that one **only in a kit app**: its classes exist only when your CSS entry
|
|
134
|
+
imports `@voltro/ui-shadcn/tokens.css`. Without it the control renders as a bare
|
|
135
|
+
`<select>` with dead `class` attributes — `voltro doctor` reports exactly this.
|
|
136
|
+
|
|
105
137
|
## The cookie names are exported
|
|
106
138
|
|
|
107
139
|
`LOCALE_COOKIE` and `THEME_COOKIE` come from **`@voltro/i18n`** (and from `@voltro/ui-shadcn` if you use the kit):
|
|
@@ -399,9 +399,16 @@ Two disciplines it enforces, because both fail silently when hand-rolled:
|
|
|
399
399
|
when `local` is `true`. Without that every client re-broadcasts what it
|
|
400
400
|
just received: one keystroke, one server write per open tab.
|
|
401
401
|
|
|
402
|
-
A page mounting an editor needs `renderMode = '
|
|
402
|
+
A page mounting an editor needs `renderMode = 'spa'`. The default is
|
|
403
403
|
`'static'`, which pre-renders at build time, and the editor finds no
|
|
404
|
-
`window` there.
|
|
404
|
+
`window` there. `'spa'` is skipped by the prerender and mounts on the
|
|
405
|
+
client; if the route has a layout, that layout still renders server-side
|
|
406
|
+
as an SSR shell.
|
|
407
|
+
|
|
408
|
+
This page said `'client'`, which is not one of the four render modes and
|
|
409
|
+
fails the build — see [Render modes](/docs/routing/render-modes), which
|
|
410
|
+
names that exact value as invalid. The `frontend-collab` template copied
|
|
411
|
+
the sentence and was unbuildable for as long as it existed.
|
|
405
412
|
|
|
406
413
|
Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
|
|
407
414
|
metadata: the roster's value-compare push would make every caret move a
|
|
@@ -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 =
|