@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.
- package/CHANGELOG.md +142 -2
- package/dist/{apiBuild-DTWp0S_q.js → apiBuild-CMvLJM_K.js} +2 -2
- package/dist/apiBuild-Cl0IDx8c.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D4ygSbnV.js → build-S0QOzqPT.js} +10 -10
- package/dist/{checkCommand-L7DTlpIF.js → checkCommand-DNkY5kwF.js} +1 -1
- package/dist/{checkCommand-Dg1G7Gwd.js → checkCommand-fbj9GDjN.js} +5 -5
- package/dist/codegen-CN6vMM4J.js +2 -0
- package/dist/{codegen-DSLM8Su9.js → codegen-SIepQtUl.js} +74 -63
- package/dist/{codegenCommand-CG_Vx4lc.js → codegenCommand-3TDJezom.js} +10 -9
- package/dist/{codemodRunner-Cd4xkC6u.js → codemodRunner-C2zxZUIw.js} +55 -0
- package/dist/{commands-6Kzi92Np.js → commands-BBYJ7Q3B.js} +24 -24
- package/dist/{dashboardCommand-Cq1PWvI1.js → dashboardCommand-D2kmyCLL.js} +3 -3
- package/dist/{dataCommand-DYzW8vkv.js → dataCommand-BEPPQiTl.js} +266 -194
- package/dist/dbCommand-BTyBGhIA.js +2 -0
- package/dist/{dbCommand-B4NWZtGL.js → dbCommand-DZTmOFT4.js} +1 -1
- package/dist/{dev-CmuvUKRq.js → dev-Ca_A_S9v.js} +2182 -2157
- package/dist/{dev-cKUiZZsB.js → dev-DfVZaoys.js} +1 -1
- package/dist/{doctorCommand-DCiFVMtZ.js → doctorCommand-CGZJK_4o.js} +15 -15
- package/dist/doctorCommand-djmqEcDC.js +2 -0
- package/dist/{dormancyCommand-w1TrmgYP.js → dormancyCommand-DY2rYpTa.js} +1 -1
- package/dist/{embeddingsCommand-CMgPyRTr.js → embeddingsCommand-BoCqZsgp.js} +1 -1
- package/dist/{envCommand-Cyynmcfa.js → envCommand-Bxy2fOjc.js} +2 -2
- package/dist/{evolveCommand-BwvQ8dVH.js → evolveCommand-BsbZ-XDg.js} +2 -2
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
- package/dist/{frameworkTableAssembly-D7LJuALW.js → frameworkTableAssembly-Do-cf6RJ.js} +92 -98
- package/dist/index.js +1 -1
- package/dist/{infoCommand-DlYlUPqs.js → infoCommand-EmM3jPKD.js} +1 -1
- package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
- package/dist/inspect-DGJwpOAb.js +2 -0
- package/dist/interruptedReplace-CwnkBb2X.js +41 -0
- package/dist/interruptedReplace-qzmFI020.js +2 -0
- package/dist/manifestBuild-CJ2zvPvT.js +2 -0
- package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
- package/dist/{migrate-BK_Bbx-_.js → migrate-CGFZS-1a.js} +2 -2
- package/dist/{probeCommand-_C0YU207.js → probeCommand-Bs3iVBSL.js} +1 -1
- package/dist/{runtimeTrace-CGWx1Q6l.js → runtimeTrace-C1BTpHGQ.js} +1 -1
- package/dist/{sdkgen-CDGHQUFj.js → sdkgen-CXMwLg9n.js} +1 -1
- package/dist/{serveCommand-Bje09q1v.js → serveCommand-C7IrCD58.js} +840 -838
- package/dist/serveCommand-Cjt5S9hD.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/{start-B0bnJgxI.js → start-DH7cat4-.js} +1 -1
- package/dist/{start-Clz-1BHB.js → start-EOV7s1NZ.js} +486 -476
- package/dist/startEntry.js +1 -1
- package/dist/{test-D_kW4KMj.js → test-DO27-x2P.js} +1 -1
- package/dist/{updateCommand-CRJlAOaM.js → updateCommand-C_jN1w18.js} +1 -1
- package/dist/updateCommand-nnFjDbl4.js +2 -0
- package/dist/{webDev-DlvZO30c.js → webDev-1XpVnYkW.js} +1 -1
- package/dist/{webDev-DSI9SOhs.js → webDev-B7vNj4Bq.js} +502 -494
- package/dist/{webhooksCommand-BvzXNHji.js → webhooksCommand-B1LVcyO3.js} +1 -1
- package/package.json +31 -19
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/authentication.md +49 -6
- package/templates/agent-docs/cli.md +94 -11
- package/templates/agent-docs/data.md +64 -13
- package/templates/agent-docs/plugins.md +46 -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/whats-new.md +76 -133
- 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/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +10 -10
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- 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 +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +4 -4
- 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-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
|
@@ -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-
|
|
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.
|
|
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.
|
|
840
|
-
"@voltro/cache": "0.
|
|
841
|
-
"@voltro/client": "0.
|
|
842
|
-
"@voltro/content": "0.
|
|
843
|
-
"@voltro/data-transfer": "0.
|
|
844
|
-
"@voltro/database": "0.
|
|
845
|
-
"@voltro/env": "0.
|
|
846
|
-
"@voltro/kv": "0.
|
|
847
|
-
"@voltro/logger": "0.
|
|
848
|
-
"@voltro/plugin-auth": "0.
|
|
849
|
-
"@voltro/plugin-broadcast": "0.
|
|
850
|
-
"@voltro/plugin-mail": "0.
|
|
851
|
-
"@voltro/plugin-storage": "0.
|
|
852
|
-
"@voltro/plugin-webhooks": "0.
|
|
853
|
-
"@voltro/protocol": "0.
|
|
854
|
-
"@voltro/runtime": "0.
|
|
855
|
-
"@voltro/serverless": "0.
|
|
856
|
-
"@voltro/workflow": "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",
|
package/templates/AGENTS.md
CHANGED
|
@@ -730,7 +730,7 @@ each plugin's own README.
|
|
|
730
730
|
|
|
731
731
|
| Topic | Open | Summary |
|
|
732
732
|
|---|---|---|
|
|
733
|
-
| **What's new in 0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
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
|
|
2973
|
-
|
|
2974
|
-
|
|
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
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
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
|
|
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
|
|
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
|
|
3585
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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: {
|
|
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.
|
|
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
|
|
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
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
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.
|
|
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
|
|
|
@@ -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`:
|