@voltro/cli 0.13.0 → 0.14.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 (165) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/dist/{apiBuild-B6_ehbem.js → apiBuild-370ITNRk.js} +2 -2
  3. package/dist/apiBuild-DatWP5hh.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-DYOuGlEC.js → commands-D-teL_TM.js} +3709 -2789
  6. package/dist/dbCommand-B3CeMU3u.js +2 -0
  7. package/dist/{dbCommand-DIOJQwsq.js → dbCommand-Dmi-vHfA.js} +2 -1
  8. package/dist/{dev-Fb9BmavX.js → dev-CKyW_LAv.js} +1620 -1509
  9. package/dist/{dev-xdijqojc.js → dev-CPKbckaN.js} +1 -1
  10. package/dist/fileConventions-COg0ZytD.js +9 -0
  11. package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-DCLhpHsF.js} +164 -163
  12. package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CyVMeofr.js} +955 -945
  15. package/dist/{serveCommand-CqYxzBZR.js → serveCommand-CU3Sw4ZM.js} +410 -396
  16. package/dist/serveEntry.js +2 -2
  17. package/dist/{start-CjG_0xhR.js → start-D8ATvTzo.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 +77 -3
  22. package/templates/agent-docs/_manifest.json +2 -2
  23. package/templates/agent-docs/authentication.md +5 -2
  24. package/templates/agent-docs/cli.md +36 -2
  25. package/templates/agent-docs/data.md +193 -1
  26. package/templates/agent-docs/database/hosting.md +2 -2
  27. package/templates/agent-docs/database/migrations.md +56 -3
  28. package/templates/agent-docs/database/overview.md +11 -0
  29. package/templates/agent-docs/database/seedsdialects.md +53 -1
  30. package/templates/agent-docs/internationalization.md +3 -3
  31. package/templates/agent-docs/introduction.md +133 -2
  32. package/templates/agent-docs/plugins.md +2 -1
  33. package/templates/agent-docs/reference.md +4 -4
  34. package/templates/agent-docs/routing.md +48 -43
  35. package/templates/agent-docs/schema-driven-ui.md +12 -2
  36. package/templates/agent-docs/templates/apibackends.md +1 -8
  37. package/templates/agent-docs/templates/appshells.md +24 -24
  38. package/templates/agent-docs/testing.md +16 -1
  39. package/templates/agent-docs/whats-new.md +16 -0
  40. package/templates/apps/api-ai/database/schema.ts +0 -2
  41. package/templates/apps/api-ai/package.json +7 -7
  42. package/templates/apps/api-auth/package.json +8 -8
  43. package/templates/apps/api-backend/database/schema.ts +0 -3
  44. package/templates/apps/api-backend/package.json +7 -7
  45. package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
  46. package/templates/apps/api-backend-deactivation/package.json +7 -7
  47. package/templates/apps/api-backend-mail/database/schema.ts +0 -3
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
  50. package/templates/apps/api-backend-mariadb/package.json +9 -9
  51. package/templates/apps/api-backend-storage/database/schema.ts +0 -3
  52. package/templates/apps/api-backend-storage/package.json +8 -8
  53. package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
  54. package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
  55. package/templates/apps/api-data-advanced/package.json +8 -8
  56. package/templates/apps/api-durable/database/schema.ts +0 -3
  57. package/templates/apps/api-durable/package.json +8 -8
  58. package/templates/apps/api-feature-flags/database/schema.ts +0 -1
  59. package/templates/apps/api-feature-flags/package.json +9 -9
  60. package/templates/apps/api-governance/database/schema.ts +0 -1
  61. package/templates/apps/api-governance/package.json +8 -8
  62. package/templates/apps/api-kv/database/schema.ts +0 -3
  63. package/templates/apps/api-kv/package.json +8 -8
  64. package/templates/apps/api-moderation/database/schema.ts +0 -2
  65. package/templates/apps/api-moderation/package.json +8 -8
  66. package/templates/apps/api-observability/database/schema.ts +0 -1
  67. package/templates/apps/api-observability/package.json +8 -8
  68. package/templates/apps/api-ratelimit/database/schema.ts +0 -1
  69. package/templates/apps/api-ratelimit/package.json +8 -8
  70. package/templates/apps/api-rbac/database/schema.ts +0 -2
  71. package/templates/apps/api-rbac/package.json +8 -8
  72. package/templates/apps/api-rest/database/schema.ts +0 -3
  73. package/templates/apps/api-rest/package.json +7 -7
  74. package/templates/apps/api-saas/database/schema.ts +0 -1
  75. package/templates/apps/api-saas/package.json +11 -11
  76. package/templates/apps/api-search/database/schema.ts +0 -1
  77. package/templates/apps/api-search/package.json +8 -8
  78. package/templates/apps/api-versioning/database/schema.ts +0 -1
  79. package/templates/apps/api-versioning/package.json +8 -8
  80. package/templates/apps/api-webhooks/database/schema.ts +0 -1
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
  84. package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  85. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  86. package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  87. package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  88. package/templates/apps/edge-functions/package.json +2 -2
  89. package/templates/apps/frontend-admin/package.json +8 -8
  90. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  91. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  92. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  93. package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
  94. package/templates/apps/frontend-app/package.json +8 -8
  95. package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  96. package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
  97. package/templates/apps/frontend-blank/package.json +7 -7
  98. package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  99. package/templates/apps/frontend-contact/package.json +7 -7
  100. package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  101. package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  102. package/templates/apps/frontend-dashboard/package.json +7 -7
  103. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  104. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  105. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
  106. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
  107. package/templates/apps/frontend-docs/package.json +7 -7
  108. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
  109. package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  110. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  111. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
  112. package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  113. package/templates/apps/frontend-i18n/package.json +6 -6
  114. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
  115. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  116. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  117. package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
  118. package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  119. package/templates/apps/frontend-landing/package.json +7 -7
  120. package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  121. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  122. package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  123. package/templates/apps/frontend-spa/package.json +7 -7
  124. package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  125. package/templates/apps/frontend-ssr/package.json +7 -7
  126. package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
  127. package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
  128. package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  129. package/templates/apps/frontend-ssr-api/package.json +8 -8
  130. package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  131. package/templates/apps/frontend-static-blog/package.json +6 -6
  132. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
  133. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  134. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  135. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  136. package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  137. package/dist/apiBuild-Bzpb383i.js +0 -2
  138. package/dist/dbCommand-ADD4mrTI.js +0 -2
  139. package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
  140. /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
  141. /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
  142. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  143. /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  144. /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
  145. /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
  146. /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
  147. /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
  148. /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
  149. /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
  150. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  151. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  152. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
  153. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
  154. /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
  155. /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
  156. /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
  157. /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
  158. /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
  159. /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
  160. /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
  161. /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
  162. /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
  163. /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
  164. /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
  165. /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
@@ -1,3 +1,3 @@
1
- import { X as e } from "./inspectMetrics-DOiiII34.js";
2
- import { t } from "./start-CjG_0xhR.js";
1
+ import { $ as e } from "./inspectMetrics-CyVMeofr.js";
2
+ import { t } from "./start-D8ATvTzo.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.13.0",
66
- "@voltro/cache": "0.13.0",
67
- "@voltro/data-transfer": "0.13.0",
68
- "@voltro/database": "0.13.0",
69
- "@voltro/env": "0.13.0",
70
- "@voltro/kv": "0.13.0",
71
- "@voltro/logger": "0.13.0",
72
- "@voltro/plugin-auth": "0.13.0",
73
- "@voltro/plugin-broadcast": "0.13.0",
74
- "@voltro/plugin-mail": "0.13.0",
75
- "@voltro/plugin-storage": "0.13.0",
76
- "@voltro/plugin-webhooks": "0.13.0",
77
- "@voltro/protocol": "0.13.0",
78
- "@voltro/runtime": "0.13.0",
79
- "@voltro/serverless": "0.13.0",
80
- "@voltro/workflow": "0.13.0",
65
+ "@voltro/ai": "0.14.0",
66
+ "@voltro/cache": "0.14.0",
67
+ "@voltro/data-transfer": "0.14.0",
68
+ "@voltro/database": "0.14.0",
69
+ "@voltro/env": "0.14.0",
70
+ "@voltro/kv": "0.14.0",
71
+ "@voltro/logger": "0.14.0",
72
+ "@voltro/plugin-auth": "0.14.0",
73
+ "@voltro/plugin-broadcast": "0.14.0",
74
+ "@voltro/plugin-mail": "0.14.0",
75
+ "@voltro/plugin-storage": "0.14.0",
76
+ "@voltro/plugin-webhooks": "0.14.0",
77
+ "@voltro/protocol": "0.14.0",
78
+ "@voltro/runtime": "0.14.0",
79
+ "@voltro/serverless": "0.14.0",
80
+ "@voltro/workflow": "0.14.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.tsx # file-based routing
222
+ │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `src/pages/**/*.tsx` | web page (file-based route) |
252
+ | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
253
+ | `*.component.tsx` | exactly ONE component (+ types) |
254
+ | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
+ | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
256
+ | `*.types.ts` | types only — zero runtime exports |
257
+ | `*.internal.ts` | only its own directory subtree may import it |
258
+ | `*.fixture.ts` | test material — no production path may reach it |
259
+ | `*.tracking.ts` | the ONLY place analytics may be called from |
260
+ | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
+ | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
+
263
+ **Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
264
+ hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
+ it rather than in a distant `components/` directory. The suffix decides *whether*
266
+ a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
+ outside `src/pages/` never routes. A page MUST default-export its component —
268
+ without it `voltro dev` refuses to boot and names the file.
269
+
270
+ **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
+ promise, and `voltro doctor` enforces all of them:
272
+
273
+ - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
274
+ never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
275
+ what lets a caller render it ten thousand times in a list, reuse it across
276
+ features, and prerender it without reading its source. Lift the write into the
277
+ owning `*.component.tsx` and pass a handler down.
278
+ - `*.internal.ts` is the promise that refactoring inside that directory breaks
279
+ nobody. An import from another subtree revokes it.
280
+ - `*.types.ts` having no runtime export is what makes importing it free and makes
281
+ it impossible for it to sit in a runtime import cycle.
282
+ - `*.store.ts` holds ONE `defineStore`. Reads go through a selector
283
+ (`wizard.use((s) => s.step)`) — there is no whole-state read, because a
284
+ component holding all of it re-renders on every field. Scope a per-entity
285
+ instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
286
+ from a loader with `seedStore(...)`; it rides the hydration payload the router
287
+ already writes. An action that writes SEVERAL fields wraps them in
288
+ `store.batch('label', () => …)` — one notification, one devtools entry, one
289
+ undo step, and a rollback if it throws; `await` first, then batch (an async
290
+ callback is an error). `{ persist: { key } }` survives a reload.
291
+ NEVER put server data in a store: a subscription is already
292
+ live, and a copy is not — the page then shows the stale one.
293
+ - `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
294
+ catalogue, so "what do we send to third parties" is a file listing rather than
295
+ archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
296
+ NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
297
+ confined — only the declaration is.)
298
+ - Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
299
+ write today. The suffix is a promise about what it MAY do.
300
+
301
+ **These rules apply to code YOU write — never to vendored code.** A shadcn
302
+ component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
303
+ the next `add`; renaming it breaks their convention and is undone next run. A
304
+ directory is exempt when the app's `components.json` names it (`aliases.ui`) or
305
+ it carries a `.voltro-vendored` file whose first line says where the code came
306
+ from. Never add that marker to a directory of your own code — the point of the
307
+ rules is that everything we author follows them.
308
+
309
+ **Write the test in the same step as the code — always, for every primitive.**
310
+ Not "afterwards", not in a cleanup pass: the filename is derivable
311
+ (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
312
+ `Card.component.tsx` → `Card.component.test.tsx`,
313
+ `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
+ nothing to decide. A change is not finished until its test exists and passes.
315
+
316
+ Two traps worth naming, because a green run can hide both:
317
+
318
+ - **`voltro test` transpiles without type-checking.** A type error in a TEST file
319
+ passes the suite and fails the package's `typecheck`. Run both.
320
+ - **Never fake a service to make a test pass.** A test that only re-asserts its
321
+ own mock's return value is worse than no test — it reports coverage it does not
322
+ have. Assert the descriptor shape instead and cover the real path elsewhere.
253
323
 
254
324
  ## app.config.ts
255
325
 
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
344
414
  ```ts
345
415
  export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
346
416
  .with(softDelete(), tenant()) // tenant() transitively requires audit()
347
- .reactive() // opt into the matcher engine (subscriptions)
348
417
  ```
349
418
 
419
+ - Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
420
+ turns reactivity OFF for a table entirely: no subscriber fires, locally or
421
+ across instances (the write still happens). Use it for a hot table nobody
422
+ subscribes to; never for one a query reads — that subscription never fires,
423
+ and `voltro dev` warns.
350
424
  - `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
351
425
  - Indexes are **table-level** (`.index('name', [...])`) — there is NO
352
426
  column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.tsx # file-based routing
222
+ │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,77 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `src/pages/**/*.tsx` | web page (file-based route) |
252
+ | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
253
+ | `*.component.tsx` | exactly ONE component (+ types) |
254
+ | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
+ | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
256
+ | `*.types.ts` | types only — zero runtime exports |
257
+ | `*.internal.ts` | only its own directory subtree may import it |
258
+ | `*.fixture.ts` | test material — no production path may reach it |
259
+ | `*.tracking.ts` | the ONLY place analytics may be called from |
260
+ | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
+ | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
+
263
+ **Only `*.page.tsx` is a route.** Everything else under `src/pages/` — components,
264
+ hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
+ it rather than in a distant `components/` directory. The suffix decides *whether*
266
+ a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
+ outside `src/pages/` never routes. A page MUST default-export its component —
268
+ without it `voltro dev` refuses to boot and names the file.
269
+
270
+ **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
+ promise, and `voltro doctor` enforces all of them:
272
+
273
+ - `*.component.ui.tsx` may read (`useT`, `useCan`, `usePermissions`) but must
274
+ never import a write hook (`useMutation`, `useAction`, `useUpload`, …). That is
275
+ what lets a caller render it ten thousand times in a list, reuse it across
276
+ features, and prerender it without reading its source. Lift the write into the
277
+ owning `*.component.tsx` and pass a handler down.
278
+ - `*.internal.ts` is the promise that refactoring inside that directory breaks
279
+ nobody. An import from another subtree revokes it.
280
+ - `*.types.ts` having no runtime export is what makes importing it free and makes
281
+ it impossible for it to sit in a runtime import cycle.
282
+ - `*.store.ts` holds ONE `defineStore`. Reads go through a selector
283
+ (`wizard.use((s) => s.step)`) — there is no whole-state read, because a
284
+ component holding all of it re-renders on every field. Scope a per-entity
285
+ instance with a KEY (`{ key: orderId }`), never a Provider. Seed it for SSR
286
+ from a loader with `seedStore(...)`; it rides the hydration payload the router
287
+ already writes. An action that writes SEVERAL fields wraps them in
288
+ `store.batch('label', () => …)` — one notification, one devtools entry, one
289
+ undo step, and a rollback if it throws; `await` first, then batch (an async
290
+ callback is an error). `{ persist: { key } }` survives a reload.
291
+ NEVER put server data in a store: a subscription is already
292
+ live, and a copy is not — the page then shows the stale one.
293
+ - `*.tracking.ts` is where every `defineTracking(...)` spec lives — the event
294
+ catalogue, so "what do we send to third parties" is a file listing rather than
295
+ archaeology. A component wires one up with `useTracking(spec, props, sink)`; it
296
+ NAMES a spec, it never declares one. (`useTracking` is a hook, so it is not
297
+ confined — only the declaration is.)
298
+ - Do NOT rename a file to `*.component.ui.tsx` just because it happens not to
299
+ write today. The suffix is a promise about what it MAY do.
300
+
301
+ **These rules apply to code YOU write — never to vendored code.** A shadcn
302
+ component (`npx shadcn add`) follows shadcn's conventions and is overwritten by
303
+ the next `add`; renaming it breaks their convention and is undone next run. A
304
+ directory is exempt when the app's `components.json` names it (`aliases.ui`) or
305
+ it carries a `.voltro-vendored` file whose first line says where the code came
306
+ from. Never add that marker to a directory of your own code — the point of the
307
+ rules is that everything we author follows them.
308
+
309
+ **Write the test in the same step as the code — always, for every primitive.**
310
+ Not "afterwards", not in a cleanup pass: the filename is derivable
311
+ (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
312
+ `Card.component.tsx` → `Card.component.test.tsx`,
313
+ `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
+ nothing to decide. A change is not finished until its test exists and passes.
315
+
316
+ Two traps worth naming, because a green run can hide both:
317
+
318
+ - **`voltro test` transpiles without type-checking.** A type error in a TEST file
319
+ passes the suite and fails the package's `typecheck`. Run both.
320
+ - **Never fake a service to make a test pass.** A test that only re-asserts its
321
+ own mock's return value is worse than no test — it reports coverage it does not
322
+ have. Assert the descriptor shape instead and cover the real path elsewhere.
253
323
 
254
324
  ## app.config.ts
255
325
 
@@ -344,9 +414,13 @@ declare it). Mixins compose via `.with(...)` and auto-stamp bookkeeping columns:
344
414
  ```ts
345
415
  export const todos = table('todos', { id: id(), title: text(), done: boolean().default(false) })
346
416
  .with(softDelete(), tenant()) // tenant() transitively requires audit()
347
- .reactive() // opt into the matcher engine (subscriptions)
348
417
  ```
349
418
 
419
+ - Every table is REACTIVE by default — nothing to opt into. `.nonReactive()`
420
+ turns reactivity OFF for a table entirely: no subscriber fires, locally or
421
+ across instances (the write still happens). Use it for a hot table nobody
422
+ subscribes to; never for one a query reads — that subscription never fires,
423
+ and `voltro dev` warns.
350
424
  - `id()` defaults to a branded **TypeID** (`todo_…`). Insert auto-injects it.
351
425
  - Indexes are **table-level** (`.index('name', [...])`) — there is NO
352
426
  column-level `.index()`. Declare `.index([...])` AFTER `.with(...)` to address
@@ -53,7 +53,7 @@
53
53
  "group": null,
54
54
  "description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
55
55
  "path": "agent-docs/data.md",
56
- "files": 16
56
+ "files": 17
57
57
  },
58
58
  {
59
59
  "id": "database/advancedqueries",
@@ -179,7 +179,7 @@
179
179
  "group": null,
180
180
  "description": "Scaffold a Voltro project and boot it locally in under a minute.",
181
181
  "path": "agent-docs/introduction.md",
182
- "files": 5
182
+ "files": 6
183
183
  },
184
184
  {
185
185
  "id": "multi-tenancy",
@@ -573,8 +573,9 @@ If your authorization is a database ROLE rather than a scope on the token, the f
573
573
  // app.config.ts
574
574
  export default defineApiConfig({
575
575
  auth: {
576
- resolveScopes: async (subject) => {
577
- const role = await roleCache.get(subject.id) // cache itsee below
576
+ resolveScopes: async (subject, { store }) => {
577
+ if (store === undefined) return [] // still bootingclaim nothing
578
+ const role = await roleCache.get(subject.id, store) // cache it — see below
578
579
  return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
579
580
  },
580
581
  },
@@ -591,6 +592,8 @@ export const payrollList = defineQuery({
591
592
  })
592
593
  ```
593
594
 
595
+ **You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `[]` then rather than guessing.
596
+
594
597
  **Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
595
598
 
596
599
  **It does not run for anonymous callers** — there is no identity to look a role up for.
@@ -154,6 +154,20 @@ requiring a scope no role grants, which makes that procedure permanently and
154
154
  silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
155
155
  exhaustive, so the rule stays quiet rather than flagging correct code.)
156
156
 
157
+ `rbac/unenforced-scope` is the same registry read in the other direction: a
158
+ scope a role GRANTS that no handler ever guards on. That direction has no
159
+ artefact to inspect — you cannot grep for an authorization check that was never
160
+ written, which is exactly why it survives review. One app modelled
161
+ `api-keys:write` in its role catalogue, complete and reviewed, and no handler
162
+ checked it: any member could mint a shared credential, and nothing failed.
163
+ Tests pass when an authorization check is missing.
164
+
165
+ It is a **warning**, because three innocent explanations exist: a plugin route
166
+ enforces it internally (the graph cannot see inside a plugin), a REST route
167
+ carries its own guards, or it is a UI-affordance scope that `useCan` reads to
168
+ hide a button and no server check backs on purpose. All three are fine. Not
169
+ knowing which is not.
170
+
157
171
  #### Declared vs OBSERVED — reconciled against reality
158
172
 
159
173
  A query's `source` and a mutation's `targets` are not documentation: the
@@ -466,6 +480,26 @@ The cleaner fix is on your side and worth doing anyway: start the container as
466
480
  the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
467
481
  handing over at all.
468
482
 
483
+ ### What the boot tells you
484
+
485
+ Three checks run at boot and print one line each when they have something to say
486
+ — never fatal, and silent when the answer is fine:
487
+
488
+ - **A reactive table with no change trigger** (postgres). The schema fingerprint
489
+ covers columns, not triggers, so a restored dump or a hand-run `DROP TRIGGER`
490
+ leaves the schema "up to date" and the subscription silently not reaching other
491
+ instances. The reverse is reported too: a `.nonReactive()` table still carrying
492
+ a trigger pays `REPLICA IDENTITY FULL` and a NOTIFY per write for nothing.
493
+ - **A `.nonReactive()` table that a query reads.** That subscription will never
494
+ fire — first snapshot, then silence forever.
495
+ - **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
496
+ on and reachable by nobody; every issue request fails its guard.
497
+
498
+ `voltro doctor` adds a fourth, over your source: an executor that writes
499
+ `ctx.request.subject.id` while naming no guard, which is how an anonymous caller
500
+ reaches a NOT NULL column and gets a raw statement failure instead of a typed
501
+ refusal.
502
+
469
503
  ## `voltro dev <appDir>`
470
504
 
471
505
  ```bash
@@ -1138,8 +1172,8 @@ A page is listed when ALL of these hold:
1138
1172
 
1139
1173
  ```text
1140
1174
  • renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
1141
- src/pages/dashboard/index.tsx (/dashboard) — default renderMode
1142
- src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
1175
+ src/pages/dashboard/index.page.tsx (/dashboard) — default renderMode
1176
+ src/pages/admin/settings.page.tsx (/admin/settings) — renderMode:'ssr'
1143
1177
  → renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.
1144
1178
  ```
1145
1179
 
@@ -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