@voltro/cli 0.51.0 → 0.53.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 (233) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  4. package/dist/agentsMd-SDDSkyl4.js +2 -0
  5. package/dist/{apiBuild-CPDHXF72.js → apiBuild-CaPfoWku.js} +11 -5
  6. package/dist/apiBuild-DHtLXYx9.js +2 -0
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D-OnvNMf.js +843 -0
  9. package/dist/{checkCommand-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
  10. package/dist/checkCommand-D2ZduVlh.js +2 -0
  11. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  12. package/dist/codegen-BWpt3VgF.js +2 -0
  13. package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
  14. package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
  15. package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
  16. package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
  17. package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
  18. package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
  19. package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
  20. package/dist/dbCommand-B1EXBC6f.js +2 -0
  21. package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
  22. package/dist/dev-GjJWAYo2.js +3 -0
  23. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  24. package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
  25. package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  26. package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
  27. package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
  28. package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
  29. package/dist/fileConventions-DASGEmj-.js +35 -0
  30. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-B7uxipWS.js} +55 -55
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  34. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +1 -1
  38. package/dist/{infoCommand-BnRFEF1o.js → infoCommand-_53iOc_j.js} +1 -1
  39. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  40. package/dist/inspect-CuoDInfZ.js +2 -0
  41. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  42. package/dist/inspectMetrics-CGF94puw.js +143 -0
  43. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  44. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  45. package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
  46. package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
  47. package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
  48. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
  49. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  50. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  51. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  52. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
  53. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
  54. package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
  55. package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
  56. package/dist/serveCommand-CueKQgzl.js +2443 -0
  57. package/dist/serveCommand-DsnrVN3U.js +2 -0
  58. package/dist/serveEntry.js +1 -1
  59. package/dist/start-BJzZLbt8.js +3 -0
  60. package/dist/start-ekPan8BT.js +1510 -0
  61. package/dist/startEntry.js +1 -1
  62. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
  63. package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
  64. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  65. package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
  66. package/dist/webDev-C7jWJ5dX.js +2 -0
  67. package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
  68. package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
  69. package/package.json +72 -18
  70. package/templates/AGENTS.core.md +11 -0
  71. package/templates/AGENTS.md +19 -6
  72. package/templates/agent-docs/_index.md +8 -6
  73. package/templates/agent-docs/_manifest.json +31 -15
  74. package/templates/agent-docs/ai.md +2 -2
  75. package/templates/agent-docs/authentication.md +1 -1
  76. package/templates/agent-docs/cli.md +97 -15
  77. package/templates/agent-docs/configuration.md +17 -0
  78. package/templates/agent-docs/data.md +680 -33
  79. package/templates/agent-docs/database/advancedqueries.md +7 -7
  80. package/templates/agent-docs/database/columntypes.md +2 -2
  81. package/templates/agent-docs/database/querying.md +1 -1
  82. package/templates/agent-docs/database/schema.md +2 -2
  83. package/templates/agent-docs/database/seedsdialects.md +2 -2
  84. package/templates/agent-docs/database/transactions.md +3 -3
  85. package/templates/agent-docs/deployment.md +30 -3
  86. package/templates/agent-docs/internationalization.md +2 -2
  87. package/templates/agent-docs/introduction.md +52 -0
  88. package/templates/agent-docs/local-first-mobile.md +132 -7
  89. package/templates/agent-docs/observability.md +2 -0
  90. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  91. package/templates/agent-docs/plugins/audit.md +5 -5
  92. package/templates/agent-docs/plugins/auth.md +1 -1
  93. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  94. package/templates/agent-docs/plugins/comments.md +142 -0
  95. package/templates/agent-docs/plugins/notifications.md +47 -4
  96. package/templates/agent-docs/plugins/presence.md +16 -3
  97. package/templates/agent-docs/plugins/prometheus.md +1 -1
  98. package/templates/agent-docs/plugins/queue.md +129 -0
  99. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  100. package/templates/agent-docs/plugins/storage.md +2 -2
  101. package/templates/agent-docs/plugins.md +38 -12
  102. package/templates/agent-docs/reference.md +54 -5
  103. package/templates/agent-docs/routing.md +868 -50
  104. package/templates/agent-docs/schema-driven-ui.md +292 -5
  105. package/templates/agent-docs/security.md +125 -8
  106. package/templates/agent-docs/templates/apibackends.md +14 -14
  107. package/templates/agent-docs/templates/overview.md +1 -1
  108. package/templates/agent-docs/whats-new.md +171 -54
  109. package/templates/apps/api-ai/package.json +6 -7
  110. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  111. package/templates/apps/api-auth/package.json +8 -8
  112. package/templates/apps/api-backend/package.json +7 -7
  113. package/templates/apps/api-backend-deactivation/package.json +7 -7
  114. package/templates/apps/api-backend-mail/package.json +8 -8
  115. package/templates/apps/api-backend-mariadb/package.json +9 -9
  116. package/templates/apps/api-backend-sqlite/package.json +8 -8
  117. package/templates/apps/api-backend-storage/package.json +8 -8
  118. package/templates/apps/api-cms/package.json +9 -10
  119. package/templates/apps/api-collab/package.json +8 -8
  120. package/templates/apps/api-data-advanced/package.json +8 -8
  121. package/templates/apps/api-durable/package.json +8 -8
  122. package/templates/apps/api-feature-flags/package.json +9 -9
  123. package/templates/apps/api-governance/package.json +8 -8
  124. package/templates/apps/api-kv/package.json +8 -8
  125. package/templates/apps/api-moderation/package.json +8 -8
  126. package/templates/apps/api-observability/package.json +8 -8
  127. package/templates/apps/api-ratelimit/package.json +8 -8
  128. package/templates/apps/api-rbac/package.json +8 -8
  129. package/templates/apps/api-rest/package.json +7 -7
  130. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  131. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  132. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  133. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  134. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  135. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  136. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  137. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  138. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  139. package/templates/apps/api-row-history/template.json +6 -0
  140. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  141. package/templates/apps/api-saas/app.config.ts +1 -0
  142. package/templates/apps/api-saas/package.json +10 -11
  143. package/templates/apps/api-saas-starter/package.json +10 -10
  144. package/templates/apps/api-search/package.json +8 -8
  145. package/templates/apps/api-status/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/app.config.ts +26 -2
  148. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  149. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  150. package/templates/apps/changelog/package.json +8 -8
  151. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  152. package/templates/apps/changelog/src/globals.d.ts +1 -1
  153. package/templates/apps/changelog/src/locales/de.ts +1 -1
  154. package/templates/apps/changelog/src/locales/en.ts +1 -1
  155. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  156. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  157. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  158. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  159. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  160. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  161. package/templates/apps/edge-functions/package.json +2 -2
  162. package/templates/apps/frontend-admin/package.json +8 -8
  163. package/templates/apps/frontend-app/package.json +9 -9
  164. package/templates/apps/frontend-auth/package.json +8 -8
  165. package/templates/apps/frontend-blank/package.json +7 -7
  166. package/templates/apps/frontend-cms/package.json +9 -9
  167. package/templates/apps/frontend-collab/package.json +10 -10
  168. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  169. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  170. package/templates/apps/frontend-contact/package.json +7 -7
  171. package/templates/apps/frontend-dashboard/package.json +7 -7
  172. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  173. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  174. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  175. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  176. package/templates/apps/frontend-docs/package.json +8 -7
  177. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  178. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  179. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  180. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  181. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  182. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  183. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  184. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  185. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  186. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  187. package/templates/apps/frontend-i18n/package.json +6 -6
  188. package/templates/apps/frontend-landing/package.json +6 -7
  189. package/templates/apps/frontend-portal/package.json +8 -8
  190. package/templates/apps/frontend-saas/package.json +8 -8
  191. package/templates/apps/frontend-spa/package.json +7 -7
  192. package/templates/apps/frontend-ssr/package.json +7 -7
  193. package/templates/apps/frontend-ssr-api/package.json +8 -8
  194. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  195. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  196. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  197. package/templates/apps/frontend-static-blog/package.json +8 -6
  198. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  199. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  200. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  201. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  202. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  203. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  204. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  205. package/templates/apps/frontend-status/package.json +8 -8
  206. package/templates/apps/mobile-app/package.json +4 -4
  207. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  208. package/dist/apiBuild-GDKuGOMV.js +0 -2
  209. package/dist/build-DETLZAFt.js +0 -752
  210. package/dist/checkCommand-CWcnDArJ.js +0 -2
  211. package/dist/codegen-DiMn2KkZ.js +0 -2
  212. package/dist/dbCommand-C27HIsGE.js +0 -2
  213. package/dist/dev-CK522MV5.js +0 -3
  214. package/dist/doctorCommand-BK4l18eG.js +0 -2
  215. package/dist/fileConventions-Cof68_BL.js +0 -33
  216. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  217. package/dist/inspect-CuGDYES0.js +0 -2
  218. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  219. package/dist/manifestBuild-CPjhvM62.js +0 -2
  220. package/dist/serveCommand-BRnPCxVd.js +0 -2
  221. package/dist/serveCommand-DdiYNBBu.js +0 -2362
  222. package/dist/start-BLNmWkLa.js +0 -1154
  223. package/dist/start-Dzicuyw8.js +0 -3
  224. package/dist/updateCommand-eXB35SEv.js +0 -2
  225. package/dist/webDev-DposiF3j.js +0 -2
  226. package/templates/apps/api-versioning/template.json +0 -6
  227. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  228. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  229. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
  230. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  231. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  232. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  233. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -379,7 +379,7 @@ Emits `CONSTRAINT <name> UNIQUE (col1, col2, ...)` inline in CREATE
379
379
  TABLE on every dialect. Standard SQL.
380
380
 
381
381
  This is what backs `ctx.store.upsert(..., { conflictColumns: ['a', 'b'] })`
382
- — see [Bulk operations](/docs/database/bulk-operations#upsert).
382
+ — see [Bulk operations](/docs/database/bulk-operations#upsert-insert-or-update-on-conflict).
383
383
 
384
384
  ## GiST indexes (PostGIS spatial)
385
385
 
@@ -689,7 +689,7 @@ mssql, sqlite 3.8+).
689
689
  depend on?"
690
690
 
691
691
  When you don't have a recursive structure, plain
692
- [`withCte()`](/docs/database/query-builder#ctes) is enough.
692
+ [`withCte()`](/docs/database/query-builder#ctes-common-table-expressions) is enough.
693
693
 
694
694
  ## Shape
695
695
 
@@ -820,7 +820,7 @@ eager-load with `.with({...})` to get the per-field pre-filter.
820
820
 
821
821
  ## See also
822
822
 
823
- - [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
823
+ - [Plain CTEs](/docs/database/query-builder#ctes-common-table-expressions) — `withCte()` for
824
824
  non-recursive named sub-queries
825
825
  - [Self-joins](/docs/database/self-joins) — for single-level
826
826
  parent/child queries
@@ -830,7 +830,7 @@ eager-load with `.with({...})` to get the per-field pre-filter.
830
830
  `unionAll`, the mechanism a recursive CTE is built on
831
831
  - [Joins](/docs/database/joins) — relation-based traversal when the
832
832
  graph depth is fixed (e.g. parent + immediate children)
833
- - [Aggregations](/docs/database/query-builder#aggregations) —
833
+ - [Aggregations](/docs/database/query-builder#aggregates) —
834
834
  COUNT/SUM/AVG over a recursive CTE's result set
835
835
 
836
836
 
@@ -940,7 +940,7 @@ keep the branches' result sets bounded.
940
940
  the column-level "in A but not in B" case
941
941
  - [Aggregations](/docs/database/aggregations) — `count()` etc. on
942
942
  a set-op result is a common pattern
943
- - [CTEs](/docs/database/query-builder#ctes) — name a complex set-op
943
+ - [CTEs](/docs/database/query-builder#ctes-common-table-expressions) — name a complex set-op
944
944
  result so you can reference it in a larger query
945
945
 
946
946
 
@@ -1044,7 +1044,7 @@ Non-correlated only. The inner query can NOT reference outer-row
1044
1044
  columns like `WHERE inner.userId = users.id`. For correlated
1045
1045
  sub-queries (a common shape: "user who has at least one post created
1046
1046
  in the last hour") use a [Self-join](/docs/database/self-joins) or
1047
- an [Eager-load](/docs/database/joins#eager-loading) — both can
1047
+ an [Eager-load](/docs/database/joins#eager-loading-via-with-spec) — both can
1048
1048
  express the same query without the correlation reference.
1049
1049
 
1050
1050
  ## Reactivity
@@ -1069,7 +1069,7 @@ every dialect we ship. No per-dialect dispatch.
1069
1069
  with `count()` etc. for "count of X where Y belongs to Z"
1070
1070
  - [Self-joins](/docs/database/self-joins) — when the relationship
1071
1071
  can be expressed as a join instead
1072
- - [CTEs](/docs/database/query-builder#ctes) — for naming a
1072
+ - [CTEs](/docs/database/query-builder#ctes-common-table-expressions) — for naming a
1073
1073
  sub-query you reuse multiple times in the same outer query
1074
1074
 
1075
1075
 
@@ -350,7 +350,7 @@ on it explicitly via `.expressionIndex(name, [...], { ... })`.
350
350
 
351
351
  - [Columns](/docs/database/columns) — `.computed(row => ...)` and
352
352
  `.default(() => ...)` for the app-side variants
353
- - [Indexes](/docs/database/indexes#expression) — `.expressionIndex()`
353
+ - [Indexes](/docs/database/indexes#expression-indexes) — `.expressionIndex()`
354
354
  for indexing a generated column
355
355
  - [Full-text search](/docs/database/full-text-search) — the FTS
356
356
  pattern uses STORED tsvector generated columns
@@ -989,7 +989,7 @@ behavioural gaps are too large to paper over.
989
989
  ## See also
990
990
 
991
991
  - [Columns](/docs/database/columns) — the regular schema-DSL types
992
- - [Expression indexes](/docs/database/indexes#expression) — the
992
+ - [Expression indexes](/docs/database/indexes#expression-indexes) — the
993
993
  framework's index API (`kind: 'gist' | 'gin'` on postgres)
994
994
  - [PostGIS docs](https://postgis.net/docs/) — the official manual,
995
995
  authoritative for every spatial function the framework re-exports
@@ -309,7 +309,7 @@ The query builder has dedicated pages for the deeper topics:
309
309
  - **[Self-joins](/docs/database/self-joins)** — `.as(alias)` +
310
310
  `.innerJoin(table, alias, on)` + `.selectJoined({...})` for parent/
311
311
  child trees and CTE references.
312
- - **[CTEs](/docs/database/query-builder#ctes)** — `.withCte(name, sub)`
312
+ - **[CTEs](/docs/database/query-builder#ctes-common-table-expressions)** — `.withCte(name, sub)`
313
313
  for named sub-queries reusable inside the outer SELECT.
314
314
  - **[Recursive CTEs](/docs/database/recursive-cte)** — `.recursiveCte`
315
315
  for tree walks (org hierarchy, comment threads, file folders).
@@ -445,7 +445,7 @@ framework runs on the post-commit change channel.
445
445
  **Reach for `reference()` first.** A real foreign key across a plugin boundary
446
446
  works and survives the plugin renaming its table, because `reference()` takes
447
447
  the table as a VALUE — see
448
- [plugins/overview](/docs/plugins/overview#pointing-your-table-at-a-plugins-row).
448
+ [plugins/overview](/docs/plugins/overview).
449
449
  `pluginRef` is for the case where you have deliberately chosen NOT to have a
450
450
  key: it enforces nothing at the database level.
451
451
 
@@ -712,7 +712,7 @@ yield* ctx.store.update('documents', input.id, { title: input.title, version: in
712
712
 
713
713
  **Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
714
714
 
715
- **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-versioning`](/docs/plugins/versioning) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
715
+ **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-row-history`](/docs/plugins/row-history) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
716
716
 
717
717
  **Three details worth knowing:**
718
718
 
@@ -543,7 +543,7 @@ and cannot be engineered away:
543
543
  - **`old` is null on an oversized update, and pk-only on an oversized delete.**
544
544
  There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
545
545
  row from a search index, an analytics mirror or a CDC stream; it is not a
546
- record of what the row contained, and `@voltro/plugin-versioning` writes
546
+ record of what the row contained, and `@voltro/plugin-row-history` writes
547
547
  `data: null` for one rather than a fabricated empty snapshot.
548
548
  - **`'unrecovered'` means the content is gone.** No retry can bring it back —
549
549
  it was never delivered. Subscriptions are unaffected (they re-query); taps
@@ -1055,7 +1055,7 @@ MariaDB's GTID format differs from MySQL's: `0-1-100` (domain-server-sequence) v
1055
1055
  - **`mariadb` schema package is wire-compatible with `mysql`**. If you migrate from MySQL → MariaDB, the framework re-emits DDL cleanly via `applySchema(..., 'mariadb')`. Production data round-trips through `mysqldump` without translation.
1056
1056
  - **`sql_mode=NO_BACKSLASH_ESCAPES`** is sometimes set on MariaDB deploys. The framework's identifier escaping handles it, but user-written `unsafe()` strings that hand-escape backslashes may produce wrong output. Leave that mode off if you can.
1057
1057
  - **Sequence-based ID columns**. MariaDB has true CREATE SEQUENCE; the framework doesn't use it (TypeID / ULID / Snowflake are client-side). If you reach for sequences for legacy reasons, they're outside the framework's auto-injection path.
1058
- - **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql#auto_increment-ids--last_insert_id-recovery) for the worked example — the recovery path is identical on both engines.
1058
+ - **Hand-rolled `AUTO_INCREMENT` primary keys** work the same as on mysql: an `insert` / `insertMany` with no client-side `id` recovers the DB-generated id via `LAST_INSERT_ID()` (connection-pinned; `insertMany` recovers the whole consecutive range). See the [mysql page](/docs/database/dialects/mysql) for the worked example — the recovery path is identical on both engines.
1059
1059
 
1060
1060
  ## Where it lives
1061
1061
 
@@ -261,7 +261,7 @@ await ctx.store.upsert('orgSlugs', {
261
261
  ```
262
262
 
263
263
  Requires a composite UNIQUE constraint on the table — declare it via
264
- `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique)).
264
+ `.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
265
265
 
266
266
  ## `insertIgnore` — keep existing on conflict
267
267
 
@@ -314,7 +314,7 @@ const count = await ctx.store.updateMany('posts', { hidden: true }, {
314
314
  // count: number of rows actually updated
315
315
  ```
316
316
 
317
- The `where` predicate is a regular [Predicate](/docs/database/query-builder#predicates)
317
+ The `where` predicate is a regular [Predicate](/docs/database/query-builder)
318
318
  AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
319
319
  `exists` are supported.
320
320
 
@@ -395,5 +395,5 @@ matters.
395
395
  `aggregate()` for read-side bulk reads
396
396
  - [Sub-queries](/docs/database/sub-queries) — `inSubquery` /
397
397
  `exists` in `updateMany` `where:` clauses
398
- - [Composite UNIQUE](/docs/database/indexes#composite-unique) — for
398
+ - [Composite UNIQUE](/docs/database/indexes#composite-unique-constraints) — for
399
399
  the constraint that backs `upsert`'s `conflictColumns: ['a', 'b']`
@@ -312,11 +312,15 @@ cause.
312
312
  When one api process isn't enough:
313
313
 
314
314
  1. Run multiple api containers behind the reverse proxy. The proxy's load-balancing default (round-robin) is fine for HTTP — for WebSocket, use sticky sessions (Caddy: `lb_policy ip_hash`).
315
- 2. Install `@voltro/plugin-cluster` in `app.config.ts.plugins`. It sets up cross-instance subscription invalidation via Postgres NOTIFY (or Redis if you set `CLUSTER_TRANSPORT=redis`).
315
+ 2. Cross-instance subscription invalidation: on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC) this is built in — nothing to install. On any other dialect, or when you prefer a broker, add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) with a Redis or NATS provider to `app.config.ts.plugins` so every replica sees every change.
316
316
  3. Workflows: `@effect/cluster` shards work across instances by workflow ID. No further config — every instance pulls from the shared workflow queue.
317
317
 
318
318
  For multi-region: you need to run Postgres logical replication between regions yourself (or move to Voltro Cloud which handles it).
319
319
 
320
+ ### Scaling is replicas, not a service split
321
+
322
+ Voltro deliberately ships no microservice transports — no `@MessagePattern`-style service-to-service RPC, no broker-backed internal messaging layer. Splitting one app into services would contradict the architecture thesis this whole page rests on: **one monolith process, scaled by running more replicas of it**, with `@effect/cluster` sharding durable work across instances by workflow ID. Every capability that a service split usually buys already has a first-class path: an external system boundary is [REST routes](/docs/data/rest-routes) + [OpenAPI](/docs/plugins/openapi) (`@voltro/plugin-openapi`), and a reliable outbound side effect is the [transactional outbox](/docs/data/outbox). If you find yourself wanting an internal message bus between "services", the answer is more replicas of the same image — not a second process shape.
323
+
320
324
  ## Backups
321
325
 
322
326
  Postgres is the source of truth. Use your provider's backup features (Neon PITR, RDS snapshots, `pg_dump` on a cron). Object-storage assets back up via the provider's lifecycle policies.
@@ -1008,6 +1012,7 @@ indexed query instead of scanning every table). Force a full re-introspect with
1008
1012
 
1009
1013
  ```sh
1010
1014
  VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
1015
+ VOLTRO_MAX_BODY_BYTES=8388608 # default 8 MiB
1011
1016
  ```
1012
1017
 
1013
1018
  A declared `Content-Length` over the cap is refused up front, so an honest client
@@ -1114,6 +1119,14 @@ VOLTRO_LOG_FORMAT=json
1114
1119
  VOLTRO_LOG_LEVEL=info
1115
1120
  ```
1116
1121
 
1122
+ In `json` mode (the default off a TTY, so a pod gets it without configuration)
1123
+ EVERY line the framework emits is a parseable record — the boot banner, the app
1124
+ surface, `voltro db apply`'s plan summary, refusal detail, and every subsystem
1125
+ logger (schedule, broadcast, workflow, flow-control). A hand-formatted table or
1126
+ a `[tag]`-prefixed adapter line between JSON records is a bug, not a style: log
1127
+ collectors show it as unparsed noise. On a TTY the same surfaces render as the
1128
+ human-readable banners and tables.
1129
+
1117
1130
  **Error reporting** — add `sentryPlugin()` from `@voltro/plugin-sentry`. It stays inert until `SENTRY_DSN` is set, and reported errors correlate to the request `traceId`:
1118
1131
 
1119
1132
  ```ts
@@ -1401,7 +1414,7 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1401
1414
  - [ ] Liveness / readiness probes point at `/internal/liveness` + `/internal/readiness`
1402
1415
  - [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
1403
1416
  - [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
1404
- - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
1417
+ - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` + `VOLTRO_MAX_BODY_BYTES` (plugin routes/webhooks) sane; ingress caps body size + per-IP rate
1405
1418
  - [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
1406
1419
  - [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
1407
1420
  - [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
@@ -1759,7 +1772,7 @@ and the drill is the part people skip.
1759
1772
  ## Which one
1760
1773
 
1761
1774
  | | scale-to-zero | managed DB | rolling deploys | cost floor |
1762
- |---|---|---|---|---|
1775
+ | --- | --- | --- | --- | --- |
1763
1776
  | Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
1764
1777
  | Railway | usage-based sleep | built-in | yes | ~0 idle |
1765
1778
  | Render | paid plans only | built-in | yes | fixed/instance |
@@ -1768,3 +1781,17 @@ and the drill is the part people skip.
1768
1781
  An api that owns cron schedules should not scale to zero. An api with bursty
1769
1782
  traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
1770
1783
  the boring answer — one always-on instance — is also the cheapest to operate.
1784
+
1785
+ ## Why there is no edge-SSR adapter
1786
+
1787
+ Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
1788
+ Node-first (`renderToPipeableStream` into a Node stream, `voltro start` as a
1789
+ long-running Node HTTP server), not a Workers/edge runtime — so there is no
1790
+ Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
1791
+ still gets first-class use where it fits the model: isolated
1792
+ [`*.serverless.ts` functions](/docs/deployment/serverless-functions)
1793
+ (`@voltro/serverless`, with Cloudflare / Scaleway / Node adapters) for
1794
+ request-shaped work at the edge, and
1795
+ [static / ISR pages](/docs/deployment/static-sites) served from a CDN for
1796
+ everything that does not need a per-request render. If a page must render per
1797
+ request, it renders in the container.
@@ -96,7 +96,7 @@ The client **adopts what the server resolved**, reading it from the `<html lang>
96
96
  | `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
97
97
  | `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |
98
98
 
99
- Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
99
+ Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.
100
100
 
101
101
  See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
102
102
 
@@ -951,7 +951,7 @@ default, so call sites don't handle a missing-service error.
951
951
  > request, publishes it on the document, and every `@voltro/i18n` formatter on
952
952
  > both sides of the hydration boundary uses it. Read it with `useTimeZone()`
953
953
  > from `@voltro/i18n`. See
954
- > [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference).
954
+ > [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr-the-setting-that-is-not-a-preference).
955
955
  >
956
956
  > The **server-side compute zone** — what `startOfDay` or a workflow's "same
957
957
  > time tomorrow" resolves against inside a handler — is still the SEAM only.
@@ -132,6 +132,22 @@ Voltro is deliberately opinionated about boring things (HTTP, state, transport,
132
132
  - **Magic.** Every file convention is documented; every generated file lives in `.framework/` and you can read it.
133
133
  - **Codegen you have to remember to run.** Schema flows from your tables to your React components automatically via Vite's module graph.
134
134
 
135
+ ## Deliberate noes
136
+
137
+ Two questions come up in every framework comparison. Both are decided — deliberately no — and here is why, so nobody has to re-litigate them.
138
+
139
+ ### Why is there no GraphQL API?
140
+
141
+ 1. **GraphQL's three core promises are solved differently here.** Type-safe selective reads ⇒ typed queries + schema inference. One endpoint for every client ⇒ the RPC socket with a generated client. Third-party consumers ⇒ [REST routes](/docs/data/rest-routes) + [OpenAPI 3.1](/docs/plugins/openapi) (`@voltro/plugin-openapi`).
142
+ 2. **A GraphQL gateway would have no access to the reactivity path** — source-based invalidation, per-delivery guards. It would be a second, dead read path whose results are never live: exactly the kind of duplicate path this framework refuses to keep.
143
+ 3. **Resolver N+1, persisted-query complexity, and a second permission model** (field-level vs. our guards/RLS) buy nothing the existing surface cannot do.
144
+
145
+ Don't build a GraphQL layer over the stores. External consumers get REST + OpenAPI; internal clients get RPC + live subscriptions.
146
+
147
+ ### Why not React Server Components?
148
+
149
+ RSC is a second rendering **and** data model — Flight serialization, `'use client'` boundaries, deep bundler integration — that would compete with the reactive subscription model instead of composing with it. The problems it solves are covered by what exists today: [islands](/docs/routing/islands) for shipping less JS, loaders for server data at render time, and streaming SSR with `defer()` for progressive delivery. Don't write `'use server'` / `'use client'` directives in a Voltro app; they mark a boundary this framework does not have.
150
+
135
151
  ## Where to read next
136
152
 
137
153
  - [Getting started](/docs/intro/getting-started) — scaffold + boot in under a minute
@@ -435,6 +451,7 @@ Voltro replaces router and registry config with **filesystem conventions**. Drop
435
451
  | `*.agent.server.tsx` | Server agent **executor**: `defineAgentExecutor(descriptor, { system, tools, model, maxSteps })`. | Agent runtime. |
436
452
  | `*.tool.tsx` | A tool an agent can call. Schema + handler. | Agent runtime. |
437
453
  | `*.webhook.tsx` | Outgoing webhook spec (target, retry, schema). | Webhook delivery worker. |
454
+ | `*.ws.ts` | Raw WebSocket gateway — `defineWebSocket({ path, auth, onConnection })` as the default export, for FOREIGN protocols beside the rpc socket. | Upgrade listener on the api server, both boot paths. |
438
455
  | `*.entity.ts` | Database table — one table per file: `table()` + columns + mixins. Re-exported from a `database/index.ts` barrel. | Migrations + the runtime data store. |
439
456
  | `*.config.ts` | App-level config (`app.config.ts`, `tsconfig.json`, etc.). | The CLI. |
440
457
 
@@ -470,6 +487,25 @@ Without the marker the leak is still caught — by the rpcGroup guard — but on
470
487
 
471
488
  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.
472
489
 
490
+ ### Raw WebSocket gateways: `*.ws.ts`
491
+
492
+ A `*.ws.ts` file's default export mounts a raw WebSocket upgrade path beside the rpc socket — for a protocol the framework does not speak (a Yjs provider, a legacy device fleet). Discovered on **both** boot paths, `voltro dev` and `voltro serve`:
493
+
494
+ ```ts
495
+ // gateways/collab.ws.ts
496
+ import { defineWebSocket } from '@voltro/protocol'
497
+
498
+ export default defineWebSocket({
499
+ path: '/gateways/collab',
500
+ auth: 'subject', // REQUIRED, no default — or 'public', a decision you write down
501
+ onConnection: ({ send, onMessage, subject }) => {
502
+ onMessage((data) => send(data)) // your protocol, your frames
503
+ return () => { /* teardown — runs on disconnect, credential expiry, shutdown */ }
504
+ },
505
+ })
506
+ ```
507
+
508
+ `auth: 'subject'` authenticates through the same chain as rpc/SSR **before** the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code `4001`); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers `426`. App realtime stays [subscriptions](/docs/data/subscriptions) — full detail under [Raw WebSocket gateways](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket).
473
509
 
474
510
  ## The web side (`apps/*/web/`)
475
511
 
@@ -482,6 +518,10 @@ An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shar
482
518
  | `src/pages/loading.tsx` | Pending UI shown while loaders resolve. |
483
519
  | `src/pages/(group)/` | Route group — does not contribute a URL segment, but layout/error files inside still apply. |
484
520
  | `src/*.island.tsx` | A client-side hydration island (split chunk). Used in `interactive: 'islands'` pages. |
521
+ | `src/**/*.collection.ts` | A content collection declaration (`defineCollection`) — schema-typed markdown/JSON under `content/<name>/**`. See [Content collections](/docs/data/content-collections). |
522
+ | `src/**/*.consumer.ts` | A queue consumer (`defineQueueConsumer`, @voltro/plugin-queue) — Schema-decoded, at-least-once, serial per partition. See [Queue](/docs/plugins/queue). |
523
+ | `grpc.manifest.json` | The gRPC field-number manifest (checked in — append-only wire identity; deletes go `reserved`). See [gRPC surface](/docs/data/grpc). |
524
+ | `content/<name>/**` | A collection's content files (markdown with frontmatter, or `.json` for data collections). Read by `getCollection`/`getEntry`. |
485
525
 
486
526
  Each page can opt into a render strategy via two exports:
487
527
 
@@ -494,6 +534,16 @@ export const interactive = 'islands' as const // 'none' | 'islands' | 'full
494
534
  - `renderMode` controls when the HTML is produced (build vs. request).
495
535
  - `interactive` controls how much JS ships (`'none'` strips it all, `'full'` hydrates the page, `'islands'` hydrates only `.island.tsx` files).
496
536
 
537
+ A page can also declare its query-string contract as a page export:
538
+
539
+ ```tsx
540
+ export const searchParams = Schema.Struct({
541
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
542
+ })
543
+ ```
544
+
545
+ - `searchParams` (an `effect/Schema` struct — every field optional or with a default) types the page's query string: `useSearchParams(searchParams)` returns the decoded shape, and links built with `withQuery` type-check against it. Details: [Pages → Query strings](/docs/routing/pages#query-strings).
546
+
497
547
  ## Discovery in practice
498
548
 
499
549
  ```text
@@ -553,6 +603,8 @@ If yes, the promise belongs in the name — you cannot see a contract before you
553
603
  | `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
554
604
  | `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
555
605
  | `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
606
+ | `*.collection.ts` | declares content collections (`defineCollection`); frontmatter schema violations fail the build naming the file | the build's collection decode + reference validation |
607
+ | `*.consumer.ts` | declares queue consumers (`defineQueueConsumer`, @voltro/plugin-queue); loading registers, the plugin's activation starts them | the queue runner (decode→DLQ, retry→DLQ, commit-per-message) |
556
608
 
557
609
  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`.
558
610
 
@@ -26,10 +26,14 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
26
26
  > transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) — the React
27
27
  > binding for a collaborative text field — [presence/awareness](#presence--awareness)
28
28
  > via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
29
- > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is a thin
30
- > [runtime binding](#whats-shipped-vs-a-runtime-seam) to provisioned infra
31
- > (a broker at scale) plus the two app-specific tags `useCrdtText` is pointed at —
32
- > not un-built framework code.
29
+ > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains falls in
30
+ > two tiers: a thin [runtime binding](#whats-shipped-vs-a-runtime-seam) to
31
+ > provisioned infra (a broker at scale) plus the two app-specific tags
32
+ > `useCrdtText` is pointed at — and the sync **engine**, which is now BUILT:
33
+ > the [query mirror](#the-sync-engine-query-mirror--durable-outbox) persists
34
+ > every subscribed query's rows per subject+tenant partition, `useOutbox`
35
+ > queues offline writes durably, and a reload renders mirrored rows offline
36
+ > and delta-resumes online.
33
37
 
34
38
  ## CRDT text: `crdtText` + `mergeCrdtStates`
35
39
 
@@ -204,6 +208,26 @@ import { Schema } from 'effect'
204
208
  body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
205
209
  ```
206
210
 
211
+ ### Known cost limits of `crdtText()` today
212
+
213
+ Two amplification effects are worth knowing before you put a `crdtText()` column
214
+ on a hot editing path — both are per-keystroke costs, and both are real today:
215
+
216
+ - **Wire amplification downstream.** A subscription delta carries the row's
217
+ columns, and for a CRDT column that is the merged **full state** (base64) —
218
+ every keystroke ships the whole document to every subscriber of the query,
219
+ not the one-edit update. Keep the streamed query's projection narrow (don't
220
+ project `body` into a list view), or subscribe to the document row alone.
221
+ - **Undo/row-history capture.** Server-side capture (the undo log — default-on
222
+ outside production — and `plugin-row-history`'s row history, where enabled)
223
+ snapshots the row per mutation, so per-keystroke mutations write a
224
+ full-state blob per keystroke into those tables. Point them away from
225
+ CRDT-heavy tables, or batch edits before pushing.
226
+
227
+ Both limits are on the framework's roadmap (incremental delivery and
228
+ CRDT-aware capture); until then they are costs to design around, not bugs to
229
+ report.
230
+
207
231
  ## Presence & awareness
208
232
 
209
233
  `usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
@@ -292,6 +316,103 @@ const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
292
316
  const sync = createSyncClient({ transport, adapter }) // state now survives reload
293
317
  ```
294
318
 
319
+ ## Rich text: `crdtDoc()` + `useCrdtEditor`
320
+
321
+ `crdtDoc()` stores a WHOLE collaborative document (rich text, maps, arrays)
322
+ as a column — same storage and authoritative server merge as `crdtText()`,
323
+ which stays as the plain-text specialisation. The editor binding is Tiptap
324
+ (`@voltro/local-first/editor`, optional peers): `useCrdtEditor({ doc })`
325
+ binds one editor to the document handle; content edits ride the app's
326
+ mutation (folded server-side under a per-row mutex) and remote edits arrive
327
+ as incremental `mergeCells` subscription deltas — a one-character edit ships
328
+ under 1 KB in BOTH directions regardless of document size.
329
+
330
+ Carets ride a `delivery: 'latest'` EVENT, deliberately not presence
331
+ metadata: the roster's value-compare push would make every caret move a
332
+ "real" change. `attachAwarenessBridge` publishes one member's state per
333
+ envelope (never the aggregated room). Stable positions for inline comments
334
+ come from `encodeAnchor`/`resolveAnchor` on the doc handle.
335
+
336
+ Operational rules: the stored blob soft-compacts past
337
+ `VOLTRO_CRDT_COMPACT_MAX_BYTES` (default 512 KiB) without breaking the merge
338
+ lineage; `rebaseText` is the explicit hard reset (a NEW EPOCH — subscribers
339
+ receive it as a fresh snapshot). CRDT columns are excluded from undo capture
340
+ and row history (document history = named snapshots taken BEFORE
341
+ compaction); `.serverOnly()` on a CRDT column is a declaration error and
342
+ `.encrypted()` makes it online-only.
343
+
344
+ ## The sync engine: query mirror + durable outbox
345
+
346
+ The engine's two halves ride the primitives you already use — there is no
347
+ second data API:
348
+
349
+ - **Reads.** The subscription cache accepts a `mirror`; every base movement of
350
+ every subscribed query persists (rows + revision) into a **subject+tenant
351
+ partitioned** store over IndexedDB, and a cold start seeds from it — the UI
352
+ renders the last materialised rows offline through the SAME
353
+ `useSubscription` call, and the next connect presents the mirrored revision
354
+ as `voltro-resume-from`, so a reload inside the resume window continues with
355
+ deltas instead of a snapshot.
356
+ - **Writes.** `useOutbox` with `persistence: outboxPersistence(adapter)` is
357
+ the durable offline queue: writes survive a reload, replay in order on
358
+ reconnect, stop at the first conflict, and a conflict resolves through
359
+ `resolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))`
360
+ — `crdtText()` columns merge, scalars follow the declared `conflictPolicy()`.
361
+ One drain per device even with many tabs (`withDrainLock`, a per-partition
362
+ Web Lock).
363
+
364
+ ```ts
365
+ import {
366
+ createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
367
+ } from '@voltro/local-first'
368
+ import { localFirstTables } from './.framework/localFirst.generated'
369
+
370
+ const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
371
+ const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
372
+ const binding = createSubscriptionMirrorBinding(mirror, {
373
+ tags: { 'docs.list': 'docs' }, // the app's sync set
374
+ metadata: localFirstTables, // codegen: encrypted columns stripped
375
+ schemaFingerprint: BUILD_ID, // local-DB migration gate
376
+ })
377
+ ```
378
+
379
+ The deliberate design decision, recorded here because the obvious alternative
380
+ keeps being suggested: the engine is **not** a browser SQL database. The
381
+ client's whole query surface is `(tag, input)` — predicates are built and
382
+ evaluated on the server — so a wa-sqlite instance would evaluate a language
383
+ the client never sees. What offline needs is the last materialised answer per
384
+ query the user visited, kept current by deltas; that is what the mirror
385
+ stores. (The `KvStore` seam still admits a SQLite backing without touching a
386
+ consumer.)
387
+
388
+ Soundness rules, all enforced structurally and tested:
389
+
390
+ - **Partition by key.** Every stored key carries subject AND tenant; a
391
+ logout/login as somebody else can never read the predecessor's rows, and
392
+ `purge()` empties exactly one partition on revocation. Build ONE binding per
393
+ resolved subject and rebuild it on an auth change — the same blank-on-auth
394
+ doctrine the subscription cache itself follows.
395
+ - **`.encrypted()` never lands.** The server decrypts on read, so a naive
396
+ mirror would persist plaintext on the device; the codegen-emitted
397
+ `localFirst.generated.ts` names those columns and the binding strips them
398
+ before every save. `.serverOnly()` columns never reach the wire at all.
399
+ - **The snapshot is the visible state.** A save replaces the mirrored row
400
+ set, so a row the server stopped sending (revoked share, RLS change, soft
401
+ delete) is evicted by construction.
402
+ - **Schema migration is a visible cold start.** Entries persist under the
403
+ build's `schemaFingerprint`; a new build's load misses them and the query
404
+ falls back to loading → fresh snapshot — never a mixed-shape render. The
405
+ offline queue deliberately does NOT gate on it: a queued old-shape write
406
+ replays against the new server, whose input schema is the authority, and a
407
+ rejection surfaces as a visible conflict instead of silently dropped work.
408
+ - **Shapes are your queries.** There is no separate replication-shape
409
+ language: what is mirrored is exactly what the app subscribes to, so tenant
410
+ scoping, guards and `setRowFilter` apply server-side, fail-closed, exactly
411
+ as online — including parent-relative predicates ("tasks where projectId is
412
+ one of my projects"), which are just queries. Mirroring is per QUERY, so a
413
+ subgraph is N queries, not one nested shape. A storage budget is enforced
414
+ with `enforceBudget(maxBytes)` — oldest-saved entries evict first.
415
+
295
416
  ## Conflict policy for non-CRDT fields
296
417
 
297
418
  CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
@@ -348,9 +469,13 @@ constructs the `ApiHandle` (runtime + subscription cache + rpc client) over a
348
469
  WebSocket **you** inject, so RN passes its own `globalThis.WebSocket` and gets
349
470
  the same client stack the web app uses, without pulling in `@voltro/web`.
350
471
 
351
- Still open before the loop is proven end-to-end on a device: codegen emitting the
352
- api's rpc group for a mobile app, an RN persistence adapter, a NetInfo connection
353
- signal, and a reconnect supervisor. `@voltro/react-native` ships the
472
+ Still open before the loop is proven end-to-end on a device: the device boot
473
+ itself everything here is unit-tested without a simulator, so booting a real
474
+ Metro runtime is the remaining verification — plus `*.deepLink.ts` codegen
475
+ discovery (until it lands, register links via `matchFirstDeepLink(links, url)`),
476
+ push **sender** adapters (APNs / FCM need per-tenant credentials), and
477
+ native-module bindings (camera, biometrics, secure token storage need a native
478
+ runtime). `@voltro/react-native` ships the
354
479
  mobile-specific plumbing around that, limited to the parts that need **no
355
480
  per-tenant credentials and no native runtime**: device registration,
356
481
  background-sync scheduling, offline-first defaults, a connection-status surface,
@@ -73,6 +73,8 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
73
73
 
74
74
  Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
75
75
 
76
+ The **web server** additionally counts [partial prerendering](/docs/routing/render-modes#partial-prerendering-ppr--cached-shell--per-request-holes): shell serves, hole passes/settles/errors, and last/max hole latency. Shell hit-rate is the isr cache's own `x-voltro-cache` HIT/STALE/MISS accounting — a ppr shell is a normal isr entry.
77
+
76
78
  ```bash
77
79
  # OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
78
80
  # metrics. Ships to any OTLP/HTTP collector (Prometheus OTLP, Grafana Agent,
@@ -356,7 +356,7 @@ import { Effect } from 'effect'
356
356
  export default defineSchedule({ cron: '*/15 * * * *', timezone: 'Europe/Berlin', handler: (s) => Effect.promise(() => runCadenceTick(s.app)) })
357
357
  ```
358
358
 
359
- Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit history.
359
+ Adopt `@voltro/plugin-row-history` on `_voltro_ai_flows` for automatic edit history.
360
360
 
361
361
  ## Deployment notes
362
362
 
@@ -86,7 +86,7 @@ auditPlugin({ sink: 'datastore', record: 'errors' })
86
86
  ```
87
87
 
88
88
  - `'all'` (default) — every invocation.
89
- - `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-versioning`](/docs/plugins/versioning), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
89
+ - `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-row-history`](/docs/plugins/row-history), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
90
90
  - a predicate — `(event) => boolean`, for anything else.
91
91
 
92
92
  `'all'` is the default even though `'errors'` is often the right choice, because defaulting to errors would silently stop recording successes for every app that upgrades — and "what did this compromised account touch" is answered by successes. Shrinking the trail is a decision you make with your eyes open.
@@ -166,7 +166,7 @@ The trail is only useful if you can enter it by the questions an incident asks.
166
166
 
167
167
  ```ts
168
168
  import { auditByTrace, auditBySubject } from '@voltro/plugin-audit'
169
- import { historyByTrace } from '@voltro/plugin-versioning'
169
+ import { historyByTrace } from '@voltro/plugin-row-history'
170
170
 
171
171
  // What happened during ONE call — and what it changed.
172
172
  const calls = await auditByTrace(ctx.store, traceId)
@@ -176,7 +176,7 @@ const changed = await historyByTrace(ctx.store, traceId, ctx.request.subject.ten
176
176
  const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit: 50 })
177
177
  ```
178
178
 
179
- `traceId` is the join key. [`plugin-versioning`](/docs/plugins/versioning) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
179
+ `traceId` is the join key. [`plugin-row-history`](/docs/plugins/row-history) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
180
180
 
181
181
  `auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
182
182
 
@@ -300,7 +300,7 @@ philosophies.
300
300
 
301
301
  That matters because the right to be forgotten is one this framework grants:
302
302
  `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) exists
303
- for it. Without a snapshot, installing audit + versioning + governance together
303
+ for it. Without a snapshot, installing audit + row-history + governance together
304
304
  makes the first two unreadable for exactly the subjects an investigation is
305
305
  about. **Anonymisation is the worse half**: the join succeeds and returns
306
306
  "Anonymised" for every entry that actor ever produced, retroactively rewriting
@@ -392,7 +392,7 @@ auditPlugin({
392
392
  typeof ctx.input?.teamId === 'string' ? { teamId: ctx.input.teamId } : undefined,
393
393
  })
394
394
 
395
- versioningPlugin({
395
+ rowHistoryPlugin({
396
396
  // From the ROW here — that is what this plugin has.
397
397
  resolveScope: (row) => ({ teamId: row.teamId }),
398
398
  })
@@ -333,7 +333,7 @@ authRoutesPlugin({
333
333
  })
334
334
  ```
335
335
 
336
- The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation#enforcing-a-deactivated-user-cant-log-in). With no guards configured, every authenticated user proceeds exactly as before.
336
+ The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation). With no guards configured, every authenticated user proceeds exactly as before.
337
337
 
338
338
  ## Rehash-on-verify
339
339
 
@@ -1,6 +1,6 @@
1
1
  # CDC-out (reverse-ETL)
2
2
 
3
- > Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
3
+ > Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
4
4
 
5
5
 
6
6
 
@@ -9,7 +9,7 @@
9
9
  <!-- source: en/plugins/cdc-out.md -->
10
10
  ## CDC-out (reverse-ETL)
11
11
 
12
- _Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
12
+ _Declaratively mirror table changes outward to external sinks (webhook, plus a CdcSink interface for custom sinks) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
13
13
 
14
14
  # CDC-out — declarative reverse-ETL
15
15