@voltro/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/dist/{apiBuild-B6_ehbem.js → apiBuild-CHItlwPO.js} +2 -2
  3. package/dist/apiBuild-D8juhYsS.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-DYOuGlEC.js → commands-CPQuQRXh.js} +3967 -2831
  6. package/dist/{dbCommand-DIOJQwsq.js → dbCommand-B8F29zV0.js} +2 -1
  7. package/dist/dbCommand-Dt7BtdMd.js +2 -0
  8. package/dist/{dev-xdijqojc.js → dev-BHLM3RPX.js} +1 -1
  9. package/dist/{dev-Fb9BmavX.js → dev-C2lCUhYI.js} +1478 -1347
  10. package/dist/fileConventions-Cu0tM-Bl.js +29 -0
  11. package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-BA2bAmYN.js} +164 -163
  12. package/dist/frameworkTableAssembly-D1zJSgOU.js +2 -0
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CYDSO3za.js} +955 -945
  15. package/dist/{serveCommand-CqYxzBZR.js → serveCommand-DBzQcCuv.js} +410 -396
  16. package/dist/serveEntry.js +2 -2
  17. package/dist/{start-CjG_0xhR.js → start-BtgatJN3.js} +246 -246
  18. package/dist/startEntry.js +2 -2
  19. package/package.json +17 -17
  20. package/templates/AGENTS.core.md +77 -3
  21. package/templates/AGENTS.md +78 -4
  22. package/templates/agent-docs/_index.md +1 -1
  23. package/templates/agent-docs/_manifest.json +2 -2
  24. package/templates/agent-docs/authentication.md +34 -2
  25. package/templates/agent-docs/cli.md +116 -2
  26. package/templates/agent-docs/data.md +195 -3
  27. package/templates/agent-docs/database/hosting.md +2 -2
  28. package/templates/agent-docs/database/migrations.md +77 -3
  29. package/templates/agent-docs/database/overview.md +11 -0
  30. package/templates/agent-docs/database/seedsdialects.md +53 -1
  31. package/templates/agent-docs/internationalization.md +3 -3
  32. package/templates/agent-docs/introduction.md +133 -2
  33. package/templates/agent-docs/plugins.md +2 -1
  34. package/templates/agent-docs/reference.md +4 -4
  35. package/templates/agent-docs/routing.md +48 -43
  36. package/templates/agent-docs/schema-driven-ui.md +14 -3
  37. package/templates/agent-docs/templates/apibackends.md +1 -8
  38. package/templates/agent-docs/templates/appshells.md +24 -24
  39. package/templates/agent-docs/testing.md +16 -1
  40. package/templates/agent-docs/whats-new.md +247 -39
  41. package/templates/apps/api-ai/database/schema.ts +0 -2
  42. package/templates/apps/api-ai/package.json +7 -7
  43. package/templates/apps/api-auth/package.json +8 -8
  44. package/templates/apps/api-backend/database/schema.ts +0 -3
  45. package/templates/apps/api-backend/package.json +7 -7
  46. package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/database/schema.ts +0 -3
  49. package/templates/apps/api-backend-mail/package.json +8 -8
  50. package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-storage/database/schema.ts +0 -3
  53. package/templates/apps/api-backend-storage/package.json +8 -8
  54. package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
  55. package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
  56. package/templates/apps/api-data-advanced/package.json +8 -8
  57. package/templates/apps/api-durable/database/schema.ts +0 -3
  58. package/templates/apps/api-durable/package.json +8 -8
  59. package/templates/apps/api-feature-flags/database/schema.ts +0 -1
  60. package/templates/apps/api-feature-flags/package.json +9 -9
  61. package/templates/apps/api-governance/database/schema.ts +0 -1
  62. package/templates/apps/api-governance/package.json +8 -8
  63. package/templates/apps/api-kv/database/schema.ts +0 -3
  64. package/templates/apps/api-kv/package.json +8 -8
  65. package/templates/apps/api-moderation/database/schema.ts +0 -2
  66. package/templates/apps/api-moderation/package.json +8 -8
  67. package/templates/apps/api-observability/database/schema.ts +0 -1
  68. package/templates/apps/api-observability/package.json +8 -8
  69. package/templates/apps/api-ratelimit/database/schema.ts +0 -1
  70. package/templates/apps/api-ratelimit/package.json +8 -8
  71. package/templates/apps/api-rbac/database/schema.ts +0 -2
  72. package/templates/apps/api-rbac/package.json +8 -8
  73. package/templates/apps/api-rest/database/schema.ts +0 -3
  74. package/templates/apps/api-rest/package.json +7 -7
  75. package/templates/apps/api-saas/database/schema.ts +0 -1
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-search/database/schema.ts +0 -1
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-versioning/database/schema.ts +0 -1
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/database/schema.ts +0 -1
  82. package/templates/apps/api-webhooks/package.json +9 -9
  83. package/templates/apps/changelog/package.json +6 -6
  84. package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
  85. package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  86. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  87. package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  88. package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  89. package/templates/apps/edge-functions/package.json +2 -2
  90. package/templates/apps/frontend-admin/package.json +8 -8
  91. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  92. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  93. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  94. package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
  95. package/templates/apps/frontend-app/package.json +8 -8
  96. package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  97. package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
  98. package/templates/apps/frontend-blank/package.json +7 -7
  99. package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  100. package/templates/apps/frontend-contact/package.json +7 -7
  101. package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  102. package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  103. package/templates/apps/frontend-dashboard/package.json +7 -7
  104. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  105. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  106. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
  107. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
  108. package/templates/apps/frontend-docs/package.json +7 -7
  109. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
  110. package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  111. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  112. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
  113. package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  114. package/templates/apps/frontend-i18n/package.json +6 -6
  115. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
  116. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  117. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  118. package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
  119. package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  120. package/templates/apps/frontend-landing/package.json +7 -7
  121. package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  122. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  123. package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  124. package/templates/apps/frontend-spa/package.json +7 -7
  125. package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  126. package/templates/apps/frontend-ssr/package.json +7 -7
  127. package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
  128. package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
  129. package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  130. package/templates/apps/frontend-ssr-api/package.json +8 -8
  131. package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  132. package/templates/apps/frontend-static-blog/package.json +6 -6
  133. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
  134. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  135. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  136. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  137. package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  138. package/dist/apiBuild-Bzpb383i.js +0 -2
  139. package/dist/dbCommand-ADD4mrTI.js +0 -2
  140. package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
  141. /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
  142. /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
  143. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  144. /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  145. /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
  146. /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
  147. /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
  148. /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
  149. /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
  150. /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
  151. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  152. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  153. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
  154. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
  155. /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
  156. /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
  157. /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
  158. /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
  159. /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
  160. /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
  161. /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
  162. /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
  163. /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
  164. /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
  165. /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
  166. /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
@@ -911,10 +911,10 @@ Throw a matching error from the server file; the client can narrow on `_tag`.
911
911
 
912
912
  Tagged errors **round-trip structurally** over the wire — the caught value carries `_tag` plus every declared field as real properties (and `instanceof` works, same Schema class both ends). You do **not** need to parse the error message string.
913
913
 
914
- Inside Effect, use `Effect.catchTag('NoteQuotaExceeded', …)`. In a React `try/catch` (outside Effect, where `catchTag` isn't available and the decoded value may be a plain object, not a class instance), match with **`errorTag(err)`** — the dependency-free tag reader `@voltro/client` ships:
914
+ Inside Effect, use `Effect.catchTag('NoteQuotaExceeded', …)`. In a React `try/catch` (outside Effect, where `catchTag` isn't available and the decoded value may be a plain object, not a class instance), match with **`errorTag(err)`** — the dependency-free tag reader `@voltro/protocol` ships (it reads what `toRpc` writes):
915
915
 
916
916
  ```ts
917
- import { errorTag } from '@voltro/client'
917
+ import { errorTag } from '@voltro/protocol'
918
918
 
919
919
  try {
920
920
  await createNote(input)
@@ -971,6 +971,27 @@ const echo = useAction('app', 'demo.echo')
971
971
  await echo.run({ message }) // one call → one typed result
972
972
  ```
973
973
 
974
+ ### Actions declare what they touch
975
+
976
+ An action is non-transactional external I/O, and it often touches a table on the
977
+ way — a cache it fills, a job row it stamps. Declare it the same way a query and
978
+ a mutation do:
979
+
980
+ ```ts
981
+ export const syncIssue = defineAction({
982
+ name: 'jira.syncIssue',
983
+ input: Schema.Struct({ key: Schema.String }),
984
+ output: Schema.Struct({ ok: Schema.Boolean }),
985
+ source: 'jiraIssueCache',
986
+ target: { table: 'jiraIssueCache', op: 'upsert' },
987
+ })
988
+ ```
989
+
990
+ Without it the table is invisible to `voltro check`, which then reports it as an
991
+ orphan. While any action declares neither, the orphan rule says so and asks for
992
+ the declaration rather than proposing you delete the table — a wrong finding
993
+ whose remedy is destructive is worse than a wrong finding.
994
+
974
995
  ## Action Pair
975
996
 
976
997
  Descriptor:
@@ -1207,6 +1228,12 @@ _How reactive query subscriptions stay live over WebSocket._
1207
1228
 
1208
1229
  A **subscription** is what the browser gets when it calls `useSubscription(...)` for a `defineQuery` RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.
1209
1230
 
1231
+ Every table is reactive by default, so a query over any table is live with
1232
+ nothing to configure. A table explicitly marked
1233
+ [`.nonReactive()`](/docs/database/overview) emits no change events at all — a
1234
+ subscription over one returns its first snapshot and then stays silent forever,
1235
+ which is why `voltro dev` warns about that combination at boot.
1236
+
1210
1237
  Subscriptions are not a separate file convention anymore. The file convention is [queries](/docs/data/queries): `*.query.ts` for the descriptor and `*.query.server.ts` for the executor.
1211
1238
 
1212
1239
  Live — add a todo (or open this page in a second tab) and the list updates with
@@ -2012,6 +2039,171 @@ When no analytics sink is configured the framework provides the no-op sink: the
2012
2039
 
2013
2040
 
2014
2041
 
2042
+ ---
2043
+
2044
+ <!-- source: en/data/client-state.md -->
2045
+ ## Client state (defineStore)
2046
+
2047
+ _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._
2048
+
2049
+ 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.
2050
+
2051
+ 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.
2052
+
2053
+ ```ts
2054
+ // wizard.store.ts
2055
+ import { defineStore } from '@voltro/client'
2056
+
2057
+ export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))
2058
+ ```
2059
+
2060
+ ```tsx
2061
+ const step = wizard.use((s) => s.step) // the global instance
2062
+ const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
2063
+
2064
+ wizard.set({ step: 2 })
2065
+ wizard.set((s) => ({ ...s, step: s.step + 1 }))
2066
+ ```
2067
+
2068
+ ## Reads go through a selector — there is no `useStore()`
2069
+
2070
+ 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:
2071
+
2072
+ ```tsx
2073
+ const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
2074
+ const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render this
2075
+ ```
2076
+
2077
+ ### Computed values need `equals: shallow`
2078
+
2079
+ 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:
2080
+
2081
+ ```tsx
2082
+ import { shallow } from '@voltro/client'
2083
+
2084
+ const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
2085
+ const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })
2086
+ ```
2087
+
2088
+ 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.
2089
+
2090
+ ## Scoping is by key, not by a Provider
2091
+
2092
+ 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:
2093
+
2094
+ ```tsx
2095
+ wizard.use((s) => s.step, { key: orderId })
2096
+ ```
2097
+
2098
+ 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.
2099
+
2100
+ `wizard.release(orderId)` drops an instance; `wizard.keys()` lists the live ones.
2101
+
2102
+ ## SSR seeding adds no new channel
2103
+
2104
+ Call `seedStore` anywhere on the server during a render and the value reaches the client's first render:
2105
+
2106
+ ```ts
2107
+ export const loader = async ({ params }) => {
2108
+ seedStore(wizard, { step: 2 }, { key: params.orderId })
2109
+ return { /* … */ }
2110
+ }
2111
+ ```
2112
+
2113
+ 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.
2114
+
2115
+ 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.
2116
+
2117
+ **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.
2118
+
2119
+ ## Surviving a reload
2120
+
2121
+ `persist` writes the state to `localStorage` (or `sessionStorage`) on every change and reads it back when the store is defined:
2122
+
2123
+ ```tsx
2124
+ export const filters = defineStore(
2125
+ 'inbox:filters',
2126
+ () => ({ status: 'open', sort: 'newest', draft: '' }),
2127
+ {
2128
+ persist: {
2129
+ key: 'inbox:filters',
2130
+ storage: 'local', // 'session' lasts the tab
2131
+ pick: (s) => ({ status: s.status, sort: s.sort }),
2132
+ migrate: (stored) => (isFilters(stored) ? stored : undefined),
2133
+ },
2134
+ },
2135
+ )
2136
+ ```
2137
+
2138
+ 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:
2139
+
2140
+ **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.
2141
+
2142
+ **`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.
2143
+
2144
+ **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.
2145
+
2146
+ **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.
2147
+
2148
+ `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.
2149
+
2150
+ ## Actions that write more than once
2151
+
2152
+ 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:
2153
+
2154
+ ```ts
2155
+ checkout.batch('applyCoupon', () => {
2156
+ checkout.set({ coupon })
2157
+ checkout.set({ total: recompute(coupon) })
2158
+ })
2159
+ ```
2160
+
2161
+ 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.
2162
+
2163
+ **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.
2164
+
2165
+ ### Async work goes around the batch, not inside it
2166
+
2167
+ ```ts
2168
+ const quote = await fetchQuote(coupon) // await FIRST
2169
+ checkout.batch('applyCoupon', () => { // then batch the writes
2170
+ checkout.set({ coupon, total: quote.total })
2171
+ })
2172
+ ```
2173
+
2174
+ 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.
2175
+
2176
+ ## Undo and redo
2177
+
2178
+ 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:
2179
+
2180
+ ```tsx
2181
+ draft.undo(orderId) // back one write
2182
+ draft.redo(orderId) // forward again
2183
+ draft.canUndo(orderId) // for disabling the button
2184
+ draft.canRedo(orderId)
2185
+ ```
2186
+
2187
+ 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.
2188
+
2189
+ An undo never becomes undoable itself, and each keyed instance has its own history.
2190
+
2191
+ ## Devtools: inspect, and travel
2192
+
2193
+ 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.
2194
+
2195
+ `◀ 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.
2196
+
2197
+ 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.
2198
+
2199
+ ## What a store must never hold
2200
+
2201
+ 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.
2202
+
2203
+ Read server state where you render it, and keep the store for what is genuinely client-side.
2204
+
2205
+
2206
+
2015
2207
  ---
2016
2208
 
2017
2209
  <!-- source: en/data/crud.md -->
@@ -3098,7 +3290,7 @@ export const blogPost = defineContentType({
3098
3290
  register them in your `database/index.ts` so auto-migrate creates them. Both
3099
3291
  carry the lifecycle `status` column (`draft` / `published` / `archived`), the
3100
3292
  `tenant()` mixin (tenant-scoped reads/writes through the request store), and
3101
- are `.reactive()` so a publish wakes live subscriptions.
3293
+ are reactive (the default) so a publish wakes live subscriptions.
3102
3294
 
3103
3295
  ## Reading — `ctx.cms`
3104
3296
 
@@ -657,11 +657,11 @@ DB_URL=postgresql://tsdbadmin:[PASSWORD]@[SERVICE].[PROJECT].tsdb.cloud.timescal
657
657
 
658
658
  Voltro's schema DSL emits ordinary Postgres tables. To turn one into a TimescaleDB hypertable (chunked by time for fast time-series queries), run `create_hypertable(...)` yourself after the table exists — via a custom migration or a `*.seed.ts` that issues the SQL. The framework's `voltro migrate` doesn't generate hypertable DDL; it's a deliberate, per-table decision.
659
659
 
660
- A table you convert to a hypertable still works with the reactive engine and `ctx.store` exactly as before — the conversion is transparent to Voltro's read/write path. Heavy time-series ingestion tables are usually NOT the ones you mark `.reactive()` (you don't want a subscription firing on every metric insert), so the two concerns rarely collide.
660
+ A table you convert to a hypertable still works with the reactive engine and `ctx.store` exactly as before — the conversion is transparent to Voltro's read/write path. Heavy time-series ingestion tables are usually the ones you mark `.nonReactive()` (you don't want a notification firing on every metric insert), so the two concerns rarely collide.
661
661
 
662
662
  ## Enabling CDC
663
663
 
664
- Timescale is real Postgres, so Voltro's change-data-capture is `LISTEN/NOTIFY` and **on by default** (`CDC=1`) — no extension, no flag, and TimescaleDB's presence changes nothing here. Timescale Cloud puts **no transaction pooler in front by default**, so the `DB_URL` you connect with is session-mode and reactivity works immediately. (If you mark a hypertable `.reactive()`, every chunk insert fires a notification — usually you don't want that on high-ingest time-series tables; see the hypertable note above.)
664
+ Timescale is real Postgres, so Voltro's change-data-capture is `LISTEN/NOTIFY` and **on by default** (`CDC=1`) — no extension, no flag, and TimescaleDB's presence changes nothing here. Timescale Cloud puts **no transaction pooler in front by default**, so the `DB_URL` you connect with is session-mode and reactivity works immediately. (Every table is reactive by default, so on a hypertable every chunk insert fires a notification — usually not what you want on high-ingest time-series tables. Mark it `.nonReactive()`; see the hypertable note above.)
665
665
 
666
666
  ## Pooling / SSL
667
667
 
@@ -435,6 +435,27 @@ VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring users table after migra
435
435
 
436
436
  For complex multi-table retirements (move data out, then drop), use a [file-based migration](./file-based.md) — explicit ordering + a transaction wrapped around the data move.
437
437
 
438
+ ### Name the tables, not the whole run
439
+
440
+ `VOLTRO_DESTRUCTIVE_OK=1` acknowledges **every** lossy op in the plan. That is
441
+ rarely what you mean — a plan with one intended drop and three other lossy ops
442
+ would have all four approved by a single `1`. Give it a comma-separated list
443
+ instead:
444
+
445
+ ```sh
446
+ # Only these tables — every other lossy op in the plan stays refused.
447
+ VOLTRO_DESTRUCTIVE_OK=users,legacy_notes voltro db apply --note 'retiring the pre-migration tables'
448
+ ```
449
+
450
+ An op the list does not name stays blocked, and a plan with anything still
451
+ blocked is refused as a whole. Half a plan applied is how a schema ends up in a
452
+ state neither the declaration nor the database describes.
453
+
454
+ There is deliberately **no `.dropped()` marker for a table**, unlike for a
455
+ column. A dropped column leaves a slot worth documenting in the declaration; a
456
+ dropped table leaves nothing, so the marker would be a dead entry you have to
457
+ remember to delete.
458
+
438
459
  ## `.narrowedFrom()` for type changes
439
460
 
440
461
  A bare column type change is **refuse-to-plan** — the planner blocks it
@@ -1132,6 +1153,59 @@ If `down` throws, the rollback is treated as failed — the schema stays
1132
1153
  in the half-rolled-back state + the operator handles it manually. The
1133
1154
  framework can't auto-recover from a broken inverse.
1134
1155
 
1156
+ ## Remote databases: boot will not apply them unattended
1157
+
1158
+ `voltro dev` applies pending migration files at boot. Against a local
1159
+ database that is the whole point of the escape hatch. Against a remote
1160
+ one it means that saving a file is enough to change production — before
1161
+ review, before CI, without typing a command.
1162
+
1163
+ So when the configured database is **not local** and there are pending
1164
+ migration files, dev boot refuses instead:
1165
+
1166
+ ```text
1167
+ file-based migrations: refusing to auto-apply 1 file-based migration(s)
1168
+ to a REMOTE database (ep-cool-dawn.eu-central-1.aws.neon.tech).
1169
+
1170
+ Pending:
1171
+ • 20260725_150000_drop_legacy_subscriptions
1172
+ ```
1173
+
1174
+ A database counts as **local** when its host is loopback
1175
+ (`localhost`, `127.0.0.1`, `::1`), a private LAN address
1176
+ (`10.x`, `192.168.x`, `172.16–31.x`), `host.docker.internal`, a `.local`
1177
+ / `.localhost` name, a `file:` / `sqlite:` URL, or a **bare hostname**
1178
+ like `postgres` or `db` — only a container network resolves those, so
1179
+ `docker compose up` keeps working untouched. Everything else — a managed
1180
+ provider, any dotted public hostname, an unparseable `DB_URL` — counts as
1181
+ remote.
1182
+
1183
+ Three ways forward:
1184
+
1185
+ ```sh
1186
+ # 1. Point the app at a local database (what dev boot assumes)
1187
+ DB_URL=postgres://app:app@localhost:5432/app voltro dev
1188
+
1189
+ # 2. Apply them deliberately, once
1190
+ voltro db files .
1191
+
1192
+ # 3. Accept unattended applies for this environment
1193
+ VOLTRO_REMOTE_MIGRATIONS_OK=1 voltro dev
1194
+ ```
1195
+
1196
+ The gate is **silent when nothing is pending**, which is the normal
1197
+ case — running dev against a remote database is unaffected until the
1198
+ moment a file would actually execute against it. It refuses rather than
1199
+ skipping quietly: a skipped migration leaves the database in a shape the
1200
+ app does not expect (a half-done table split, a column the handlers
1201
+ already read), and the failures that follow point everywhere except at
1202
+ the cause.
1203
+
1204
+ It does **not** try to detect destructive SQL. In arbitrary SQL that is
1205
+ not decidable, so such a check would be either leaky or noisy. What the
1206
+ gate separates is the two things boot used to conflate: *saving a file*
1207
+ and *applying it to production*.
1208
+
1135
1209
  ## Applying file-based migrations from the CLI
1136
1210
 
1137
1211
  `voltro db files` applies pending file-based migrations under
@@ -2242,7 +2316,7 @@ If multiple apps target the same database, they're showing the same `_voltro_mig
2242
2316
 
2243
2317
  ## Source code
2244
2318
 
2245
- The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.tsx`. Both repos are open to extension.
2319
+ The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.page.tsx`. Both repos are open to extension.
2246
2320
 
2247
2321
 
2248
2322
 
@@ -2430,8 +2504,8 @@ server enforces this regardless of which buttons the UI renders.
2430
2504
 
2431
2505
  ## Source code
2432
2506
 
2433
- - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx` — shared
2434
- - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.tsx`
2507
+ - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx` — shared
2508
+ - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.page.tsx`
2435
2509
  - Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
2436
2510
  - Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
2437
2511
  - Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
@@ -39,6 +39,17 @@ This section covers everything about how the framework reads + writes data.
39
39
 
40
40
  You declare tables once. The migrator produces SQL; the runtime gives you a typed `ctx.store`; the reactive engine watches the same rows.
41
41
 
42
+ **Every table is reactive by default.** There is nothing to opt into — this is a
43
+ reactive framework, so a capability you had to remember to switch on is one half
44
+ your tables would not have. Writes emit change events, subscriptions stay live,
45
+ and on a multi-instance deployment the change reaches the other instances too.
46
+
47
+ `.nonReactive()` turns it off for one table, on every dialect: no subscriber
48
+ fires, locally or across instances. The write still happens — this is about
49
+ notification, never persistence. Use it for a genuinely hot table nobody
50
+ subscribes to (an append-only event log, a metrics sink); never for one a query
51
+ reads, because that subscription would never fire. `voltro dev` warns if you do.
52
+
42
53
  ## A first table
43
54
 
44
55
  ```ts
@@ -150,6 +150,13 @@ See [migrations](/docs/database/migrations) for schema changes — seeds populat
150
150
 
151
151
  _Six SQL backends, one schema DSL. Decision matrix, configuration, boot-log shape, and the cross-dialect feature parity table the framework hides for you._
152
152
 
153
+ **Reactivity is dialect-independent.** Every table is reactive by default on all
154
+ six backends — you write nothing to get it, and `.nonReactive()` turns it off
155
+ for one table everywhere. What differs is only the TRANSPORT that carries a
156
+ change between instances: Postgres uses LISTEN/NOTIFY, MySQL and MariaDB read
157
+ the binlog, MSSQL uses Change Tracking, and SQLite / Turso have no native one —
158
+ [`@voltro/plugin-broadcast`](../multi-replica) closes that gap.
159
+
153
160
  Voltro runs on six SQL backends. The application code — schema, queries, workflows, subscriptions, the reactive engine — is written ONCE and compiles down to the dialect-native idiom at runtime. Selecting a dialect is a single environment variable.
154
161
 
155
162
  ```
@@ -361,7 +368,27 @@ The framework wraps `pg` (`node-postgres`) via Effect's typed connection layer.
361
368
 
362
369
  ## CDC — LISTEN/NOTIFY
363
370
 
364
- Every `*.reactive()` table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
371
+ Every table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
372
+
373
+ **Every table gets this by default.** Reactivity is what the framework is for,
374
+ so you write nothing to opt in — the trigger above is installed for every table
375
+ unless you opt it out.
376
+
377
+ `.nonReactive()` turns reactivity **off** for a table — not "off across
378
+ instances". It emits no change events at all: no local subscriber fires and no
379
+ cross-instance transport carries it. That holds on every dialect, including
380
+ sqlite and turso, because the guard sits at each store's emit. On postgres it
381
+ additionally drops the trigger and `REPLICA IDENTITY FULL`; on mysql/mariadb and
382
+ mssql it drops the table from the reader's filter.
383
+
384
+ The write itself is unaffected — this is about notification, never persistence.
385
+
386
+ Worth using for a genuinely hot table nobody subscribes to: an append-only event
387
+ log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the
388
+ WAL and the trigger fires on every write, so that is a real saving.
389
+
390
+ Do **not** use it on a table a query reads — that subscription will never fire.
391
+ `voltro dev` and `voltro serve` warn at boot when that combination exists.
365
392
 
366
393
  Latency: very low on a local-network postgres (it varies with network and load). The framework instruments this — `voltro traces` shows the notification → dispatcher → subscriber waterfall.
367
394
 
@@ -514,6 +541,11 @@ rows.map((r) => r.id) // ← the full generated range, in input order
514
541
 
515
542
  MySQL has no `LISTEN/NOTIFY`, but MySQL 8's ROW-format binary log is a real out-of-band CDC source — the SAME binlog the framework already tails for mariadb. The framework tails the primary's binlog via the `@vlasky/zongji` replication client, so **every replica tails the binlog itself and a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy). This is at parity with mariadb: binlog CDC is **one path** with per-variant detection, not two implementations.
516
543
 
544
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
545
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
546
+ across instances. The write still happens; only the notification is suppressed.
547
+ See [the schema overview](/docs/database/overview).
548
+
517
549
  ### How it works
518
550
 
519
551
  1. With `CDC=1` (the default for any SQL dialect) on `mysql`, the store starts a binlog reader on a **separate replication connection** — distinct from the SQL pool.
@@ -739,6 +771,11 @@ The framework uses it for the no-pagination case of `many()` / `manyToMany()` on
739
771
 
740
772
  MariaDB has no `LISTEN/NOTIFY`, but it gets a real out-of-band CDC source: the framework tails the primary's **ROW-format binary log** via the `@vlasky/zongji` replication client. This is the MariaDB equivalent of postgres's `LISTEN/NOTIFY` — **every replica tails the binlog itself, so a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy).
741
773
 
774
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
775
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
776
+ across instances. The write still happens; only the notification is suppressed.
777
+ See [the schema overview](/docs/database/overview).
778
+
742
779
  This is **one binlog-CDC path shared with `mysql`** — mysql-8 and mariadb speak the same ROW binlog to the reader, so both get cross-instance CDC. The only engine difference is the binlog-end query (mariadb `SHOW MASTER STATUS`; mysql 8.4 `SHOW BINARY LOG STATUS`), which the store picks per variant.
743
780
 
744
781
  ### How it works
@@ -979,6 +1016,11 @@ Requires the deployment to use Always-On AGs (the modern HA story since SQL Serv
979
1016
 
980
1017
  SQL Server ships **Change Tracking** (CT) — a lightweight, built-in change source available on every edition (unlike the heavier Change Data Capture feature). The framework's mssql store uses it as an out-of-band CDC reader so a write on ANY instance surfaces on EVERY instance's `onChange` — the mssql equivalent of postgres `LISTEN/NOTIFY` or mariadb binlog CDC.
981
1018
 
1019
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1020
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1021
+ across instances. The write still happens; only the notification is suppressed.
1022
+ See [the schema overview](/docs/database/overview).
1023
+
982
1024
  Enable it and set `changeStrategy: 'cdc'` (the default when `CDC` is not `0`):
983
1025
 
984
1026
  ```sh
@@ -1098,6 +1140,11 @@ SQLite is single-writer by definition. Setting `DB_REPLICA_URLS=…` is a no-op
1098
1140
 
1099
1141
  Sqlite has no LISTEN/NOTIFY equivalent and no trigger-based fan-out is needed (everything runs in one process). The framework's `SqliteStore` uses an in-process Node `EventEmitter` (composed as a private field, not subclassed):
1100
1142
 
1143
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1144
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1145
+ across instances. The write still happens; only the notification is suppressed.
1146
+ See [the schema overview](/docs/database/overview).
1147
+
1101
1148
  - Insert/update/delete emit `'change'` events synchronously to the dispatcher.
1102
1149
  - The dispatcher's `onChange` callback is registered against the emitter — no polling, no triggers, no log table.
1103
1150
 
@@ -1237,6 +1284,11 @@ Like `sqlite`, Turso is single-node here. The workflow engine boots with `runner
1237
1284
 
1238
1285
  Same as `sqlite`: an in-process Node `EventEmitter` fans insert/update/delete events to the dispatcher. Sub-millisecond, single-process. No `LISTEN/NOTIFY`, no triggers.
1239
1286
 
1287
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1288
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1289
+ across instances. The write still happens; only the notification is suppressed.
1290
+ See [the schema overview](/docs/database/overview).
1291
+
1240
1292
  ## Not supported on Turso (beta gaps)
1241
1293
 
1242
1294
  These are SQLite features the `sqlite` dialect has but Turso's MVCC mode does not. The framework fails LOUD at migrate rather than emit DDL the engine rejects:
@@ -423,7 +423,7 @@ export default function Layout({ children }) {
423
423
  }
424
424
  ```
425
425
 
426
- Pair this with locale-prefixed page files: `src/pages/[locale]/index.tsx`, `src/pages/[locale]/docs/[...slug].tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
426
+ Pair this with locale-prefixed page files: `src/pages/[locale]/index.page.tsx`, `src/pages/[locale]/docs/[...slug].page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
427
427
 
428
428
  Reference implementations: `voltro-dev/apps/voltro-dev/docs/` (URL-prefix on dynamic + static content) and `voltro-dev/apps/voltro-dev/landing/` (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined `voltro build` run).
429
429
 
@@ -441,7 +441,7 @@ dist/de/features/foo/index.html ← de mirror
441
441
  Each variant ships with the right `<I18nProvider>`-wrapped body **and** the right per-locale `<title>` / `<meta description>` / `<link rel="canonical">` / OG tags — **if** the page's `meta` is exported as a function of `({ locale })`. With a plain static `meta: PageMeta` object, the body is correctly localised but the head tags stay default-locale on every variant.
442
442
 
443
443
  ```tsx
444
- // src/pages/features/foo.tsx — meta as a function of locale
444
+ // src/pages/features/foo.page.tsx — meta as a function of locale
445
445
  import { getCatalog } from '../lib/locale'
446
446
  import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'
447
447
 
@@ -472,7 +472,7 @@ The `[locale]/…` mirror is the trigger for SSG **output** — without mirror f
472
472
  12 lines per page. Re-export the canonical page's `default`, `renderMode`, `interactive`, `meta`, plus a `getStaticPaths` that enumerates non-default locales:
473
473
 
474
474
  ```tsx
475
- // src/pages/[locale]/features/foo.tsx
475
+ // src/pages/[locale]/features/foo.page.tsx
476
476
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
477
477
  export { default } from '../../features/foo'
478
478
  export { renderMode, interactive, meta } from '../../features/foo'