@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.
- package/CHANGELOG.md +639 -2
- package/bin/voltro.mjs +24 -0
- package/dist/{apiBuild-DTWp0S_q.js → apiBuild-Bdaetr37.js} +119 -88
- package/dist/apiBuild-Vw1figjO.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D4ygSbnV.js → build-CI36wL4R.js} +339 -314
- package/dist/buildReport-52gHKgfO.js +64 -0
- package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-CAwFXrxA.js} +6 -6
- package/dist/{checkCommand-L7DTlpIF.js → checkCommand-D0QV_zM_.js} +1 -1
- package/dist/{clusterCmd-DrVFCzSj.js → clusterCmd-CdLB1GkT.js} +1 -1
- package/dist/{codegen-DSLM8Su9.js → codegen-Bth5lUTU.js} +76 -64
- package/dist/codegen-DbH7NbCR.js +2 -0
- package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-CidbQzbv.js} +15 -14
- package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-BlQPfjzA.js} +277 -0
- package/dist/{commands-6Kzi92Np.js → commands-CWjfThXv.js} +35 -35
- package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-BekcY5Ls.js} +3 -3
- package/dist/{dataCommand-DYzW8vkv.js → dataCommand-2pccgbIy.js} +267 -195
- package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DpK_vQET.js} +457 -441
- package/dist/dbCommand-DrycGWWt.js +2 -0
- package/dist/{dev-cKUiZZsB.js → dev-B9Gz0k85.js} +1 -1
- package/dist/{dev-CmuvUKRq.js → dev-Dw263KPu.js} +2598 -2452
- package/dist/doctorCommand-BMWs6aVm.js +2 -0
- package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-aR_bFmIi.js} +353 -251
- package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-eXTQMbHU.js} +1 -1
- package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-CTmiQvwa.js} +1 -1
- package/dist/{envCommand-Cyynmcfa.js → envCommand-BDUgV7EM.js} +12 -12
- package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-YV8qW1LU.js} +2 -2
- package/dist/frameworkTableAssembly-CGNC0qr7.js +2 -0
- package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-DNOFXfEQ.js} +102 -100
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DlYlUPqs.js → infoCommand-BjVXpMlP.js} +1 -1
- package/dist/inspect-CNYvNXPU.js +1484 -0
- package/dist/inspect-S2rWy1Ys.js +2 -0
- package/dist/{inspectCmd-niF97fAq.js → inspectCmd-CP-G0sVK.js} +1 -1
- package/dist/{inspectFetch-EMuhTG_9.js → inspectFetch-BU1NyzxV.js} +36 -24
- package/dist/{inspectMetrics-CGF94puw.js → inspectMetrics-BY0Sjb2F.js} +19 -19
- package/dist/interruptedReplace-CwnkBb2X.js +41 -0
- package/dist/interruptedReplace-qzmFI020.js +2 -0
- package/dist/{logsCmd-B6oNsfaZ.js → logsCmd-BU8uCdys.js} +1 -1
- package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-CEkjfpwc.js} +1 -1
- package/dist/manifestBuild-DIa_s6u0.js +2 -0
- package/dist/{migrate-BK_Bbx-_.js → migrate-SICulyz1.js} +2 -2
- package/dist/precompressAssets-YhTi1aWp.js +40 -0
- package/dist/{probeCommand-_C0YU207.js → probeCommand-6HxEkNDG.js} +2 -2
- package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-DgYMc09E.js} +1 -1
- package/dist/{scheduleCmd-DQRu6BZC.js → scheduleCmd-DYBUfo_T.js} +1 -1
- package/dist/{sdkgen-CDGHQUFj.js → sdkgen-PY-umd6O.js} +1 -1
- package/dist/{seedRunner-Dgsiwk_e.js → seedRunner-DISBKow-.js} +16 -16
- package/dist/serveCommand-BITS8Hpj.js +2 -0
- package/dist/{serveCommand-Bje09q1v.js → serveCommand-DIJ3ma76.js} +951 -909
- package/dist/serveEntry.js +1 -1
- package/dist/{start-Clz-1BHB.js → start-B9NGB8gn.js} +627 -588
- package/dist/{start-B0bnJgxI.js → start-BFQQkL1i.js} +1 -1
- package/dist/startEntry.js +1 -1
- package/dist/staticCachePolicy-CIyj6DbS.js +15 -0
- package/dist/{test-D_kW4KMj.js → test-jipIQ5Mx.js} +1 -1
- package/dist/{tracesCmd-DgtgOUdi.js → tracesCmd-BWYDqMy6.js} +1 -1
- package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C9n_Z_oG.js} +8 -2
- package/dist/updateCommand-DsXEAHbd.js +2 -0
- package/dist/webDev-C2dRz9s5.js +2 -0
- package/dist/{webDev-DSI9SOhs.js → webDev-C53hJdcL.js} +1265 -1288
- package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-uuPu8qQX.js} +1 -1
- package/dist/{workflowsCmd-BGF-mRZ5.js → workflowsCmd-g-DNpaUc.js} +1 -1
- package/package.json +67 -19
- package/templates/AGENTS.core.md +20 -1
- package/templates/AGENTS.md +21 -2
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/authentication.md +49 -6
- package/templates/agent-docs/cli.md +216 -11
- package/templates/agent-docs/data.md +209 -18
- package/templates/agent-docs/database/scaling.md +40 -1
- package/templates/agent-docs/deployment.md +53 -1
- package/templates/agent-docs/internationalization.md +32 -0
- package/templates/agent-docs/local-first-mobile.md +9 -2
- package/templates/agent-docs/observability.md +227 -0
- package/templates/agent-docs/plugins/billing.md +15 -0
- package/templates/agent-docs/plugins/broadcast.md +2 -1
- package/templates/agent-docs/plugins/ratelimit.md +6 -1
- package/templates/agent-docs/plugins/row-history.md +11 -0
- package/templates/agent-docs/plugins.md +65 -8
- package/templates/agent-docs/reference.md +5 -3
- package/templates/agent-docs/routing.md +18 -0
- package/templates/agent-docs/schema-driven-ui.md +125 -0
- package/templates/agent-docs/templates/appshells.md +3 -3
- package/templates/agent-docs/whats-new.md +408 -106
- 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-CeUN55uk.js +0 -2
- package/dist/codegen-DjgxEOnD.js +0 -2
- package/dist/dbCommand-CSFWs9ev.js +0 -2
- package/dist/doctorCommand-J3qu4E0Y.js +0 -2
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
- package/dist/inspect-Bd8-9wsi.js +0 -1193
- package/dist/inspect-CuoDInfZ.js +0 -2
- package/dist/interruptedReplace-C3O3M1MM.js +0 -28
- package/dist/interruptedReplace-CvmiAM9K.js +0 -2
- package/dist/manifestBuild-C4-J1-m_.js +0 -2
- package/dist/serveCommand-BiPe8BJm.js +0 -2
- package/dist/updateCommand-CIoVDKnj.js +0 -2
- 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.
|
|
587
|
-
|
|
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
|
|
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
|
|
616
|
-
index rows
|
|
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
|
|
233
|
-
|
|
234
|
-
`
|
|
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 =
|