@voltro/cli 0.23.0 → 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 +111 -0
- package/bin/voltro.mjs +39 -4
- package/dist/{apiBuild-JQtIhZPy.js → apiBuild-B2m4XK_8.js} +44 -22
- package/dist/apiBuild-UEM3QBke.js +2 -0
- package/dist/bin.js +2 -2
- package/dist/{commands-Bsu9Buln.js → commands-CaIhTsC2.js} +2565 -2444
- package/dist/{dbCommand-GI7-BVZt.js → dbCommand-CC61CsAc.js} +248 -218
- package/dist/dbCommand-Cr__4ATv.js +2 -0
- package/dist/{dev-DRxF_qOB.js → dev-DPkQVUTP.js} +1 -1
- package/dist/{dev-jM07kq_D.js → dev-NCuEhNxs.js} +1039 -1021
- package/dist/index.js +1 -1
- package/dist/{serveCommand-Cl8sicD5.js → serveCommand-C4gzDZzc.js} +349 -336
- package/dist/serveEntry.js +1 -1
- package/package.json +17 -17
- package/templates/agent-docs/authentication.md +21 -0
- package/templates/agent-docs/cli.md +17 -0
- package/templates/agent-docs/data.md +185 -165
- package/templates/agent-docs/database/migrations.md +92 -63
- package/templates/agent-docs/database/seedsdialects.md +12 -0
- package/templates/agent-docs/deployment.md +66 -1
- 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/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-CD-4JLLA.js +0 -2
- package/dist/dbCommand-CSguuUQn.js +0 -2
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Z as e } from "./inspectMetrics-dcC7gnxu.js";
|
|
2
2
|
import { c as t } from "./seedRunner-D6eu-u5U.js";
|
|
3
3
|
import { r as n } from "./appModuleLoader-C9r9mxZt.js";
|
|
4
|
-
import { t as r } from "./serveCommand-
|
|
4
|
+
import { t as r } from "./serveCommand-C4gzDZzc.js";
|
|
5
5
|
export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
|
@@ -62,22 +62,22 @@
|
|
|
62
62
|
"@effect/platform-node": "^0.108.0",
|
|
63
63
|
"@effect/sql": "^0.52.0",
|
|
64
64
|
"@effect/workflow": "^0.19.0",
|
|
65
|
-
"@voltro/ai": "0.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "0.
|
|
65
|
+
"@voltro/ai": "0.24.0",
|
|
66
|
+
"@voltro/cache": "0.24.0",
|
|
67
|
+
"@voltro/data-transfer": "0.24.0",
|
|
68
|
+
"@voltro/database": "0.24.0",
|
|
69
|
+
"@voltro/env": "0.24.0",
|
|
70
|
+
"@voltro/kv": "0.24.0",
|
|
71
|
+
"@voltro/logger": "0.24.0",
|
|
72
|
+
"@voltro/plugin-auth": "0.24.0",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.24.0",
|
|
74
|
+
"@voltro/plugin-mail": "0.24.0",
|
|
75
|
+
"@voltro/plugin-storage": "0.24.0",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.24.0",
|
|
77
|
+
"@voltro/protocol": "0.24.0",
|
|
78
|
+
"@voltro/runtime": "0.24.0",
|
|
79
|
+
"@voltro/serverless": "0.24.0",
|
|
80
|
+
"@voltro/workflow": "0.24.0",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
|
@@ -166,6 +166,27 @@ import { handleSignIn } from '@voltro/plugin-auth'
|
|
|
166
166
|
|
|
167
167
|
Use `handleSignIn` instead of rolling your own — the timing-oracle gap is the kind of subtle bug that hides for years. Note it returns a `401 HandlerResult`, it does not throw a domain error.
|
|
168
168
|
|
|
169
|
+
## Brute-force lockout
|
|
170
|
+
|
|
171
|
+
`plugin-auth` locks an account after repeated failed credential attempts, so password-spraying and credential-stuffing don't get unlimited guesses. After **5 failed attempts** (a wrong password — or, for MFA users, a wrong second-factor code) within **15 minutes**, sign-in for that email is refused with a `429 account_locked` (carrying a `retryAfterSeconds`) for **15 minutes**. A completed login clears the counter.
|
|
172
|
+
|
|
173
|
+
The counter is keyed by **email**, not user id, and it tracks unknown addresses too: a locked account and an unknown-but-hammered address respond identically, so the lock can't be turned into an existence oracle — the same reasoning as the timing-oracle defence above.
|
|
174
|
+
|
|
175
|
+
It is **on by default** — a security default you get for free. Tune or disable it per app:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
authRoutesPlugin({
|
|
179
|
+
store,
|
|
180
|
+
lockout: {
|
|
181
|
+
maxAttempts: 5, // failed attempts before locking (default 5)
|
|
182
|
+
windowSeconds: 900, // counting window (default 15 min)
|
|
183
|
+
lockSeconds: 900, // lock duration (default 15 min)
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The counter lives in the `loginAttempts` table (contributed via `authTables`), so it appears automatically on your next `voltro db apply` / `voltro dev` boot. For lockout that holds across multiple nodes, back the store with Postgres (`postgresUserStore`) — the in-memory store is single-node.
|
|
189
|
+
|
|
169
190
|
## Rehashing on parameter bump
|
|
170
191
|
|
|
171
192
|
When the framework updates the default cost parameters, existing hashes stay valid — `verifyPassword` reads `N`/`r`/`p` from the stored hash string itself (they're encoded inline as `scrypt$<N>$<r>$<p>$…`). Rehash-on-verify ships: `needsRehash(stored)` reports whether a hash is below the current cost, and `verifyPasswordWithRehash(plaintext, stored)` returns `{ valid, rehash? }` — when the password matches an under-cost hash, `rehash` is a freshly-minted replacement. `handleSignIn` wires this through `UserStore.updatePassword`, so a user's stored hash silently strengthens on their next login, no forced reset and no backfill.
|
|
@@ -2174,6 +2174,23 @@ voltro data restore ./backups/2026-07-01 # pg_restore / mysql / …
|
|
|
2174
2174
|
|
|
2175
2175
|
These shell out to the vendor tools resolved from your `DB_DIALECT` + connection env. They produce a dialect-native artifact (`db.dump`, `db.sql`, `db.sqlite`, `db.bacpac`) that is lossless and point-in-time consistent for **same-dialect** restore — the right tool for disaster recovery. Secrets are passed via the tools' environment variables (`PGPASSWORD`, `MYSQL_PWD`), never on the command line, where the tool supports it. The named tool must be installed and on `PATH`.
|
|
2176
2176
|
|
|
2177
|
+
### The provenance stamp — a restore that refuses the wrong DB
|
|
2178
|
+
|
|
2179
|
+
A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the live schema `fingerprint`, the `@voltro/cli` version, and the timestamp.
|
|
2180
|
+
|
|
2181
|
+
`restore` reads the stamp **before touching the DB** and acts on two failures that are otherwise silent until they corrupt:
|
|
2182
|
+
|
|
2183
|
+
- **Cross-dialect restore → refused.** Restoring a postgres dump while `DB_DIALECT=mysql` is never valid; it stops with an error instead of half-loading. Override with `--force` only if you genuinely know better.
|
|
2184
|
+
- **Schema/code skew → warned.** If the backup's schema fingerprint differs from what the running code declares, restore prints a warning to run `voltro db apply` afterwards — the dump's shape predates (or postdates) this deploy's code. (Production boot already refuses on a fingerprint mismatch; the stamp surfaces it at restore time, before the boot.)
|
|
2185
|
+
|
|
2186
|
+
A backup with no stamp (older, or hand-made) restores with a caution rather than a hard stop.
|
|
2187
|
+
|
|
2188
|
+
### Point-in-time recovery (PITR) is your database's job, not the framework's
|
|
2189
|
+
|
|
2190
|
+
`backup` is a point-in-time **snapshot**. "Restore to 14:32, just before the bad deploy" (PITR) needs continuous WAL/binlog archiving, which lives at the database/provider layer — pg's `archive_command` + a base backup (pgBackRest / WAL-G), a managed provider's continuous backup (RDS, Cloud SQL, Neon, PlanetScale). The framework deliberately does **not** reimplement it: layer PITR under these native snapshots at the infra layer. A weekly `voltro data backup` + provider PITR together give you both a portable artifact and a fine-grained restore point.
|
|
2191
|
+
|
|
2192
|
+
> **Test your backups.** A backup you've never restored is a hypothesis. Restore your latest artifact into a throwaway database and boot the app against it on a schedule — the stamp's dialect/fingerprint checks turn a silently-broken backup into a loud one, but only an actual restore proves the bytes are good.
|
|
2193
|
+
|
|
2177
2194
|
## Masking (prod → dev/stage safely)
|
|
2178
2195
|
|
|
2179
2196
|
Cloning prod into a lower environment must not carry real user data. `voltro data export`
|
|
@@ -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
|
|
|
@@ -2123,171 +2143,6 @@ When no analytics sink is configured the framework provides the no-op sink: the
|
|
|
2123
2143
|
|
|
2124
2144
|
|
|
2125
2145
|
|
|
2126
|
-
---
|
|
2127
|
-
|
|
2128
|
-
<!-- source: en/data/client-state.md -->
|
|
2129
|
-
## Client state (defineStore)
|
|
2130
|
-
|
|
2131
|
-
_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._
|
|
2132
|
-
|
|
2133
|
-
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.
|
|
2134
|
-
|
|
2135
|
-
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.
|
|
2136
|
-
|
|
2137
|
-
```ts
|
|
2138
|
-
// wizard.store.ts
|
|
2139
|
-
import { defineStore } from '@voltro/client'
|
|
2140
|
-
|
|
2141
|
-
export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
|
|
2142
|
-
```
|
|
2143
|
-
|
|
2144
|
-
```tsx
|
|
2145
|
-
const step = wizard.use((s) => s.step) // the global instance
|
|
2146
|
-
const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
2147
|
-
|
|
2148
|
-
wizard.set({ step: 2 })
|
|
2149
|
-
wizard.set((s) => ({ ...s, step: s.step + 1 }))
|
|
2150
|
-
```
|
|
2151
|
-
|
|
2152
|
-
## Reads go through a selector — there is no `useStore()`
|
|
2153
|
-
|
|
2154
|
-
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:
|
|
2155
|
-
|
|
2156
|
-
```tsx
|
|
2157
|
-
const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
|
|
2158
|
-
const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
|
|
2159
|
-
```
|
|
2160
|
-
|
|
2161
|
-
### Computed values need `equals: shallow`
|
|
2162
|
-
|
|
2163
|
-
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:
|
|
2164
|
-
|
|
2165
|
-
```tsx
|
|
2166
|
-
import { shallow } from '@voltro/client'
|
|
2167
|
-
|
|
2168
|
-
const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
|
|
2169
|
-
const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
|
|
2170
|
-
```
|
|
2171
|
-
|
|
2172
|
-
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.
|
|
2173
|
-
|
|
2174
|
-
## Scoping is by key, not by a Provider
|
|
2175
|
-
|
|
2176
|
-
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:
|
|
2177
|
-
|
|
2178
|
-
```tsx
|
|
2179
|
-
wizard.use((s) => s.step, { key: orderId })
|
|
2180
|
-
```
|
|
2181
|
-
|
|
2182
|
-
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.
|
|
2183
|
-
|
|
2184
|
-
`wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
|
|
2185
|
-
|
|
2186
|
-
## SSR seeding adds no new channel
|
|
2187
|
-
|
|
2188
|
-
Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
|
|
2189
|
-
|
|
2190
|
-
```ts
|
|
2191
|
-
export const loader = async ({ params }) => {
|
|
2192
|
-
seedStore(wizard, { step: 2 }, { key: params.orderId })
|
|
2193
|
-
return { /* … */ }
|
|
2194
|
-
}
|
|
2195
|
-
```
|
|
2196
|
-
|
|
2197
|
-
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.
|
|
2198
|
-
|
|
2199
|
-
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.
|
|
2200
|
-
|
|
2201
|
-
**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.
|
|
2202
|
-
|
|
2203
|
-
## Surviving a reload
|
|
2204
|
-
|
|
2205
|
-
`persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
|
|
2206
|
-
|
|
2207
|
-
```tsx
|
|
2208
|
-
export const filters = defineStore(
|
|
2209
|
-
'inbox:filters',
|
|
2210
|
-
() => ({ status: 'open', sort: 'newest', draft: '' }),
|
|
2211
|
-
{
|
|
2212
|
-
persist: {
|
|
2213
|
-
key: 'inbox:filters',
|
|
2214
|
-
storage: 'local', // 'session' lasts the tab
|
|
2215
|
-
pick: (s) => ({ status: s.status, sort: s.sort }),
|
|
2216
|
-
migrate: (stored) => (isFilters(stored) ? stored : undefined),
|
|
2217
|
-
},
|
|
2218
|
-
},
|
|
2219
|
-
)
|
|
2220
|
-
```
|
|
2221
|
-
|
|
2222
|
-
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:
|
|
2223
|
-
|
|
2224
|
-
**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.
|
|
2225
|
-
|
|
2226
|
-
**`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.
|
|
2227
|
-
|
|
2228
|
-
**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.
|
|
2229
|
-
|
|
2230
|
-
**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.
|
|
2231
|
-
|
|
2232
|
-
`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.
|
|
2233
|
-
|
|
2234
|
-
## Actions that write more than once
|
|
2235
|
-
|
|
2236
|
-
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:
|
|
2237
|
-
|
|
2238
|
-
```ts
|
|
2239
|
-
checkout.batch('applyCoupon', () => {
|
|
2240
|
-
checkout.set({ coupon })
|
|
2241
|
-
checkout.set({ total: recompute(coupon) })
|
|
2242
|
-
})
|
|
2243
|
-
```
|
|
2244
|
-
|
|
2245
|
-
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.
|
|
2246
|
-
|
|
2247
|
-
**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.
|
|
2248
|
-
|
|
2249
|
-
### Async work goes around the batch, not inside it
|
|
2250
|
-
|
|
2251
|
-
```ts
|
|
2252
|
-
const quote = await fetchQuote(coupon) // await FIRST
|
|
2253
|
-
checkout.batch('applyCoupon', () => { // then batch the writes
|
|
2254
|
-
checkout.set({ coupon, total: quote.total })
|
|
2255
|
-
})
|
|
2256
|
-
```
|
|
2257
|
-
|
|
2258
|
-
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.
|
|
2259
|
-
|
|
2260
|
-
## Undo and redo
|
|
2261
|
-
|
|
2262
|
-
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:
|
|
2263
|
-
|
|
2264
|
-
```tsx
|
|
2265
|
-
draft.undo(orderId) // back one write
|
|
2266
|
-
draft.redo(orderId) // forward again
|
|
2267
|
-
draft.canUndo(orderId) // for disabling the button
|
|
2268
|
-
draft.canRedo(orderId)
|
|
2269
|
-
```
|
|
2270
|
-
|
|
2271
|
-
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.
|
|
2272
|
-
|
|
2273
|
-
An undo never becomes undoable itself, and each keyed instance has its own history.
|
|
2274
|
-
|
|
2275
|
-
## Devtools: inspect, and travel
|
|
2276
|
-
|
|
2277
|
-
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.
|
|
2278
|
-
|
|
2279
|
-
`◀ 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.
|
|
2280
|
-
|
|
2281
|
-
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.
|
|
2282
|
-
|
|
2283
|
-
## What a store must never hold
|
|
2284
|
-
|
|
2285
|
-
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.
|
|
2286
|
-
|
|
2287
|
-
Read server state where you render it, and keep the store for what is genuinely client-side.
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
2146
|
---
|
|
2292
2147
|
|
|
2293
2148
|
<!-- source: en/data/crud.md -->
|
|
@@ -2735,6 +2590,171 @@ into the agent's prompt.
|
|
|
2735
2590
|
|
|
2736
2591
|
|
|
2737
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
|
+
|
|
2738
2758
|
---
|
|
2739
2759
|
|
|
2740
2760
|
<!-- source: en/data/outbox.md -->
|
|
@@ -4,69 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
<!-- source: en/database/migrations/adopt.md -->
|
|
10
|
-
## Adopting a table into a plugin's
|
|
11
|
-
|
|
12
|
-
_voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last._
|
|
13
|
-
|
|
14
|
-
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.
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
|
|
18
|
-
voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
**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.
|
|
22
|
-
|
|
23
|
-
## The map file is yours
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
// ai-flows.map.ts
|
|
27
|
-
export default {
|
|
28
|
-
map: {
|
|
29
|
-
name: 'name',
|
|
30
|
-
mode: { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
|
|
31
|
-
costMicroUsd: { expr: '"totalCostCents" * 10000' },
|
|
32
|
-
},
|
|
33
|
-
leaveUnset: ['id'],
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
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.
|
|
38
|
-
|
|
39
|
-
`leaveUnset` is how "the target's own default fills this" stops looking like "I forgot it".
|
|
40
|
-
|
|
41
|
-
## What it refuses
|
|
42
|
-
|
|
43
|
-
- **a NOT NULL target column nobody maps to** — the alternative is a silent zero that reads as real data forever after;
|
|
44
|
-
- **a target table that already holds rows** — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
|
|
45
|
-
- **a typo on either side of the map.**
|
|
46
|
-
|
|
47
|
-
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.
|
|
48
|
-
|
|
49
|
-
## The order, and why the drop is last
|
|
50
|
-
|
|
51
|
-
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.
|
|
52
|
-
2. **copy**
|
|
53
|
-
3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows.
|
|
54
|
-
4. **drop the source** — last, and only if the counts match.
|
|
55
|
-
|
|
56
|
-
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.
|
|
57
|
-
|
|
58
|
-
## Ids, if the typeid prefixes differ
|
|
59
|
-
|
|
60
|
-
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**.
|
|
61
|
-
|
|
62
|
-
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.
|
|
63
|
-
|
|
64
|
-
## After the move
|
|
65
|
-
|
|
66
|
-
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.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
7
|
---
|
|
71
8
|
|
|
72
9
|
<!-- source: en/database/migrations/index.md -->
|
|
@@ -3295,6 +3232,35 @@ Fix:
|
|
|
3295
3232
|
resolves the root from the first non-flag argument, defaulting to
|
|
3296
3233
|
the current working directory
|
|
3297
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
|
+
|
|
3298
3264
|
## When the fix hint doesn't match reality
|
|
3299
3265
|
|
|
3300
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:
|
|
@@ -3313,3 +3279,66 @@ Hint mismatches are bugs in the planner's classification — they're rare but al
|
|
|
3313
3279
|
- [Drift](./drift.md) — when the live DB diverged
|
|
3314
3280
|
- [Multi-dialect strategy](./multi-dialect.md) — why MySQL + forward-roll
|
|
3315
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.
|