@voltro/cli 0.22.1 → 0.24.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 (76) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/bin/voltro.mjs +39 -4
  3. package/dist/{apiBuild-DiWxVz-M.js → apiBuild-B2m4XK_8.js} +45 -23
  4. package/dist/apiBuild-UEM3QBke.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-BxRaIOBG.js → commands-CaIhTsC2.js} +2944 -2557
  7. package/dist/{dbCommand-DVASmZj2.js → dbCommand-CC61CsAc.js} +474 -263
  8. package/dist/dbCommand-Cr__4ATv.js +2 -0
  9. package/dist/{dev-Dd3EZzj5.js → dev-DPkQVUTP.js} +1 -1
  10. package/dist/{dev-BRiPgbKw.js → dev-NCuEhNxs.js} +1593 -1575
  11. package/dist/index.js +1 -1
  12. package/dist/{inspectMetrics-9ZSuDeqD.js → inspectMetrics-dcC7gnxu.js} +566 -552
  13. package/dist/{serveCommand-DXJOARkO.js → serveCommand-C4gzDZzc.js} +425 -411
  14. package/dist/serveEntry.js +2 -2
  15. package/dist/{start-CsIjcOi-.js → start-C_4VUDT8.js} +327 -325
  16. package/dist/startEntry.js +2 -2
  17. package/package.json +17 -17
  18. package/templates/AGENTS.md +1 -1
  19. package/templates/agent-docs/_index.md +1 -1
  20. package/templates/agent-docs/_manifest.json +1 -1
  21. package/templates/agent-docs/authentication.md +21 -0
  22. package/templates/agent-docs/caching.md +21 -5
  23. package/templates/agent-docs/cli.md +61 -0
  24. package/templates/agent-docs/data.md +208 -170
  25. package/templates/agent-docs/database/migrations.md +123 -0
  26. package/templates/agent-docs/database/seedsdialects.md +12 -0
  27. package/templates/agent-docs/deployment.md +66 -1
  28. package/templates/agent-docs/plugins.md +42 -0
  29. package/templates/agent-docs/routing.md +23 -1
  30. package/templates/agent-docs/templates/apibackends.md +2 -2
  31. package/templates/agent-docs/whats-new.md +281 -32
  32. package/templates/agent-docs/workflows.md +30 -0
  33. package/templates/apps/api-ai/package.json +8 -7
  34. package/templates/apps/api-auth/package.json +9 -8
  35. package/templates/apps/api-backend/package.json +8 -7
  36. package/templates/apps/api-backend-deactivation/package.json +8 -7
  37. package/templates/apps/api-backend-mail/package.json +9 -8
  38. package/templates/apps/api-backend-mariadb/package.json +10 -9
  39. package/templates/apps/api-backend-storage/package.json +9 -8
  40. package/templates/apps/api-data-advanced/package.json +9 -8
  41. package/templates/apps/api-durable/package.json +9 -8
  42. package/templates/apps/api-feature-flags/package.json +10 -9
  43. package/templates/apps/api-governance/package.json +9 -8
  44. package/templates/apps/api-kv/package.json +9 -8
  45. package/templates/apps/api-moderation/package.json +9 -8
  46. package/templates/apps/api-observability/package.json +9 -8
  47. package/templates/apps/api-ratelimit/package.json +9 -8
  48. package/templates/apps/api-rbac/package.json +9 -8
  49. package/templates/apps/api-rest/package.json +8 -7
  50. package/templates/apps/api-saas/package.json +12 -11
  51. package/templates/apps/api-search/package.json +9 -8
  52. package/templates/apps/api-versioning/app.config.ts +6 -4
  53. package/templates/apps/api-versioning/package.json +9 -8
  54. package/templates/apps/api-webhooks/package.json +10 -9
  55. package/templates/apps/changelog/package.json +7 -6
  56. package/templates/apps/edge-functions/package.json +3 -2
  57. package/templates/apps/frontend-admin/package.json +9 -8
  58. package/templates/apps/frontend-app/package.json +9 -8
  59. package/templates/apps/frontend-blank/package.json +8 -7
  60. package/templates/apps/frontend-contact/package.json +8 -7
  61. package/templates/apps/frontend-contact/src/globals.d.ts +6 -0
  62. package/templates/apps/frontend-dashboard/package.json +8 -7
  63. package/templates/apps/frontend-docs/package.json +8 -7
  64. package/templates/apps/frontend-docs/src/globals.d.ts +6 -0
  65. package/templates/apps/frontend-i18n/package.json +7 -6
  66. package/templates/apps/frontend-landing/package.json +8 -7
  67. package/templates/apps/frontend-landing/src/globals.d.ts +6 -0
  68. package/templates/apps/frontend-spa/package.json +8 -7
  69. package/templates/apps/frontend-spa/src/globals.d.ts +6 -0
  70. package/templates/apps/frontend-ssr/package.json +8 -7
  71. package/templates/apps/frontend-ssr/src/globals.d.ts +6 -0
  72. package/templates/apps/frontend-ssr-api/package.json +9 -8
  73. package/templates/apps/frontend-static-blog/package.json +7 -6
  74. package/templates/apps/frontend-static-blog/src/globals.d.ts +6 -0
  75. package/dist/apiBuild-COyPDf3R.js +0 -2
  76. package/dist/dbCommand-CO3eSAZR.js +0 -2
@@ -816,6 +816,26 @@ try {
816
816
 
817
817
  The same applies to `run` on [actions](/docs/data/actions).
818
818
 
819
+ ## Idempotency — a retried mutation runs exactly once
820
+
821
+ The reactive client resends an in-flight mutation after a network blip. Without a guard, "create order" or "charge card" would run twice. `useMutation` (and `useAction`) mint a fresh **idempotency key** per call and attach it to the rpc frame; when idempotency is enabled the server dedupes a repeat of that key — the handler runs once and the retry replays the first result.
822
+
823
+ Enable it once (this also covers the REST `Idempotency-Key` header — one switch, both surfaces):
824
+
825
+ ```ts
826
+ // app.config.ts
827
+ export default {
828
+ idempotency: true, // or { ttlMs: 600_000 } — the dedup window (default 24h)
829
+ }
830
+ ```
831
+
832
+ For a HIGHER-level guarantee — dedupe a double-click or an offline resend of the *same logical action* — pass a STABLE key derived from the action's identity, instead of the per-call one:
833
+
834
+ ```ts
835
+ await placeOrder.mutate(cart, { idempotencyKey: `order:${cart.id}` })
836
+ ```
837
+
838
+ The key is scoped to `(tenant, subject, mutation)`, so one user's key can never replay another's. The replayed result is byte-for-byte the first one — a `Date` in the output comes back a `Date`, not a string — because it round-trips through the mutation's output schema. Off by default: with no `idempotency` config, every call runs.
819
839
 
820
840
  ## Auto-Optimistic
821
841
 
@@ -953,7 +973,11 @@ try {
953
973
 
954
974
  **Trace id for debugging.** An error caught from `useMutation` / `useAction` carries a **non-enumerable `__voltroTraceId`** — the bridge to the server logs for that exact call:
955
975
 
976
+ ```ts
977
+ const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
978
+ // → `voltro logs --trace <traceId>` to see the server-side span
956
979
  ```
980
+
957
981
  `errorTag` lives in `@voltro/protocol` rather than in the client for a reason
958
982
  worth knowing before you decide where your own error handling goes: **`_tag` is a
959
983
  wire concept, and protocol owns the wire.** So a shared error handler in a package
@@ -961,10 +985,6 @@ that has no business depending on `@voltro/client` — a UI kit, an i18n layer
961
985
  can read a tag without taking that dependency. `instanceof` is the thing that
962
986
  does NOT survive the wire: what arrives in the browser was decoded from JSON and
963
987
  never constructed, so match on the tag, not on the class.
964
- ts
965
- const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
966
- // → `voltro logs --trace <traceId>` to see the server-side span
967
- ```
968
988
 
969
989
  **Exhaustive matching with the generated `matchError`.** Codegen emits a per-app `matchError` (plus `AppError` / `AppErrorTag`) into `rpcGroup.generated.ts`, derived by reference from every descriptor's `error:` schema + your plugins' cross-cutting errors — so there's no hand-maintained tag list to drift out of date (a dead/renamed tag is a compile error):
970
990
 
@@ -1009,6 +1029,24 @@ nobody wrote a client call for it, it is on the wire — the same reasoning as
1009
1029
  whatever authority its caller has. This removes the wire surface, not the need to
1010
1030
  check who is asking; `voltro doctor`'s authz scan still covers it.
1011
1031
 
1032
+ **It cannot be combined with `publicApi` or `exposeAsTool`.** Those add a REST
1033
+ route and an agent tool respectively — opt-ins to a *different* surface — so a
1034
+ procedure carrying both would be unreachable from your own client and reachable
1035
+ from the internet. That combination throws where it is declared:
1036
+
1037
+ ```text
1038
+ auditLog.createFromAction: `internal: true` cannot be combined with `publicApi`.
1039
+ ```
1040
+
1041
+ Neither silent resolution would be right: dropping the REST route breaks a live
1042
+ endpoint invisibly, and keeping it defeats the flag. Drop `internal: true` if the
1043
+ wider surface is intended, or remove the annotation if it is not.
1044
+
1045
+ The flag also removes the procedure from `voltro dev`'s inspect invoker, so the
1046
+ devtools "invoke" panel will not list it. That is deliberate — an internal
1047
+ procedure is the one most likely to carry no guard, since "only server code calls
1048
+ this" is the reason people write them.
1049
+
1012
1050
  ## When Not To Use A Mutation
1013
1051
 
1014
1052
  - **External I/O.** Use an action or workflow.
@@ -2105,171 +2143,6 @@ When no analytics sink is configured the framework provides the no-op sink: the
2105
2143
 
2106
2144
 
2107
2145
 
2108
- ---
2109
-
2110
- <!-- source: en/data/client-state.md -->
2111
- ## Client state (defineStore)
2112
-
2113
- _defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes._
2114
-
2115
- Server state already has a home: a subscription **is** live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
2116
-
2117
- Without a primitive for that you reach for zustand or jotai, which is a *parallel runtime* — the one thing the framework asks you not to bring. So it ships one.
2118
-
2119
- ```ts
2120
- // wizard.store.ts
2121
- import { defineStore } from '@voltro/client'
2122
-
2123
- export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
2124
- ```
2125
-
2126
- ```tsx
2127
- const step = wizard.use((s) => s.step) // the global instance
2128
- const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
2129
-
2130
- wizard.set({ step: 2 })
2131
- wizard.set((s) => ({ ...s, step: s.step + 1 }))
2132
- ```
2133
-
2134
- ## Reads go through a selector — there is no `useStore()`
2135
-
2136
- A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when **that slice** changes:
2137
-
2138
- ```tsx
2139
- const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
2140
- const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
2141
- ```
2142
-
2143
- ### Computed values need `equals: shallow`
2144
-
2145
- A selector that *builds* something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on **every** store change:
2146
-
2147
- ```tsx
2148
- import { shallow } from '@voltro/client'
2149
-
2150
- const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
2151
- const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
2152
- ```
2153
-
2154
- You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
2155
-
2156
- ## Scoping is by key, not by a Provider
2157
-
2158
- A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that *is* what makes context painful at scale. So an instance is addressed by a key you already have:
2159
-
2160
- ```tsx
2161
- wizard.use((s) => s.step, { key: orderId })
2162
- ```
2163
-
2164
- which is the same model as everywhere else in the framework: `useSubscription('orders.list', { orgId })` is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
2165
-
2166
- `wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
2167
-
2168
- ## SSR seeding adds no new channel
2169
-
2170
- Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
2171
-
2172
- ```ts
2173
- export const loader = async ({ params }) => {
2174
- seedStore(wizard, { step: 2 }, { key: params.orderId })
2175
- return { /* … */ }
2176
- }
2177
- ```
2178
-
2179
- There is no `dehydrate()` to remember and no `hydrate()` to forget — the seed rides the hydration payload the router already writes, and `mount()` applies it before the tree exists. A step you can forget is a step somebody will.
2180
-
2181
- On the client `seedStore` **throws**. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
2182
-
2183
- **The key is part of the address.** Seed with `{ key: orderId }` and read with `{ key: orderId }`. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
2184
-
2185
- ## Surviving a reload
2186
-
2187
- `persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
2188
-
2189
- ```tsx
2190
- export const filters = defineStore(
2191
- 'inbox:filters',
2192
- () => ({ status: 'open', sort: 'newest', draft: '' }),
2193
- {
2194
- persist: {
2195
- key: 'inbox:filters',
2196
- storage: 'local', // 'session' lasts the tab
2197
- pick: (s) => ({ status: s.status, sort: s.sort }),
2198
- migrate: (stored) => (isFilters(stored) ? stored : undefined),
2199
- },
2200
- },
2201
- )
2202
- ```
2203
-
2204
- Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
2205
-
2206
- **The stored value is merged over `initial()`, not substituted for it.** Ship a new field and every returning user has state without it — `undefined` where the type promises a string. Merging means an old payload gains the new defaults.
2207
-
2208
- **`migrate` returning `undefined` discards the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
2209
-
2210
- **Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`, not just on writing. A store that throws at import time takes the page with it.
2211
-
2212
- **A persisted store on a server-rendered page hydrates against the *server* value.** The server has no `localStorage`, so it renders `initial()`; the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. `get()` is not deferred, only the render: an action reading the draft before the first paint reads the draft.
2213
-
2214
- `pick` narrows what gets written — persist the filters, not the open/closed state of every panel. And only the **global** instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
2215
-
2216
- ## Actions that write more than once
2217
-
2218
- An action rarely touches one field. Applying a coupon writes the coupon *and* the recomputed total; that is one thing the user did, and `batch` says so:
2219
-
2220
- ```ts
2221
- checkout.batch('applyCoupon', () => {
2222
- checkout.set({ coupon })
2223
- checkout.set({ total: recompute(coupon) })
2224
- })
2225
- ```
2226
-
2227
- One notification, one entry in the devtools feed named `applyCoupon`, and **one undo step**. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own; it cannot batch the meaning.
2228
-
2229
- **If the callback throws, every write it made is rolled back.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested `batch` joins its parent rather than opening a second one.
2230
-
2231
- ### Async work goes around the batch, not inside it
2232
-
2233
- ```ts
2234
- const quote = await fetchQuote(coupon) // await FIRST
2235
- checkout.batch('applyCoupon', () => { // then batch the writes
2236
- checkout.set({ coupon, total: quote.total })
2237
- })
2238
- ```
2239
-
2240
- Passing an `async` function to `batch` is an **error**, not a warning. Everything after the first `await` would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
2241
-
2242
- ## Undo and redo
2243
-
2244
- Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
2245
-
2246
- ```tsx
2247
- draft.undo(orderId) // back one write
2248
- draft.redo(orderId) // forward again
2249
- draft.canUndo(orderId) // for disabling the button
2250
- draft.canRedo(orderId)
2251
- ```
2252
-
2253
- It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a **new write after an undo drops the redo tail** — the behaviour every editor has.
2254
-
2255
- An undo never becomes undoable itself, and each keyed instance has its own history.
2256
-
2257
- ## Devtools: inspect, and travel
2258
-
2259
- The `voltro dev` overlay has a **Stores** tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
2260
-
2261
- `◀ Back` and `Forward ▶` step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
2262
-
2263
- No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
2264
-
2265
- ## What a store must never hold
2266
-
2267
- Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as *"reactivity is broken"*. `voltro doctor` reports a `*.store.ts` that reads a subscription.
2268
-
2269
- Read server state where you render it, and keep the store for what is genuinely client-side.
2270
-
2271
-
2272
-
2273
2146
  ---
2274
2147
 
2275
2148
  <!-- source: en/data/crud.md -->
@@ -2717,6 +2590,171 @@ into the agent's prompt.
2717
2590
 
2718
2591
 
2719
2592
 
2593
+ ---
2594
+
2595
+ <!-- source: en/data/client-state.md -->
2596
+ ## Client state (defineStore)
2597
+
2598
+ _defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes._
2599
+
2600
+ Server state already has a home: a subscription **is** live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
2601
+
2602
+ Without a primitive for that you reach for zustand or jotai, which is a *parallel runtime* — the one thing the framework asks you not to bring. So it ships one.
2603
+
2604
+ ```ts
2605
+ // wizard.store.ts
2606
+ import { defineStore } from '@voltro/client'
2607
+
2608
+ export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
2609
+ ```
2610
+
2611
+ ```tsx
2612
+ const step = wizard.use((s) => s.step) // the global instance
2613
+ const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
2614
+
2615
+ wizard.set({ step: 2 })
2616
+ wizard.set((s) => ({ ...s, step: s.step + 1 }))
2617
+ ```
2618
+
2619
+ ## Reads go through a selector — there is no `useStore()`
2620
+
2621
+ A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when **that slice** changes:
2622
+
2623
+ ```tsx
2624
+ const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
2625
+ const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
2626
+ ```
2627
+
2628
+ ### Computed values need `equals: shallow`
2629
+
2630
+ A selector that *builds* something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on **every** store change:
2631
+
2632
+ ```tsx
2633
+ import { shallow } from '@voltro/client'
2634
+
2635
+ const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
2636
+ const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
2637
+ ```
2638
+
2639
+ You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
2640
+
2641
+ ## Scoping is by key, not by a Provider
2642
+
2643
+ A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that *is* what makes context painful at scale. So an instance is addressed by a key you already have:
2644
+
2645
+ ```tsx
2646
+ wizard.use((s) => s.step, { key: orderId })
2647
+ ```
2648
+
2649
+ which is the same model as everywhere else in the framework: `useSubscription('orders.list', { orgId })` is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
2650
+
2651
+ `wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
2652
+
2653
+ ## SSR seeding adds no new channel
2654
+
2655
+ Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
2656
+
2657
+ ```ts
2658
+ export const loader = async ({ params }) => {
2659
+ seedStore(wizard, { step: 2 }, { key: params.orderId })
2660
+ return { /* … */ }
2661
+ }
2662
+ ```
2663
+
2664
+ There is no `dehydrate()` to remember and no `hydrate()` to forget — the seed rides the hydration payload the router already writes, and `mount()` applies it before the tree exists. A step you can forget is a step somebody will.
2665
+
2666
+ On the client `seedStore` **throws**. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
2667
+
2668
+ **The key is part of the address.** Seed with `{ key: orderId }` and read with `{ key: orderId }`. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
2669
+
2670
+ ## Surviving a reload
2671
+
2672
+ `persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
2673
+
2674
+ ```tsx
2675
+ export const filters = defineStore(
2676
+ 'inbox:filters',
2677
+ () => ({ status: 'open', sort: 'newest', draft: '' }),
2678
+ {
2679
+ persist: {
2680
+ key: 'inbox:filters',
2681
+ storage: 'local', // 'session' lasts the tab
2682
+ pick: (s) => ({ status: s.status, sort: s.sort }),
2683
+ migrate: (stored) => (isFilters(stored) ? stored : undefined),
2684
+ },
2685
+ },
2686
+ )
2687
+ ```
2688
+
2689
+ Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
2690
+
2691
+ **The stored value is merged over `initial()`, not substituted for it.** Ship a new field and every returning user has state without it — `undefined` where the type promises a string. Merging means an old payload gains the new defaults.
2692
+
2693
+ **`migrate` returning `undefined` discards the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
2694
+
2695
+ **Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`, not just on writing. A store that throws at import time takes the page with it.
2696
+
2697
+ **A persisted store on a server-rendered page hydrates against the *server* value.** The server has no `localStorage`, so it renders `initial()`; the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. `get()` is not deferred, only the render: an action reading the draft before the first paint reads the draft.
2698
+
2699
+ `pick` narrows what gets written — persist the filters, not the open/closed state of every panel. And only the **global** instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
2700
+
2701
+ ## Actions that write more than once
2702
+
2703
+ An action rarely touches one field. Applying a coupon writes the coupon *and* the recomputed total; that is one thing the user did, and `batch` says so:
2704
+
2705
+ ```ts
2706
+ checkout.batch('applyCoupon', () => {
2707
+ checkout.set({ coupon })
2708
+ checkout.set({ total: recompute(coupon) })
2709
+ })
2710
+ ```
2711
+
2712
+ One notification, one entry in the devtools feed named `applyCoupon`, and **one undo step**. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own; it cannot batch the meaning.
2713
+
2714
+ **If the callback throws, every write it made is rolled back.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested `batch` joins its parent rather than opening a second one.
2715
+
2716
+ ### Async work goes around the batch, not inside it
2717
+
2718
+ ```ts
2719
+ const quote = await fetchQuote(coupon) // await FIRST
2720
+ checkout.batch('applyCoupon', () => { // then batch the writes
2721
+ checkout.set({ coupon, total: quote.total })
2722
+ })
2723
+ ```
2724
+
2725
+ Passing an `async` function to `batch` is an **error**, not a warning. Everything after the first `await` would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
2726
+
2727
+ ## Undo and redo
2728
+
2729
+ Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
2730
+
2731
+ ```tsx
2732
+ draft.undo(orderId) // back one write
2733
+ draft.redo(orderId) // forward again
2734
+ draft.canUndo(orderId) // for disabling the button
2735
+ draft.canRedo(orderId)
2736
+ ```
2737
+
2738
+ It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a **new write after an undo drops the redo tail** — the behaviour every editor has.
2739
+
2740
+ An undo never becomes undoable itself, and each keyed instance has its own history.
2741
+
2742
+ ## Devtools: inspect, and travel
2743
+
2744
+ The `voltro dev` overlay has a **Stores** tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
2745
+
2746
+ `◀ Back` and `Forward ▶` step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
2747
+
2748
+ No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
2749
+
2750
+ ## What a store must never hold
2751
+
2752
+ Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as *"reactivity is broken"*. `voltro doctor` reports a `*.store.ts` that reads a subscription.
2753
+
2754
+ Read server state where you render it, and keep the store for what is genuinely client-side.
2755
+
2756
+
2757
+
2720
2758
  ---
2721
2759
 
2722
2760
  <!-- source: en/data/outbox.md -->
@@ -3522,7 +3560,7 @@ plugin's `tables` option, then read a timeline or restore a snapshot:
3522
3560
  import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
3523
3561
 
3524
3562
  // Register in your app's plugin list:
3525
- versioningPlugin({ tables: ['blogPost_published', 'blogPost_drafts'] })
3563
+ versioningPlugin({})
3526
3564
 
3527
3565
  const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
3528
3566
  await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
@@ -1556,6 +1556,37 @@ no-ops out.
1556
1556
 
1557
1557
  The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
1558
1558
 
1559
+ ## File-based migrations in this pipeline
1560
+
1561
+ `voltro db apply` runs pending `migrations/*.ts` **first**, then diffs — the same order the boot path uses.
1562
+
1563
+ `voltro db apply --plan plan.json` does **not** run them. It **refuses** when any are pending:
1564
+
1565
+ ```txt
1566
+ db apply --plan: refusing — 2 pending file-based migration(s).
1567
+ 20260714_090000_split_full_name
1568
+ 20260721_143000_backfill_slug
1569
+
1570
+ These perform the changes a state diff cannot infer, so they change the shape
1571
+ this plan was computed against. Apply them and regenerate the plan:
1572
+
1573
+ voltro db files .
1574
+ voltro db plan --json > plan.json
1575
+ voltro db apply --plan plan.json
1576
+ ```
1577
+
1578
+ That is not caution for its own sake. A saved plan was computed and reviewed against an earlier state; a file migration performs exactly the kind of change (a table split, a cross-table data move) that makes the plan stale. Running the migrations first would trip the fingerprint guard immediately afterwards and leave a half-applied deploy; running them after would apply a plan reviewed against a state that no longer exists.
1579
+
1580
+ **So a pipeline that uses the saved-plan form needs `voltro db files` as its own step**, before the plan is generated:
1581
+
1582
+ ```bash
1583
+ voltro db files . # authored data steps
1584
+ voltro db plan --json > plan.json # diff, now against the corrected shape
1585
+ voltro db apply --plan plan.json # reviewed, fingerprint-guarded
1586
+ ```
1587
+
1588
+ If you use plain `voltro db apply` instead, the first step is already included.
1589
+
1559
1590
 
1560
1591
 
1561
1592
  ---
@@ -3201,6 +3232,35 @@ Fix:
3201
3232
  resolves the root from the first non-flag argument, defaulting to
3202
3233
  the current working directory
3203
3234
 
3235
+ ## "N reactive table(s) have NO change trigger in the database" (postgres)
3236
+
3237
+ ```text
3238
+ auto-migrate: 500 reactive table(s) have NO change trigger in the database —
3239
+ ab_test_results, ab_test_variants, ab_tests, … (+492). Writes to them will not
3240
+ reach another instance's subscribers; a single instance is unaffected, which is
3241
+ why this stays invisible until you scale out. Run `voltro db apply` to install them.
3242
+ ```
3243
+
3244
+ On postgres, reactivity is carried by DDL: a per-table `framework_changes_<table>` trigger that `NOTIFY`s the CDC channel. The declared schema and the database can disagree about which tables have one.
3245
+
3246
+ **Run `voltro db apply`.** It converges the triggers as its own step, and it does so **even when the schema diff is empty** — the usual case here, because a missing trigger is not a shape difference and `db plan` will correctly report `0 operations`:
3247
+
3248
+ ```text
3249
+ $ voltro db apply
3250
+ schema diff: 0 operations, 0 blocked
3251
+ (schema is up to date)
3252
+ db apply: installing change triggers on 500 table(s)
3253
+ db apply: change triggers converged (1501 statement(s))
3254
+ ```
3255
+
3256
+ `db apply --plan` converges them too, so the pre-deploy Job pattern needs no extra step.
3257
+
3258
+ **Why a table ends up without one.** The trigger DDL is emitted by the full-schema path — a fresh database — so any table that arrived while your app was already running, or during a release that installed none, has no trigger. A restored dump can do it too (triggers travel with a full dump, but not with a schema-only or `--no-triggers` one), as can a hand-run `DROP TRIGGER` during an incident.
3259
+
3260
+ **Why it stays invisible.** A single instance's own writes reach its own subscribers through the in-process path. The trigger is what carries a write to the *other* instances, so the symptom only appears when you scale out — subscriptions that quietly stop updating, with nothing in the logs.
3261
+
3262
+ The mirror case is reported the same way: a `.nonReactive()` table that still carries a trigger keeps paying `REPLICA IDENTITY FULL` and a `NOTIFY` on every write for a subscription nobody receives. `db apply` removes both.
3263
+
3204
3264
  ## When the fix hint doesn't match reality
3205
3265
 
3206
3266
  The fix hints come from the planner's classification logic — they should always be actionable. If you see one that doesn't make sense given your code:
@@ -3219,3 +3279,66 @@ Hint mismatches are bugs in the planner's classification — they're rare but al
3219
3279
  - [Drift](./drift.md) — when the live DB diverged
3220
3280
  - [Multi-dialect strategy](./multi-dialect.md) — why MySQL + forward-roll
3221
3281
  - [Prod pipeline](./prod-pipeline.md) — the deploy-step apply pattern
3282
+
3283
+
3284
+
3285
+ ---
3286
+
3287
+ <!-- source: en/database/migrations/adopt.md -->
3288
+ ## Adopting a table into a plugin's
3289
+
3290
+ _voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last._
3291
+
3292
+ An app that did not start on a green field already has a table for half the plugins it installs. `voltro db adopt` is the way **into** the plugin's table, so using the plugin does not mean running a second source of truth beside it.
3293
+
3294
+ ```bash
3295
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
3296
+ voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply
3297
+ ```
3298
+
3299
+ **Dry run unless you pass `--apply`.** The interesting failure here is irreversible and the interesting output is the refusal, so nothing is written until you say so. A refused plan prints no steps at all, rather than a preview of something that will not happen.
3300
+
3301
+ ## The map file is yours
3302
+
3303
+ ```ts
3304
+ // ai-flows.map.ts
3305
+ export default {
3306
+ map: {
3307
+ name: 'name',
3308
+ mode: { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
3309
+ costMicroUsd: { expr: '"totalCostCents" * 10000' },
3310
+ },
3311
+ leaveUnset: ['id'],
3312
+ }
3313
+ ```
3314
+
3315
+ Read `target: source` — fill the plugin's column FROM this expression of mine, which is the direction the SQL runs. A string is a source column; `{ expr }` is raw SQL against the source row, for the unit conversions and merged fields no tool can infer. Those are domain knowledge, and a tool inventing them silently corrupts data.
3316
+
3317
+ `leaveUnset` is how "the target's own default fills this" stops looking like "I forgot it".
3318
+
3319
+ ## What it refuses
3320
+
3321
+ - **a NOT NULL target column nobody maps to** — the alternative is a silent zero that reads as real data forever after;
3322
+ - **a target table that already holds rows** — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
3323
+ - **a typo on either side of the map.**
3324
+
3325
+ A source column nobody carries across is reported but not fatal: dropping a dead column is deliberate often enough, and "I forgot this" and "I decided" look identical in a map file.
3326
+
3327
+ ## The order, and why the drop is last
3328
+
3329
+ 1. **snapshot** — `<table>__adopt_snapshot`, a real table in the same database, so restoring is a statement rather than an operational procedure at 2am. It keeps the columns the adopt left behind.
3330
+ 2. **copy**
3331
+ 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows.
3332
+ 4. **drop the source** — last, and only if the counts match.
3333
+
3334
+ On a mismatch **both tables stay** and the command says so. The snapshot is never removed after a failed verify — it exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
3335
+
3336
+ ## Ids, if the typeid prefixes differ
3337
+
3338
+ The dry run says so before anything runs, because discovering it after the copy is discovering it too late: every row gets a new id, so every reference to the old table has to be rewritten — **including ids embedded in JSON columns**.
3339
+
3340
+ Rewriting them is **not** automatic. Those ids live in your columns and inside your JSON, and only you know where. The translation table is what the command owes you; the rewrite is what you owe yourself. Doing it automatically is the one place here where being wrong would be silent.
3341
+
3342
+ ## After the move
3343
+
3344
+ Your rows are now in a table whose shape the framework evolves — and nothing special happens to them. They migrate exactly like every other row, through the same declarative differ. A narrowing can fail on your data, loudly, the same way it would on anybody's.
@@ -225,6 +225,18 @@ DB_URL=file:./db.sqlite # or `:memory:` for ephemeral
225
225
 
226
226
  The `app.config.ts` `store:` field stays as the dev-friendly shortcut (`store: 'postgres'`, `store: 'memory'`) — env always wins.
227
227
 
228
+ ### Query timeout — bound a runaway query
229
+
230
+ ```sh
231
+ DB_STATEMENT_TIMEOUT_MS=30000 # cancel any single query after 30s
232
+ ```
233
+
234
+ A missing index or an accidental cartesian join can run for minutes, and while it does it **pins a pooled connection**. Enough of them under load and the pool is exhausted — every other request now waits on a connection that will never free, and the whole app stalls. `DB_STATEMENT_TIMEOUT_MS` puts a ceiling on it: a query that outlasts the deadline is cancelled, its connection returns to the pool, and the caller gets a normal error instead of a hang.
235
+
236
+ It applies to the **runtime query path only**. Migrations (`voltro db apply`) run legitimately long statements — backfills, index builds — and are **never** cancelled by it. (Caveat: `voltro dev`'s boot auto-migrate shares the app connection, so a very slow dev migration under a low timeout would trip it — raise the value, or run `voltro db apply` first.)
237
+
238
+ **Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real, server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the variable but currently ignore it, and the reasons are honest rather than incidental: `@effect/sql-mssql` exposes only a connection-establishment timeout, not a per-request one; MySQL/MariaDB's `max_execution_time` bounds `SELECT`s only (writes stay unbounded), which would be a misleading half-guarantee; and SQLite is in-process with a single connection, so there is no pool to protect. Unset (or any non-postgres dialect) = no timeout.
239
+
228
240
  ## Local development — bring up all five
229
241
 
230
242
  The framework ships a docker-compose at `voltro/test/docker-compose.yml` that brings up postgres + mysql + mariadb + mssql on distinct ports so per-dialect tests can run side-by-side and the dev fixture never clashes with your starter postgres on `:5432`: