@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
@@ -1132,6 +1132,59 @@ If `down` throws, the rollback is treated as failed — the schema stays
1132
1132
  in the half-rolled-back state + the operator handles it manually. The
1133
1133
  framework can't auto-recover from a broken inverse.
1134
1134
 
1135
+ ## Remote databases: boot will not apply them unattended
1136
+
1137
+ `voltro dev` applies pending migration files at boot. Against a local
1138
+ database that is the whole point of the escape hatch. Against a remote
1139
+ one it means that saving a file is enough to change production — before
1140
+ review, before CI, without typing a command.
1141
+
1142
+ So when the configured database is **not local** and there are pending
1143
+ migration files, dev boot refuses instead:
1144
+
1145
+ ```text
1146
+ file-based migrations: refusing to auto-apply 1 file-based migration(s)
1147
+ to a REMOTE database (ep-cool-dawn.eu-central-1.aws.neon.tech).
1148
+
1149
+ Pending:
1150
+ • 20260725_150000_drop_legacy_subscriptions
1151
+ ```
1152
+
1153
+ A database counts as **local** when its host is loopback
1154
+ (`localhost`, `127.0.0.1`, `::1`), a private LAN address
1155
+ (`10.x`, `192.168.x`, `172.16–31.x`), `host.docker.internal`, a `.local`
1156
+ / `.localhost` name, a `file:` / `sqlite:` URL, or a **bare hostname**
1157
+ like `postgres` or `db` — only a container network resolves those, so
1158
+ `docker compose up` keeps working untouched. Everything else — a managed
1159
+ provider, any dotted public hostname, an unparseable `DB_URL` — counts as
1160
+ remote.
1161
+
1162
+ Three ways forward:
1163
+
1164
+ ```sh
1165
+ # 1. Point the app at a local database (what dev boot assumes)
1166
+ DB_URL=postgres://app:app@localhost:5432/app voltro dev
1167
+
1168
+ # 2. Apply them deliberately, once
1169
+ voltro db files .
1170
+
1171
+ # 3. Accept unattended applies for this environment
1172
+ VOLTRO_REMOTE_MIGRATIONS_OK=1 voltro dev
1173
+ ```
1174
+
1175
+ The gate is **silent when nothing is pending**, which is the normal
1176
+ case — running dev against a remote database is unaffected until the
1177
+ moment a file would actually execute against it. It refuses rather than
1178
+ skipping quietly: a skipped migration leaves the database in a shape the
1179
+ app does not expect (a half-done table split, a column the handlers
1180
+ already read), and the failures that follow point everywhere except at
1181
+ the cause.
1182
+
1183
+ It does **not** try to detect destructive SQL. In arbitrary SQL that is
1184
+ not decidable, so such a check would be either leaky or noisy. What the
1185
+ gate separates is the two things boot used to conflate: *saving a file*
1186
+ and *applying it to production*.
1187
+
1135
1188
  ## Applying file-based migrations from the CLI
1136
1189
 
1137
1190
  `voltro db files` applies pending file-based migrations under
@@ -2242,7 +2295,7 @@ If multiple apps target the same database, they're showing the same `_voltro_mig
2242
2295
 
2243
2296
  ## Source code
2244
2297
 
2245
- The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.tsx`. Both repos are open to extension.
2298
+ The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.page.tsx`. Both repos are open to extension.
2246
2299
 
2247
2300
 
2248
2301
 
@@ -2430,8 +2483,8 @@ server enforces this regardless of which buttons the UI renders.
2430
2483
 
2431
2484
  ## Source code
2432
2485
 
2433
- - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx` — shared
2434
- - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.tsx`
2486
+ - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.page.tsx` — shared
2487
+ - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.page.tsx`
2435
2488
  - Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
2436
2489
  - Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
2437
2490
  - Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
@@ -39,6 +39,17 @@ This section covers everything about how the framework reads + writes data.
39
39
 
40
40
  You declare tables once. The migrator produces SQL; the runtime gives you a typed `ctx.store`; the reactive engine watches the same rows.
41
41
 
42
+ **Every table is reactive by default.** There is nothing to opt into — this is a
43
+ reactive framework, so a capability you had to remember to switch on is one half
44
+ your tables would not have. Writes emit change events, subscriptions stay live,
45
+ and on a multi-instance deployment the change reaches the other instances too.
46
+
47
+ `.nonReactive()` turns it off for one table, on every dialect: no subscriber
48
+ fires, locally or across instances. The write still happens — this is about
49
+ notification, never persistence. Use it for a genuinely hot table nobody
50
+ subscribes to (an append-only event log, a metrics sink); never for one a query
51
+ reads, because that subscription would never fire. `voltro dev` warns if you do.
52
+
42
53
  ## A first table
43
54
 
44
55
  ```ts
@@ -150,6 +150,13 @@ See [migrations](/docs/database/migrations) for schema changes — seeds populat
150
150
 
151
151
  _Six SQL backends, one schema DSL. Decision matrix, configuration, boot-log shape, and the cross-dialect feature parity table the framework hides for you._
152
152
 
153
+ **Reactivity is dialect-independent.** Every table is reactive by default on all
154
+ six backends — you write nothing to get it, and `.nonReactive()` turns it off
155
+ for one table everywhere. What differs is only the TRANSPORT that carries a
156
+ change between instances: Postgres uses LISTEN/NOTIFY, MySQL and MariaDB read
157
+ the binlog, MSSQL uses Change Tracking, and SQLite / Turso have no native one —
158
+ [`@voltro/plugin-broadcast`](../multi-replica) closes that gap.
159
+
153
160
  Voltro runs on six SQL backends. The application code — schema, queries, workflows, subscriptions, the reactive engine — is written ONCE and compiles down to the dialect-native idiom at runtime. Selecting a dialect is a single environment variable.
154
161
 
155
162
  ```
@@ -361,7 +368,27 @@ The framework wraps `pg` (`node-postgres`) via Effect's typed connection layer.
361
368
 
362
369
  ## CDC — LISTEN/NOTIFY
363
370
 
364
- Every `*.reactive()` table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
371
+ Every table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
372
+
373
+ **Every table gets this by default.** Reactivity is what the framework is for,
374
+ so you write nothing to opt in — the trigger above is installed for every table
375
+ unless you opt it out.
376
+
377
+ `.nonReactive()` turns reactivity **off** for a table — not "off across
378
+ instances". It emits no change events at all: no local subscriber fires and no
379
+ cross-instance transport carries it. That holds on every dialect, including
380
+ sqlite and turso, because the guard sits at each store's emit. On postgres it
381
+ additionally drops the trigger and `REPLICA IDENTITY FULL`; on mysql/mariadb and
382
+ mssql it drops the table from the reader's filter.
383
+
384
+ The write itself is unaffected — this is about notification, never persistence.
385
+
386
+ Worth using for a genuinely hot table nobody subscribes to: an append-only event
387
+ log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the
388
+ WAL and the trigger fires on every write, so that is a real saving.
389
+
390
+ Do **not** use it on a table a query reads — that subscription will never fire.
391
+ `voltro dev` and `voltro serve` warn at boot when that combination exists.
365
392
 
366
393
  Latency: very low on a local-network postgres (it varies with network and load). The framework instruments this — `voltro traces` shows the notification → dispatcher → subscriber waterfall.
367
394
 
@@ -514,6 +541,11 @@ rows.map((r) => r.id) // ← the full generated range, in input order
514
541
 
515
542
  MySQL has no `LISTEN/NOTIFY`, but MySQL 8's ROW-format binary log is a real out-of-band CDC source — the SAME binlog the framework already tails for mariadb. The framework tails the primary's binlog via the `@vlasky/zongji` replication client, so **every replica tails the binlog itself and a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy). This is at parity with mariadb: binlog CDC is **one path** with per-variant detection, not two implementations.
516
543
 
544
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
545
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
546
+ across instances. The write still happens; only the notification is suppressed.
547
+ See [the schema overview](/docs/database/overview).
548
+
517
549
  ### How it works
518
550
 
519
551
  1. With `CDC=1` (the default for any SQL dialect) on `mysql`, the store starts a binlog reader on a **separate replication connection** — distinct from the SQL pool.
@@ -739,6 +771,11 @@ The framework uses it for the no-pagination case of `many()` / `manyToMany()` on
739
771
 
740
772
  MariaDB has no `LISTEN/NOTIFY`, but it gets a real out-of-band CDC source: the framework tails the primary's **ROW-format binary log** via the `@vlasky/zongji` replication client. This is the MariaDB equivalent of postgres's `LISTEN/NOTIFY` — **every replica tails the binlog itself, so a write on any instance surfaces on every instance's `onChange`** (true cross-instance reactivity in a multi-replica deploy).
741
773
 
774
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
775
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
776
+ across instances. The write still happens; only the notification is suppressed.
777
+ See [the schema overview](/docs/database/overview).
778
+
742
779
  This is **one binlog-CDC path shared with `mysql`** — mysql-8 and mariadb speak the same ROW binlog to the reader, so both get cross-instance CDC. The only engine difference is the binlog-end query (mariadb `SHOW MASTER STATUS`; mysql 8.4 `SHOW BINARY LOG STATUS`), which the store picks per variant.
743
780
 
744
781
  ### How it works
@@ -979,6 +1016,11 @@ Requires the deployment to use Always-On AGs (the modern HA story since SQL Serv
979
1016
 
980
1017
  SQL Server ships **Change Tracking** (CT) — a lightweight, built-in change source available on every edition (unlike the heavier Change Data Capture feature). The framework's mssql store uses it as an out-of-band CDC reader so a write on ANY instance surfaces on EVERY instance's `onChange` — the mssql equivalent of postgres `LISTEN/NOTIFY` or mariadb binlog CDC.
981
1018
 
1019
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1020
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1021
+ across instances. The write still happens; only the notification is suppressed.
1022
+ See [the schema overview](/docs/database/overview).
1023
+
982
1024
  Enable it and set `changeStrategy: 'cdc'` (the default when `CDC` is not `0`):
983
1025
 
984
1026
  ```sh
@@ -1098,6 +1140,11 @@ SQLite is single-writer by definition. Setting `DB_REPLICA_URLS=…` is a no-op
1098
1140
 
1099
1141
  Sqlite has no LISTEN/NOTIFY equivalent and no trigger-based fan-out is needed (everything runs in one process). The framework's `SqliteStore` uses an in-process Node `EventEmitter` (composed as a private field, not subclassed):
1100
1142
 
1143
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1144
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1145
+ across instances. The write still happens; only the notification is suppressed.
1146
+ See [the schema overview](/docs/database/overview).
1147
+
1101
1148
  - Insert/update/delete emit `'change'` events synchronously to the dispatcher.
1102
1149
  - The dispatcher's `onChange` callback is registered against the emitter — no polling, no triggers, no log table.
1103
1150
 
@@ -1237,6 +1284,11 @@ Like `sqlite`, Turso is single-node here. The workflow engine boots with `runner
1237
1284
 
1238
1285
  Same as `sqlite`: an in-process Node `EventEmitter` fans insert/update/delete events to the dispatcher. Sub-millisecond, single-process. No `LISTEN/NOTIFY`, no triggers.
1239
1286
 
1287
+ **Every table is reactive by default** — nothing to opt into. `.nonReactive()`
1288
+ turns it off for one table on *every* dialect: no subscriber fires, locally or
1289
+ across instances. The write still happens; only the notification is suppressed.
1290
+ See [the schema overview](/docs/database/overview).
1291
+
1240
1292
  ## Not supported on Turso (beta gaps)
1241
1293
 
1242
1294
  These are SQLite features the `sqlite` dialect has but Turso's MVCC mode does not. The framework fails LOUD at migrate rather than emit DDL the engine rejects:
@@ -423,7 +423,7 @@ export default function Layout({ children }) {
423
423
  }
424
424
  ```
425
425
 
426
- Pair this with locale-prefixed page files: `src/pages/[locale]/index.tsx`, `src/pages/[locale]/docs/[...slug].tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
426
+ Pair this with locale-prefixed page files: `src/pages/[locale]/index.page.tsx`, `src/pages/[locale]/docs/[...slug].page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.
427
427
 
428
428
  Reference implementations: `voltro-dev/apps/voltro-dev/docs/` (URL-prefix on dynamic + static content) and `voltro-dev/apps/voltro-dev/landing/` (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined `voltro build` run).
429
429
 
@@ -441,7 +441,7 @@ dist/de/features/foo/index.html ← de mirror
441
441
  Each variant ships with the right `<I18nProvider>`-wrapped body **and** the right per-locale `<title>` / `<meta description>` / `<link rel="canonical">` / OG tags — **if** the page's `meta` is exported as a function of `({ locale })`. With a plain static `meta: PageMeta` object, the body is correctly localised but the head tags stay default-locale on every variant.
442
442
 
443
443
  ```tsx
444
- // src/pages/features/foo.tsx — meta as a function of locale
444
+ // src/pages/features/foo.page.tsx — meta as a function of locale
445
445
  import { getCatalog } from '../lib/locale'
446
446
  import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'
447
447
 
@@ -472,7 +472,7 @@ The `[locale]/…` mirror is the trigger for SSG **output** — without mirror f
472
472
  12 lines per page. Re-export the canonical page's `default`, `renderMode`, `interactive`, `meta`, plus a `getStaticPaths` that enumerates non-default locales:
473
473
 
474
474
  ```tsx
475
- // src/pages/[locale]/features/foo.tsx
475
+ // src/pages/[locale]/features/foo.page.tsx
476
476
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
477
477
  export { default } from '../../features/foo'
478
478
  export { renderMode, interactive, meta } from '../../features/foo'
@@ -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 {