@voltro/cli 0.16.0 → 0.18.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 (139) hide show
  1. package/CHANGELOG.md +297 -0
  2. package/bin/voltro.mjs +6 -1
  3. package/dist/{apiBuild-BJtiqoOu.js → apiBuild-CPDTJHkH.js} +2 -2
  4. package/dist/apiBuild-D1UBJ4TM.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-8A5RU3MW.js → commands-laJDMj2m.js} +2679 -2444
  7. package/dist/dbCommand-C3R5LrBZ.js +2 -0
  8. package/dist/{dbCommand-OPMtO0sX.js → dbCommand-DJSxnjPt.js} +2 -2
  9. package/dist/{dev-DGjEvvkV.js → dev-BOFxC21E.js} +1076 -1013
  10. package/dist/{dev-HdG7JdSk.js → dev-DOK0w6ZW.js} +1 -1
  11. package/dist/{fileConventions-Cu0tM-Bl.js → fileConventions-CBNPZVOV.js} +1 -1
  12. package/dist/frameworkTableAssembly-DRJrAVr1.js +2 -0
  13. package/dist/{frameworkTableAssembly-BA2bAmYN.js → frameworkTableAssembly-sg41s14F.js} +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/{inspectMetrics-CYDSO3za.js → inspectMetrics-D1DmLeJs.js} +491 -478
  16. package/dist/{serveCommand-C3lq3mFd.js → serveCommand-CxcxHc9Y.js} +322 -322
  17. package/dist/serveEntry.js +2 -2
  18. package/dist/{start-BtgatJN3.js → start-Clvz4IJb.js} +379 -379
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +17 -17
  21. package/templates/AGENTS.core.md +62 -9
  22. package/templates/AGENTS.md +63 -10
  23. package/templates/agent-docs/_index.md +1 -1
  24. package/templates/agent-docs/authentication.md +48 -0
  25. package/templates/agent-docs/cli.md +11 -7
  26. package/templates/agent-docs/database/migrations.md +3 -3
  27. package/templates/agent-docs/database/querying.md +6 -2
  28. package/templates/agent-docs/internationalization.md +3 -3
  29. package/templates/agent-docs/introduction.md +17 -3
  30. package/templates/agent-docs/reference.md +4 -4
  31. package/templates/agent-docs/routing.md +42 -40
  32. package/templates/agent-docs/schema-driven-ui.md +6 -3
  33. package/templates/agent-docs/templates/appshells.md +24 -24
  34. package/templates/agent-docs/whats-new.md +170 -65
  35. package/templates/apps/api-ai/package.json +7 -7
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/package.json +9 -9
  41. package/templates/apps/api-backend-storage/package.json +8 -8
  42. package/templates/apps/api-data-advanced/package.json +8 -8
  43. package/templates/apps/api-durable/package.json +8 -8
  44. package/templates/apps/api-feature-flags/package.json +9 -9
  45. package/templates/apps/api-governance/package.json +8 -8
  46. package/templates/apps/api-kv/package.json +8 -8
  47. package/templates/apps/api-moderation/package.json +8 -8
  48. package/templates/apps/api-observability/package.json +8 -8
  49. package/templates/apps/api-ratelimit/package.json +8 -8
  50. package/templates/apps/api-rbac/package.json +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +9 -9
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/changelog/src/pages/[locale]/{[slug].page.tsx → [slug]/page.tsx} +4 -4
  58. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  59. package/templates/apps/changelog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  60. package/templates/apps/changelog/src/pages/{[slug].page.test.tsx → [slug]/page.test.tsx} +1 -1
  61. package/templates/apps/changelog/src/pages/{[slug].page.tsx → [slug]/page.tsx} +2 -2
  62. package/templates/apps/changelog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
  66. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
  67. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
  68. package/templates/apps/frontend-admin/src/pages/admin/{[entity].page.tsx → [entity]/page.tsx} +1 -1
  69. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  70. package/templates/apps/frontend-admin/src/pages/admin/{index.page.test.tsx → page.test.tsx} +1 -1
  71. package/templates/apps/frontend-app/package.json +8 -8
  72. package/templates/apps/frontend-app/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  73. package/templates/apps/frontend-app/src/pages/{schema-ui.page.test.tsx → schema-ui/page.test.tsx} +2 -2
  74. package/templates/apps/frontend-app/src/pages/{schema-ui.page.tsx → schema-ui/page.tsx} +1 -1
  75. package/templates/apps/frontend-blank/package.json +7 -7
  76. package/templates/apps/frontend-blank/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  77. package/templates/apps/frontend-contact/package.json +7 -7
  78. package/templates/apps/frontend-contact/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  79. package/templates/apps/frontend-contact/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  80. package/templates/apps/frontend-dashboard/package.json +7 -7
  81. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
  82. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
  83. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
  84. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.test.tsx → page.test.tsx} +1 -1
  85. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.test.tsx → settings/page.test.tsx} +2 -2
  86. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.tsx → settings/page.tsx} +2 -2
  87. package/templates/apps/frontend-docs/package.json +7 -7
  88. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].page.tsx → [...slug]/page.tsx} +4 -4
  89. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  90. package/templates/apps/frontend-docs/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  91. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.test.tsx → [...slug]/page.test.tsx} +3 -3
  92. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.tsx → [...slug]/page.tsx} +1 -1
  93. package/templates/apps/frontend-docs/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  94. package/templates/apps/frontend-i18n/package.json +6 -6
  95. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.page.tsx → about/page.tsx} +3 -3
  96. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  97. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  98. package/templates/apps/frontend-i18n/src/pages/{about.page.test.tsx → about/page.test.tsx} +3 -3
  99. package/templates/apps/frontend-i18n/src/pages/{about.page.tsx → about/page.tsx} +1 -1
  100. package/templates/apps/frontend-i18n/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  101. package/templates/apps/frontend-landing/package.json +7 -7
  102. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  103. package/templates/apps/frontend-landing/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  104. package/templates/apps/frontend-landing/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  105. package/templates/apps/frontend-spa/package.json +7 -7
  106. package/templates/apps/frontend-spa/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  107. package/templates/apps/frontend-ssr/package.json +7 -7
  108. package/templates/apps/frontend-ssr/src/pages/{feed.page.test.tsx → feed/page.test.tsx} +2 -2
  109. package/templates/apps/frontend-ssr/src/pages/{feed.page.tsx → feed/page.tsx} +1 -1
  110. package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.test.tsx → feed-swr/page.test.tsx} +2 -2
  111. package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.tsx → feed-swr/page.tsx} +1 -1
  112. package/templates/apps/frontend-ssr/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  113. package/templates/apps/frontend-ssr-api/package.json +8 -8
  114. package/templates/apps/frontend-ssr-api/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  115. package/templates/apps/frontend-static-blog/package.json +6 -6
  116. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].page.tsx → [slug]/page.tsx} +4 -4
  117. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  118. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  119. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.test.tsx → [slug]/page.test.tsx} +3 -3
  120. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.tsx → [slug]/page.tsx} +3 -3
  121. package/templates/apps/frontend-static-blog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  122. package/dist/apiBuild-BOczx9-m.js +0 -2
  123. package/dist/dbCommand-DNQ6l-su.js +0 -2
  124. package/dist/frameworkTableAssembly-D1zJSgOU.js +0 -2
  125. /package/templates/apps/changelog/src/pages/{index.page.tsx → page.tsx} +0 -0
  126. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
  127. /package/templates/apps/frontend-admin/src/pages/admin/{index.page.tsx → page.tsx} +0 -0
  128. /package/templates/apps/frontend-app/src/pages/{index.page.tsx → page.tsx} +0 -0
  129. /package/templates/apps/frontend-blank/src/pages/{index.page.tsx → page.tsx} +0 -0
  130. /package/templates/apps/frontend-contact/src/pages/{index.page.tsx → page.tsx} +0 -0
  131. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
  132. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.tsx → page.tsx} +0 -0
  133. /package/templates/apps/frontend-docs/src/pages/{index.page.tsx → page.tsx} +0 -0
  134. /package/templates/apps/frontend-i18n/src/pages/{index.page.tsx → page.tsx} +0 -0
  135. /package/templates/apps/frontend-landing/src/pages/{index.page.tsx → page.tsx} +0 -0
  136. /package/templates/apps/frontend-spa/src/pages/{index.page.tsx → page.tsx} +0 -0
  137. /package/templates/apps/frontend-ssr/src/pages/{index.page.tsx → page.tsx} +0 -0
  138. /package/templates/apps/frontend-ssr-api/src/pages/{index.page.tsx → page.tsx} +0 -0
  139. /package/templates/apps/frontend-static-blog/src/pages/{index.page.tsx → page.tsx} +0 -0
@@ -1,3 +1,3 @@
1
- import { $ as e } from "./inspectMetrics-CYDSO3za.js";
2
- import { t } from "./start-BtgatJN3.js";
1
+ import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
2
+ import { t } from "./start-Clvz4IJb.js";
3
3
  export { e as loadDotEnv, t as runStartCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -62,22 +62,22 @@
62
62
  "@effect/platform-node": "^0.107.0",
63
63
  "@effect/sql": "^0.51.1",
64
64
  "@effect/workflow": "^0.18.2",
65
- "@voltro/ai": "0.16.0",
66
- "@voltro/cache": "0.16.0",
67
- "@voltro/data-transfer": "0.16.0",
68
- "@voltro/database": "0.16.0",
69
- "@voltro/env": "0.16.0",
70
- "@voltro/kv": "0.16.0",
71
- "@voltro/logger": "0.16.0",
72
- "@voltro/plugin-auth": "0.16.0",
73
- "@voltro/plugin-broadcast": "0.16.0",
74
- "@voltro/plugin-mail": "0.16.0",
75
- "@voltro/plugin-storage": "0.16.0",
76
- "@voltro/plugin-webhooks": "0.16.0",
77
- "@voltro/protocol": "0.16.0",
78
- "@voltro/runtime": "0.16.0",
79
- "@voltro/serverless": "0.16.0",
80
- "@voltro/workflow": "0.16.0",
65
+ "@voltro/ai": "0.18.0",
66
+ "@voltro/cache": "0.18.0",
67
+ "@voltro/data-transfer": "0.18.0",
68
+ "@voltro/database": "0.18.0",
69
+ "@voltro/env": "0.18.0",
70
+ "@voltro/kv": "0.18.0",
71
+ "@voltro/logger": "0.18.0",
72
+ "@voltro/plugin-auth": "0.18.0",
73
+ "@voltro/plugin-broadcast": "0.18.0",
74
+ "@voltro/plugin-mail": "0.18.0",
75
+ "@voltro/plugin-storage": "0.18.0",
76
+ "@voltro/plugin-webhooks": "0.18.0",
77
+ "@voltro/protocol": "0.18.0",
78
+ "@voltro/runtime": "0.18.0",
79
+ "@voltro/serverless": "0.18.0",
80
+ "@voltro/workflow": "0.18.0",
81
81
  "chokidar": "^5.0.0",
82
82
  "ioredis": "^5.11.1",
83
83
  "tinyglobby": "^0.2.17",
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
222
+ │ └── src/pages/**/page.tsx # a DIRECTORY is a route segment
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
252
+ | `page.tsx` (under `src/pages/`) | the route its DIRECTORY serves + `page.test.tsx` |
253
253
  | `*.component.tsx` | exactly ONE component (+ types) |
254
254
  | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
255
  | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
@@ -260,12 +260,27 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
260
260
  | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
261
  | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
262
 
263
- **Only `*.page.tsx` is a route.** Everything else under `src/pages/` components,
264
- hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
- it rather than in a distant `components/` directory. The suffix decides *whether*
266
- a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
- outside `src/pages/` never routes. A page MUST default-export its component —
268
- without it `voltro dev` refuses to boot and names the file.
263
+ **A DIRECTORY is a route segment, and its route is `page.tsx`.**
264
+
265
+ ```
266
+ src/pages/page.tsx → /
267
+ src/pages/pricing/page.tsx → /pricing
268
+ src/pages/users/[id]/page.tsx → /users/[id]
269
+ src/pages/docs/[...slug]/page.tsx → /docs/<anything>
270
+ ```
271
+
272
+ A parameter is a DIRECTORY name (`[id]/`), never a filename. Beside `page.tsx`
273
+ sit the other reserved names its segment owns — `layout.tsx`, `error.tsx`,
274
+ `loading.tsx`, `not-found.tsx` — plus its `page.test.tsx` and any co-located
275
+ components. **Anything in that folder that is not one of the reserved names is
276
+ structurally not a route**, so put a component next to the page that uses it
277
+ rather than in a distant `components/` directory.
278
+
279
+ A `page.tsx` outside `src/pages/` never routes. A page MUST default-export its
280
+ component — without it `voltro dev` refuses to boot and names the file.
281
+
282
+ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were the
283
+ 0.15.0 spelling and route nothing now.
269
284
 
270
285
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
286
  promise, and `voltro doctor` enforces all of them:
@@ -308,7 +323,7 @@ rules is that everything we author follows them.
308
323
 
309
324
  **Write the test in the same step as the code — always, for every primitive.**
310
325
  Not "afterwards", not in a cleanup pass: the filename is derivable
311
- (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
326
+ (`users/[id]/page.tsx` → `users/[id]/page.test.tsx`,
312
327
  `Card.component.tsx` → `Card.component.test.tsx`,
313
328
  `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
329
  nothing to decide. A change is not finished until its test exists and passes.
@@ -434,6 +449,44 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
434
449
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
435
450
  **`data.md`**.
436
451
 
452
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
453
+
454
+ A column is not "protected" or "unprotected". Three separate questions get three
455
+ separate markers, and using one to answer another's question is the mistake:
456
+
457
+ | Marker | Answers | Enforced by |
458
+ |---|---|---|
459
+ | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit — it FAILS the boot**, it does not warn |
460
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
+
463
+ ```ts
464
+ export const users = table('users', {
465
+ id: id(),
466
+ email: text().sensitive('pii'), // exportable only through a profile
467
+ pinHash: text().serverOnly(), // never reaches a client, ever
468
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
469
+ })
470
+ ```
471
+
472
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
+ query that declares such a column in its OUTPUT does not start: the boot audit
474
+ refuses. That is a feature — the failure happens at boot, not in a bundle.
475
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
477
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
478
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
479
+ one — say both when you mean both.
480
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
481
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
482
+ per column, not a filter you remember to apply.
483
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
484
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
485
+ that stripping gets forgotten.
486
+
487
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
488
+ redacted CRUD surface → **`data/crud`**.
489
+
437
490
  ## Naming / RPC tags
438
491
 
439
492
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -219,7 +219,7 @@ a `web/` (frontend).
219
219
  │ │ └── rpcGroup.generated.ts # codegen — do NOT edit
220
220
  │ └── web/
221
221
  │ ├── app.config.ts # { type:'web', apis, theme, locales, … }
222
- │ └── src/pages/**/*.page.tsx # file-based routing (only *.page.tsx)
222
+ │ └── src/pages/**/page.tsx # a DIRECTORY is a route segment
223
223
  ├── packages/ # shared libs
224
224
  └── AGENTS.md / CLAUDE.md # this file
225
225
  ```
@@ -249,7 +249,7 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
249
249
  | `*.agent.tsx` + `*.agent.server.tsx` | server-side LLM chat |
250
250
  | `*.tool.tsx` | tool an agent can call |
251
251
  | `*.entity.ts` / `*.schema.ts` / `schema.ts` | one table per file |
252
- | `*.page.tsx` (under `src/pages/`) | web page (file-based route) + `*.page.test.tsx` |
252
+ | `page.tsx` (under `src/pages/`) | the route its DIRECTORY serves + `page.test.tsx` |
253
253
  | `*.component.tsx` | exactly ONE component (+ types) |
254
254
  | `*.component.ui.tsx` | presentational: one component, READS only — never writes |
255
255
  | `*.hook.ts` | exactly ONE `use*` hook (+ types) |
@@ -260,12 +260,27 @@ public REST routes, declared via `restRoutes` in `app.config.ts`).
260
260
  | `*.store.ts` | exactly ONE `defineStore` — client state, never server state |
261
261
  | `*.client.ts` | declares itself + its transitive imports browser-safe |
262
262
 
263
- **Only `*.page.tsx` is a route.** Everything else under `src/pages/` components,
264
- hooks, tests — is ordinary code with no URL, so put it next to the page that uses
265
- it rather than in a distant `components/` directory. The suffix decides *whether*
266
- a file is a route; the path under `src/pages/` decides *which URL*. A `*.page.tsx`
267
- outside `src/pages/` never routes. A page MUST default-export its component —
268
- without it `voltro dev` refuses to boot and names the file.
263
+ **A DIRECTORY is a route segment, and its route is `page.tsx`.**
264
+
265
+ ```
266
+ src/pages/page.tsx → /
267
+ src/pages/pricing/page.tsx → /pricing
268
+ src/pages/users/[id]/page.tsx → /users/[id]
269
+ src/pages/docs/[...slug]/page.tsx → /docs/<anything>
270
+ ```
271
+
272
+ A parameter is a DIRECTORY name (`[id]/`), never a filename. Beside `page.tsx`
273
+ sit the other reserved names its segment owns — `layout.tsx`, `error.tsx`,
274
+ `loading.tsx`, `not-found.tsx` — plus its `page.test.tsx` and any co-located
275
+ components. **Anything in that folder that is not one of the reserved names is
276
+ structurally not a route**, so put a component next to the page that uses it
277
+ rather than in a distant `components/` directory.
278
+
279
+ A `page.tsx` outside `src/pages/` never routes. A page MUST default-export its
280
+ component — without it `voltro dev` refuses to boot and names the file.
281
+
282
+ Do NOT write `x.page.tsx`, `index.page.tsx` or `[id].page.tsx` — those were the
283
+ 0.15.0 spelling and route nothing now.
269
284
 
270
285
  **Every suffix above is a CONTRACT, not a label.** Something else depends on each
271
286
  promise, and `voltro doctor` enforces all of them:
@@ -308,7 +323,7 @@ rules is that everything we author follows them.
308
323
 
309
324
  **Write the test in the same step as the code — always, for every primitive.**
310
325
  Not "afterwards", not in a cleanup pass: the filename is derivable
311
- (`users/[id].page.tsx` → `users/[id].page.test.tsx`,
326
+ (`users/[id]/page.tsx` → `users/[id]/page.test.tsx`,
312
327
  `Card.component.tsx` → `Card.component.test.tsx`,
313
328
  `order.mutation.server.ts` → `order.mutation.server.test.ts`), so there is
314
329
  nothing to decide. A change is not finished until its test exists and passes.
@@ -434,6 +449,44 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
434
449
  docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
435
450
  **`data.md`**.
436
451
 
452
+ ## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
453
+
454
+ A column is not "protected" or "unprotected". Three separate questions get three
455
+ separate markers, and using one to answer another's question is the mistake:
456
+
457
+ | Marker | Answers | Enforced by |
458
+ |---|---|---|
459
+ | `.serverOnly()` | may this value leave the server AT ALL? | **the boot audit — it FAILS the boot**, it does not warn |
460
+ | `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
461
+ | `.encrypted()` | is it encrypted AT REST? | the store's codec |
462
+
463
+ ```ts
464
+ export const users = table('users', {
465
+ id: id(),
466
+ email: text().sensitive('pii'), // exportable only through a profile
467
+ pinHash: text().serverOnly(), // never reaches a client, ever
468
+ ssn: text().encrypted().serverOnly(), // both — they are not the same claim
469
+ })
470
+ ```
471
+
472
+ - **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
473
+ query that declares such a column in its OUTPUT does not start: the boot audit
474
+ refuses. That is a feature — the failure happens at boot, not in a bundle.
475
+ - **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
476
+ encrypted; the runtime decrypts them for a handler, so an encrypted column
477
+ flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
478
+ Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
479
+ one — say both when you mean both.
480
+ - **Masking is fail-closed**: an unclassified column blocks the export rather
481
+ than passing through, so `.sensitive()` / `.safe()` is a decision you make once
482
+ per column, not a filter you remember to apply.
483
+ - `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
484
+ they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
485
+ that stripping gets forgotten.
486
+
487
+ Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
488
+ redacted CRUD surface → **`data/crud`**.
489
+
437
490
  ## Naming / RPC tags
438
491
 
439
492
  - **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
@@ -526,7 +579,7 @@ each plugin's own README.
526
579
 
527
580
  | Topic | Open | Summary |
528
581
  |---|---|---|
529
- | **What's new in 0.15.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
582
+ | **What's new in 0.18.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
530
583
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
531
584
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
532
585
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -9,7 +9,7 @@ each plugin's own README.
9
9
 
10
10
  | Topic | Open | Summary |
11
11
  |---|---|---|
12
- | **What's new in 0.15.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
12
+ | **What's new in 0.18.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
13
13
  | AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
14
14
  | Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
15
15
  | Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
@@ -569,6 +569,54 @@ Read users, sessions, keys. A strategy that runs domain writes while deciding
569
569
  who the caller is has the two jobs the wrong way round; nothing in the type
570
570
  stops you, and it is still wrong.
571
571
 
572
+ #### What the boot store carries, and what it does not
573
+
574
+ The line is **everything that does not need a Subject** — not "less than
575
+ `ctx.store`":
576
+
577
+ | | Boot store (`input.store`, `req.store`) | Request store (`ctx.store`) |
578
+ |---|---|---|
579
+ | `.encrypted()` columns decrypt / encrypt | ✓ | ✓ |
580
+ | Array columns round-trip on non-native dialects | ✓ | ✓ |
581
+ | Tenant scope | — | ✓ |
582
+ | Soft-delete filter | — | ✓ |
583
+ | Audit-column stamping | — | ✓ |
584
+ | Row-level security | — | ✓ |
585
+
586
+ The right-hand four need a resolved Subject, and a strategy runs *before* one
587
+ exists — so a read of tenant-owned rows here must derive and apply that scope
588
+ itself. The first two do not, and getting them wrong is silent: a `.encrypted()`
589
+ column read raw hands back the string `enc:v1:…`, which compares, concatenates,
590
+ renders and logs perfectly well, and simply never matches the token you compare
591
+ it to.
592
+
593
+ This is also what changes when you move a read **off** hand-written SQL and onto
594
+ the store. Raw SQL sees ciphertext and you decrypt it yourself — `decryptField`
595
+ from `@voltro/runtime` is the escape hatch for exactly that. Through either
596
+ store you get plaintext, so a hand-rolled `decryptField` on the way out will now
597
+ be handed a plaintext value; `decryptField` passes a non-ciphertext value
598
+ through unchanged, so the double call is harmless, but the manual step is no
599
+ longer doing anything.
600
+
601
+ #### Reading a plugin's own tables
602
+
603
+ A plugin's tables are declared through `extendSchema` like any others, so they
604
+ are in the same registry and the same store reads them. A public route that
605
+ needs a row a plugin wrote — a storage reference for an avatar proxy, say —
606
+ reads it directly:
607
+
608
+ ```ts
609
+ const [ref] = await req.store.query(
610
+ queryFor(storageObjects).where(eq('id', objectId)).descriptor,
611
+ )
612
+ ```
613
+
614
+ Two things to keep in mind. The table is the plugin's contract with itself, not
615
+ with you, so it can change shape in any release — pin the version if you depend
616
+ on it. And this store applies no tenant scope, so a route reading a
617
+ tenant-owned plugin table must filter by tenant itself, from something the
618
+ request proves rather than something it claims.
619
+
572
620
  ## Composing the chain
573
621
 
574
622
  `composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
@@ -600,19 +600,23 @@ export default {
600
600
  }
601
601
  ```
602
602
 
603
- #### Inspect token (token-gated deploys)
603
+ #### Inspect token
604
604
 
605
- The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints. Under `voltro dev` that surface is open, so no auth is needed. A token-gated deploy (`voltro start` with `VOLTRO_INSPECT_TOKEN` set) requires the same `Authorization: Bearer <token>` the CLI sends otherwise the panels 401 to their empty state.
605
+ The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints, and that surface is **fail-closed everywhere**: with no `VOLTRO_INSPECT_TOKEN` configured, nobody is authorised`voltro dev` included.
606
606
 
607
- The overlay reads that token from `VITE_VOLTRO_INSPECT_TOKEN` (only `VITE_`-prefixed vars reach the browser bundle). A foreign-host mount can also pass it explicitly:
607
+ **Under `voltro dev` you configure nothing.** The dev server mints a token per project and its proxy attaches the `Authorization: Bearer` header server-side, on the `/_voltro/api/<name>` route the panels fetch through. The token stays in the dev server's process; the browser never holds it.
608
+
609
+ That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — `voltro dev` and `voltro build` set vite's `envPrefix` to a sentinel precisely so nothing leaks through `import.meta.env`.
610
+
611
+ For an api the dev proxy does not front — a `voltro start` deploy with `VOLTRO_INSPECT_TOKEN` set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:
608
612
 
609
613
  ```tsx
610
614
  import { VoltroDevtools } from '@voltro/devtools'
611
615
 
612
- <VoltroDevtools inspectToken={import.meta.env.VITE_VOLTRO_INSPECT_TOKEN} />
616
+ <VoltroDevtools inspectToken={myToken} />
613
617
  ```
614
618
 
615
- When neither the prop nor the env var is set, no `Authorization` header is sent local dev is unaffected. (The indexes panel's live SSE stream can't carry a header; a token-gated deploy falls back to token-carrying HTTP polling for that panel.)
619
+ Without the prop the overlay sends no `Authorization` header of its own, which is correct: under `voltro dev` the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)
616
620
 
617
621
  #### Overriding the overlay's labels
618
622
 
@@ -1211,8 +1215,8 @@ A page is listed when ALL of these hold:
1211
1215
 
1212
1216
  ```text
1213
1217
  • renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
1214
- src/pages/dashboard/index.page.tsx (/dashboard) — default renderMode
1215
- src/pages/admin/settings.page.tsx (/admin/settings) — renderMode:'ssr'
1218
+ src/pages/dashboard/page.tsx (/dashboard) — default renderMode
1219
+ src/pages/admin/settings/page.tsx (/admin/settings) — renderMode:'ssr'
1216
1220
  → renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.
1217
1221
  ```
1218
1222
 
@@ -2316,7 +2316,7 @@ If multiple apps target the same database, they're showing the same `_voltro_mig
2316
2316
 
2317
2317
  ## Source code
2318
2318
 
2319
- The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.page.tsx`. Both repos are open to extension.
2319
+ The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage/page.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations/page.tsx`. Both repos are open to extension.
2320
2320
 
2321
2321
 
2322
2322
 
@@ -2504,8 +2504,8 @@ server enforces this regardless of which buttons the UI renders.
2504
2504
 
2505
2505
  ## Source code
2506
2506
 
2507
- - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx` — shared
2508
- - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.page.tsx`
2507
+ - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage/page.tsx` — shared
2508
+ - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations/page.tsx`
2509
2509
  - Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
2510
2510
  - Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
2511
2511
  - Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
@@ -105,11 +105,15 @@ ctx.store.select('notes')
105
105
  .where('col', '>', value)
106
106
  .where('col', '>=', value)
107
107
  .where('col', 'in', [a, b, c])
108
- .where('col', 'like', 'abc%')
109
- .where('col', 'contains', 'needle') // case-insensitive substring (ILIKE '%…%')
108
+ .where('col', 'contains', 'needle') // case-INsensitive substring (ILIKE '%…%')
109
+ .where('col', 'startsWith', 'awb_') // case-SENSITIVE prefix (LIKE 'awb\_%')
110
110
  .where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
111
111
  ```
112
112
 
113
+ `contains` folds case because it is a search primitive — a human typing into a box means `hello` to find `Hello`. `startsWith` does not, because a prefix is a namespace: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. `startsWith` is also the only one of the two a database can answer from an index — `LIKE 'literal%'` is a btree range scan, `%…%` is not. `%` and `_` inside either value are escaped, so they match literally.
114
+
115
+ There is no `'like'`. It used to be here, and it was a lie: it mapped to `contains`, so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%` and the wildcard you wrote did nothing. An operator named after SQL's must honour your wildcards or not exist.
116
+
113
117
  These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
114
118
 
115
119
  ```ts
@@ -423,7 +423,7 @@ export default function Layout({ children }) {
423
423
  }
424
424
  ```
425
425
 
426
- Pair this with locale-prefixed page files: `src/pages/[locale]/index.page.tsx`, `src/pages/[locale]/docs/[...slug].page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
426
+ Pair this with locale-prefixed page files: `src/pages/[locale]/page.tsx`, `src/pages/[locale]/docs/[...slug]/page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
427
427
 
428
428
  Reference implementations: `voltro-dev/apps/voltro-dev/docs/` (URL-prefix on dynamic + static content) and `voltro-dev/apps/voltro-dev/landing/` (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined `voltro build` run).
429
429
 
@@ -441,7 +441,7 @@ dist/de/features/foo/index.html ← de mirror
441
441
  Each variant ships with the right `<I18nProvider>`-wrapped body **and** the right per-locale `<title>` / `<meta description>` / `<link rel="canonical">` / OG tags — **if** the page's `meta` is exported as a function of `({ locale })`. With a plain static `meta: PageMeta` object, the body is correctly localised but the head tags stay default-locale on every variant.
442
442
 
443
443
  ```tsx
444
- // src/pages/features/foo.page.tsx — meta as a function of locale
444
+ // src/pages/features/foo/page.tsx — meta as a function of locale
445
445
  import { getCatalog } from '../lib/locale'
446
446
  import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'
447
447
 
@@ -472,7 +472,7 @@ The `[locale]/…` mirror is the trigger for SSG **output** — without mirror f
472
472
  12 lines per page. Re-export the canonical page's `default`, `renderMode`, `interactive`, `meta`, plus a `getStaticPaths` that enumerates non-default locales:
473
473
 
474
474
  ```tsx
475
- // src/pages/[locale]/features/foo.page.tsx
475
+ // src/pages/[locale]/features/foo/page.tsx
476
476
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
477
477
  export { default } from '../../features/foo'
478
478
  export { renderMode, interactive, meta } from '../../features/foo'
@@ -404,7 +404,7 @@ An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shar
404
404
 
405
405
  | Path | What it is |
406
406
  |---|---|
407
- | `src/pages/*.tsx` | A page. URL is the file path; `[id].page.tsx` -> `/:id`, `[...slug].page.tsx` -> catch-all. |
407
+ | `src/pages/**/page.tsx` | A page. URL is the file path; `[id]/page.tsx` -> `/:id`, `[...slug]/page.tsx` -> catch-all. |
408
408
  | `src/pages/layout.tsx` | Outer layout — wraps every page. |
409
409
  | `src/pages/error.tsx` | Error boundary for the whole subtree. |
410
410
  | `src/pages/not-found.tsx` | Fallback rendered when no page matches. |
@@ -415,7 +415,7 @@ An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shar
415
415
  Each page can opt into a render strategy via two exports:
416
416
 
417
417
  ```tsx
418
- // src/pages/blog/[slug].page.tsx
418
+ // src/pages/blog/[slug]/page.tsx
419
419
  export const renderMode = 'isr' as const // 'static' | 'spa' | 'ssr' | 'isr'
420
420
  export const interactive = 'islands' as const // 'none' | 'islands' | 'full'
421
421
  ```
@@ -473,7 +473,7 @@ If yes, the promise belongs in the name — you cannot see a contract before you
473
473
 
474
474
  | Suffix | Promise | Enforced by |
475
475
  |---|---|---|
476
- | `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
476
+ | `*.component.tsx` | exactly one component | `component/one-per-file`, `component/no-hook-export` |
477
477
  | `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
478
478
  | `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
479
479
  | `*.types.ts` | zero runtime exports | `types/runtime-export` |
@@ -483,6 +483,10 @@ If yes, the promise belongs in the name — you cannot see a contract before you
483
483
  | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
484
484
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
485
485
 
486
+ A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
487
+
488
+ The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
489
+
486
490
  ## `*.component.ui.tsx` — reads, never writes
487
491
 
488
492
  ```tsx
@@ -536,6 +540,16 @@ A component then wires it up with `useTracking(checkoutTracking, props, sink)`
536
540
 
537
541
  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
542
 
543
+ ## `convention/missing-test` — why a shallow test is still worth writing
544
+
545
+ Every suffix that declares a runtime contract also expects a test beside it, named mechanically: `Card.component.tsx` → `Card.component.test.tsx`. It is an advisory, not an error.
546
+
547
+ The usual objection is that a per-component test at any real size is low value, and for *assertions* that is often true. That is not what the rule buys. What it buys is that something **mounts** the component — and a render loop, a crashing effect, a missing provider or a broken context is invisible until something does.
548
+
549
+ That is not hypothetical. One app adopting the taxonomy wrote 251 of these, deliberately shallow (it mounts, it performs no domain write, it renders no raw catalogue key). The first run found a page whose breadcrumb effect rebuilt a fresh array literal on every render — effect → context state → re-render → new literal, without end. That one test took 423 seconds and exhausted the heap. Ten sibling pages memoised; exactly one did not, and in a browser the screen had looked usable. After the fix the whole web suite went from 645 s to 57 s.
550
+
551
+ So write them shallow if you like. The mount is the point.
552
+
539
553
  ## What deliberately has NO suffix
540
554
 
541
555
  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.
@@ -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].page.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].page.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].page.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].page.tsx
607
+ // src/pages/notes/[id]/page.tsx
608
608
  import { useLoaderData } from '@voltro/web'
609
609
 
610
610
  interface Note {