@voltro/cli 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +691 -0
- package/bin/voltro.mjs +24 -0
- package/dist/apiBuild-B83Cb2Rv.js +2 -0
- package/dist/{apiBuild-CMvLJM_K.js → apiBuild-DDr2aNFd.js} +119 -88
- package/dist/bin.js +1 -1
- package/dist/{build-S0QOzqPT.js → build-QKP6Bm0J.js} +308 -279
- package/dist/buildReport-52gHKgfO.js +64 -0
- package/dist/{checkCommand-DNkY5kwF.js → checkCommand-BISqx1OJ.js} +1 -1
- package/dist/{checkCommand-fbj9GDjN.js → checkCommand-DUtMWjcR.js} +6 -6
- 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-DnVuDxwT.js} +11 -11
- package/dist/{codemodRunner-C2zxZUIw.js → codemodRunner-BlQPfjzA.js} +222 -0
- package/dist/{commands-BBYJ7Q3B.js → commands-CRbxgxv0.js} +35 -35
- package/dist/{dashboardCommand-D2kmyCLL.js → dashboardCommand-Bf_-Ne3P.js} +5 -5
- package/dist/{dataCommand-BEPPQiTl.js → dataCommand-BoBJJ-Gb.js} +3 -3
- package/dist/{dbCommand-DZTmOFT4.js → dbCommand-CMAIz-Bf.js} +457 -441
- package/dist/dbCommand-DHi_RuDl.js +2 -0
- package/dist/dev-Bl9HqtV7.js +3 -0
- package/dist/{dev-Ca_A_S9v.js → dev-DOEJXicj.js} +2623 -2485
- package/dist/{doctorCommand-CGZJK_4o.js → doctorCommand-BrWu67JZ.js} +524 -280
- package/dist/doctorCommand-DrQv9SL3.js +2 -0
- package/dist/{dormancyCommand-DY2rYpTa.js → dormancyCommand-xn2y-pJm.js} +1 -1
- package/dist/{embeddingsCommand-BoCqZsgp.js → embeddingsCommand-Cn5MbRDM.js} +1 -1
- package/dist/emptyResultHeadline-Csa5fZOF.js +18 -0
- package/dist/{envCommand-Bxy2fOjc.js → envCommand-UJmJIbs9.js} +8 -8
- package/dist/{evolveCommand-BsbZ-XDg.js → evolveCommand-Db30twUy.js} +2 -2
- package/dist/frameworkTableAssembly-B96WCNJA.js +2 -0
- package/dist/{frameworkTableAssembly-Do-cf6RJ.js → frameworkTableAssembly-vfkzuzEo.js} +131 -104
- package/dist/index.js +1 -1
- package/dist/{infoCommand-EmM3jPKD.js → infoCommand-BjVXpMlP.js} +1 -1
- package/dist/inspect-B0hL41s0.js +2 -0
- package/dist/inspect-DZnan87F.js +1500 -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-C5y9HyrG.js} +59 -54
- package/dist/{manifestBuild-DjX5MoXy.js → manifestBuild-DnbFKF6w.js} +1 -1
- package/dist/manifestBuild-UXrnUcXP.js +2 -0
- package/dist/{migrate-CGFZS-1a.js → migrate-DtC3lu7H.js} +4 -4
- package/dist/precompressAssets-YhTi1aWp.js +40 -0
- package/dist/{probeCommand-Bs3iVBSL.js → probeCommand-C5fuN6Z2.js} +2 -2
- package/dist/{runtimeTrace-C1BTpHGQ.js → runtimeTrace-BQL_lfz6.js} +1 -1
- package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
- package/dist/{sdkgen-CXMwLg9n.js → sdkgen-CMUPrDjH.js} +1 -1
- package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
- package/dist/serveCommand-B_w-Mpb_.js +2544 -0
- package/dist/serveCommand-Dtb48ffg.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-EOV7s1NZ.js → start-YaUehtDV.js} +580 -558
- package/dist/{start-DH7cat4-.js → start-s25GAIgn.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-C1qKa94g.js} +69 -64
- package/dist/{updateCommand-C_jN1w18.js → updateCommand-C9n_Z_oG.js} +8 -2
- package/dist/updateCommand-DsXEAHbd.js +2 -0
- package/dist/webDev-B6ZMX42w.js +2 -0
- package/dist/{webDev-B7vNj4Bq.js → webDev-BgdkyjP6.js} +1379 -1303
- package/dist/{webhooksCommand-B1LVcyO3.js → webhooksCommand-CYXTNvXq.js} +2 -2
- 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 +2 -2
- package/templates/agent-docs/ai.md +1 -1
- package/templates/agent-docs/authentication.md +34 -19
- package/templates/agent-docs/cli.md +170 -0
- package/templates/agent-docs/configuration.md +29 -4
- package/templates/agent-docs/data.md +145 -5
- package/templates/agent-docs/database/scaling.md +50 -2
- package/templates/agent-docs/deployment.md +75 -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/audit.md +21 -5
- package/templates/agent-docs/plugins/billing.md +17 -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/reference.md +1 -0
- package/templates/agent-docs/scheduling.md +23 -0
- package/templates/agent-docs/schema-driven-ui.md +81 -0
- package/templates/agent-docs/templates/appshells.md +3 -3
- package/templates/agent-docs/whats-new.md +133 -77
- 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/dev-DfVZaoys.js +0 -3
- 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-C7IrCD58.js +0 -2445
- package/dist/serveCommand-Cjt5S9hD.js +0 -2
- package/dist/updateCommand-nnFjDbl4.js +0 -2
- package/dist/webDev-1XpVnYkW.js +0 -2
|
@@ -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.
|
|
@@ -356,7 +356,7 @@ What it discloses, and what it never does:
|
|
|
356
356
|
|
|
357
357
|
| | |
|
|
358
358
|
| --- | --- |
|
|
359
|
-
| a string | its LENGTH — `string(113)`. Never a prefix, never a hash |
|
|
359
|
+
| a string | its LENGTH — `string(113)`. Never a prefix, never a hash. Below a floor, a bucket: `string(<16)` |
|
|
360
360
|
| a number, a boolean, a date | its TYPE only. A number can BE the secret |
|
|
361
361
|
| a declared field name | survives — `jiraToken` |
|
|
362
362
|
| a key that is not an identifier | described, not reproduced — `<key:string(36)>` |
|
|
@@ -369,10 +369,26 @@ Two of those are worth a sentence each.
|
|
|
369
369
|
it looks like a declared field: a plain short identifier. A legitimate key that
|
|
370
370
|
is not one (`content-type`) loses its name and keeps its shape.
|
|
371
371
|
|
|
372
|
-
**A string's length is a real disclosure, and a small one
|
|
373
|
-
credential it carries nothing — every token of a given kind is the
|
|
374
|
-
For a human-chosen password it is a weak hint.
|
|
375
|
-
|
|
372
|
+
**A string's length is a real disclosure, and a small one — above a floor.** For
|
|
373
|
+
a fixed-format credential it carries nothing — every token of a given kind is the
|
|
374
|
+
same length. For a human-chosen password it is a weak hint.
|
|
375
|
+
|
|
376
|
+
Below a floor it stops being small: a six-digit TOTP reported as `string(6)`, or
|
|
377
|
+
a four-digit PIN as `string(4)`, tells a reader with log access exactly what
|
|
378
|
+
shape to try. Nothing this mode is FOR lives under 16 characters — the case that
|
|
379
|
+
motivated it is 113 against an expected 44 — so a string shorter than that
|
|
380
|
+
reports `string(<16)`: shorter than the floor, not how much shorter.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
auditPlugin({ redactOutcome: 'shape', redactionLengthFloor: 0 })
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`0` turns the floor off, for a deployment whose audited payloads are ids and
|
|
387
|
+
tokens and every character of length is worth having; raise it for one holding
|
|
388
|
+
short human-entered secrets. An EMPTY string stays exact (`string(0)`) — "the
|
|
389
|
+
field arrived empty" is a real diagnosis and an empty string is not a secret. The
|
|
390
|
+
floor applies to described keys too, for the reason above them: a key that is not
|
|
391
|
+
a declared field is a key carrying data.
|
|
376
392
|
|
|
377
393
|
`redactSubject` spells its variant `'metadata-shape'`, because its default
|
|
378
394
|
(`'metadata'`) already names the field it acts on.
|
|
@@ -40,6 +40,8 @@ export default {
|
|
|
40
40
|
|
|
41
41
|
`plans` is the single source of tier→limit truth — it lives in code, not the DB, so limits change by redeploy, not migration. Entitlement values are `number | 'unlimited'`. With no provider configured (and no `STRIPE_SECRET_KEY`), the plugin uses an in-memory **mock provider** — zero-config for dev and tests.
|
|
42
42
|
|
|
43
|
+
> **Under `NODE_ENV=production`, a mock chosen by ABSENCE warns.** `STRIPE_SECRET_KEY` is a deployment variable, and one that silently stops being set — a rotated secret, a typo in a values file, a CI variable nobody created — is routine. Without the line, an app that had been charging customers keeps answering every billing call successfully, reaches nobody, and leaves nothing to find afterwards. Set the key, or write `provider: 'mock'` so the mock is a decision on the page rather than an absence. An explicitly declared mock stays silent.
|
|
44
|
+
|
|
43
45
|
What it provides:
|
|
44
46
|
|
|
45
47
|
- The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) via `extendSchema.tables`.
|
|
@@ -239,6 +241,21 @@ All three route through the existing `BillingService.reportUsage` (a local per-p
|
|
|
239
241
|
|
|
240
242
|
**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
243
|
|
|
244
|
+
**A `cdc` meter accrues once fleet-wide, not once per replica.** The change tap it
|
|
245
|
+
rides is delivered to EVERY replica — that is what makes a `changeScope: 'fleet'`
|
|
246
|
+
store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
|
|
247
|
+
and accrual is an increment on a shared counter, so a tenant on two pods used to
|
|
248
|
+
be invoiced twice. Each change is now claimed in `_voltro_change_claims` before it
|
|
249
|
+
accrues, through the same INSERT-wins arbiter behind
|
|
250
|
+
[`defineSubscriber({ once })`](/docs/data/subscribers) and a reaction's
|
|
251
|
+
`dedupeKey`. Nothing to configure; the boot log warns loudly if a deployment ever
|
|
252
|
+
runs the tap ungated, because an over-counted meter is indistinguishable from a
|
|
253
|
+
correct one by looking at the number.
|
|
254
|
+
|
|
255
|
+
The claim key names the CHANGE, not the row: two genuine edits to one row share an
|
|
256
|
+
id, so an id-keyed meter on `op: 'update'` would count the first and drop every one
|
|
257
|
+
after it.
|
|
258
|
+
|
|
242
259
|
**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
260
|
|
|
244
261
|
## 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
|
|
@@ -89,6 +89,7 @@ these before hand-rolling a form, a table, or a picker** — full guide in
|
|
|
89
89
|
| [`usePreview`](/docs/ui/client-utilities/use-preview) | Mutation dry-run — run the real handler in a rolled-back transaction. |
|
|
90
90
|
| [`useProvenance`](/docs/ui/client-utilities/use-provenance) | "Why is this value here?" — lineage lookup for a field. |
|
|
91
91
|
| [`useOnRpcError`](/docs/ui/client-utilities/use-on-rpc-error) / `reportClientError` | Subscribe to the rpc error bus; report a client error to the server. |
|
|
92
|
+
| [`useSubscriptionHealth`](/docs/ui/client-utilities/use-subscription-health) | Which of an api's calls are currently REFUSED — a refused subscription never retries and reads as `loading`. |
|
|
92
93
|
| [`useTracking`](/docs/ui/client-utilities/use-tracking) | Fire mount/unmount + interaction tracking events. |
|
|
93
94
|
| [`useCapabilityManifest`](/docs/ui/client-utilities/use-capability-manifest) | The api's capability manifest (procedures + tables + schemas), fetched once. |
|
|
94
95
|
| [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions) | Force-refresh live subscriptions (e.g. after an out-of-band change). |
|
|
@@ -539,6 +539,29 @@ defineSchedule({
|
|
|
539
539
|
|
|
540
540
|
> The watchdog stops *waiting* and records the timeout; it cannot truly abort a Promise's in-flight side effects (JavaScript has no thread-kill). Make long handlers cooperative — check a deadline, or do the heavy lifting in a [workflow](/docs/workflows/overview) with its own step-level durability.
|
|
541
541
|
|
|
542
|
+
## Across replicas — the heartbeat
|
|
543
|
+
|
|
544
|
+
`onOverlap: 'skip'` is a **cluster-wide** rule, not a per-process one: a firing is suppressed when an occurrence of the same schedule is running on *any* replica. That check reads `_voltro_schedule_runs`, so it needs a way to tell a run that is still working from one whose process died mid-run and left its row on `running` forever.
|
|
545
|
+
|
|
546
|
+
That is `heartbeatAt`: a run in flight bumps it every **30 seconds**, and a row silent for three beats is read as dead rather than as a live occurrence. Without it the only evidence a row carried was `firedAt`, and a window generous enough never to cut off a long run is also long enough to suppress a half-hourly cron for hours after one restart.
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
// app.config.ts
|
|
550
|
+
export default defineAppConfig({
|
|
551
|
+
scheduling: {
|
|
552
|
+
scheduleHeartbeatMs: 30_000, // default; VOLTRO_SCHEDULE_HEARTBEAT_MS overrides it
|
|
553
|
+
},
|
|
554
|
+
})
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Raise it in a deployment with many long-running schedules where the writes matter more than the detection latency; a run shorter than one interval writes no beat at all and costs nothing either way. It is **not a lease** — a stale beat only stops a corpse from holding the schedule shut; nothing takes ownership of the run.
|
|
558
|
+
|
|
559
|
+
The skip is logged with what it measured, so the two cases are legible from the log alone:
|
|
560
|
+
|
|
561
|
+
```
|
|
562
|
+
schedule: overlap skip name=reindex scope=cluster heldBy=pod-7f4c lastSeenMs=1840
|
|
563
|
+
```
|
|
564
|
+
|
|
542
565
|
## Backfill — `backfill`
|
|
543
566
|
|
|
544
567
|
When the process was down across one or more firing instants, what should happen on boot? Computed from the last `_voltro_schedule_runs` row for the schedule.
|
|
@@ -370,6 +370,36 @@ widget })` rides an annotation, `description` becomes help text, and
|
|
|
370
370
|
underneath is an implementation detail — no engine type appears in the public
|
|
371
371
|
API, and production builds stub its devtools channel automatically.
|
|
372
372
|
|
|
373
|
+
### When the FORM decides which mutation it is
|
|
374
|
+
|
|
375
|
+
Some forms only learn their target from what the user does: a calendar entry
|
|
376
|
+
becomes a recurring **series** the moment "repeats" is ticked, and the series
|
|
377
|
+
mutation takes eleven more fields than the single one.
|
|
378
|
+
|
|
379
|
+
Pass a function of the current values instead of a tag:
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
const form = useFormBinding<CalendarInput>(
|
|
383
|
+
'app',
|
|
384
|
+
(v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'),
|
|
385
|
+
{ defaults: { title: '', repeats: false } },
|
|
386
|
+
)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The schema in force follows the tag, so `form.fields` grows and shrinks with it
|
|
390
|
+
and validation always matches what will actually be submitted. **The values do
|
|
391
|
+
not reset** — the engine is constructed once and never rebuilt, so everything
|
|
392
|
+
typed before the switch survives it. That is the whole reason this exists:
|
|
393
|
+
deriving the tag outside the binding is not available (the values belong to the
|
|
394
|
+
binding and do not exist before it), and re-mounting with a different tag throws
|
|
395
|
+
the user's input away.
|
|
396
|
+
|
|
397
|
+
Two details worth knowing. On the first render there are no values yet, so the
|
|
398
|
+
function is called with your `defaults`. And the accessibility ids are pinned to
|
|
399
|
+
the first tag and stay there — they are DOM ids, and letting them move on the
|
|
400
|
+
keystroke that flips the branch would remount every field, taking the focus and
|
|
401
|
+
the caret with it.
|
|
402
|
+
|
|
373
403
|
### Reference fields, uploads, and testing the form
|
|
374
404
|
|
|
375
405
|
**Reference fields.** Mark a schema field as a table reference and it renders
|
|
@@ -1597,6 +1627,57 @@ identity* changed underneath them.
|
|
|
1597
1627
|
|
|
1598
1628
|
|
|
1599
1629
|
|
|
1630
|
+
---
|
|
1631
|
+
|
|
1632
|
+
<!-- source: en/ui/client-utilities/use-subscription-health.md -->
|
|
1633
|
+
## useSubscriptionHealth
|
|
1634
|
+
|
|
1635
|
+
_Which of an api's calls are currently refused — the state a screen cannot derive from its own hook._
|
|
1636
|
+
|
|
1637
|
+
A refused subscription is **terminal for its transport**. It does not retry, so
|
|
1638
|
+
there is no second error event to react to — and `SubscriptionFailed` presents
|
|
1639
|
+
`data: undefined`, which is the value most reading layers derive `loading` from.
|
|
1640
|
+
A component that branches on `data === undefined` therefore shows a spinner from
|
|
1641
|
+
then on, indefinitely. The state union models this correctly (`failed: true`,
|
|
1642
|
+
`loading: false`); what loses it is the wrapper hook in between, and a wrapper
|
|
1643
|
+
that passes `{ data, loading }` through is the natural shape to write.
|
|
1644
|
+
|
|
1645
|
+
`useSubscriptionHealth` answers the question no single hook can: **is anything on
|
|
1646
|
+
this api currently refused, and what.**
|
|
1647
|
+
|
|
1648
|
+
```tsx
|
|
1649
|
+
import { useSubscriptionHealth } from '@voltro/client'
|
|
1650
|
+
|
|
1651
|
+
const SessionBar = () => {
|
|
1652
|
+
const { healthy, failed } = useSubscriptionHealth('app')
|
|
1653
|
+
if (healthy) return null
|
|
1654
|
+
return <Banner>Not loading: {failed.map((f) => f.tag).join(', ')}</Banner>
|
|
1655
|
+
}
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
A tag enters `failed` when a call for it fails and leaves when a call for the
|
|
1659
|
+
**same tag** succeeds. It is keyed by tag rather than counted, because the
|
|
1660
|
+
question a screen asks is "is this working now" and two failures of one call are
|
|
1661
|
+
one broken thing. `healthy` is the positive form of the same fact, so a component
|
|
1662
|
+
reads `if (!healthy)` instead of `if (failed.length > 0)`.
|
|
1663
|
+
|
|
1664
|
+
Each entry is `{ tag, source, error, at }` — `at` is the timestamp of the
|
|
1665
|
+
failure, so a bar can say *since 12:04* rather than only *broken*.
|
|
1666
|
+
|
|
1667
|
+
## What it does not do
|
|
1668
|
+
|
|
1669
|
+
It is not a retry mechanism and not an error boundary. Recovery is the
|
|
1670
|
+
transport's job: an `Unauthenticated` failure makes the framework re-resolve the
|
|
1671
|
+
credential and rebuild the connection, and a rebuilt client re-subscribes on its
|
|
1672
|
+
own — a failed entry is never carried across the swap, so it is re-attempted
|
|
1673
|
+
rather than restored as broken. Nothing in your app wires that.
|
|
1674
|
+
|
|
1675
|
+
The register is scoped to one api's runtime and is reset when that runtime is
|
|
1676
|
+
rebuilt, deliberately: carrying a failure across a transport swap would report a
|
|
1677
|
+
call as broken that has not been tried since.
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
|
|
1600
1681
|
---
|
|
1601
1682
|
|
|
1602
1683
|
<!-- source: en/ui/client-utilities/use-tracking.md -->
|
|
@@ -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 =
|