@voltro/cli 0.54.0 → 0.55.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 (119) hide show
  1. package/CHANGELOG.md +142 -2
  2. package/dist/{apiBuild-DTWp0S_q.js → apiBuild-CMvLJM_K.js} +2 -2
  3. package/dist/apiBuild-Cl0IDx8c.js +2 -0
  4. package/dist/bin.js +1 -1
  5. package/dist/{build-D4ygSbnV.js → build-S0QOzqPT.js} +10 -10
  6. package/dist/{checkCommand-L7DTlpIF.js → checkCommand-DNkY5kwF.js} +1 -1
  7. package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-fbj9GDjN.js} +5 -5
  8. package/dist/codegen-CN6vMM4J.js +2 -0
  9. package/dist/{codegen-DSLM8Su9.js → codegen-SIepQtUl.js} +74 -63
  10. package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-3TDJezom.js} +10 -9
  11. package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-C2zxZUIw.js} +55 -0
  12. package/dist/{commands-6Kzi92Np.js → commands-BBYJ7Q3B.js} +24 -24
  13. package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-D2kmyCLL.js} +3 -3
  14. package/dist/{dataCommand-DYzW8vkv.js → dataCommand-BEPPQiTl.js} +266 -194
  15. package/dist/dbCommand-BTyBGhIA.js +2 -0
  16. package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DZTmOFT4.js} +1 -1
  17. package/dist/{dev-CmuvUKRq.js → dev-Ca_A_S9v.js} +2182 -2157
  18. package/dist/{dev-cKUiZZsB.js → dev-DfVZaoys.js} +1 -1
  19. package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-CGZJK_4o.js} +15 -15
  20. package/dist/doctorCommand-djmqEcDC.js +2 -0
  21. package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-DY2rYpTa.js} +1 -1
  22. package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-BoCqZsgp.js} +1 -1
  23. package/dist/{envCommand-Cyynmcfa.js → envCommand-Bxy2fOjc.js} +2 -2
  24. package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-BsbZ-XDg.js} +2 -2
  25. package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
  26. package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-Do-cf6RJ.js} +92 -98
  27. package/dist/index.js +1 -1
  28. package/dist/{infoCommand-DlYlUPqs.js → infoCommand-EmM3jPKD.js} +1 -1
  29. package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
  30. package/dist/inspect-DGJwpOAb.js +2 -0
  31. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  32. package/dist/interruptedReplace-qzmFI020.js +2 -0
  33. package/dist/manifestBuild-CJ2zvPvT.js +2 -0
  34. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
  35. package/dist/{migrate-BK_Bbx-_.js → migrate-CGFZS-1a.js} +2 -2
  36. package/dist/{probeCommand-_C0YU207.js → probeCommand-Bs3iVBSL.js} +1 -1
  37. package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-C1BTpHGQ.js} +1 -1
  38. package/dist/{sdkgen-CDGHQUFj.js → sdkgen-CXMwLg9n.js} +1 -1
  39. package/dist/{serveCommand-Bje09q1v.js → serveCommand-C7IrCD58.js} +840 -838
  40. package/dist/serveCommand-Cjt5S9hD.js +2 -0
  41. package/dist/serveEntry.js +1 -1
  42. package/dist/{start-B0bnJgxI.js → start-DH7cat4-.js} +1 -1
  43. package/dist/{start-Clz-1BHB.js → start-EOV7s1NZ.js} +486 -476
  44. package/dist/startEntry.js +1 -1
  45. package/dist/{test-D_kW4KMj.js → test-DO27-x2P.js} +1 -1
  46. package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C_jN1w18.js} +1 -1
  47. package/dist/updateCommand-nnFjDbl4.js +2 -0
  48. package/dist/{webDev-DlvZO30c.js → webDev-1XpVnYkW.js} +1 -1
  49. package/dist/{webDev-DSI9SOhs.js → webDev-B7vNj4Bq.js} +502 -494
  50. package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-B1LVcyO3.js} +1 -1
  51. package/package.json +31 -19
  52. package/templates/AGENTS.md +1 -1
  53. package/templates/agent-docs/_index.md +1 -1
  54. package/templates/agent-docs/authentication.md +49 -6
  55. package/templates/agent-docs/cli.md +94 -11
  56. package/templates/agent-docs/data.md +64 -13
  57. package/templates/agent-docs/plugins.md +46 -8
  58. package/templates/agent-docs/reference.md +5 -3
  59. package/templates/agent-docs/routing.md +18 -0
  60. package/templates/agent-docs/schema-driven-ui.md +125 -0
  61. package/templates/agent-docs/whats-new.md +76 -133
  62. package/templates/apps/api-ai/package.json +6 -6
  63. package/templates/apps/api-auth/package.json +8 -8
  64. package/templates/apps/api-backend/package.json +7 -7
  65. package/templates/apps/api-backend-deactivation/package.json +7 -7
  66. package/templates/apps/api-backend-mail/package.json +8 -8
  67. package/templates/apps/api-backend-mariadb/package.json +9 -9
  68. package/templates/apps/api-backend-sqlite/package.json +8 -8
  69. package/templates/apps/api-backend-storage/package.json +8 -8
  70. package/templates/apps/api-cms/package.json +9 -9
  71. package/templates/apps/api-collab/package.json +8 -8
  72. package/templates/apps/api-data-advanced/package.json +8 -8
  73. package/templates/apps/api-durable/package.json +8 -8
  74. package/templates/apps/api-feature-flags/package.json +9 -9
  75. package/templates/apps/api-governance/package.json +8 -8
  76. package/templates/apps/api-kv/package.json +8 -8
  77. package/templates/apps/api-moderation/package.json +8 -8
  78. package/templates/apps/api-observability/package.json +8 -8
  79. package/templates/apps/api-ratelimit/package.json +8 -8
  80. package/templates/apps/api-rbac/package.json +8 -8
  81. package/templates/apps/api-rest/package.json +7 -7
  82. package/templates/apps/api-row-history/package.json +8 -8
  83. package/templates/apps/api-saas/package.json +11 -11
  84. package/templates/apps/api-saas-starter/package.json +10 -10
  85. package/templates/apps/api-search/package.json +8 -8
  86. package/templates/apps/api-status/package.json +8 -8
  87. package/templates/apps/api-webhooks/package.json +9 -9
  88. package/templates/apps/changelog/package.json +7 -7
  89. package/templates/apps/edge-functions/package.json +2 -2
  90. package/templates/apps/frontend-admin/package.json +8 -8
  91. package/templates/apps/frontend-app/package.json +9 -9
  92. package/templates/apps/frontend-auth/package.json +8 -8
  93. package/templates/apps/frontend-blank/package.json +7 -7
  94. package/templates/apps/frontend-cms/package.json +9 -9
  95. package/templates/apps/frontend-collab/package.json +10 -10
  96. package/templates/apps/frontend-contact/package.json +7 -7
  97. package/templates/apps/frontend-dashboard/package.json +7 -7
  98. package/templates/apps/frontend-docs/package.json +8 -8
  99. package/templates/apps/frontend-i18n/package.json +6 -6
  100. package/templates/apps/frontend-landing/package.json +7 -7
  101. package/templates/apps/frontend-portal/package.json +8 -8
  102. package/templates/apps/frontend-saas/package.json +8 -8
  103. package/templates/apps/frontend-spa/package.json +7 -7
  104. package/templates/apps/frontend-ssr/package.json +7 -7
  105. package/templates/apps/frontend-ssr-api/package.json +8 -8
  106. package/templates/apps/frontend-static-blog/package.json +8 -8
  107. package/templates/apps/frontend-status/package.json +8 -8
  108. package/templates/apps/mobile-app/package.json +4 -4
  109. package/dist/apiBuild-CeUN55uk.js +0 -2
  110. package/dist/codegen-DjgxEOnD.js +0 -2
  111. package/dist/dbCommand-CSFWs9ev.js +0 -2
  112. package/dist/doctorCommand-J3qu4E0Y.js +0 -2
  113. package/dist/frameworkTableAssembly-IPD1pUnZ.js +0 -2
  114. package/dist/inspect-CuoDInfZ.js +0 -2
  115. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  116. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  117. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  118. package/dist/serveCommand-BiPe8BJm.js +0 -2
  119. package/dist/updateCommand-CIoVDKnj.js +0 -2
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
222
222
  ...t === void 0 ? {} : { payload: t }
223
223
  };
224
224
  }, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
225
- let { walk: t, loadDiscovered: n } = await import("./dev-cKUiZZsB.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
225
+ let { walk: t, loadDiscovered: n } = await import("./dev-DfVZaoys.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
226
226
  return r((await n(await t(e))).events.map((e) => ({
227
227
  file: e.file,
228
228
  descriptor: e.descriptor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -754,6 +754,18 @@
754
754
  "title": "WidgetKind gained 'array' and 'reference' — total widget registries need two new entries",
755
755
  "kind": "manual"
756
756
  },
757
+ {
758
+ "version": "0.55.0",
759
+ "id": "0.55.0/01_target-relations-declare-columns",
760
+ "title": "A target's `relations:` names the junction's two columns now, not just the table",
761
+ "kind": "manual"
762
+ },
763
+ {
764
+ "version": "0.55.0",
765
+ "id": "0.55.0/02_widget-kind-gained-rich-text",
766
+ "title": "WidgetKind gained 'rich-text' — total widget registries need one new entry",
767
+ "kind": "manual"
768
+ },
757
769
  {
758
770
  "version": "0.6.0",
759
771
  "id": "0.6.0/01_no-dev-session-secret",
@@ -836,24 +848,24 @@
836
848
  "@effect/platform-node": "^0.108.0",
837
849
  "@effect/sql": "^0.52.0",
838
850
  "@effect/workflow": "^0.19.0",
839
- "@voltro/ai": "0.54.0",
840
- "@voltro/cache": "0.54.0",
841
- "@voltro/client": "0.54.0",
842
- "@voltro/content": "0.54.0",
843
- "@voltro/data-transfer": "0.54.0",
844
- "@voltro/database": "0.54.0",
845
- "@voltro/env": "0.54.0",
846
- "@voltro/kv": "0.54.0",
847
- "@voltro/logger": "0.54.0",
848
- "@voltro/plugin-auth": "0.54.0",
849
- "@voltro/plugin-broadcast": "0.54.0",
850
- "@voltro/plugin-mail": "0.54.0",
851
- "@voltro/plugin-storage": "0.54.0",
852
- "@voltro/plugin-webhooks": "0.54.0",
853
- "@voltro/protocol": "0.54.0",
854
- "@voltro/runtime": "0.54.0",
855
- "@voltro/serverless": "0.54.0",
856
- "@voltro/workflow": "0.54.0",
851
+ "@voltro/ai": "0.55.0",
852
+ "@voltro/cache": "0.55.0",
853
+ "@voltro/client": "0.55.0",
854
+ "@voltro/content": "0.55.0",
855
+ "@voltro/data-transfer": "0.55.0",
856
+ "@voltro/database": "0.55.0",
857
+ "@voltro/env": "0.55.0",
858
+ "@voltro/kv": "0.55.0",
859
+ "@voltro/logger": "0.55.0",
860
+ "@voltro/plugin-auth": "0.55.0",
861
+ "@voltro/plugin-broadcast": "0.55.0",
862
+ "@voltro/plugin-mail": "0.55.0",
863
+ "@voltro/plugin-storage": "0.55.0",
864
+ "@voltro/plugin-webhooks": "0.55.0",
865
+ "@voltro/protocol": "0.55.0",
866
+ "@voltro/runtime": "0.55.0",
867
+ "@voltro/serverless": "0.55.0",
868
+ "@voltro/workflow": "0.55.0",
857
869
  "chokidar": "^5.0.0",
858
870
  "ioredis": "^5.11.1",
859
871
  "tinyglobby": "^0.2.17",
@@ -730,7 +730,7 @@ each plugin's own README.
730
730
 
731
731
  | Topic | Open | Summary |
732
732
  |---|---|---|
733
- | **What's new in 0.54.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
733
+ | **What's new in 0.55.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
734
734
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
735
735
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
736
736
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.54.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.55.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -2617,10 +2617,14 @@ Optional, one line, and it buys back a feature the filter otherwise switches
2617
2617
  off for the whole app:
2618
2618
 
2619
2619
  ```ts
2620
+ const OWNED = new Set(['documents', 'comments'])
2621
+
2620
2622
  setRowFilter({
2621
2623
  load,
2622
- predicate,
2623
- tables: ['bookmarks', 'recentSearches', 'todoSchedules', 'todoTags'],
2624
+ predicate: (ctx, table) => (OWNED.has(table) ? eq('ownerId', ctx.userId) : undefined),
2625
+ // Derived from the same set the predicate reads. Two hand-kept lists is the
2626
+ // shape in which a table lands in exactly one of them.
2627
+ tables: [...OWNED],
2624
2628
  })
2625
2629
  ```
2626
2630
 
@@ -2629,10 +2633,49 @@ is excluded for a subscription whose row set is re-resolved per delivery —
2629
2633
  replaying deltas could serve rows the subject has since lost. Without a
2630
2634
  declaration the framework cannot tell which tables your predicate may reach, so
2631
2635
  it excludes them all: one registration disables cheap reconnects for every
2632
- subscription in the process. A deployment measured a filter over 4 tables
2633
- costing it on all 173 of their query descriptors, 55 of whose source tables the
2634
- filter never touches. With the declaration, only subscriptions on the listed
2635
- tables are excluded.
2636
+ subscription in the process, including every one reading a table your predicate
2637
+ never returns anything for. With the declaration, only subscriptions on the
2638
+ listed tables are excluded.
2639
+
2640
+ **What it does not buy, and this bounds the whole feature.** A subscription only
2641
+ has a delta chain when its executor returns a **descriptor**. One that returns a
2642
+ mapped value or a page envelope —
2643
+
2644
+ ```ts
2645
+ export default async ({ database }) => {
2646
+ const rows = await database.notifications.where(...)
2647
+ return { notifications: rows.map(toDto), hasMore: rows.length === 20 }
2648
+ }
2649
+ ```
2650
+
2651
+ — re-runs an opaque handler and emits snapshots, with or without a filter. So
2652
+ the count `tables:` gives back is the count of descriptor-returning
2653
+ subscriptions, not the number of queries in your app. Declare it anyway (it
2654
+ costs nothing, and it applies the moment such a query returns the builder), but
2655
+ measure before expecting a change.
2656
+
2657
+ **How to see which of yours are which.** The two shapes are indistinguishable
2658
+ from the outside — a subscription that resumed and one that was never eligible
2659
+ both reconnect with rows on the screen. So the runtime records its own verdict
2660
+ at the moment it decides, per query label:
2661
+
2662
+ ```sh
2663
+ curl -s localhost:4000/_voltro/inspect/subscriptions | jq .resume
2664
+ ```
2665
+
2666
+ ```json
2667
+ [
2668
+ { "label": "documents.list", "resumable": 12, "excluded": {} },
2669
+ { "label": "notifications.list", "resumable": 0, "excluded": { "computed": 8 } },
2670
+ { "label": "comments.list", "resumable": 0, "excluded": { "row-filter": 3 } }
2671
+ ]
2672
+ ```
2673
+
2674
+ A label appears once something has subscribed to it, so click through the app
2675
+ first. `computed` means no declaration can ever help that query; `row-filter`
2676
+ means the filter narrows its source and the exclusion is the point;
2677
+ `eager-load` means dropping the `.with(...)` would flip it. `voltro dev` also
2678
+ logs each verdict once per label under the `voltro:resume` scope.
2636
2679
 
2637
2680
  **Why a declaration and not a probe.** Resolving the scope at subscribe time
2638
2681
  and treating "returns `undefined` for this table" as safe is cheaper and
@@ -2969,17 +2969,67 @@ the swap could not run: 2 row(s) in the bundle reference a row the bundle does n
2969
2969
  The target is UNCHANGED — the swap runs in one transaction and none of it committed.
2970
2970
  ```
2971
2971
 
2972
- Staging tables from a run that died mid-load are collected by the next
2973
- `replace` over the same tables. One over a DIFFERENT set leaves them, and
2974
- nothing else removes them:
2972
+ ### Staging tables a dead run left behind
2973
+
2974
+ A staged run RECORDS the scratch tables it creates, in the same
2975
+ `_voltro_replace_in_progress` table an interrupted destructive `replace` writes
2976
+ to — with one difference that matters: **a staging record never refuses a boot.**
2977
+ Nothing was destroyed, so there is nothing to refuse over. The boot reports
2978
+ instead:
2979
+
2980
+ ```
2981
+ staged data-import leftovers:
2982
+ - 3 staging table(s) from a `replace` over api, last active 74 minute(s) ago — DROPPED: the run
2983
+ is not resumable and has been silent long enough that nothing is loading into them.
2984
+ The target of a staged `replace` is untouched until one short swap at the end, so none of this is
2985
+ a reason to refuse the boot — it is a reason to know the disk is holding a copy of a bundle.
2986
+ ```
2987
+
2988
+ The run refreshes a heartbeat on that record every couple of seconds while rows
2989
+ land, which is what lets a boot tell the three cases apart:
2990
+
2991
+ | what the record says | what the boot does |
2992
+ |---|---|
2993
+ | silent past the threshold, started without `--no-atomic` | **drops** the tables it names |
2994
+ | still beating | leaves them — an import is loading into them right now, here or on another replica |
2995
+ | started `--no-atomic` | leaves them — its staging IS the resume point |
2996
+
2997
+ The threshold is `30` minutes by default. It is deliberately generous: the cost
2998
+ of collecting too early is that an in-flight import's swap fails with a missing
2999
+ table and you re-run it — the target is untouched either way — but the cost is
3000
+ still a re-run.
3001
+
3002
+ Declare a different one for a deployment whose imports routinely pause longer
3003
+ than that, waiting on an upstream export or a maintenance window:
3004
+
3005
+ ```ts
3006
+ // app.config.ts
3007
+ export default {
3008
+ dataTransfer: {
3009
+ stagingStaleMinutes: 90,
3010
+ },
3011
+ }
3012
+ ```
3013
+
3014
+ `VOLTRO_STAGING_STALE_MINUTES` overrides the declaration in turn — an operator
3015
+ acting on a running deployment outranks what the project declared. Note that the
3016
+ threshold decides only what a boot DROPS: leftover staging tables are named in
3017
+ the boot log either way.
3018
+
3019
+ What the boot does NOT collect, you can:
2975
3020
 
2976
3021
  ```bash
2977
3022
  voltro data clear-staging --yes
2978
3023
  ```
2979
3024
 
2980
- Deliberately a command and not a boot sweep: a booting process cannot tell a
2981
- leftover from a staging table another replica is loading into right now, and
2982
- deleting the second would destroy an import in flight.
3025
+ It now labels each table with what its own run says, so a resume point is
3026
+ distinguishable from a leftover before you drop it:
3027
+
3028
+ ```
3029
+ 2 staging table(s) from an earlier `--mode replace`:
3030
+ _voltro_staging_tasks — RESUMABLE: a `--no-atomic` re-run continues from it, last active 4 min ago
3031
+ _voltro_staging_notes — no run claims it (an orphan, or from before the marker)
3032
+ ```
2983
3033
 
2984
3034
  A **cycle** in the bundle's foreign keys is detected before the load, not after
2985
3035
  it. The swap inserts parents first, so two tables referencing each other cannot
@@ -3562,7 +3612,14 @@ A native run reports **blobs**, not rows: the vendor tool reports no row count w
3562
3612
 
3563
3613
  ### The provenance stamp — a restore that refuses the wrong DB
3564
3614
 
3565
- A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the live schema `fingerprint`, the `@voltro/cli` version, and the timestamp.
3615
+ A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the `@voltro/cli` version, the timestamp, and **two** schema fingerprints.
3616
+
3617
+ Two, because they are different facts and only one of them is a claim about the artifact:
3618
+
3619
+ - **`schemaFingerprint`** — the source database's whole live schema at backup time. This is what the skew warning below compares against a target.
3620
+ - **`dumpFingerprint`** — the schema the **artifact carries**: that same snapshot minus the tables the dump excludes. On postgres and the mysql family those are `_voltro_replace_in_progress` and `_voltro_data_transfers` (see above); on sqlite, turso and mssql nothing is excluded and the two values are equal.
3621
+
3622
+ The distinction is not bookkeeping. `voltro data backup` opens its own run row in `_voltro_data_transfers` *before* it dumps, so on any database the framework has run against, the artifact is two tables short of the live schema it was taken from. Anything comparing a restored schema against a stamped one has to compare against `dumpFingerprint` — the drill did not, and failed every healthy backup with *"the artifact is inconsistent."*
3566
3623
 
3567
3624
  `restore` reads the stamp **before touching the DB** and acts on two failures that are otherwise silent until they corrupt:
3568
3625
 
@@ -3578,13 +3635,39 @@ voltro data restore ./backups/2026-07-01 --drill --drill-url postgres://…/scra
3578
3635
  # or set DRILL_DB_URL and just: voltro data restore ./backups/2026-07-01 --drill
3579
3636
  ```
3580
3637
 
3581
- `--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp:
3638
+ `--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and probes its migration ledger:
3582
3639
 
3583
3640
  - **zero tables restored** → FAIL (the dump is empty or unreadable — this backup would not recover you),
3584
- - **fingerprint disagrees with the stamp** → FAIL (the restore didn't reproduce what was backed up),
3585
- - **tables + matching fingerprint** → PASS.
3641
+ - **schema fingerprint disagrees with the stamp's `dumpFingerprint`** → FAIL (the restore didn't reproduce what was backed up),
3642
+ - **`_voltro_migration_plans` restored EMPTY** → FAIL (see below),
3643
+ - **tables + matching fingerprint + a populated or absent ledger** → PASS.
3644
+
3645
+ A stamp too old to carry a `dumpFingerprint` gives a **PASS (partial)** that says the shape could not be cross-checked. It does not fall back to `schemaFingerprint`: that is the comparison that fails a healthy backup, and a check that is red on every real input gets switched off — taking its genuine failures with it.
3646
+
3647
+ It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron — a backup you've never restored is a hypothesis, and this is how you keep it a fact.
3648
+
3649
+ #### The ledger check — the one thing a schema comparison cannot see
3650
+
3651
+ A fingerprint answers *"is the shape right?"*. A drill's real question is *"would my app come up against this?"*, and the gap between them is **content** — a framework table that restored with the right columns and the wrong rows.
3652
+
3653
+ `voltro serve`'s boot gate reads the newest row of `_voltro_migration_plans` and refuses with `prod-mismatch` when there is none. So a ledger table that restores with exactly the right columns and **zero rows** is a database no source tree can boot, and its schema fingerprint is identical to a healthy one's. The drill fails that, and names it:
3654
+
3655
+ ```
3656
+ FAIL — restored 30 table(s) with the right shape, but `_voltro_migration_plans`
3657
+ came back EMPTY.
3658
+ `voltro serve` reads the newest row of that table as its boot gate and
3659
+ refuses with `prod-mismatch` when there is none.
3660
+ ```
3661
+
3662
+ A restored database with **no ledger table at all** is not a voltro-managed schema (a hand-made dump, someone else's database) — the drill says so and claims nothing about booting it, rather than failing it.
3663
+
3664
+ What the drill deliberately does **not** judge is a ledger whose fingerprint differs from what your code declares. It has no way to know which commit you will deploy next to this database, and `voltro db apply` clears that state anyway; failing a backup for it would make the drill red for a reason that is not about the backup.
3665
+
3666
+ #### Why there is no full app boot
3667
+
3668
+ Booting a real app against the restored database sounds like the stronger check, and it would be a weaker one. There is no app in the drill's path — it would have to boot a **fixture**, and a fixture booting says nothing about whether *your* app boots. It moves the drill from *"proves your backup"* to *"proves our fixture"* while reading as the bigger claim.
3586
3669
 
3587
- It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron a backup you've never restored is a hypothesis, and this is how you keep it a fact. (The verify is schema-level; a full app boot against the restored DB is a heavier check you can layer on top.)
3670
+ The part worth having does not need a process: the boot gate is a comparison, not a startup sequence, so the one boot-fatal condition that holds regardless of which code you deploy is reachable with a `SELECT`. That is the ledger check above.
3588
3671
 
3589
3672
  ### Point-in-time recovery (PITR) is your database's job, not the framework's
3590
3673
 
@@ -431,6 +431,15 @@ between acting on it and learning to skim it:
431
431
  recorder resolves it through the relation registry instead, target and (for a
432
432
  many-to-many) junction alike. A write to the junction changes membership,
433
433
  which is exactly the change a user makes.
434
+ - **A `crud.*` executor is watched exactly like a hand-written one** — and it is
435
+ the case that needs it most. `crud.list('tasks', { include: { subTasks: true } })`
436
+ reads a table your own file never names, so there is nothing in front of you to
437
+ check `source:` against. The descriptor stays yours either way: `crud.*` supplies
438
+ only the executor, you write the `source:` beside it. `crud.count` counts as a
439
+ read too — it returns a number rather than rows, but an insert changes that
440
+ number, so the counted table belongs in `source:` or "page 3 of 12" stops moving.
441
+ `crud.create` / `update` / `remove` issue no read at all and never produce a
442
+ finding.
434
443
  - **A table read only to NARROW a result is not counted** — a parent reached
435
444
  through `inSubquery(...)`, or a read the framework made to resolve your row
436
445
  filter. Those decide which rows come back rather than contributing rows, and
@@ -733,9 +742,12 @@ is always present (the fallback stands in until the first snapshot) and `loading
733
742
  is a plain boolean reporting the true state. There is nothing to narrow.
734
743
 
735
744
  **Errors.** `loading` means **no data has arrived yet** — it is not a claim that
736
- the subscription is healthy. A **cold-start** failure (nothing ever arrived)
737
- leaves `loading` true *and* sets `error`, so a component that branches on
738
- `loading` alone renders a skeleton forever; check `error` to break out of it. A
745
+ the subscription is healthy. A **cold-start** failure (nothing ever arrived) is
746
+ its own state: `loading` is `false`, `failed` is `true`, and `error` is
747
+ non-optional there, so branching on `loading` alone can no longer render a
748
+ skeleton forever. (It used to leave `loading` true, and the type's own comment
749
+ predicted the consequence — the fix was to stop making `loading` mean two
750
+ things rather than to keep warning about it.) A
739
751
  failure AFTER data arrived deliberately does NOT replace good data with an error
740
752
  banner (a transient websocket hiccup would blank a working screen); those reach
741
753
  the api's error bus instead — subscribe with `useOnRpcError` for
@@ -1119,7 +1131,13 @@ export const employeesUpdate = defineMutation({
1119
1131
  target: {
1120
1132
  table: 'employees',
1121
1133
  op: 'update',
1122
- relations: { assignedStores: 'employee_assigned_stores' },
1134
+ relations: {
1135
+ assignedStores: {
1136
+ junction: 'employee_assigned_stores',
1137
+ anchorColumn: 'employeeId', // the junction reference() pointing at `employees`
1138
+ targetColumn: 'storeId', // the junction's other reference()
1139
+ },
1140
+ },
1123
1141
  },
1124
1142
  })
1125
1143
  ```
@@ -1127,9 +1145,7 @@ export const employeesUpdate = defineMutation({
1127
1145
  After the executor succeeds, `input.assignedStores` is reconciled against the
1128
1146
  junction via the diff-based link writer (`store.relationLinks`): missing rows
1129
1147
  inserted, surplus rows deleted, unchanged rows untouched — so reactive
1130
- subscriptions on the junction see one change per changed row. The anchor
1131
- column is derived from the junction's `reference()` targets; a self-junction
1132
- (both columns referencing one table) is refused by name, never guessed.
1148
+ subscriptions on the junction see one change per changed row.
1133
1149
 
1134
1150
  The semantics worth knowing: an ABSENT input field leaves the links
1135
1151
  untouched — absent is not empty; an empty array is the explicit "clear them
@@ -1137,6 +1153,34 @@ all". The row id comes from the executor's `output.id`, falling back to
1137
1153
  `input.id`. The link writes go through `ctx.store`, so undo capture and
1138
1154
  cross-table rules see them like any other write.
1139
1155
 
1156
+ ### The same declaration drives the optimistic update
1157
+
1158
+ A junction change used to reach the browser only with the server delta — so on
1159
+ one submit the renamed title flipped immediately and the assigned stores sat on
1160
+ their old value until the roundtrip landed. It does not any more: `useMutation`
1161
+ reconciles the junction rows of every subscription sourced on `junction` the
1162
+ moment the mutation is sent, against the same `input[field]` the server will
1163
+ write.
1164
+
1165
+ It is a diff, not a redraw: a surviving link keeps its own row (and its real
1166
+ id), a surplus link disappears, and only a genuinely new link is a staged
1167
+ optimistic row. The patches ride the ordinary optimistic lane — reverted if the
1168
+ mutation fails, kept after it succeeds until the server data actually moves.
1169
+ Nothing is on a timer.
1170
+
1171
+ Client-side the anchor id is `input.id`; for an `insert` it is the same
1172
+ optimistic id the new row was stamped with, since the server's `output.id` is
1173
+ not knowable before the response arrives.
1174
+
1175
+ **Why you state the two columns.** The optimistic patch runs in the BROWSER, and
1176
+ the browser cannot import your `db/` schema — `@voltro/database` is server-only
1177
+ by construction — so the junction's two `reference()` columns cannot be derived
1178
+ there. `anchorColumn` is the one pointing at the target's own table;
1179
+ `targetColumn` is the other. They are not taken on trust: before it writes, the
1180
+ server compares your declaration against the junction's real reference columns
1181
+ and refuses, naming the correct pair, if they disagree. A self-junction (both
1182
+ columns referencing one table) is still refused by name, never guessed.
1183
+
1140
1184
  ## Typed Errors
1141
1185
 
1142
1186
  ```ts
@@ -2065,7 +2109,8 @@ the resume window (`reactive.resume.windowMs`, default 60 s) the server replays
2065
2109
  **only the deltas the client missed** — the re-subscribe presents the last
2066
2110
  materialised revision and the stream continues on the same revision line, so a
2067
2111
  short offline gap costs a handful of patches instead of every row. Outside the
2068
- window, for computed queries, for row-filtered apps, or whenever anything is in
2112
+ window, for computed queries, for a subscription whose source table a
2113
+ registered row filter may narrow, or whenever anything is in
2069
2114
  doubt, the query answers with a fresh snapshot — the delta-resume wire contract
2070
2115
  lives in [the wire protocol](/docs/data/wire-protocol#reconnect-delta-resume).
2071
2116
 
@@ -5022,11 +5067,17 @@ A resume is declined — always with a fresh snapshot — when:
5022
5067
  keeps delta-resume on every subscription whose source is not in that set —
5023
5068
  the common case, since most filters narrow a handful of tables. Without the
5024
5069
  declaration the framework cannot know which tables the predicate may reach
5025
- and excludes them all, which is what a deployment measured as one filter over
5026
- 4 tables costing the feature on all 173 of their queries. Eager loads are
5027
- excluded wholesale because a relation resolves below the seam that narrows.
5028
- They reconnect with a fresh
5029
- snapshot, exactly as before.
5070
+ and excludes them all. Eager loads are excluded wholesale because a relation
5071
+ resolves below the seam that narrows. They reconnect with a fresh snapshot,
5072
+ exactly as before.
5073
+
5074
+ **Which of your queries actually got a ring** is recorded per label, since the
5075
+ excluded and the never-eligible look identical on the wire:
5076
+ `/_voltro/inspect/subscriptions` returns a `resume` array of
5077
+ `{ label, resumable, excluded }`, and `voltro dev` logs each verdict once under
5078
+ the `voltro:resume` scope. A `computed` verdict is the one worth reading first:
5079
+ it means the executor returns a value rather than a descriptor, so no row-filter
5080
+ declaration can ever change it.
5030
5081
 
5031
5082
  **Author a live-subscribed getter to return, not throw.** A subscription is a
5032
5083
  long-lived stream, so a getter that throws on every re-evaluation is a broken
@@ -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
 
@@ -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`: