@voltro/cli 0.54.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 (173) hide show
  1. package/CHANGELOG.md +639 -2
  2. package/bin/voltro.mjs +24 -0
  3. package/dist/{apiBuild-DTWp0S_q.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-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
  7. package/dist/buildReport-52gHKgfO.js +64 -0
  8. package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
  9. package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
  10. package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
  11. package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
  12. package/dist/codegen-DbH7NbCR.js +2 -0
  13. package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
  14. package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
  15. package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
  16. package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
  17. package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
  18. package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
  19. package/dist/dbCommand-DrycGWWt.js +2 -0
  20. package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
  21. package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
  22. package/dist/doctorCommand-BMWs6aVm.js +2 -0
  23. package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
  24. package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
  25. package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
  26. package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
  27. package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
  28. package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
  29. package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
  30. package/dist/index.js +1 -1
  31. package/dist/{infoCommand-DlYlUPqs.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/interruptedReplace-CwnkBb2X.js +41 -0
  38. package/dist/interruptedReplace-qzmFI020.js +2 -0
  39. package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
  40. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
  41. package/dist/manifestBuild-DIa_s6u0.js +2 -0
  42. package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
  43. package/dist/precompressAssets-YhTi1aWp.js +40 -0
  44. package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
  45. package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
  46. package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
  47. package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
  48. package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
  49. package/dist/serveCommand-BITS8Hpj.js +2 -0
  50. package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
  51. package/dist/serveEntry.js +1 -1
  52. package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
  53. package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
  54. package/dist/startEntry.js +1 -1
  55. package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
  56. package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
  57. package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
  58. package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
  59. package/dist/updateCommand-DsXEAHbd.js +2 -0
  60. package/dist/webDev-C2dRz9s5.js +2 -0
  61. package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
  62. package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
  63. package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
  64. package/package.json +67 -19
  65. package/templates/AGENTS.core.md +20 -1
  66. package/templates/AGENTS.md +21 -2
  67. package/templates/agent-docs/_index.md +1 -1
  68. package/templates/agent-docs/_manifest.json +1 -1
  69. package/templates/agent-docs/authentication.md +49 -6
  70. package/templates/agent-docs/cli.md +216 -11
  71. package/templates/agent-docs/data.md +209 -18
  72. package/templates/agent-docs/database/scaling.md +40 -1
  73. package/templates/agent-docs/deployment.md +53 -1
  74. package/templates/agent-docs/internationalization.md +32 -0
  75. package/templates/agent-docs/local-first-mobile.md +9 -2
  76. package/templates/agent-docs/observability.md +227 -0
  77. package/templates/agent-docs/plugins/billing.md +15 -0
  78. package/templates/agent-docs/plugins/broadcast.md +2 -1
  79. package/templates/agent-docs/plugins/ratelimit.md +6 -1
  80. package/templates/agent-docs/plugins/row-history.md +11 -0
  81. package/templates/agent-docs/plugins.md +65 -8
  82. package/templates/agent-docs/reference.md +5 -3
  83. package/templates/agent-docs/routing.md +18 -0
  84. package/templates/agent-docs/schema-driven-ui.md +125 -0
  85. package/templates/agent-docs/templates/appshells.md +3 -3
  86. package/templates/agent-docs/whats-new.md +408 -106
  87. package/templates/apps/api-ai/package.json +6 -6
  88. package/templates/apps/api-auth/package.json +8 -8
  89. package/templates/apps/api-backend/package.json +7 -7
  90. package/templates/apps/api-backend-deactivation/package.json +7 -7
  91. package/templates/apps/api-backend-mail/package.json +8 -8
  92. package/templates/apps/api-backend-mariadb/package.json +9 -9
  93. package/templates/apps/api-backend-sqlite/package.json +8 -8
  94. package/templates/apps/api-backend-storage/package.json +8 -8
  95. package/templates/apps/api-cms/package.json +9 -9
  96. package/templates/apps/api-collab/package.json +8 -8
  97. package/templates/apps/api-data-advanced/package.json +8 -8
  98. package/templates/apps/api-durable/package.json +8 -8
  99. package/templates/apps/api-feature-flags/package.json +9 -9
  100. package/templates/apps/api-governance/package.json +8 -8
  101. package/templates/apps/api-kv/package.json +8 -8
  102. package/templates/apps/api-moderation/package.json +8 -8
  103. package/templates/apps/api-observability/package.json +8 -8
  104. package/templates/apps/api-ratelimit/package.json +8 -8
  105. package/templates/apps/api-rbac/package.json +8 -8
  106. package/templates/apps/api-rest/package.json +7 -7
  107. package/templates/apps/api-row-history/package.json +8 -8
  108. package/templates/apps/api-saas/package.json +11 -11
  109. package/templates/apps/api-saas-starter/package.json +10 -10
  110. package/templates/apps/api-search/package.json +8 -8
  111. package/templates/apps/api-status/package.json +8 -8
  112. package/templates/apps/api-webhooks/package.json +9 -9
  113. package/templates/apps/changelog/package.json +7 -7
  114. package/templates/apps/changelog/src/pages/[locale]/page.tsx +7 -1
  115. package/templates/apps/edge-functions/package.json +2 -2
  116. package/templates/apps/frontend-admin/package.json +7 -8
  117. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +1 -2
  118. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +1 -2
  119. package/templates/apps/frontend-app/package.json +8 -9
  120. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -2
  121. package/templates/apps/frontend-auth/package.json +7 -8
  122. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +1 -2
  123. package/templates/apps/frontend-blank/package.json +6 -7
  124. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -2
  125. package/templates/apps/frontend-cms/package.json +8 -9
  126. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +1 -2
  127. package/templates/apps/frontend-collab/README.md +7 -2
  128. package/templates/apps/frontend-collab/package.json +9 -10
  129. package/templates/apps/frontend-collab/src/pages/layout.tsx +1 -2
  130. package/templates/apps/frontend-collab/src/pages/page.test.tsx +8 -4
  131. package/templates/apps/frontend-collab/src/pages/page.tsx +10 -5
  132. package/templates/apps/frontend-contact/package.json +7 -7
  133. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +1 -1
  134. package/templates/apps/frontend-dashboard/package.json +6 -7
  135. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +1 -2
  136. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +1 -2
  137. package/templates/apps/frontend-docs/package.json +8 -8
  138. package/templates/apps/frontend-i18n/package.json +6 -6
  139. package/templates/apps/frontend-landing/package.json +7 -7
  140. package/templates/apps/frontend-portal/package.json +7 -8
  141. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +1 -2
  142. package/templates/apps/frontend-saas/package.json +7 -8
  143. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +1 -2
  144. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +1 -2
  145. package/templates/apps/frontend-spa/package.json +6 -7
  146. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -2
  147. package/templates/apps/frontend-ssr/package.json +6 -7
  148. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -2
  149. package/templates/apps/frontend-ssr-api/package.json +7 -8
  150. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -2
  151. package/templates/apps/frontend-static-blog/package.json +8 -8
  152. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +1 -1
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/page.tsx +7 -1
  154. package/templates/apps/frontend-status/package.json +7 -8
  155. package/templates/apps/frontend-status/src/pages/layout.tsx +1 -2
  156. package/templates/apps/mobile-app/package.json +4 -4
  157. package/templates/baselines/compose/docker/api.Dockerfile +61 -5
  158. package/templates/baselines/compose/docker/web.Dockerfile +55 -10
  159. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +61 -5
  160. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +55 -10
  161. package/dist/apiBuild-CeUN55uk.js +0 -2
  162. package/dist/codegen-DjgxEOnD.js +0 -2
  163. package/dist/dbCommand-CSFWs9ev.js +0 -2
  164. package/dist/doctorCommand-J3qu4E0Y.js +0 -2
  165. package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
  166. package/dist/inspect-Bd8-9wsi.js +0 -1193
  167. package/dist/inspect-CuoDInfZ.js +0 -2
  168. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  169. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  170. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  171. package/dist/serveCommand-BiPe8BJm.js +0 -2
  172. package/dist/updateCommand-CIoVDKnj.js +0 -2
  173. package/dist/webDev-DlvZO30c.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.
@@ -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
@@ -583,16 +583,45 @@ notificationsPlugin({ name: 'ops' }) // notifications#ops
583
583
  notificationsPlugin({ alias: 'alerts', name: 'ops' }) // alerts#ops.inbox
584
584
  ```
585
585
 
586
- `alias` exists to escape a tag collision, which is fatal at codegen. Two costs
587
- are worth knowing before you reach for it:
586
+ `alias` exists to escape a tag collision, which is fatal at codegen. One cost is
587
+ worth knowing before you reach for it:
588
588
 
589
- - the local and cloud dashboards fetch a plugin's inspect panel at its DEFAULT
590
- slug, so an aliased plugin keeps serving its inspect endpoints while its
591
- dashboard panel stops resolving;
592
589
  - the plugin-migration ledger key is `<plugin-alias>__<migration.id>`, so
593
590
  aliasing a plugin that ships `extendSchema.migrations` makes its already-applied
594
591
  migrations look unapplied. Choose the alias before first boot, not after.
595
592
 
593
+ The dashboard panel is NOT one of those costs. The dashboards fetch a plugin's
594
+ inspect panel at its DEFAULT slug with the path compiled in — they live in other
595
+ repositories and cannot follow an alias — so a plugin's inspect endpoints keep a
596
+ mount under its canonical name alongside the aliased one. Aliasing does not take
597
+ the panel away.
598
+
599
+ The exception is `name`, not `alias`: two installs of one plugin are two panels
600
+ with one canonical name, so they get no shared mount. Showing either one under
601
+ it would hand a dashboard the other install's rows under a name that looks
602
+ right. Each install is reachable at its own slug, which `/_voltro/inspect/plugins`
603
+ reports for every plugin as `inspectSlug` (alongside `baseName`, the canonical
604
+ package name before any alias).
605
+
606
+ The plugin's own hooks are not one of those costs either — they follow the alias.
607
+ `useInbox()`, `useUpload()`, `useComments()` and the rest resolve their wire tag
608
+ at call time from the namespace your app installed the plugin under, so
609
+ `notificationsPlugin({ alias: 'alerts' })` makes `useInbox()` subscribe to
610
+ `alerts.inbox` with no change at the call site. Two pieces make that work and
611
+ both are automatic:
612
+
613
+ - `voltro dev` writes a `registerPluginAliases({ … })` declaration into
614
+ `rpcGroup.generated.ts` — the module the web client already loads — mapping
615
+ each installed plugin's canonical package name to the namespace it answers to;
616
+ - each plugin's hooks call `pluginTag(baseName, route)` from `@voltro/protocol`
617
+ instead of spelling the namespace.
618
+
619
+ If you install the SAME plugin twice with `name` (two instances) and no install
620
+ is the un-suffixed primary, `pluginTag` refuses rather than guessing which one a
621
+ hook means — call the route by its full tag
622
+ (`useSubscription(api, 'notifications#ops.inbox')`) to say which install you
623
+ want.
624
+
596
625
  ### `tables: false` — keeping your own tables
597
626
 
598
627
  Plugins whose tables carry no authorization or safety decision accept
@@ -610,12 +639,21 @@ writes to those tables BY NAME, so you take over declaring each one with the
610
639
  shape the package exports, and a missing or mis-shaped table fails at the first
611
640
  write rather than at boot.
612
641
 
613
- It is deliberately NOT offered on plugins whose tables carry a guarantee — the
642
+ It is deliberately NOT offered on plugins whose tables carry a guarantee. The
614
643
  SAML assertion replay cache, SCIM provisioning state, billing's usage counters,
615
- cdc-out's delivery outbox, the governance consent ledger, search's tenant-scoped
616
- index rows. A `tables: false` there would disable a security or correctness
644
+ cdc-out's delivery outbox, the governance consent ledger and search's
645
+ tenant-scoped index rows are examples, **not the whole list** read "the plugin
646
+ does not offer `tables`" as the answer, never "so every plugin not named here
647
+ would let me". A `tables: false` there would disable a security or correctness
617
648
  decision with no signal to the app that it now owns it.
618
649
 
650
+ `@voltro/plugin-storage` is the case people expect to find in that list.
651
+ `_voltro_storage_grants` decides who may read and write an object, so it belongs
652
+ there — but the option would not reach it in any case: storage's four tables are
653
+ contributed as framework tables, not through `extendSchema`, so there is nothing
654
+ for a `tables: false` to switch off. Owning the grant table would need a
655
+ grant-store seam, which does not exist yet.
656
+
619
657
  Boot fails with a clear error on tag collisions (between two plugins, or
620
658
  with a user-authored tag).
621
659
 
@@ -1092,6 +1130,25 @@ framework's ALREADY-OPEN handles so it never rebuilds them:
1092
1130
  the SAME pool the app's store uses. Run raw SQL through it instead of
1093
1131
  standing up your OWN `ManagedRuntime` + pool from env. `undefined` on
1094
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
+
1095
1152
  - **`ctx.scheduleCoordinated(name, intervalMs, effect)`** — run a periodic
1096
1153
  task on ONLY ONE replica per tick, cluster-coordinated via the same
1097
1154
  claim-table exactly-once gate the cron scheduler uses. Replaces the
@@ -61,6 +61,8 @@ these before hand-rolling a form, a table, or a picker** — full guide in
61
61
  | Hook | Purpose |
62
62
  |---|---|
63
63
  | [`useFormBinding`](/docs/ui/forms-and-tables) | Bind a form to a MUTATION — fields + validation from its input Schema; a server `ValidationError({ field })` routes to that field. |
64
+ | [`useFormField`](/docs/ui/forms-and-tables) | One field of the enclosing binding — value, blur, the display-gated error, a11y props. Re-renders that field alone. |
65
+ | [`useFormBindingContext`](/docs/ui/forms-and-tables) | The binding a `<FormBindingProvider>` (or `<AutoForm>`) mounted above — for a widget kit that needs the form itself, not one field. |
64
66
  | [`useDataTable`](/docs/ui/forms-and-tables) | Bind a table to a QUERY — live rows, columns derived from the output Schema, sort/filter/pagination. |
65
67
  | [`useQueryFilters`](/docs/ui/forms-and-tables) | Filter controls derived from a query's INPUT Schema (the read-side mirror of a form). |
66
68
  | [`useQueryField`](/docs/ui/forms-and-tables) | Query-bound picker — a debounced search term drives a live subscription. |
@@ -229,9 +231,9 @@ type TeamsState = SubscriptionState<ReadonlyArray<Team>> & { readonly canEdit: b
229
231
  ```
230
232
 
231
233
  `loading` means **no data has arrived yet**, not "the subscription is still
232
- warming up". A cold-start failure leaves `loading` true and sets `error`, so a
233
- component branching on `loading` alone renders a skeleton forever — check
234
- `error`.
234
+ warming up". A cold-start failure is its own state `loading: false`,
235
+ `failed: true`, `error` non-optional — so branching on `loading` alone is safe;
236
+ render the failure off `failed`.
235
237
 
236
238
  Use `{ skip }` to defer until inputs are ready:
237
239
 
@@ -207,6 +207,24 @@ src/pages/users/new/page.tsx # /users/new → wins (static beats dynamic)
207
207
  src/pages/[...rest]/page.tsx # everything else
208
208
  ```
209
209
 
210
+ ## Development mounts in React `StrictMode`
211
+
212
+ The client entry wraps the tree in `StrictMode`, so **in development every
213
+ effect runs twice, with a real unmount in between**. That is the point — it
214
+ surfaces effects that are not safe to re-run — but it has one consequence
215
+ worth stating outright, because it is expensive to rediscover:
216
+
217
+ **An effect that keys off "have I mounted before?" fires on the second mount.**
218
+ A deployment measured this as a picker that cleared its own just-loaded value:
219
+ a "when the dependency changes, clear the selection" effect built on a
220
+ mount-counting ref saw the second mount as a change, and an edit form opened
221
+ with an empty required field and a red message while the record had the value.
222
+ Visible only in development, which is exactly where it reads as a data bug.
223
+
224
+ The rule that survives the double mount: **compare VALUES, not runs.** A reset
225
+ that fires because "this is not the first run" is a reset waiting for the next
226
+ remount; one that fires because the dependency actually differs is not.
227
+
210
228
  ## Query strings
211
229
 
212
230
  Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a **`searchParams` schema export**, the same page-export convention as `meta`, `loader`, and `renderMode`:
@@ -257,6 +257,30 @@ const Input = Schema.Struct({
257
257
  )
258
258
  ```
259
259
 
260
+ **The ids, and the shapes worth knowing.** `required`, `minLength {min}`,
261
+ `maxLength {max}`, `betweenLength {min,max}`, `exactLength {amount}`,
262
+ `pattern`, `invalidEmail` / `invalidUrl` / `invalidUuid`, `minValue {min}`,
263
+ `maxValue {max}`, `minDate {min}` / `maxDate {max}`, `minItems` / `maxItems`,
264
+ `integer`, `invalid`, `checking`, `invalidFileType` / `fileTooLarge`.
265
+
266
+ Three of those exist because the generic answer is worse at the point of use:
267
+
268
+ - **Two length bounds on one field are ONE statement.** `minLength(2)` +
269
+ `maxLength(50)` produce `betweenLength {min,max}` — not "at least 2" for a
270
+ field whose rule is "between 2 and 50" — and `length(4)` produces
271
+ `exactLength {amount}`.
272
+ - **A declared `format` names the rule.** A regex never does: "Invalid format"
273
+ beside an email box tells nobody anything. Annotate the format and the id
274
+ gets specific — `Schema.String.pipe(Schema.pattern(EMAIL))
275
+ .annotations({ jsonSchema: { format: 'email' } })` → `validation.invalidEmail`.
276
+ - **A date bound is not a number bound.** `minDate` / `maxDate` rather than
277
+ `minValue` reading "must be at least 2026-01-01".
278
+
279
+ **Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }`
280
+ (alongside `min`/`max`) because that is the parameter an i18n layer selects a
281
+ plural form on — i18next keys pluralisation on a parameter named exactly
282
+ `count`, so a message carrying only `{min}` cannot be pluralised at all.
283
+
260
284
  **Server-side rules route to their field too.** An executor raises a typed
261
285
  field error through the always-present `ctx.validation` — no declaration
262
286
  needed, `ValidationError` is auto-merged into every mutation's and action's
@@ -383,6 +407,61 @@ const form = useFormBinding('app', 'tasks.create', {
383
407
  holds SPA navigations (Back button included) and arms the native
384
408
  `beforeunload` prompt — see the routing docs.
385
409
 
410
+ ### Rich text — and who sanitizes it
411
+
412
+ A rich-text field is `RichTextDocument`. Use it in the mutation input and the
413
+ form renders an editor with no further annotation:
414
+
415
+ ```ts
416
+ import { RichTextDocument } from '@voltro/web'
417
+
418
+ const ArticleUpdateInput = Schema.Struct({
419
+ id: Schema.String,
420
+ body: RichTextDocument,
421
+ })
422
+ ```
423
+
424
+ Display it with `<RichTextView doc={article.body} />`.
425
+
426
+ **The contract, because "who sanitizes" is the whole question.** The value is
427
+ **not an HTML string** — it is a closed document tree with a fixed set of node
428
+ types. There is no `html` node, no raw-markup escape hatch, no attribute bag,
429
+ so there is nothing to sanitize: anything that is not one of the declared nodes
430
+ simply fails to decode.
431
+
432
+ That makes the **`Schema` decode the boundary** — the server's existing,
433
+ non-bypassable input check, the same one every mutation input already passes
434
+ through. The guarantee is therefore not "somebody remembered to sanitize this
435
+ one"; it is that a document which reached your database is one of these shapes.
436
+
437
+ The rest follows from that:
438
+
439
+ - **A link's `href` is the one field pointing outward, and it is allowlisted**:
440
+ `http(s)`, `mailto:`, a `#fragment`, a `/path`. Nothing else — `javascript:`
441
+ and `data:` are refused by the decode, and control characters/whitespace are
442
+ stripped before the check, because `java\tscript:` navigates exactly like
443
+ `javascript:`.
444
+ - **Client-side sanitizing is not a security boundary and is not treated as
445
+ one.** The widget's parser runs in the browser for the editing experience;
446
+ the browser is where an attacker sits, so every property it maintains is
447
+ re-established by the decode on the server.
448
+ - **Rendering never uses `dangerouslySetInnerHTML`.** `<RichTextView>` maps
449
+ nodes to React elements and text to React children, so markup someone typed
450
+ into the box is markup the reader SEES. It also drops an href that would not
451
+ survive a decode — for the value that never went through one.
452
+
453
+ **The built-in editor** is a `<textarea>` over a small, closed markdown subset:
454
+ headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, `-` lists,
455
+ `>` quotes and fenced code. Everything it does not recognise stays literal text.
456
+ That is also what makes the field work with JavaScript off — the textarea posts
457
+ source, `/form/*` parses it, the same decode validates it. Register your own
458
+ `rich-text` widget (rung 2) for a WYSIWYG; the stored value shape is unchanged.
459
+
460
+ **Not the collaborative case.** Concurrent, multi-writer editing is
461
+ `crdtDoc()` + `useCrdtEditor` (`@voltro/local-first`) — a CRDT bytes column, a
462
+ sync lane, Tiptap. This is the single-editor field: one column, one writer,
463
+ ordinary JSON your server can validate, index and diff.
464
+
386
465
  **Testing.** `renderFormBinding` (from `@voltro/testing/client`) drives the
387
466
  REAL binding against a fake api — fill, blur, submit, read the visible
388
467
  errors; a mutation handler that throws `ValidationError({ field })`
@@ -399,6 +478,52 @@ expect(form.errors()['email']).toBe('validation.emailTaken')
399
478
 
400
479
  Runs under jsdom (`// @vitest-environment jsdom`).
401
480
 
481
+ ### What submit does with a failure, and what never reaches the wire
482
+
483
+ **`submit()` does not reject.** A form calls it from an `onSubmit` handler that
484
+ cannot await it, so a rejection has nowhere to go but the console — the form
485
+ sits there looking saved while the failure is invisible. It resolves
486
+ `undefined` instead, and the failure is state: field-routable errors land on
487
+ their field, everything else in `state.submitError`, with an optional
488
+ `onError` for a toast. That covers a composed `onSubmit` too — a follow-up
489
+ write failing on its OWN mutation handle is a failure the binding never saw
490
+ before, and it is the common shape (create the row, then its first child).
491
+
492
+ **Only declared keys are sent.** A form almost always carries more than the
493
+ mutation declares — a display toggle, a repeat control, a file held before
494
+ upload — and the server has refused undeclared input fields since 0.37. The
495
+ binding restricts the payload to the keys the input schema declares, which is
496
+ the rule the no-JS path already followed (`unknown keys are dropped`), so the
497
+ two submit paths agree. In development it warns once, naming what it dropped,
498
+ because a genuinely misplaced field should still be visible. `toInput` remains
499
+ the place to say what the write actually takes.
500
+
501
+ **`setValue` with an unchanged value is a no-op.** Every React state source is
502
+ expected to behave that way, and this one did not: each call produced a fresh
503
+ `values` object, so an effect depending on `values` that re-set a field to the
504
+ value it already held never settled.
505
+
506
+ A widget kit that needs the whole form rather than one field reads it with
507
+ `useFormBindingContext()` — the same provider, one level up.
508
+
509
+ **Server and browser derive the same form.** A page rendered on the server
510
+ resolves the mutation's input schema exactly as the browser will, so field
511
+ lists, labels and required marks match and hydration holds. (`voltro dev` and
512
+ `voltro start` both hand the descriptors over before rendering.)
513
+
514
+ ```tsx
515
+ const form = useFormBinding('app', 'employees.update', {
516
+ toInput: (values) => ({ id, ...employeePatch(values) }),
517
+ onError: (error) => toast.error(String(error)), // optional; state.submitError always carries it
518
+ })
519
+
520
+ // A field component anywhere below — no binding threaded through as a prop
521
+ const City = () => {
522
+ const f = useFormField('address.city')
523
+ return <input value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} />
524
+ }
525
+ ```
526
+
402
527
  ### Forms without JavaScript
403
528
 
404
529
  On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
@@ -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 =