@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
@@ -11,7 +11,9 @@
11
11
 
12
12
  _Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework._
13
13
 
14
- The web side of a Voltro app uses **file-based routing**: drop a TSX file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.
14
+ The web side of a Voltro app uses **file-based routing**: drop a `*.page.tsx` file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.
15
+
16
+ The suffix is what makes a file a route — the directory tree only decides *which URL*. Anything else under `src/pages/` (components, hooks, tests) is ordinary code and gets no URL, so it can live beside the page that uses it.
15
17
 
16
18
  This section covers everything about how URLs map to React + how Voltro decides when to render, what to ship to the browser, and how to navigate between pages.
17
19
 
@@ -23,18 +25,18 @@ src/pages/
23
25
  ├── error.tsx # error boundary
24
26
  ├── loading.tsx # pending UI
25
27
  ├── not-found.tsx # 404 fallback
26
- ├── index.tsx # /
27
- ├── about.tsx # /about
28
+ ├── index.page.tsx # /
29
+ ├── about.page.tsx # /about
28
30
  ├── (marketing)/ # route group — no URL segment
29
31
  │ ├── layout.tsx # marketing-scoped layout
30
- │ └── pricing.tsx # /pricing
32
+ │ └── pricing.page.tsx # /pricing
31
33
  ├── users/
32
34
  │ ├── layout.tsx # users-scoped layout
33
35
  │ ├── error.tsx # users-scoped error boundary
34
- │ ├── [id].tsx # /users/:id
35
- │ └── index.tsx # /users
36
+ │ ├── [id].page.tsx # /users/:id
37
+ │ └── index.page.tsx # /users
36
38
  └── docs/
37
- └── [...slug].tsx # /docs/<anything> (catch-all)
39
+ └── [...slug].page.tsx # /docs/<anything> (catch-all)
38
40
  ```
39
41
 
40
42
  That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
@@ -50,7 +52,7 @@ That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
50
52
 
51
53
  ## Architecture in one paragraph
52
54
 
53
- The framework generates a `.framework/app.tsx` on every boot that imports each `*.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
55
+ The framework generates a `.framework/app.tsx` on every boot that imports each `*.page.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
54
56
 
55
57
  You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.
56
58
 
@@ -123,7 +125,7 @@ A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`la
123
125
  ## A static page
124
126
 
125
127
  ```tsx
126
- // src/pages/about.tsx → /about
128
+ // src/pages/about.page.tsx → /about
127
129
  import type { ReactNode } from 'react'
128
130
 
129
131
  export default function About(): ReactNode {
@@ -143,9 +145,9 @@ That's it. Save the file, the CLI's discovery sees it on next save, the page is
143
145
  `index.tsx` maps to the directory's URL:
144
146
 
145
147
  ```text
146
- src/pages/index.tsx → /
147
- src/pages/users/index.tsx → /users
148
- src/pages/admin/index.tsx → /admin
148
+ src/pages/index.page.tsx → /
149
+ src/pages/users/index.page.tsx → /users
150
+ src/pages/admin/index.page.tsx → /admin
149
151
  ```
150
152
 
151
153
  ## Dynamic segments
@@ -153,7 +155,7 @@ src/pages/admin/index.tsx → /admin
153
155
  Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:
154
156
 
155
157
  ```tsx
156
- // src/pages/users/[id].tsx → /users/:id
158
+ // src/pages/users/[id].page.tsx → /users/:id
157
159
  import { useParams } from '@voltro/web'
158
160
 
159
161
  export default function User() {
@@ -165,7 +167,7 @@ export default function User() {
165
167
  Multiple dynamic segments in one path:
166
168
 
167
169
  ```text
168
- src/pages/orgs/[orgId]/projects/[projectId].tsx
170
+ src/pages/orgs/[orgId]/projects/[projectId].page.tsx
169
171
  // → /orgs/:orgId/projects/:projectId
170
172
 
171
173
  const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
@@ -176,7 +178,7 @@ const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
176
178
  `[...name]` captures one OR more URL segments as a single param value (joined by `/`):
177
179
 
178
180
  ```tsx
179
- // src/pages/docs/[...slug].tsx → /docs/<anything>
181
+ // src/pages/docs/[...slug].page.tsx → /docs/<anything>
180
182
  const { slug } = useParams<{ slug: string }>()
181
183
  // /docs/intro/getting-started → slug = "intro/getting-started"
182
184
  ```
@@ -184,7 +186,7 @@ const { slug } = useParams<{ slug: string }>()
184
186
  **Optional** catch-all (matches the base URL too):
185
187
 
186
188
  ```tsx
187
- // src/pages/docs/[[...slug]].tsx
189
+ // src/pages/docs/[[...slug]].page.tsx
188
190
  // /docs → slug = ""
189
191
  // /docs/foo → slug = "foo"
190
192
  // /docs/foo/bar → slug = "foo/bar"
@@ -199,10 +201,10 @@ When multiple files could match (static, dynamic, catch-all), priority is:
199
201
  3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.
200
202
 
201
203
  ```text
202
- src/pages/users/index.tsx # /users → wins for /users
203
- src/pages/users/[id].tsx # /users/:id → wins for /users/42
204
- src/pages/users/new.tsx # /users/new → wins (static beats dynamic)
205
- src/pages/[...rest].tsx # everything else
204
+ src/pages/users/index.page.tsx # /users → wins for /users
205
+ src/pages/users/[id].page.tsx # /users/:id → wins for /users/42
206
+ src/pages/users/new.page.tsx # /users/new → wins (static beats dynamic)
207
+ src/pages/[...rest].page.tsx # everything else
206
208
  ```
207
209
 
208
210
  ## Query strings
@@ -238,20 +240,23 @@ export default function SearchPage() {
238
240
 
239
241
  Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
240
242
 
241
- ## Private files
243
+ ## Co-locating components, hooks and tests
242
244
 
243
- Files starting with `_` are skipped by discovery they're helpers next to pages:
245
+ Only `*.page.tsx` is a route. Everything else under `src/pages/` is ordinary code and may sit next to the page that uses it:
244
246
 
245
247
  ```text
246
248
  src/pages/
247
- ├── _components/
248
- │ └── UserCard.tsx # NOT a route — import from siblings
249
249
  ├── users/
250
- │ ├── _helpers.ts # NOT a route
251
- └── [id].tsx # /users/:id
250
+ │ ├── index.page.tsx # /users
251
+ ├── index.page.test.tsx # its test
252
+ │ ├── [id].page.tsx # → /users/:id
253
+ │ ├── UserCard.tsx # a component — no URL
254
+ │ └── useFilters.ts # a hook — no URL
252
255
  ```
253
256
 
254
- You can also use the colocation pattern: keep page-specific components in a directory named with `_` prefix.
257
+ No naming trick is needed to keep something out of the router: the absence of the suffix already does it. A `_`-prefixed directory has **no special meaning** — it is neither required nor recognised.
258
+
259
+ > Before this convention, every `.tsx` under `src/pages/` became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, `voltro update` renames your pages for you.
255
260
 
256
261
  ## Trailing slashes
257
262
 
@@ -314,11 +319,11 @@ Conventions:
314
319
  ```text
315
320
  src/pages/
316
321
  ├── layout.tsx # outer (every page)
317
- ├── about.tsx # /about → wrapped in outer layout
322
+ ├── about.page.tsx # /about → wrapped in outer layout
318
323
  └── dashboard/
319
324
  ├── layout.tsx # nested (only /dashboard/*)
320
- ├── index.tsx # /dashboard
321
- └── settings.tsx # /dashboard/settings
325
+ ├── index.page.tsx # /dashboard
326
+ └── settings.page.tsx # /dashboard/settings
322
327
  ```
323
328
 
324
329
  For `/dashboard/settings`, the React tree is:
@@ -341,13 +346,13 @@ A directory in `(parentheses)` does NOT contribute a URL segment, but its layout
341
346
  src/pages/
342
347
  ├── (marketing)/
343
348
  │ ├── layout.tsx # marketing-scoped chrome
344
- │ ├── index.tsx # /
345
- │ ├── pricing.tsx # /pricing
346
- │ └── about.tsx # /about
349
+ │ ├── index.page.tsx # /
350
+ │ ├── pricing.page.tsx # /pricing
351
+ │ └── about.page.tsx # /about
347
352
  └── (app)/
348
353
  ├── layout.tsx # authenticated app chrome
349
- ├── dashboard.tsx # /dashboard
350
- └── settings.tsx # /settings
354
+ ├── dashboard.page.tsx # /dashboard
355
+ └── settings.page.tsx # /settings
351
356
  ```
352
357
 
353
358
  Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.
@@ -491,7 +496,7 @@ Those four are the **complete** set. An unrecognised value is a hard error namin
491
496
  ## static (SSG)
492
497
 
493
498
  ```tsx
494
- // src/pages/about.tsx
499
+ // src/pages/about.page.tsx
495
500
  export const renderMode = 'static' as const
496
501
  ```
497
502
 
@@ -504,7 +509,7 @@ At `voltro build`:
504
509
  For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:
505
510
 
506
511
  ```tsx
507
- // src/pages/blog/[slug].tsx
512
+ // src/pages/blog/[slug].page.tsx
508
513
  export const renderMode = 'static' as const
509
514
 
510
515
  export const getStaticPaths = async () => [
@@ -729,7 +734,7 @@ Both are static module exports — the framework discovers them, the build pipel
729
734
  ## A loader
730
735
 
731
736
  ```tsx
732
- // src/pages/notes/[id].tsx
737
+ // src/pages/notes/[id].page.tsx
733
738
  import { useLoaderData } from '@voltro/web'
734
739
 
735
740
  interface Note {
@@ -839,7 +844,7 @@ splits the loader's result into data that blocks the shell and data that
839
844
  soon as the promise settles.
840
845
 
841
846
  ```tsx
842
- // src/pages/dashboard.tsx
847
+ // src/pages/dashboard.page.tsx
843
848
  import { Await, defer, useLoaderData } from '@voltro/web'
844
849
 
845
850
  export const renderMode = 'ssr' as const // required — see below
@@ -959,7 +964,7 @@ Use `signal` for any fetch that could outlive the request — pass it to `fetch(
959
964
  Instead of hand-rolling a `fetch(INTERNAL_API/...)`, a server-side loader can call the backend rpc directly through `ctx.query` — the same query tags the client subscribes to, resolved to their initial snapshot:
960
965
 
961
966
  ```tsx
962
- // src/pages/notes/[id].tsx
967
+ // src/pages/notes/[id].page.tsx
963
968
  import { useSubscription } from '@voltro/client'
964
969
  import { useLoaderData, type PageMeta } from '@voltro/web'
965
970
 
@@ -1131,7 +1136,7 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
1131
1136
  ### Authenticated dashboard with cookie-driven loader
1132
1137
 
1133
1138
  ```tsx
1134
- // src/pages/dashboard/index.tsx
1139
+ // src/pages/dashboard/index.page.tsx
1135
1140
  import { useLoaderData } from '@voltro/web'
1136
1141
 
1137
1142
  export const renderMode = 'ssr' as const
@@ -1157,7 +1162,7 @@ export default function Dashboard() {
1157
1162
  `getStaticPaths` has no framework store — it reads its own content source (a CMS client, the filesystem, an API). The loader runs server-side and fetches via `query` (the backend rpc, resolved to its first snapshot):
1158
1163
 
1159
1164
  ```tsx
1160
- // src/pages/blog/[slug].tsx
1165
+ // src/pages/blog/[slug].page.tsx
1161
1166
  import { listPostSlugs, type Post } from '../../content/posts'
1162
1167
 
1163
1168
  export const renderMode = 'static' as const
@@ -1558,7 +1563,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
1558
1563
  Use it in a page:
1559
1564
 
1560
1565
  ```tsx
1561
- // src/pages/blog/[slug].tsx
1566
+ // src/pages/blog/[slug].page.tsx
1562
1567
  import LikeButton from '../../components/LikeButton.island'
1563
1568
 
1564
1569
  export const renderMode = 'static' as const
@@ -1093,16 +1093,26 @@ readable in a single place.
1093
1093
  ```tsx
1094
1094
  import { defineTracking, useTracking } from '@voltro/client'
1095
1095
 
1096
- const spec = defineTracking('CheckoutButton', {
1096
+ interface CheckoutButtonProps {
1097
+ readonly plan: 'free' | 'pro'
1098
+ readonly onClick: () => void
1099
+ }
1100
+
1101
+ const spec = defineTracking<CheckoutButtonProps>('CheckoutButton', {
1097
1102
  onMount: 'checkout.viewed',
1098
1103
  onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
1099
1104
  })
1100
1105
 
1101
- const CheckoutButton = (props) => {
1106
+ const CheckoutButton = (props: CheckoutButtonProps) => {
1102
1107
  const tracked = useTracking(spec, props, sink)
1103
1108
  return <button {...tracked}>Checkout</button>
1104
1109
  }
1105
1110
  ```
1111
+ **Name the props.** The type parameter is what lets a payload builder read
1112
+ `props.plan` instead of indexing into `unknown` and casting — and a cast is the
1113
+ last thing that belongs in the one file that states what leaves the browser. It
1114
+ defaults to an untyped bag, so a loose catalogue still compiles; `useTracking`
1115
+ then returns the props type, which is what makes `{...tracked}` typecheck at all.
1106
1116
 
1107
1117
  `useTracking(spec, props, sink)` returns a **copy of your props** with every
1108
1118
  callback the map names wrapped, so invoking `onClick` fires its event and then
@@ -137,7 +137,6 @@ export const notes = table('notes', {
137
137
  id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
138
138
  })
139
139
  .with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
140
- .reactive() // subscriptions get a fresh snapshot/delta on every write to notes
141
140
 
142
141
  export type Note = InferRow<typeof notes>
143
142
  export const database = databaseHandle({ actors, tenants, notes })
@@ -588,13 +587,12 @@ export const orders = table('orders', {
588
587
  amountCents: integer(),
589
588
  })
590
589
  .with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
591
- .reactive() // subscriber + live subscriptions wake on every write to orders
592
590
 
593
591
  export type Order = InferRow<typeof orders>
594
592
  export const database = databaseHandle({ actors, tenants, orders })
595
593
  ```
596
594
 
597
- `tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. `.reactive()` opts the table into the matcher engine so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
595
+ `tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. Every table is in the matcher engine by default, so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
598
596
 
599
597
  ## Place — mutation that emits a domain event
600
598
 
@@ -1292,7 +1290,6 @@ export const docs = table('docs', {
1292
1290
  }),
1293
1291
  )
1294
1292
  // Opt into the matcher engine so subscriptions over `docs` re-fire on write.
1295
- .reactive()
1296
1293
 
1297
1294
  export type Doc = InferRow<typeof docs>
1298
1295
 
@@ -1459,7 +1456,6 @@ export const authors = table('authors', {
1459
1456
  })
1460
1457
  // tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
1461
1458
  .with(tenant())
1462
- .reactive()
1463
1459
  ```
1464
1460
 
1465
1461
  ```ts
@@ -1588,7 +1584,6 @@ export const books = table('books', {
1588
1584
  weights: { title: 'A', summary: 'B' },
1589
1585
  })
1590
1586
  .with(tenant())
1591
- .reactive()
1592
1587
  ```
1593
1588
 
1594
1589
  The query executor compiles the FTS predicate via `.matching('bookSearch', q)` — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; `books` carries `tenant()`, so the runtime AND-merges `eq('tenantId', subject.tenantId)`:
@@ -2935,7 +2930,6 @@ export const users = table('users', {
2935
2930
  // adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
2936
2931
  // NO defaultWhere — a deactivated user still shows up in queries.
2937
2932
  .with(deactivation())
2938
- .reactive()
2939
2933
  ```
2940
2934
 
2941
2935
  Deactivate with a normal update — `deactivatedAt` is just a column:
@@ -3122,7 +3116,6 @@ export const syncedEvents = table('synced_events', {
3122
3116
  sequence: integer(),
3123
3117
  })
3124
3118
  .with(tenant()) // tenantId + audit columns, auto-stamped from the subject
3125
- .reactive() // events.list subscription wakes on every insert
3126
3119
 
3127
3120
  export type SyncedEvent = InferRow<typeof syncedEvents>
3128
3121
  export const database = databaseHandle({ actors, tenants, syncedEvents })
@@ -39,7 +39,7 @@ apps/acme/web/ # dir named by the app, not the template
39
39
 
40
40
  ## The page
41
41
 
42
- `src/pages/index.tsx` is plain JSX with the two static-render exports:
42
+ `src/pages/index.page.tsx` is plain JSX with the two static-render exports:
43
43
 
44
44
  ```tsx
45
45
  export const renderMode = 'static' as const // pre-render at build time
@@ -59,7 +59,7 @@ export default function Index() {
59
59
  </section>
60
60
  <section>
61
61
  <h2>Ready to start?</h2>
62
- <p>Edit <code>src/pages/index.tsx</code> to make it yours.</p>
62
+ <p>Edit <code>src/pages/index.page.tsx</code> to make it yours.</p>
63
63
  </section>
64
64
  </main>
65
65
  )
@@ -80,7 +80,7 @@ export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
80
80
  ```
81
81
 
82
82
  ```tsx
83
- // src/pages/index.tsx
83
+ // src/pages/index.page.tsx
84
84
  export const interactive = 'islands' as const // was 'none'
85
85
  import SignupForm from '../components/SignupForm.island'
86
86
  // … render <SignupForm /> somewhere in the page …
@@ -185,7 +185,7 @@ If your final product is going to be a:
185
185
 
186
186
  _A documentation site with a catch-all docs router and build-time static pre-rendering of every page via getStaticPaths._
187
187
 
188
- A documentation-site starter. `src/pages/docs/[...slug].tsx` is a catch-all router; `getStaticPaths` enumerates every doc URL at build time so `voltro build` pre-renders them all into a static site you can deploy anywhere. Template id: **`frontend-docs`**.
188
+ A documentation-site starter. `src/pages/docs/[...slug].page.tsx` is a catch-all router; `getStaticPaths` enumerates every doc URL at build time so `voltro build` pre-renders them all into a static site you can deploy anywhere. Template id: **`frontend-docs`**.
189
189
 
190
190
  The starter ships a tiny in-file `DOCS` array as stub content. Replace it with a real loader (a markdown reader, a CMS query, a database fetch) — the page component's contract stays the same.
191
191
 
@@ -215,7 +215,7 @@ apps/acme/docs/ # dir named by the app, not the template
215
215
 
216
216
  ## The catch-all renderer
217
217
 
218
- `src/pages/docs/[...slug].tsx` is the whole engine. It declares:
218
+ `src/pages/docs/[...slug].page.tsx` is the whole engine. It declares:
219
219
 
220
220
  ```tsx
221
221
  export const renderMode = 'static' as const // pre-render at build
@@ -418,7 +418,7 @@ No rpc client, no loader, no `apis:` entry, no `@voltro/ui-shadcn` dependency. T
418
418
  The page exports `renderMode = 'spa'`, which tells `voltro build` not to pre-render it: the client renders it on load. Use this when the whole page is interactive AND its state lives in the browser — an SSR'd first paint would just be discarded on hydration, so rendering it on the server buys nothing.
419
419
 
420
420
  ```tsx
421
- // src/pages/index.tsx — a pure client-side SPA (a bill splitter)
421
+ // src/pages/index.page.tsx — a pure client-side SPA (a bill splitter)
422
422
  import type { ReactNode } from 'react'
423
423
  import { useEffect, useState } from 'react'
424
424
 
@@ -603,7 +603,7 @@ The full reference for these modes lives in the [render modes guide](/docs/routi
603
603
  `index.tsx` exports `renderMode = 'ssr'`. Its loader runs SERVER-SIDE on EVERY request under `voltro start` — the timestamp and nonce change on each refresh, and the loader can read the incoming request to personalise the HTML before it's sent. There is no caching: the response carries `x-voltro-rendered-by: ssr`.
604
604
 
605
605
  ```tsx
606
- // src/pages/index.tsx
606
+ // src/pages/index.page.tsx
607
607
  import type { ReactNode } from 'react'
608
608
  import { useLoaderData, useServerRequest, type LoaderFn, type PageMeta } from '@voltro/web'
609
609
 
@@ -646,7 +646,7 @@ Use SSR for anything that varies per request: a logged-in dashboard, a page that
646
646
  `feed.tsx` takes the SAME render path as SSR, but `voltro start` CACHES the HTML and serves the cached copy to every request within the `revalidate` window. After the window expires the next request re-renders and replaces the cache — cache hits return in well under a millisecond. `tenantAware: true` folds the `x-tenant` request header into the cache key, so tenant A's render is never served to tenant B:
647
647
 
648
648
  ```tsx
649
- // src/pages/feed.tsx
649
+ // src/pages/feed.page.tsx
650
650
  import type { ReactNode } from 'react'
651
651
  import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
652
652
 
@@ -685,7 +685,7 @@ The loader's `headers` is populated server-side (lowercased keys) — that's how
685
685
  `feed-swr.tsx` is the same ISR page plus a `staleWhileRevalidate` window. When `revalidate` expires, instead of blocking the next visitor on a fresh render, the cache serves the STALE HTML immediately AND kicks off a background re-render. The visitor never waits; the cache catches up out-of-band. Beyond `revalidate + staleWhileRevalidate` the next request blocks on a fresh render, like a cold MISS:
686
686
 
687
687
  ```tsx
688
- // src/pages/feed-swr.tsx
688
+ // src/pages/feed-swr.page.tsx
689
689
  import type { ReactNode } from 'react'
690
690
  import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
691
691
 
@@ -878,10 +878,10 @@ export const posts: ReadonlyArray<Post> = [
878
878
 
879
879
  ## SSG dynamic routes — `getStaticPaths` + `loader` + per-post `meta`
880
880
 
881
- A dynamic route like `src/pages/blog/[slug].tsx` matches infinitely many URLs. To pre-render it statically the build needs to know WHICH slugs exist — that is what `getStaticPaths` answers, by mapping over the content source. For each enumerated `params` the build runs the `loader` (server-side, at build time) and renders one HTML file. The `meta` export is a FUNCTION of the loader data, so each post gets its own correct `<title>` / `<description>` baked into the HTML — exactly what you want for SEO and social cards.
881
+ A dynamic route like `src/pages/blog/[slug].page.tsx` matches infinitely many URLs. To pre-render it statically the build needs to know WHICH slugs exist — that is what `getStaticPaths` answers, by mapping over the content source. For each enumerated `params` the build runs the `loader` (server-side, at build time) and renders one HTML file. The `meta` export is a FUNCTION of the loader data, so each post gets its own correct `<title>` / `<description>` baked into the HTML — exactly what you want for SEO and social cards.
882
882
 
883
883
  ```tsx
884
- // src/pages/blog/[slug].tsx
884
+ // src/pages/blog/[slug].page.tsx
885
885
  import type { ReactNode } from 'react'
886
886
  import { useLoaderData, notFound, type LoaderFn, type PageMeta } from '@voltro/web'
887
887
  import { posts, type Post } from '../../content/posts'
@@ -972,7 +972,7 @@ export default island(ReadingProgress, { name: 'ReadingProgress', hydrate: 'load
972
972
  The index at `/` is pure content: a list of posts with no interactivity. It declares `interactive: 'none'`, which makes the framework strip EVERY `<script>` it would otherwise emit — the HTML ships with no JS bundle at all. It reads the same content source the `[slug]` page + `getStaticPaths` use:
973
973
 
974
974
  ```tsx
975
- // src/pages/index.tsx
975
+ // src/pages/index.page.tsx
976
976
  import type { ReactNode } from 'react'
977
977
  import type { PageMeta } from '@voltro/web'
978
978
  import { posts } from '../content/posts'
@@ -1106,10 +1106,10 @@ The serverless function lives next to the web app but is a separate deploy artif
1106
1106
 
1107
1107
  ## The static page + the island form
1108
1108
 
1109
- `src/pages/index.tsx` is `renderMode: 'static'` (pre-rendered, servable from a CDN) and `interactive: 'islands'` — so only the `<ContactForm>` island hydrates; the headline + copy ship as inert HTML with no React lifecycle:
1109
+ `src/pages/index.page.tsx` is `renderMode: 'static'` (pre-rendered, servable from a CDN) and `interactive: 'islands'` — so only the `<ContactForm>` island hydrates; the headline + copy ship as inert HTML with no React lifecycle:
1110
1110
 
1111
1111
  ```tsx
1112
- // src/pages/index.tsx
1112
+ // src/pages/index.page.tsx
1113
1113
  import type { ReactNode } from 'react'
1114
1114
  import type { PageMeta } from '@voltro/web'
1115
1115
  import ContactForm from '../components/ContactForm.island'
@@ -1358,7 +1358,7 @@ apps/demo/web/
1358
1358
  ├── README.md
1359
1359
  └── src/pages/
1360
1360
  ├── layout.tsx # root shell (header + <main>)
1361
- └── index.tsx # the reactive page — useSubscription + useMutation
1361
+ └── index.page.tsx # the reactive page — useSubscription + useMutation
1362
1362
  ```
1363
1363
 
1364
1364
  ## The `apis` wiring
@@ -1390,7 +1390,7 @@ The `package` resolves the api workspace package; its port is auto-discovered fr
1390
1390
  ## The reactive loop
1391
1391
 
1392
1392
  ```tsx
1393
- // src/pages/index.tsx
1393
+ // src/pages/index.page.tsx
1394
1394
  import { useMutation, useSubscription } from '@voltro/client'
1395
1395
 
1396
1396
  const TENANT = 'acme'
@@ -1418,7 +1418,7 @@ The subscription builder also exposes `revision`, `emittedAt`, `error`, and `pen
1418
1418
 
1419
1419
  ## Pairing with a different api
1420
1420
 
1421
- This page is wired to api-backend's `notes` domain. To point it at your own api, change `apis.app.package` in `app.config.ts`, then swap the rpc tags + the row type in `src/pages/index.tsx` to match your descriptors. The hook shapes are identical for any api.
1421
+ This page is wired to api-backend's `notes` domain. To point it at your own api, change `apis.app.package` in `app.config.ts`, then swap the rpc tags + the row type in `src/pages/index.page.tsx` to match your descriptors. The hook shapes are identical for any api.
1422
1422
 
1423
1423
  ## When to use
1424
1424
 
@@ -1533,7 +1533,7 @@ export const withLocalePrefix = (path: string, locale: string): string =>
1533
1533
  Each page has a one-line **mirror** under `src/pages/[locale]/`. The mirror re-exports the bare page verbatim (the component reads its locale from the URL via the layout's provider) and uses `getStaticPaths` to enumerate the *non-default* locales — which is what tells `voltro build` to emit the `/de/...` HTML. Without the mirror, only the default-locale URL is built.
1534
1534
 
1535
1535
  ```tsx
1536
- // src/pages/[locale]/index.tsx — the entire file
1536
+ // src/pages/[locale]/index.page.tsx — the entire file
1537
1537
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../lib/locale'
1538
1538
 
1539
1539
  export { default } from '../index'
@@ -1550,7 +1550,7 @@ export const getStaticPaths = async (): Promise<Array<{ params: { locale: string
1550
1550
  The body of each page is localised automatically (the SSG pipeline wraps every variant in the right catalog's provider). The document head is NOT — unless you export `meta` as a **function of `{ locale }`**. The framework drives `locale` from `params.locale` on the `[locale]` route (and `defaultLocale` on the bare path), so each variant bakes the right `<title>` / `<meta description>` into its HTML — exactly what you want for SEO and social cards. A plain `meta` object would leave the head English on `/de`.
1551
1551
 
1552
1552
  ```tsx
1553
- // src/pages/index.tsx
1553
+ // src/pages/index.page.tsx
1554
1554
  import { T, useT, useLocale } from '@voltro/i18n'
1555
1555
  import { getCatalog } from '../lib/locale'
1556
1556
 
@@ -1662,7 +1662,7 @@ The `[locale]` mirrors pick it up automatically — `getStaticPaths` reads `SUPP
1662
1662
 
1663
1663
  ## Anti-patterns
1664
1664
 
1665
- - **Forgetting the `[locale]` mirror for a new page.** Create `src/pages/foo.tsx` AND a one-line mirror `src/pages/[locale]/foo.tsx`. Without the mirror, only the default-locale URL is emitted — `/de/foo` 404s.
1665
+ - **Forgetting the `[locale]` mirror for a new page.** Create `src/pages/foo.page.tsx` AND a one-line mirror `src/pages/[locale]/foo.page.tsx`. Without the mirror, only the default-locale URL is emitted — `/de/foo` 404s.
1666
1666
  - **Exporting `meta` as a plain object.** The body localises, but the `<title>` / `<meta description>` stay English on `/de`. Export `meta` as a function of `{ locale }` and read the catalog via `getCatalog(locale)`.
1667
1667
  - **Skipping `defineLocale<typeof en>()` for a non-base catalog.** Plain `defineCatalog(...)` compiles but loses the parity check — the one feature that stops translations drifting. Always run non-base locales through `defineLocale<typeof en>()`.
1668
1668
  - **Angle brackets in catalog strings.** react-intl's ICU parser treats `<tag>` as a rich-text element and fails (`FORMAT_ERROR`) if there's no matching close tag. Write "page titles", not "`<title>` tags", in a message.
@@ -1759,7 +1759,7 @@ export default function DashboardLayout({ children }) {
1759
1759
  The gate must run **per request** — it reads the request's cookie and may redirect. A `static` page is pre-rendered once at build time with no per-visitor render, so it can't redirect per-visitor; in fact `voltro build` **rejects** a `RedirectError` thrown during static pre-render. So the authed pages declare `renderMode: 'ssr'`:
1760
1760
 
1761
1761
  ```tsx
1762
- // src/pages/dashboard/index.tsx
1762
+ // src/pages/dashboard/index.page.tsx
1763
1763
  export const renderMode = 'ssr' as const
1764
1764
  export const meta = { title: 'Overview' }
1765
1765
  export default function Overview() { /* … */ }
@@ -1884,7 +1884,7 @@ export default {
1884
1884
  `query` is present ONLY server-side (`ssr` / `isr`). It invokes the api's rpc directly over `POST /rpc`, **forwarding the request's session cookie** so the same Subject + tenant resolve as the WebSocket path. A streaming query is drained to its FIRST snapshot — here, the current notes for this tenant. `meta` reads the result, so the `<title>` reflects real data in the server-rendered HTML:
1885
1885
 
1886
1886
  ```tsx
1887
- // src/pages/index.tsx
1887
+ // src/pages/index.page.tsx
1888
1888
  import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
1889
1889
  import { useSubscription } from '@voltro/client'
1890
1890
 
@@ -1994,7 +1994,7 @@ const entities = manifest ? deriveEntityAdmins(manifest) : []
1994
1994
  ## Per-entity CRUD
1995
1995
 
1996
1996
  ```tsx
1997
- // src/pages/admin/[entity].tsx (abridged)
1997
+ // src/pages/admin/[entity].page.tsx (abridged)
1998
1998
  const canCreate = useCan(spec.createScope) // <table>:create
1999
1999
  {spec.createTag && canCreate ? (
2000
2000
  <AutoForm api="app" mutation={spec.createTag} submitLabel={`Add ${spec.table}`} />
@@ -2041,7 +2041,7 @@ apps/acme/web/
2041
2041
 
2042
2042
  ## Make it yours
2043
2043
 
2044
- Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts`) + demo scopes (`lib/admin.ts`) for real auth; replace the generic `[entity].tsx` with a hand-built page for any entity that needs more than CRUD.
2044
+ Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts`) + demo scopes (`lib/admin.ts`) for real auth; replace the generic `[entity].page.tsx` with a hand-built page for any entity that needs more than CRUD.
2045
2045
 
2046
2046
  ## When to use frontend-admin vs. the other web templates
2047
2047
 
@@ -23,7 +23,7 @@ Everything in it is in-memory and synchronous-friendly — no docker, no running
23
23
 
24
24
  | Export | Use |
25
25
  |---|---|
26
- | `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `email`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
26
+ | `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `email`, `webhooks`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
27
27
  | `mockStore(seed)` | Seed rows: `makeTestContext({ store: mockStore({ todos: [...] }) })`. |
28
28
  | `MockClock` / `MockEmail` / `MockLLM` | Deterministic clock, captured email, queued LLM responses. |
29
29
  | `makeVoltroTestClient(config)` | The frontend harness — render a component against mocked `useSubscription` / `useMutation`, from the `@voltro/testing/client` subpath. → [Component testing](/docs/testing/component-testing) |
@@ -51,6 +51,8 @@ voltro e2e # boot api + web siblings, run tests, tear down
51
51
 
52
52
  `voltro test` is a thin wrapper over vitest — it runs the app's test files with the framework's config. Everything `@voltro/testing` exports is plain TypeScript you import inside those files; there's no special test runner.
53
53
 
54
+ `e2e/` is left alone: those specs belong to `voltro e2e`, which drives them through tsx against a booted api + web. They define no vitest suite, so collecting them would report "No test suite found" — a red run for an app laid out exactly as the framework asks. Pass your own `--exclude` and it wins outright.
55
+
54
56
  ## What NOT to do
55
57
 
56
58
  - **Don't spin up Postgres for a handler test.** `makeTestContext`'s in-memory store enforces the same mixin behaviour — tenant scoping, soft-delete, audit — so a unit test catches the same class of bug far faster. Save a real database for [dialect parity](/docs/testing/dialect-parity) and e2e.
@@ -480,6 +482,19 @@ ctx.email.lastTo('a@b.com') // the most recent email to that address, or unde
480
482
  ctx.email.clear() // reset between cases
481
483
  ```
482
484
 
485
+ ### `ctx.webhooks` — `MockWebhooks`
486
+
487
+ Records outgoing webhook emissions. Every context derived from this one — the transaction context `invoke` builds for a mutation, a `withSubject` / `withTenant` block — shares the same recorder, so what you assert on IS what the handler emitted.
488
+
489
+ ```ts
490
+ ctx.webhooks.emitted // EmittedWebhook[] — every emit, in order
491
+ ctx.webhooks.last('todo.created') // the most recent emission of that event
492
+ ctx.webhooks.payloadsFor('todo.created') // just the payloads, oldest first
493
+ ctx.webhooks.clear() // reset between cases
494
+ ```
495
+
496
+ It records; it does not deliver, sign, or consult subscriptions. `emit` reports `{ delivered: 0 }` because no targets are subscribed, and saying so is more honest than a number a test might assert against.
497
+
483
498
  ### `ctx.llm` — `MockLLM`
484
499
 
485
500
  A queue of canned model responses plus a record of every call. Each `MockResponse` is one of `{ text }`, `{ toolCall: { name, input } }`, or `{ error: { code, message? } }`.
@@ -76,6 +76,22 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
76
76
  A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
77
77
 
78
78
  `0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
79
+ - **@voltro/cli** — Framework-generated output is handed to the workspace's owner, not left owned by whoever the process happens to be.
80
+
81
+ The previous release fixed this for the minted `.env.local`. The report that followed showed the scope was wrong: it is EVERY directory the framework generates. A dev pod running as root with the host monorepo bind-mounted leaves `.framework/` and `app.graph.observed.*` as `root:root` inside the developer's own tree, and on the host:
82
+
83
+ ```
84
+ voltro build . → EACCES: permission denied, open '…/apps/display/.framework/index.html'
85
+ ```
86
+
87
+ That is the harder failure. `.env.local` broke env loading; this breaks the production build of every web app outright, with no workaround short of chown-ing by hand after each pod boot. One team could only verify their frontends through test suites and live requests against the running pods.
88
+
89
+ `voltro dev` and `voltro build` now hand their generated output — `.framework`, `.env.local`, every `*.generated.*` — to the uid that owns the app root, and say so loudly when they cannot. A no-op on every ordinary run and in any container started with `--user <uid>:<gid>`: when the process already owns the root it returns without touching the tree. Only generated state is claimed; the framework never chowns a file a human wrote.
90
+ - **@voltro/cli** — The observed app-graph no longer restarts the dev server.
91
+
92
+ `app.graph.observed.json` was written into the watched app root every 10 seconds, and the supervisor's watcher fired on each write. A downstream pod measured two restarts before every boot over 2000 log lines — the rule, not an outlier — and paid a ~46 s boot three times per save.
93
+
94
+ The watcher excludes `<name>.generated.<ext>`, a substring rule chosen precisely because a per-extension whitelist had already let a generated file slip twice. This file slipped it a third time by not carrying the segment at all. It is now `app.graph.observed.generated.json`, which matches the convention instead of adding a fourth special case to a list that has drifted three times; a stale un-suffixed file from an older dev server is removed on boot so it cannot keep triggering restarts.
79
95
  - **@voltro/cli** — Four tooling fixes, all from downstream reports:
80
96
 
81
97
  - **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
@@ -59,8 +59,6 @@ export const docs = table('docs', {
59
59
  dimensions: 1536,
60
60
  }),
61
61
  )
62
- // Opt into the matcher engine so subscriptions over `docs` re-fire on write.
63
- .reactive()
64
62
 
65
63
  export type Doc = InferRow<typeof docs>
66
64