@voltro/cli 0.1.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 (626) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +20236 -0
  6. package/bin/voltro.mjs +43 -0
  7. package/dist/bin.d.ts +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/commands-CfPH2Wf4.js +18061 -0
  10. package/dist/frameworkInspectState-CX2250XB.js +86 -0
  11. package/dist/index.d.ts +48 -0
  12. package/dist/index.js +5 -0
  13. package/dist/inspectState.d.ts +116 -0
  14. package/dist/inspectState.js +2 -0
  15. package/dist/startup.d.ts +26 -0
  16. package/dist/startup.js +2 -0
  17. package/dist/startupRunner-CRhuUl91.js +71 -0
  18. package/package.json +88 -0
  19. package/templates/AGENTS.core.md +258 -0
  20. package/templates/AGENTS.md +351 -0
  21. package/templates/agent-docs/_index.md +93 -0
  22. package/templates/agent-docs/_manifest.json +655 -0
  23. package/templates/agent-docs/ai.md +1845 -0
  24. package/templates/agent-docs/authentication.md +1788 -0
  25. package/templates/agent-docs/caching.md +624 -0
  26. package/templates/agent-docs/cli.md +1650 -0
  27. package/templates/agent-docs/configuration.md +295 -0
  28. package/templates/agent-docs/data.md +2172 -0
  29. package/templates/agent-docs/database/advancedqueries.md +1583 -0
  30. package/templates/agent-docs/database/columntypes.md +1200 -0
  31. package/templates/agent-docs/database/hosting.md +881 -0
  32. package/templates/agent-docs/database/migrations.md +2938 -0
  33. package/templates/agent-docs/database/misc.md +270 -0
  34. package/templates/agent-docs/database/overview.md +108 -0
  35. package/templates/agent-docs/database/querying.md +1622 -0
  36. package/templates/agent-docs/database/scaling.md +331 -0
  37. package/templates/agent-docs/database/schema.md +1458 -0
  38. package/templates/agent-docs/database/seedsdialects.md +1235 -0
  39. package/templates/agent-docs/database/transactions.md +285 -0
  40. package/templates/agent-docs/deployment.md +999 -0
  41. package/templates/agent-docs/internationalization.md +359 -0
  42. package/templates/agent-docs/introduction.md +438 -0
  43. package/templates/agent-docs/multi-tenancy.md +610 -0
  44. package/templates/agent-docs/observability.md +350 -0
  45. package/templates/agent-docs/plugins.md +1273 -0
  46. package/templates/agent-docs/reference.md +978 -0
  47. package/templates/agent-docs/routing.md +1553 -0
  48. package/templates/agent-docs/scheduling.md +666 -0
  49. package/templates/agent-docs/schema-driven-ui.md +607 -0
  50. package/templates/agent-docs/security.md +42 -0
  51. package/templates/agent-docs/templates/apibackends.md +3214 -0
  52. package/templates/agent-docs/templates/appshells.md +2062 -0
  53. package/templates/agent-docs/templates/custom.md +128 -0
  54. package/templates/agent-docs/templates/overview.md +122 -0
  55. package/templates/agent-docs/templates/serverless.md +315 -0
  56. package/templates/agent-docs/testing.md +376 -0
  57. package/templates/agent-docs/workflows.md +1351 -0
  58. package/templates/apps/api-ai/README.md +105 -0
  59. package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
  60. package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
  61. package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
  62. package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
  63. package/templates/apps/api-ai/app.config.ts +57 -0
  64. package/templates/apps/api-ai/database/schema.ts +67 -0
  65. package/templates/apps/api-ai/package.json +27 -0
  66. package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
  67. package/templates/apps/api-ai/template.json +6 -0
  68. package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
  69. package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
  70. package/templates/apps/api-ai/tsconfig.json +5 -0
  71. package/templates/apps/api-auth/.env +17 -0
  72. package/templates/apps/api-auth/README.md +109 -0
  73. package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
  74. package/templates/apps/api-auth/actions/me.action.ts +19 -0
  75. package/templates/apps/api-auth/app.config.ts +59 -0
  76. package/templates/apps/api-auth/database/schema.ts +26 -0
  77. package/templates/apps/api-auth/package.json +28 -0
  78. package/templates/apps/api-auth/template.json +6 -0
  79. package/templates/apps/api-auth/tests/me.test.ts +37 -0
  80. package/templates/apps/api-auth/tsconfig.json +5 -0
  81. package/templates/apps/api-backend/README.md +25 -0
  82. package/templates/apps/api-backend/app.config.ts +39 -0
  83. package/templates/apps/api-backend/database/schema.ts +57 -0
  84. package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
  85. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
  86. package/templates/apps/api-backend/package.json +28 -0
  87. package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
  88. package/templates/apps/api-backend/queries/notes.query.ts +20 -0
  89. package/templates/apps/api-backend/template.json +6 -0
  90. package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
  91. package/templates/apps/api-backend/tsconfig.json +5 -0
  92. package/templates/apps/api-backend-deactivation/README.md +49 -0
  93. package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
  94. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
  95. package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
  96. package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
  97. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
  98. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
  99. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
  100. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
  101. package/templates/apps/api-backend-deactivation/package.json +28 -0
  102. package/templates/apps/api-backend-deactivation/template.json +6 -0
  103. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
  104. package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
  105. package/templates/apps/api-backend-mail/README.md +39 -0
  106. package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
  107. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
  108. package/templates/apps/api-backend-mail/app.config.ts +34 -0
  109. package/templates/apps/api-backend-mail/database/schema.ts +57 -0
  110. package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
  111. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
  112. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
  113. package/templates/apps/api-backend-mail/package.json +30 -0
  114. package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
  115. package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
  116. package/templates/apps/api-backend-mail/template.json +6 -0
  117. package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
  118. package/templates/apps/api-backend-mail/tsconfig.json +5 -0
  119. package/templates/apps/api-backend-mariadb/.env.example +35 -0
  120. package/templates/apps/api-backend-mariadb/README.md +31 -0
  121. package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
  122. package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
  123. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
  124. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
  125. package/templates/apps/api-backend-mariadb/package.json +30 -0
  126. package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
  127. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
  128. package/templates/apps/api-backend-mariadb/template.json +6 -0
  129. package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
  130. package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
  131. package/templates/apps/api-backend-storage/README.md +86 -0
  132. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
  133. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
  134. package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
  135. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
  136. package/templates/apps/api-backend-storage/app.config.ts +38 -0
  137. package/templates/apps/api-backend-storage/database/schema.ts +57 -0
  138. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
  139. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
  140. package/templates/apps/api-backend-storage/package.json +27 -0
  141. package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
  142. package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
  143. package/templates/apps/api-backend-storage/template.json +6 -0
  144. package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
  145. package/templates/apps/api-backend-storage/tsconfig.json +5 -0
  146. package/templates/apps/api-data-advanced/.env +17 -0
  147. package/templates/apps/api-data-advanced/README.md +88 -0
  148. package/templates/apps/api-data-advanced/app.config.ts +56 -0
  149. package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
  150. package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
  151. package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
  152. package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
  153. package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
  154. package/templates/apps/api-data-advanced/database/index.ts +23 -0
  155. package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
  156. package/templates/apps/api-data-advanced/package.json +28 -0
  157. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
  158. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
  159. package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
  160. package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
  161. package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
  162. package/templates/apps/api-data-advanced/template.json +6 -0
  163. package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
  164. package/templates/apps/api-data-advanced/tsconfig.json +5 -0
  165. package/templates/apps/api-durable/README.md +81 -0
  166. package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
  167. package/templates/apps/api-durable/app.config.ts +41 -0
  168. package/templates/apps/api-durable/database/schema.ts +65 -0
  169. package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
  170. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
  171. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
  172. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
  173. package/templates/apps/api-durable/package.json +28 -0
  174. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
  175. package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
  176. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
  177. package/templates/apps/api-durable/template.json +6 -0
  178. package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
  179. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
  180. package/templates/apps/api-durable/tsconfig.json +5 -0
  181. package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
  182. package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
  183. package/templates/apps/api-feature-flags/README.md +63 -0
  184. package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
  185. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
  186. package/templates/apps/api-feature-flags/app.config.ts +41 -0
  187. package/templates/apps/api-feature-flags/database/schema.ts +37 -0
  188. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
  189. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
  190. package/templates/apps/api-feature-flags/package.json +29 -0
  191. package/templates/apps/api-feature-flags/template.json +6 -0
  192. package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
  193. package/templates/apps/api-feature-flags/tsconfig.json +5 -0
  194. package/templates/apps/api-governance/.env +4 -0
  195. package/templates/apps/api-governance/README.md +61 -0
  196. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
  197. package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
  198. package/templates/apps/api-governance/app.config.ts +35 -0
  199. package/templates/apps/api-governance/database/schema.ts +41 -0
  200. package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
  201. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
  202. package/templates/apps/api-governance/package.json +29 -0
  203. package/templates/apps/api-governance/template.json +6 -0
  204. package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
  205. package/templates/apps/api-governance/tsconfig.json +5 -0
  206. package/templates/apps/api-kv/README.md +100 -0
  207. package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
  208. package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
  209. package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
  210. package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
  211. package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
  212. package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
  213. package/templates/apps/api-kv/app.config.ts +48 -0
  214. package/templates/apps/api-kv/database/schema.ts +62 -0
  215. package/templates/apps/api-kv/package.json +28 -0
  216. package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
  217. package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
  218. package/templates/apps/api-kv/template.json +6 -0
  219. package/templates/apps/api-kv/tests/sync.test.ts +103 -0
  220. package/templates/apps/api-kv/tsconfig.json +5 -0
  221. package/templates/apps/api-moderation/README.md +46 -0
  222. package/templates/apps/api-moderation/app.config.ts +33 -0
  223. package/templates/apps/api-moderation/database/schema.ts +45 -0
  224. package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
  225. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
  226. package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
  227. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
  228. package/templates/apps/api-moderation/package.json +29 -0
  229. package/templates/apps/api-moderation/template.json +6 -0
  230. package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
  231. package/templates/apps/api-moderation/tsconfig.json +5 -0
  232. package/templates/apps/api-observability/README.md +106 -0
  233. package/templates/apps/api-observability/app.config.ts +32 -0
  234. package/templates/apps/api-observability/database/schema.ts +38 -0
  235. package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
  237. package/templates/apps/api-observability/package.json +29 -0
  238. package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
  239. package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
  240. package/templates/apps/api-observability/template.json +6 -0
  241. package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
  242. package/templates/apps/api-observability/tsconfig.json +5 -0
  243. package/templates/apps/api-ratelimit/README.md +49 -0
  244. package/templates/apps/api-ratelimit/app.config.ts +42 -0
  245. package/templates/apps/api-ratelimit/database/schema.ts +37 -0
  246. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
  247. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
  248. package/templates/apps/api-ratelimit/package.json +29 -0
  249. package/templates/apps/api-ratelimit/template.json +6 -0
  250. package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
  251. package/templates/apps/api-ratelimit/tsconfig.json +5 -0
  252. package/templates/apps/api-rbac/README.md +59 -0
  253. package/templates/apps/api-rbac/app.config.ts +48 -0
  254. package/templates/apps/api-rbac/database/schema.ts +37 -0
  255. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
  256. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
  257. package/templates/apps/api-rbac/package.json +29 -0
  258. package/templates/apps/api-rbac/template.json +6 -0
  259. package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
  260. package/templates/apps/api-rbac/tsconfig.json +5 -0
  261. package/templates/apps/api-rest/README.md +85 -0
  262. package/templates/apps/api-rest/app.config.ts +70 -0
  263. package/templates/apps/api-rest/database/schema.ts +52 -0
  264. package/templates/apps/api-rest/lib/product.ts +30 -0
  265. package/templates/apps/api-rest/package.json +27 -0
  266. package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
  267. package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
  268. package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
  269. package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
  270. package/templates/apps/api-rest/template.json +6 -0
  271. package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
  272. package/templates/apps/api-rest/tsconfig.json +5 -0
  273. package/templates/apps/api-saas/README.md +106 -0
  274. package/templates/apps/api-saas/app.config.ts +51 -0
  275. package/templates/apps/api-saas/database/schema.ts +50 -0
  276. package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
  277. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
  278. package/templates/apps/api-saas/package.json +31 -0
  279. package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
  280. package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
  281. package/templates/apps/api-saas/template.json +6 -0
  282. package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
  283. package/templates/apps/api-saas/tsconfig.json +5 -0
  284. package/templates/apps/api-search/README.md +68 -0
  285. package/templates/apps/api-search/app.config.ts +31 -0
  286. package/templates/apps/api-search/database/schema.ts +47 -0
  287. package/templates/apps/api-search/lib/search.ts +27 -0
  288. package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
  289. package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
  290. package/templates/apps/api-search/package.json +29 -0
  291. package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
  292. package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
  293. package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
  294. package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
  295. package/templates/apps/api-search/template.json +6 -0
  296. package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
  297. package/templates/apps/api-search/tsconfig.json +5 -0
  298. package/templates/apps/api-versioning/README.md +51 -0
  299. package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
  300. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
  301. package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
  302. package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
  303. package/templates/apps/api-versioning/app.config.ts +24 -0
  304. package/templates/apps/api-versioning/database/schema.ts +38 -0
  305. package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
  306. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
  307. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
  308. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
  309. package/templates/apps/api-versioning/package.json +29 -0
  310. package/templates/apps/api-versioning/template.json +6 -0
  311. package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
  312. package/templates/apps/api-versioning/tsconfig.json +5 -0
  313. package/templates/apps/api-webhooks/.env +6 -0
  314. package/templates/apps/api-webhooks/README.md +106 -0
  315. package/templates/apps/api-webhooks/app.config.ts +16 -0
  316. package/templates/apps/api-webhooks/database/schema.ts +49 -0
  317. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
  318. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
  319. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
  320. package/templates/apps/api-webhooks/package.json +28 -0
  321. package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
  322. package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
  323. package/templates/apps/api-webhooks/template.json +6 -0
  324. package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
  325. package/templates/apps/api-webhooks/tsconfig.json +5 -0
  326. package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
  327. package/templates/apps/changelog/README.md +77 -0
  328. package/templates/apps/changelog/app.config.ts +27 -0
  329. package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
  330. package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
  331. package/templates/apps/changelog/package.json +30 -0
  332. package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
  333. package/templates/apps/changelog/src/globals.css +66 -0
  334. package/templates/apps/changelog/src/globals.d.ts +16 -0
  335. package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
  336. package/templates/apps/changelog/src/lib/locale.ts +55 -0
  337. package/templates/apps/changelog/src/lib/releases.ts +21 -0
  338. package/templates/apps/changelog/src/locales/de.ts +22 -0
  339. package/templates/apps/changelog/src/locales/en.ts +30 -0
  340. package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
  341. package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
  342. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
  343. package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
  344. package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
  345. package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
  346. package/templates/apps/changelog/src/pages/index.tsx +55 -0
  347. package/templates/apps/changelog/src/pages/layout.tsx +84 -0
  348. package/templates/apps/changelog/template.json +6 -0
  349. package/templates/apps/changelog/tsconfig.json +11 -0
  350. package/templates/apps/edge-functions/README.md +55 -0
  351. package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
  352. package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
  353. package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
  354. package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
  355. package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
  356. package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
  357. package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
  358. package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
  359. package/templates/apps/edge-functions/package.json +21 -0
  360. package/templates/apps/edge-functions/template.json +6 -0
  361. package/templates/apps/edge-functions/tsconfig.json +10 -0
  362. package/templates/apps/frontend-admin/README.md +40 -0
  363. package/templates/apps/frontend-admin/app.config.ts +37 -0
  364. package/templates/apps/frontend-admin/package.json +30 -0
  365. package/templates/apps/frontend-admin/src/config.ts +8 -0
  366. package/templates/apps/frontend-admin/src/globals.css +76 -0
  367. package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
  368. package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
  369. package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
  370. package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
  371. package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
  372. package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
  373. package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
  374. package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
  375. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
  376. package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
  377. package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
  378. package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
  379. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
  380. package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
  381. package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
  382. package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
  383. package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
  384. package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
  385. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
  386. package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
  387. package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
  388. package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
  389. package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
  390. package/templates/apps/frontend-admin/template.json +6 -0
  391. package/templates/apps/frontend-admin/tsconfig.json +11 -0
  392. package/templates/apps/frontend-app/README.md +98 -0
  393. package/templates/apps/frontend-app/app.config.ts +43 -0
  394. package/templates/apps/frontend-app/package.json +30 -0
  395. package/templates/apps/frontend-app/src/locales/de.ts +36 -0
  396. package/templates/apps/frontend-app/src/locales/en.ts +43 -0
  397. package/templates/apps/frontend-app/src/locales/index.ts +15 -0
  398. package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
  399. package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
  400. package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
  401. package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
  402. package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
  403. package/templates/apps/frontend-app/template.json +6 -0
  404. package/templates/apps/frontend-app/tsconfig.json +11 -0
  405. package/templates/apps/frontend-blank/README.md +18 -0
  406. package/templates/apps/frontend-blank/app.config.ts +29 -0
  407. package/templates/apps/frontend-blank/package.json +29 -0
  408. package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
  409. package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
  410. package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
  411. package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
  412. package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
  413. package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
  414. package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
  415. package/templates/apps/frontend-blank/template.json +6 -0
  416. package/templates/apps/frontend-blank/tsconfig.json +11 -0
  417. package/templates/apps/frontend-contact/README.md +65 -0
  418. package/templates/apps/frontend-contact/app.config.ts +25 -0
  419. package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
  420. package/templates/apps/frontend-contact/package.json +33 -0
  421. package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
  422. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
  423. package/templates/apps/frontend-contact/src/config.ts +12 -0
  424. package/templates/apps/frontend-contact/src/globals.css +84 -0
  425. package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
  426. package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
  427. package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
  428. package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
  429. package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
  430. package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
  431. package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
  432. package/templates/apps/frontend-contact/template.json +6 -0
  433. package/templates/apps/frontend-contact/tsconfig.json +11 -0
  434. package/templates/apps/frontend-dashboard/README.md +54 -0
  435. package/templates/apps/frontend-dashboard/app.config.ts +39 -0
  436. package/templates/apps/frontend-dashboard/package.json +29 -0
  437. package/templates/apps/frontend-dashboard/src/config.ts +8 -0
  438. package/templates/apps/frontend-dashboard/src/globals.css +72 -0
  439. package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
  440. package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
  441. package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
  442. package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
  443. package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
  444. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
  445. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
  446. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
  447. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
  448. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
  449. package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
  450. package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
  451. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
  452. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
  453. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
  454. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
  455. package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
  456. package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
  457. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
  458. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
  459. package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
  460. package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
  461. package/templates/apps/frontend-dashboard/template.json +6 -0
  462. package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
  463. package/templates/apps/frontend-docs/README.md +19 -0
  464. package/templates/apps/frontend-docs/app.config.ts +25 -0
  465. package/templates/apps/frontend-docs/package.json +29 -0
  466. package/templates/apps/frontend-docs/src/globals.css +32 -0
  467. package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
  468. package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
  469. package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
  470. package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
  471. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
  472. package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
  473. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
  474. package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
  475. package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
  476. package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
  477. package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
  478. package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
  479. package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
  480. package/templates/apps/frontend-docs/template.json +6 -0
  481. package/templates/apps/frontend-docs/tsconfig.json +11 -0
  482. package/templates/apps/frontend-i18n/README.md +61 -0
  483. package/templates/apps/frontend-i18n/app.config.ts +33 -0
  484. package/templates/apps/frontend-i18n/package.json +28 -0
  485. package/templates/apps/frontend-i18n/src/globals.css +46 -0
  486. package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
  487. package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
  488. package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
  489. package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
  490. package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
  491. package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
  492. package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
  493. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
  494. package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
  495. package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
  496. package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
  497. package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
  498. package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
  499. package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
  500. package/templates/apps/frontend-i18n/template.json +6 -0
  501. package/templates/apps/frontend-i18n/tsconfig.json +11 -0
  502. package/templates/apps/frontend-landing/README.md +17 -0
  503. package/templates/apps/frontend-landing/app.config.ts +25 -0
  504. package/templates/apps/frontend-landing/package.json +29 -0
  505. package/templates/apps/frontend-landing/src/globals.css +23 -0
  506. package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
  507. package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
  508. package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
  509. package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
  510. package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
  511. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
  512. package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
  513. package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
  514. package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
  515. package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
  516. package/templates/apps/frontend-landing/template.json +6 -0
  517. package/templates/apps/frontend-landing/tsconfig.json +11 -0
  518. package/templates/apps/frontend-spa/README.md +45 -0
  519. package/templates/apps/frontend-spa/app.config.ts +27 -0
  520. package/templates/apps/frontend-spa/package.json +29 -0
  521. package/templates/apps/frontend-spa/src/globals.css +84 -0
  522. package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
  523. package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
  524. package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
  525. package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
  526. package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
  527. package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
  528. package/templates/apps/frontend-spa/template.json +6 -0
  529. package/templates/apps/frontend-spa/tsconfig.json +11 -0
  530. package/templates/apps/frontend-ssr/README.md +68 -0
  531. package/templates/apps/frontend-ssr/app.config.ts +32 -0
  532. package/templates/apps/frontend-ssr/package.json +29 -0
  533. package/templates/apps/frontend-ssr/src/globals.css +67 -0
  534. package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
  535. package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
  536. package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
  537. package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
  538. package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
  539. package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
  540. package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
  541. package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
  542. package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
  543. package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
  544. package/templates/apps/frontend-ssr/template.json +6 -0
  545. package/templates/apps/frontend-ssr/tsconfig.json +11 -0
  546. package/templates/apps/frontend-ssr-api/README.md +50 -0
  547. package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
  548. package/templates/apps/frontend-ssr-api/package.json +30 -0
  549. package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
  550. package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
  551. package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
  552. package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
  553. package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
  554. package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
  555. package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
  556. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
  557. package/templates/apps/frontend-ssr-api/template.json +6 -0
  558. package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
  559. package/templates/apps/frontend-static-blog/README.md +49 -0
  560. package/templates/apps/frontend-static-blog/app.config.ts +34 -0
  561. package/templates/apps/frontend-static-blog/package.json +28 -0
  562. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
  563. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
  564. package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
  565. package/templates/apps/frontend-static-blog/src/globals.css +75 -0
  566. package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
  567. package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
  568. package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
  569. package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
  570. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
  571. package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
  572. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
  573. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
  574. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
  575. package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
  576. package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
  577. package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
  578. package/templates/apps/frontend-static-blog/template.json +6 -0
  579. package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
  580. package/templates/baselines/bare/.env.example +37 -0
  581. package/templates/baselines/bare/README.md +47 -0
  582. package/templates/baselines/bare/baseline.json +31 -0
  583. package/templates/baselines/bare/deploy/README.md +43 -0
  584. package/templates/baselines/bare/deploy/voltro.service.example +36 -0
  585. package/templates/baselines/compose/.env.example +46 -0
  586. package/templates/baselines/compose/README.md +69 -0
  587. package/templates/baselines/compose/baseline.json +51 -0
  588. package/templates/baselines/compose/docker/.dockerignore +38 -0
  589. package/templates/baselines/compose/docker/api.Dockerfile +57 -0
  590. package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
  591. package/templates/baselines/compose/docker/web.Dockerfile +59 -0
  592. package/templates/baselines/compose/docker-compose.dev.yml +89 -0
  593. package/templates/baselines/compose/docker-compose.prod.yml +87 -0
  594. package/templates/baselines/compose/docker-compose.yml +41 -0
  595. package/templates/baselines/compose-mariadb/.env.example +57 -0
  596. package/templates/baselines/compose-mariadb/README.md +78 -0
  597. package/templates/baselines/compose-mariadb/baseline.json +51 -0
  598. package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
  599. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
  600. package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
  601. package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
  602. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
  603. package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
  604. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
  605. package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
  606. package/templates/baselines/helm/.env.example +39 -0
  607. package/templates/baselines/helm/README.md +98 -0
  608. package/templates/baselines/helm/baseline.json +53 -0
  609. package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
  610. package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
  611. package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
  612. package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
  613. package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
  614. package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
  615. package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
  616. package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
  617. package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
  618. package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
  619. package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
  620. package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
  621. package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
  622. package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
  623. package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
  624. package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
  625. package/templates/baselines/helm/deploy/README.md +94 -0
  626. package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
@@ -0,0 +1,2172 @@
1
+ # Data
2
+
3
+ > How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/data/overview.md -->
10
+ ## Overview
11
+
12
+ _How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies._
13
+
14
+ The api side of a Voltro app is a collection of typed RPC primitives. You drop descriptor/server-executor pairs into the api tree; the framework discovers them; the typed client lights up.
15
+
16
+ This section covers how data flows between server and client.
17
+
18
+ A form bound to a mutation, a table bound to a query, one reactive backend — add
19
+ a row and it appears instantly, pushed from the server (your own throwaway
20
+ sandbox; it resets on refresh):
21
+
22
+ ```tsx
23
+ <AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
24
+ <DataTable api="app" query="todos.list" />
25
+ ```
26
+
27
+ ## The shape of it
28
+
29
+ ```text
30
+ ┌──────────────────────────────────────────────────────────────────┐
31
+ │ Client (React) │
32
+ │ useSubscription('app', 'notes.list', input) │
33
+ │ useMutation('app', 'notes.create') │
34
+ │ useAction('app', 'invites.send') │
35
+ │ useAgentStream('app', 'support.run') │
36
+ └──────────────────────────┬───────────────────────────────────────┘
37
+ │ @effect/rpc over WebSocket
38
+
39
+ ┌──────────────────────────────────────────────────────────────────┐
40
+ │ api app │
41
+ │ *.query.ts + *.query.server.ts — reactive reads │
42
+ │ *.mutation.ts + *.mutation.server.ts — transactional writes │
43
+ │ *.action.ts + *.action.server.ts — external I/O / unary │
44
+ │ *.stream.ts + *.stream.server.ts — one-shot element push │
45
+ │ *.route.tsx — public raw-HTTP route │
46
+ │ *.workflow.tsx + *.trigger.tsx — durable work + events │
47
+ │ *.agent.tsx — persisted AI chat │
48
+ └──────────────────────────────────────────────────────────────────┘
49
+ ```
50
+
51
+ Most of it goes through one WebSocket (REST routes are the public raw-HTTP exception). Queries and streams are both streaming RPCs, but they mean different things: a query emits reactive snapshot/delta envelopes; a stream emits plain elements and then finishes.
52
+
53
+ ## Every primitive at a glance
54
+
55
+ Voltro ships more building blocks than this one section holds — each is a typed descriptor in a file the CLI discovers. The five at the top live here; the rest have their own sections but are the same kind of thing. The complete set:
56
+
57
+ | Primitive | File | What it is |
58
+ |---|---|---|
59
+ | **Query** | `*.query.ts` | reactive read → [Queries](/docs/data/queries) |
60
+ | **Mutation** | `*.mutation.ts` | transactional write → [Mutations](/docs/data/mutations) |
61
+ | **Action** | `*.action.ts` | unary external I/O → [Actions](/docs/data/actions) |
62
+ | **Stream** | `*.stream.ts` | one-shot element push → [Streams](/docs/data/streams) |
63
+ | **REST route** | `*.route.tsx` | public raw-HTTP endpoint → [REST routes](/docs/data/rest-routes) |
64
+ | **Aggregate** | `*.aggregate.ts` | scheduled materialised query → [Aggregates](/docs/data/aggregates) |
65
+ | **Subscriber** | `*.subscribe.ts` | per-table post-commit reaction → [Subscribers](/docs/data/subscribers) |
66
+ | **Workflow** | `*.workflow.tsx` | durable multi-step work → [Workflows](/docs/workflows/overview) |
67
+ | **Event trigger** | `*.trigger.tsx` | event → workflow fan-out → [Event triggers](/docs/workflows/event-triggers) |
68
+ | **Schedule** | `*.cron.tsx` | cron-fired handler → [Scheduling](/docs/scheduling/overview) |
69
+ | **Agent** | `*.agent.tsx` | persisted AI chat → [Agents](/docs/ai/agents) |
70
+ | **Tool** | `*.tool.tsx` | model-callable function → [Tools](/docs/ai/tools) |
71
+ | **Seed** | `*.seed.ts` | boot/data seeding → [Seeds](/docs/database/seeds) |
72
+ | **Startup** | `*.startup.tsx` | run-once boot hook (long-lived work + teardown) → [Startup hooks](/docs/reference/startup) |
73
+ | **Email** | `*.email.tsx` | React-Email template → [Mail](/docs/plugins/mail) |
74
+ | **Webhook** | `*.webhook.tsx` | signed inbound / outbound → [Webhooks](/docs/plugins/webhooks) |
75
+
76
+ ## What's in this section
77
+
78
+ **Primitives** — [Queries](/docs/data/queries) (reactive reads + dependency tracking) · [Mutations](/docs/data/mutations) (transactional writes, typed errors, auto-optimistic) · [Actions](/docs/data/actions) (unary RPC, no transaction) · [Subscriptions](/docs/data/subscriptions) (the reactive engine behind snapshots/deltas) · [Streams](/docs/data/streams) (`defineStream` element push) · [REST routes](/docs/data/rest-routes) (public raw-HTTP for third parties) · [Aggregates](/docs/data/aggregates) (scheduled materialised queries) · [Subscribers](/docs/data/subscribers) (per-table post-commit reactions).
79
+
80
+ **Protocol & errors** — [Wire protocol](/docs/data/wire-protocol) (framing, multiplexing) · [Error handling](/docs/data/errors) (Schema-tagged errors, retries, client narrowing).
81
+
82
+ ## The contract
83
+
84
+ Every query, mutation, action, and stream has two files:
85
+
86
+ ```text
87
+ queries/notes.list.query.ts # descriptor: browser-safe schema + metadata
88
+ queries/notes.list.query.server.ts # executor: server-only implementation
89
+ ```
90
+
91
+ The descriptor defines the wire surface:
92
+
93
+ ```ts
94
+ defineX({
95
+ name: 'feature.action',
96
+ input: Schema.Struct({ /* ... */ }),
97
+ output: Schema.Struct({ /* mutations/actions */ }),
98
+ element: Schema.Struct({ /* streams */ }),
99
+ error: Schema.Union(ErrorVariantA, ErrorVariantB),
100
+ })
101
+ ```
102
+
103
+ The server executor receives `(input, ctx)` and can return a plain value, `Promise`, `Effect`, query descriptor, or `Stream`, depending on the primitive. Everything is Schema-validated at the boundary. The TypeScript types flow to the client automatically through `rpcGroup.generated.ts`.
104
+
105
+ > **`name` charset.** A procedure `name` is `.`-separated and becomes a JS
106
+ > identifier in `rpcGroup.generated.ts` (`todos.list` → `todosListRpc`). It
107
+ > must start with a letter and use only letters, digits, and `.` / `-` / `_`.
108
+ > `-` and `_` are camelCased away, so two names differing only by separator
109
+ > **collide**. Codegen rejects an invalid or colliding name at `voltro dev`
110
+ > boot, naming the file. Prefer plain camelCase segments (`todos.list`).
111
+
112
+ ## Reactivity model
113
+
114
+ Queries are live. A query descriptor declares `source: 'tableName'`; the server executor returns either a query builder/descriptor or a computed value. When a mutation writes to the table, the runtime recomputes affected subscriptions and pushes a snapshot or delta.
115
+
116
+ Streams are not live subscriptions. A stream executor returns a one-shot `Stream` of elements. Use streams for transient token feeds, progress events, logs, or external event sources. If the result should survive reloads or update other tabs, persist rows and expose them through a query.
117
+
118
+ ## Tenant scoping
119
+
120
+ Every executor receives `ctx.request.subject` — the typed identity of the caller. Tables with the `tenant()` mixin auto-scope reads and writes. See [Multi-tenancy](/docs/multi-tenancy/overview) for the full story.
121
+
122
+ ## When to use what
123
+
124
+ | You want… | Use |
125
+ |---|---|
126
+ | Read data that updates live | `*.query.ts` + `*.query.server.ts` + `useSubscription` |
127
+ | Write data atomically | `*.mutation.ts` + `*.mutation.server.ts` + `useMutation` |
128
+ | External I/O / unary side effect | `*.action.ts` + `*.action.server.ts` + `useAction` |
129
+ | Public HTTP endpoint for a third party (URL + JSON) | [`*.route.tsx`](/docs/data/rest-routes) (`defineRestRoute`) |
130
+ | Transient server-to-client element stream | `*.stream.ts` + `*.stream.server.ts` + `useAgentStream` |
131
+ | Durable persisted AI chat | `*.agent.tsx` or action + query over `agent_messages` |
132
+ | Background job | `*.workflow.tsx` |
133
+ | Fan a domain event out to one or more workflows | [`*.trigger.tsx`](/docs/workflows/event-triggers) + `ctx.events.emit(...)` |
134
+ | Pre-computed query result (top-N, summary) | [`*.aggregate.ts`](/docs/data/aggregates) |
135
+ | React to every commit on a table (server-side) | [`*.subscribe.ts`](/docs/data/subscribers) |
136
+ | Event ingestion + analytical aggregates over events | [Analytics sink](/docs/plugins/analytics) |
137
+
138
+ Pages don't have to pick only one. Most use `useSubscription` for reads and `useMutation` for writes; add actions or streams only when the workflow calls for them.
139
+
140
+
141
+
142
+ ---
143
+
144
+ <!-- source: en/data/queries.md -->
145
+ ## Queries
146
+
147
+ _`*.query.ts` + `*.query.server.ts` pairs — reactive reads consumed with useSubscription, with dependency tracking and delta updates._
148
+
149
+ A **query** is a reactive read. The client subscribes to it; when the underlying data changes, Voltro pushes a fresh snapshot or delta over the WebSocket. No polling, no manual `refetch`, no mutation response gymnastics.
150
+
151
+ Live — a computed-return query: the `{ open, done, total }` counts recompute the
152
+ instant you add or toggle a todo (it declares `source: 'todos'`):
153
+
154
+ ```tsx
155
+ const stats = useSubscription('app', 'todos.stats') // computed, live — no refetch
156
+ ```
157
+
158
+ ## Minimal query pair
159
+
160
+ Descriptor file:
161
+
162
+ ```ts
163
+ // apps/api/queries/notes.list.query.ts
164
+ import { defineQuery } from '@voltro/protocol'
165
+ import { Schema } from 'effect'
166
+
167
+ export const listNotes = defineQuery({
168
+ name: 'notes.list',
169
+ source: 'notes',
170
+ input: Schema.Struct({}),
171
+ output: Schema.Array(Schema.Struct({
172
+ id: Schema.String,
173
+ title: Schema.String,
174
+ })),
175
+ })
176
+ ```
177
+
178
+ Server executor:
179
+
180
+ ```ts
181
+ // apps/api/queries/notes.list.query.server.ts
182
+ import { database } from '../database/index'
183
+
184
+ export default () =>
185
+ database.notes.orderBy('createdAt', 'desc').limit(100)
186
+ ```
187
+
188
+ Save both files and `notes.list` becomes a streaming query in the typed client.
189
+
190
+ ## Consuming a query
191
+
192
+ ```tsx no-check
193
+ import { useSubscription } from '@voltro/client'
194
+
195
+ export default function Notes() {
196
+ const { data, error } = useSubscription('app', 'notes.list', {})
197
+ if (error) return <p>Error: {String(error)}</p>
198
+ if (data === undefined) return <p>Loading...</p>
199
+ return (
200
+ <ul>
201
+ {data.map((note) => <li key={note.id}>{note.title}</li>)}
202
+ </ul>
203
+ )
204
+ }
205
+ ```
206
+
207
+ The first argument is the api name from `app.config.ts.apis`; the second is the descriptor's `name`.
208
+
209
+ ## Inputs
210
+
211
+ Descriptor:
212
+
213
+ ```ts
214
+ // apps/api/queries/messages.list.query.ts
215
+ import { defineQuery } from '@voltro/protocol'
216
+ import { Schema } from 'effect'
217
+
218
+ export const listMessages = defineQuery({
219
+ name: 'messages.list',
220
+ source: 'messages',
221
+ input: Schema.Struct({
222
+ channelId: Schema.String,
223
+ limit: Schema.Number,
224
+ }),
225
+ output: Schema.Array(Schema.Struct({
226
+ id: Schema.String,
227
+ channelId: Schema.String,
228
+ body: Schema.String,
229
+ })),
230
+ })
231
+ ```
232
+
233
+ Server executor:
234
+
235
+ ```ts
236
+ // apps/api/queries/messages.list.query.server.ts
237
+ import { eq } from '@voltro/database'
238
+ import { database } from '../database/index'
239
+
240
+ export default (input: { channelId: string; limit: number }) =>
241
+ database.messages
242
+ .where(eq('channelId', input.channelId))
243
+ .orderBy('createdAt', 'desc')
244
+ .limit(input.limit)
245
+ ```
246
+
247
+ Client:
248
+
249
+ ```tsx
250
+ const { data } = useSubscription('app', 'messages.list', {
251
+ channelId: 'c_123',
252
+ limit: 50,
253
+ })
254
+ ```
255
+
256
+ ## Descriptor-return vs computed-return
257
+
258
+ A query executor can return either:
259
+
260
+ | Executor returns | Use when |
261
+ |---|---|
262
+ | A `database.<table>` query builder / descriptor | You are streaming rows from one table and want fine-grained predicate-aware invalidation. |
263
+ | A computed value | You are building an aggregate, join, projection, or other derived shape. |
264
+
265
+ Computed example:
266
+
267
+ ```ts
268
+ // apps/api/queries/notes.summary.query.ts
269
+ import { defineQuery } from '@voltro/protocol'
270
+ import { Schema } from 'effect'
271
+
272
+ export const notesSummary = defineQuery({
273
+ name: 'notes.summary',
274
+ source: 'notes',
275
+ input: Schema.Struct({}),
276
+ output: Schema.Struct({
277
+ open: Schema.Number,
278
+ done: Schema.Number,
279
+ }),
280
+ })
281
+ ```
282
+
283
+ ```ts
284
+ // apps/api/queries/notes.summary.query.server.ts
285
+ export default async (_input: Record<string, never>, ctx) => {
286
+ const notes = await ctx.store.select('notes').all()
287
+ return {
288
+ open: notes.filter((note) => !note.done).length,
289
+ done: notes.filter((note) => note.done).length,
290
+ }
291
+ }
292
+ ```
293
+
294
+ For computed queries, `source` is the reactivity trigger. When any row in the source table changes, the runtime re-runs the executor and emits the new value if it changed. If the executor reads more than one table (a join or matrix), declare `source` as an array — the executor re-runs when **any** listed table changes (e.g. `source: ['skills', 'ratings']`).
295
+
296
+ ## Auto-optimistic source
297
+
298
+ `source` also connects query caches to mutation `target` metadata:
299
+
300
+ ```ts
301
+ defineQuery({ name: 'notes.list', source: 'notes', /* ... */ })
302
+ defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' }, /* ... */ })
303
+ ```
304
+
305
+ With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
306
+
307
+ ## What gets sent on the wire
308
+
309
+ Queries are streaming RPCs whose elements are **subscription events**: an initial `snapshot` followed by `delta`s. See [Wire protocol](/docs/data/wire-protocol#subscription-events-snapshot-delta) for the envelope shape. For plain element streams, use [Streams](/docs/data/streams).
310
+
311
+ ## Anti-patterns
312
+
313
+ - **Putting server-only imports in `*.query.ts`.** Descriptors are imported by browser-safe codegen. Put database/SDK/filesystem imports in `*.query.server.ts`.
314
+ - **Mutating from a query executor.** Queries are reads. Use a mutation for writes.
315
+ - **Using a stream for durable data.** Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
316
+
317
+
318
+
319
+ ---
320
+
321
+ <!-- source: en/data/mutations.md -->
322
+ ## Mutations
323
+
324
+ _`*.mutation.ts` + `*.mutation.server.ts` pairs — atomic writes with typed schemas and auto-optimistic metadata._
325
+
326
+ A **mutation** is the write primitive. Every `ctx.store.insert`, `update`, and `delete` inside one mutation runs in a single transaction. If the executor throws, the transaction rolls back and subscribers never observe a partial write.
327
+
328
+ Declare `target` metadata in the descriptor so the client can derive optimistic patches for queries whose `source` points at the same table.
329
+
330
+ Live — submitting adds the row optimistically (it shows instantly, then the
331
+ server delta confirms it):
332
+
333
+ ```tsx
334
+ <AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
335
+ <DataTable api="app" query="todos.list" />
336
+ ```
337
+
338
+ ## Mutation Pair
339
+
340
+ Descriptor:
341
+
342
+ ```ts
343
+ // apps/api/mutations/notes.create.mutation.ts
344
+ import { defineMutation } from '@voltro/protocol'
345
+ import { Schema } from 'effect'
346
+
347
+ export const createNote = defineMutation({
348
+ name: 'notes.create',
349
+ target: { table: 'notes', op: 'insert' },
350
+ input: Schema.Struct({
351
+ title: Schema.NonEmptyString,
352
+ body: Schema.String,
353
+ }),
354
+ output: Schema.Struct({
355
+ id: Schema.String,
356
+ }),
357
+ })
358
+ ```
359
+
360
+ Server executor:
361
+
362
+ ```ts
363
+ // apps/api/mutations/notes.create.mutation.server.ts
364
+ import type { AppContext } from '@voltro/runtime'
365
+
366
+ export default async (
367
+ input: { title: string; body: string },
368
+ ctx: AppContext,
369
+ ) => {
370
+ const inserted = await ctx.store.insert('notes', {
371
+ title: input.title,
372
+ body: input.body,
373
+ authorId: ctx.request.subject.id,
374
+ tenantId: ctx.request.subject.tenantId,
375
+ })
376
+
377
+ return { id: inserted['id'] as string }
378
+ }
379
+ ```
380
+
381
+ The descriptor is the wire contract. The `.mutation.server.ts` file is the server-only implementation.
382
+
383
+ ## What The Runtime Does
384
+
385
+ 1. Decode `input` with the descriptor schema.
386
+ 2. Run mutation plugin interceptors.
387
+ 3. Execute the server file inside `store.transactional(...)`.
388
+ 4. Encode `output` with the descriptor schema.
389
+ 5. Commit the transaction.
390
+ 6. Drain the batched change events so matching query subscriptions receive new snapshots or deltas.
391
+
392
+ ## Calling From React
393
+
394
+ ```tsx
395
+ import { useMutation } from '@voltro/client'
396
+
397
+ export default function NewNote() {
398
+ const create = useMutation<{ title: string; body: string }, { id: string }>(
399
+ 'app',
400
+ 'notes.create',
401
+ )
402
+
403
+ return (
404
+ <form onSubmit={async (event) => {
405
+ event.preventDefault()
406
+ const form = new FormData(event.currentTarget)
407
+ await create.mutate({
408
+ title: String(form.get('title')),
409
+ body: String(form.get('body')),
410
+ })
411
+ }}>
412
+ <input name="title" />
413
+ <textarea name="body" />
414
+ <button disabled={create.pending}>
415
+ {create.pending ? 'Saving...' : 'Save'}
416
+ </button>
417
+ </form>
418
+ )
419
+ }
420
+ ```
421
+
422
+ `useMutation` returns `mutate`, `pending`, `error`, `data`, plus the chainable optimistic helpers.
423
+
424
+ ## Auto-Optimistic
425
+
426
+ The default path is declarative:
427
+
428
+ ```ts
429
+ defineQuery({
430
+ name: 'notes.list',
431
+ source: 'notes',
432
+ input: Schema.Struct({}),
433
+ output: Schema.Array(Note),
434
+ })
435
+
436
+ defineMutation({
437
+ name: 'notes.create',
438
+ target: { table: 'notes', op: 'insert' },
439
+ input,
440
+ output,
441
+ })
442
+ ```
443
+
444
+ Then the client can stay plain:
445
+
446
+ ```tsx
447
+ const notes = useSubscription('app', 'notes.list', {})
448
+ const create = useMutation('app', 'notes.create')
449
+
450
+ await create.mutate({ title, body })
451
+ ```
452
+
453
+ The client stages an optimistic patch, calls the server, then removes the patch when the server result or failure arrives. Server-pushed deltas are still the source of truth.
454
+
455
+ For special shapes, override the patch:
456
+
457
+ ```tsx
458
+ const create = useMutation('app', 'notes.create').withOptimistic((cache, input) => {
459
+ cache.forTag<ReadonlyArray<{ id: string; title: string }>>('notes.list', (rows) => [
460
+ { id: `temp:${Date.now()}`, title: input.title },
461
+ ...rows,
462
+ ])
463
+ })
464
+ ```
465
+
466
+ Use `.withoutOptimistic()` for effects that should not preview locally.
467
+
468
+ ## Typed Errors
469
+
470
+ ```ts
471
+ import { Schema } from 'effect'
472
+
473
+ class NoteQuotaExceeded extends Schema.TaggedError<NoteQuotaExceeded>()('NoteQuotaExceeded', {
474
+ limit: Schema.Number,
475
+ }) {}
476
+
477
+ export const createNote = defineMutation({
478
+ name: 'notes.create',
479
+ target: { table: 'notes', op: 'insert' },
480
+ input,
481
+ output,
482
+ error: NoteQuotaExceeded,
483
+ })
484
+ ```
485
+
486
+ Throw a matching error from the server file; the client can narrow on `_tag`.
487
+
488
+ ## When Not To Use A Mutation
489
+
490
+ - **External I/O.** Use an action or workflow.
491
+ - **Progress output.** Use a stream.
492
+ - **Reads.** Use a query.
493
+ - **Long-running durable work.** Use a workflow.
494
+
495
+
496
+
497
+ ---
498
+
499
+ <!-- source: en/data/actions.md -->
500
+ ## Actions
501
+
502
+ _`*.action.ts` + `*.action.server.ts` pairs — unary server calls for external I/O and non-transactional work._
503
+
504
+ An **action** is a typed unary RPC that runs outside the mutation transaction wrapper. Use it for external I/O and request-scoped work: HTTP calls, emails, signed upload URLs, AI calls that return one value, or kicking off a workflow.
505
+
506
+ Actions can read or write through `ctx.store`, but those writes are not grouped into one automatic transaction and they do not drive client auto-optimistic updates. If the main purpose is an atomic database write, use a [mutation](/docs/data/mutations). If the client should receive incremental elements while work is running, use a [stream](/docs/data/streams).
507
+
508
+ Live — a unary action: call it, get one typed answer back. No transaction, no
509
+ optimistic patch:
510
+
511
+ ```tsx
512
+ const echo = useAction('app', 'demo.echo')
513
+ await echo.run({ message }) // one call → one typed result
514
+ ```
515
+
516
+ ## Action Pair
517
+
518
+ Descriptor:
519
+
520
+ ```ts
521
+ // apps/api/actions/support.ping.action.ts
522
+ import { defineAction } from '@voltro/protocol'
523
+ import { Schema } from 'effect'
524
+
525
+ export const pingExternal = defineAction({
526
+ name: 'support.ping',
527
+ input: Schema.Struct({ url: Schema.String }),
528
+ output: Schema.Struct({
529
+ status: Schema.Number,
530
+ durationMs: Schema.Number,
531
+ }),
532
+ })
533
+ ```
534
+
535
+ Server executor:
536
+
537
+ ```ts
538
+ // apps/api/actions/support.ping.action.server.ts
539
+ import { HttpClient, HttpClientRequest } from '@effect/platform'
540
+ import { Effect } from 'effect'
541
+
542
+ export default (input: { url: string }) =>
543
+ Effect.gen(function* () {
544
+ const client = yield* HttpClient.HttpClient
545
+ const startedAt = Date.now()
546
+ const response = yield* client.execute(HttpClientRequest.get(input.url))
547
+
548
+ return {
549
+ status: response.status,
550
+ durationMs: Date.now() - startedAt,
551
+ }
552
+ })
553
+ ```
554
+
555
+ The descriptor file is safe for client-side discovery. The `.action.server.ts` file contains the server-only code and can return a plain value, a `Promise`, or an `Effect`.
556
+
557
+ ## Calling From React
558
+
559
+ ```tsx
560
+ import { useAction } from '@voltro/client'
561
+
562
+ const ping = useAction<{ url: string }, { status: number; durationMs: number }>(
563
+ 'app',
564
+ 'support.ping',
565
+ )
566
+
567
+ const onClick = async () => {
568
+ const result = await ping.run({ url: 'https://example.com/health' })
569
+ console.log(result.status)
570
+ }
571
+ ```
572
+
573
+ `useAction` returns `run`, `pending`, `error`, and `lastResult`.
574
+
575
+ ## Action vs Mutation vs Stream
576
+
577
+ | Need | Use |
578
+ |---|---|
579
+ | Atomic database write with rollback | Mutation |
580
+ | External I/O or one-shot server call | Action |
581
+ | Progressive server-to-client output | Stream |
582
+ | Durable multi-step work | Workflow |
583
+ | Client-side optimistic preview | Mutation with `target` metadata |
584
+
585
+ ## Database Writes
586
+
587
+ Actions are not wrapped in the mutation transaction. A throw does not roll back previous writes or external side effects.
588
+
589
+ ```ts
590
+ export default async (input, ctx) => {
591
+ await ctx.store.insert('auditLogs', {
592
+ message: input.message,
593
+ actorId: ctx.request.subject.id,
594
+ })
595
+
596
+ await sendExternalWebhook(input)
597
+ return { ok: true }
598
+ }
599
+ ```
600
+
601
+ Use this shape only when that ordering is acceptable. If several writes must commit or roll back together, move them to a mutation. If the external side effect must survive retries, model the operation as a workflow.
602
+
603
+ ## Typed Errors
604
+
605
+ Actions use the same `error: Schema.Union(...)` descriptor field as mutations. Throwing a matching tagged error reaches the client as a typed failure from `run(...)`.
606
+
607
+ See [Error handling](/docs/data/errors) for the pattern.
608
+
609
+ ## Anti-Patterns
610
+
611
+ - **Actions used as reads.** Use a query if the client wants cached reactive data.
612
+ - **Actions used for atomic writes.** Use a mutation so subscribers never see partial state.
613
+ - **Actions used for progress feeds.** Use `defineStream` and `useAgentStream`.
614
+
615
+
616
+
617
+ ---
618
+
619
+ <!-- source: en/data/subscriptions.md -->
620
+ ## Subscriptions
621
+
622
+ _How reactive query subscriptions stay live over WebSocket._
623
+
624
+ A **subscription** is what the browser gets when it calls `useSubscription(...)` for a `defineQuery` RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.
625
+
626
+ Subscriptions are not a separate file convention anymore. The file convention is [queries](/docs/data/queries): `*.query.ts` for the descriptor and `*.query.server.ts` for the executor.
627
+
628
+ Live — add a todo (or open this page in a second tab) and the list updates with
629
+ no refetch; the `<DataTable>` is a subscription under the hood:
630
+
631
+ ```tsx
632
+ <AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
633
+ <DataTable api="app" query="todos.list" /> {/* live subscription */}
634
+ ```
635
+
636
+ ## Lifecycle
637
+
638
+ ```tsx
639
+ const { data } = useSubscription('app', 'notes.list', { archived: false })
640
+ ```
641
+
642
+ 1. The client opens or reuses the API WebSocket.
643
+ 2. It sends the query tag and input.
644
+ 3. The runtime runs `notes.list.query.server.ts`.
645
+ 4. The first value is delivered as a snapshot.
646
+ 5. Later mutations emit change events when their transaction commits.
647
+ 6. Matching query subscribers receive the updated value.
648
+
649
+ From React, `data` just changes. There is no `refetch` call.
650
+
651
+ ## Query Descriptor
652
+
653
+ ```ts
654
+ // apps/api/queries/notes.list.query.ts
655
+ import { defineQuery } from '@voltro/protocol'
656
+ import { Schema } from 'effect'
657
+
658
+ export const listNotes = defineQuery({
659
+ name: 'notes.list',
660
+ source: 'notes',
661
+ input: Schema.Struct({ archived: Schema.Boolean }),
662
+ output: Schema.Array(Schema.Struct({
663
+ id: Schema.String,
664
+ title: Schema.String,
665
+ })),
666
+ })
667
+ ```
668
+
669
+ `source` declares which table re-runs computed queries and lets mutations with matching `target` metadata patch the client cache optimistically.
670
+
671
+ ## Query Executor
672
+
673
+ ```ts
674
+ // apps/api/queries/notes.list.query.server.ts
675
+ import { eq } from '@voltro/database'
676
+ import { database } from '../database/schema'
677
+
678
+ export default (input: { archived: boolean }) =>
679
+ database.notes.where(eq('archived', input.archived)).orderBy('createdAt', 'desc')
680
+ ```
681
+
682
+ Descriptor-returning executors get fine-grained row matching. Computed-return executors can return arrays, objects, scalars, or `null`; they should declare `source` so the runtime knows what table should re-run them.
683
+
684
+ ## `skip`
685
+
686
+ Use `{ skip }` when the input is not ready yet:
687
+
688
+ ```tsx
689
+ const { data } = useSubscription(
690
+ 'app',
691
+ 'messages.list',
692
+ { channelId },
693
+ { skip: channelId === undefined },
694
+ )
695
+ ```
696
+
697
+ While skipped, no WebSocket subscription opens and `data` stays `undefined`.
698
+
699
+ ## Streams Are Different
700
+
701
+ For non-database or transient element feeds, use [streams](/docs/data/streams), not subscriptions:
702
+
703
+ ```tsx
704
+ const ticker = useAgentStream('app', 'ticker.watch')
705
+ ticker.start({ symbol: 'BTC' })
706
+ ```
707
+
708
+ Queries/subscriptions are for live state. Streams are for one-shot element flows such as tokens, progress events, and import logs.
709
+
710
+ ## Reconnect
711
+
712
+ On reconnect, the client re-subscribes to active queries and receives a fresh snapshot. Optimistic patches are client-local and are reverted when their mutation settles.
713
+
714
+ ## Tenant Scoping
715
+
716
+ Tables with the `tenant()` mixin are scoped by the runtime using `ctx.request.subject.tenantId`. Do not add duplicate tenant predicates in query executors unless you are deliberately narrowing further.
717
+
718
+ ## Inspect
719
+
720
+ The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.
721
+
722
+ ## See also
723
+
724
+ - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
725
+ - [Streams](/docs/data/streams) — transient, non-database element feeds (no snapshot/reconnect replay).
726
+
727
+
728
+
729
+ ---
730
+
731
+ <!-- source: en/data/streams.md -->
732
+ ## Streams
733
+
734
+ _`*.stream.ts` + `*.stream.server.ts` pairs — one-shot server-to-client element streams with defineStream and useAgentStream._
735
+
736
+ A **stream** is a one-shot server-to-client feed. It emits plain elements, in order, and then finishes. It is not reactive, does not have snapshots or patches, and does not participate in auto-optimistic cache updates.
737
+
738
+ Use streams for transient work: LLM token events, progress updates, import logs, provider events, or any server-side operation where the client should see elements as they arrive.
739
+
740
+ Live — click Start and `{ n }` elements arrive one at a time (150ms apart), then
741
+ the stream finishes (one-shot push, no snapshot/delta):
742
+
743
+ ```tsx
744
+ const s = useAgentStream('app', 'progress.count')
745
+ s.start({ to: 20 }) // s.events appends as elements arrive
746
+ ```
747
+
748
+ ## Minimal stream pair
749
+
750
+ Descriptor:
751
+
752
+ ```ts
753
+ // apps/api/streams/ticker.stream.ts
754
+ import { defineStream } from '@voltro/protocol'
755
+ import { Schema } from 'effect'
756
+
757
+ export const ticker = defineStream({
758
+ name: 'ticker.watch',
759
+ input: Schema.Struct({ symbol: Schema.String }),
760
+ element: Schema.Struct({
761
+ price: Schema.Number,
762
+ at: Schema.Date,
763
+ }),
764
+ })
765
+ ```
766
+
767
+ Server executor:
768
+
769
+ ```ts
770
+ // apps/api/streams/ticker.stream.server.ts
771
+ import { Stream } from 'effect'
772
+
773
+ export default (input: { symbol: string }) =>
774
+ Stream.fromAsyncIterable(
775
+ externalTickerStream(input.symbol),
776
+ (error) => error,
777
+ )
778
+ ```
779
+
780
+ The executor can return `Stream`, `Effect<Stream>`, or a `Promise<Stream>`. The runtime binds it with `bindStream` and interrupts it when the client cancels or disconnects.
781
+
782
+ ## Client hook
783
+
784
+ `useAgentStream` consumes any `defineStream` RPC, not only AI agents:
785
+
786
+ ```tsx
787
+ import { useAgentStream } from '@voltro/client'
788
+
789
+ const ticker = useAgentStream<{ price: number; at: Date }>('app', 'ticker.watch')
790
+
791
+ return (
792
+ <>
793
+ <button onClick={() => ticker.start({ symbol: 'BTC' })}>Start</button>
794
+ <button onClick={ticker.cancel}>Cancel</button>
795
+ <ul>
796
+ {ticker.events.map((event, index) => (
797
+ <li key={index}>{event.price}</li>
798
+ ))}
799
+ </ul>
800
+ </>
801
+ )
802
+ ```
803
+
804
+ State shape:
805
+
806
+ | Field | Meaning |
807
+ |---|---|
808
+ | `events` | Elements received so far, in order. |
809
+ | `status` | `'idle'`, `'streaming'`, `'done'`, or `'error'`. |
810
+ | `error` | Last stream failure when `status === 'error'`. |
811
+ | `start(input?)` | Starts a new stream and clears previous events. |
812
+ | `cancel()` | Interrupts the in-flight stream. |
813
+
814
+ ## Stream vs query
815
+
816
+ | | Query | Stream |
817
+ |---|---|---|
818
+ | Files | `*.query.ts` + `*.query.server.ts` | `*.stream.ts` + `*.stream.server.ts` |
819
+ | Descriptor field | `output` | `element` |
820
+ | Client hook | `useSubscription` | `useAgentStream` |
821
+ | Wire element | Subscription event envelope | Plain element |
822
+ | Reactivity | Re-runs on matching writes | No automatic reactivity |
823
+ | Persistence | Usually backed by database state | Transient unless you persist yourself |
824
+ | Best for | Live data views | Token/progress/event feeds |
825
+
826
+ If users should be able to reload the page and still see the result, persist rows and expose them through a query. If the stream is just live progress for the current run, keep it as a stream.
827
+
828
+ ## AI streams
829
+
830
+ Transient agent runs are just streams whose elements are token/tool events. `useAgent` is an ergonomic wrapper over `useAgentStream`; durable chat uses a different pattern: an action writes/patches `agent_messages`, and a query streams those persisted rows.
831
+
832
+ See [AI streaming](/docs/ai/streaming) and [Agents](/docs/ai/agents) for those patterns.
833
+
834
+ ## Anti-patterns
835
+
836
+ - **Using streams as a database subscription replacement.** Queries already do this and handle reconnect snapshots.
837
+ - **Streaming data that must be durable.** Put it in a table and subscribe to a query.
838
+ - **Forgetting cancellation.** Long streams should release upstream resources when interrupted.
839
+
840
+ ## See also
841
+
842
+ - [Subscriptions](/docs/data/subscriptions) — live, reactive query data with snapshot + reconnect replay (use this for durable row sets).
843
+ - [Subscribers (`*.subscribe.ts`)](/docs/data/subscribers) — server-side post-commit reactivity to a table.
844
+
845
+
846
+
847
+ ---
848
+
849
+ <!-- source: en/data/rest-routes.md -->
850
+ ## REST routes
851
+
852
+ _defineRestRoute — public raw-HTTP endpoints (URL + JSON) for third parties that don't speak the rpc WebSocket. Schema-typed input/output, guards, deprecation/sunset, mounted on the same listener._
853
+
854
+ The five primitives above ride one WebSocket — great for your own UI, wrong for a third party calling your API with a plain URL + JSON body. `defineRestRoute` (from `@voltro/protocol/rest`) is the public raw-HTTP surface: a schema-typed request/response endpoint that desugars to exactly one HTTP route on the **same** listener the rpc server + plugin routes use. It is NOT a separate runtime.
855
+
856
+ ```tsx
857
+ // routes/v1/customers.list.route.tsx
858
+ import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
859
+ import { Schema } from 'effect'
860
+
861
+ export default defineRestRoute({
862
+ method: 'GET',
863
+ path: '/v1/customers',
864
+ // Input shape is `{ query?, params?, body? }`. The desugar parses the
865
+ // query string, path params, and JSON body into this, then decodes it.
866
+ input: Schema.Struct({
867
+ query: Schema.Struct({
868
+ limit: Schema.optional(Schema.Number.pipe(Schema.between(1, 100))),
869
+ cursor: Schema.optional(Schema.String),
870
+ }),
871
+ }),
872
+ output: Schema.Struct({ data: Schema.Array(Customer), nextCursor: Schema.NullOr(Schema.String) }),
873
+ summary: 'List customers', // OpenAPI metadata — inert until a generator consumes it
874
+ guards: [requireScope('customers:read')], // → 403 when the subject lacks the scope
875
+ handler: async ({ query }, ctx) => {
876
+ const limit = query.limit ?? 20
877
+ // ctx = { subject, headers, store }. The auth resolver populates
878
+ // `subject`; `store` is the framework DataStore (cast at the call site).
879
+ return { data, nextCursor }
880
+ },
881
+ })
882
+ ```
883
+
884
+ ## Registration
885
+
886
+ REST routes are NOT auto-discovered by file suffix — register them explicitly via `restRoutes` in `app.config.ts` (the `*.route.tsx` filename is convention, not magic):
887
+
888
+ ```ts
889
+ import listCustomers from './routes/v1/customers.list.route'
890
+
891
+ export default {
892
+ type: 'api' as const,
893
+ name: 'publicApi',
894
+ restRoutes: [listCustomers],
895
+ }
896
+ ```
897
+
898
+ The module's `default` export is the descriptor — one descriptor per file, the same one-procedure-per-file discipline as `*.query.ts` / `*.mutation.ts`.
899
+
900
+ ## The request lifecycle (the desugar)
901
+
902
+ Each descriptor becomes one HTTP route. Per request, in order:
903
+
904
+ | Step | Outcome on failure |
905
+ |---|---|
906
+ | 1. Method gate | `405 Method Not Allowed` (`Allow:` header) |
907
+ | 2. Sunset gate (past `sunset` date) | `410 Gone` + replacement pointer |
908
+ | 3. Input decode (`{ query, params, body }` → schema) | `400 Bad Request` |
909
+ | 4. Guards (after decode + ctx resolution) | the guard's `{ status, message }` (e.g. `403`) |
910
+ | 5. `await handler(input, ctx)` | a thrown `{ status, message }` → that status; anything else → `500` |
911
+ | 6. Output encode → JSON | `200 OK` |
912
+
913
+ `deprecated` sets a `Deprecation: true` response header; `sunset` (an ISO date) sets a `Sunset:` header and, once past, flips the route to `410`. Both are pure metadata otherwise — the route keeps working until the sunset date.
914
+
915
+ ## `ctx` — what the handler gets
916
+
917
+ ```ts
918
+ interface RestRouteContext {
919
+ readonly subject: Subject // resolved by the auth resolver
920
+ readonly headers: Readonly<Record<string, string>> // request headers
921
+ readonly store: unknown // framework DataStore — cast at the call site
922
+ }
923
+ ```
924
+
925
+ The same `AuthMiddleware`/`ConnectionInfoMiddleware` that gate the rpc surface run here too, so a forwarded session cookie resolves the same `Subject` + tenant as the WebSocket path.
926
+
927
+ ## Guards
928
+
929
+ A `RestGuard` runs after input decode, before the handler. Return `undefined` to proceed, or `{ status, message }` to short-circuit:
930
+
931
+ ```ts
932
+ import { requireScope, type RestGuard } from '@voltro/protocol/rest'
933
+
934
+ // Built-in: rejects 403 unless the subject carries the scope (admin '*' bypasses).
935
+ guards: [requireScope('customers:read')]
936
+
937
+ // Custom guard:
938
+ const requireApiKey: RestGuard = (ctx) =>
939
+ ctx.subject.type === 'apiKey' ? undefined : { status: 401, message: 'API key required' }
940
+ ```
941
+
942
+ ## Idempotency (`Idempotency-Key`)
943
+
944
+ Set `idempotency: true` in `app.config.ts` and every mutating REST request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries an `Idempotency-Key` header is deduplicated:
945
+
946
+ ```ts
947
+ // app.config.ts
948
+ export default { type: 'api' as const, name: 'api', restRoutes: [chargeRoute], idempotency: true }
949
+ // or: idempotency: { header: 'Idempotency-Key', ttlMs: 86_400_000 }
950
+ ```
951
+
952
+ ```bash
953
+ # First call runs the handler + caches the response.
954
+ curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
955
+ # → { "chargeId": "uuid-1", … }
956
+
957
+ # A retry with the SAME key replays the cached response — the handler does NOT run again.
958
+ curl -XPOST https://api.example.com/v1/charge -H 'Idempotency-Key: pay-abc' -d '{"amount":99}'
959
+ # → { "chargeId": "uuid-1", … } (+ response header `Idempotency-Replayed: true`)
960
+ ```
961
+
962
+ - **Replay** within the window returns the first response verbatim, with `Idempotency-Replayed: true`.
963
+ - **In-flight duplicate** (the first call hasn't finished) gets `409`.
964
+ - Keys are scoped per `(tenant, method, path)`, persisted in `_voltro_idempotency`, expiring after `ttlMs` (default 24h).
965
+
966
+ This is the Stripe-style contract — the **client** opts in by sending the header. It's the standard for external API clients. Scope + guarantee:
967
+
968
+ - **REST/HTTP only.** It rides the `Idempotency-Key` HTTP header. The WS rpc transport (`useMutation` from your own frontend) has no per-call header — guard double-submit there with optimistic UI + a disabled button, not server idempotency.
969
+ - **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
970
+ - **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.
971
+
972
+ ## REST route vs Action
973
+
974
+ Both are unary request/response. Pick by transport + audience:
975
+
976
+ | | [Action](/docs/data/actions) | REST route |
977
+ |---|---|---|
978
+ | Transport | rpc over WebSocket (+ `POST /rpc`) | public raw HTTP at a URL you choose |
979
+ | Caller | your own UI via `useAction` | third parties with `fetch` / curl / SDKs |
980
+ | Wire shape | the rpc JSON envelope | plain JSON body + status codes |
981
+ | Typed errors | `Schema.TaggedError` on the rpc channel | HTTP status codes (`throw { status, message }`) |
982
+
983
+ Use an action when your own client calls it; use a REST route when an external system needs a stable, documented URL. For signed inbound webhooks (Stripe, GitHub, …) use [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) instead — it adds signature verification + idempotency on top.
984
+
985
+
986
+
987
+ ---
988
+
989
+ <!-- source: en/data/aggregates.md -->
990
+ ## Aggregates
991
+
992
+ _Pre-defined materialised queries that refresh on a schedule. The framework owns the lifecycle — discovery, refresh timer, cached rows, staleness metadata, dashboard surface._
993
+
994
+ > **Not what you want?** For an on-demand `count` / `sum` / `groupBy` / window that runs when called (not on a schedule), see [Aggregations](/docs/database/aggregations) — the query-builder methods. This page is the SCHEDULED, materialised `*.aggregate.ts` convention.
995
+
996
+ Use an aggregate for **a pre-defined query whose result should be computed once and served fast for many reads**. Top-N leaderboards. Most-used X over Y. Daily/weekly summary snapshots. Anything where the source query is expensive but the result is small and bounded.
997
+
998
+ The framework discovers `*.aggregate.ts` files at boot, runs the build function on a schedule, holds the rows in a cache, and serves them via `useAggregate(def).read(...)`. You don't write a backing table, a refresh timer, a staleness check, or a dashboard surface — they're all part of the convention.
999
+
1000
+ ## Why this instead of `*.cron.tsx`
1001
+
1002
+ You could put the same logic in a cron handler: query the source, delete the cache table, insert the new rows. The aggregate convention earns its slot by owning seven things cron leaves to you:
1003
+
1004
+ | Capability | Cron handler | `*.aggregate.ts` |
1005
+ |---|---|---|
1006
+ | Auto-create + manage backing storage | ❌ user defines table + migration | ✅ framework owns lifecycle |
1007
+ | Atomic refresh (no empty-window for readers) | ❌ DELETE-then-INSERT shows empty briefly | ✅ transactional swap |
1008
+ | `refreshedAt` metadata per aggregate | ❌ track manually | ✅ first-class |
1009
+ | Staleness-aware reads (`maxAgeMs` throw) | ❌ none | ✅ explicit |
1010
+ | Refresh overlap handling | ⚠️ generic cron | ✅ aggregate-specific |
1011
+ | `meta.lastError` for failures | ❌ user logs | ✅ surfaced |
1012
+ | Dashboard integration | ⚠️ generic cron entry | ✅ aggregate-specific (rows, last error, staleness) |
1013
+
1014
+ ## File shape
1015
+
1016
+ ```ts
1017
+ // apps/api/aggregates/topPlayers.aggregate.ts
1018
+ import { defineAggregate } from '@voltro/runtime'
1019
+ import { Schema } from 'effect'
1020
+ import { gt, gte, and } from '@voltro/database'
1021
+ import { database } from '../database/index'
1022
+ import { yearStart } from '../lib/time'
1023
+
1024
+ export const TopPlayer = Schema.Struct({
1025
+ playerId: Schema.String,
1026
+ rank: Schema.Number,
1027
+ kd: Schema.Number,
1028
+ })
1029
+ export type TopPlayer = Schema.Schema.Type<typeof TopPlayer>
1030
+
1031
+ export default defineAggregate({
1032
+ name: 'topPlayers',
1033
+ refresh: '1h', // simple interval string
1034
+
1035
+ output: TopPlayer, // every row decoded against this
1036
+
1037
+ // Optional B-tree indexes on the DB-backed storage — honoured on the
1038
+ // SQL stores; a no-op on the in-memory store (no secondary indexes).
1039
+ indexes: [['rank']],
1040
+
1041
+ // The build function. Same `ctx.store` a mutation handler sees.
1042
+ build: async (ctx) => {
1043
+ const players = await ctx.store.query(
1044
+ database.players
1045
+ .where(and(gt('lastMatchAt', yearStart()), gte('kdRatio', 3.0)))
1046
+ .orderBy('kdRatio', 'desc')
1047
+ .take(100)
1048
+ .descriptor,
1049
+ )
1050
+ return players.map((p, i) => ({
1051
+ playerId: p.id as string,
1052
+ rank: i + 1,
1053
+ kd: p.kdRatio as number,
1054
+ }))
1055
+ },
1056
+ })
1057
+ ```
1058
+
1059
+ The default export must be the return value of `defineAggregate({...})`. The cli identifies files by checking for that branded shape — extra exports (like `TopPlayer` for the row type) are fine and conventional.
1060
+
1061
+ ## Reading from a handler
1062
+
1063
+ ```ts
1064
+ import { useAggregate } from '@voltro/runtime'
1065
+ import topPlayersDef, { type TopPlayer } from '../aggregates/topPlayers.aggregate'
1066
+
1067
+ export default (input, _ctx) => Effect.gen(function* () {
1068
+ const handle = yield* useAggregate(topPlayersDef)
1069
+ const top10: ReadonlyArray<TopPlayer> = yield* handle.read({ limit: 10 })
1070
+ return { top10 }
1071
+ })
1072
+ ```
1073
+
1074
+ The reading API is **explicit + namespace-only** — `useAggregate(def).read(...)`, not `database.topPlayers.findAll()`. This is a deliberate API choice (see [Why no virtual-table integration](#why-no-virtual-table-integration) below).
1075
+
1076
+ ### Read options
1077
+
1078
+ ```ts
1079
+ handle.read({
1080
+ limit: 10, // pagination
1081
+ offset: 20,
1082
+ orderBy: 'rank', // any column in the output schema
1083
+ direction: 'desc',
1084
+ maxAgeMs: 10 * 60_000, // throws AggregateStale if last refresh older
1085
+ })
1086
+ ```
1087
+
1088
+ There's deliberately no `filter` / `where` / `join`. If you need a different cut of the data, define another aggregate.
1089
+
1090
+ ### Metadata
1091
+
1092
+ For dashboards, "data was last refreshed X minutes ago" UI labels, and operational health:
1093
+
1094
+ ```ts
1095
+ const meta = yield* handle.meta()
1096
+ // {
1097
+ // refreshedAt: Date | null, // null until first refresh
1098
+ // rowCount: number, // last refresh result count
1099
+ // durationMs: number, // how long the build function took
1100
+ // lastError: string | null, // last refresh error (null on success)
1101
+ // nextRefreshAt: Date | null, // when the next interval fires
1102
+ // }
1103
+ ```
1104
+
1105
+ `lastError` keeps the last failure visible without dropping the previous good rows — failing refreshes preserve last-good data. Reading `meta` after a failed refresh tells you the run failed; reading the rows still serves the last successful result.
1106
+
1107
+ ### Manual refresh
1108
+
1109
+ ```ts
1110
+ yield* handle.refresh()
1111
+ ```
1112
+
1113
+ Triggers an immediate refresh from the current handler. Use for "Run now" dashboard buttons or for cron-fed callers (your `*.cron.tsx` can drive aggregate refresh in custom windows that the aggregate's own interval policy doesn't cover).
1114
+
1115
+ ## Refresh policies
1116
+
1117
+ Three forms — all three honoured at runtime:
1118
+
1119
+ ```ts
1120
+ refresh: '5m' // interval (string)
1121
+
1122
+ refresh: { cron: '0 2 * * *', timezone: 'UTC' } // cron expression
1123
+
1124
+ refresh: { interval: '1h', onChange: ['matches'], debounceMs: 30_000 } // hybrid
1125
+ ```
1126
+
1127
+ - **Interval string** — fires every X. Parses `<n><unit>` where unit is `ms` / `s` / `m` / `h` / `d`. Invalid strings fail boot loudly.
1128
+ - **Cron expression** — `{ cron: '<expr>', timezone: '<iana-tz>' }`. The framework computes next-firing via the same `compileCron` / `Cron.next` the `*.cron.tsx` engine uses.
1129
+ - **Hybrid** — `{ interval, onChange: [<table>, ...], debounceMs }`. The interval is the upper bound; on every change to one of the listed tables, the framework debounces by `debounceMs` before firing. Use for "freshness on write but capped staleness".
1130
+
1131
+ ## Incremental maintenance (IVM)
1132
+
1133
+ By default an aggregate **recomputes** — it re-runs its build query on each refresh
1134
+ (interval / cron / onChange). For a **bounded** aggregate you can opt into
1135
+ **incremental view maintenance**: each CDC delta on the source table updates the
1136
+ maintained value in place, with no full re-query.
1137
+
1138
+ ```ts
1139
+ export default defineAggregate({
1140
+ name: 'sales.byRegion',
1141
+ refresh: { onChange: ['orders'] },
1142
+ output: SalesByRegion,
1143
+ incremental: {
1144
+ source: 'orders',
1145
+ op: 'sum', // exactly one of count / sum / avg / min / max
1146
+ column: 'amount', // required for everything but count
1147
+ groupBy: ['region'], // one maintained row per group (optional)
1148
+ toRow: (g) => ({ region: g.group['region'] as string, total: g.value }),
1149
+ },
1150
+ })
1151
+ ```
1152
+
1153
+ **The shape must be maintainable** — `defineAggregate` throws at import (via
1154
+ `classifyShape`) otherwise:
1155
+
1156
+ - exactly **one** op, from `count` / `sum` / `avg` / `min` / `max`;
1157
+ - a single `source` table (no joins);
1158
+ - no window functions, no `distinct`, no `having`.
1159
+
1160
+ How it maintains, per delta on the source:
1161
+
1162
+ - **count / sum / avg** — applied directly (insert adds, delete subtracts, update
1163
+ adjusts); O(1) per change.
1164
+ - **min / max** — applied directly on insert/raise; a delete/lower of the current
1165
+ extreme triggers a **bounded rescan** of that one group to find the new extreme.
1166
+
1167
+ When a shape can't be incremental, leave `incremental` off and use a refresh
1168
+ policy — the engine recomputes. Incremental is an optimisation for hot, bounded
1169
+ aggregates, not a different result.
1170
+
1171
+ ## Boot strategy
1172
+
1173
+ Each aggregate declares `bootRefresh` (default `'persistent'`). All three modes are wired in the aggregate runner (`attachAggregates`):
1174
+
1175
+ | Mode | Behaviour | Use when |
1176
+ |---|---|---|
1177
+ | `'persistent'` (default) | Storage is rehydrated from the prior run's rows, so reads return the previous-good result immediately on restart; an async refresh then runs in the background and the first scheduled refresh fires at its natural time. | Most aggregates — best UX, no empty window on restart. |
1178
+ | `'async'` | Backing storage is reset at boot; the first refresh fires on the next tick, so reads return `[]` until it completes. | Test/dev fresh-start, or when stale data on restart would mislead. |
1179
+ | `'sync'` | Boot BLOCKS until the first refresh completes — slow boot, never serves stale data. | Pricing / business-critical computations. |
1180
+
1181
+ A fresh-boot empty window only happens under `'async'`. If that would cause real bugs and you don't want `'sync'`'s slow boot, gate reads on `meta().refreshedAt` (or use `read({ maxAgeMs })` to throw `AggregateStale`).
1182
+
1183
+ ## Failure semantics
1184
+
1185
+ A build function that throws **keeps the last-good rows in the cache**. The error message lands in `meta.lastError`. Reads continue to serve the prior result.
1186
+
1187
+ This is intentional: a failing refresh shouldn't take down reads of the previous good aggregate. Last-good-data beats empty-on-fail.
1188
+
1189
+ A build function that returns rows that fail `output` decode also records `lastError` (`output schema mismatch: row[N] failed output-schema decode at <path>: <issue>`). The cache stays at the last successful refresh.
1190
+
1191
+ A second refresh starting while the first is still running is **skipped** — overlap-handling is `'skip'` by design. Long-running build functions don't pile up calls; they just slow the effective refresh rate.
1192
+
1193
+ ## Storage backends
1194
+
1195
+ The framework picks per-store:
1196
+
1197
+ - **Memory store** → in-process state, per-replica. Lost on restart; multi-instance fans out duplicate work. Fine for dev / single-pod deployments.
1198
+ - **SQL stores** (postgres / mysql / mariadb / mssql / sqlite / turso) → cross-dialect JSON-payload backing (`_voltro_aggregate_meta` + `_voltro_aggregate_rows` tables, auto-created on first boot). Atomic refresh via a generation counter — readers always see either the old generation or the new, never a mid-swap mix. Per-process in-memory mirror keeps reads sub-millisecond on the hot path.
1199
+
1200
+ ## Cluster coordination
1201
+
1202
+ Aggregates inherit the framework's existing coordination story (plan 45). On `store: 'memory'` the runner uses `singleCoordinator` (one process); on real SQL stores it uses an `advisoryLockCoordinator` so multi-instance deployments fire exactly-one refresh per scheduled tick. No config needed — the runner picks based on the resolved store dialect.
1203
+
1204
+ ## Inspect surface
1205
+
1206
+ Two endpoints under `/_voltro/inspect/`:
1207
+
1208
+ ```
1209
+ GET /_voltro/inspect/aggregates — snapshot all + meta
1210
+ POST /_voltro/inspect/aggregates/<name>/refresh — manual "Run now"
1211
+ ```
1212
+
1213
+ The cloud dashboard's Aggregates panel consumes these. Same auth resolver gates the surface as the rest of `/_voltro/inspect/*` (`VOLTRO_INSPECT_TOKEN` by default).
1214
+
1215
+ ## Refresh strategy — `'replace'` vs `'merge'`
1216
+
1217
+ `strategy` controls how each refresh applies its rows:
1218
+
1219
+ ```ts
1220
+ defineAggregate({
1221
+ name: 'topPlayers',
1222
+ refresh: '1h',
1223
+ output: TopPlayer,
1224
+ strategy: 'replace', // default — every refresh fully replaces the previous result
1225
+ build: async (ctx) => { ... },
1226
+ })
1227
+ ```
1228
+
1229
+ ```ts
1230
+ defineAggregate({
1231
+ name: 'playerWins',
1232
+ refresh: '5m',
1233
+ output: PlayerWins,
1234
+ strategy: 'merge',
1235
+ mergeKey: 'playerId', // required for 'merge' — each row's identity field
1236
+ build: async (ctx) => {
1237
+ // Only return players whose stats CHANGED since the last refresh.
1238
+ // Players not returned this time KEEP their previous values.
1239
+ return getPlayersWithRecentActivity()
1240
+ },
1241
+ })
1242
+ ```
1243
+
1244
+ **`'replace'`** (default) — every refresh fully replaces the previous result. Rows from a prior refresh that the new build didn't return are dropped. Best for top-N leaderboards, summaries, snapshot-style aggregates.
1245
+
1246
+ **`'merge'`** — upsert by `mergeKey`. Each row from `build()` either UPDATEs an existing entry (matched by `row[mergeKey]`) or INSERTs a new one. Prior rows whose key isn't in the new batch STAY. Best for "track per entity" patterns where each refresh only needs to recompute the changed entities (active players in the last hour, weapons used today, etc.). The `mergeKey` field MUST exist on every row your `build` returns — `defineAggregate` throws at registration if `strategy: 'merge'` is set without a `mergeKey`.
1247
+
1248
+ Mental model: `'replace'` is "snapshot at time T"; `'merge'` is "incremental delta into a long-lived aggregate". Pick `'replace'` when an aggregate's value is the FULL recomputation; pick `'merge'` when each refresh contributes only the deltas.
1249
+
1250
+ ## Why no virtual-table integration
1251
+
1252
+ The defining property of an aggregate is **the query is fixed in advance**. Treating it as a query-buildable virtual table (`database.topPlayers.where(...)`) opens four footguns:
1253
+
1254
+ 1. **Hidden staleness.** `database.topPlayers.where(...)` looks like a live query. Readers can't tell it's stale data.
1255
+ 2. **Computation drift.** Adding `WHERE region='EU'` shifts the filter from refresh-time to read-time — the materialisation point IS the query; don't re-query it.
1256
+ 3. **Misleading expectations.** Users would reflexively try `database.topPlayers.insert(...)`. Framework would either silently do nothing or error with a cryptic message.
1257
+ 4. **Cross-timeline joins.** Joining an aggregate with a live table mixes two timelines (refresh-time + now). Mostly a footgun.
1258
+
1259
+ The explicit namespace (`useAggregate(def).read(...)`) makes the materialisation explicit. Friction in the wrong direction (filtering, joining) is a feature — it pushes you to either define another aggregate or do the work in app code with clear boundaries.
1260
+
1261
+ ## Decision: aggregate vs subscriber vs cron
1262
+
1263
+ | What you want | Use |
1264
+ |---|---|
1265
+ | In-transaction work tied to a write | mutation handler |
1266
+ | Reject the insert | `table().validate(Schema)` (schema DSL) |
1267
+ | Derive a value at write time | `column.computed(row => ...)` / `column.default(() => ...)` |
1268
+ | Best-effort post-commit per-row reactivity | [`*.subscribe.ts`](/docs/data/subscribers) |
1269
+ | Crash-safe post-commit reactivity | workflow invoked from `*.subscribe.ts` |
1270
+ | **Periodically-refreshed pre-computed query** | **`*.aggregate.ts`** (this) |
1271
+ | Event ingestion + time-bucketed aggregates over events | [Analytics sink](/docs/plugins/analytics) |
1272
+ | Real OLAP / 50M+ rows / arbitrary SQL | warehouse plugin (ClickHouse, DuckDB, Tinybird) |
1273
+
1274
+ ## Cross-plan: querying the warehouse from a build function
1275
+
1276
+ The build function gets `ctx.analytics` alongside `ctx.store` — the configured [`AnalyticsSink`](/docs/plugins/analytics). `ctx.store` reads the main DataStore (OLTP); `ctx.analytics` reads the warehouse (OLAP: DuckDB / ClickHouse / postgres-lite). This is the first-class path for "reduce 10B warehouse events to a 100-row leaderboard materialised in the main store for fast reads":
1277
+
1278
+ ```ts
1279
+ import { defineAggregate } from '@voltro/runtime'
1280
+ import { Effect, Schema } from 'effect'
1281
+
1282
+ export const TopPlayer = Schema.Struct({
1283
+ playerId: Schema.String,
1284
+ rank: Schema.Number,
1285
+ wins: Schema.Number,
1286
+ })
1287
+
1288
+ export default defineAggregate({
1289
+ name: 'topPlayers',
1290
+ refresh: '1h',
1291
+ output: TopPlayer,
1292
+ indexes: [['rank']],
1293
+ build: (ctx) =>
1294
+ Effect.gen(function* () {
1295
+ // Warehouse query — reduces millions of events to the top 100.
1296
+ const top = yield* ctx.analytics.topN({
1297
+ event: 'match_completed',
1298
+ groupBy: 'playerId',
1299
+ metric: 'count',
1300
+ n: 100,
1301
+ range: { from: new Date(Date.now() - 30 * 86_400_000) },
1302
+ })
1303
+ return top.map((entry, i) => ({ playerId: entry.key, rank: i + 1, wins: entry.value }))
1304
+ }),
1305
+ })
1306
+ ```
1307
+
1308
+ `ctx.analytics` exposes the full sink contract — `topN`, `aggregate`, `timeseries`, and `track`. Like `ctx.store`, the build function runs as the system (no per-request subject).
1309
+
1310
+ When no analytics sink is configured the framework provides the no-op sink: the read methods fail with `AnalyticsCapabilityNotSupported({ provider: 'noop' })`, which surfaces as a refresh error (last-good rows are preserved). Provider-specific queries beyond the four-method contract (raw SQL, HyperLogLog) live outside the sink — there is no raw-client escape hatch; query the provider with your own client instance inside the build function where you need them.
1311
+
1312
+
1313
+
1314
+ ---
1315
+
1316
+ <!-- source: en/data/subscribers.md -->
1317
+ ## Subscribers
1318
+
1319
+ _Per-table post-commit reactivity via file convention. Default-exported defineSubscriber({ table, on, handler }) — fires AFTER commit, best-effort, fire-and-forget for async handlers._
1320
+
1321
+ Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index, emit an external notification, invalidate a cache, push to a worker queue. The file convention is parallel to `*.startup.ts` / `*.cron.tsx` / `*.webhook.tsx`: drop a file matching the suffix anywhere under `apps/<api>/`, default-export a `defineSubscriber({...})`, the framework discovers + binds it at boot.
1322
+
1323
+ Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work, have the subscriber invoke a workflow.
1324
+
1325
+ ## File shape
1326
+
1327
+ ```ts
1328
+ // apps/api/subscribers/auditUserChanges.subscribe.ts
1329
+ import { defineSubscriber } from '@voltro/runtime'
1330
+
1331
+ export default defineSubscriber({
1332
+ table: 'users',
1333
+ on: 'any', // 'insert' | 'update' | 'delete' | 'any' | ['insert','delete']
1334
+ handler: async (event, ctx) => {
1335
+ ctx.log.info('user changed', {
1336
+ op: event.op,
1337
+ id: event.new?.id ?? event.old?.id,
1338
+ })
1339
+ // event.new — present on insert + update; null on delete
1340
+ // event.old — present on update + delete; null on insert
1341
+ },
1342
+ })
1343
+ ```
1344
+
1345
+ The default export must be a `defineSubscriber({...})` result. The file is identified by suffix (`*.subscribe.ts` / `*.subscribe.tsx`).
1346
+
1347
+ ## What fires when
1348
+
1349
+ The framework binds to the store's `onChange` channel. Subscribers fire **after the transaction commits** — the row IS persisted when your handler runs. This means:
1350
+
1351
+ - **You can read the just-committed row** via `event.new` (it's the committed value).
1352
+ - **You CAN'T reject the change** — by the time the handler runs, the transaction has landed. For pre-write rejection, use [`table().validate(Schema)`](/docs/database/columns#table-level-validation).
1353
+ - **No transaction boundary.** Side effects you do in the handler are not rolled back if some later operation fails.
1354
+
1355
+ The `on` filter narrows by operation:
1356
+
1357
+ - `'any'` (default) — every insert/update/delete fires the handler
1358
+ - `'insert'` / `'update'` / `'delete'` — single op
1359
+ - `['insert', 'delete']` — array of ops
1360
+
1361
+ Other-table events get filtered out before your handler sees them. The matcher does this at the dispatcher level so subscribers add zero hot-path overhead to writes that don't match their table.
1362
+
1363
+ ## Semantics — best-effort, fire-and-forget
1364
+
1365
+ Subscribers are **non-durable** by design:
1366
+
1367
+ - **A throw doesn't fail the request.** The original mutation has already committed. The framework logs the failure (scoped to `subscribe:<filename>`) and the next event still arrives.
1368
+ - **Async handlers are NOT awaited by the dispatcher.** Fire-and-forget — a slow handler can't back-pressure the change stream. Errors propagate to the structured log via `.catch()`, but the change-emission path returns immediately.
1369
+ - **No retry, no resume.** If the process crashes mid-handler, the work is gone. Same if the network call inside the handler fails — there's no built-in retry policy.
1370
+
1371
+ If you need any of those properties (transactional, durable, retried), invoke a workflow from inside the subscriber:
1372
+
1373
+ ```ts
1374
+ export default defineSubscriber({
1375
+ table: 'orders',
1376
+ on: 'insert',
1377
+ handler: async (event, ctx) => {
1378
+ // Best-effort kickoff. The workflow itself is crash-safe + retried.
1379
+ await ctx.app.workflowClient.send('orders.fulfill', {
1380
+ orderId: event.new!.id as string,
1381
+ })
1382
+ },
1383
+ })
1384
+ ```
1385
+
1386
+ The workflow body owns durability. The subscriber's job is just "translate row-change into workflow invocation".
1387
+
1388
+ ## Why this and not a regular subscription?
1389
+
1390
+ A `useSubscription('app', 'todos.list')` is the right tool when the **client** wants live data — the framework pushes a delta over WebSocket and React re-renders. Subscribers are the server-side equivalent: when something **on the server** wants to react to a write — emit a notification, refresh an index, push to a queue — without round-tripping through a connected client.
1391
+
1392
+ | What's reacting | Use |
1393
+ |---|---|
1394
+ | The browser, to show fresh data | `useSubscription` in a hook |
1395
+ | A server-side action, every commit | `*.subscribe.ts` (this) |
1396
+ | A server-side action, eventually-consistent + durable | `*.subscribe.ts` that kicks off a workflow |
1397
+ | A server-side action, in the same transaction | Do the work inside the mutation handler |
1398
+
1399
+ ## Decision tree — picking the right seam
1400
+
1401
+ | What you want | Use |
1402
+ |---|---|
1403
+ | In-transaction work tied to a write | mutation handler |
1404
+ | Reject the insert | [`table().validate(Schema)`](/docs/database/columns#table-level-validation) |
1405
+ | Derive a value at write time | [`column.computed(row => ...)`](/docs/database/columns#computed-columns) / [`column.default(() => ...)`](/docs/database/columns#default-with-callback) |
1406
+ | **Best-effort post-commit reactivity per row** | **`*.subscribe.ts`** (this) |
1407
+ | Crash-safe post-commit reactivity | workflow invoked from `*.subscribe.ts` |
1408
+ | Periodically-refreshed pre-computed query | [`*.aggregate.ts`](/docs/data/aggregates) |
1409
+ | Event ingestion + analytical aggregates | [Analytics sink](/docs/plugins/analytics) |
1410
+
1411
+ ## Common patterns
1412
+
1413
+ ### Refresh a search index
1414
+
1415
+ ```ts
1416
+ import { meiliClient } from '../lib/meili'
1417
+
1418
+ export default defineSubscriber({
1419
+ table: 'posts',
1420
+ on: ['insert', 'update'],
1421
+ handler: async (event) => {
1422
+ if (!event.new) return
1423
+ await meiliClient.index('posts').updateDocuments([event.new])
1424
+ },
1425
+ })
1426
+ ```
1427
+
1428
+ ### Mirror to an external system
1429
+
1430
+ ```ts
1431
+ export default defineSubscriber({
1432
+ table: 'organizations',
1433
+ on: 'insert',
1434
+ handler: async (event, ctx) => {
1435
+ if (!event.new) return
1436
+ const slug = event.new.slug as string
1437
+ // Best-effort sync; failure logs + continues
1438
+ await fetch('https://my-crm.example/sync', {
1439
+ method: 'POST',
1440
+ headers: { 'content-type': 'application/json' },
1441
+ body: JSON.stringify({ slug, name: event.new.name }),
1442
+ })
1443
+ },
1444
+ })
1445
+ ```
1446
+
1447
+ ### Soft-delete cleanup workflow
1448
+
1449
+ ```ts
1450
+ export default defineSubscriber({
1451
+ table: 'documents',
1452
+ on: 'update',
1453
+ handler: async (event, ctx) => {
1454
+ // Trigger a long-running cleanup ONLY when deletedAt was just set
1455
+ if (!event.new || !event.old) return
1456
+ if (event.old.deletedAt === null && event.new.deletedAt !== null) {
1457
+ await ctx.app.workflowClient.send('documents.cleanupDeleted', {
1458
+ documentId: event.new.id as string,
1459
+ })
1460
+ }
1461
+ },
1462
+ })
1463
+ ```
1464
+
1465
+ The workflow does the heavy lift (scrub child rows, notify owners, archive to cold storage); the subscriber's job is just to detect the state transition.
1466
+
1467
+ ## Failure semantics in detail
1468
+
1469
+ The framework logs subscriber failures via the structured logger — scope `subscribe:<filename>` — so you can grep + filter with `voltro logs --scope 'subscribe:*'`. Operations the handler reaches that themselves emit logs (HTTP client, store calls) keep their own scope.
1470
+
1471
+ There's no retry. If a transient failure should be retried, do one of:
1472
+
1473
+ 1. Wrap the call in your own retry policy inside the handler (`Effect.retry`, `pRetry`, etc.).
1474
+ 2. Move the work to a workflow that the subscriber just kicks off — workflows have first-class retry semantics.
1475
+
1476
+ ## By design
1477
+
1478
+ The subscriber is a deliberately thin post-commit hook — the sharp edges below are choices, not gaps. When you outgrow them, the escape hatch is a workflow (durable, retried) or handler-side logic.
1479
+
1480
+ - **One subscriber per file.** Multiple defaults exported don't compose; pick the most natural file boundary (one cohesive concern per file).
1481
+ - **Filter on `on` + table; per-row predicates live in the handler.** A predicate like "fire only when the user's plan changed" is a one-line guard at the top of the handler — kept there rather than in a framework pre-filter so the matching rule sits next to the code that reacts to it.
1482
+ - **No batching.** Each commit fires its subscribers individually. If a hot write path needs batched network calls, batch inside the handler (rolling window, debounce) — the framework doesn't impose a batching window you'd have to fight.
1483
+
1484
+ ## Configuration
1485
+
1486
+ `*.subscribe.ts` files are discovered + bound automatically by `voltro dev` / `voltro start`. There's no `app.config.ts` flag — the file existing IS the registration.
1487
+
1488
+ Subscribers fire AFTER the mutation handler commits, which happens AFTER any plugin `interceptMutation` chain returns. They're at the very tail of the write path:
1489
+
1490
+ ```
1491
+ client → AuthMiddleware → plugin interceptors → mutation handler → commit → subscriber handler
1492
+ ```
1493
+
1494
+ The subscriber sees the post-commit row, which has been auto-stamped by audit/tenant/softDelete mixins, validated against `table().validate(...)` if set, and persisted to the database.
1495
+
1496
+ ## See also
1497
+
1498
+ - [Subscriptions](/docs/data/subscriptions) — the CLIENT-side `useSubscription` hook for live query data (different concept, similar name).
1499
+ - [Streams](/docs/data/streams) — transient element feeds; for crash-safe post-commit work, invoke a workflow from a subscriber.
1500
+
1501
+
1502
+
1503
+ ---
1504
+
1505
+ <!-- source: en/data/reactions.md -->
1506
+ ## Reactions
1507
+
1508
+ _Standing reactive agents — a *.reaction.tsx watches a table and runs an agent/workflow on a change behind mandatory spend guards (dedupe / rate-limit / budget)._
1509
+
1510
+ # Reactions (`*.reaction.tsx`)
1511
+
1512
+ A **reaction** watches a table and, on a relevant change, runs an agent or
1513
+ workflow — behind MANDATORY spend guards. It's the data-driven sibling of a
1514
+ [subscriber](/docs/data/subscribers): where a subscriber is "run my code on a
1515
+ change," a reaction is "run an agent/workflow on a change, without footgunning a
1516
+ spend storm." Discovery + dispatch are automatic — drop a `*.reaction.tsx` file
1517
+ in and `voltro dev` binds it to the post-commit change stream.
1518
+
1519
+ ```tsx
1520
+ // reactions/flagBigOrder.reaction.tsx
1521
+ import { defineReaction } from '@voltro/runtime'
1522
+
1523
+ export default defineReaction({
1524
+ name: 'flagBigOrder',
1525
+ watch: { table: 'orders', on: 'insert' }, // table (+ optional op filter)
1526
+ when: (e) => Number((e.new as { total?: number })?.total ?? 0) > 1000, // optional predicate
1527
+ act: { kind: 'workflow', workflow: 'orders.review' }, // run THIS workflow on the change
1528
+ guards: {
1529
+ // REQUIRED — a stable idempotency key so the same change acts ONCE. Without
1530
+ // it, a reaction whose act writes the watched table self-triggers into a
1531
+ // spend storm; defineReaction throws at boot if it's missing.
1532
+ dedupeKey: (e) => String((e.new as { id?: string })?.id ?? ''),
1533
+ rateLimit: { limit: 10, windowMs: 60_000 }, // optional per-reaction cap
1534
+ costBudgetUsd: 5, // optional per-tenant AI ceiling
1535
+ },
1536
+ })
1537
+ ```
1538
+
1539
+ ## How it fires
1540
+
1541
+ Both `voltro dev` and `voltro serve` discover every `*.reaction.tsx`, bind it to
1542
+ the store's post-commit `onChange` channel, and on each change run the act
1543
+ through the guard chain: **op/table filter → `when` predicate → dedupe →
1544
+ rate-limit → budget**. The changed ROW is handed to the act — as the workflow's
1545
+ payload (so its payload schema should match the watched table's row), or seeded
1546
+ into the agent's prompt.
1547
+
1548
+ ## Guards (the point)
1549
+
1550
+ - **`dedupeKey` (required)** — the same logical change acts exactly once. This is
1551
+ what stops a reaction whose act writes the watched table from self-triggering
1552
+ forever. `defineReaction` throws at boot if it's missing.
1553
+ - **`rateLimit` (optional)** — at most `limit` firings per `windowMs`.
1554
+ - **`costBudgetUsd` (optional)** — a per-tenant AI spend ceiling; over budget,
1555
+ the reaction refuses (fails closed).
1556
+
1557
+ ## The two act targets
1558
+
1559
+ - **`act: { kind: 'workflow', workflow }`** — starts the workflow on the change
1560
+ (durable, retryable; the row is the payload). Reach for this when you need
1561
+ durable multi-step orchestration.
1562
+ - **`act: { kind: 'agent', agent }`** — fires the agent headlessly: opens a fresh
1563
+ thread, seeds it with the change as the prompt, and drives the synthesized
1564
+ `<agent>.send` as the agent actor (tenant-scoped to the row). The assistant's
1565
+ turn streams onto the durable `<agent>.messages` thread. Reach for this when a
1566
+ standing agent should react in natural language.
1567
+
1568
+ ## Limits (v1)
1569
+
1570
+ - Best-effort + fire-and-forget (like subscribers) — a failing act logs +
1571
+ continues; it can't back-pressure the change stream. Durability comes from a
1572
+ workflow act (an agent act is best-effort).
1573
+ - `dedupeKey` is in-memory per process in v1 (it stops the self-trigger storm
1574
+ within a run); a durable cross-restart dedupe table is a follow-up.
1575
+
1576
+ ## When to use what
1577
+
1578
+ - **Run my own code on a change** → [`*.subscribe.ts`](/docs/data/subscribers).
1579
+ - **Run an agent/workflow on a change, with spend guards** → `*.reaction.tsx` (this).
1580
+ - **Reject a write / derive a value at write time** → schema DSL
1581
+ (`validate(Schema)` / `computed` / `default`).
1582
+
1583
+
1584
+
1585
+ ---
1586
+
1587
+ <!-- source: en/data/wire-protocol.md -->
1588
+ ## Wire protocol
1589
+
1590
+ _What's on the WebSocket — @effect/rpc over JSON, the snapshot/delta subscription envelope, and the POST /rpc one-shot path._
1591
+
1592
+ Voltro's client and api speak `@effect/rpc` over a WebSocket. Every primitive — queries, mutations, actions, agents, subscriptions — rides the one bidirectional connection, multiplexed by the rpc layer. The serialization is **JSON** (`RpcSerialization.layerJson`), not a bespoke binary format.
1593
+
1594
+ You usually don't think about the wire — the typed client + `@voltro/protocol` handle it end-to-end. This page is for when you DO need to: debugging a mystery, proxying through a gateway, or understanding what the inspect tooling shows you.
1595
+
1596
+ ## The transport
1597
+
1598
+ The api boots two rpc server instances on the same `HttpLayerRouter`:
1599
+
1600
+ - **WebSocket** — `RpcServer.layerProtocolWebsocketRouter({ path })`, the primary transport. Long-lived; carries streaming queries (subscriptions), unary mutations/actions, and agent runs.
1601
+ - **HTTP one-shot** — `RpcServer.layerProtocolHttpRouter({ path: '/rpc' })`, registered at `POST /rpc`. Non-streaming; one request → one batched response.
1602
+
1603
+ Both are provided `RpcSerialization.layerJson` and the SAME rpc group (`options.group`), so the SAME per-rpc handlers + the group-level `AuthMiddleware` / `ConnectionInfoMiddleware` run on either path. A forwarded session cookie resolves the same `Subject` + tenant whether the call arrives over WS or HTTP.
1604
+
1605
+ Because `@effect/rpc` owns the framing, there's no app-level frame taxonomy to learn — the rpc client encodes a request, the server decodes it, runs the handler, and streams back the result. What's app-specific is the **payload schema** of each rpc (your `defineQuery` / `defineMutation` input + output) and, for streaming queries, the subscription-event envelope below.
1606
+
1607
+ ## Subscription events — snapshot / delta
1608
+
1609
+ A streaming query (what `useSubscription` opens) emits a sequence of **subscription events**. The envelope is defined in `@voltro/protocol`'s `subscriptionEvent(output)` — a `Schema.Union` of three variants, parameterised on the query's declared `output` schema so the rpc layer enforces the row shape end-to-end:
1610
+
1611
+ ```ts
1612
+ // snapshot — always the FIRST event; the full initial query result
1613
+ { _tag: 'snapshot', revision: number, data: <output> }
1614
+
1615
+ // delta — every subsequent event; an id-keyed JSON-patch, NOT full data
1616
+ {
1617
+ _tag: 'delta'
1618
+ revision: number
1619
+ emittedAt: number
1620
+ patch: {
1621
+ ops: Array<
1622
+ | { op: 'add'; path: '/<id>'; value: <row> }
1623
+ | { op: 'replace'; path: '/<id>'; value: <row> }
1624
+ | { op: 'remove'; path: '/<id>' }
1625
+ >
1626
+ order: Array<id> // the full id sequence of the next set, in order
1627
+ }
1628
+ }
1629
+
1630
+ // error — this ONE subscription's handler failed; surfaced in-band on its own
1631
+ // stream (never a defect that would stall siblings on the shared connection)
1632
+ { _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
1633
+ ```
1634
+
1635
+ - **`revision`** — monotonically increasing; lets the client order events and detect gaps.
1636
+ - **`emittedAt`** — epoch milliseconds, present on `delta` only.
1637
+ - **`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).
1638
+ - **`patch`** (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
1639
+ - **`error`** — a JSON-safe shape of a typed error (its `_tag` + fields + `message` preserved, so the client can pattern-match `error._tag`). `useSubscription` exposes it as `.error` for THAT query key.
1640
+
1641
+ The first event is always a `snapshot` carrying the full initial result — the client materialises it as its base. Subsequent events are `delta`s carrying **only the rows that changed**, plus the next id `order`. The server still re-runs the query on a change (the patch saves wire egress, not the re-query); it then diffs the previous row set against the new one into the patch.
1642
+
1643
+ ### Per-subscription errors — isolated, not connection-wide
1644
+
1645
+ Many subscriptions multiplex over ONE WebSocket. If a single subscription's
1646
+ handler fails (e.g. a live single-row getter that throws for a stale/foreign
1647
+ id), the server emits an **`error` event on THAT subscription's own stream** and
1648
+ completes it — it does **not** let the failure become a defect, which would
1649
+ propagate to the shared connection and stall every *other* subscription on it
1650
+ (the classic "one not-found and the whole dashboard hangs on loading"). The
1651
+ client surfaces it as `useSubscription(...).error` for that one query key;
1652
+ siblings keep delivering their snapshots and deltas.
1653
+
1654
+ **Author a live-subscribed getter to return, not throw.** A subscription is a
1655
+ long-lived stream, so a getter that throws on every re-evaluation is a broken
1656
+ stream. For an expected-absent row, make the query `output: Schema.NullOr(...)`
1657
+ and return `null` — that's a normal snapshot the widget renders as "empty",
1658
+ cleaner than an error banner. Reserve throwing for genuinely exceptional cases;
1659
+ even then it's now contained to the one subscription.
1660
+
1661
+ ### How the patch is keyed — by row `id`, not array index
1662
+
1663
+ The diff keys on each row's `id`, addressing it as `path: '/<id>'`, rather than by array index. Index-based paths are brittle the moment a row moves — and reordering is the common case for the live result sets this targets (leaderboards, collaborative lists, game state). Id-keying makes a reshuffle cost just the changed rows plus the id list:
1664
+
1665
+ - **`replace`** — a row present in both prev and next whose content changed; `value` is the full new row.
1666
+ - **`add`** — a row new in next; `value` is it.
1667
+ - **`remove`** — a row gone from next.
1668
+ - **reorder** — carried entirely by `order` (the next id sequence); a pure reorder emits **zero ops** and just a new `order`.
1669
+
1670
+ The client keeps the last materialised row set, applies the ops to an id→row map, then materialises the result strictly in `order`. The round-trip is exact: applying the computed patch to the previous set reproduces the new set for every case — add, remove, replace, reorder, and combinations.
1671
+
1672
+ **No-op suppression still applies.** When a change re-runs the query but the resolved row set is unchanged, no event is emitted at all — the patch path covers "a small part of a big result changed", the suppression covers "nothing changed".
1673
+
1674
+ **Fallbacks.** A result set whose rows aren't id-keyed (a custom projection that drops `id`) can't be diffed by id, so its update ships as a full `snapshot` instead. Computed queries (a handler that returns a scalar or aggregate, not an id-keyed row set) likewise ship every update as a `snapshot` — there's no row set to patch.
1675
+
1676
+ ## HTTP one-shot rpc (`POST /rpc`)
1677
+
1678
+ The WebSocket is the primary transport, but the api ALSO exposes `POST /rpc` for non-streaming invokes. It speaks the same JSON envelope and runs through the SAME per-rpc handlers + the same auth middleware — a forwarded session cookie resolves the same Subject + tenant as the WS path.
1679
+
1680
+ This is what a server-side web-router loader's `ctx.query` uses: a loader runs inside the request handler with no WS connection, so it calls the backend over HTTP for SSR first-paint + `meta`. The HTTP protocol drains a streaming query handler's stream to completion and returns it, so a streaming query yields its FIRST (initial) `snapshot` in the response batch — exactly what first-paint needs. See [Loaders & meta](/docs/routing/loaders-and-meta#fetching-backend-data-with-ctx-query).
1681
+
1682
+ For live, after-hydration data, subscribe over the WebSocket with `useSubscription` instead — `POST /rpc` is one-shot and never streams deltas.
1683
+
1684
+ ## Authentication
1685
+
1686
+ The session cookie travels in the WebSocket upgrade headers (and in the `POST /rpc` request headers). The api's `AuthMiddleware` resolver decodes it and binds the resolved `Subject` to the connection; subsequent rpc calls on that connection inherit it.
1687
+
1688
+ For API-key authentication, send `Authorization: Bearer <key>` in the upgrade (or request) headers — the same resolver path maps it to a `subject.type === 'apiKey'`.
1689
+
1690
+ ## Debugging
1691
+
1692
+ Use the framework's own tooling rather than reading raw frames:
1693
+
1694
+ - **`voltro traces`** — the per-request span waterfall, including each subscription `snapshot` / `delta` delivery with its produce→push latency. `voltro traces --errors` filters to failed hops.
1695
+ - **`voltro logs --trace <id>`** — every log line of one request across hops, in order.
1696
+ - The inspect dashboard's stream firehose (`GET /_voltro/inspect/stream`) surfaces rpc / cdc / log events live.
1697
+
1698
+ ## Anti-patterns
1699
+
1700
+ - **Hand-crafting rpc frames.** Use `@voltro/client` — `@effect/rpc` owns the framing and the protocol can change.
1701
+ - **Long-polling fallbacks.** None — Voltro is WS-or-bust for live data. Browsers without WebSocket support don't get reactive updates (one-shot reads still work over `POST /rpc`).
1702
+ - **Proxying through a CDN without WebSocket support.** Cloudflare / Fastly / Vercel Edge all support it; configure the upgrade headers.
1703
+
1704
+
1705
+
1706
+ ---
1707
+
1708
+ <!-- source: en/data/errors.md -->
1709
+ ## Error handling
1710
+
1711
+ _Schema-tagged error variants, runtime errors vs business errors, client narrowing, retry semantics._
1712
+
1713
+ Voltro distinguishes **business errors** (typed, declared in the schema, expected) from **runtime errors** (unexpected exceptions, transport failures, framework bugs). Each surface has clear semantics.
1714
+
1715
+ Live — `demo.greet` throws a typed `NameTooLong` for a name over 20 chars; the
1716
+ client narrows on `_tag` and reads the typed fields:
1717
+
1718
+ ```tsx
1719
+ const greet = useAction('app', 'demo.greet')
1720
+ try { await greet.run({ name }) }
1721
+ catch (e) { if (e._tag === 'NameTooLong') { /* typed: e.max, e.actual */ } }
1722
+ ```
1723
+
1724
+ ## Business errors
1725
+
1726
+ Declare your own with `Schema.TaggedError` — the class-extension form:
1727
+
1728
+ ```ts
1729
+ import { Schema } from 'effect'
1730
+
1731
+ class InsufficientFunds extends Schema.TaggedError<InsufficientFunds>()('InsufficientFunds', {
1732
+ required: Schema.Number,
1733
+ available: Schema.Number,
1734
+ }) {}
1735
+
1736
+ class TitleTooLong extends Schema.TaggedError<TitleTooLong>()('TitleTooLong', {
1737
+ maxLength: Schema.Number,
1738
+ }) {}
1739
+ ```
1740
+
1741
+ Wire them into a mutation / action / query via the `error` field:
1742
+
1743
+ ```ts
1744
+ export const transfer = defineMutation({
1745
+ name: 'wallet.transfer',
1746
+ input: Schema.Struct({ to: Schema.String, amount: Schema.Number }),
1747
+ output: Schema.Struct({ txId: Schema.String }),
1748
+ error: Schema.Union(InsufficientFunds, TitleTooLong),
1749
+ })
1750
+
1751
+ export default async (input, ctx) => {
1752
+ const wallet = await ctx.store.select('wallets').where('id', ctx.request.subject.id).one()
1753
+ if (wallet.balance < input.amount) {
1754
+ throw new InsufficientFunds({ required: input.amount, available: wallet.balance })
1755
+ }
1756
+ // …
1757
+ }
1758
+ ```
1759
+
1760
+ Client narrows on `_tag`:
1761
+
1762
+ ```tsx
1763
+ const transfer = useMutation('app', 'wallet.transfer')
1764
+
1765
+ const result = await transfer.mutate({ to: 'usr-42', amount: 100 }).catch((e) => e)
1766
+
1767
+ if (result._tag === 'InsufficientFunds') {
1768
+ // result.required, result.available are typed
1769
+ toast.error(`Need ${result.required}, have ${result.available}`)
1770
+ } else if (result._tag === 'TitleTooLong') {
1771
+ toast.error(`Too long — max ${result.maxLength}`)
1772
+ } else if ('txId' in result) {
1773
+ toast.success(`Transfer ${result.txId} complete`)
1774
+ }
1775
+ ```
1776
+
1777
+ Tagged errors:
1778
+
1779
+ - Are wire-safe — they serialise as JSON (the rpc transport is `RpcSerialization.layerJson`) and restore on the client with the correct `_tag` + payload.
1780
+ - Narrow correctly in TypeScript via the `_tag` discriminant.
1781
+ - Carry typed payload fields.
1782
+ - Don't fire alerts / unhandled-promise-rejection signals — they're expected business outcomes.
1783
+
1784
+ ## Runtime errors
1785
+
1786
+ Unexpected throws — `TypeError`, `RangeError`, framework bugs, third-party SDK failures — don't match the descriptor's `error` union, so they surface as **defects** rather than typed failures:
1787
+
1788
+ - The throw is logged (via `@voltro/logger` to your configured sink).
1789
+ - The client sees a generic failure (no typed payload — runtime errors might leak sensitive context).
1790
+ - The trace records the throw + trace ID for cross-referencing; `voltro logs --trace <id>` returns the whole causal chain.
1791
+ - The trace is searchable in OpenTelemetry.
1792
+
1793
+ To distinguish "we know about this" from "this surprised us":
1794
+
1795
+ | Throw | Treatment |
1796
+ |---|---|
1797
+ | `throw new InsufficientFunds({ … })` (declared tagged error in `error:`) | Marshalled to the client typed, no log-volume increase |
1798
+ | `throw new Error('oops')` | Surfaces as a defect — full log + trace, generic failure to client |
1799
+ | Unexpected exception from a library | Same — caught, logged, generic failure |
1800
+
1801
+ ## Framework-shipped error variants
1802
+
1803
+ `@voltro/protocol` exports exactly two tagged errors. The rest of your typed errors are ones you declare yourself (above) or ones a plugin / the runtime contributes.
1804
+
1805
+ | Variant | From | When |
1806
+ |---|---|---|
1807
+ | `ScopeError` | `@voltro/protocol` | `requireScope(subject, scope)` failed — `{ required, message }`. |
1808
+ | `Unauthenticated` | `@voltro/protocol` | The resolved Subject is anonymous but a signed-in caller was required — optional `{ reason }`. |
1809
+ | `TenantScopeViolation` | `@voltro/runtime` | A tenant-scoped `EffectStore` write had no authenticated subject. |
1810
+ | `StoreOperationFailed` | `@voltro/runtime` | The underlying store operation failed (transient). |
1811
+ | `TableValidationFailed` | `@voltro/runtime` | A `table().validate(Schema)` row check rejected the write. |
1812
+ | `CacheError` | `@voltro/cache` | A cache backend op failed — `{ operation, key, cause }`. |
1813
+ | `RateLimited` | `@voltro/plugin-ratelimit` | The limiter rejected the call — `{ limit, retryAfterMs, resetAtMs }`. |
1814
+ | `TenantMismatch` | `@voltro/plugin-multitenancy` | `assertOwnTenant(input.tenantId, subject)` rejected a cross-tenant write. |
1815
+
1816
+ Each plugin that ships an error (`RateLimited`, `EntitlementExceeded`, `StorageError`, `MailError`, …) merges it into every procedure's wire-error union, so the client decodes it typed without you adding it to each `error:`. To surface a runtime store error to the client, add it to the descriptor's `error:` union yourself (e.g. `error: Schema.Union(TenantScopeViolation, StoreOperationFailed, MyDomainError)`).
1817
+
1818
+ All carry `_tag` + typed payloads, all narrow correctly on the client.
1819
+
1820
+ ## Loader errors
1821
+
1822
+ Loaders run in the web app — they receive `{ params, query, headers, signal }`, NOT a server `ctx` with `.store`. A loader reaches the backend through `query(...)` (the `POST /rpc` path, server-side only). It short-circuits with the branded control-flow signals `NotFoundError` / `RedirectError`:
1823
+
1824
+ ```ts
1825
+ import { notFound } from '@voltro/web'
1826
+
1827
+ export const loader = async ({ params, query }) => {
1828
+ // `query` is present only server-side (SSR/ISR). Guard for client nav.
1829
+ const note = query ? await query('notes.get', { id: params.id }) : undefined
1830
+ if (!note) throw notFound(`note ${params.id}`) // → scoped not-found.tsx / 404
1831
+ return note
1832
+ }
1833
+ ```
1834
+
1835
+ A `NotFoundError` renders the scoped `not-found.tsx` subtree (it is NOT routed to the `error.tsx` boundary). A plain `throw new Error(...)` DOES hit `error.tsx`:
1836
+
1837
+ ```tsx
1838
+ export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
1839
+ return <GenericError error={error} reset={reset} />
1840
+ }
1841
+ ```
1842
+
1843
+ See [Loaders & meta](/docs/routing/loaders-and-meta) for `NotFoundError` / `RedirectError` and the `ctx.query` SSR path.
1844
+
1845
+ ## Retry semantics
1846
+
1847
+ | Surface | Auto-retry? | Notes |
1848
+ |---|---|---|
1849
+ | `useSubscription` | ✓ on disconnect | The api connection auto-reconnects with exponential backoff (`500ms × 2^(attempt-1)`, capped at 5s) and re-subscribes; the server replays the current snapshot. Reconnect attempts are not capped — it keeps trying until the connection is restored or the component unmounts. |
1850
+ | `useMutation` | ✗ | Mutations might be non-idempotent. Retry only when the operation is safe to repeat. |
1851
+ | `useAction` | ✗ | Same. |
1852
+ | `useAgentStream` / `useAgent` | ✗ | Streaming is hard to resume. Use a workflow for durable agent runs. |
1853
+ | Workflow steps | ✓ | Configurable per-workflow + per-step. |
1854
+
1855
+ For mutations that are safe to repeat, wrap `mutate` in your own retry:
1856
+
1857
+ ```tsx
1858
+ const result = await retry(
1859
+ () => create.mutate(input),
1860
+ { attempts: 3, backoff: 'expo' },
1861
+ )
1862
+ ```
1863
+
1864
+ For operations that must be durable or exactly-once across disconnects, queue the work through a workflow and use database uniqueness constraints around the business key.
1865
+
1866
+ ## Validation errors
1867
+
1868
+ Each rpc decodes its input against the declared `input` schema before the executor runs. A payload that fails the schema is rejected by `@effect/rpc` as a decode failure — the executor never runs. To surface field-level messages to a form UI, declare your own validation error and decode the input yourself in the handler:
1869
+
1870
+ ```ts
1871
+ import { Schema } from 'effect'
1872
+
1873
+ class ValidationFailed extends Schema.TaggedError<ValidationFailed>()('ValidationFailed', {
1874
+ errors: Schema.Array(Schema.Struct({ path: Schema.String, message: Schema.String })),
1875
+ }) {}
1876
+ ```
1877
+
1878
+ You can attach custom messages to the field constraints with `Schema.message`:
1879
+
1880
+ ```ts
1881
+ Schema.String.pipe(
1882
+ Schema.minLength(1, { message: () => 'Title is required' }),
1883
+ Schema.maxLength(200, { message: () => 'Title is too long (max 200 chars)' }),
1884
+ )
1885
+ ```
1886
+
1887
+ Schema-level row validation on a table (`table().validate(Schema)`) throws the runtime's `TableValidationFailed` — declare it in the mutation's `error:` to surface it typed.
1888
+
1889
+ ## Network errors
1890
+
1891
+ A WebSocket disconnect interrupts in-flight unary calls — the awaited `mutate` / `run` rejects. The framework reconnects query subscriptions automatically; for mutations and actions, you decide whether retrying is safe:
1892
+
1893
+ ```tsx
1894
+ try {
1895
+ await create.mutate(input)
1896
+ } catch (e) {
1897
+ // Connection dropped mid-call. Wait for the api to reconnect, then retry
1898
+ // ONLY if the operation is idempotent.
1899
+ await waitForReconnect()
1900
+ await create.mutate(input)
1901
+ }
1902
+ ```
1903
+
1904
+ ## Anti-patterns
1905
+
1906
+ - **Swallowing every error and showing "Something went wrong".** You're hiding genuine bugs. Let the dashboard's error pane + `voltro traces --errors` surface them; only catch the specific tagged variants you've declared.
1907
+ - **Throwing strings.** `throw 'oh no'` → surfaces as a defect, no useful payload. Use `Error` subclasses or `Schema.TaggedError` variants.
1908
+ - **`if (error instanceof InsufficientFunds)` on the client.** The wire-deserialised value isn't structurally identical to the server's class. Always check `_tag`.
1909
+ - **The curried `Schema.TaggedError('Name')({...})` form.** That doesn't type-check — use the class-extension form `class X extends Schema.TaggedError<X>()('X', {...})`.
1910
+
1911
+
1912
+
1913
+ ---
1914
+
1915
+ <!-- source: en/data/cms.md -->
1916
+ ## CMS
1917
+
1918
+ _Headless CMS on the data layer — content types declared in code, derived draft/published tables, the ctx.cms read surface, and the saveDraft → publish write pipeline with save-time validation + derivation._
1919
+
1920
+ `@voltro/cms` is a headless CMS built on the framework's own data layer. You
1921
+ declare a content type once, in code; the package compiles it to two real
1922
+ database tables (`<type>_drafts` + `<type>_published`) that auto-migrate picks
1923
+ up, gives you a typed read surface, and a write pipeline that enforces the
1924
+ schema's validation/derivation rules on every save.
1925
+
1926
+ Two entry points keep the browser/server boundary clean:
1927
+
1928
+ - `@voltro/cms` — the server entry: table derivation (`@voltro/database`),
1929
+ the write pipeline, signed preview tokens (`node:crypto`), the REST mount.
1930
+ - `@voltro/cms/web` — browser-safe: the field DSL, the validation/derivation
1931
+ engine + its typed errors (effect-only), and the `<ContentForm>` renderer
1932
+ (react-only). An editor page pre-validates with the SAME rules the server
1933
+ applies; a mutation descriptor can declare `error: ContentValidationFailed`
1934
+ without dragging the server graph into the bundle.
1935
+
1936
+ ## Declaring a content type
1937
+
1938
+ By convention each content type lives in a `*.contentType.ts` file. The field
1939
+ DSL is a plain-data descriptor language — validation rules travel as data and
1940
+ are applied at save time.
1941
+
1942
+ ```ts
1943
+ import { defineContentType, Schema, derivedFrom, slugify } from '@voltro/cms'
1944
+
1945
+ export const blogPost = defineContentType({
1946
+ name: 'blogPost',
1947
+ displayName: 'Blog Post',
1948
+ pluralName: 'Blog Posts',
1949
+ fields: {
1950
+ title: Schema.String.pipe(Schema.maxLength(200)),
1951
+ slug: Schema.String.pipe(Schema.pattern(/^[a-z0-9-]+$/), Schema.unique(), derivedFrom('title', slugify)),
1952
+ body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
1953
+ tags: Schema.Array(Schema.String),
1954
+ publishedAt: Schema.DateTime.optional(),
1955
+ coverImage: Schema.Media().optional(),
1956
+ author: Schema.Reference('author'),
1957
+ visibility: Schema.Literal('public', 'unlisted', 'private'),
1958
+ },
1959
+ list: { columns: ['title', 'updatedAt'], defaultSort: { field: 'updatedAt', dir: 'desc' } },
1960
+ })
1961
+ ```
1962
+
1963
+ - `maxLength` / `pattern` are enforced by the save-time validator (and
1964
+ `maxLength` also caps the shipped text widget).
1965
+ - `derivedFrom(source, transform)` marks a field computed-on-save: the
1966
+ pipeline computes it from its source and **overwrites** a caller-provided
1967
+ value — `slugify` is the canonical transform.
1968
+ - `unique()` marks a string field unique **per tenant** — the derived tables
1969
+ get a real composite `(tenantId, field)` DB UNIQUE constraint (the database
1970
+ rejects a duplicate slug within a tenant, not the app). Pair it with
1971
+ `derivedFrom('title', slugify)` for a unique slug.
1972
+ - `Schema.RichText(options?)` stores a JSON document (e.g. a TipTap doc);
1973
+ `allowImages` / `allowEmbeds: false` reject image/embed nodes (or `<img>` /
1974
+ `<iframe>` / `<embed>` markers in string values) at save time. The shipped
1975
+ default widget is a plain textarea placeholder — swap in a real editor via
1976
+ the `ContentForm` `widgets` override.
1977
+ - `Schema.Media()` stores a storage object key as text (e.g. one issued by
1978
+ `@voltro/plugin-storage`); resolve it to a URL after a read with
1979
+ `resolveMedia` (below).
1980
+ - `list` describes the consuming editor's list view; every referenced name is
1981
+ validated at definition time.
1982
+ - Reserved field names (`defineContentType` throws): `id`, `status`,
1983
+ `tenantId`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`.
1984
+
1985
+ `contentTypeToEntities(blogPost)` returns the `{ draft, published }` tables —
1986
+ register them in your `database/index.ts` so auto-migrate creates them. Both
1987
+ carry the lifecycle `status` column (`draft` / `published` / `archived`), the
1988
+ `tenant()` mixin (tenant-scoped reads/writes through the request store), and
1989
+ are `.reactive()` so a publish wakes live subscriptions.
1990
+
1991
+ ## Reading — `ctx.cms`
1992
+
1993
+ `cmsContext(store, types)` builds the read surface over a request-scoped
1994
+ store; attach it to your `AppContext` and read it with `useCms(ctx)`. Reads
1995
+ target `<type>_published` and exclude `archived` rows.
1996
+
1997
+ ```ts
1998
+ import { cmsContext, useCms } from '@voltro/cms'
1999
+
2000
+ const posts = await useCms(ctx)
2001
+ .contentType('blogPost')
2002
+ .where('publishedAt', '<=', new Date())
2003
+ .orderBy('publishedAt', 'desc')
2004
+ .limit(20)
2005
+ .all()
2006
+ ```
2007
+
2008
+ `createCmsClient({ store, types })` is the same surface without a request
2009
+ handle (build-time / static-site generation). Signed preview tokens
2010
+ (`previewToken` / `verifyPreviewToken` / `cms.preview`) let an app read the
2011
+ draft row instead — see the package README for the token contract and the
2012
+ API-key-gated REST mount (`handleCmsRest`).
2013
+
2014
+ ## Writing — the pipeline
2015
+
2016
+ The lifecycle ops take the caller's store first. Hand them `ctx.store` and
2017
+ every write rides the runtime's auto-scope spine: tenant + audit columns are
2018
+ stamped, reads are tenant-scoped, ids come from the table's id scheme.
2019
+
2020
+ ```ts
2021
+ import { saveDraft, publish, unpublish, archive } from '@voltro/cms'
2022
+
2023
+ // derive → validate → write into blogPost_drafts (status: 'draft')
2024
+ const draft = await saveDraft(ctx.store, blogPost, input)
2025
+
2026
+ // copy the draft into blogPost_published (same id), atomic per row
2027
+ const row = await publish(ctx.store, blogPost, draft.id as string)
2028
+
2029
+ await unpublish(ctx.store, blogPost, draft.id as string) // remove the published copy, keep the draft
2030
+ await archive(ctx.store, blogPost, draft.id as string) // status 'archived' — reads exclude it
2031
+ ```
2032
+
2033
+ `saveDraft` applies the `derivedFrom` derivations first, then validates the
2034
+ derived row — a rule on a derived field checks the value actually written.
2035
+ Violations throw `ContentValidationFailed` carrying **every** per-field
2036
+ violation (`{ field, rule, message }`), so an editor can annotate the whole
2037
+ form in one pass. Re-saving with `row.id` updates the caller's own draft;
2038
+ `publish` copies into the published table (insert on first publish, update
2039
+ after) and marks the draft in-sync.
2040
+
2041
+ Tenant isolation is structural: a foreign id reads as absent through the
2042
+ scoped store (`ContentNotFound`), a caller-pinned `id` that exists only in
2043
+ another tenant is refused (`ContentIdConflict`), and a caller-provided
2044
+ `tenantId` never reaches a write — the pipeline owns the lifecycle columns.
2045
+
2046
+ Handlers written in `Effect.gen` use the Effect-native siblings — the same
2047
+ typed error instances on the error channel:
2048
+
2049
+ ```ts
2050
+ import { Effect } from 'effect'
2051
+ import { saveDraftEffect, publishEffect } from '@voltro/cms'
2052
+
2053
+ const program = Effect.gen(function* () {
2054
+ const draft = yield* saveDraftEffect(ctx.store, blogPost, input)
2055
+ return yield* publishEffect(ctx.store, blogPost, draft.id as string)
2056
+ }).pipe(
2057
+ Effect.catchTag('ContentValidationFailed', (e) => Effect.succeed({ invalid: e.violations })),
2058
+ )
2059
+ ```
2060
+
2061
+ ## The engine, standalone
2062
+
2063
+ The validation/derivation engine is pure and exported on its own (also on
2064
+ `/web`) for custom write paths and client-side form validation:
2065
+
2066
+ ```ts
2067
+ import { validateContent, contentViolations, applyDerivations } from '@voltro/cms/web'
2068
+
2069
+ const derived = applyDerivations(blogPost, formValue) // derivedFrom fields computed
2070
+ const violations = contentViolations(blogPost, derived) // pure — never throws
2071
+ validateContent(blogPost, derived) // throws ContentValidationFailed on any violation
2072
+ ```
2073
+
2074
+ ## Scheduled publishing
2075
+
2076
+ A draft can be marked to go live at a future time, then flipped live by a
2077
+ periodic sweep. `schedulePublish` stamps the draft's `publishAt` (a draft-only
2078
+ nullable column) without publishing now; `publishDue` publishes every draft
2079
+ whose time has passed.
2080
+
2081
+ ```ts
2082
+ import { schedulePublish, publishDue } from '@voltro/cms'
2083
+
2084
+ // Mark a draft to publish later (does NOT publish now):
2085
+ await schedulePublish(ctx.store, blogPost, draftId, new Date('2026-01-01T09:00:00Z'))
2086
+
2087
+ // Flip every due draft live (idempotent; returns the published ids):
2088
+ const publishedIds = await publishDue(ctx.store, blogPost, new Date())
2089
+ ```
2090
+
2091
+ `schedulePublish` keys off a tenant-scoped read (a foreign/missing id throws
2092
+ `ContentNotFound`) and leaves `status: 'draft'` until the row goes live.
2093
+ `publishDue` selects `publishAt <= now AND status = 'draft'`, runs the ordinary
2094
+ `publish` per row, and clears `publishAt` — idempotent, so a row is never
2095
+ double-published. It sweeps whatever store it is handed: a request store covers
2096
+ one tenant; a background/root store covers every tenant.
2097
+
2098
+ `@voltro/cms` does not ship the scheduler — you drive `publishDue` from a
2099
+ `*.cron.tsx` schedule:
2100
+
2101
+ ```tsx no-check
2102
+ // apps/api/schedules/content-scheduler.cron.tsx
2103
+ import { defineSchedule } from '@voltro/runtime'
2104
+ import { publishDue } from '@voltro/cms'
2105
+ import { blogPost } from '../blogPost.contentType'
2106
+
2107
+ export default defineSchedule({
2108
+ name: 'contentScheduler',
2109
+ cron: '* * * * *',
2110
+ timezone: 'UTC',
2111
+ // app.store has no request subject, so the sweep flips due drafts live
2112
+ // across ALL tenants.
2113
+ handler: async ({ app }) => {
2114
+ await publishDue(app.store, blogPost, new Date())
2115
+ },
2116
+ })
2117
+ ```
2118
+
2119
+ ## Media resolution
2120
+
2121
+ A `Schema.Media()` field stores a storage object **key**. Turning it into a
2122
+ served URL needs the storage service (its `getUrl` / `mintUrl` are
2123
+ Effect-returning and `mintUrl` needs the request `Subject`), so that context
2124
+ lives in your app — not the core package. Rather than couple `@voltro/cms` to a
2125
+ storage plugin, resolution is a typed hook: you supply a `MediaResolver`, and
2126
+ `resolveMedia` swaps every media key on a row (including media nested in
2127
+ `Array` / `Struct` fields) for the resolved URL.
2128
+
2129
+ ```ts
2130
+ import { resolveMedia, resolveMediaAll, type MediaResolver } from '@voltro/cms'
2131
+ import { Effect } from 'effect'
2132
+
2133
+ const resolver: MediaResolver = (key) =>
2134
+ Effect.runPromise(storage.mintUrl(key, ctx.subject)).catch(() => null)
2135
+
2136
+ const post = await useCms(ctx).contentType('blogPost').where('slug', '=', 'hello').one()
2137
+ const withUrls = post ? await resolveMedia(blogPost, post, resolver) : null
2138
+ ```
2139
+
2140
+ `resolveMedia` returns a new row (the input is not mutated); a key the resolver
2141
+ returns `null` for is left as its stored key. `resolveMediaAll` maps a whole
2142
+ list; `mediaFields(type)` lists the top-level media field names.
2143
+
2144
+ ## Versioning content
2145
+
2146
+ `@voltro/cms` ships no parallel revision system — the derived tables are
2147
+ ordinary database tables, so `@voltro/plugin-versioning` gives full row history
2148
+ + time-travel with no new machinery. List the derived table names in the
2149
+ plugin's `tables` option, then read a timeline or restore a snapshot:
2150
+
2151
+ ```ts no-check
2152
+ import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
2153
+
2154
+ // Register in your app's plugin list:
2155
+ versioningPlugin({ tables: ['blogPost_published', 'blogPost_drafts'] })
2156
+
2157
+ const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
2158
+ await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)
2159
+ ```
2160
+
2161
+ ## The editor form
2162
+
2163
+ `<ContentForm>` (from `/web`) renders one labelled widget per field, driven by
2164
+ each field's editor hint — stateless and unstyled. Override any widget (e.g. a
2165
+ TipTap-backed `richText`) via the `widgets` prop:
2166
+
2167
+ ```tsx
2168
+ import { ContentForm } from '@voltro/cms/web'
2169
+
2170
+ <ContentForm contentType={blogPost} value={row} onChange={setRow}
2171
+ widgets={{ richText: MyTipTapWidget }} />
2172
+ ```