@voltro/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/dist/{apiBuild-B6_ehbem.js → apiBuild-CHItlwPO.js} +2 -2
  3. package/dist/apiBuild-D8juhYsS.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-DYOuGlEC.js → commands-CPQuQRXh.js} +3967 -2831
  6. package/dist/{dbCommand-DIOJQwsq.js → dbCommand-B8F29zV0.js} +2 -1
  7. package/dist/dbCommand-Dt7BtdMd.js +2 -0
  8. package/dist/{dev-xdijqojc.js → dev-BHLM3RPX.js} +1 -1
  9. package/dist/{dev-Fb9BmavX.js → dev-C2lCUhYI.js} +1478 -1347
  10. package/dist/fileConventions-Cu0tM-Bl.js +29 -0
  11. package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-BA2bAmYN.js} +164 -163
  12. package/dist/frameworkTableAssembly-D1zJSgOU.js +2 -0
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CYDSO3za.js} +955 -945
  15. package/dist/{serveCommand-CqYxzBZR.js → serveCommand-DBzQcCuv.js} +410 -396
  16. package/dist/serveEntry.js +2 -2
  17. package/dist/{start-CjG_0xhR.js → start-BtgatJN3.js} +246 -246
  18. package/dist/startEntry.js +2 -2
  19. package/package.json +17 -17
  20. package/templates/AGENTS.core.md +77 -3
  21. package/templates/AGENTS.md +78 -4
  22. package/templates/agent-docs/_index.md +1 -1
  23. package/templates/agent-docs/_manifest.json +2 -2
  24. package/templates/agent-docs/authentication.md +34 -2
  25. package/templates/agent-docs/cli.md +116 -2
  26. package/templates/agent-docs/data.md +195 -3
  27. package/templates/agent-docs/database/hosting.md +2 -2
  28. package/templates/agent-docs/database/migrations.md +77 -3
  29. package/templates/agent-docs/database/overview.md +11 -0
  30. package/templates/agent-docs/database/seedsdialects.md +53 -1
  31. package/templates/agent-docs/internationalization.md +3 -3
  32. package/templates/agent-docs/introduction.md +133 -2
  33. package/templates/agent-docs/plugins.md +2 -1
  34. package/templates/agent-docs/reference.md +4 -4
  35. package/templates/agent-docs/routing.md +48 -43
  36. package/templates/agent-docs/schema-driven-ui.md +14 -3
  37. package/templates/agent-docs/templates/apibackends.md +1 -8
  38. package/templates/agent-docs/templates/appshells.md +24 -24
  39. package/templates/agent-docs/testing.md +16 -1
  40. package/templates/agent-docs/whats-new.md +247 -39
  41. package/templates/apps/api-ai/database/schema.ts +0 -2
  42. package/templates/apps/api-ai/package.json +7 -7
  43. package/templates/apps/api-auth/package.json +8 -8
  44. package/templates/apps/api-backend/database/schema.ts +0 -3
  45. package/templates/apps/api-backend/package.json +7 -7
  46. package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/database/schema.ts +0 -3
  49. package/templates/apps/api-backend-mail/package.json +8 -8
  50. package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-storage/database/schema.ts +0 -3
  53. package/templates/apps/api-backend-storage/package.json +8 -8
  54. package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
  55. package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
  56. package/templates/apps/api-data-advanced/package.json +8 -8
  57. package/templates/apps/api-durable/database/schema.ts +0 -3
  58. package/templates/apps/api-durable/package.json +8 -8
  59. package/templates/apps/api-feature-flags/database/schema.ts +0 -1
  60. package/templates/apps/api-feature-flags/package.json +9 -9
  61. package/templates/apps/api-governance/database/schema.ts +0 -1
  62. package/templates/apps/api-governance/package.json +8 -8
  63. package/templates/apps/api-kv/database/schema.ts +0 -3
  64. package/templates/apps/api-kv/package.json +8 -8
  65. package/templates/apps/api-moderation/database/schema.ts +0 -2
  66. package/templates/apps/api-moderation/package.json +8 -8
  67. package/templates/apps/api-observability/database/schema.ts +0 -1
  68. package/templates/apps/api-observability/package.json +8 -8
  69. package/templates/apps/api-ratelimit/database/schema.ts +0 -1
  70. package/templates/apps/api-ratelimit/package.json +8 -8
  71. package/templates/apps/api-rbac/database/schema.ts +0 -2
  72. package/templates/apps/api-rbac/package.json +8 -8
  73. package/templates/apps/api-rest/database/schema.ts +0 -3
  74. package/templates/apps/api-rest/package.json +7 -7
  75. package/templates/apps/api-saas/database/schema.ts +0 -1
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-search/database/schema.ts +0 -1
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-versioning/database/schema.ts +0 -1
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/database/schema.ts +0 -1
  82. package/templates/apps/api-webhooks/package.json +9 -9
  83. package/templates/apps/changelog/package.json +6 -6
  84. package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
  85. package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  86. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  87. package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  88. package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  89. package/templates/apps/edge-functions/package.json +2 -2
  90. package/templates/apps/frontend-admin/package.json +8 -8
  91. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  92. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  93. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  94. package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
  95. package/templates/apps/frontend-app/package.json +8 -8
  96. package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  97. package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
  98. package/templates/apps/frontend-blank/package.json +7 -7
  99. package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  100. package/templates/apps/frontend-contact/package.json +7 -7
  101. package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  102. package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  103. package/templates/apps/frontend-dashboard/package.json +7 -7
  104. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  105. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  106. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
  107. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
  108. package/templates/apps/frontend-docs/package.json +7 -7
  109. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
  110. package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  111. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  112. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
  113. package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  114. package/templates/apps/frontend-i18n/package.json +6 -6
  115. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
  116. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  117. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  118. package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
  119. package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  120. package/templates/apps/frontend-landing/package.json +7 -7
  121. package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  122. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  123. package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  124. package/templates/apps/frontend-spa/package.json +7 -7
  125. package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  126. package/templates/apps/frontend-ssr/package.json +7 -7
  127. package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
  128. package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
  129. package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  130. package/templates/apps/frontend-ssr-api/package.json +8 -8
  131. package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  132. package/templates/apps/frontend-static-blog/package.json +6 -6
  133. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
  134. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  135. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  136. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  137. package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  138. package/dist/apiBuild-Bzpb383i.js +0 -2
  139. package/dist/dbCommand-ADD4mrTI.js +0 -2
  140. package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
  141. /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
  142. /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
  143. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  144. /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  145. /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
  146. /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
  147. /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
  148. /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
  149. /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
  150. /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
  151. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  152. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  153. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
  154. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
  155. /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
  156. /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
  157. /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
  158. /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
  159. /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
  160. /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
  161. /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
  162. /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
  163. /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
  164. /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
  165. /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
  166. /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
@@ -383,11 +383,28 @@ Procedure descriptors are intentionally separate from server executors. Descript
383
383
 
384
384
  The browser-safe rule is **transitive**, and that is where it usually breaks. The codegen pulls every descriptor (and every workflow descriptor) value-level into `rpcGroup.generated.ts`, which the web client loads — so a descriptor plus *everything it imports* must stay free of server-only code (`node:*`, the `database` handle, `@voltro/ai`, cluster, plugins, `@voltro/protocol/session`). The classic mistake is not a literal `import 'node:crypto'` but a descriptor importing a shared typed-error or helper from a `lib/` file that *also* imports the database — which drags the whole schema graph into the browser bundle. Keep typed errors, Schemas, and pure helpers in files with zero server imports; put DB-backed guards in `.server.ts`. A leak shows up as the web app fetching hundreds of modules / tens of MB on first load, or crashing with `Module "node:crypto" has been externalized for browser compatibility`.
385
385
 
386
+ ### Declaring a shared file browser-safe: `*.client.ts`
387
+
388
+ A shared `lib/` helper can state the rule about itself. Name it `*.client.ts` (or `*.client.tsx`) and it declares: *I, and everything I transitively import, are browser-safe.* `voltro dev` walks its import graph at boot and refuses to start if the claim is false, printing the chain.
389
+
390
+ ```text
391
+ lib/orderErrors.client.ts # I and my imports are browser-safe — checked at boot
392
+ lib/orderGuards.server.ts # I may touch the database handle
393
+ lib/orderTypes.ts # unmarked: no claim, the graph decides
394
+ ```
395
+
396
+ This is the mirror of `*.server.ts`, and it works for the same reason: both declare a **permission**, which is something an import graph cannot derive. The graph can tell you what a file imports; it can never tell you what a file is *allowed* to import.
397
+
398
+ Without the marker the leak is still caught — by the rpcGroup guard — but only once some descriptor happens to reach the file, and the error is a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
399
+
400
+ An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
401
+
402
+
386
403
  ## The web side (`apps/*/web/`)
387
404
 
388
405
  | Path | What it is |
389
406
  |---|---|
390
- | `src/pages/*.tsx` | A page. URL is the file path; `[id].tsx` -> `/:id`, `[...slug].tsx` -> catch-all. |
407
+ | `src/pages/*.tsx` | A page. URL is the file path; `[id].page.tsx` -> `/:id`, `[...slug].page.tsx` -> catch-all. |
391
408
  | `src/pages/layout.tsx` | Outer layout — wraps every page. |
392
409
  | `src/pages/error.tsx` | Error boundary for the whole subtree. |
393
410
  | `src/pages/not-found.tsx` | Fallback rendered when no page matches. |
@@ -398,7 +415,7 @@ The browser-safe rule is **transitive**, and that is where it usually breaks. Th
398
415
  Each page can opt into a render strategy via two exports:
399
416
 
400
417
  ```tsx
401
- // src/pages/blog/[slug].tsx
418
+ // src/pages/blog/[slug].page.tsx
402
419
  export const renderMode = 'isr' as const // 'static' | 'spa' | 'ssr' | 'isr'
403
420
  export const interactive = 'islands' as const // 'none' | 'islands' | 'full'
404
421
  ```
@@ -436,3 +453,117 @@ apps/acme/api/
436
453
  - **Don't put two procedure descriptors with the same `name`.** The CLI fails the boot — fix the name collision.
437
454
  - **Don't import `.server.ts` files from your web app.** Use the generated client. Importing the server module straight into the browser bundle leaks server-only deps (Postgres driver, secret keys).
438
455
  - **Don't move generated files in `.framework/` into the user source tree.** They're disposable; the CLI rewrites them on every boot.
456
+
457
+
458
+
459
+ ---
460
+
461
+ <!-- source: en/intro/file-taxonomy.md -->
462
+ ## The web file taxonomy
463
+
464
+ _The contract suffixes for web code — component, component.ui, hook, types, internal, fixture, tracking — what each one promises and which rule enforces it._
465
+
466
+ Every suffix on this page is a **contract**, not a label. Something else in the codebase depends on the promise, and `voltro doctor` enforces it. That is the whole admission test, and it is why the list is short:
467
+
468
+ > Does another file's correctness depend on this file keeping its promise?
469
+
470
+ If yes, the promise belongs in the name — you cannot see a contract before you break it otherwise. If no, it is a category, and categories are read out of the file.
471
+
472
+ ## The catalogue
473
+
474
+ | Suffix | Promise | Enforced by |
475
+ |---|---|---|
476
+ | `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
477
+ | `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
478
+ | `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
479
+ | `*.types.ts` | zero runtime exports | `types/runtime-export` |
480
+ | `*.internal.ts` | only its own subtree imports it | `internal/foreign-import` |
481
+ | `*.fixture.ts` | no production path reaches it | `fixture/production-import` |
482
+ | `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
483
+ | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
484
+ | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
485
+
486
+ ## `*.component.ui.tsx` — reads, never writes
487
+
488
+ ```tsx
489
+ // OrderRow.component.ui.tsx
490
+ import { useCan } from '@voltro/client'
491
+ import { useT } from '@voltro/i18n'
492
+
493
+ export const OrderRow = (props: { order: Order; onCancel: () => void }) => {
494
+ const cancelLabel = useT('orders.cancel')
495
+ const mayCancel = useCan('orders:write')
496
+ return <tr>{/* … */}</tr>
497
+ }
498
+ ```
499
+
500
+ Reading is allowed on purpose. Threading translations and permissions through props is prop-drilling — it makes every call site worse without making the component more portable.
501
+
502
+ **Writing** is what breaks the contract. A component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading what it does — and that property is exactly what its callers rely on. Lift the mutation into the `*.component.tsx` that owns it and pass a handler down.
503
+
504
+ The same file must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page. An unrendered presentational component is carried, reviewed and refactored forever without ever reaching a user; that is how a design system quietly doubles in size.
505
+
506
+ ## `*.internal.ts` — the feature boundary
507
+
508
+ ```text
509
+ src/features/orders/
510
+ ├── index.ts # the public surface
511
+ ├── orderState.internal.ts # only this directory may import it
512
+ └── OrderList.component.tsx
513
+ ```
514
+
515
+ `.internal` is the promise that refactoring inside that directory breaks nobody. An import from another feature revokes it — silently, and without a single review comment, which is how a boundary rots.
516
+
517
+ ## `*.types.ts` — provably free to import
518
+
519
+ No runtime export at all. That is not tidiness: it is what makes importing the module cost nothing in the bundle **and** makes it impossible for it to participate in a runtime import cycle. In a large codebase the second guarantee is the valuable one — an import cycle is only visible when it finally throws.
520
+
521
+ ## `*.tracking.ts` — analytics is confined
522
+
523
+ ```ts
524
+ // checkout.tracking.ts
525
+ import { defineTracking } from '@voltro/client'
526
+
527
+ export const checkoutTracking = defineTracking('CheckoutButton', {
528
+ onMount: (props) => ({ event: 'checkout_started', orderId: props.orderId }),
529
+ onClick: 'checkout.confirmed',
530
+ })
531
+ ```
532
+
533
+ A component then wires it up with `useTracking(checkoutTracking, props, sink)` — it names a spec, it does not author one. Event names, property bags and the decisions about which fields leave the building all live in one place.
534
+
535
+ `useTracking` itself is a hook, so it is *not* confined — a rule nobody could satisfy is a rule everybody disables. What is confined is `defineTracking`, the declaration.
536
+
537
+ The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
538
+
539
+ ## What deliberately has NO suffix
540
+
541
+ A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
542
+
543
+ `*.store.ts` was in this section until `defineStore` shipped, and the reason it moved out is the rule itself: **suffixes follow primitives, never the reverse.** While client state was something you brought yourself, a suffix for it would have promised nobody anything. Now `voltro check` reads those files — one store per file, and no store mirroring server state — so the name carries a contract.
544
+
545
+ There is no `*.form.tsx`, and it is the most-requested one. A form is a component; what makes it a form is the schema it validates against, which is already declared and already typed. A suffix would add a rename without adding a checkable promise — "contains a `<form>`" is not something another file's correctness depends on. If forms ever gain a framework primitive that other code binds to, the rule above will produce the suffix on its own.
546
+
547
+ ## Code you did not write
548
+
549
+ Our rules are **ours**. A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions (many exports per file, a hook beside the component), and is overwritten by the next `add`. Renaming it would break their convention, be undone on the next generator run, and leave the directory half-migrated the moment one file fails to classify.
550
+
551
+ So the taxonomy applies to what you write, always — and to nothing else. Two signals mark a directory as not-yours:
552
+
553
+ 1. **`components.json`** at the app root. Its `aliases.ui` names the directory shadcn owns, so a shadcn project needs no configuration at all. Only `aliases.ui` is honoured — `aliases.components` is where your own components live too, and exempting it would silence the taxonomy across most of a codebase.
554
+ 2. **A `.voltro-vendored` file** in any directory, whose first line names the source:
555
+
556
+ ```text
557
+ src/vendor/.voltro-vendored # "copied from acme-design-system v3"
558
+ ```
559
+
560
+ The marker is a file with a reason in it rather than a config list on purpose. A config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. `voltro doctor` prints every exemption it honoured, so the escape hatch is never silent.
561
+
562
+ ## Migrating
563
+
564
+ `voltro update` renames what it can decide from the exports alone: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file.
565
+
566
+ Two things it will NOT do:
567
+
568
+ - A file exporting a component **and** a hook is left alone and reported. That is the file the taxonomy most wants split, and no codemod can decide which half keeps the name.
569
+ - `*.component.ui.tsx` is never inferred. "Presentational" is a promise about what a component *may* do; one that merely happens not to write today has not made it.
@@ -471,7 +471,8 @@ definePluginRoute({
471
471
  })
472
472
  ```
473
473
 
474
- `source` must name a table declared `.reactive()`. Omit `source` for a plain
474
+ `source` must name a table that is reactive every table is, unless it opted
475
+ out with `.nonReactive()`. Omit `source` for a plain
475
476
  poll-only plugin query, a mutation, or an action. This is exactly how
476
477
  `@voltro/plugin-presence` makes `presence.list` push-driven — the
477
478
  `usePresence` roster updates live with no client polling.
@@ -482,7 +482,7 @@ className={pathname.startsWith('/dashboard') ? 'active' : ''}
482
482
  URL params from `[name]` segments. Typed via the generic.
483
483
 
484
484
  ```tsx
485
- // src/pages/users/[id].tsx
485
+ // src/pages/users/[id].page.tsx
486
486
  import { useParams } from '@voltro/web'
487
487
 
488
488
  const { id } = useParams<{ id: string }>()
@@ -491,7 +491,7 @@ const { id } = useParams<{ id: string }>()
491
491
  For catch-all queries:
492
492
 
493
493
  ```tsx
494
- // src/pages/docs/[...slug].tsx
494
+ // src/pages/docs/[...slug].page.tsx
495
495
  const { slug } = useParams<{ slug: string }>()
496
496
  // /docs/intro/getting-started → slug = "intro/getting-started"
497
497
  ```
@@ -499,7 +499,7 @@ const { slug } = useParams<{ slug: string }>()
499
499
  For multi-segment dynamic paths:
500
500
 
501
501
  ```tsx
502
- // src/pages/orgs/[orgId]/projects/[projectId].tsx
502
+ // src/pages/orgs/[orgId]/projects/[projectId].page.tsx
503
503
  const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
504
504
  ```
505
505
 
@@ -604,7 +604,7 @@ Idempotent — multiple calls for the same path fire one loader. The prefetched
604
604
  Page's loader output, typed.
605
605
 
606
606
  ```tsx
607
- // src/pages/notes/[id].tsx
607
+ // src/pages/notes/[id].page.tsx
608
608
  import { useLoaderData } from '@voltro/web'
609
609
 
610
610
  interface Note {
@@ -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
@@ -1017,7 +1017,8 @@ be on. Rather than repeat that check in every `useMutation` call site, each api'
1017
1017
  runtime carries an error bus; this hook subscribes to it with React lifecycle.
1018
1018
 
1019
1019
  ```tsx
1020
- import { useOnRpcError, errorTag } from '@voltro/client'
1020
+ import { useOnRpcError } from '@voltro/client'
1021
+ import { errorTag } from '@voltro/protocol'
1021
1022
 
1022
1023
  useOnRpcError('app', useCallback((e) => {
1023
1024
  if (errorTag(e.error) === 'Unauthenticated') redirectToSignIn()
@@ -1093,16 +1094,26 @@ readable in a single place.
1093
1094
  ```tsx
1094
1095
  import { defineTracking, useTracking } from '@voltro/client'
1095
1096
 
1096
- const spec = defineTracking('CheckoutButton', {
1097
+ interface CheckoutButtonProps {
1098
+ readonly plan: 'free' | 'pro'
1099
+ readonly onClick: () => void
1100
+ }
1101
+
1102
+ const spec = defineTracking<CheckoutButtonProps>('CheckoutButton', {
1097
1103
  onMount: 'checkout.viewed',
1098
1104
  onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
1099
1105
  })
1100
1106
 
1101
- const CheckoutButton = (props) => {
1107
+ const CheckoutButton = (props: CheckoutButtonProps) => {
1102
1108
  const tracked = useTracking(spec, props, sink)
1103
1109
  return <button {...tracked}>Checkout</button>
1104
1110
  }
1105
1111
  ```
1112
+ **Name the props.** The type parameter is what lets a payload builder read
1113
+ `props.plan` instead of indexing into `unknown` and casting — and a cast is the
1114
+ last thing that belongs in the one file that states what leaves the browser. It
1115
+ defaults to an untyped bag, so a loose catalogue still compiles; `useTracking`
1116
+ then returns the props type, which is what makes `{...tracked}` typecheck at all.
1106
1117
 
1107
1118
  `useTracking(spec, props, sink)` returns a **copy of your props** with every
1108
1119
  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 })