@voltro/cli 0.52.0 → 0.54.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 (244) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  4. package/dist/agentsMd-DCY1RSs8.js +2 -0
  5. package/dist/apiBuild-CeUN55uk.js +2 -0
  6. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D4ygSbnV.js +843 -0
  9. package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
  10. package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
  11. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  12. package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
  13. package/dist/codegen-DjgxEOnD.js +2 -0
  14. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  15. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
  16. package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
  17. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
  18. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
  19. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
  20. package/dist/dbCommand-CSFWs9ev.js +2 -0
  21. package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
  22. package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
  23. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
  24. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  25. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
  26. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  27. package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
  28. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
  29. package/dist/fileConventions-l-RIXbx8.js +36 -0
  30. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  34. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +2 -2
  38. package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.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/manifestBuild-C4-J1-m_.js +2 -0
  43. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  44. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
  45. package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
  46. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  47. package/dist/mobileCommand-DAum7tsG.js +2 -0
  48. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
  49. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
  50. package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
  51. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  52. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  53. package/dist/renderModeScan-43yQ2opo.js +147 -0
  54. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
  55. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  56. package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
  57. package/dist/serveCommand-BiPe8BJm.js +2 -0
  58. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
  59. package/dist/serveEntry.js +1 -1
  60. package/dist/start-B0bnJgxI.js +3 -0
  61. package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
  62. package/dist/startEntry.js +1 -1
  63. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
  64. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  65. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  66. package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
  67. package/dist/updateCommand-CIoVDKnj.js +2 -0
  68. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
  69. package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
  70. package/dist/webDev-DlvZO30c.js +2 -0
  71. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
  72. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  73. package/package.json +60 -19
  74. package/templates/AGENTS.core.md +2 -0
  75. package/templates/AGENTS.md +8 -4
  76. package/templates/agent-docs/_index.md +6 -4
  77. package/templates/agent-docs/_manifest.json +21 -5
  78. package/templates/agent-docs/ai.md +6 -6
  79. package/templates/agent-docs/authentication.md +73 -1
  80. package/templates/agent-docs/cli.md +6 -3
  81. package/templates/agent-docs/configuration.md +17 -0
  82. package/templates/agent-docs/data.md +522 -29
  83. package/templates/agent-docs/database/advancedqueries.md +8 -8
  84. package/templates/agent-docs/database/columntypes.md +2 -2
  85. package/templates/agent-docs/database/migrations.md +1 -1
  86. package/templates/agent-docs/database/querying.md +1 -1
  87. package/templates/agent-docs/database/schema.md +1 -1
  88. package/templates/agent-docs/database/seedsdialects.md +65 -3
  89. package/templates/agent-docs/database/transactions.md +3 -3
  90. package/templates/agent-docs/deployment.md +8 -0
  91. package/templates/agent-docs/internationalization.md +4 -2
  92. package/templates/agent-docs/introduction.md +32 -1
  93. package/templates/agent-docs/local-first-mobile.md +226 -30
  94. package/templates/agent-docs/observability.md +5 -1
  95. package/templates/agent-docs/plugins/atlassian.md +2 -2
  96. package/templates/agent-docs/plugins/audit.md +2 -2
  97. package/templates/agent-docs/plugins/auth.md +1 -1
  98. package/templates/agent-docs/plugins/billing.md +1 -1
  99. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  100. package/templates/agent-docs/plugins/comments.md +164 -0
  101. package/templates/agent-docs/plugins/notifications.md +47 -4
  102. package/templates/agent-docs/plugins/presence.md +45 -3
  103. package/templates/agent-docs/plugins/prometheus.md +3 -1
  104. package/templates/agent-docs/plugins/queue.md +172 -0
  105. package/templates/agent-docs/plugins.md +17 -13
  106. package/templates/agent-docs/reference.md +35 -4
  107. package/templates/agent-docs/routing.md +585 -7
  108. package/templates/agent-docs/scheduling.md +1 -1
  109. package/templates/agent-docs/schema-driven-ui.md +226 -4
  110. package/templates/agent-docs/security.md +3 -3
  111. package/templates/agent-docs/templates/appshells.md +36 -4
  112. package/templates/agent-docs/whats-new.md +134 -66
  113. package/templates/apps/api-ai/package.json +6 -6
  114. package/templates/apps/api-auth/package.json +8 -8
  115. package/templates/apps/api-backend/package.json +7 -7
  116. package/templates/apps/api-backend-deactivation/package.json +7 -7
  117. package/templates/apps/api-backend-mail/package.json +8 -8
  118. package/templates/apps/api-backend-mariadb/package.json +9 -9
  119. package/templates/apps/api-backend-sqlite/package.json +8 -8
  120. package/templates/apps/api-backend-storage/package.json +8 -8
  121. package/templates/apps/api-cms/package.json +9 -9
  122. package/templates/apps/api-collab/README.md +3 -3
  123. package/templates/apps/api-collab/app.config.ts +1 -1
  124. package/templates/apps/api-collab/database/schema.ts +12 -8
  125. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  126. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  127. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  128. package/templates/apps/api-collab/package.json +8 -8
  129. package/templates/apps/api-collab/template.json +1 -1
  130. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  131. package/templates/apps/api-data-advanced/package.json +8 -8
  132. package/templates/apps/api-durable/package.json +8 -8
  133. package/templates/apps/api-feature-flags/package.json +9 -9
  134. package/templates/apps/api-governance/package.json +8 -8
  135. package/templates/apps/api-kv/package.json +8 -8
  136. package/templates/apps/api-moderation/package.json +8 -8
  137. package/templates/apps/api-observability/package.json +8 -8
  138. package/templates/apps/api-ratelimit/package.json +8 -8
  139. package/templates/apps/api-rbac/package.json +8 -8
  140. package/templates/apps/api-rest/package.json +7 -7
  141. package/templates/apps/api-row-history/package.json +8 -8
  142. package/templates/apps/api-saas/package.json +11 -10
  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 +9 -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/README.md +43 -24
  168. package/templates/apps/frontend-collab/app.config.ts +3 -3
  169. package/templates/apps/frontend-collab/package.json +14 -10
  170. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  171. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  172. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  173. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  174. package/templates/apps/frontend-collab/template.json +2 -2
  175. package/templates/apps/frontend-contact/package.json +7 -7
  176. package/templates/apps/frontend-dashboard/package.json +7 -7
  177. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  178. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  179. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  180. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  181. package/templates/apps/frontend-docs/package.json +9 -6
  182. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  183. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  184. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  185. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  186. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  187. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  188. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  189. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  190. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  191. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  192. package/templates/apps/frontend-i18n/package.json +6 -6
  193. package/templates/apps/frontend-landing/README.md +48 -0
  194. package/templates/apps/frontend-landing/app.config.ts +28 -0
  195. package/templates/apps/frontend-landing/package.json +7 -6
  196. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  197. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  198. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  199. package/templates/apps/frontend-landing/src/globals.css +15 -0
  200. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  201. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  202. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  203. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  204. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  205. package/templates/apps/frontend-landing/template.json +2 -2
  206. package/templates/apps/frontend-portal/package.json +8 -8
  207. package/templates/apps/frontend-saas/package.json +8 -8
  208. package/templates/apps/frontend-spa/package.json +7 -7
  209. package/templates/apps/frontend-ssr/package.json +7 -7
  210. package/templates/apps/frontend-ssr-api/package.json +8 -8
  211. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  212. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  213. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  214. package/templates/apps/frontend-static-blog/package.json +9 -6
  215. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  216. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  217. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  218. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  219. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  220. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  221. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  222. package/templates/apps/frontend-status/package.json +8 -8
  223. package/templates/apps/mobile-app/package.json +12 -11
  224. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  225. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  226. package/dist/agentsMd-SDDSkyl4.js +0 -2
  227. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  228. package/dist/build-CPgcMQug.js +0 -793
  229. package/dist/codegen-CctkDO-1.js +0 -2
  230. package/dist/codegenCommand-DCdG2JN-.js +0 -137
  231. package/dist/dbCommand-DNb6yeOG.js +0 -2
  232. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  233. package/dist/fileConventions-DOqD3lPS.js +0 -34
  234. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  235. package/dist/inspect-CuGDYES0.js +0 -2
  236. package/dist/manifestBuild-CPjhvM62.js +0 -2
  237. package/dist/renderModeScan-CcH2X1_D.js +0 -120
  238. package/dist/serveCommand-DLc-BznW.js +0 -2
  239. package/dist/start-DfL3fOiN.js +0 -3
  240. package/dist/updateCommand-5gFVfK5q.js +0 -2
  241. package/dist/webDev-CZbTsDcH.js +0 -2
  242. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  243. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  244. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -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
@@ -1683,7 +1717,7 @@ created by every migration and diffed on every boot.
1683
1717
 
1684
1718
  The other tempting option is to point `source:` at a name that resolves to
1685
1719
  nothing. That is worse than the empty table: the [stale-`source` boot
1686
- warning](#fan-out--how-many-subscribers-may-one-change-wake) is the only signal
1720
+ warning](#fan-out-how-many-subscribers-may-one-change-wake) is the only signal
1687
1721
  for a subscription that has gone permanently quiet, and an exemption for a name
1688
1722
  you invented disables it for the one case it was built for.
1689
1723
 
@@ -1813,7 +1847,7 @@ Two consequences worth knowing:
1813
1847
  - **Not free per subscriber.** A publish wakes every subscriber of that channel
1814
1848
  and re-runs each one's executor; the channel is one routing key, so
1815
1849
  subscribers looking at different slices of the state are woken too. Publish on
1816
- a real change, not on a timer — see [Fan-out](#fan-out--how-many-subscribers-may-one-change-wake).
1850
+ a real change, not on a timer — see [Fan-out](#fan-out-how-many-subscribers-may-one-change-wake).
1817
1851
 
1818
1852
  ## Query Executor
1819
1853
 
@@ -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
@@ -2123,6 +2172,15 @@ subscriber on every delivery, on purpose — a role revoked or a share withdrawn
2123
2172
  to end the stream on the very NEXT delivery, not whenever a cache happens to
2124
2173
  expire — and each of them can be a database round-trip.
2125
2174
 
2175
+ **On every transport.** A live query can leave the server three ways — the
2176
+ WebSocket the browser client uses, an [SSE
2177
+ stream](/docs/data/rest-routes#live-updates-over-http-stream-sse), and a
2178
+ [gRPC](/docs/data/grpc) server-streaming rpc — and all three resolve per
2179
+ delivery through the same code: guards re-checked before each frame, row
2180
+ visibility re-derived from the unfiltered base descriptor for each frame, and a
2181
+ revoked scope ending the stream. The transport decides how the frame is
2182
+ framed, never what the subject may see.
2183
+
2126
2184
  So deliveries run **concurrently, up to a bound**. The default is 8 in flight.
2127
2185
  Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them
2128
2186
  serially, 72 ms at 8 lanes.
@@ -2158,32 +2216,40 @@ once. Order BETWEEN subscribers was never guaranteed.
2158
2216
  There is a number, it is not a constant, and which number you get depends on a
2159
2217
  property of your **queries** rather than of your scale. Re-derive it on your own
2160
2218
  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).
2219
+ below are the spread across three runs on a busy developer machine (12-core,
2220
+ macOS) at 10 matched writes per second, against a budget of 100 ms of
2221
+ event-loop time per second (10% of one core).
2164
2222
 
2165
2223
  | Subscriber population | Marginal CPU per subscriber | Subscribers per node |
2166
2224
  | --- | --- | --- |
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.
2225
+ | **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 |
2226
+ | **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 |
2227
+
2228
+ The shared case fans out by design one read and one diff (the memoisation
2229
+ above), then N emits and it is the shape that sets the ceiling. The distinct
2230
+ case changed shape entirely with **matcher authority**: a subscription whose
2231
+ query is a plain predicate read is woken by the predicate index alone, so a
2232
+ write to *somebody else's* row is a non-event, not a wake. Per write it costs a
2233
+ bucket lookup plus one delivery, regardless of how many thousands of distinct
2234
+ subscribers are resident. Measured: **0 of 200** subscribers whose predicate
2235
+ matched nothing were woken by a write on their table **a selective `where`
2236
+ buys real headroom** now.
2237
+
2238
+ What still wakes conservatively (any change on the table), and deliberately
2239
+ this list is exhaustive:
2240
+
2241
+ - queries with an **eager `.with()` spec**, a setOp (`union`/…) or a CTE — the
2242
+ handler reads rows the root predicate does not describe;
2243
+ - **`dependsOn`** raw reads (the dispatcher can only re-run the descriptor,
2244
+ never the handler);
2245
+ - **computed** queries and **`reactivityChannel`** queries (their own
2246
+ recompute paths, unchanged);
2247
+ - **oversized change events** (`tombstone` / `unrecovered` — row images the
2248
+ matcher cannot see wake the whole table for that one event; `rehydrated`
2249
+ events are judged normally).
2250
+
2251
+ One thing that is easy to assume and is not true:
2252
+
2187
2253
  - **It is not 512.** That constant bounds `onChange` LISTENERS — one per declared
2188
2254
  subscription file, reaction or aggregate, bound once at boot. Every client
2189
2255
  subscription in a process shares the dispatcher's single listener, so ten
@@ -2195,6 +2261,17 @@ mysql/mariadb (binlog), so a second node needs no extra wiring — the cost bein
2195
2261
  budgeted here is the matcher and re-query CPU each node spends on ITS OWN
2196
2262
  clients.
2197
2263
 
2264
+ ### Deltas are per-query — two queries can briefly diverge
2265
+
2266
+ Every subscription has its own revision line and its own delivery moment. After
2267
+ one write that affects two queries you hold open, the deltas arrive as two
2268
+ independent pushes — usually microseconds apart, but there is no cross-query
2269
+ transaction on the wire, and a render between the two pushes can see query A
2270
+ after the write and query B before it. Within ONE query you never see a partial
2271
+ write (a delta is computed from a committed row set); across queries, design for
2272
+ eventual agreement rather than instantaneous consistency — derive values that
2273
+ must agree atomically inside one query instead of joining two on the client.
2274
+
2198
2275
  ## Raw WebSocket gateways — `defineWebSocket`
2199
2276
 
2200
2277
  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:
@@ -3137,7 +3214,9 @@ es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
3137
3214
 
3138
3215
  Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
3139
3216
 
3140
- Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
3217
+ Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the [row filter](/docs/authentication/row-level-security) and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
3218
+
3219
+ And they run before **every** event, not only before the first one: the guards are re-checked and the subject's row visibility is re-resolved from the unfiltered base descriptor per delivery, so a scope revoked while the `EventSource` is open ends the stream on the next event, and a membership that ends stops carrying those rows in the next `delta`. An open SSE stream is not a cheaper read path than a fresh `GET`.
3141
3220
 
3142
3221
  `stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
3143
3222
 
@@ -4854,7 +4933,7 @@ A streaming query (what `useSubscription` opens) emits a sequence of **subscript
4854
4933
  { _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
4855
4934
  ```
4856
4935
 
4857
- - **`revision`** — monotonically increasing; lets the client order events and detect gaps.
4936
+ - **`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.
4858
4937
  - **`emittedAt`** — epoch milliseconds, present on `delta` only.
4859
4938
  - **`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).
4860
4939
  - **`patch`** (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
@@ -4873,6 +4952,82 @@ propagate to the shared connection and stall every *other* subscription on it
4873
4952
  client surfaces it as `useSubscription(...).error` for that one query key;
4874
4953
  siblings keep delivering their snapshots and deltas.
4875
4954
 
4955
+ ### Slow consumers — coalescing and `SubscriptionOverrun`
4956
+
4957
+ A consumer that stops reading (a backgrounded tab, a saturated link) does not
4958
+ grow the server without bound. While its socket is blocked, updates
4959
+ **coalesce**: the server keeps only the newest state per subscription and, when
4960
+ the socket accepts again, sends ONE event — a patch against the last state the
4961
+ consumer was actually handed (`revision` jumps accordingly, see above). Memory
4962
+ per blocked subscription is bounded by construction: one pending state,
4963
+ regardless of how far behind the consumer is.
4964
+
4965
+ A consumer that stays more than `reactive.socket.maxBufferedBytes` (default
4966
+ 1 MiB, env `VOLTRO_REACTIVE_MAX_BUFFERED_BYTES`) behind for
4967
+ `reactive.socket.overrunAfterMs` (default 10 s) is closed **loudly**: it
4968
+ receives an `error` event with `error._tag: 'SubscriptionOverrun'` (carrying
4969
+ `bufferedBytes` + `maxBufferedBytes`) and the stream ends — never a silent
4970
+ drop. The client re-subscribes and starts from a fresh snapshot.
4971
+
4972
+ Oversized events are telemetry, not a cap: an event over
4973
+ `reactive.socket.oversizedEventBytes` (default 256 KiB) is delivered normally
4974
+ and counted (`voltro_subscription_oversized_total`) with a WARN naming the
4975
+ query — alongside `voltro_subscription_buffered_bytes`,
4976
+ `voltro_subscription_coalesced_total` and `voltro_subscription_overrun_total`
4977
+ in the Prometheus exporter and the inspect Metrics panel.
4978
+
4979
+ ### Reconnect — delta-resume
4980
+
4981
+ A client that reconnects inside the **resume window** does not have to pay for
4982
+ a full snapshot: it sends the last `revision` it materialised in the per-call
4983
+ `voltro-resume-from` request header (the same header surface the idempotency
4984
+ key rides), and the server — which kept the subscription alive server-side for
4985
+ the window after the disconnect — **replays only the deltas that were missed**
4986
+ and re-attaches the stream on the SAME revision line.
4987
+
4988
+ The signal is the first event's tag, not a schema field:
4989
+
4990
+ - **first event `delta`** — the resume was honoured; apply the patch onto the
4991
+ rows you already hold and continue.
4992
+ - **first event `snapshot`** — the resume was declined; reset to the snapshot.
4993
+ This is the answer whenever anything is in doubt, because a wrong snapshot
4994
+ costs bytes while a wrong replay would leak rows.
4995
+
4996
+ `@voltro/client` does both automatically — the reconnect-seeded cache keeps its
4997
+ rows and revision, presents the header, and treats a snapshot-first stream as
4998
+ the reset it already knows how to do. Replayed deltas may **coalesce** exactly
4999
+ as slow-consumer updates do (revisions jump; patch continuity holds).
5000
+
5001
+ A resume is declined — always with a fresh snapshot — when:
5002
+
5003
+ - the window expired (`reactive.resume.windowMs`, default 60 s, env
5004
+ `VOLTRO_REACTIVE_RESUME_WINDOW_MS`), or more deltas were missed than the ring
5005
+ retains (`reactive.resume.maxDeltas`, default 256, env
5006
+ `VOLTRO_REACTIVE_RESUME_MAX_DELTAS`);
5007
+ - the query's `guards:` were revoked while the client was away — the
5008
+ per-delivery re-check keeps running on the detached subscription, and a
5009
+ revocation drops the retained history outright;
5010
+ - the resuming caller is a different subject or tenant (a login, logout or
5011
+ tenant switch between disconnect and resume) — the retained history is keyed
5012
+ by subject AND tenant, so a changed identity simply never finds it;
5013
+ - the query is a **computed** query — it re-runs a handler, so there is no
5014
+ delta chain to replay;
5015
+ - a registered row filter (`setRowFilter`) can narrow THIS subscription's
5016
+ source table, or the query declares an eager `.with(...)`. A row-filtered
5017
+ subscription's visible row set exists only per delivery, so replaying it
5018
+ could serve rows the subject has since lost.
5019
+
5020
+ **This is per table, not per app.** A filter that declares
5021
+ `tables: [...]` (see [row-level security](/docs/authentication/row-level-security))
5022
+ keeps delta-resume on every subscription whose source is not in that set —
5023
+ the common case, since most filters narrow a handful of tables. Without the
5024
+ declaration the framework cannot know which tables the predicate may reach
5025
+ and excludes them all, which is what a deployment measured as one filter over
5026
+ 4 tables costing the feature on all 173 of their queries. Eager loads are
5027
+ excluded wholesale because a relation resolves below the seam that narrows.
5028
+ They reconnect with a fresh
5029
+ snapshot, exactly as before.
5030
+
4876
5031
  **Author a live-subscribed getter to return, not throw.** A subscription is a
4877
5032
  long-lived stream, so a getter that throws on every re-evaluation is a broken
4878
5033
  stream. For an expected-absent row, make the query `output: Schema.NullOr(...)`
@@ -5205,6 +5360,203 @@ try {
5205
5360
 
5206
5361
 
5207
5362
 
5363
+ ---
5364
+
5365
+ <!-- source: en/data/content-collections.md -->
5366
+ ## Content collections
5367
+
5368
+ _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._
5369
+
5370
+ A content collection turns a folder of markdown files into typed, rendered
5371
+ content: you declare the frontmatter schema in code, and `getCollection()` /
5372
+ `getEntry()` hand you decoded data plus server-rendered HTML with
5373
+ syntax-highlighted code fences. The markdown engine lives in the framework —
5374
+ **do not install your own `marked` / `remark` / `shiki`**; a second pipeline
5375
+ drifts from the one your artifacts, feeds and templates already use.
5376
+
5377
+ ## A blog in 20 lines
5378
+
5379
+ One collection file, one markdown file, one page:
5380
+
5381
+ ```ts
5382
+ // src/collections/posts.collection.ts
5383
+ import { Schema } from 'effect'
5384
+ import { defineCollection } from '@voltro/content'
5385
+
5386
+ export const posts = defineCollection({
5387
+ name: 'posts',
5388
+ directory: 'content/posts',
5389
+ schema: Schema.Struct({ title: Schema.String, date: Schema.String }),
5390
+ })
5391
+ export type Post = Schema.Schema.Type<typeof posts.schema>
5392
+ ```
5393
+
5394
+ ```ts
5395
+ // src/pages/blog/[slug]/page.tsx
5396
+ import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
5397
+ import { useLoaderData } from '@voltro/web'
5398
+ import { posts, type Post } from '../../../collections/posts.collection'
5399
+
5400
+ export const renderMode = 'static' as const
5401
+ export const getStaticPaths = async () =>
5402
+ (await getCollection(posts.name)).map((e) => ({ params: { slug: e.slug } }))
5403
+ export const loader = async ({ params }: { params: { slug: string } }) =>
5404
+ await getEntry<Post>(posts.name, params.slug)
5405
+
5406
+ export default function Post() {
5407
+ const post = useLoaderData<ContentEntry<Post> | null>()
5408
+ if (!post) return <main>Not found</main>
5409
+ return <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
5410
+ }
5411
+ ```
5412
+
5413
+ Drop `content/posts/hello.md` with `title:` + `date:` frontmatter and the
5414
+ build pre-renders `/blog/hello` — highlighted code fences included.
5415
+
5416
+ ## How it stays out of your bundle
5417
+
5418
+ The loader is **isomorphic**. At build/SSR time it reads the filesystem and
5419
+ renders markdown (shiki runs on the server only). The build also emits JSON
5420
+ artifacts under `dist/assets/content/<name>[.<locale>]/…` — an index (slugs +
5421
+ frontmatter, no bodies) and one file per entry (rendered HTML + headings). On
5422
+ an SPA navigation, the CLIENT branch of `getCollection`/`getEntry` fetches
5423
+ those artifacts. The result: no markdown engine, no highlighter, and no
5424
+ content bodies in your JavaScript bundle. `voltro dev` serves the same
5425
+ artifact shapes on demand and invalidates them when a `content/**` file
5426
+ changes.
5427
+
5428
+ ## Frontmatter is a schema, and violations fail the build
5429
+
5430
+ The `schema` is an `effect/Schema` struct decoded per file. A missing or
5431
+ mistyped field is a **build error naming the file** — not a page that renders
5432
+ `undefined`. Numbers in frontmatter arrive as strings; use
5433
+ `Schema.Union(Schema.NumberFromString, Schema.Number)` for numeric fields.
5434
+ `getCollection<A>` returns entries whose `data` is the schema's inferred
5435
+ type — no casts.
5436
+
5437
+ ## Slugs come from the path
5438
+
5439
+ `content/posts/hello.md` → `hello`; nested folders stay in the slug
5440
+ (`database/joins.md` → `database/joins`). Two files resolving to one slug
5441
+ (a rename that left both) is a build error.
5442
+
5443
+ ## Locale trees + fallback
5444
+
5445
+ A collection with `i18n` treats the first path segment as the locale:
5446
+
5447
+ ```ts
5448
+ export const docs = defineCollection({
5449
+ name: 'docs',
5450
+ directory: 'content/docs',
5451
+ schema: Schema.Struct({ title: Schema.String }),
5452
+ i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
5453
+ })
5454
+ ```
5455
+
5456
+ `getCollection('docs', { locale: 'de' })` reads the `de/` tree. A slug missing
5457
+ in the requested locale is served from the default tree with
5458
+ `fallback: true` on the entry (render an "untranslated" banner off it) — or
5459
+ omitted entirely with `missing: 'missing'`. Incomplete translations are the
5460
+ normal case; decide the policy per collection instead of improvising per page.
5461
+
5462
+ ## Headings as data
5463
+
5464
+ Every rendered entry carries `headings: [{ depth, slug, text }]` — the TOC
5465
+ input. The slugs are the SAME ids stamped on the rendered `<h2 id="…">`
5466
+ elements, so sidebar anchors never drift from the body. For a TOC without a
5467
+ render pass, `extractHeadings(markdown)` (from `@voltro/content/markdown`)
5468
+ computes the same data synchronously.
5469
+
5470
+ ## Data collections
5471
+
5472
+ `kind: 'data'` reads `.json` files instead of markdown — the `authors.json`
5473
+ case. Each file decodes whole against the schema; there is no render path:
5474
+
5475
+ ```ts
5476
+ export const authors = defineCollection({
5477
+ name: 'authors',
5478
+ directory: 'content/authors',
5479
+ kind: 'data',
5480
+ schema: Schema.Struct({ name: Schema.String, url: Schema.String }),
5481
+ })
5482
+ ```
5483
+
5484
+ ## References between collections
5485
+
5486
+ `reference('<collection>')` declares a frontmatter field that names an entry
5487
+ of another collection by slug:
5488
+
5489
+ ```ts
5490
+ schema: Schema.Struct({
5491
+ title: Schema.String,
5492
+ author: reference('authors'),
5493
+ })
5494
+ ```
5495
+
5496
+ The build validates every reference — a dangling one (`author: nobody`) fails
5497
+ the build naming the collection, entry, field and target. Resolve it with
5498
+ `getEntry('authors', entry.data.author)`.
5499
+
5500
+ ## RSS feeds from a collection
5501
+
5502
+ Declare feeds in `app.config.ts`; the build writes them next to
5503
+ `sitemap.xml`, and `voltro dev` serves the same XML live:
5504
+
5505
+ ```ts
5506
+ export default {
5507
+ // …
5508
+ seo: { siteUrl: 'https://example.com' },
5509
+ feeds: [{
5510
+ path: '/rss.xml',
5511
+ collection: 'posts',
5512
+ title: 'My blog',
5513
+ item: (e) => e.data.draft === 'true' ? null : ({
5514
+ title: e.data.title, link: `/blog/${e.slug}`, date: e.data.date,
5515
+ }),
5516
+ }],
5517
+ }
5518
+ ```
5519
+
5520
+ Returning `null` from `item` excludes an entry — that is the **draft filter**:
5521
+ keep a `draft: true` field in your schema and filter it in `item` and in your
5522
+ page loaders (the changelog template's `visibleReleases` helper is the worked
5523
+ example, including future-dated staging).
5524
+
5525
+ ## No MDX — islands carry the interactivity
5526
+
5527
+ Collection bodies are **markdown, not MDX**: JSX, `import`s and
5528
+ `{expressions}` in a body are not executed. When a content page needs a live
5529
+ widget, the surrounding PAGE provides it via the islands mechanism — the
5530
+ content stays inert HTML and the widget hydrates alone:
5531
+
5532
+ ```tsx
5533
+ // src/pages/blog/[slug]/page.tsx
5534
+ export const interactive = 'islands' as const
5535
+
5536
+ export default function Post() {
5537
+ const post = useLoaderData<ContentEntry<Post>>()
5538
+ return (
5539
+ <main>
5540
+ <ReadingProgress /> {/* an island() component — the ONLY hydrated JS */}
5541
+ <article dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
5542
+ </main>
5543
+ )
5544
+ }
5545
+ ```
5546
+
5547
+ ## Limits + neighbors
5548
+
5549
+ - **Images referenced from markdown bodies** are copied as-is (no transform):
5550
+ the [image pipeline](/docs/routing/assets) covers `?image` imports from
5551
+ code. Put content images under `public/` and reference them absolutely.
5552
+ - **Files are DEVELOPER content** — versioned with the code, deployed by the
5553
+ build. Editorial content with drafts, roles and a save/publish pipeline is
5554
+ [`@voltro/cms`](/docs/data/cms). Astro's remote "Content Layer loaders"
5555
+ map to `@voltro/cms` here: remote/editorial sources go through the CMS,
5556
+ not through file collections.
5557
+
5558
+
5559
+
5208
5560
  ---
5209
5561
 
5210
5562
  <!-- source: en/data/cms.md -->
@@ -5745,3 +6097,144 @@ app's public origin comes from `VOLTRO_PUBLIC_URL`.
5745
6097
  a secret, not a connection.
5746
6098
  - **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
5747
6099
  otherwise every app declaring a connection would ship an open redirector.
6100
+
6101
+
6102
+
6103
+ ---
6104
+
6105
+ <!-- source: en/data/grpc.md -->
6106
+ ## gRPC surface
6107
+
6108
+ _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._
6109
+
6110
+ The gRPC surface serves a NAMED list of your procedures to external gRPC
6111
+ clients — the polyglot-microservice door. The `.proto` is generated from the
6112
+ same `effect/Schema` your procedures already declare, so there is no second
6113
+ contract to maintain; the wire semantics are the framework's own: guards,
6114
+ plugin interceptors and typed errors behave **identically** to the rpc
6115
+ socket, because a gRPC call runs the *same bound runner* every other surface
6116
+ uses (the e2e proves interceptor order side by side).
6117
+
6118
+ ```ts
6119
+ // app.config.ts
6120
+ export default {
6121
+ type: 'api' as const,
6122
+ name: 'api',
6123
+ grpc: {
6124
+ port: 50051,
6125
+ procedures: ['orders.get', 'orders.list', 'orders.create'],
6126
+ // tls: { certPath, keyPath, caPath? } — plaintext without it (dev / mesh).
6127
+ // drainMs: 5000, // shutdown drain budget — see below
6128
+ // maxMessageBytes, maxMetadataBytes — grpc-js frame limits
6129
+ },
6130
+ }
6131
+ ```
6132
+
6133
+ NOTHING is exposed by default — every tag is named. Booting writes
6134
+ `.framework/grpc.proto` (hand it to any proto codegen) and mounts
6135
+ `grpc.health.v1` health checking plus server reflection (`grpcurl … list`
6136
+ works out of the box). The gRPC packages ship as script-free optional
6137
+ dependencies of `@voltro/cli`; a configured `grpc:` block with them missing
6138
+ refuses the boot by name.
6139
+
6140
+ ## Shutdown drains, then forces — `drainMs`
6141
+
6142
+ On SIGTERM the surface flips its health status to `NOT_SERVING` (so a load
6143
+ balancer stops sending it work) and gives open calls **`drainMs`** to finish
6144
+ before force-closing them. Default `5000`; `0` forces immediately; the env
6145
+ override is `VOLTRO_GRPC_DRAIN_MS`.
6146
+
6147
+ Pick it from two numbers only you have. Keep it **below** your orchestrator's
6148
+ termination grace (`terminationGracePeriodSeconds`, `docker stop -t`) — past
6149
+ that point SIGKILL arrives and the drain never completes, so a larger budget
6150
+ buys nothing. Keep it **above** your longest legitimately in-flight unary
6151
+ call, or every rolling deploy force-closes work that would have finished. When
6152
+ the budget is exceeded the surface says so in a warning naming the budget,
6153
+ rather than leaking the port into the next boot.
6154
+
6155
+ ## Field numbers are managed — `grpc.manifest.json`
6156
+
6157
+ Field numbers are the proto wire identity, so they may never depend on
6158
+ property order. They come from a checked-in manifest in your app root:
6159
+
6160
+ - a **new** field gets the next never-used number — an **inserted** field
6161
+ never renumbers its neighbours;
6162
+ - a **deleted** field's number becomes `reserved` (emitted into the proto,
6163
+ so `protoc` refuses a colliding hand-edit too);
6164
+ - **reusing** a reserved number is a codegen error, never a warning — an old
6165
+ client would silently read the wrong field.
6166
+
6167
+ Commit the manifest with the schema change that moved it: the diff review IS
6168
+ the wire-contract review.
6169
+
6170
+ ## The mapping table
6171
+
6172
+ | Schema | proto3 |
6173
+ |---|---|
6174
+ | `Schema.String` / `Number` / `Boolean` | `string` / `double` / `bool` |
6175
+ | integer schemas | `int64` |
6176
+ | `Schema.Array(T)` | `repeated T` |
6177
+ | nested `Schema.Struct` | nested message |
6178
+ | `Schema.Record({ key: String, value: T })` | `map<string, T>` |
6179
+ | `Schema.optional(T)` **and** `Schema.NullOr(T)` | `optional T` — absent and `null` are ONE wire state (proto3 presence) |
6180
+ | string-literal unions | `string` (validated server-side on decode) |
6181
+ | unions of shapes, tuples, recursion, free-form objects | a LOUD per-procedure codegen error naming the schema path |
6182
+
6183
+ Requests are decoded against the descriptor's input schema before the
6184
+ executor runs — proto3 suppresses default values on the wire, and without
6185
+ that decode an empty string would arrive as an absent field and fail
6186
+ somewhere much later.
6187
+
6188
+ ## Status codes — complete against the wire error union
6189
+
6190
+ | outcome | gRPC status | trailers |
6191
+ |---|---|---|
6192
+ | no credential on a guarded call | `UNAUTHENTICATED` | |
6193
+ | presented-and-rejected credential | `UNAUTHENTICATED` | |
6194
+ | authenticated, missing scope (`ScopeError`) | `PERMISSION_DENIED` | `voltro-error: scope` |
6195
+ | input fails the schema | `INVALID_ARGUMENT` | `voltro-error: input` |
6196
+ | `BusinessRuleViolation` | `FAILED_PRECONDITION` | `voltro-error: rule` |
6197
+ | `requiresApproval` pending — a FLOW OUTCOME, not a failure | `FAILED_PRECONDITION` | `voltro-pending: approval` + `voltro-approval-id` |
6198
+ | your declared typed error | `FAILED_PRECONDITION` | `voltro-error: <tag>` |
6199
+ | deadline exceeded | `DEADLINE_EXCEEDED` | |
6200
+ | anything else | `INTERNAL` | |
6201
+
6202
+ **Deadlines interrupt the work.** A client deadline (`grpc-timeout`) aborts
6203
+ the executor's fiber through the request signal — the server stops doing the
6204
+ work, it does not merely suppress the response (the e2e pins this with a
6205
+ sleeping action whose post-sleep write never lands).
6206
+
6207
+ ## Streaming queries
6208
+
6209
+ A `query` becomes a **server-streaming** rpc: each frame is the CURRENT full
6210
+ snapshot, re-pushed live when the query's `source:` changes — subscribe,
6211
+ mutate from anywhere, and the open stream receives the new frame with no
6212
+ re-request.
6213
+
6214
+ **Authorization is re-derived per FRAME, not frozen at open.** Before every
6215
+ delivery the framework re-runs the query's `guards:` and re-resolves the
6216
+ subject's [row-level visibility](/docs/authentication/row-level-security)
6217
+ from the unfiltered base descriptor. A revoked scope ends the stream with the
6218
+ mapped status; a membership that ends mid-stream stops carrying those rows in
6219
+ the next frame, with the stream itself untouched. This is the same code the
6220
+ WebSocket and SSE transports run — an open gRPC stream is not a cheaper read
6221
+ path than a fresh call.
6222
+
6223
+ Slow consumers are handled through grpc-js write backpressure — frames
6224
+ coalesce to the latest snapshot rather than buffering unboundedly.
6225
+
6226
+ ## Declared limits (v1)
6227
+
6228
+ - **No client- or bidi-streaming**, and `*.stream.ts` procedures are NOT
6229
+ exposable — the fourth kind is a one-shot element stream with its own
6230
+ semantics; put it behind a query or keep it on the socket.
6231
+ - **No gRPC-Web** — a browser talks the framework's own subscription
6232
+ protocol (that is the better browser transport in every dimension we care
6233
+ about); gRPC is for backends.
6234
+ - **No Connect protocol** — connectrpc is NOT gRPC-Web; a connect consumer's
6235
+ alternative today is the [REST/OpenAPI projection](/docs/data/rest-routes).
6236
+ - App realtime stays on the framework's subscription protocol, and gateways
6237
+ exist for the other case: a FOREIGN protocol that needs a socket the
6238
+ framework does not speak — the same boundary
6239
+ [data/subscriptions](/docs/data/subscriptions) draws for raw WebSocket
6240
+ gateways, one sentence, two doors.