@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.
- package/CHANGELOG.md +427 -0
- package/bin/voltro.mjs +39 -4
- package/dist/{apiBuild-DiWxVz-M.js → apiBuild-B2m4XK_8.js} +45 -23
- package/dist/apiBuild-UEM3QBke.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-BxRaIOBG.js → commands-CaIhTsC2.js} +2944 -2557
- package/dist/{dbCommand-DVASmZj2.js → dbCommand-CC61CsAc.js} +474 -263
- package/dist/dbCommand-Cr__4ATv.js +2 -0
- package/dist/{dev-Dd3EZzj5.js → dev-DPkQVUTP.js} +1 -1
- package/dist/{dev-BRiPgbKw.js → dev-NCuEhNxs.js} +1593 -1575
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-9ZSuDeqD.js → inspectMetrics-dcC7gnxu.js} +566 -552
- package/dist/{serveCommand-DXJOARkO.js → serveCommand-C4gzDZzc.js} +425 -411
- package/dist/serveEntry.js +2 -2
- package/dist/{start-CsIjcOi-.js → start-C_4VUDT8.js} +327 -325
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/authentication.md +21 -0
- package/templates/agent-docs/caching.md +21 -5
- package/templates/agent-docs/cli.md +61 -0
- package/templates/agent-docs/data.md +208 -170
- package/templates/agent-docs/database/migrations.md +123 -0
- package/templates/agent-docs/database/seedsdialects.md +12 -0
- package/templates/agent-docs/deployment.md +66 -1
- package/templates/agent-docs/plugins.md +42 -0
- package/templates/agent-docs/routing.md +23 -1
- package/templates/agent-docs/templates/apibackends.md +2 -2
- package/templates/agent-docs/whats-new.md +281 -32
- package/templates/agent-docs/workflows.md +30 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/package.json +8 -7
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-versioning/app.config.ts +6 -4
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/changelog/package.json +7 -6
- package/templates/apps/edge-functions/package.json +3 -2
- package/templates/apps/frontend-admin/package.json +9 -8
- package/templates/apps/frontend-app/package.json +9 -8
- package/templates/apps/frontend-blank/package.json +8 -7
- package/templates/apps/frontend-contact/package.json +8 -7
- package/templates/apps/frontend-contact/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/package.json +8 -7
- package/templates/apps/frontend-docs/package.json +8 -7
- package/templates/apps/frontend-docs/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/package.json +7 -6
- package/templates/apps/frontend-landing/package.json +8 -7
- package/templates/apps/frontend-landing/src/globals.d.ts +6 -0
- package/templates/apps/frontend-spa/package.json +8 -7
- package/templates/apps/frontend-spa/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr/package.json +8 -7
- package/templates/apps/frontend-ssr/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/package.json +9 -8
- package/templates/apps/frontend-static-blog/package.json +7 -6
- package/templates/apps/frontend-static-blog/src/globals.d.ts +6 -0
- package/dist/apiBuild-COyPDf3R.js +0 -2
- 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({
|
|
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`:
|