@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
@@ -1103,6 +1103,40 @@ target: {
1103
1103
 
1104
1104
  `path`, `by`, `match`, and `shapeItem` are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as `identify`/`shape`.
1105
1105
 
1106
+ ## Declared relations — a junction saved in the same mutation
1107
+
1108
+ A form with a multi-reference field (assigned stores, tags, members) writes a
1109
+ JUNCTION table beside the row. Declare that on the write target and the
1110
+ framework reconciles the links INSIDE the mutation's transaction — no
1111
+ hand-written junction code in the executor, and a failure rolls the whole
1112
+ write back:
1113
+
1114
+ ```ts
1115
+ export const employeesUpdate = defineMutation({
1116
+ name: 'employees.update',
1117
+ input: EmployeesUpdateInput, // carries assignedStores: string[]
1118
+ output: Employee,
1119
+ target: {
1120
+ table: 'employees',
1121
+ op: 'update',
1122
+ relations: { assignedStores: 'employee_assigned_stores' },
1123
+ },
1124
+ })
1125
+ ```
1126
+
1127
+ After the executor succeeds, `input.assignedStores` is reconciled against the
1128
+ junction via the diff-based link writer (`store.relationLinks`): missing rows
1129
+ inserted, surplus rows deleted, unchanged rows untouched — so reactive
1130
+ subscriptions on the junction see one change per changed row. The anchor
1131
+ column is derived from the junction's `reference()` targets; a self-junction
1132
+ (both columns referencing one table) is refused by name, never guessed.
1133
+
1134
+ The semantics worth knowing: an ABSENT input field leaves the links
1135
+ untouched — absent is not empty; an empty array is the explicit "clear them
1136
+ all". The row id comes from the executor's `output.id`, falling back to
1137
+ `input.id`. The link writes go through `ctx.store`, so undo capture and
1138
+ cross-table rules see them like any other write.
1139
+
1106
1140
  ## Typed Errors
1107
1141
 
1108
1142
  ```ts
@@ -2026,8 +2060,14 @@ Queries/subscriptions are for live state. Streams are for one-shot element flows
2026
2060
  ## Reconnect
2027
2061
 
2028
2062
  A dropped WebSocket rebuilds the whole client stack — new socket, new RPC
2029
- client, new subscription cache — and re-subscribes every active query, each of
2030
- which answers with a fresh snapshot.
2063
+ client, new subscription cache — and re-subscribes every active query. Inside
2064
+ the resume window (`reactive.resume.windowMs`, default 60 s) the server replays
2065
+ **only the deltas the client missed** — the re-subscribe presents the last
2066
+ materialised revision and the stream continues on the same revision line, so a
2067
+ short offline gap costs a handful of patches instead of every row. Outside the
2068
+ window, for computed queries, for row-filtered apps, or whenever anything is in
2069
+ doubt, the query answers with a fresh snapshot — the delta-resume wire contract
2070
+ lives in [the wire protocol](/docs/data/wire-protocol#reconnect--delta-resume).
2031
2071
 
2032
2072
  **What is on screen while that happens is your last-known-good data, not a
2033
2073
  skeleton.** The replacement cache is seeded from the one it retires, so `data`
@@ -2054,6 +2094,15 @@ Three things are deliberately NOT carried across:
2054
2094
  - **Entries nothing re-subscribes to.** A screen that unmounted during the
2055
2095
  reconnect does not pin its rows; the seed evicts on the normal inactive TTL.
2056
2096
 
2097
+ ### The local-first mirror partitions by subject — the carve-out
2098
+
2099
+ An app using `@voltro/local-first`'s query mirror keeps rows on the DEVICE
2100
+ across reloads. The blank-on-auth rule extends there structurally: every
2101
+ mirrored key carries the subject AND tenant, so the next subject's binding
2102
+ simply never finds the predecessor's rows, and a logout or membership
2103
+ revocation calls `purge()` on the departing partition. Nothing about the
2104
+ in-memory blanking above changes.
2105
+
2057
2106
  ### An auth change still blanks — on purpose
2058
2107
 
2059
2108
  When the rebuild happens because the connection's *subject* changed — a cookie
@@ -2158,32 +2207,40 @@ once. Order BETWEEN subscribers was never guaranteed.
2158
2207
  There is a number, it is not a constant, and which number you get depends on a
2159
2208
  property of your **queries** rather than of your scale. Re-derive it on your own
2160
2209
  hardware with `node packages/runtime/scripts/fanout-ceiling.mjs`; the figures
2161
- below are the spread across three runs on a busy developer machine at 10 matched
2162
- writes per second, against a budget of 100 ms of event-loop time per second (10%
2163
- of one core).
2210
+ below are the spread across three runs on a busy developer machine (12-core,
2211
+ macOS) at 10 matched writes per second, against a budget of 100 ms of
2212
+ event-loop time per second (10% of one core).
2164
2213
 
2165
2214
  | Subscriber population | Marginal CPU per subscriber | Subscribers per node |
2166
2215
  | --- | --- | --- |
2167
- | **Shared** — N clients on the SAME query (a leaderboard, a shared board) | 0.5–0.9 µs | ≈ 11 000–20 000 |
2168
- | **Distinct** — N clients each on their OWN query (`where userId = me`) | 22–29 µs | 350–450 |
2169
-
2170
- Ranges rather than single numbers, deliberately: that is the spread three runs
2171
- produced, and a ceiling quoted to three significant figures from one run is a
2172
- number somebody will hold you to.
2173
-
2174
- The shared case is cheap because the memoisation above applies: one read, one
2175
- diff, N emits. The distinct case gets no sharing at all — the read, the diff and
2176
- the emit are all per subscriber and a per-user dashboard is exactly that shape.
2177
- **Plan against the distinct number**, and note that it scales inversely with your
2178
- write rate: at 1 matched write per second it is ten times higher.
2179
-
2180
- Two things that are easy to assume and are not true:
2181
-
2182
- - **A more selective `where` buys no headroom.** Measured: 200 of 200
2183
- subscribers whose predicate matched *nothing* were still woken by one write on
2184
- their table. Every subscription is a dependent of its own table, so a change
2185
- wakes all of them and each re-queries. The ceiling counts subscribers **on the
2186
- table**, not subscribers whose predicate matches.
2216
+ | **Shared** — N clients on the SAME query (a leaderboard, a shared board), every write matches all of them | 0.37–0.41 µs | ≈ 25 000–27 000 |
2217
+ | **Distinct** — N clients each on their OWN query (`where userId = me`), a write matches ONE | flat per-write cost does not grow with resident subscribers | not set by subscriber count |
2218
+
2219
+ The shared case fans out by design one read and one diff (the memoisation
2220
+ above), then N emits and it is the shape that sets the ceiling. The distinct
2221
+ case changed shape entirely with **matcher authority**: a subscription whose
2222
+ query is a plain predicate read is woken by the predicate index alone, so a
2223
+ write to *somebody else's* row is a non-event, not a wake. Per write it costs a
2224
+ bucket lookup plus one delivery, regardless of how many thousands of distinct
2225
+ subscribers are resident. Measured: **0 of 200** subscribers whose predicate
2226
+ matched nothing were woken by a write on their table **a selective `where`
2227
+ buys real headroom** now.
2228
+
2229
+ What still wakes conservatively (any change on the table), and deliberately
2230
+ this list is exhaustive:
2231
+
2232
+ - queries with an **eager `.with()` spec**, a setOp (`union`/…) or a CTE — the
2233
+ handler reads rows the root predicate does not describe;
2234
+ - **`dependsOn`** raw reads (the dispatcher can only re-run the descriptor,
2235
+ never the handler);
2236
+ - **computed** queries and **`reactivityChannel`** queries (their own
2237
+ recompute paths, unchanged);
2238
+ - **oversized change events** (`tombstone` / `unrecovered` — row images the
2239
+ matcher cannot see wake the whole table for that one event; `rehydrated`
2240
+ events are judged normally).
2241
+
2242
+ One thing that is easy to assume and is not true:
2243
+
2187
2244
  - **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
2188
2245
  subscription file, reaction or aggregate, bound once at boot. Every client
2189
2246
  subscription in a process shares the dispatcher's single listener, so ten
@@ -2195,6 +2252,46 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
2195
2252
  budgeted here is the matcher and re-query CPU each node spends on ITS OWN
2196
2253
  clients.
2197
2254
 
2255
+ ### Deltas are per-query — two queries can briefly diverge
2256
+
2257
+ Every subscription has its own revision line and its own delivery moment. After
2258
+ one write that affects two queries you hold open, the deltas arrive as two
2259
+ independent pushes — usually microseconds apart, but there is no cross-query
2260
+ transaction on the wire, and a render between the two pushes can see query A
2261
+ after the write and query B before it. Within ONE query you never see a partial
2262
+ write (a delta is computed from a committed row set); across queries, design for
2263
+ eventual agreement rather than instantaneous consistency — derive values that
2264
+ must agree atomically inside one query instead of joining two on the client.
2265
+
2266
+ ## Raw WebSocket gateways — `defineWebSocket`
2267
+
2268
+ Everything above rides the framework's subscription protocol, and that stays the answer for app realtime — live queries, optimistic patches, reconnect. A **gateway** exists for the other case: a FOREIGN protocol that needs a socket the framework does not speak — a Yjs provider, a legacy device fleet, an MQTT-over-WS bridge. It mounts its own upgrade path beside the rpc socket, in a `*.ws.ts` file discovered on **both** boot paths:
2269
+
2270
+ ```ts
2271
+ // gateways/yjs.ws.ts
2272
+ import { defineWebSocket } from '@voltro/protocol'
2273
+
2274
+ export default defineWebSocket({
2275
+ path: '/gateways/yjs',
2276
+ auth: 'subject', // REQUIRED, no default: 'subject' | 'public'
2277
+ onConnection: ({ send, close, onMessage, subject, headers, path }) => {
2278
+ const doc = attachDoc(subject!.id)
2279
+ onMessage((data) => doc.applyUpdate(data)) // binary-safe frames
2280
+ const stop = doc.onUpdate((update) => send(update))
2281
+ return () => { stop(); doc.release() } // teardown
2282
+ },
2283
+ })
2284
+ ```
2285
+
2286
+ The contract, in the order it protects you:
2287
+
2288
+ - **`auth` is mandatory and has no default.** `'subject'` runs the SAME auth chain as rpc/SSR *before* the upgrade — an unauthenticated caller gets `401` while the request is still plain http, and the connection is bound to the credential's expiry: when it lapses, the socket closes with application code `4001`, so a foreign client can re-auth and reconnect. `'public'` is a deliberate, written-down decision (a device fleet with protocol-level auth of its own).
2289
+ - **Every gateway path is origin-checked at upgrade** — cross-origin means `403`, which closes cross-site WebSocket hijacking for your protocol exactly as for the framework's socket.
2290
+ - **`onConnection({ send, close, onMessage, subject, headers, path })`** may return a teardown function — it runs on client disconnect, on credential expiry, and on server shutdown, so whatever the handler opened cannot outlive the socket.
2291
+ - A plain GET on a gateway path answers `426 Upgrade Required`; two gateways declaring one path refuse the boot.
2292
+
2293
+ **The boundary to keep:** if your own UI needs live data, that is a query + `useSubscription`, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.
2294
+
2198
2295
  ## See also
2199
2296
 
2200
2297
  - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
@@ -2940,6 +3037,44 @@ const requireApiKey: RestGuard = (ctx) =>
2940
3037
  ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }
2941
3038
  ```
2942
3039
 
3040
+ ## Methods — PATCH, HEAD and OPTIONS are first-class
3041
+
3042
+ `method:` accepts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`, on REST routes and plugin HTTP routes alike. Two details:
3043
+
3044
+ - **HEAD is admitted wherever GET is** (RFC 9110): a `HEAD` request runs the GET route's whole pipeline — method gate, guards, handler — and the transport drops the body. You never declare a second route for it.
3045
+ - **A wrong method is still a precise `405`**, with an `Allow:` header naming exactly the methods mounted on that path — including when several routes share one path.
3046
+
3047
+ ## Body limits — `maxBodyBytes`
3048
+
3049
+ Every HTTP body read is capped at 8 MiB by default — `POST /rpc` (as it always was), plugin routes, REST routes and incoming webhooks. The app-wide cap is `http.maxBodyBytes` in `app.config.ts` (env override `VOLTRO_MAX_BODY_BYTES`); a route that legitimately takes more declares its own:
3050
+
3051
+ ```ts
3052
+ export default defineRestRoute({
3053
+ method: 'POST',
3054
+ path: '/v1/import',
3055
+ maxBodyBytes: 64 * 1024 * 1024, // this route only — the app cap stays 8 MiB
3056
+ // …
3057
+ })
3058
+ ```
3059
+
3060
+ The same per-route override exists on an incoming webhook's handler (`maxBodyBytes`) — fat provider payloads are the normal case there, not the exception. Two details:
3061
+
3062
+ - **Routes that share one PATH share one body read.** The body is read once for the whole group, so the widest `maxBodyBytes` override in the group applies to the group.
3063
+ - An oversized body answers `413` whether it announces itself (`Content-Length`) or arrives chunked — the counter cuts it at the cap and never buffers past it.
3064
+
3065
+ ## Conditional GET — `etag: true`
3066
+
3067
+ ```ts
3068
+ export default defineRestRoute({
3069
+ method: 'GET',
3070
+ path: '/v1/customers',
3071
+ etag: true, // GET only — ignored elsewhere
3072
+ // …
3073
+ })
3074
+ ```
3075
+
3076
+ The route stamps a **weak, content-derived** `ETag` (`W/"<sha-1 of the encoded output>"`) on every `200`, and answers a matching `If-None-Match` with `304 Not Modified` — the tag, no body. Weak on purpose: the transport may vary the BYTES per content-encoding, but the representation is the same. (`voltro start` does the equivalent for the web app's HTML on its own — `If-None-Match` answers `304` for buffered `200`s, with weak `W/"md5"` tags over the *uncompressed* body; `no-store` responses excepted.)
3077
+
2943
3078
  ## Idempotency (`Idempotency-Key`)
2944
3079
 
2945
3080
  Set `idempotency: true` in `app.config.ts` and every mutating REST request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries an `Idempotency-Key` header is deduplicated:
@@ -2970,6 +3105,56 @@ This is the Stripe-style contract — the **client** opts in by sending the head
2970
3105
  - **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
2971
3106
  - **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
2972
3107
 
3108
+ ## API versions — opt-in `version:` + the sunset flow
3109
+
3110
+ A route that will evolve declares its version instead of baking it into the
3111
+ path; `version: 'v2'` mounts under `/v2/…`:
3112
+
3113
+ ```ts
3114
+ // v2 — the current shape
3115
+ export const listCustomers = defineRestRoute({
3116
+ method: 'GET',
3117
+ path: '/customers',
3118
+ version: 'v2',
3119
+ output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
3120
+ handler: async (_i, ctx) => ({ data: await ctx.store.query(customers), nextCursor: null }),
3121
+ })
3122
+
3123
+ // v1 — still mounted, deprecated, and gone on a date
3124
+ export const listCustomersV1 = defineRestRoute({
3125
+ method: 'GET',
3126
+ path: '/customers',
3127
+ version: 'v1',
3128
+ deprecated: 'GET /v2/customers', // Deprecation header + replacement pointer
3129
+ sunset: '2027-03-01', // Sunset header; 410 Gone from this date
3130
+ output: Schema.Struct({ customers: Schema.Array(Customer) }),
3131
+ handler: async (_i, ctx) => ({ customers: await ctx.store.query(customers) }),
3132
+ })
3133
+ ```
3134
+
3135
+ Two versions are **two descriptors** — the old one is ordinary code (visible,
3136
+ testable, deletable), not an entry in a transformation DSL. While it lives,
3137
+ responses carry `Deprecation: true` + `Sunset:`; past the date it answers
3138
+ `410 Gone` with `{ version: 'v1', replacement: 'GET /v2/customers' }`. Then
3139
+ you delete it. `version` is opt-in: a route without it keeps its literal path
3140
+ (no auto-prefix), and declaring `version:` on a path that already starts with
3141
+ `/vN/` is refused at definition — both spellings at once is never intended.
3142
+ `publicApi` projections version the same way (`spec.version`, default `v1`),
3143
+ and the OpenAPI doc groups each version's operations under a version tag with
3144
+ `x-voltro-api-version` — one document, the `/vN/` paths already separate them.
3145
+
3146
+ **URI versioning only, on purpose.** Header- and media-type-versioning (the
3147
+ NestJS options) are not supported: the OpenAPI document, cache keys and plain
3148
+ `curl` are all path-shaped, and a version a URL cannot express is a version a
3149
+ cached response cannot vary on. If an edge must accept `Accept-Version:`
3150
+ headers, rewrite them to the path prefix at the proxy.
3151
+
3152
+ **And the rpc socket is deliberately outside this.** The generated client is
3153
+ versioned with the server it was generated from — there is no `/v2` for
3154
+ `useMutation`. Honest edge: a browser tab that stayed open across your deploy
3155
+ runs the PREVIOUS client until reload; that skew window exists, it is small,
3156
+ and URL versioning would not remove it.
3157
+
2973
3158
  ## Projecting an existing procedure — `publicApi`
2974
3159
 
2975
3160
  You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
@@ -3026,6 +3211,45 @@ Same guarantees as the WebSocket path, because it is the same code: the declarat
3026
3211
 
3027
3212
  For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
3028
3213
 
3214
+ ## Binary downloads — `bytes()`
3215
+
3216
+ A handler that serves a file, an export or any non-JSON body returns `bytes(stream, options)` — imported beside `defineRestRoute` / `sse`:
3217
+
3218
+ ```ts
3219
+ import { defineRestRoute, bytes, requireScope } from '@voltro/protocol/rest'
3220
+
3221
+ export default defineRestRoute({
3222
+ method: 'GET',
3223
+ path: '/v1/exports/:id',
3224
+ guards: [requireScope('exports:read')],
3225
+ handler: async ({ params }, ctx) => {
3226
+ const file = await locateExport(params.id)
3227
+ // Lazy thunk form — the source is opened only when the response streams.
3228
+ return bytes(() => openExportStream(file), {
3229
+ contentType: 'application/zip',
3230
+ contentLength: file.size,
3231
+ contentDisposition: `attachment; filename="${file.name}"`,
3232
+ })
3233
+ },
3234
+ })
3235
+ ```
3236
+
3237
+ - The first argument is a web `ReadableStream<Uint8Array>` — or the **lazy thunk form** `() => ReadableStream`, which defers opening the source until the response actually streams.
3238
+ - The server **pipes without buffering** — a body larger than the heap is fine (the guarantee is exercised with a 256-MiB stream), and byte streams are **never compressed**.
3239
+ - Everything before the handler still runs — method gate, sunset, input decode, guards — so a streaming route is exactly as gated as a buffered one.
3240
+ - On a plugin HTTP route the same shape is `PluginHttpRouteResult.byteStream`.
3241
+
3242
+ ### Idempotency × streams — decided
3243
+
3244
+ `streaming: true` on a method the idempotency binding claims (`POST`/`PUT`/`PATCH`/`DELETE`) is a **mount error**: a stream cannot cache a replayable body, so the idempotency claim could never complete — every retry would `409` until the TTL lapsed. The refusal names the two ways out: serve the stream on `GET`, or keep the idempotency binding away from the app's streaming routes. A handler that returns a stream *without* declaring `streaming: true` is caught at runtime instead — the claim is **released** so a retry re-processes.
3245
+
3246
+ ## No multipart parser — a declared boundary
3247
+
3248
+ There is **no multipart parser** on REST or webhook routes — `multipart/form-data` against `/form/*` answers `415`, and a REST handler never sees parsed file parts. That boundary is deliberate, and this list of alternatives is complete:
3249
+
3250
+ - **File uploads** ride [`@voltro/plugin-storage`](/docs/plugins/storage)'s upload routes — a binary PUT plus a resumable, chunked upload with signed tickets. That is the sanctioned file path, not a workaround.
3251
+ - **A provider that delivers webhooks as multipart** (the Mailgun-inbound class) needs, today, either a small parser proxy in front of the endpoint or the provider's JSON delivery mode where it offers one.
3252
+
3029
3253
  ## REST route vs Action
3030
3254
 
3031
3255
  Both are unary request/response. Pick by transport + audience:
@@ -4464,7 +4688,7 @@ rather than letting whichever loaded last silently win.
4464
4688
  ## Retries, backoff, dead-letter
4465
4689
 
4466
4690
  | | |
4467
- |---|---|
4691
+ | --- | --- |
4468
4692
  | Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
4469
4693
  | Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
4470
4694
  | Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
@@ -4530,7 +4754,7 @@ delivery-history screen renders. So every attempt appends a row to
4530
4754
  `_voltro_outbox_attempts`:
4531
4755
 
4532
4756
  | column | |
4533
- |---|---|
4757
+ | --- | --- |
4534
4758
  | `outboxId` | the entry this attempt belongs to |
4535
4759
  | `effect` | denormalised — the history stays readable after the entry is purged |
4536
4760
  | `attempt` | 1-indexed, monotonic across the entry's whole life |
@@ -4626,6 +4850,20 @@ bound.
4626
4850
  - **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
4627
4851
  is built for reverse-ETL with per-pipe ordering.
4628
4852
 
4853
+ ## There is no generic job queue — take X for Y
4854
+
4855
+ Voltro deliberately ships no `defineJob` primitive (priorities, worker pools, a
4856
+ BullMQ equivalent). The outbox, [workflows](/docs/workflows/overview) and
4857
+ [schedules](/docs/scheduling/overview) cover the cases between them, and a third
4858
+ durability primitive would drift from both. What to reach for instead:
4859
+
4860
+ | you want… | take |
4861
+ | --- | --- |
4862
+ | a concurrency-limited worker pool | workflows + [declarative flow control](/docs/workflows/declarative-flow-control) — `concurrency` / `throttle` bound how many runs execute at once |
4863
+ | true priority scheduling (high-priority work overtakes queued low-priority work) | does not exist as a primitive — a workflow draining **your own queue table** in your priority order is the honest build |
4864
+ | a delayed / scheduled message | `delayMs` on `enqueue` (above) for a one-off delayed effect; a [schedule](/docs/scheduling/overview) for recurring time-based work; workflow [`sleep`](/docs/workflows/sleep) for a pause inside a durable process |
4865
+ | exactly-once delivery | does not exist — delivery is at-least-once everywhere, which is the strongest guarantee available without a distributed transaction into the target; **idempotent handlers are mandatory** (see above) |
4866
+
4629
4867
  ## See also
4630
4868
 
4631
4869
  - [Mutations](/docs/data/mutations) — the transaction boundary this rides
@@ -4684,7 +4922,7 @@ A streaming query (what `useSubscription` opens) emits a sequence of **subscript
4684
4922
  { _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
4685
4923
  ```
4686
4924
 
4687
- - **`revision`** — monotonically increasing; lets the client order events and detect gaps.
4925
+ - **`revision`** — monotonically increasing; lets the client order events. **Revisions may JUMP forward** — under socket backpressure the server coalesces updates a slow consumer hasn't read yet into one event whose patch is computed against the row set of the last event it actually handed over, so patch continuity holds across the jump. A jump is therefore normal, never a gap; only a *regressing* or repeating revision would be a protocol violation.
4688
4926
  - **`emittedAt`** — epoch milliseconds, present on `delta` only.
4689
4927
  - **`data`** (snapshot) — the full payload, typed by the query's `output` schema (a row, an array of rows, a computed value — whatever the handler returns).
4690
4928
  - **`patch`** (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
@@ -4703,6 +4941,70 @@ propagate to the shared connection and stall every *other* subscription on it
4703
4941
  client surfaces it as `useSubscription(...).error` for that one query key;
4704
4942
  siblings keep delivering their snapshots and deltas.
4705
4943
 
4944
+ ### Slow consumers — coalescing and `SubscriptionOverrun`
4945
+
4946
+ A consumer that stops reading (a backgrounded tab, a saturated link) does not
4947
+ grow the server without bound. While its socket is blocked, updates
4948
+ **coalesce**: the server keeps only the newest state per subscription and, when
4949
+ the socket accepts again, sends ONE event — a patch against the last state the
4950
+ consumer was actually handed (`revision` jumps accordingly, see above). Memory
4951
+ per blocked subscription is bounded by construction: one pending state,
4952
+ regardless of how far behind the consumer is.
4953
+
4954
+ A consumer that stays more than `reactive.socket.maxBufferedBytes` (default
4955
+ 1 MiB, env `VOLTRO_REACTIVE_MAX_BUFFERED_BYTES`) behind for
4956
+ `reactive.socket.overrunAfterMs` (default 10 s) is closed **loudly**: it
4957
+ receives an `error` event with `error._tag: 'SubscriptionOverrun'` (carrying
4958
+ `bufferedBytes` + `maxBufferedBytes`) and the stream ends — never a silent
4959
+ drop. The client re-subscribes and starts from a fresh snapshot.
4960
+
4961
+ Oversized events are telemetry, not a cap: an event over
4962
+ `reactive.socket.oversizedEventBytes` (default 256 KiB) is delivered normally
4963
+ and counted (`voltro_subscription_oversized_total`) with a WARN naming the
4964
+ query — alongside `voltro_subscription_buffered_bytes`,
4965
+ `voltro_subscription_coalesced_total` and `voltro_subscription_overrun_total`
4966
+ in the Prometheus exporter and the inspect Metrics panel.
4967
+
4968
+ ### Reconnect — delta-resume
4969
+
4970
+ A client that reconnects inside the **resume window** does not have to pay for
4971
+ a full snapshot: it sends the last `revision` it materialised in the per-call
4972
+ `voltro-resume-from` request header (the same header surface the idempotency
4973
+ key rides), and the server — which kept the subscription alive server-side for
4974
+ the window after the disconnect — **replays only the deltas that were missed**
4975
+ and re-attaches the stream on the SAME revision line.
4976
+
4977
+ The signal is the first event's tag, not a schema field:
4978
+
4979
+ - **first event `delta`** — the resume was honoured; apply the patch onto the
4980
+ rows you already hold and continue.
4981
+ - **first event `snapshot`** — the resume was declined; reset to the snapshot.
4982
+ This is the answer whenever anything is in doubt, because a wrong snapshot
4983
+ costs bytes while a wrong replay would leak rows.
4984
+
4985
+ `@voltro/client` does both automatically — the reconnect-seeded cache keeps its
4986
+ rows and revision, presents the header, and treats a snapshot-first stream as
4987
+ the reset it already knows how to do. Replayed deltas may **coalesce** exactly
4988
+ as slow-consumer updates do (revisions jump; patch continuity holds).
4989
+
4990
+ A resume is declined — always with a fresh snapshot — when:
4991
+
4992
+ - the window expired (`reactive.resume.windowMs`, default 60 s, env
4993
+ `VOLTRO_REACTIVE_RESUME_WINDOW_MS`), or more deltas were missed than the ring
4994
+ retains (`reactive.resume.maxDeltas`, default 256, env
4995
+ `VOLTRO_REACTIVE_RESUME_MAX_DELTAS`);
4996
+ - the query's `guards:` were revoked while the client was away — the
4997
+ per-delivery re-check keeps running on the detached subscription, and a
4998
+ revocation drops the retained history outright;
4999
+ - the resuming caller is a different subject or tenant (a login, logout or
5000
+ tenant switch between disconnect and resume) — the retained history is keyed
5001
+ by subject AND tenant, so a changed identity simply never finds it;
5002
+ - the app registers a row filter (`setRowFilter`), or the query is a
5003
+ **computed** query — both are excluded from resume by design: a row-filtered
5004
+ subscription's visible row set exists only per delivery, and a computed query
5005
+ re-runs a handler with no delta chain to replay. They reconnect with a fresh
5006
+ snapshot, exactly as before.
5007
+
4706
5008
  **Author a live-subscribed getter to return, not throw.** A subscription is a
4707
5009
  long-lived stream, so a getter that throws on every re-evaluation is a broken
4708
5010
  stream. For an expected-absent row, make the query `output: Schema.NullOr(...)`
@@ -5035,6 +5337,203 @@ try {
5035
5337
 
5036
5338
 
5037
5339
 
5340
+ ---
5341
+
5342
+ <!-- source: en/data/content-collections.md -->
5343
+ ## Content collections
5344
+
5345
+ _File-based, schema-typed markdown content — defineCollection over content/<name>/**/*.md, an isomorphic getCollection/getEntry, locale trees with fallback, headings for TOCs, data collections, references, and RSS feeds — without installing a markdown dependency._
5346
+
5347
+ A content collection turns a folder of markdown files into typed, rendered
5348
+ content: you declare the frontmatter schema in code, and `getCollection()` /
5349
+ `getEntry()` hand you decoded data plus server-rendered HTML with
5350
+ syntax-highlighted code fences. The markdown engine lives in the framework —
5351
+ **do not install your own `marked` / `remark` / `shiki`**; a second pipeline
5352
+ drifts from the one your artifacts, feeds and templates already use.
5353
+
5354
+ ## A blog in 20 lines
5355
+
5356
+ One collection file, one markdown file, one page:
5357
+
5358
+ ```ts
5359
+ // src/collections/posts.collection.ts
5360
+ import { Schema } from 'effect'
5361
+ import { defineCollection } from '@voltro/content'
5362
+
5363
+ export const posts = defineCollection({
5364
+ name: 'posts',
5365
+ directory: 'content/posts',
5366
+ schema: Schema.Struct({ title: Schema.String, date: Schema.String }),
5367
+ })
5368
+ export type Post = Schema.Schema.Type<typeof posts.schema>
5369
+ ```
5370
+
5371
+ ```ts
5372
+ // src/pages/blog/[slug]/page.tsx
5373
+ import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
5374
+ import { useLoaderData } from '@voltro/web'
5375
+ import { posts, type Post } from '../../../collections/posts.collection'
5376
+
5377
+ export const renderMode = 'static' as const
5378
+ export const getStaticPaths = async () =>
5379
+ (await getCollection(posts.name)).map((e) => ({ params: { slug: e.slug } }))
5380
+ export const loader = async ({ params }: { params: { slug: string } }) =>
5381
+ await getEntry<Post>(posts.name, params.slug)
5382
+
5383
+ export default function Post() {
5384
+ const post = useLoaderData<ContentEntry<Post> | null>()
5385
+ if (!post) return <main>Not found</main>
5386
+ return <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
5387
+ }
5388
+ ```
5389
+
5390
+ Drop `content/posts/hello.md` with `title:` + `date:` frontmatter and the
5391
+ build pre-renders `/blog/hello` — highlighted code fences included.
5392
+
5393
+ ## How it stays out of your bundle
5394
+
5395
+ The loader is **isomorphic**. At build/SSR time it reads the filesystem and
5396
+ renders markdown (shiki runs on the server only). The build also emits JSON
5397
+ artifacts under `dist/assets/content/<name>[.<locale>]/…` — an index (slugs +
5398
+ frontmatter, no bodies) and one file per entry (rendered HTML + headings). On
5399
+ an SPA navigation, the CLIENT branch of `getCollection`/`getEntry` fetches
5400
+ those artifacts. The result: no markdown engine, no highlighter, and no
5401
+ content bodies in your JavaScript bundle. `voltro dev` serves the same
5402
+ artifact shapes on demand and invalidates them when a `content/**` file
5403
+ changes.
5404
+
5405
+ ## Frontmatter is a schema, and violations fail the build
5406
+
5407
+ The `schema` is an `effect/Schema` struct decoded per file. A missing or
5408
+ mistyped field is a **build error naming the file** — not a page that renders
5409
+ `undefined`. Numbers in frontmatter arrive as strings; use
5410
+ `Schema.Union(Schema.NumberFromString, Schema.Number)` for numeric fields.
5411
+ `getCollection<A>` returns entries whose `data` is the schema's inferred
5412
+ type — no casts.
5413
+
5414
+ ## Slugs come from the path
5415
+
5416
+ `content/posts/hello.md` → `hello`; nested folders stay in the slug
5417
+ (`database/joins.md` → `database/joins`). Two files resolving to one slug
5418
+ (a rename that left both) is a build error.
5419
+
5420
+ ## Locale trees + fallback
5421
+
5422
+ A collection with `i18n` treats the first path segment as the locale:
5423
+
5424
+ ```ts
5425
+ export const docs = defineCollection({
5426
+ name: 'docs',
5427
+ directory: 'content/docs',
5428
+ schema: Schema.Struct({ title: Schema.String }),
5429
+ i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
5430
+ })
5431
+ ```
5432
+
5433
+ `getCollection('docs', { locale: 'de' })` reads the `de/` tree. A slug missing
5434
+ in the requested locale is served from the default tree with
5435
+ `fallback: true` on the entry (render an "untranslated" banner off it) — or
5436
+ omitted entirely with `missing: 'missing'`. Incomplete translations are the
5437
+ normal case; decide the policy per collection instead of improvising per page.
5438
+
5439
+ ## Headings as data
5440
+
5441
+ Every rendered entry carries `headings: [{ depth, slug, text }]` — the TOC
5442
+ input. The slugs are the SAME ids stamped on the rendered `<h2 id="…">`
5443
+ elements, so sidebar anchors never drift from the body. For a TOC without a
5444
+ render pass, `extractHeadings(markdown)` (from `@voltro/content/markdown`)
5445
+ computes the same data synchronously.
5446
+
5447
+ ## Data collections
5448
+
5449
+ `kind: 'data'` reads `.json` files instead of markdown — the `authors.json`
5450
+ case. Each file decodes whole against the schema; there is no render path:
5451
+
5452
+ ```ts
5453
+ export const authors = defineCollection({
5454
+ name: 'authors',
5455
+ directory: 'content/authors',
5456
+ kind: 'data',
5457
+ schema: Schema.Struct({ name: Schema.String, url: Schema.String }),
5458
+ })
5459
+ ```
5460
+
5461
+ ## References between collections
5462
+
5463
+ `reference('<collection>')` declares a frontmatter field that names an entry
5464
+ of another collection by slug:
5465
+
5466
+ ```ts
5467
+ schema: Schema.Struct({
5468
+ title: Schema.String,
5469
+ author: reference('authors'),
5470
+ })
5471
+ ```
5472
+
5473
+ The build validates every reference — a dangling one (`author: nobody`) fails
5474
+ the build naming the collection, entry, field and target. Resolve it with
5475
+ `getEntry('authors', entry.data.author)`.
5476
+
5477
+ ## RSS feeds from a collection
5478
+
5479
+ Declare feeds in `app.config.ts`; the build writes them next to
5480
+ `sitemap.xml`, and `voltro dev` serves the same XML live:
5481
+
5482
+ ```ts
5483
+ export default {
5484
+ // …
5485
+ seo: { siteUrl: 'https://example.com' },
5486
+ feeds: [{
5487
+ path: '/rss.xml',
5488
+ collection: 'posts',
5489
+ title: 'My blog',
5490
+ item: (e) => e.data.draft === 'true' ? null : ({
5491
+ title: e.data.title, link: `/blog/${e.slug}`, date: e.data.date,
5492
+ }),
5493
+ }],
5494
+ }
5495
+ ```
5496
+
5497
+ Returning `null` from `item` excludes an entry — that is the **draft filter**:
5498
+ keep a `draft: true` field in your schema and filter it in `item` and in your
5499
+ page loaders (the changelog template's `visibleReleases` helper is the worked
5500
+ example, including future-dated staging).
5501
+
5502
+ ## No MDX — islands carry the interactivity
5503
+
5504
+ Collection bodies are **markdown, not MDX**: JSX, `import`s and
5505
+ `{expressions}` in a body are not executed. When a content page needs a live
5506
+ widget, the surrounding PAGE provides it via the islands mechanism — the
5507
+ content stays inert HTML and the widget hydrates alone:
5508
+
5509
+ ```tsx
5510
+ // src/pages/blog/[slug]/page.tsx
5511
+ export const interactive = 'islands' as const
5512
+
5513
+ export default function Post() {
5514
+ const post = useLoaderData<ContentEntry<Post>>()
5515
+ return (
5516
+ <main>
5517
+ <ReadingProgress /> {/* an island() component — the ONLY hydrated JS */}
5518
+ <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
5519
+ </main>
5520
+ )
5521
+ }
5522
+ ```
5523
+
5524
+ ## Limits + neighbors
5525
+
5526
+ - **Images referenced from markdown bodies** are copied as-is (no transform):
5527
+ the [image pipeline](/docs/routing/assets) covers `?image` imports from
5528
+ code. Put content images under `public/` and reference them absolutely.
5529
+ - **Files are DEVELOPER content** — versioned with the code, deployed by the
5530
+ build. Editorial content with drafts, roles and a save/publish pipeline is
5531
+ [`@voltro/cms`](/docs/data/cms). Astro's remote "Content Layer loaders"
5532
+ map to `@voltro/cms` here: remote/editorial sources go through the CMS,
5533
+ not through file collections.
5534
+
5535
+
5536
+
5038
5537
  ---
5039
5538
 
5040
5539
  <!-- source: en/data/cms.md -->
@@ -5183,6 +5682,38 @@ const program = Effect.gen(function* () {
5183
5682
  )
5184
5683
  ```
5185
5684
 
5685
+ ### ISR revalidation on publish
5686
+
5687
+ A content type can declare which ISR routes fall when its content is
5688
+ published or unpublished — `publish()`/`unpublish()` fire
5689
+ [`revalidatePath` / `revalidateTag`](/docs/routing/render-modes#on-demand-revalidation)
5690
+ for each entry after the write commits, reaching every `voltro start`
5691
+ replica:
5692
+
5693
+ ```ts
5694
+ import { defineContentType, Schema } from '@voltro/cms'
5695
+
5696
+ const blogPost = defineContentType({
5697
+ name: 'blogPost',
5698
+ displayName: 'Blog post',
5699
+ pluralName: 'Blog posts',
5700
+ fields: {
5701
+ title: Schema.String.pipe(Schema.maxLength(200)),
5702
+ body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
5703
+ },
5704
+ revalidate: {
5705
+ paths: ['/blog/[slug]', '/blog'],
5706
+ tags: ['blog'],
5707
+ },
5708
+ })
5709
+ ```
5710
+
5711
+ You don't need this on postgres for the plain publish case: a route declaring
5712
+ `cacheInvalidatesOn: ['blogPost_published']` is already dropped by CDC when
5713
+ the published table changes. Declare `revalidate` for what CDC can't see —
5714
+ non-postgres dialects, routes whose loaders read the content indirectly, or
5715
+ tag fanout across several routes.
5716
+
5186
5717
  ## The engine, standalone
5187
5718
 
5188
5719
  The validation/derivation engine is pure and exported on its own (also on
@@ -5269,15 +5800,16 @@ list; `mediaFields(type)` lists the top-level media field names.
5269
5800
  ## Versioning content
5270
5801
 
5271
5802
  `@voltro/cms` ships no parallel revision system — the derived tables are
5272
- ordinary database tables, so `@voltro/plugin-versioning` gives full row history
5273
- + time-travel with no new machinery. List the derived table names in the
5274
- plugin's `tables` option, then read a timeline or restore a snapshot:
5803
+ ordinary database tables, so `@voltro/plugin-row-history` gives full row history
5804
+ plus time-travel with no new machinery. The plugin records every table by default
5805
+ narrow it with `include:` (pass the derived table handles) or `exclude:` if you
5806
+ only want content history — then read a timeline or restore a snapshot:
5275
5807
 
5276
5808
  ```ts no-check
5277
- import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
5809
+ import { rowHistoryPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-row-history'
5278
5810
 
5279
5811
  // Register in your app's plugin list:
5280
- versioningPlugin({})
5812
+ rowHistoryPlugin({})
5281
5813
 
5282
5814
  const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
5283
5815
  await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
@@ -5542,3 +6074,118 @@ app's public origin comes from `VOLTRO_PUBLIC_URL`.
5542
6074
  a secret, not a connection.
5543
6075
  - **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
5544
6076
  otherwise every app declaring a connection would ship an open redirector.
6077
+
6078
+
6079
+
6080
+ ---
6081
+
6082
+ <!-- source: en/data/grpc.md -->
6083
+ ## gRPC surface
6084
+
6085
+ _Serve opt-in procedures to generated gRPC clients — proto emitted from your effect/Schema with checked-in field-number stability, unary for mutations/actions, server-streaming for live queries, guards + interceptors + typed errors identical to the socket._
6086
+
6087
+ The gRPC surface serves a NAMED list of your procedures to external gRPC
6088
+ clients — the polyglot-microservice door. The `.proto` is generated from the
6089
+ same `effect/Schema` your procedures already declare, so there is no second
6090
+ contract to maintain; the wire semantics are the framework's own: guards,
6091
+ plugin interceptors and typed errors behave **identically** to the rpc
6092
+ socket, because a gRPC call runs the *same bound runner* every other surface
6093
+ uses (the e2e proves interceptor order side by side).
6094
+
6095
+ ```ts
6096
+ // app.config.ts
6097
+ export default {
6098
+ type: 'api' as const,
6099
+ name: 'api',
6100
+ grpc: {
6101
+ port: 50051,
6102
+ procedures: ['orders.get', 'orders.list', 'orders.create'],
6103
+ // tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
6104
+ },
6105
+ }
6106
+ ```
6107
+
6108
+ NOTHING is exposed by default — every tag is named. Booting writes
6109
+ `.framework/grpc.proto` (hand it to any proto codegen) and mounts
6110
+ `grpc.health.v1` health checking plus server reflection (`grpcurl … list`
6111
+ works out of the box). The gRPC packages ship as script-free optional
6112
+ dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
6113
+ refuses the boot by name.
6114
+
6115
+ ## Field numbers are managed — `grpc.manifest.json`
6116
+
6117
+ Field numbers are the proto wire identity, so they may never depend on
6118
+ property order. They come from a checked-in manifest in your app root:
6119
+
6120
+ - a **new** field gets the next never-used number — an **inserted** field
6121
+ never renumbers its neighbours;
6122
+ - a **deleted** field's number becomes `reserved` (emitted into the proto,
6123
+ so `protoc` refuses a colliding hand-edit too);
6124
+ - **reusing** a reserved number is a codegen error, never a warning — an old
6125
+ client would silently read the wrong field.
6126
+
6127
+ Commit the manifest with the schema change that moved it: the diff review IS
6128
+ the wire-contract review.
6129
+
6130
+ ## The mapping table
6131
+
6132
+ | Schema | proto3 |
6133
+ |---|---|
6134
+ | `Schema.String` / `Number` / `Boolean` | `string` / `double` / `bool` |
6135
+ | integer schemas | `int64` |
6136
+ | `Schema.Array(T)` | `repeated T` |
6137
+ | nested `Schema.Struct` | nested message |
6138
+ | `Schema.Record({ key: String, value: T })` | `map<string, T>` |
6139
+ | `Schema.optional(T)` **and** `Schema.NullOr(T)` | `optional T` — absent and `null` are ONE wire state (proto3 presence) |
6140
+ | string-literal unions | `string` (validated server-side on decode) |
6141
+ | unions of shapes, tuples, recursion, free-form objects | a LOUD per-procedure codegen error naming the schema path |
6142
+
6143
+ Requests are decoded against the descriptor's input schema before the
6144
+ executor runs — proto3 suppresses default values on the wire, and without
6145
+ that decode an empty string would arrive as an absent field and fail
6146
+ somewhere much later.
6147
+
6148
+ ## Status codes — complete against the wire error union
6149
+
6150
+ | outcome | gRPC status | trailers |
6151
+ |---|---|---|
6152
+ | no credential on a guarded call | `UNAUTHENTICATED` | |
6153
+ | presented-and-rejected credential | `UNAUTHENTICATED` | |
6154
+ | authenticated, missing scope (`ScopeError`) | `PERMISSION_DENIED` | `voltro-error: scope` |
6155
+ | input fails the schema | `INVALID_ARGUMENT` | `voltro-error: input` |
6156
+ | `BusinessRuleViolation` | `FAILED_PRECONDITION` | `voltro-error: rule` |
6157
+ | `requiresApproval` pending — a FLOW OUTCOME, not a failure | `FAILED_PRECONDITION` | `voltro-pending: approval` + `voltro-approval-id` |
6158
+ | your declared typed error | `FAILED_PRECONDITION` | `voltro-error: <tag>` |
6159
+ | deadline exceeded | `DEADLINE_EXCEEDED` | |
6160
+ | anything else | `INTERNAL` | |
6161
+
6162
+ **Deadlines interrupt the work.** A client deadline (`grpc-timeout`) aborts
6163
+ the executor's fiber through the request signal — the server stops doing the
6164
+ work, it does not merely suppress the response (the e2e pins this with a
6165
+ sleeping action whose post-sleep write never lands).
6166
+
6167
+ ## Streaming queries
6168
+
6169
+ A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
6170
+ snapshot, re-pushed live when the query's `source:` changes — subscribe,
6171
+ mutate from anywhere, and the open stream receives the new frame with no
6172
+ re-request. The per-delivery guard re-check applies (a revoked scope ends
6173
+ the stream with the mapped status), and slow consumers are handled through
6174
+ grpc-js write backpressure — frames coalesce to the latest snapshot rather
6175
+ than buffering unboundedly.
6176
+
6177
+ ## Declared limits (v1)
6178
+
6179
+ - **No client- or bidi-streaming**, and `*.stream.ts` procedures are NOT
6180
+ exposable — the fourth kind is a one-shot element stream with its own
6181
+ semantics; put it behind a query or keep it on the socket.
6182
+ - **No gRPC-Web** — a browser talks the framework's own subscription
6183
+ protocol (that is the better browser transport in every dimension we care
6184
+ about); gRPC is for backends.
6185
+ - **No Connect protocol** — connectrpc is NOT gRPC-Web; a connect consumer's
6186
+ alternative today is the [REST/OpenAPI projection](/docs/data/rest-routes).
6187
+ - App realtime stays on the framework's subscription protocol, and gateways
6188
+ exist for the other case: a FOREIGN protocol that needs a socket the
6189
+ framework does not speak — the same boundary
6190
+ [data/subscriptions](/docs/data/subscriptions) draws for raw WebSocket
6191
+ gateways, one sentence, two doors.