@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,3214 @@
1
+ # templates.apiBackends
2
+
3
+ > The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/templates/api-backend.md -->
10
+ ## API · Backend
11
+
12
+ _The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box._
13
+
14
+ The minimal but complete api template: an `app.config.ts`, a one-table schema, one streaming query, and one tenant-guarded mutation. It's the smallest reference that shows the framework's core file convention — the **descriptor / `.server.ts` executor split** — and the tenant-scoping guard. Start here when you're not sure which api template fits. Template id: **`api-backend`**.
15
+
16
+ ## Scaffold
17
+
18
+ ```bash
19
+ voltro create-project acme --api=api-backend
20
+ ```
21
+
22
+ ## What ships
23
+
24
+ ```text
25
+ apps/acme/api/ # dir named by the app, not the template
26
+ ├── app.config.ts # type:api, name, store:'memory'
27
+ ├── package.json
28
+ ├── tsconfig.json
29
+ ├── README.md
30
+ ├── database/
31
+ │ └── schema.ts # actors + tenants (core) + a notes table
32
+ ├── queries/
33
+ │ ├── notes.query.ts # descriptor — browser-safe
34
+ │ └── notes.query.server.ts # server executor — default export
35
+ └── mutations/
36
+ ├── notes.create.mutation.ts # descriptor — browser-safe
37
+ └── notes.create.mutation.server.ts # server executor — default export
38
+ ```
39
+
40
+ No actions, workflows, agents, or `AGENTS.md` ship — this template is deliberately the smallest working shape. The mail / storage / mariadb variants build on this same `notes` base.
41
+
42
+ ## The descriptor / `.server.ts` split
43
+
44
+ Every query / mutation / action is **two files paired by basename**: a browser-safe descriptor (`*.query.ts` / `*.mutation.ts`) holding `defineQuery` / `defineMutation`, and a server-only executor (`*.query.server.ts` / `*.mutation.server.ts`) with a default export. The framework pairs them at boot. The descriptor file must never import `node:*` / database / runtime modules — it's what the web client pulls through codegen.
45
+
46
+ ### Query — `queries/notes.query.ts` + `.server.ts`
47
+
48
+ ```ts
49
+ // notes.query.ts — descriptor (browser-safe)
50
+ import { defineQuery } from '@voltro/protocol'
51
+ import { Schema } from 'effect'
52
+
53
+ export const listNotes = defineQuery({
54
+ name: 'notes.list',
55
+ input: Schema.Struct({}),
56
+ output: Schema.Struct({
57
+ id: Schema.String, title: Schema.String, body: Schema.String,
58
+ done: Schema.Boolean, tenantId: Schema.String, createdAt: Schema.Date,
59
+ }),
60
+ })
61
+ ```
62
+
63
+ ```ts
64
+ // notes.query.server.ts — executor (default export)
65
+ import type { AppContext } from '@voltro/runtime'
66
+
67
+ const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
68
+ descriptor: {
69
+ table: 'notes' as const,
70
+ order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
71
+ take: 100,
72
+ // tenant scope is AND-merged by the runtime — no manual eq('tenantId', …)
73
+ },
74
+ })
75
+
76
+ export default execute
77
+ ```
78
+
79
+ ### Mutation — `mutations/notes.create.mutation.ts` + `.server.ts`
80
+
81
+ ```ts
82
+ // notes.create.mutation.ts — descriptor
83
+ import { defineMutation } from '@voltro/protocol'
84
+ import { TenantMismatch } from '@voltro/plugin-multitenancy'
85
+ import { Schema } from 'effect'
86
+
87
+ export const createNote = defineMutation({
88
+ name: 'notes.create',
89
+ target: { table: 'notes', op: 'insert',
90
+ shape: (input: { tenantId: string; title: string; body: string }) => ({
91
+ title: input.title, body: input.body, done: false,
92
+ tenantId: input.tenantId, createdAt: new Date(),
93
+ }) },
94
+ input: Schema.Struct({ tenantId: Schema.String, title: Schema.NonEmptyString, body: Schema.String }),
95
+ output: Schema.Struct({ /* id, title, body, done, tenantId, createdAt */ }),
96
+ error: TenantMismatch,
97
+ })
98
+ ```
99
+
100
+ ```ts
101
+ // notes.create.mutation.server.ts — executor
102
+ import { assertOwnTenant } from '@voltro/plugin-multitenancy'
103
+ import type { AppContext } from '@voltro/runtime'
104
+
105
+ const execute = async (input: { tenantId: string; title: string; body: string }, ctx: AppContext) => {
106
+ // Cross-tenant write guard — input.tenantId MUST match the subject's,
107
+ // else the rpc layer surfaces a typed TenantMismatch.
108
+ assertOwnTenant(input.tenantId, ctx.request.subject)
109
+ // The framework auto-injects a `note_…` TypeID from the table's id() decl.
110
+ return ctx.store.insert('notes', {
111
+ title: input.title, body: input.body, done: false,
112
+ tenantId: input.tenantId, createdAt: new Date(),
113
+ })
114
+ }
115
+
116
+ export default execute
117
+ ```
118
+
119
+ ## Database
120
+
121
+ The shipped schema declares the two framework core tables plus one example table:
122
+
123
+ ```ts
124
+ // database/schema.ts
125
+ import { boolean, databaseHandle, id, table, text, timestamp, type InferRow } from '@voltro/database'
126
+ import { tenant } from '@voltro/plugin-multitenancy'
127
+
128
+ // Core tables — required by the audit / tenant mixins.
129
+ export const actors = table('actors', {
130
+ id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
131
+ displayName: text().nullable(), createdAt: timestamp().default('now'),
132
+ })
133
+ export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
134
+
135
+ // Application table — rename / replace with your own.
136
+ export const notes = table('notes', {
137
+ id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
138
+ })
139
+ .with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
140
+ .reactive() // subscriptions get a fresh snapshot/delta on every write to notes
141
+
142
+ export type Note = InferRow<typeof notes>
143
+ export const database = databaseHandle({ actors, tenants, notes })
144
+ ```
145
+
146
+ `tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate, so cross-tenant reads can't leak.
147
+
148
+ ## Store — memory by default
149
+
150
+ `app.config.ts` ships `store: 'memory'` — fast startup, no Docker, data resets on restart. Good for dev, tests, and a first impression. Switch to a real SQL backend by setting the dialect + connection env (or `store:` in `app.config.ts`):
151
+
152
+ ```bash
153
+ DB_DIALECT=postgres DB_URL=postgres://app:app@localhost:5432/acme voltro dev .
154
+ ```
155
+
156
+ `voltro dev` against a SQL store auto-migrates the discovered schema before any handler boots. See the SQL dialects guide for `mysql` / `mariadb` / `mssql` / `sqlite` / `turso`.
157
+
158
+ ## Discovery
159
+
160
+ `voltro dev .` walks the app for the file conventions — `*.query.ts` + `*.query.server.ts`, `*.mutation.ts` + `*.mutation.server.ts`, `*.action.ts` + `*.action.server.ts`, `*.workflow.tsx`, `*.cron.tsx`, `schema.ts` / `*.entity.ts` — pairs each descriptor with its `.server.ts` executor, and rewrites `rpcGroup.generated.ts` (the typed client). Drop more files anywhere in the tree; nothing is registered manually.
161
+
162
+ ## When to use api-backend vs. the variants
163
+
164
+ | You need… | Pick |
165
+ |---|---|
166
+ | The smallest reference to extend | `api-backend` |
167
+ | Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
168
+ | File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
169
+ | MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
170
+
171
+ All four share this `notes` base; the variants just wire a plugin / store on top.
172
+
173
+ ## Pairs well with
174
+
175
+ - Any web template — `api-backend` is the most generic api.
176
+ - [`@voltro/plugin-auth`](/docs/plugins/auth) — wire it in for real sign-in.
177
+
178
+ ## Anti-patterns
179
+
180
+ - **Putting `node:*` / database imports in a descriptor (`*.query.ts`).** Those files reach the browser bundle through codegen. Keep server-only code in the `.server.ts` half.
181
+ - **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — without it a client authenticated as A can submit `tenantId: 'B'` and the row lands in B's data.
182
+ - **Discovery walker as a security boundary.** It picks up everything matching the pattern. Every discovered descriptor is exposed via the rpc client — don't put secrets in one.
183
+
184
+
185
+
186
+ ---
187
+
188
+ <!-- source: en/templates/api-backend-mail.md -->
189
+ ## API · Backend + Mail
190
+
191
+ _The api-backend base plus transactional email wired — @voltro/plugin-mail and a React-Email welcome template you preview and send from the dashboard._
192
+
193
+ The same minimal `notes` base as [`api-backend`](/docs/templates/api-backend), plus transactional email wired out of the box via [`@voltro/plugin-mail`](/docs/plugins/mail). It ships a React-Email welcome template and a `mail.sendWelcome` action. Template id: **`api-backend-mail`**.
194
+
195
+ ## Scaffold
196
+
197
+ ```bash
198
+ voltro create-project acme --api=api-backend-mail
199
+ ```
200
+
201
+ ## What ships
202
+
203
+ ```text
204
+ apps/acme/api/ # dir named by the app, not the template
205
+ ├── app.config.ts # api + mailPlugin({ provider: 'console' })
206
+ ├── package.json
207
+ ├── tsconfig.json
208
+ ├── README.md
209
+ ├── database/
210
+ │ └── schema.ts # actors + tenants + notes (same as api-backend)
211
+ ├── queries/
212
+ │ ├── notes.query.ts + .query.server.ts
213
+ ├── mutations/
214
+ │ └── notes.create.mutation.ts + .mutation.server.ts
215
+ ├── emails/
216
+ │ └── welcome.email.tsx # a React-Email welcome template
217
+ └── actions/
218
+ └── sendWelcome.action.ts + .action.server.ts # mail.sendWelcome
219
+ ```
220
+
221
+ ## The wiring
222
+
223
+ `app.config.ts` adds the mail plugin with the zero-config `console` provider:
224
+
225
+ ```ts
226
+ import { mailPlugin } from '@voltro/plugin-mail'
227
+
228
+ export default {
229
+ type: 'api' as const,
230
+ name: '{{projectNamePascal}}{{appNamePascal}}',
231
+ store: 'memory' as const,
232
+ plugins: [
233
+ mailPlugin({ provider: 'console', from: '{{projectNamePascal}} <hello@example.com>' }),
234
+ ],
235
+ }
236
+ ```
237
+
238
+ The `console` provider **logs** what would be sent (no API key) and captures it in the dashboard outbox. For real delivery, switch `provider` to `'resend'` / `'postmark'` / `'sendgrid'` / `'smtp'` and set the matching env var (e.g. `RESEND_API_KEY`).
239
+
240
+ ## The email template — `emails/welcome.email.tsx`
241
+
242
+ Authored with `defineEmail` + `@react-email/components`. Auto-discovered by `voltro dev` and registered with the plugin, so `mail.send({ template: 'welcome', props })` works and the dashboard Mail panel previews it:
243
+
244
+ ```tsx
245
+ import { defineEmail } from '@voltro/plugin-mail'
246
+ import { Body, Button, Container, Heading, Html, Text } from '@react-email/components'
247
+ import { Schema } from 'effect'
248
+
249
+ export const welcome = defineEmail({
250
+ name: 'welcome',
251
+ props: Schema.Struct({ name: Schema.String }),
252
+ preview: { name: 'Mario' }, // pre-fills the dashboard preview box
253
+ subject: (p) => `Welcome, ${p.name}!`,
254
+ render: (p) => (
255
+ <Html><Body><Container>
256
+ <Heading>Welcome, {p.name} 👋</Heading>
257
+ <Text>Your workspace is ready.</Text>
258
+ <Button href="https://example.com/dashboard">Open your dashboard</Button>
259
+ </Container></Body></Html>
260
+ ),
261
+ })
262
+ ```
263
+
264
+ The shipped template carries `prefers-color-scheme` dark-mode handling and inline styles for email-client compatibility — replace the copy and brand for your product.
265
+
266
+ ## Sending — `actions/sendWelcome.action.ts` + `.server.ts`
267
+
268
+ Email is external I/O, so it's an **action**, not a mutation. The descriptor declares the wire shape; the `.server.ts` executor calls `MailService`:
269
+
270
+ ```ts
271
+ // sendWelcome.action.server.ts
272
+ import { Effect } from 'effect'
273
+ import { MailService } from '@voltro/plugin-mail'
274
+
275
+ const execute = (input: { email: string; name: string }) =>
276
+ Effect.gen(function* () {
277
+ const mail = yield* MailService
278
+ const res = yield* mail.send({ to: input.email, template: 'welcome', props: { name: input.name } })
279
+ return { id: res.id, provider: res.provider }
280
+ })
281
+
282
+ export default execute
283
+ ```
284
+
285
+ Preview templates and inspect the dev outbox in the dashboard's **Mail** tab.
286
+
287
+ ## Pairs well with
288
+
289
+ - Any web template — wire the `mail.sendWelcome` action behind a signup flow.
290
+
291
+ ## See also
292
+
293
+ - [`@voltro/plugin-mail`](/docs/plugins/mail) — providers, templates, suppression, durable delivery.
294
+ - [`api-backend`](/docs/templates/api-backend) — the base this builds on.
295
+
296
+
297
+
298
+ ---
299
+
300
+ <!-- source: en/templates/api-backend-storage.md -->
301
+ ## API · Backend + Storage
302
+
303
+ _The api-backend base plus file storage wired — @voltro/plugin-storage with public (CDN-direct) and private (policy + grants) objects, with upload examples._
304
+
305
+ The same minimal `notes` base as [`api-backend`](/docs/templates/api-backend), plus file storage wired out of the box via [`@voltro/plugin-storage`](/docs/plugins/storage). It ships two upload actions demonstrating the public and private object classes. Template id: **`api-backend-storage`**.
306
+
307
+ ## Scaffold
308
+
309
+ ```bash
310
+ voltro create-project acme --api=api-backend-storage
311
+ ```
312
+
313
+ ## What ships
314
+
315
+ ```text
316
+ apps/acme/api/ # dir named by the app, not the template
317
+ ├── app.config.ts # api + storagePlugin({ provider: 'memory' })
318
+ ├── package.json
319
+ ├── tsconfig.json
320
+ ├── README.md
321
+ ├── database/
322
+ │ └── schema.ts # actors + tenants + notes (same as api-backend)
323
+ ├── queries/
324
+ │ └── notes.query.ts + .query.server.ts
325
+ ├── mutations/
326
+ │ └── notes.create.mutation.ts + .mutation.server.ts
327
+ └── actions/
328
+ ├── uploadAvatar.action.ts + .action.server.ts # storage.uploadAvatar — PUBLIC
329
+ └── uploadDocument.action.ts + .action.server.ts # storage.uploadDocument — PRIVATE
330
+ ```
331
+
332
+ ## The wiring
333
+
334
+ `app.config.ts` adds the storage plugin with the zero-config `memory` provider:
335
+
336
+ ```ts
337
+ import { storagePlugin } from '@voltro/plugin-storage'
338
+
339
+ export default {
340
+ type: 'api' as const,
341
+ name: '{{projectNamePascal}}{{appNamePascal}}',
342
+ store: 'memory' as const,
343
+ plugins: [
344
+ storagePlugin({ provider: 'memory' }),
345
+ ],
346
+ }
347
+ ```
348
+
349
+ The `memory` provider keeps blobs in-process (dev only). Swap to `'s3'` / `'minio'` (any S3-compatible bucket via `endpoint` — AWS, R2, GCS, B2, Wasabi), `'azure'`, `'filesystem'`, or `'database'` for real object storage. Set `cdnBaseUrl` on the plugin for production public delivery.
350
+
351
+ ## Two object classes
352
+
353
+ - **PUBLIC** (`visibility: 'public'`) — served direct from the bucket/CDN; the returned `url` is what you put in an `<img src>`. The app's serve route is only a dev fallback.
354
+ - **PRIVATE** (default) — gated by an **access policy** (any-of rules over `owner` / `roles` / `groups` / `scopes` / `tenant` / `apiKey` / `password` / a custom `guard`) PLUS **per-object grants** (share a file with a specific user / group / api-key, optionally expiring).
355
+
356
+ ### `actions/uploadAvatar.action.server.ts` — a PUBLIC object
357
+
358
+ ```ts
359
+ import { Effect } from 'effect'
360
+ import type { AppContext } from '@voltro/runtime'
361
+ import { StorageService } from '@voltro/plugin-storage'
362
+
363
+ const execute = (input: { bytesBase64: string; contentType: string }, ctx: AppContext) =>
364
+ Effect.gen(function* () {
365
+ const storage = yield* StorageService
366
+ const bytes = new Uint8Array(Buffer.from(input.bytesBase64, 'base64'))
367
+ const ref = yield* storage.put({
368
+ bytes, contentType: input.contentType,
369
+ visibility: 'public',
370
+ ownerId: ctx.request.subject.id, tenantId: ctx.request.subject.tenantId,
371
+ key: `avatars/${ctx.request.subject.id ?? 'anon'}/avatar`,
372
+ })
373
+ const url = yield* storage.getUrl(ref.id)
374
+ return { id: ref.id, url }
375
+ })
376
+
377
+ export default execute
378
+ ```
379
+
380
+ The shipped `uploadDocument` action mirrors this with `visibility: 'private'` so the file is access-checked on read. File upload is external I/O → an action, not a mutation. For large files prefer a presigned direct-to-bucket upload via the plugin's built-in `storage.mintUploadUrl` route.
381
+
382
+ ## Built-in routes + serve endpoint
383
+
384
+ The plugin ships typed client routes — `storage.upload`, `storage.mintUrl`, `storage.mintUploadUrl`, `storage.share`, `storage.revoke`, `storage.listGrants` — plus the serve endpoint `GET /_voltro/storage/:id` (public → 302 to CDN; private → access-checked). Browse and share stored objects from the dashboard's **Storage** tab. `_voltro_storage_refs` + `_voltro_storage_grants` are auto-migrated on a SQL store.
385
+
386
+ ## Pairs well with
387
+
388
+ - Any web template — wire the upload actions behind a file picker.
389
+
390
+ ## See also
391
+
392
+ - [`@voltro/plugin-storage`](/docs/plugins/storage) — providers, access policies, grants, upload constraints, virus scanning, image transforms.
393
+ - [`api-backend`](/docs/templates/api-backend) — the base this builds on.
394
+
395
+
396
+
397
+ ---
398
+
399
+ <!-- source: en/templates/api-backend-mariadb.md -->
400
+ ## API · Backend (MariaDB)
401
+
402
+ _MariaDB-backed Voltro backend — binlog CDC real-time across replicas plus file storage. The dogfooded shape for K8s multi-replica apps._
403
+
404
+ The `notes` base wired for **MariaDB**: `store: 'mariadb'` turns on the binlog CDC reader so reactive subscriptions fan out across every replica via the database's binary log — no Redis/NATS, the binlog IS the message bus. File storage is on by default. This is the local-dev mirror of the `helm` MariaDB production shape. Template id: **`api-backend-mariadb`**.
405
+
406
+ ## Scaffold
407
+
408
+ ```bash
409
+ voltro create-project acme --api=api-backend-mariadb --baseline=compose-mariadb
410
+ ```
411
+
412
+ Pair it with the `compose-mariadb` baseline (or `helm`): MariaDB must run with `binlog_format=ROW` + `binlog_row_image=FULL` and a user holding `REPLICATION SLAVE, REPLICATION CLIENT`, plus MinIO/S3 for storage. The `compose-mariadb` baseline sets all of this up with one `pnpm db:up`.
413
+
414
+ ## What ships
415
+
416
+ ```text
417
+ apps/acme/api/ # dir named by the app, not the template
418
+ ├── app.config.ts # store:'mariadb' + storagePlugin()
419
+ ├── .env.example # DB / storage / AI / auth / cluster env
420
+ ├── package.json
421
+ ├── tsconfig.json
422
+ ├── README.md
423
+ ├── database/
424
+ │ └── schema.ts # actors + tenants + notes (same as api-backend)
425
+ ├── queries/
426
+ │ └── notes.query.ts + .query.server.ts
427
+ └── mutations/
428
+ └── notes.create.mutation.ts + .mutation.server.ts
429
+ ```
430
+
431
+ ## The wiring
432
+
433
+ ```ts
434
+ // app.config.ts
435
+ import { storagePlugin } from '@voltro/plugin-storage'
436
+
437
+ export default {
438
+ type: 'api' as const,
439
+ name: '{{capProjectName}}{{capAppName}}',
440
+ store: 'mariadb' as const, // turns on binlog CDC
441
+ plugins: [
442
+ // Blobs in object storage (MinIO locally, S3 in prod); metadata in
443
+ // _voltro_storage_refs. Provider resolved from STORAGE_PROVIDER.
444
+ storagePlugin(),
445
+ ],
446
+ }
447
+ ```
448
+
449
+ The shipped `app.config.ts` also carries commented-out `jwtBearerStrategy` + `apiKeyStrategy` blocks under an `auth:` key — uncomment and configure them to wire your IdP / API keys.
450
+
451
+ ## Environment — `.env.example`
452
+
453
+ Copy to `.env` and adjust. The key vars:
454
+
455
+ ```bash
456
+ # Database (MariaDB + binlog CDC)
457
+ DB_DIALECT=mariadb
458
+ DB_URL=mysql://app:app@localhost:3307/{{projectNameSnake}}
459
+ CDC=1 # push-based real-time across replicas (set 0 for inline-only)
460
+
461
+ # File storage — MinIO locally, S3 in prod
462
+ STORAGE_PROVIDER=minio
463
+ S3_ENDPOINT=http://localhost:9000
464
+ S3_BUCKET={{projectNameSnake}}
465
+ S3_ACCESS_KEY_ID=minioadmin
466
+ S3_SECRET_ACCESS_KEY=minioadmin
467
+ S3_FORCE_PATH_STYLE=1
468
+
469
+ # Auth — HMAC key for signed sessions (REQUIRED in production)
470
+ VOLTRO_SESSION_SECRET=dev-only-change-me
471
+
472
+ # Cluster (K8s, >1 replica) — advertise a routable host so a workflow
473
+ # can resume on another pod. Inject POD_IP via the downward API in prod.
474
+ # VOLTRO_WORKFLOW_RUNNER_HOST=
475
+ ```
476
+
477
+ ## Why MariaDB CDC
478
+
479
+ Behind a load balancer each live client WebSocket lives on one replica. A mutation runs on one replica; without cross-instance fan-out, other replicas' clients go stale. MariaDB's ROW-format binlog is one of the two dialects (with postgres `LISTEN/NOTIFY`) that observe writes across instances natively — `CDC=1` reads the binlog and injects remote change events into every replica's store. With `CDC=0` it falls back to single-process inline emit.
480
+
481
+ ## Boot
482
+
483
+ ```bash
484
+ pnpm install
485
+ cp .env.example .env # then adjust DB_URL / secrets
486
+ # Start MariaDB (binlog ROW) + MinIO — the compose-mariadb baseline does this:
487
+ pnpm db:up
488
+ pnpm --filter @acme/api dev
489
+ ```
490
+
491
+ `voltro dev` against MariaDB auto-migrates the schema before any handler boots, and the boot log confirms `CDC: binlog CDC (ROW)`.
492
+
493
+ ## Pairs well with
494
+
495
+ - The [`compose-mariadb` baseline](/docs/templates/overview#baselines-the-deploy-layer) — local MariaDB + MinIO.
496
+ - The `helm` baseline — the K8s production target (per-pod `server_id` + workflow-runner host).
497
+
498
+ ## See also
499
+
500
+ - [`api-backend`](/docs/templates/api-backend) — the base this builds on.
501
+ - [`@voltro/plugin-storage`](/docs/plugins/storage) — the storage plugin wired here.
502
+
503
+
504
+
505
+ ---
506
+
507
+ <!-- source: en/templates/api-durable.md -->
508
+ ## API · Durable
509
+
510
+ _A cohesive order-fulfillment domain that exercises the whole durable surface — a workflow with a human-approval gate, an event trigger, a signal-injecting mutation, a cron, a subscriber, an aggregate, and a startup hook. Zero-infra boot._
511
+
512
+ The template that exercises the framework's **durable-execution surface** end-to-end. Instead of a one-table CRUD reference, it ships a cohesive **order-fulfillment** domain: placing an order emits a domain event, a trigger starts a durable workflow, the workflow reserves stock → sleeps → **parks on a human-approval gate**, and a second mutation injects the approval signal that wakes it. Around that flow it wires a nightly cron, a table subscriber, a materialized aggregate, and a run-once startup hook — every durable / scheduled / reactive primitive in one app. It boots with **zero infrastructure** (`store: 'memory'`). Template id: **`api-durable`**.
513
+
514
+ ## Scaffold
515
+
516
+ ```bash
517
+ voltro create-project acme --api=api-durable
518
+ ```
519
+
520
+ No env or services required. `store: 'memory'` runs the in-process durable workflow engine (replay within one process). Switch `app.config.ts` to `store: 'postgres'` (and `docker compose up`) when you want durable state across restarts + multi-instance cluster workflow runners.
521
+
522
+ ## What ships
523
+
524
+ ```text
525
+ apps/acme/api/ # dir named by the app, not the template
526
+ ├── app.config.ts # type:api, store:'memory', defineEnv
527
+ ├── package.json
528
+ ├── tsconfig.json
529
+ ├── README.md
530
+ ├── database/
531
+ │ └── schema.ts # actors + tenants (core) + an orders table
532
+ ├── mutations/
533
+ │ ├── orders.place.mutation.ts # descriptor — insert + emit order.placed
534
+ │ ├── orders.place.mutation.server.ts # executor — tenant guard + post-commit emit
535
+ │ ├── orders.approve.mutation.ts # descriptor — the human-approval surface
536
+ │ └── orders.approve.mutation.server.ts # executor — inject the approval signal
537
+ ├── triggers/
538
+ │ └── order.placed.trigger.tsx # event order.placed → start orders.fulfill
539
+ ├── workflows/
540
+ │ ├── order.fulfill.workflow.tsx # descriptor — browser-safe contract
541
+ │ └── order.fulfill.workflow.server.tsx # executor — step → sleep → awaitSignal → step
542
+ ├── schedules/
543
+ │ └── nightlyReport.cron.tsx # cron 0 2 * * * (UTC) — per-status tally
544
+ ├── subscribers/
545
+ │ └── orderChanges.subscribe.ts # post-commit reaction to every orders write
546
+ ├── aggregates/
547
+ │ └── orderStats.aggregate.ts # materialized per-tenant/per-status roll-up
548
+ └── startup/
549
+ └── warm.startup.tsx # run-once boot hook + onShutdown teardown
550
+ ```
551
+
552
+ Every primitive is **auto-discovered by file convention** — nothing is registered in `app.config.ts`. Compared to [`api-backend`](/docs/templates/api-backend) (the smallest CRUD reference), this template adds the workflow / trigger / schedule / subscriber / aggregate / startup conventions on top of the same descriptor-split mutation shape.
553
+
554
+ ## The end-to-end flow
555
+
556
+ 1. **Place an order** → `orders.place` inserts the row, then emits `order.placed` *after the transaction commits* — so a rollback never starts a workflow for an order that doesn't exist.
557
+ 2. **The trigger fires** → `order.placed.trigger.tsx` starts the durable `orders.fulfill` workflow with `{ orderId, tenantId }`.
558
+ 3. **The workflow runs durably** → reserves stock (with saga-style compensation), waits a journaled delay, then **parks on `awaitSignal('approval')`**.
559
+ 4. **A human approves** → `orders.approve` re-derives the run's deterministic executionId and injects the `approval` signal; the parked run wakes (sub-second) and either ships or marks the order rejected.
560
+
561
+ Meanwhile the **subscriber** logs every `orders` write, the **aggregate** keeps a live per-status tally, the **schedule** reports nightly, and the **startup hook** holds a process-lifetime resource.
562
+
563
+ ## Database
564
+
565
+ The schema declares the two framework core tables plus an `orders` table carrying the order lifecycle:
566
+
567
+ ```ts
568
+ // database/schema.ts
569
+ import {
570
+ databaseHandle, id, integer, table, text, timestamp, type InferRow,
571
+ } from '@voltro/database'
572
+ import { tenant } from '@voltro/plugin-multitenancy'
573
+
574
+ // Core tables — required by the audit / tenant mixins.
575
+ export const actors = table('actors', {
576
+ id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
577
+ displayName: text().nullable(), createdAt: timestamp().default('now'),
578
+ })
579
+ export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
580
+
581
+ // Application table — the order-fulfillment domain.
582
+ export const orders = table('orders', {
583
+ id: id({ prefix: 'order' }),
584
+ // The fulfillment lifecycle. `oneOf` narrows the row type to the literal
585
+ // union AND emits a cross-dialect CHECK constraint.
586
+ status: text().oneOf(['placed', 'reserved', 'approved', 'rejected', 'shipped']).default('placed'),
587
+ customerName: text(),
588
+ amountCents: integer(),
589
+ })
590
+ .with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
591
+ .reactive() // subscriber + live subscriptions wake on every write to orders
592
+
593
+ export type Order = InferRow<typeof orders>
594
+ export const database = databaseHandle({ actors, tenants, orders })
595
+ ```
596
+
597
+ `tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. `.reactive()` opts the table into the matcher engine so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
598
+
599
+ ## Place — mutation that emits a domain event
600
+
601
+ `orders.place` is the entry point. The descriptor declares the optimistic `target` + typed error; the executor writes the row, guards the tenant, and emits `order.placed` **after commit**.
602
+
603
+ ```ts
604
+ // orders.place.mutation.ts — descriptor (browser-safe)
605
+ import { defineMutation } from '@voltro/protocol'
606
+ import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
607
+ import { Schema } from 'effect'
608
+
609
+ export const placeOrder = defineMutation({
610
+ name: 'orders.place',
611
+ // Auto-optimistic: every query whose `source: 'orders'` matches gets a
612
+ // placeholder row prepended; the server delta replaces it on commit.
613
+ target: {
614
+ table: 'orders', op: 'insert',
615
+ shape: (input: { tenantId: string; customerName: string; amountCents: number }) => ({
616
+ status: 'placed', customerName: input.customerName,
617
+ amountCents: input.amountCents, tenantId: input.tenantId,
618
+ }),
619
+ },
620
+ input: Schema.Struct({
621
+ tenantId: Schema.String,
622
+ customerName: Schema.NonEmptyString,
623
+ amountCents: Schema.Int.pipe(Schema.greaterThan(0)),
624
+ }),
625
+ output: Schema.Struct({
626
+ id: Schema.String, status: Schema.String, customerName: Schema.String,
627
+ amountCents: Schema.Number, tenantId: Schema.String,
628
+ }),
629
+ error: TenantMismatch,
630
+ })
631
+ ```
632
+
633
+ ```ts
634
+ // orders.place.mutation.server.ts — executor (default export)
635
+ import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
636
+ import type { AppContext } from '@voltro/runtime'
637
+
638
+ const execute = async (
639
+ input: { tenantId: string; customerName: string; amountCents: number },
640
+ ctx: AppContext,
641
+ ) => {
642
+ // Cross-tenant write guard — reject a caller spoofing input.tenantId.
643
+ assertOwnTenant(input.tenantId, ctx.request.subject)
644
+
645
+ // Framework auto-injects an `order_…` id + the tenant/audit columns.
646
+ const row = await ctx.store.insert('orders', {
647
+ status: 'placed', customerName: input.customerName,
648
+ amountCents: input.amountCents, tenantId: input.tenantId,
649
+ })
650
+
651
+ // Post-commit fan-out → the `order.placed` trigger starts the
652
+ // `orders.fulfill` workflow. Guard `ctx.events` so a unit-test ctx
653
+ // without it doesn't throw.
654
+ await ctx.events?.emit('order.placed', {
655
+ orderId: row['id'] as string, tenantId: input.tenantId,
656
+ })
657
+
658
+ return {
659
+ id: row['id'] as string, status: row['status'] as string,
660
+ customerName: row['customerName'] as string,
661
+ amountCents: row['amountCents'] as number, tenantId: row['tenantId'] as string,
662
+ }
663
+ }
664
+
665
+ export default execute
666
+ ```
667
+
668
+ `TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.emit(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
669
+
670
+ ## Event trigger — start a workflow from a domain event
671
+
672
+ The trigger maps the `order.placed` event to a workflow start. It's auto-discovered (`*.trigger.tsx`); no `app.config.ts` wiring. See [event triggers](/docs/workflows/event-triggers).
673
+
674
+ ```tsx
675
+ // triggers/order.placed.trigger.tsx
676
+ import { defineEventTrigger } from '@voltro/runtime'
677
+
678
+ export default defineEventTrigger<
679
+ { orderId: string; tenantId: string }
680
+ >({
681
+ event: 'order.placed',
682
+ workflow: 'orders.fulfill',
683
+ // Dedup deliveries: a re-delivered event (crash + replay) with the same
684
+ // key short-circuits the second start.
685
+ idempotencyKey: (event) => `order.placed:${event.data.orderId}`,
686
+ })
687
+ ```
688
+
689
+ The event payload IS the workflow payload here (both `{ orderId, tenantId }`), so no `payload` mapper is needed — pass one when the shapes differ.
690
+
691
+ ## Durable workflow — step → sleep → awaitSignal → step
692
+
693
+ Like the rpc primitives, a workflow is **two files paired by basename**: a browser-safe `*.workflow.tsx` descriptor (the `workflow({...})` contract — schema + idempotency only) and a server-only `*.workflow.server.tsx` executor. See [workflows](/docs/workflows/overview).
694
+
695
+ ```tsx
696
+ // workflows/order.fulfill.workflow.tsx — descriptor (browser-safe)
697
+ import { workflow } from '@voltro/workflow/define'
698
+ import { Schema } from 'effect'
699
+
700
+ export const FulfillOrder = workflow({
701
+ name: 'orders.fulfill',
702
+ payload: {
703
+ orderId: Schema.String,
704
+ tenantId: Schema.String,
705
+ },
706
+ success: Schema.Struct({
707
+ orderId: Schema.String,
708
+ outcome: Schema.Literal('shipped', 'rejected'),
709
+ }),
710
+ // Dedup concurrent starts for the same order — after a crash + replay the
711
+ // trigger could fan out twice; the same key collapses them to one run.
712
+ idempotencyKey: ({ orderId }) => `fulfill:${orderId}`,
713
+ })
714
+ ```
715
+
716
+ The descriptor imports `workflow` from the browser-safe `@voltro/workflow/define` subpath so the codegen can value-import it for the client; the executor imports `step` / `sleep` / `awaitSignal` from the full `@voltro/workflow`.
717
+
718
+ ```tsx
719
+ // workflows/order.fulfill.workflow.server.tsx — executor (server-only)
720
+ import { step, sleep, awaitSignal, withCompensation } from '@voltro/workflow'
721
+ import { Effect, Schema } from 'effect'
722
+ import type { AppContext } from '@voltro/runtime'
723
+
724
+ const Decision = Schema.Struct({ approved: Schema.Boolean })
725
+
726
+ const buildExecute = (ctx: AppContext) =>
727
+ (payload: { orderId: string; tenantId: string }, _executionId: string) =>
728
+ Effect.gen(function* () {
729
+ const { orderId } = payload
730
+
731
+ // 1. Reserve stock. `withCompensation` registers an undo that runs
732
+ // only if the workflow as a whole fails after this point.
733
+ yield* withCompensation(
734
+ step({
735
+ name: 'reserve-stock',
736
+ input: { orderId },
737
+ success: Schema.Struct({ orderId: Schema.String }),
738
+ execute: Effect.tryPromise({
739
+ try: async () => {
740
+ await ctx.store.update('orders', orderId, { status: 'reserved' })
741
+ return { orderId }
742
+ },
743
+ catch: (error) => error as never,
744
+ }),
745
+ }),
746
+ // Saga rollback: release the reservation on whole-workflow failure.
747
+ () =>
748
+ Effect.promise(() =>
749
+ ctx.store.update('orders', orderId, { status: 'placed' }).then(() => undefined),
750
+ ),
751
+ )
752
+
753
+ // 2. Durable delay. The wake is journaled — survives a restart.
754
+ yield* sleep({ name: 'settle-window', duration: '5 seconds' })
755
+
756
+ // 3. Human-approval gate. The run parks here until an external caller
757
+ // sends a matching `approval` signal; the parsed payload is
758
+ // journaled, so a post-signal replay returns it instantly.
759
+ const decision = yield* awaitSignal(ctx, {
760
+ name: 'approval',
761
+ schema: Decision,
762
+ // pollIntervalMs defaults to 200 (backoff → 5s); timeoutMs 24h.
763
+ })
764
+
765
+ if (!decision.approved) {
766
+ yield* step({
767
+ name: 'mark-rejected',
768
+ input: { orderId },
769
+ success: Schema.Struct({ orderId: Schema.String }),
770
+ execute: Effect.tryPromise({
771
+ try: async () => {
772
+ await ctx.store.update('orders', orderId, { status: 'rejected' })
773
+ return { orderId }
774
+ },
775
+ catch: (error) => error as never,
776
+ }),
777
+ })
778
+ return { orderId, outcome: 'rejected' as const }
779
+ }
780
+
781
+ // 4. Approved → ship.
782
+ yield* step({
783
+ name: 'ship',
784
+ input: { orderId },
785
+ success: Schema.Struct({ orderId: Schema.String }),
786
+ execute: Effect.tryPromise({
787
+ try: async () => {
788
+ await ctx.store.update('orders', orderId, { status: 'shipped' })
789
+ return { orderId }
790
+ },
791
+ catch: (error) => error as never,
792
+ }),
793
+ })
794
+
795
+ return { orderId, outcome: 'shipped' as const }
796
+ })
797
+
798
+ export default buildExecute
799
+ ```
800
+
801
+ Three durable primitives in one body: each `step()` is journaled (replayed from its cached result on a crash, never re-run); [`sleep`](/docs/workflows/sleep) is a journaled delay that survives a restart; `awaitSignal` parks the run until an external decision arrives. `withCompensation` adds a saga-style finalizer that runs only if the whole workflow later fails — releasing the reservation so a downstream failure doesn't strand held stock. The default export is a **factory** `(ctx) => execute`; the CLI calls `workflow.toLayer(execute)` so the executor closes over the live `AppContext`.
802
+
803
+ ## Approve — inject the signal that wakes the parked run
804
+
805
+ `orders.approve` is the human-approval surface. It pokes the workflow rather than writing a row, so it declares no optimistic `target`.
806
+
807
+ ```ts
808
+ // orders.approve.mutation.ts — descriptor (browser-safe)
809
+ import { defineMutation } from '@voltro/protocol'
810
+ import { Schema } from 'effect'
811
+
812
+ export const approveOrder = defineMutation({
813
+ name: 'orders.approve',
814
+ input: Schema.Struct({
815
+ orderId: Schema.String,
816
+ tenantId: Schema.String,
817
+ approved: Schema.Boolean,
818
+ }),
819
+ output: Schema.Struct({
820
+ orderId: Schema.String,
821
+ eventId: Schema.String,
822
+ }),
823
+ })
824
+ ```
825
+
826
+ ```ts
827
+ // orders.approve.mutation.server.ts — executor
828
+ import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
829
+ import type { AppContext } from '@voltro/runtime'
830
+
831
+ const execute = async (
832
+ input: { orderId: string; tenantId: string; approved: boolean },
833
+ ctx: AppContext,
834
+ ) => {
835
+ assertOwnTenant(input.tenantId, ctx.request.subject)
836
+
837
+ if (!ctx.workflows) {
838
+ throw new Error('orders.approve: workflow runtime is not available in this context')
839
+ }
840
+
841
+ // Deterministic executionId for the running fulfillment workflow — same
842
+ // payload the `order.placed` trigger started it with.
843
+ const executionId = await ctx.workflows.executionId('orders.fulfill', {
844
+ orderId: input.orderId,
845
+ tenantId: input.tenantId,
846
+ })
847
+
848
+ const { eventId } = await ctx.workflows.signal(
849
+ { executionId },
850
+ 'approval',
851
+ { approved: input.approved },
852
+ )
853
+
854
+ return { orderId: input.orderId, eventId }
855
+ }
856
+
857
+ export default execute
858
+ ```
859
+
860
+ Because the workflow's `idempotencyKey` (`fulfill:<id>`) makes its executionId a deterministic function of the payload, the approve mutation re-derives it from `{ orderId, tenantId }` and addresses the signal by executionId — no need to track the run handle from the place mutation. `ctx.workflows.signal(...)` writes a `signal-sent` row to the run's events log; the workflow's `awaitSignal('approval')` poll picks it up and parses it against its `{ approved: boolean }` schema.
861
+
862
+ ## Schedule — a nightly cron
863
+
864
+ A single-file `*.cron.tsx` schedule runs periodic work. `timezone` is mandatory; the cron is validated at boot. See [scheduling](/docs/scheduling/overview).
865
+
866
+ ```tsx
867
+ // schedules/nightlyReport.cron.tsx
868
+ import { defineSchedule } from '@voltro/runtime'
869
+ import { count, eq } from '@voltro/database'
870
+ import { database } from '../database/schema'
871
+
872
+ export default defineSchedule({
873
+ name: 'nightlyReport',
874
+ cron: '0 2 * * *', // 02:00 every day
875
+ timezone: 'UTC',
876
+ description: 'Logs a per-status order tally each night.',
877
+ handler: async (ctx) => {
878
+ // Tally placed-but-not-yet-shipped orders as a simple health signal.
879
+ const open = await ctx.app.store.query(
880
+ database.orders.where(eq('status', 'placed')).aggregate({ total: count() }).descriptor,
881
+ )
882
+ const total = (open[0] as { total?: number } | undefined)?.total ?? 0
883
+ console.info(`[nightlyReport] fired for ${ctx.scheduledAt.toISOString()} — ${total} order(s) still 'placed'`)
884
+ },
885
+ // optional, all default-safe:
886
+ onOverlap: 'skip',
887
+ maxRuntimeMs: 5 * 60_000,
888
+ })
889
+ ```
890
+
891
+ The handler is "a mutation the clock invokes" — `ctx.app` is the same `AppContext` shape a mutation gets. Zero-config coordination: on `store: 'memory'` it runs single-process; on a multi-instance SQL store the framework advisory-lock coordinates so exactly one replica fires each tick. For durable multi-step work, prefer the `workflow: { name, payload }` form over a handler.
892
+
893
+ ## Subscriber — react to every write post-commit
894
+
895
+ A single-file `*.subscribe.ts` reacts to table writes after they commit. See [subscribers](/docs/data/subscribers).
896
+
897
+ ```ts
898
+ // subscribers/orderChanges.subscribe.ts
899
+ import { defineSubscriber } from '@voltro/runtime'
900
+
901
+ export default defineSubscriber({
902
+ table: 'orders',
903
+ on: 'any', // 'insert' | 'update' | 'delete' | 'any' | a list
904
+ handler: async (event, ctx) => {
905
+ const row = (event.new ?? event.old) as { id?: string; status?: string } | null
906
+ ctx.log.info('order changed', {
907
+ op: event.op,
908
+ id: row?.id,
909
+ status: row?.status,
910
+ })
911
+ },
912
+ })
913
+ ```
914
+
915
+ It fires AFTER commit (the row IS written), best-effort + fire-and-forget — a throw logs and the next event still arrives; it can't back-pressure the change stream. `event.new` is present on insert + update (null on delete); `event.old` on update + delete (null on insert). For crash-safe multi-step reactions, have the subscriber start a workflow instead.
916
+
917
+ ## Aggregate — a materialized roll-up
918
+
919
+ A `*.aggregate.ts` is a pre-defined query the framework refreshes on a schedule, caches, and serves through `ctx.aggregates.<name>.read(...)`. See [aggregates](/docs/data/aggregates).
920
+
921
+ ```ts
922
+ // aggregates/orderStats.aggregate.ts
923
+ import { defineAggregate } from '@voltro/runtime'
924
+ import { column, count } from '@voltro/database'
925
+ import { Schema } from 'effect'
926
+ import { database } from '../database/schema'
927
+
928
+ export const OrderStat = Schema.Struct({
929
+ tenantId: Schema.String,
930
+ status: Schema.String,
931
+ orders: Schema.Number,
932
+ })
933
+ export type OrderStat = Schema.Schema.Type<typeof OrderStat>
934
+
935
+ export default defineAggregate({
936
+ name: 'orderStats',
937
+ refresh: '1m', // re-run every minute (interval shorthand)
938
+ output: OrderStat,
939
+ build: async (ctx) => {
940
+ const rows = await ctx.store.query(
941
+ database.orders
942
+ .groupBy(['tenantId', 'status'])
943
+ // Grouped columns must be EXPLICITLY projected via `column(...)` in
944
+ // the aggregate spec — they are NOT auto-included (SQL rule: a
945
+ // selected non-aggregate column must be in GROUP BY).
946
+ .aggregate({
947
+ tenantId: column('tenantId'),
948
+ status: column('status'),
949
+ orders: count(),
950
+ })
951
+ .descriptor,
952
+ )
953
+ return rows.map((r) => ({
954
+ tenantId: (r as { tenantId: string }).tenantId,
955
+ status: (r as { status: string }).status,
956
+ orders: (r as { orders: number }).orders,
957
+ }))
958
+ },
959
+ })
960
+ ```
961
+
962
+ `output` MUST match each returned row's shape. `build` runs as the system (no per-request subject), so it reads across every tenant; `tenantId` is part of the grouping so reads stay per-tenant. Reach for an aggregate when the source query is expensive but the result is small + bounded.
963
+
964
+ ## Startup — a run-once boot hook
965
+
966
+ A `*.startup.tsx` default-exports a function (no `define*` wrapper). It runs once after migrations + seeds, when the rpc server is listening, and holds a resource for the process lifetime.
967
+
968
+ ```tsx
969
+ // startup/warm.startup.tsx
970
+ import type { StartupContext } from '@voltro/cli/startup'
971
+
972
+ export default async ({ store, log, onShutdown, id }: StartupContext) => {
973
+ log.info(`startup '${id}': warming order-fulfillment caches`)
974
+
975
+ // Example long-lived resource: a heartbeat interval. Replace with a real
976
+ // warm-up (preload a dashboard cache, open a consumer, …).
977
+ const timer = setInterval(() => {
978
+ void store // `store` is the already-migrated DataStore, ready to use.
979
+ }, 60_000)
980
+
981
+ // Teardown runs on SIGTERM / SIGINT, LIFO across all startups, each
982
+ // awaited with a hard 5s timeout. Always release what you acquire.
983
+ onShutdown(() => {
984
+ clearInterval(timer)
985
+ log.info(`startup '${id}': torn down`)
986
+ })
987
+ }
988
+ ```
989
+
990
+ A throw here does NOT block boot — it's logged; the rpc surface stays up. Distinct from `*.seed.ts` (runs once and RETURNS) and `*.cron.tsx` (periodic): a startup HOLDS a resource until shutdown. `StartupContext` is imported from the `@voltro/cli/startup` subpath.
991
+
992
+ ## Try it
993
+
994
+ Over the rpc surface (a `voltro dev` web client, `POST /rpc`, or the inspect `invoke` endpoint):
995
+
996
+ ```jsonc
997
+ // 1. place — inserts the order and starts the fulfillment workflow via order.placed
998
+ { "tag": "orders.place", "input": { "tenantId": "acme", "customerName": "Ada", "amountCents": 4200 } }
999
+ // 2. approve — wakes the parked awaitSignal('approval') gate
1000
+ { "tag": "orders.approve", "input": { "tenantId": "acme", "orderId": "order_…", "approved": true } }
1001
+ ```
1002
+
1003
+ Watch the run in the dashboard's **Workflows** tab (the `awaitSignal` gate also has a "Send signal…" button) and the order status walk `placed → reserved → shipped` live.
1004
+
1005
+ ## When to use api-durable vs. the variants
1006
+
1007
+ | You need… | Pick |
1008
+ |---|---|
1009
+ | The smallest CRUD reference to extend | [`api-backend`](/docs/templates/api-backend) |
1010
+ | Workflows / triggers / schedules / aggregates exercised end-to-end | `api-durable` |
1011
+ | Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
1012
+ | File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
1013
+ | MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
1014
+
1015
+ `api-durable` is the durable-execution showcase; the `notes`-based variants build CRUD + one plugin on top of [`api-backend`](/docs/templates/api-backend).
1016
+
1017
+ ## Pairs well with
1018
+
1019
+ - Any web template — the durable api streams its order status over the same reactive subscriptions every web template consumes.
1020
+ - [`store: 'postgres'`](/docs/database/dialects) — switch the store for durable state across restarts + cluster workflow runners.
1021
+
1022
+ ## Anti-patterns
1023
+
1024
+ - **Emitting the domain event before the write commits.** `orders.place` emits `order.placed` *after* `ctx.store.insert` returns — `ctx.events.emit` is post-commit safe so a rolled-back transaction never starts a workflow for an order that doesn't exist. Don't emit eagerly inside the same expression as the insert.
1025
+ - **Doing external I/O inside a mutation.** The place mutation only inserts + emits. HTTP calls, payments, and other side effects belong in a workflow `step()` (journaled + replayed) or an action — a mutation runs in a transaction and can't roll back an HTTP side effect.
1026
+ - **Tracking the workflow run handle to send a signal.** `orders.approve` re-derives the executionId from the payload via the deterministic `idempotencyKey` instead. Storing the handle from the place mutation is unnecessary and breaks across restarts.
1027
+ - **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — and `TenantMismatch` must be imported from `@voltro/plugin-multitenancy/guard` in the descriptor, never the package root (the root leaks `@voltro/database` into the browser bundle).
1028
+ - **Putting server-only imports in a workflow descriptor.** `*.workflow.tsx` is value-imported into the client rpcGroup. Keep `step` / database / cluster imports in the `.server.tsx` executor; the descriptor imports only `@voltro/workflow/define`.
1029
+
1030
+
1031
+
1032
+ ---
1033
+
1034
+ <!-- source: en/templates/api-ai.md -->
1035
+ ## API · AI agent
1036
+
1037
+ _A RAG support agent over a docs knowledge base — vectorEmbedding() auto-embedded docs, a retrieval tool, a real defineAgent/defineAgentExecutor model loop, and a generateObject action. Boots zero-infra; needs an AI key only to RUN the model._
1038
+
1039
+ The AI template: a **RAG support agent** over a docs knowledge base. It exercises the framework's AI surface end-to-end — a `vectorEmbedding()` `docs` table that auto-embeds on every write, a `defineTool` the agent calls to retrieve docs, a real `defineAgent` / `defineAgentExecutor` model loop, and a `generateObject` action for structured output. It **boots and discovers every primitive with zero infra and no AI key** (`store: 'memory'`); you only need a provider key to actually RUN the agent or the summarize action. Template id: **`api-ai`**.
1040
+
1041
+ ## Scaffold
1042
+
1043
+ ```bash
1044
+ voltro create-project acme --api=api-ai
1045
+ ```
1046
+
1047
+ ## What ships
1048
+
1049
+ ```text
1050
+ apps/acme/api/ # dir named by the app, not the template
1051
+ ├── app.config.ts # type:api, store:'memory', AI env declared
1052
+ ├── package.json # adds @voltro/ai to the api deps
1053
+ ├── tsconfig.json
1054
+ ├── README.md
1055
+ ├── database/
1056
+ │ └── schema.ts # actors + tenants (core) + a docs table with vectorEmbedding()
1057
+ ├── agents/
1058
+ │ ├── support.agent.tsx # descriptor — browser-safe (name + input)
1059
+ │ └── support.agent.server.tsx # executor — system prompt + tools + maxSteps
1060
+ ├── tools/
1061
+ │ └── searchDocs.tool.tsx # retrieval tool — nearestNeighbours over docs
1062
+ ├── actions/
1063
+ │ ├── summarize.action.ts # descriptor — browser-safe
1064
+ │ └── summarize.action.server.tsx # executor — generateObject structured output
1065
+ └── seeds/
1066
+ └── docs.seed.ts # boot seed of sample docs (auto-embedded on insert)
1067
+ ```
1068
+
1069
+ No queries or mutations ship — the agent's two routes are SYNTHESIZED (see below). Builds on the same `actors` + `tenants` core as [`api-backend`](/docs/templates/api-backend), but the example table is `docs` (vector-embedded), not `notes`.
1070
+
1071
+ ## The agent — descriptor / `.server.ts` split
1072
+
1073
+ Like every rpc primitive, an agent is **two files paired by basename**: a browser-safe descriptor (`*.agent.tsx`, `defineAgent` from `@voltro/ai/agent`) carrying just `name` + `input`, and a server-only executor (`*.agent.server.tsx`, `defineAgentExecutor` from `@voltro/ai`) carrying the system prompt, tools, model, and `maxSteps`. The descriptor is value-imported into `rpcGroup.generated.ts` so the web client is typed end-to-end; the executor holds the secrets and the server imports.
1074
+
1075
+ ### Descriptor — `agents/support.agent.tsx`
1076
+
1077
+ ```tsx
1078
+ // support.agent.tsx — DESCRIPTOR (browser-safe)
1079
+ import { defineAgent } from '@voltro/ai/agent'
1080
+ import { Schema } from 'effect'
1081
+
1082
+ export const support = defineAgent({
1083
+ name: 'support',
1084
+ input: Schema.Struct({
1085
+ prompt: Schema.String,
1086
+ // Optional locale to steer the system prompt's reply language.
1087
+ locale: Schema.optional(Schema.String),
1088
+ }),
1089
+ })
1090
+ ```
1091
+
1092
+ ### Executor — `agents/support.agent.server.tsx`
1093
+
1094
+ ```tsx no-check
1095
+ // support.agent.server.tsx — EXECUTOR (server-only)
1096
+ import { defineAgentExecutor } from '@voltro/ai'
1097
+ import { support } from './support.agent'
1098
+ import { searchDocs } from '../tools/searchDocs.tool'
1099
+
1100
+ export default defineAgentExecutor(support, {
1101
+ system: (input) =>
1102
+ [
1103
+ 'You are a friendly, precise support agent for this product.',
1104
+ 'Answer ONLY from the knowledge base. ALWAYS call the `searchDocs`',
1105
+ 'tool first to retrieve relevant docs, then answer using their',
1106
+ 'content. If the docs do not cover the question, say so plainly',
1107
+ 'instead of guessing.',
1108
+ `Reply in this locale: ${input.locale ?? 'en'}.`,
1109
+ ].join(' '),
1110
+ tools: { searchDocs },
1111
+ // No `model` → inherit AI_PROVIDER / AI_MODEL + the key var from env.
1112
+ maxSteps: 6,
1113
+ })
1114
+ ```
1115
+
1116
+ Omitting `model` makes the agent inherit the provider from env (`AI_PROVIDER` / `AI_MODEL` + the provider's standard key var). That keeps the secret server-side and lets the same template run against any provider via `.env`.
1117
+
1118
+ ### Two synthesized routes — you write neither
1119
+
1120
+ From the agent descriptor the framework SYNTHESIZES two procedures into `rpcGroup.generated.ts`:
1121
+
1122
+ - **`support.send`** — an **action** that appends the user turn and drives a streaming assistant turn (delta-persisted to `agent_messages`). Wire input: the descriptor's `input` fields plus `threadId` + `order`.
1123
+ - **`support.messages`** — a **reactive query** (`source: 'agent_messages'`) that streams the persisted turns, including the live `streaming:true` row being patched, to the client.
1124
+
1125
+ The thread tables (`agent_threads`, `agent_messages`) are auto-provided and auto-migrated because this app ships an `*.agent.tsx` — no schema file needed. Drive them from a web client:
1126
+
1127
+ ```tsx
1128
+ // mint a thread id, subscribe to the live feed, then send a turn
1129
+ const threadId = `thread_${crypto.randomUUID().replace(/-/g, '')}`
1130
+ const { data: messages } = useSubscription('app', ['support.messages', { threadId }], { threadId })
1131
+ const send = useAction('app', 'support.send')
1132
+ await send.run({ threadId, order: messages?.length ?? 0, prompt: 'How do I reset my password?' })
1133
+ ```
1134
+
1135
+ As `support.send` writes token deltas to the streaming row, the `support.messages` subscription re-fires and the client sees each chunk — the live typewriter bubble is just a row whose `streaming` flag is `true`. No streaming RPC, no manual ws handling.
1136
+
1137
+ ## The retrieval tool — `tools/searchDocs.tool.tsx`
1138
+
1139
+ A `defineTool` (the `*.tool.tsx` convention) is a named DESCRIPTOR with Schema-typed `input` / `output` and NO body, plus a default-exported `(input, ctx) => …` handler. The framework wires the default export onto the descriptor at discovery, and the agent's `tools: { searchDocs }` imports the SAME module instance. This file is SERVER-ONLY — it's referenced from the agent executor, never a browser-loaded descriptor, so importing the `database` handle here is safe.
1140
+
1141
+ ```tsx
1142
+ import { defineTool } from '@voltro/ai'
1143
+ import type { AppContext } from '@voltro/runtime'
1144
+ import { Schema } from 'effect'
1145
+ import { database } from '../database/schema'
1146
+
1147
+ const MAX_RESULTS = 5
1148
+ const SNIPPET_LEN = 280
1149
+
1150
+ export const searchDocs = defineTool({
1151
+ name: 'searchDocs',
1152
+ description:
1153
+ 'Search the support knowledge base for documents relevant to a question. ' +
1154
+ 'Returns up to 5 results, each with a title and a short snippet of the body.',
1155
+ input: Schema.Struct({ query: Schema.String }),
1156
+ output: Schema.Array(
1157
+ Schema.Struct({ title: Schema.String, snippet: Schema.String }),
1158
+ ),
1159
+ })
1160
+
1161
+ export default async (
1162
+ { query }: { query: string },
1163
+ ctx: AppContext,
1164
+ ): Promise<ReadonlyArray<{ title: string; snippet: string }>> => {
1165
+ try {
1166
+ const rows = await ctx.store.query(
1167
+ database.docs.nearestNeighbours(query, MAX_RESULTS).descriptor,
1168
+ )
1169
+ return rows.map((row) => {
1170
+ const body = String((row as { body?: unknown }).body ?? '')
1171
+ return {
1172
+ title: String((row as { title?: unknown }).title ?? ''),
1173
+ snippet: body.length > SNIPPET_LEN ? `${body.slice(0, SNIPPET_LEN)}…` : body,
1174
+ }
1175
+ })
1176
+ } catch {
1177
+ // No AI key / embed failure / cold table → degrade to "no results"
1178
+ // rather than aborting the agent's tool loop.
1179
+ return []
1180
+ }
1181
+ }
1182
+ ```
1183
+
1184
+ Because `docs` carries `vectorEmbedding()`, the STRING overload of `nearestNeighbours(query, k)` is valid — the runtime embeds `query` before searching. Any failure (no AI key, embed error) is caught and returns `[]`, so a tool hiccup surfaces to the model as "no results" instead of aborting the whole agent turn.
1185
+
1186
+ ## The structured-output action — `actions/summarize.action.ts` + `.server.tsx`
1187
+
1188
+ AI inference is external I/O, so it's an **action**, not a mutation. This one uses `generateObject` to take free-text and produce STRUCTURED output — a typed `{ title, summary, keyPoints, sentiment }` object instead of prose. The `output` Schema is exported from the descriptor so the executor can reuse the SAME schema to constrain the model: one source of truth for both the wire output and the shape the model must satisfy.
1189
+
1190
+ ```ts
1191
+ // summarize.action.ts — DESCRIPTOR (browser-safe)
1192
+ import { defineAction } from '@voltro/protocol'
1193
+ import { Schema } from 'effect'
1194
+
1195
+ // The structured shape the model must emit. Top-level `Schema.Struct`
1196
+ // (generateObject requires a JSON `object` at the root — an array/scalar
1197
+ // must be wrapped in a field, as `keyPoints` is here).
1198
+ export const SummaryResult = Schema.Struct({
1199
+ title: Schema.String,
1200
+ summary: Schema.String,
1201
+ keyPoints: Schema.Array(Schema.String),
1202
+ sentiment: Schema.Literal('positive', 'neutral', 'negative'),
1203
+ })
1204
+ export type SummaryResult = Schema.Schema.Type<typeof SummaryResult>
1205
+
1206
+ export const summarize = defineAction({
1207
+ name: 'support.summarize',
1208
+ input: Schema.Struct({
1209
+ // The raw text to summarise (a support thread, a doc, a transcript).
1210
+ text: Schema.String,
1211
+ }),
1212
+ output: SummaryResult,
1213
+ })
1214
+ ```
1215
+
1216
+ ```ts
1217
+ // summarize.action.server.tsx — EXECUTOR (server-only)
1218
+ import { Effect } from 'effect'
1219
+ import { generateObject } from '@voltro/ai'
1220
+ import { SummaryResult } from './summarize.action'
1221
+
1222
+ const execute = (input: { text: string }) =>
1223
+ Effect.gen(function* () {
1224
+ const { object } = yield* generateObject({
1225
+ schema: SummaryResult,
1226
+ system:
1227
+ 'Summarise the given support text. Produce a short title, a 1-2 sentence ' +
1228
+ 'summary, the key points as a list, and the overall sentiment.',
1229
+ prompt: input.text,
1230
+ maxTokens: 500,
1231
+ })
1232
+ return object
1233
+ })
1234
+
1235
+ export default execute
1236
+ ```
1237
+
1238
+ `generateObject` converts the `SummaryResult` Effect Schema to JSON Schema for the provider, then decodes the result back so brands/refinements hold. A provider failure or malformed output surfaces as a typed `AiError` (`reason: 'generation' | 'decode'`) on the Effect failure channel — declare it on the descriptor's `error:` to surface it typed to the client, or `Effect.catchTag('AiError', …)` to handle it in the executor.
1239
+
1240
+ ## Database — auto-embedded `docs`
1241
+
1242
+ The schema declares the two framework core tables plus one vector-embedded knowledge-base table:
1243
+
1244
+ ```ts
1245
+ // database/schema.ts
1246
+ import {
1247
+ databaseHandle,
1248
+ id,
1249
+ table,
1250
+ text,
1251
+ vectorEmbedding,
1252
+ type InferRow,
1253
+ } from '@voltro/database'
1254
+
1255
+ // Core tables — required by the audit / tenant mixins.
1256
+ export const actors = table('actors', {
1257
+ id: id(),
1258
+ kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
1259
+ displayName: text().nullable(),
1260
+ })
1261
+
1262
+ export const tenants = table('tenants', {
1263
+ id: id(),
1264
+ name: text(),
1265
+ })
1266
+
1267
+ // Knowledge base — vectorEmbedding({ from: 'body' }) adds the `embedding`
1268
+ // vector column + an HNSW index, and registers a re-embed hook that calls
1269
+ // @voltro/ai's `embed` on every insert/update of `body` — so a plain
1270
+ // ctx.store.insert('docs', { title, body }) auto-embeds, no app code.
1271
+ export const docs = table('docs', {
1272
+ id: id({ prefix: 'doc' }),
1273
+ title: text(),
1274
+ body: text(),
1275
+ })
1276
+ .with(
1277
+ vectorEmbedding({
1278
+ from: 'body',
1279
+ model: 'text-embedding-3-small',
1280
+ dimensions: 1536,
1281
+ }),
1282
+ )
1283
+ // Opt into the matcher engine so subscriptions over `docs` re-fire on write.
1284
+ .reactive()
1285
+
1286
+ export type Doc = InferRow<typeof docs>
1287
+
1288
+ export const database = databaseHandle({ actors, tenants, docs })
1289
+ ```
1290
+
1291
+ `dimensions` MUST match the embedding model's output width — `1536` is OpenAI's `text-embedding-3-small`. If you point at a different embedding model, change it to match (e.g. `768` / `3072`) or the vector column size won't line up. See [Vectors / RAG](/docs/database/vectors) for `nearestNeighbours`, `hybridSearch`, and the `vectorEmbedding()` mixin in depth.
1292
+
1293
+ ## The seed — `seeds/docs.seed.ts`
1294
+
1295
+ `defineSeed` with `lifecycle: 'boot'` runs once per `voltro dev` boot, re-running only when the seed file's content fingerprint changes. `upsertByUnique` keys on `title`, so re-runs UPDATE rather than duplicate — idempotent. Because `docs` carries `vectorEmbedding({ from: 'body' })`, each insert/update AUTO-EMBEDS the body — no embedding code here.
1296
+
1297
+ ```ts
1298
+ import { defineSeed } from '@voltro/database'
1299
+
1300
+ const SAMPLE_DOCS: ReadonlyArray<{ title: string; body: string }> = [
1301
+ { title: 'Resetting your password', body: '…' },
1302
+ { title: 'Inviting teammates', body: '…' },
1303
+ { title: 'Exporting your data', body: '…' },
1304
+ { title: 'API rate limits', body: '…' },
1305
+ ]
1306
+
1307
+ export default defineSeed({
1308
+ id: 'docs',
1309
+ name: 'Sample knowledge-base docs',
1310
+ lifecycle: 'boot',
1311
+ steps: ({ step }) => [
1312
+ step('seed-docs', async ({ upsertByUnique }) => {
1313
+ let rowsTouched = 0
1314
+ for (const doc of SAMPLE_DOCS) {
1315
+ await upsertByUnique('docs', { title: doc.title }, { title: doc.title, body: doc.body })
1316
+ rowsTouched += 1
1317
+ }
1318
+ return { rowsTouched }
1319
+ }),
1320
+ ],
1321
+ })
1322
+ ```
1323
+
1324
+ The auto-embed needs a provider key. Without one the rows still seed (title + body persist) but the `embedding` column stays empty. Set a key, then fill the gaps:
1325
+
1326
+ ```bash
1327
+ voltro embeddings backfill docs --text body --vector embedding
1328
+ # add --model <m> if you changed the embedding model from the default
1329
+ ```
1330
+
1331
+ This (re-)embeds existing rows the `vectorEmbedding()` mixin missed — the same command to run after a model change.
1332
+
1333
+ ## Store + AI provider — env
1334
+
1335
+ `app.config.ts` ships `store: 'memory'` — fast startup, no Docker, data resets on restart. The vector column is still stored, but ANN falls back to a sequential scan (correct, just unindexed). Switch to `store: 'postgres'` for a real pgvector HNSW index.
1336
+
1337
+ `@voltro/ai` reads the provider/model/key from env. All are OPTIONAL for booting; they're only needed to RUN the model. The template declares them with `defineEnv` so they show up in `voltro env` and `.env.example`:
1338
+
1339
+ ```ts
1340
+ // app.config.ts (excerpt)
1341
+ import { defineEnv, envVar } from '@voltro/env'
1342
+
1343
+ export const env = defineEnv({
1344
+ LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
1345
+ AI_PROVIDER: envVar.string({ access: 'public', optional: true }), // e.g. 'openai' | 'anthropic'
1346
+ AI_MODEL: envVar.string({ access: 'public', optional: true }), // e.g. 'gpt-4o-mini'
1347
+ AI_API_KEY: envVar.string({ access: 'secret', optional: true }), // server-only
1348
+ })
1349
+
1350
+ export default {
1351
+ type: 'api' as const,
1352
+ name: 'acmeApi',
1353
+ store: 'memory' as const,
1354
+ env,
1355
+ }
1356
+ ```
1357
+
1358
+ ```bash
1359
+ # .env — @voltro/ai also accepts the provider-standard var (OPENAI_API_KEY / ANTHROPIC_API_KEY)
1360
+ AI_PROVIDER=openai
1361
+ AI_MODEL=gpt-4o-mini
1362
+ AI_API_KEY=sk-...
1363
+ ```
1364
+
1365
+ Editing `.env` hard-restarts `voltro dev` automatically. Run `voltro env` to see the resolved manifest.
1366
+
1367
+ ## When to use api-ai vs. the variants
1368
+
1369
+ | You need… | Pick |
1370
+ |---|---|
1371
+ | RAG agent + tools + structured output (the AI showcase) | `api-ai` |
1372
+ | The smallest generic backend to extend | [`api-backend`](/docs/templates/api-backend) |
1373
+ | Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
1374
+ | File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
1375
+
1376
+ ## Pairs well with
1377
+
1378
+ - Any web template — pair `support.send` / `support.messages` with a chat UI for the live typewriter feed.
1379
+ - [AI agents](/docs/ai/agents) — the `defineAgent` / `defineAgentExecutor` model loop, synthesized routes, thread persistence.
1380
+ - [AI tools](/docs/ai/tools) — `defineTool` shape, how tool bodies reach app services.
1381
+ - [RAG](/docs/ai/rag) and [Vectors](/docs/database/vectors) — `vectorEmbedding()`, `nearestNeighbours`, `hybridSearch`.
1382
+
1383
+ ## Anti-patterns
1384
+
1385
+ - **Hardcoding a model or API key in the descriptor.** The model + key live in the `.server.tsx` executor (or env), never the browser-safe `*.agent.tsx`. Omitting `model` to inherit from env keeps the secret server-side and lets one template run against any provider.
1386
+ - **Making the AI call a mutation.** AI inference is external I/O — use an **action**. A mutation runs in a transaction and can't roll back the model call's side effect; `summarize` is correctly an action.
1387
+ - **Letting a tool failure abort the agent turn.** `searchDocs` catches every error and returns `[]` so a missing key or embed failure degrades to "no results" instead of crashing the whole tool loop.
1388
+ - **Mismatching `dimensions` with the embedding model.** The `vector` column width is fixed at declaration. If `dimensions` doesn't match the model's output width, inserts/queries won't line up — change it together with the model.
1389
+ - **Hand-writing thread/send/list routes.** The agent path SYNTHESIZES `support.send` + `support.messages` and types them through codegen. Don't reinvent them just to get a typed client.
1390
+
1391
+
1392
+
1393
+ ---
1394
+
1395
+ <!-- source: en/templates/api-data-advanced.md -->
1396
+ ## API · Data (advanced)
1397
+
1398
+ _A library/catalog api that tours the advanced schema DSL — the entity/relations split with eager loading, full-text search, dbEnum, array + generated + encrypted columns, and declarative query caching._
1399
+
1400
+ A small **library/catalog** (authors + books) that closes the advanced schema-DSL gaps — the features that are documented but shown in no other api template: the `*.entity.ts` / `*.relations.ts` split with eager `.with()` loading, full-text search, `dbEnum`, array / generated / encrypted columns, and declarative query result caching. Reach for it as a worked reference when you're modelling something richer than a single flat table. Template id: **`api-data-advanced`**.
1401
+
1402
+ ## Scaffold
1403
+
1404
+ ```bash
1405
+ voltro create-project acme --api=api-data-advanced
1406
+ ```
1407
+
1408
+ ## What ships
1409
+
1410
+ ```text
1411
+ apps/acme/api/ # dir named by the app, not the template
1412
+ ├── app.config.ts # type:api, store:'memory', governancePlugin({ fieldEncryption: true })
1413
+ ├── .env # DEV-ONLY VOLTRO_FIELD_ENCRYPTION_KEY (see below)
1414
+ ├── package.json
1415
+ ├── tsconfig.json
1416
+ ├── README.md
1417
+ ├── database/
1418
+ │ ├── actors.entity.ts # core audit-subject table
1419
+ │ ├── tenants.entity.ts # core tenant boundary
1420
+ │ ├── authors.entity.ts # name + .encrypted() bio
1421
+ │ ├── books.entity.ts # dbEnum genre, array tags, generated slug, FTS index
1422
+ │ ├── authors.relations.ts # author → many books
1423
+ │ ├── books.relations.ts # book → one author
1424
+ │ └── index.ts # databaseHandle({...}) + relation registration
1425
+ ├── queries/
1426
+ │ ├── books.search.query.ts(.server) # FTS via .matching(...) + cache
1427
+ │ └── authors.withBooks.query.ts(.server) # eager-load via .with({ books: true })
1428
+ └── seeds/
1429
+ └── catalog.seed.ts # demo authors + books
1430
+ ```
1431
+
1432
+ `store: 'memory'` keeps boot zero-infra (no Docker, data resets on restart). The schema DSL is identical across every SQL backend — switch `app.config.ts` to `store: 'postgres'` to see the native DDL the migrator emits (`CREATE TYPE … ENUM`, `text[]`, the STORED generated column, the tsvector + GIN FTS index). On `memory` the same features run through the in-process store: FTS degrades to a substring scan, arrays round-trip as JS arrays, encryption still applies. See the [SQL dialects guide](/docs/database/dialects).
1433
+
1434
+ ## The `*.entity.ts` / `*.relations.ts` split
1435
+
1436
+ Tables are declared **one per file** with the `*.entity.ts` extension. Relations are declared OUTSIDE the table descriptor, in a sibling `*.relations.ts` file — the framework registers them at boot and the query builder's `.with()` chain uses them to eager-load.
1437
+
1438
+ ```ts
1439
+ // database/authors.entity.ts
1440
+ import { id, table, text } from '@voltro/database'
1441
+ import { tenant } from '@voltro/plugin-multitenancy/mixin'
1442
+
1443
+ export const authors = table('authors', {
1444
+ id: id({ prefix: 'author' }),
1445
+ name: text(),
1446
+ // Encrypted at rest. Requires governancePlugin({ fieldEncryption: true }).
1447
+ bio: text().encrypted().nullable(),
1448
+ })
1449
+ // tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
1450
+ .with(tenant())
1451
+ .reactive()
1452
+ ```
1453
+
1454
+ ```ts
1455
+ // database/authors.relations.ts — declared OUTSIDE the table descriptor
1456
+ import { relations } from '@voltro/database'
1457
+ import { authors } from './authors.entity'
1458
+ import { books } from './books.entity'
1459
+
1460
+ // An author has MANY books. `foreignKey` auto-derives because exactly one
1461
+ // reference() column on `books` (`authorId`) points back at `authors`.
1462
+ export const authorsRelations = relations(authors, ({ many }) => ({
1463
+ books: many(books),
1464
+ }))
1465
+ ```
1466
+
1467
+ ```ts
1468
+ // database/books.relations.ts — the inverse, a book belongs to ONE author
1469
+ import { relations } from '@voltro/database'
1470
+ import { authors } from './authors.entity'
1471
+ import { books } from './books.entity'
1472
+
1473
+ export const booksRelations = relations(books, ({ one }) => ({
1474
+ author: one(authors),
1475
+ }))
1476
+ ```
1477
+
1478
+ The handle barrel imports the `*.relations.ts` files (side-effecting) so the eager-load walker can resolve them:
1479
+
1480
+ ```ts
1481
+ // database/index.ts
1482
+ import { databaseHandle, type InferRow } from '@voltro/database'
1483
+ import { actors } from './actors.entity'
1484
+ import { tenants } from './tenants.entity'
1485
+ import { authors } from './authors.entity'
1486
+ import { books } from './books.entity'
1487
+
1488
+ // Register relations (side-effecting imports).
1489
+ import './authors.relations'
1490
+ import './books.relations'
1491
+
1492
+ export { actors, tenants, authors, books }
1493
+ export type Author = InferRow<typeof authors>
1494
+ export type Book = InferRow<typeof books>
1495
+
1496
+ export const database = databaseHandle({ actors, tenants, authors, books })
1497
+ ```
1498
+
1499
+ See [Relations](/docs/database/relations).
1500
+
1501
+ ## Eager loading — `.with({ books: true })`
1502
+
1503
+ `authors.withBooks` returns each author with their books attached as an array, in ONE SQL roundtrip (the dialect's JSON-aggregation idiom — postgres `jsonb_agg`, sqlite `json_group_array`, mysql `JSON_ARRAYAGG`, mssql `FOR JSON PATH`). The per-branch `orderBy`/`limit` apply PER author.
1504
+
1505
+ ```ts
1506
+ // queries/authors.withBooks.query.server.ts — executor (server-only)
1507
+ import type { AppContext } from '@voltro/runtime'
1508
+ import { database } from '../database/index'
1509
+
1510
+ const execute = (_input: Record<string, never>, _ctx: AppContext) =>
1511
+ database.authors.with({
1512
+ books: { orderBy: [{ column: 'title', direction: 'asc' }], limit: 50 },
1513
+ })
1514
+
1515
+ export default execute
1516
+ ```
1517
+
1518
+ The descriptor's `output` carries the nested `books` array, and `source: ['authors', 'books']` makes the reactive subscription re-run when EITHER table changes — a new book under an author pushes a fresh snapshot:
1519
+
1520
+ ```ts
1521
+ // queries/authors.withBooks.query.ts — descriptor (browser-safe)
1522
+ import { defineQuery } from '@voltro/protocol'
1523
+ import { Schema } from 'effect'
1524
+
1525
+ const Book = Schema.Struct({
1526
+ id: Schema.String, authorId: Schema.String, title: Schema.String,
1527
+ summary: Schema.String, genre: Schema.String,
1528
+ tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
1529
+ })
1530
+
1531
+ export const authorsWithBooks = defineQuery({
1532
+ name: 'authors.withBooks',
1533
+ source: ['authors', 'books'],
1534
+ input: Schema.Struct({}),
1535
+ output: Schema.Struct({
1536
+ id: Schema.String, name: Schema.String,
1537
+ // `bio` is the .encrypted() column — handlers + the wire see plaintext.
1538
+ bio: Schema.NullOr(Schema.String), tenantId: Schema.String,
1539
+ books: Schema.Array(Book), // eager-loaded relation
1540
+ }),
1541
+ })
1542
+ ```
1543
+
1544
+ See [Eager loading](/docs/database/relations/eager-loading).
1545
+
1546
+ ## Full-text search — `.fullTextIndex` + `.matching`
1547
+
1548
+ `books` declares one FTS index over `title` + `summary`. That single declaration compiles to a different backend per dialect (postgres tsvector + GIN, mysql/mariadb `FULLTEXT`, sqlite FTS5, mssql ranked-LIKE fallback). The `books` table also carries the other specialized columns — `dbEnum`, `array(text())`, and a stored `.generatedAs(...)`:
1549
+
1550
+ ```ts
1551
+ // database/books.entity.ts
1552
+ import { array, dbEnum, id, reference, table, text } from '@voltro/database'
1553
+ import { tenant } from '@voltro/plugin-multitenancy/mixin'
1554
+ import { authors } from './authors.entity'
1555
+
1556
+ // Native ENUM type — declared module-level as a reusable handle. `as const`
1557
+ // is required for the literal-union type. postgres → CREATE TYPE … ENUM
1558
+ // (ADD VALUE is O(1)); mysql/mariadb → native ENUM(...); mssql/sqlite → CHECK.
1559
+ export const bookGenre = dbEnum('book_genre', [
1560
+ 'fiction', 'nonfiction', 'fantasy', 'sciFi', 'mystery', 'biography',
1561
+ ] as const)
1562
+
1563
+ export const books = table('books', {
1564
+ id: id({ prefix: 'book' }),
1565
+ authorId: reference(() => authors), // FK → authors, auto-indexed
1566
+ title: text(),
1567
+ summary: text(),
1568
+ genre: bookGenre.column().default('fiction'), // native ENUM column
1569
+ tags: array(text()).default([]), // text[] on postgres; JSON codec elsewhere
1570
+ // DB-computed STORED column derived from `title` on INSERT + UPDATE.
1571
+ // `stored: true` persists it on disk so it's indexable. Same-row columns
1572
+ // only. Raw SQL emitted verbatim — keep it portable (quoted identifier).
1573
+ slug: text().generatedAs(`lower("title")`, { stored: true }),
1574
+ })
1575
+ .fullTextIndex('bookSearch', ['title', 'summary'], {
1576
+ config: 'english',
1577
+ weights: { title: 'A', summary: 'B' },
1578
+ })
1579
+ .with(tenant())
1580
+ .reactive()
1581
+ ```
1582
+
1583
+ The query executor compiles the FTS predicate via `.matching('bookSearch', q)` — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; `books` carries `tenant()`, so the runtime AND-merges `eq('tenantId', subject.tenantId)`:
1584
+
1585
+ ```ts
1586
+ // queries/books.search.query.server.ts — executor (server-only)
1587
+ import type { AppContext } from '@voltro/runtime'
1588
+ import { database } from '../database/index'
1589
+
1590
+ const execute = (input: { q: string }, _ctx: AppContext) =>
1591
+ database.books.matching('bookSearch', input.q).limit(50)
1592
+
1593
+ export default execute
1594
+ ```
1595
+
1596
+ See [Full-text search](/docs/database/full-text-search), [Enums](/docs/database/enums), [Arrays & intervals](/docs/database/arrays-intervals), and [Generated columns](/docs/database/generated-columns).
1597
+
1598
+ ## Field encryption — `.encrypted()` + the DEV-ONLY `.env`
1599
+
1600
+ `authors.bio` is flagged `.encrypted()`. The store middleware transparently encrypts it on write (AES-256-GCM) and decrypts on read — handlers always see plaintext while the column stores an opaque `enc:v1:…` string on disk on every dialect. An `.encrypted()` column is ciphertext in SQL, so you can't filter or sort by its plaintext — encrypt only what you read back whole (PII, tokens, notes). A book search never touches `bio`, so this is safe.
1601
+
1602
+ The column ONLY works because `app.config.ts` wires `governancePlugin({ fieldEncryption: true })`, which registers the cipher. Boot fails loud if an `.encrypted()` column exists but no cipher is registered.
1603
+
1604
+ ```ts
1605
+ // app.config.ts
1606
+ import { defineEnv, envVar } from '@voltro/env'
1607
+ import { governancePlugin } from '@voltro/plugin-governance'
1608
+
1609
+ export const env = defineEnv({
1610
+ LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
1611
+ // NO `default` on purpose — see the note below.
1612
+ VOLTRO_FIELD_ENCRYPTION_KEY: envVar.string({ access: 'secret' }),
1613
+ })
1614
+
1615
+ export default {
1616
+ type: 'api' as const,
1617
+ name: '{{capProjectName}}{{capAppName}}',
1618
+ store: 'memory' as const,
1619
+ env,
1620
+ plugins: [
1621
+ governancePlugin({ fieldEncryption: true }), // reads secret VOLTRO_FIELD_ENCRYPTION_KEY
1622
+ ],
1623
+ }
1624
+ ```
1625
+
1626
+ **Where the key actually comes from is the load-bearing detail.** `governancePlugin({ fieldEncryption: true })` resolves the AES-256-GCM key through the **Secrets-Resolver** — i.e. the process environment — NOT through a `defineEnv` `default`. A `defineEnv` default would satisfy the boot validation gate while the cipher still failed to resolve a key. That's why the env declaration above deliberately has no `default`, and the template instead ships a `.env` carrying a **DEV-ONLY** placeholder so `voltro dev` boots out of the box:
1627
+
1628
+ ```bash
1629
+ # .env — DEV-ONLY. `voltro dev` loads this into process.env at boot, BEFORE
1630
+ # the env-validation gate and BEFORE plugins activate, which is how the
1631
+ # governance plugin's cipher resolves its key from the Secrets-Resolver.
1632
+ VOLTRO_FIELD_ENCRYPTION_KEY=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
1633
+ ```
1634
+
1635
+ > **Before production**, replace this placeholder with a real key (`openssl rand -hex 32`) and move it into your deployment's secret store — never ship the shipped value. **Lose the key → lose the data** (GCM fails closed, never silent corruption); **rotating it** makes every existing `.encrypted()` value unreadable.
1636
+
1637
+ See [Field encryption](/docs/plugins/governance) and the [columns reference](/docs/database/columns).
1638
+
1639
+ ## Declarative query caching
1640
+
1641
+ `books.search` adds `cache` to its `defineQuery`. The framework caches the server snapshot and auto-invalidates it whenever a mutation writes any table the query depends on (here, `books`) — zero manual busting. `scope` is REQUIRED and is a security decision: this query is tenant-filtered (the `tenant()` mixin AND-merges the caller's `tenantId`), so it depends on the caller → `scope: 'subject'` (the cache key includes the subject id, no cross-tenant leak). NEVER `global` on a subject-filtered query.
1642
+
1643
+ ```ts
1644
+ // queries/books.search.query.ts — descriptor (browser-safe)
1645
+ import { defineQuery } from '@voltro/protocol'
1646
+ import { Schema } from 'effect'
1647
+
1648
+ export const searchBooks = defineQuery({
1649
+ name: 'books.search',
1650
+ source: 'books',
1651
+ input: Schema.Struct({ q: Schema.String }),
1652
+ output: Schema.Struct({
1653
+ id: Schema.String, authorId: Schema.String, title: Schema.String,
1654
+ summary: Schema.String, genre: Schema.String,
1655
+ tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
1656
+ }),
1657
+ cache: { ttl: '30s', swr: '5m', scope: 'subject' },
1658
+ })
1659
+ ```
1660
+
1661
+ The `authors.withBooks` query deliberately has NO `cache` — it's a join-shaped result, so the template lets the live dispatcher keep it fresh rather than cache a snapshot. See [Query result caching](/docs/caching/query-cache).
1662
+
1663
+ ## Boot seed
1664
+
1665
+ `catalog.seed.ts` is a boot-lifecycle seed that idempotently `upsertByUnique`s a demo tenant, two authors, and three books, so the catalog has data the moment `voltro dev` boots. The seed store is the RAW store (no authenticated subject), so `tenant()` auto-fill doesn't fire — it passes `tenantId: 'acme'` explicitly (matching the dev AuthMiddleware default, which resolves `acme` from the `x-tenant` header). It passes `bio` as plaintext (the middleware encrypts it), `tags` as a JS array, and OMITS `slug` (the `.generatedAs(...)` STORED column is filled by the DB engine). See [Seeds](/docs/database/seeds).
1666
+
1667
+ ## Try it
1668
+
1669
+ Subscribe from a web app via `useSubscription('app', 'books.search', { q: 'magic' })` and `useSubscription('app', 'authors.withBooks')`, or invoke them over HTTP through `POST /_voltro/inspect/invoke` for a one-shot snapshot.
1670
+
1671
+ ## When to use api-data-advanced vs. the variants
1672
+
1673
+ | You need… | Pick |
1674
+ |---|---|
1675
+ | The smallest reference to extend | [`api-backend`](/docs/templates/api-backend) |
1676
+ | A worked tour of the advanced schema DSL (relations + eager loading, FTS, enum/array/generated/encrypted columns, query cache) | `api-data-advanced` |
1677
+ | Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
1678
+ | File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
1679
+ | MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
1680
+
1681
+ ## Pairs well with
1682
+
1683
+ - Any web template — `api-data-advanced` exposes its queries over the standard rpc client.
1684
+ - [`@voltro/plugin-governance`](/docs/plugins/governance) — already wired here for field encryption; it also ships retention sweeps, GDPR export/erase, and a consent ledger.
1685
+
1686
+ ## Anti-patterns
1687
+
1688
+ - **Shipping the placeholder `VOLTRO_FIELD_ENCRYPTION_KEY` to production.** The `.env` value is a public, DEV-ONLY constant. Generate a real key (`openssl rand -hex 32`), store it in your deployment's secret store, and never commit it. Lose the key and the ciphertext is gone for good.
1689
+ - **Expecting a `defineEnv` `default` to feed the cipher.** It won't — the cipher reads the Secrets-Resolver (process env), not the typed-env default. A default would pass the boot gate while the cipher still fails to resolve, so the key must reach `process.env` (here via `.env`).
1690
+ - **Filtering or sorting by an `.encrypted()` column's plaintext in SQL.** It's ciphertext on disk — those predicates can't run. Encrypt only fields you read back whole.
1691
+ - **Adding `tenantId` filters by hand in the query executors.** `authors` and `books` carry `tenant()`, so the runtime scopes reads automatically. Doing both works but signals you don't trust the framework.
1692
+ - **Passing `slug` in a write.** It's a `.generatedAs(...)` STORED column — the DB engine fills it from `title`. Passing a value fights the generator.
1693
+
1694
+
1695
+
1696
+ ---
1697
+
1698
+ <!-- source: en/templates/api-auth.md -->
1699
+ ## API · Auth
1700
+
1701
+ _Real user authentication wired turnkey with @voltro/plugin-auth — password sign-up/in/out over HttpOnly session cookies, a strategy that resolves the session into a typed Subject, and a session.me action proving it. Zero-infra boot._
1702
+
1703
+ Real user authentication, wired turnkey. Password **sign-up / sign-in / sign-out** over HttpOnly **session cookies**, a strategy that resolves the session into a typed `Subject` on every rpc call, and a `session.me` action that proves the loop end-to-end. Boots with **zero infra** (`memoryUserStore()`). Template id: **`api-auth`**.
1704
+
1705
+ ## Scaffold
1706
+
1707
+ ```bash
1708
+ voltro create-project acme --api=api-auth
1709
+ ```
1710
+
1711
+ The shipped `.env` supplies a DEV-ONLY `VOLTRO_SESSION_SECRET` so it boots out of the box — replace it for production (see Environment).
1712
+
1713
+ ## What ships
1714
+
1715
+ ```text
1716
+ apps/acme/api/
1717
+ ├── app.config.ts # authRoutesPlugin + voltroPasswordStrategy + memoryUserStore
1718
+ ├── .env # DEV-ONLY VOLTRO_SESSION_SECRET
1719
+ ├── package.json # + @voltro/plugin-auth
1720
+ ├── database/schema.ts # core actors + tenants (users live in the memory store)
1721
+ └── actions/
1722
+ ├── me.action.ts # session.me descriptor — browser-safe
1723
+ └── me.action.server.ts # returns ctx.request.subject
1724
+ ```
1725
+
1726
+ ## The two halves
1727
+
1728
+ `app.config.ts` wires both, sharing ONE secret:
1729
+
1730
+ ```ts
1731
+ import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
1732
+
1733
+ const SECRET = process.env.VOLTRO_SESSION_SECRET ?? 'dev-only-unsafe-session-secret-change-me'
1734
+
1735
+ export default {
1736
+ type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
1737
+ plugins: [
1738
+ authRoutesPlugin({
1739
+ store: memoryUserStore(),
1740
+ secret: SECRET,
1741
+ defaultTenantId: 'acme',
1742
+ cookieSecure: process.env.NODE_ENV === 'production', // ← see the gotcha below
1743
+ }),
1744
+ ],
1745
+ auth: { strategies: [voltroPasswordStrategy({ secret: SECRET })] },
1746
+ }
1747
+ ```
1748
+
1749
+ - **`authRoutesPlugin`** mounts the HTTP auth surface under `/auth/*` (`POST /auth/sign-up`, `/auth/sign-in`, `/auth/sign-out`, `GET /auth/csrf`, password-reset, magic-link, `/auth/sessions`, tenant memberships) and **signs** the `voltro:session` cookie on success.
1750
+ - **`voltroPasswordStrategy`** runs in the [AuthMiddleware](/docs/authentication/overview) chain on every rpc/ws call, **verifies** that cookie, and resolves it to a `Subject` — so handlers read the user via `ctx.request.subject`.
1751
+
1752
+ They MUST share the same `secret` (one signs, the other verifies); the template reads `VOLTRO_SESSION_SECRET` once and passes it to both.
1753
+
1754
+ ### Reading the Subject — `session.me`
1755
+
1756
+ ```ts
1757
+ // actions/me.action.server.ts
1758
+ const execute = async (_input, ctx) => {
1759
+ const subject = ctx.request.subject as { type: string; id: string | null; tenantId: string | null }
1760
+ return { type: subject.type, id: subject.id ?? null, tenantId: subject.tenantId ?? null }
1761
+ }
1762
+ ```
1763
+
1764
+ Anonymous before sign-in (`type: 'anonymous'`, `id: null`); a `user` Subject once the session cookie resolves.
1765
+
1766
+ ## Gotcha — `cookieSecure` in dev
1767
+
1768
+ `Secure` cookies are **HTTPS-only** — over `http://localhost` a browser (and `curl`) silently drops a `Secure` session cookie, so the sign-in → session → authenticated-call loop never authenticates. Setting `cookieSecure: process.env.NODE_ENV === 'production'` is what makes the loop work locally (off in dev, on over HTTPS in prod).
1769
+
1770
+ ## Try the loop (curl)
1771
+
1772
+ State-changing routes are CSRF-protected; `session.me` is an rpc procedure (invoke it over HTTP via the dev inspect endpoint, which forwards your cookie):
1773
+
1774
+ ```bash
1775
+ # 1. CSRF token (+ csrf cookie into the jar)
1776
+ curl -s -c jar.txt http://localhost:4000/auth/csrf # → { "csrfToken": "…" }
1777
+
1778
+ # 2. Sign up (sets the HttpOnly session cookie)
1779
+ curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
1780
+ -H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
1781
+ -d '{"email":"ada@example.com","password":"hunter2hunter2"}'
1782
+ # → { "ok": true, "subject": { "type": "user", "id": "u_…", "tenantId": "acme" } }
1783
+
1784
+ # 3. session.me with the cookie → you're a user
1785
+ curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
1786
+ -H 'content-type: application/json' -d '{"tag":"session.me","input":{}}'
1787
+ # → { "ok": true, "result": { "type": "user", "id": "u_…", "tenantId": "acme" } }
1788
+ ```
1789
+
1790
+ ## Going to production
1791
+
1792
+ | Want… | Do |
1793
+ |---|---|
1794
+ | Durable accounts | `postgresUserStore({ sql })` + `store: 'postgres'` (manages its own auth tables) |
1795
+ | Magic-link / password-reset email | pass `sendEmail: mailSender(mailService)` (from [`@voltro/plugin-mail`](/docs/plugins/mail)) to `authRoutesPlugin` |
1796
+ | A login UI | pair with [`frontend-app`](/docs/templates/app) — a form POSTing to `/auth/sign-in`; authenticated rpc carries the cookie automatically |
1797
+
1798
+ ## Anti-patterns
1799
+
1800
+ - **Forgetting `cookieSecure` in dev.** The `Secure` default drops the cookie over http — auth silently fails. See the gotcha above.
1801
+ - **Different secrets for the routes plugin vs. the strategy.** One signs, the other verifies — they must match. Read `VOLTRO_SESSION_SECRET` once and pass it to both.
1802
+ - **Shipping the DEV-ONLY `.env` secret.** Generate a real one (`openssl rand -hex 32`); rotating it invalidates every session.
1803
+ - **Trusting `x-tenant` in production.** The dev resolver reads it unauthenticated — this template's session strategy is the real path; don't leave the header as your auth.
1804
+
1805
+
1806
+
1807
+ ---
1808
+
1809
+ <!-- source: en/templates/api-rest.md -->
1810
+ ## API · REST + OpenAPI
1811
+
1812
+ _A public REST API built on defineRestRoute — GET/POST/DELETE with query, path, and body parsing, scope guards, Idempotency-Key dedup, and an OpenAPI 3.1 spec + Swagger UI auto-generated from the route descriptors. Zero-infra boot._
1813
+
1814
+ A turnkey **public REST API** — plain raw-HTTP endpoints a third party calls with a URL + JSON, the opposite end from the reactive rpc/WebSocket surface ([`api-backend`](/docs/templates/api-backend)). Four `defineRestRoute` endpoints over a `products` catalog, **scope-guarded** writes, **`Idempotency-Key`** dedup, and an **OpenAPI 3.1 spec + Swagger UI** generated from the descriptors. Boots with **zero infra** (`store: 'memory'`). Template id: **`api-rest`**.
1815
+
1816
+ ## Scaffold
1817
+
1818
+ ```bash
1819
+ voltro create-project acme --api=api-rest
1820
+ ```
1821
+
1822
+ Boots on `http://localhost:4000`; open `http://localhost:4000/docs` for the Swagger UI.
1823
+
1824
+ ## What ships
1825
+
1826
+ ```text
1827
+ apps/acme/api/
1828
+ ├── app.config.ts # restRoutes + openapiPlugin + apiKeyStrategy + idempotency
1829
+ ├── package.json # + @voltro/plugin-openapi
1830
+ ├── database/schema.ts # a plain `products` table (no mixins — the routes own auth)
1831
+ ├── lib/product.ts # shared wire Schema + row→wire mapper (one source of truth)
1832
+ └── routes/v1/
1833
+ ├── products.list.route.tsx # GET /v1/products?limit=&cursor= (public, paginated)
1834
+ ├── products.get.route.tsx # GET /v1/products/:id (public, 404)
1835
+ ├── products.create.route.tsx # POST /v1/products (guarded + idempotent)
1836
+ └── products.delete.route.tsx # DELETE /v1/products/:id (guarded)
1837
+ ```
1838
+
1839
+ REST routes are the **one** primitive that is NOT auto-discovered — they're registered explicitly via `restRoutes` in `app.config.ts`.
1840
+
1841
+ ## A route — query / path / body in, typed JSON out
1842
+
1843
+ ```tsx
1844
+ // routes/v1/products.create.route.tsx
1845
+ import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
1846
+ import { Schema } from 'effect'
1847
+ import { Product, toProduct } from '../../lib/product'
1848
+
1849
+ export default defineRestRoute({
1850
+ method: 'POST',
1851
+ path: '/v1/products',
1852
+ input: Schema.Struct({ // the desugar parses { query?, params?, body? }
1853
+ body: Schema.Struct({
1854
+ name: Schema.NonEmptyString,
1855
+ priceCents: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0)),
1856
+ }),
1857
+ }),
1858
+ output: Product,
1859
+ summary: 'Create a product', // OpenAPI metadata
1860
+ guards: [requireScope('products:write')], // → 403 when the subject lacks the scope
1861
+ handler: async ({ body }, ctx) => { /* ctx.subject + ctx.store injected by serve */ },
1862
+ })
1863
+ ```
1864
+
1865
+ The desugar, per request: method gate (`405`), input decode (`400` on a bad shape), guards (`403`), `await handler`, output encode (`200` JSON). A handler may `throw { status, message }` for a specific code (the `get`/`delete` routes `throw { status: 404 }`).
1866
+
1867
+ ## OpenAPI + Swagger UI — zero hand-written docs
1868
+
1869
+ ```ts
1870
+ // app.config.ts
1871
+ import { openapiPlugin } from '@voltro/plugin-openapi'
1872
+
1873
+ const routes = [listProducts, getProduct, createProduct, deleteProduct]
1874
+
1875
+ export default {
1876
+ type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
1877
+ plugins: [openapiPlugin({ routes, info: { title: 'Acme API', version: '1.0.0' } })],
1878
+ restRoutes: routes, // the SAME array mounts the routes AND documents them
1879
+ idempotency: true, // Idempotency-Key dedup for the mutating routes
1880
+ }
1881
+ ```
1882
+
1883
+ `openapiPlugin` serves `GET /openapi.json` (the spec) + `GET /docs` (Swagger UI), built from the descriptors — `:id` paths render as OpenAPI `{id}`, and refined types (`NonEmptyString`, …) resolve under `components.schemas`. The route descriptors ARE the spec; there is nothing to hand-maintain.
1884
+
1885
+ ## Auth — the demo key is DEV-ONLY
1886
+
1887
+ The guarded routes gate on a scope. `app.config.ts` wires an [`apiKeyStrategy`](/docs/authentication/overview) so `Authorization: Bearer <key>` resolves to a scoped `Subject`, and ships ONE hardcoded demo key (`restdemo_devkey` → `products:read` + `products:write`) so the write path works on first boot:
1888
+
1889
+ ```ts
1890
+ import { apiKeyStrategy } from '@voltro/protocol/apikey'
1891
+ // sha256('restdemo_devkey') → a fixed scoped Subject. DEV-ONLY.
1892
+ const DEMO_KEY_SHA256 = 'c891…41cd'
1893
+ auth: { strategies: [apiKeyStrategy({
1894
+ prefix: 'restdemo_',
1895
+ resolveKey: (hash) => hash === DEMO_KEY_SHA256
1896
+ ? { id: 'svc_demo', tenantId: 'acme', scopes: ['products:read', 'products:write'] }
1897
+ : null,
1898
+ })] }
1899
+ ```
1900
+
1901
+ Before deploying, **delete the demo constant** and look the token hash up against your own key store (store key **hashes**, never raw tokens) — or flip on the framework's first-class API keys (`apiKeys: true`) for admin-gated issue/list/revoke against `_voltro_api_keys`.
1902
+
1903
+ ## Try it (curl)
1904
+
1905
+ ```bash
1906
+ # The spec + interactive docs
1907
+ curl -s http://localhost:4000/openapi.json | jq '.info, (.paths | keys)'
1908
+ open http://localhost:4000/docs
1909
+
1910
+ # Public reads — no auth
1911
+ curl -s http://localhost:4000/v1/products # → { "data": [], "nextCursor": null }
1912
+
1913
+ # Guarded create — the demo key carries products:write
1914
+ curl -s -X POST http://localhost:4000/v1/products \
1915
+ -H 'Authorization: Bearer restdemo_devkey' -H 'content-type: application/json' \
1916
+ -d '{"name":"Widget","priceCents":1999}' # → 201 the created product
1917
+
1918
+ # Without the key → 403 (the guard rejects)
1919
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/v1/products \
1920
+ -H 'content-type: application/json' -d '{"name":"x","priceCents":1}' # → 403
1921
+
1922
+ # Idempotency — same key header twice → the first response replayed, not re-inserted
1923
+ curl -s -X POST http://localhost:4000/v1/products \
1924
+ -H 'Authorization: Bearer restdemo_devkey' -H 'Idempotency-Key: order-42' \
1925
+ -H 'content-type: application/json' -d '{"name":"Once","priceCents":500}'
1926
+ ```
1927
+
1928
+ ## Going to production
1929
+
1930
+ | Want… | Do |
1931
+ |---|---|
1932
+ | Durable data | `store: 'memory'` → `store: 'postgres'` (data survives restarts) |
1933
+ | Real API keys | delete the demo key; hash-lookup your own store, or set `apiKeys: true` |
1934
+ | Multi-tenant scoping | add `tenant()` to the table + scope each handler by the key's `tenantId` |
1935
+ | Versioning / sunset | a descriptor's `deprecated` sets a `Deprecation:` header; `sunset` sets `Sunset:` + `410`s past the date |
1936
+
1937
+ ## Anti-patterns
1938
+
1939
+ - **Forgetting to register the route.** REST routes are NOT auto-discovered — add every `defineRestRoute` to the `restRoutes` array (and the `openapiPlugin` `routes` array) in `app.config.ts`, or it's neither mounted nor documented.
1940
+ - **Shipping the DEV-ONLY demo key.** It's a sample credential, like a README password — delete it and back the strategy with a real hash store before deploying.
1941
+ - **Reaching for REST when an action fits.** A REST route is for EXTERNAL HTTP callers. For your own reactive frontend, a [mutation](/docs/data/mutations) / [action](/docs/data/actions) over the rpc WebSocket is typed end-to-end and auto-optimistic.
1942
+ - **Expecting `Idempotency-Key` on the rpc transport.** It rides the HTTP surface only; double-submit from your own frontend is a UI concern (disable the button), not server idempotency.
1943
+
1944
+ ## See also
1945
+
1946
+ - [App templates](/docs/reference/templates) — the full catalogue
1947
+ - [`@voltro/plugin-openapi`](/docs/plugins/overview) — the spec + Swagger generator
1948
+ - [`api-auth`](/docs/templates/api-auth) — real user sessions (the rpc-side auth counterpart)
1949
+
1950
+
1951
+
1952
+ ---
1953
+
1954
+ <!-- source: en/templates/api-saas.md -->
1955
+ ## API · SaaS bundle
1956
+
1957
+ _The SaaS plugin bundle in one domain — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One projects.create mutation exercises billing + analytics + notifications together. Zero-infra boot._
1958
+
1959
+ The four cross-cutting **SaaS plugins** wired turnkey in one cohesive domain — the billing / notifications / analytics / presence slice of the framework end to end, the way [`api-durable`](/docs/templates/api-durable) bundles the durable surface. A single `projects.create` mutation pulls three of them together. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-saas`**.
1960
+
1961
+ ## Scaffold
1962
+
1963
+ ```bash
1964
+ voltro create-project acme --api=api-saas
1965
+ ```
1966
+
1967
+ ## What ships
1968
+
1969
+ ```text
1970
+ apps/acme/api/
1971
+ ├── app.config.ts # billingPlugin + notificationsPlugin + presencePlugin
1972
+ ├── package.json # + the four SaaS plugins
1973
+ ├── database/schema.ts # a tenant-scoped `projects` table
1974
+ ├── queries/projects.list.query.ts # reactive list (+ .server.ts)
1975
+ └── mutations/projects.create.mutation.ts # the bundle in one handler (+ .server.ts)
1976
+ ```
1977
+
1978
+ Each plugin contributes its **own** routes + tables automatically — you wire them in `app.config.ts` and consume their services in handlers. The boot banner shows them all: `billing.subscription/startCheckout/…`, `notifications.inbox/markRead/…`, `presence.heartbeat/list/leave`.
1979
+
1980
+ ## The four plugins
1981
+
1982
+ ```ts
1983
+ // app.config.ts
1984
+ import { billingPlugin } from '@voltro/plugin-billing'
1985
+ import { presencePlugin } from '@voltro/plugin-presence'
1986
+ import { notificationsPlugin, consoleChannel } from '@voltro/plugin-notifications'
1987
+
1988
+ plugins: [
1989
+ billingPlugin({
1990
+ provider: 'mock', // in-memory; no Stripe key
1991
+ plans: { // tier → quota, in code
1992
+ free: { entitlements: { projects: 3 } },
1993
+ pro: { priceId: 'price_demo_pro', entitlements: { projects: 'unlimited' } },
1994
+ },
1995
+ }),
1996
+ notificationsPlugin({ channels: [consoleChannel()] }), // + a durable in-app inbox
1997
+ presencePlugin(), // presence.heartbeat / list / leave
1998
+ ]
1999
+ ```
2000
+
2001
+ - **Billing** — subscriptions + entitlements; `plans` is the single source of tier→quota truth. The `free` default caps `projects` at 3.
2002
+ - **Notifications** — one `NotificationService.send(...)` across channels + an in-app inbox (`useInbox()` / `useUnreadCount()` on the client).
2003
+ - **Presence** — ephemeral "who's online" per channel, plus the `usePresence(channel)` hook.
2004
+
2005
+ ## The bundle, in one handler
2006
+
2007
+ `projects.create` is Effect-mode so it can `yield*` the plugin services the framework provides in the per-request stack:
2008
+
2009
+ ```ts
2010
+ // mutations/projects.create.mutation.server.ts
2011
+ import { EffectStore, useAnalytics } from '@voltro/runtime'
2012
+ import { requireEntitlement } from '@voltro/plugin-billing'
2013
+ import { NotificationService } from '@voltro/plugin-notifications'
2014
+
2015
+ const execute = (input: { name: string }, ctx) =>
2016
+ Effect.gen(function* () {
2017
+ yield* requireEntitlement(ctx, 'projects', 1) // 1. BILLING — quota gate
2018
+ const store = yield* EffectStore
2019
+ const row = yield* store.insert('projects', { name: input.name }) // tenant() auto-stamps
2020
+ yield* (yield* useAnalytics()).track({ // 2. ANALYTICS — event
2021
+ name: 'project_created', subjectId: ctx.request.subject.id, properties: { name: input.name },
2022
+ })
2023
+ yield* Effect.promise(() => (yield* NotificationService).send({ // 3. NOTIFICATIONS — inbox + console
2024
+ to: ctx.request.subject.id ?? 'system', category: 'project',
2025
+ title: 'Project created', body: `"${input.name}" is live.`,
2026
+ }))
2027
+ return { id: row['id'] as string, name: row['name'] as string, tenantId: row['tenantId'] as string }
2028
+ })
2029
+ ```
2030
+
2031
+ The billing plugin auto-merges its typed `EntitlementExceeded` (+ `BillingError`) into this procedure's wire-error union — the client decodes the over-quota failure typed, **without** a manual `error:` declaration.
2032
+
2033
+ ## Try it (curl)
2034
+
2035
+ `projects.create` and the plugin routes are rpc procedures — call them with `useMutation` / `useSubscription` from a web app, or over HTTP via the dev inspect endpoint:
2036
+
2037
+ ```bash
2038
+ # Create projects — the free plan allows 3
2039
+ for i in 1 2 3; do
2040
+ curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
2041
+ -H 'content-type: application/json' \
2042
+ -d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
2043
+ done
2044
+ # → { "ok": true, "result": { "id": "proj_…", … } }
2045
+
2046
+ # The 4th trips the billing entitlement
2047
+ curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
2048
+ -H 'content-type: application/json' \
2049
+ -d '{"tag":"projects.create","input":{"name":"Project 4"}}'
2050
+ # → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, "used": 3 } }
2051
+ ```
2052
+
2053
+ Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `notification_inbox` row.
2054
+
2055
+ ## Enable durable analytics
2056
+
2057
+ `useAnalytics().track(...)` is **noop-safe** with no sink configured (it never crashes), so the event API is reachable on the zero-infra memory store. To actually STORE + query events (`aggregate` / `timeseries` / `topN`), add a sink AND a SQL store — the postgres-lite sink needs a `SqlClient`:
2058
+
2059
+ ```ts
2060
+ // app.config.ts
2061
+ import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
2062
+
2063
+ export default {
2064
+ type: 'api' as const, name: 'AcmeApi',
2065
+ store: 'postgres' as const, // or 'sqlite' (file:./.voltro/db.sqlite)
2066
+ analytics: postgresAnalytics(), // events → _voltro_events
2067
+ plugins: [ /* … */ ],
2068
+ }
2069
+ ```
2070
+
2071
+ ## Going to production
2072
+
2073
+ | Want… | Do |
2074
+ |---|---|
2075
+ | Real subscriptions / checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
2076
+ | Email / Slack / SMS notifications | add `emailChannel()` / `webhookChannel()` / `smsChannel()` to `channels` |
2077
+ | Durable analytics at scale | `postgresAnalytics()` on postgres, or swap to `@voltro/plugin-clickhouse` / `@voltro/plugin-duckdb` |
2078
+ | Live presence roster in the UI | subscribe a reactive query over `_voltro_presence`, or use `usePresence(channel)` |
2079
+
2080
+ ## Anti-patterns
2081
+
2082
+ - **Hand-rolling `notifications.*` / `billing.*` / `presence.*` procedures.** The plugins own those tag namespaces — boot fails on a collision. Use the plugin routes + services.
2083
+ - **Expecting analytics to persist on `memory`.** The postgres-lite sink needs a `SqlClient`. `track()` is reachable + safe on memory; events land in `_voltro_events` only once you wire a sink + a SQL store.
2084
+ - **Skipping the entitlement gate on a paid action.** A scope says "may you call this"; an entitlement says "do you have quota left" — orthogonal. A paid action needs both.
2085
+
2086
+ ## See also
2087
+
2088
+ - [Plugins overview](/docs/plugins/overview) — the full plugin catalogue + status
2089
+ - [`api-durable`](/docs/templates/api-durable) — the durable + reactive bundle (the sibling showcase)
2090
+ - [App templates](/docs/reference/templates) — the full catalogue
2091
+
2092
+
2093
+
2094
+ ---
2095
+
2096
+ <!-- source: en/templates/api-observability.md -->
2097
+ ## API · Observability
2098
+
2099
+ _Production-readiness in one backend — Prometheus metrics (GET /metrics + a custom counter), Sentry error tracking (inert without a DSN), distributed tracing, and a @voltro/testing unit test (makeTestContext + mockStore, run with voltro test). Zero-infra boot._
2100
+
2101
+ The production-readiness slice in one backend: **Prometheus metrics**, **Sentry error tracking**, **distributed tracing**, and a **`@voltro/testing` unit test** — so a real service is observable AND covered from day one. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-observability`**.
2102
+
2103
+ ## Scaffold
2104
+
2105
+ ```bash
2106
+ voltro create-project acme --api=api-observability
2107
+ ```
2108
+
2109
+ ## What ships
2110
+
2111
+ ```text
2112
+ apps/acme/api/
2113
+ ├── app.config.ts # prometheusPlugin() + sentryPlugin()
2114
+ ├── package.json # + the plugins; devDeps: @voltro/testing, vitest
2115
+ ├── database/schema.ts # a tenant-scoped `notes` table
2116
+ ├── queries/notes.list.query.ts # reactive list (+ .server.ts)
2117
+ ├── mutations/notes.create.mutation.ts # create + a custom metric (+ .server.ts)
2118
+ └── tests/notes.create.test.ts # makeTestContext + mockStore
2119
+ ```
2120
+
2121
+ ## Metrics — `GET /metrics` + your own counters
2122
+
2123
+ ```ts
2124
+ // app.config.ts
2125
+ import { prometheusPlugin } from '@voltro/plugin-prometheus'
2126
+ import { sentryPlugin } from '@voltro/plugin-sentry'
2127
+
2128
+ plugins: [ prometheusPlugin(), sentryPlugin() ]
2129
+ ```
2130
+
2131
+ The framework already records `voltro_rpc_*` / `voltro_http_*` / subscription / cache metrics into Effect's global `MetricRegistry`; `prometheusPlugin()` exports them at `/metrics`. A **custom** counter shows up there automatically — declare it at module level, bump it in a handler:
2132
+
2133
+ ```ts
2134
+ // mutations/notes.create.mutation.server.ts
2135
+ import { Effect, Metric } from 'effect'
2136
+ import { counter } from '@voltro/runtime'
2137
+
2138
+ const notesCreated = counter('app_notes_created_total', 'Notes created.')
2139
+ Effect.runSync(Metric.increment(notesCreated)) // → /metrics + the DevTools Metrics panel
2140
+ ```
2141
+
2142
+ ```bash
2143
+ curl -s http://localhost:4000/metrics | grep -E 'app_notes_created_total|voltro_rpc_requests_total'
2144
+ # app_notes_created_total 1
2145
+ # voltro_rpc_requests_total{status="ok",tag="mutation.notes.create"} 1
2146
+ ```
2147
+
2148
+ Point Prometheus / Grafana at `/metrics`; gate a public deploy with `prometheusPlugin({ token })` / `PROMETHEUS_TOKEN`.
2149
+
2150
+ ## Errors — Sentry, inert until you set a DSN
2151
+
2152
+ `sentryPlugin()` reports rpc / REST / workflow / schedule / subscriber / render errors, correlated to the active **`traceId`** — and is **inert without `SENTRY_DSN`**, so it ships wired. Set the env to turn it on; no code change. The plugin subscribes to the framework's server-error bus once, so every primitive is covered. For the full browser→backend waterfall, add the `sentry` field to a paired web app's `app.config.ts`.
2153
+
2154
+ ## Tracing — on by default
2155
+
2156
+ `voltro dev` runs a buffer-only tracer that powers the DevTools **Traces** panel and stamps `fields.traceId` on every log line (`voltro logs --trace <id>`). Set `OTEL_EXPORTER_OTLP_ENDPOINT` to ship spans to Jaeger / Tempo / Honeycomb — no code change.
2157
+
2158
+ ## Testing — `makeTestContext`, no DB, no server
2159
+
2160
+ ```ts
2161
+ // tests/notes.create.test.ts
2162
+ import { makeTestContext, mockStore } from '@voltro/testing'
2163
+ import createNote from '../mutations/notes.create.mutation.server'
2164
+
2165
+ const ctx = makeTestContext({
2166
+ subject: { type: 'user', id: 'user_1', tenantId: 'acme' },
2167
+ store: mockStore({ notes: [] }),
2168
+ })
2169
+ const row = await createNote({ title: 'Hello', body: 'World' }, ctx)
2170
+ expect(row.tenantId).toBe('acme') // tenant() stamped it — the REAL store, in-memory
2171
+ ```
2172
+
2173
+ `ctx.store` is the **same** mixin-wrapped store production uses, so tenant auto-scoping, soft-delete filtering, and audit auto-fill behave identically — in milliseconds, no docker. `ctx.withTenant('t2', (c) => c.store.query(…))` re-scopes the same data to prove cross-tenant isolation. Run it:
2174
+
2175
+ ```bash
2176
+ voltro test
2177
+ ```
2178
+
2179
+ The package also ships `MockClock` / `MockEmail` / `mockAi` / `makeWorkflowRunner` and `runDialectParity` (the `@voltro/testing/dialect` subpath).
2180
+
2181
+ ## Going to production
2182
+
2183
+ | Want… | Do |
2184
+ |---|---|
2185
+ | Metrics on a public deploy | `prometheusPlugin({ token })` + scrape over TLS |
2186
+ | Errors in Sentry | set `SENTRY_DSN`; add `sentryPlugin({ traces: true })` for performance traces |
2187
+ | Spans in your tracer | `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318` |
2188
+ | Vendor-native (Datadog) | add `@voltro/plugin-datadog` — agentless metrics + correlated logs/traces |
2189
+ | A CI gate | run `voltro test`; add `runDialectParity` for hand-written SQL |
2190
+
2191
+ ## Anti-patterns
2192
+
2193
+ - **Re-creating the counter per call.** Declare `counter(...)` at MODULE level — a per-call `counter()` muddies the series.
2194
+ - **Wiring Sentry per primitive.** The plugin covers them all via the server-error bus — you just set the DSN.
2195
+ - **A real DB in unit tests.** `makeTestContext` + `mockStore` give real store behaviour in-memory; save a live database for `voltro e2e`.
2196
+
2197
+ ## See also
2198
+
2199
+ - [Observability](/docs/observability/overview) · [Distributed tracing](/docs/observability/distributed-tracing) · [Unit testing](/docs/testing/unit-testing)
2200
+ - [App templates](/docs/reference/templates) — the full catalogue
2201
+
2202
+
2203
+
2204
+ ---
2205
+
2206
+ <!-- source: en/templates/api-webhooks.md -->
2207
+ ## API · Webhooks
2208
+
2209
+ _First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineOutgoingEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
2210
+
2211
+ Webhooks **both ways**, wired turnkey: a **signature-verified incoming receiver** that rejects forged traffic before your code runs, and an **outgoing event** a mutation emits to subscribed targets through a durable, signed, retried delivery workflow. Webhooks are **file-convention** (drop a `*.webhook.tsx`, it's auto-discovered) — not a `plugins:[]` entry. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-webhooks`**.
2212
+
2213
+ ## Scaffold
2214
+
2215
+ ```bash
2216
+ voltro create-project acme --api=api-webhooks
2217
+ ```
2218
+
2219
+ The shipped `.env` supplies a DEV-ONLY `VOLTRO_WEBHOOK_SECRET_ORDERS` so the incoming receiver boots with a key.
2220
+
2221
+ ## What ships
2222
+
2223
+ ```text
2224
+ apps/acme/api/
2225
+ ├── .env # VOLTRO_WEBHOOK_SECRET_ORDERS (dev signing secret)
2226
+ ├── database/schema.ts # orders + webhookTables() bookkeeping
2227
+ ├── webhooks/orders.webhook.tsx # INCOMING — signature-verified receiver
2228
+ ├── events/order.completed.webhook.tsx # OUTGOING — defineOutgoingEvent
2229
+ ├── mutations/orders.fulfill.mutation.ts # create order + emit (+ .server.ts)
2230
+ └── queries/orders.list.query.ts # reactive list (+ .server.ts)
2231
+ ```
2232
+
2233
+ ## Incoming — verified before your handler runs
2234
+
2235
+ ```tsx
2236
+ // webhooks/orders.webhook.tsx → mounts at POST /webhooks/orders
2237
+ import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
2238
+ import { genericProvider } from '@voltro/plugin-webhooks/providers'
2239
+
2240
+ export default defineIncomingWebhook({
2241
+ id: 'orders', provider: genericProvider(),
2242
+ payload: Schema.Struct({ event: Schema.String, orderId: Schema.String, sku: Schema.String, totalCents: Schema.Number }),
2243
+ handler: async (ctx) => { /* ctx.body is validated; ctx.idempotencyKey set */ },
2244
+ })
2245
+ ```
2246
+
2247
+ The framework's incoming middleware runs **before** `handler`:
2248
+
2249
+ 1. verify the HMAC over `<ts>.<rawBody>` with `VOLTRO_WEBHOOK_SECRET_ORDERS` → **`401`** on mismatch
2250
+ 2. reject a `t` older than the replay window → **`401`**
2251
+ 3. decode the body against `payload` → **`422`** on a bad shape
2252
+ 4. claim the `Idempotency-Key` → a replay returns **`200 {duplicate:true}`** and the handler is **skipped**
2253
+
2254
+ Only a request that passes all of that reaches `handler`. You never hand-roll a signature check, and forged traffic never runs your code. Built-in presets: `stripeProvider` / `githubProvider` / `slackProvider` / `genericProvider` (or `defineWebhookProvider` for a custom partner). The signing secret is `VOLTRO_WEBHOOK_SECRET_<UPPER_ID>` — here `_ORDERS`.
2255
+
2256
+ ### Try it (curl)
2257
+
2258
+ ```bash
2259
+ SECRET='devonly-webhook-secret-change-me'
2260
+ BODY='{"event":"order.created","orderId":"o_1","sku":"WIDGET","totalCents":1999}'
2261
+ TS=$(date +%s)
2262
+ SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= //')
2263
+
2264
+ # valid signature → 200, handler runs
2265
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
2266
+ -H "X-Webhook-Signature: t=$TS,v1=$SIG" -H 'content-type: application/json' \
2267
+ -H 'Idempotency-Key: evt_1' --data "$BODY" # → 200
2268
+
2269
+ # no signature → 401 · forged → 401 · valid sig + wrong shape → 422
2270
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
2271
+ -H 'content-type: application/json' --data "$BODY" # → 401
2272
+ ```
2273
+
2274
+ ## Outgoing — emit to subscribed targets
2275
+
2276
+ ```ts
2277
+ // events/order.completed.webhook.tsx
2278
+ export default defineOutgoingEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
2279
+
2280
+ // mutations/orders.fulfill.mutation.server.ts — after the row commits:
2281
+ const { eventId, deliveries } = await useWebhooks(ctx).emit('order.completed', { orderId, tenantId, /* … */ })
2282
+ ```
2283
+
2284
+ External systems subscribe at runtime — `ctx.webhooks.subscribe({ event: 'order.completed', url })` (a row in `_voltro_webhook_targets`). On `emit`, the framework fans out to every matching target through a **durable delivery workflow** — HMAC-signing the outbound request, retrying with backoff, honouring `Retry-After`; each attempt lands in `_voltro_webhook_deliveries`. `emit` returns immediately (the POSTs run in the background); `deliveries` is one entry per matched target (empty until someone subscribes).
2285
+
2286
+ ```bash
2287
+ curl -s -X POST http://localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2288
+ -d '{"tag":"orders.fulfill","input":{"sku":"WIDGET","totalCents":4999}}'
2289
+ # → the order; the log shows: order.completed event=… → 0 target(s)
2290
+ ```
2291
+
2292
+ ## Going to production
2293
+
2294
+ | Want… | Do |
2295
+ |---|---|
2296
+ | A real partner (Stripe/GitHub/Slack) | swap `genericProvider()` → `stripeProvider()` etc.; set `VOLTRO_WEBHOOK_SECRET_<ID>` |
2297
+ | Durable targets + delivery history | `store: 'postgres'` (the `_voltro_webhook_*` tables survive restarts) |
2298
+ | A subscribe API | a mutation calling `ctx.webhooks.subscribe({ event, url, secret?, retry? })` |
2299
+ | A custom signature scheme | `defineWebhookProvider({ id, signature, idempotency })` |
2300
+
2301
+ ## Anti-patterns
2302
+
2303
+ - **Trusting webhook input without verification.** The framework rejects unsigned incoming requests by default — don't set `signature: undefined` unless a network trust boundary gates the route.
2304
+ - **Mutating domain state synchronously in the outgoing emit path.** Mutate in the mutation, THEN emit — delivery is async (rollback drops the queued send).
2305
+ - **Re-using the idempotency TTL as the provider's retry window.** Pick TTL ≥ 2× the provider's max retry window so the dedup catches the slowest retry.
2306
+
2307
+ ## See also
2308
+
2309
+ - [Plugins overview](/docs/plugins/overview) — `@voltro/plugin-webhooks` + the full catalogue
2310
+ - [App templates](/docs/reference/templates) — the full catalogue
2311
+
2312
+
2313
+
2314
+ ---
2315
+
2316
+ <!-- source: en/templates/api-search.md -->
2317
+ ## API · Search
2318
+
2319
+ _Full-text search that stays in sync — @voltro/plugin-search mirrors every table write into an index via the post-commit change tap; the synthesized search.query rpc returns tenant-scoped hits. Memory backend (zero infra); swap one line for Typesense/Meilisearch/Algolia._
2320
+
2321
+ Search that never drifts from your data. `@voltro/plugin-search` taps the post-commit ChangeEvent stream: every insert/update to a source table upserts its doc into a search index, every delete removes it — no app code, no cron, no manual reindex. The plugin synthesizes a `search.query` rpc that returns hits **auto-filtered to the caller's tenant**. The template runs on `memoryBackend()` (in-process, zero infra) so it boots and validates with nothing installed; going to production is one line in `lib/search.ts`. Template id: **`api-search`**.
2322
+
2323
+ ## Scaffold
2324
+
2325
+ ```bash
2326
+ voltro create-project acme --api=api-search
2327
+ voltro add-app search --template=api-search --to acme
2328
+ ```
2329
+
2330
+ ## What ships
2331
+
2332
+ ```text
2333
+ apps/acme/api/
2334
+ ├── app.config.ts # store:'memory', plugins: [searchPlugin({ backend, indexes })]
2335
+ ├── lib/
2336
+ │ └── search.ts # the SHARED backend instance + the articles index spec
2337
+ ├── database/schema.ts # actors, tenants, articles (tenant-scoped, reactive)
2338
+ ├── mutations/articles.create.* # writes that auto-index via the tap
2339
+ ├── queries/articles.list.* # a live list to sit next to the search box
2340
+ ├── seeds/articles.seed.ts # demo data
2341
+ ├── startup/searchBackfill.startup.tsx # backfillIndex() on boot
2342
+ ├── package.json # + @voltro/plugin-search
2343
+ └── tsconfig.json
2344
+ ```
2345
+
2346
+ ## The index — `searchPlugin` + a shared backend
2347
+
2348
+ `lib/search.ts` holds the wiring, shared so `app.config.ts` (the plugin) and the boot startup (the backfill) use the **same** backend instance — `memoryBackend()` is a closure over a Map, so two calls would be two separate indexes:
2349
+
2350
+ ```ts
2351
+ // lib/search.ts
2352
+ import { memoryBackend, type IndexSpec, type SearchDoc } from '@voltro/plugin-search'
2353
+
2354
+ export const searchBackend = memoryBackend()
2355
+
2356
+ export const articlesIndex: IndexSpec = {
2357
+ index: 'articles',
2358
+ // Flatten a row → a search doc (must include `id`).
2359
+ map: (row): SearchDoc => ({
2360
+ id: row['id'] as string,
2361
+ title: row['title'] as string,
2362
+ body: row['body'] as string,
2363
+ tag: row['tag'] as string,
2364
+ tenantId: row['tenantId'] as string,
2365
+ }),
2366
+ // Makes search.query auto-filter to the caller's tenant — no leakage.
2367
+ tenantField: 'tenantId',
2368
+ }
2369
+ ```
2370
+
2371
+ ```ts
2372
+ // app.config.ts
2373
+ import { searchPlugin } from '@voltro/plugin-search'
2374
+ import { searchBackend, articlesIndex } from './lib/search'
2375
+
2376
+ export default {
2377
+ type: 'api' as const, name: 'AcmeSearch', store: 'memory' as const,
2378
+ plugins: [searchPlugin({ backend: searchBackend, indexes: { articles: articlesIndex } })],
2379
+ }
2380
+ ```
2381
+
2382
+ ## Live sync — the change tap
2383
+
2384
+ There is **no indexing code** in your handlers. The plugin registers an `onChangeEvent` tap; when a mutation commits a write to `articles`, the plugin maps the row and upserts it (or removes it on delete). So a freshly-created article is searchable on the very next request:
2385
+
2386
+ ```bash
2387
+ # create an article — indexed on commit
2388
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2389
+ -d '{"tag":"articles.create","input":{"tenantId":"acme","title":"Reactive queries","body":"Subscriptions push deltas live.","tag":"guide"}}'
2390
+ # …then find it
2391
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2392
+ -d '{"tag":"search.query","input":{"index":"articles","q":"reactive"}}'
2393
+ # → { ok:true, result:[ { id, title:"Reactive queries", … } ] }
2394
+ ```
2395
+
2396
+ ## `search.query` — tenant-scoped by construction
2397
+
2398
+ The plugin synthesizes the `search.query` rpc — input `{ index, q, limit?, filters? }`, output the matching docs. Because the index spec set `tenantField: 'tenantId'`, the query auto-filters to the caller's tenant: a request resolved to tenant `acme` only ever sees `acme` docs, even though every tenant's docs share one index. Searching the same term as a different tenant returns nothing.
2399
+
2400
+ ## Backfill existing rows
2401
+
2402
+ The change tap covers writes AFTER boot. `startup/searchBackfill.startup.tsx` seeds the index from rows already in the table — the demo seed's articles, or every row after a restart or a switch to a new engine. It uses the same `searchBackend` instance, so the docs it writes are exactly what `search.query` reads:
2403
+
2404
+ ```tsx
2405
+ import { backfillIndex } from '@voltro/plugin-search'
2406
+ import { database } from '../database/schema'
2407
+ import { searchBackend, articlesIndex } from '../lib/search'
2408
+
2409
+ export default async ({ store, log }) => {
2410
+ const rows = await store.query(database.articles.descriptor)
2411
+ const count = await backfillIndex(searchBackend, articlesIndex, rows)
2412
+ log.info(`search: backfilled ${count} article(s) into the index`)
2413
+ }
2414
+ ```
2415
+
2416
+ ## On the web side
2417
+
2418
+ ```tsx
2419
+ import { useSearch } from '@voltro/plugin-search/web'
2420
+ const { results, run, pending } = useSearch('articles')
2421
+ // run('reactive') → results auto-scoped to the signed-in tenant
2422
+ ```
2423
+
2424
+ The `/web` subpath imports nothing server-only — it's just a query over the `search.query` rpc, browser-safe.
2425
+
2426
+ ## Going to production — one line
2427
+
2428
+ `memoryBackend()` is in-process: great for dev, single-instance only. For real deployments swap it in `lib/search.ts` for a vendor engine — the indexing + query + backfill code is identical:
2429
+
2430
+ ```ts
2431
+ import { typesenseBackend } from '@voltro/plugin-search' // or meilisearch / algolia
2432
+ export const searchBackend = typesenseBackend({ url: process.env.TYPESENSE_URL!, apiKey: process.env.TYPESENSE_KEY! })
2433
+ ```
2434
+
2435
+ ## When to use api-search vs. the other backends
2436
+
2437
+ | You want… | Pick |
2438
+ |---|---|
2439
+ | Full-text search kept in sync with a table | `api-search` |
2440
+ | Semantic / vector search + a RAG agent | [`api-ai`](/docs/templates/api-ai) (a `vectorEmbedding()` index + an agent) |
2441
+ | The minimal CRUD backend | [`api-backend`](/docs/templates/api-backend) |
2442
+
2443
+ `api-search` is keyword/full-text (Typesense-style); `api-ai` is embeddings/semantic. They compose — `hybridSearch` fuses both.
2444
+
2445
+ ## Pairs well with
2446
+
2447
+ - Any **web** template — drop a search box wired to `useSearch('articles')`.
2448
+ - [`api-backend`](/docs/templates/api-backend) patterns for the rest of the CRUD surface around the searchable table.
2449
+
2450
+ ## Anti-patterns
2451
+
2452
+ - **Two `memoryBackend()` instances.** It's a closure over a Map — calling it twice gives two separate indexes, so a backfill into one is invisible to a `search.query` reading the other. Create ONE instance in `lib/search.ts` and share it (this template's whole reason for that file).
2453
+ - **Hand-indexing in your mutation.** Don't `searchBackend.upsert(...)` inside `articles.create` — the change tap already does it on commit. Doubling up risks drift and races. Let the tap own the index.
2454
+ - **Forgetting `tenantField`.** Without it, `search.query` returns every tenant's hits — a cross-tenant leak. Set `tenantField` on any index over a `tenant()`-scoped table.
2455
+ - **Relying on the seed's rows being indexed without the backfill.** The seed writes through the RAW store (no subject); whether that fires the tap is store-dependent. The boot `backfillIndex` is what guarantees pre-existing rows are in the index — keep it.
2456
+
2457
+
2458
+
2459
+ ---
2460
+
2461
+ <!-- source: en/templates/api-feature-flags.md -->
2462
+ ## API · Feature flags
2463
+
2464
+ _Feature flags wired turnkey with @voltro/plugin-flags — flags as code (kill-switch / % rollout / targeting), a declarative gatedBy map that fails typed FlagDisabled before the handler, an in-handler requireFlag guard, and flags.evaluate for useFlag() on the web. Memory store, zero infra._
2465
+
2466
+ Feature flags without a service. `@voltro/plugin-flags` declares flags **as code** and gates rpc calls two ways — declaratively (a `gatedBy` map fails `FlagDisabled` before the handler) and in-handler (`requireFlag(ctx, key)`). A flag is a bare boolean (kill-switch) or a rich definition with a deterministic `% rollout` and OR-of-rules `targeting`. The client gets the same evaluation via `useFlag()`. Memory store is zero-infra; `postgres` adds runtime toggles. Template id: **`api-feature-flags`**.
2467
+
2468
+ ## Scaffold
2469
+
2470
+ ```bash
2471
+ voltro create-project acme --api=api-feature-flags
2472
+ ```
2473
+
2474
+ ## Flags as code
2475
+
2476
+ ```ts
2477
+ // app.config.ts
2478
+ import { flagsPlugin } from '@voltro/plugin-flags'
2479
+
2480
+ flagsPlugin({
2481
+ flags: {
2482
+ newEditor: true, // kill-switch (on)
2483
+ betaExport: false, // kill-switch (off)
2484
+ proDashboard: { targeting: [{ metadata: { plan: 'pro' } }] }, // targeting
2485
+ gradualRollout: { rollout: 25 }, // deterministic % rollout
2486
+ },
2487
+ gatedBy: { 'notes.export': 'betaExport' }, // tag → flag
2488
+ store: 'memory',
2489
+ })
2490
+ ```
2491
+
2492
+ ## Two ways to gate
2493
+
2494
+ - **Declarative** — the `gatedBy` map points `notes.export` at `betaExport`. The framework fails typed `FlagDisabled` **before** the handler when the flag is off. No code in the handler.
2495
+ - **In-handler** — `notes.create` calls the Effect-native guard:
2496
+
2497
+ ```ts
2498
+ // notes.create.mutation.server.ts
2499
+ import { requireFlag } from '@voltro/plugin-flags'
2500
+
2501
+ export default (input, ctx) => Effect.gen(function* () {
2502
+ yield* requireFlag(ctx, 'newEditor') // fails typed FlagDisabled when off
2503
+ const store = yield* EffectStore
2504
+ return yield* store.insert('notes', { title: input.title, body: input.body })
2505
+ })
2506
+ ```
2507
+
2508
+ Declare `error: FlagDisabled` (from the browser-safe `@voltro/plugin-flags/errors`) on the procedure so the client decodes it typed.
2509
+
2510
+ ## Try it
2511
+
2512
+ ```bash
2513
+ # newEditor ON → requireFlag passes → created:
2514
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2515
+ -d '{"tag":"notes.create","input":{"tenantId":"acme","title":"hi","body":"x"}}'
2516
+ # betaExport OFF → gatedBy blocks → typed FlagDisabled:
2517
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2518
+ -d '{"tag":"notes.export","input":{}}'
2519
+ # → { ok:false, error:{ _tag:"FlagDisabled", flag:"betaExport" } }
2520
+ ```
2521
+
2522
+ ## On the web side
2523
+
2524
+ ```tsx
2525
+ import { useFlag } from '@voltro/plugin-flags/web'
2526
+ const showBeta = useFlag('betaExport') // same targeting + rollout as the server
2527
+ {showBeta && <BetaExportButton />}
2528
+ ```
2529
+
2530
+ ## Runtime toggles
2531
+
2532
+ `store: 'postgres'` overlays runtime-toggleable flags (`_voltro_feature_flags`) on the code baseline — flip a kill-switch without a deploy. The dashboard ships a **Flags** panel for it.
2533
+
2534
+ ## Anti-patterns
2535
+
2536
+ - **Trusting `useFlag` for enforcement.** It's a UI affordance; the server guard (`gatedBy` / `requireFlag`) is the real gate. Never gate a write on the client alone.
2537
+ - **A `% rollout` keyed on a non-stable id.** Rollout buckets deterministically per subject (or tenant via `rolloutBy: 'tenant'`) — an anonymous/idless caller buckets on `'anon'`, so don't expect per-call variation.
2538
+
2539
+
2540
+
2541
+ ---
2542
+
2543
+ <!-- source: en/templates/api-ratelimit.md -->
2544
+ ## API · Rate limiting
2545
+
2546
+ _Per-endpoint / per-subject / per-tenant rpc rate limiting with @voltro/plugin-ratelimit — a default fallback + rules (exact tag or regex), sliding-window / token-bucket algorithms, composite keying, and a typed RateLimited error auto-merged into the wire error union. Memory store, zero infra._
2547
+
2548
+ Throttle rpc calls without touching a handler. `@voltro/plugin-ratelimit` runs on the rpc interceptors — the one surface that sees every call AND the resolved subject. A `default` fallback applies everywhere; `rules` override per endpoint with a choice of algorithm and bucket key. Over-limit calls fail a typed `RateLimited` error the plugin merges into every procedure's wire error union. Memory store is single-node; postgres/redis for a cluster. Template id: **`api-ratelimit`**.
2549
+
2550
+ ## Scaffold
2551
+
2552
+ ```bash
2553
+ voltro create-project acme --api=api-ratelimit
2554
+ ```
2555
+
2556
+ ## Limits as config
2557
+
2558
+ ```ts
2559
+ // app.config.ts
2560
+ import { rateLimitPlugin } from '@voltro/plugin-ratelimit'
2561
+
2562
+ rateLimitPlugin({
2563
+ default: { limit: 100, window: '1m' }, // global fallback, per subject
2564
+ rules: [
2565
+ // 3/min per tenant, token-bucket (starts full → burst of 3, then refills)
2566
+ { match: 'notes.create', limit: 3, window: '1m', algorithm: 'token-bucket', burst: 3, by: 'tenant' },
2567
+ ],
2568
+ store: 'memory',
2569
+ })
2570
+ ```
2571
+
2572
+ - **`match`** — an exact tag or a `/regex/`; **`kind`** narrows to mutation/query/action.
2573
+ - **`algorithm`** — `sliding-window` (default), `fixed-window`, or `token-bucket` (with `burst`).
2574
+ - **`by`** — the bucket key: `subject` (default), `tenant`, `apiKey`, `global`, or a composite array.
2575
+
2576
+ ## Typed `RateLimited`
2577
+
2578
+ There's no rate-limit code in the handler — the interceptor enforces it. An over-limit call fails `RateLimited` (`{ tag, limit, retryAfterMs, resetAtMs }`), auto-merged into the procedure's wire error union, so the client decodes it typed and can show a "try again in N seconds".
2579
+
2580
+ ## Try it
2581
+
2582
+ ```bash
2583
+ # Fire notes.create four times for tenant 'acme' — the 4th trips the limit:
2584
+ for i in 1 2 3 4; do
2585
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2586
+ -d "{\"tag\":\"notes.create\",\"input\":{\"tenantId\":\"acme\",\"title\":\"n$i\",\"body\":\"x\"}}"
2587
+ done
2588
+ # → calls 1-3: { ok:true } ; call 4: { ok:false, error:{ _tag:"RateLimited", … } }
2589
+ ```
2590
+
2591
+ ## Multi-node
2592
+
2593
+ `store: 'memory'` is single-process. Switch to `'postgres'` (state under a row lock, reuses `DB_*`/`PG_*`) or `'redis'` (fastest — one atomic Lua script; reads `CACHE_REDIS_URL` / `REDIS_URL`). All shared stores **fail open** on a backend error — a limiter outage degrades to "no limit", never to "deny everything".
2594
+
2595
+ ## Anti-patterns
2596
+
2597
+ - **Rate-limiting reads you didn't mean to.** The `default` applies to queries too. Use `kind: 'mutation'` on a rule (or a tighter default) if you only want to throttle writes.
2598
+ - **Reusing the cache store as the limiter store.** A cache is get/set; a limiter needs an ATOMIC increment. They're separate backends — `store` here is independent of `@voltro/cache`.
2599
+
2600
+
2601
+
2602
+ ---
2603
+
2604
+ <!-- source: en/templates/api-rbac.md -->
2605
+ ## API · RBAC
2606
+
2607
+ _Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and an in-handler permission(ctx, 'notes:write') guard that fails typed Forbidden. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
2608
+
2609
+ Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `Forbidden`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
2610
+
2611
+ ## Scaffold
2612
+
2613
+ ```bash
2614
+ voltro create-project acme --api=api-rbac
2615
+ ```
2616
+
2617
+ ## Roles as config
2618
+
2619
+ ```ts
2620
+ // app.config.ts
2621
+ import { rbacPlugin } from '@voltro/plugin-rbac'
2622
+
2623
+ const roles = {
2624
+ viewer: ['notes:read'],
2625
+ editor: ['notes:read', 'notes:write'],
2626
+ admin: ['admin:full'], // blanket bypass — passes every check
2627
+ }
2628
+
2629
+ rbacPlugin({
2630
+ roles,
2631
+ // PRODUCTION: the default resolver reads subject.metadata.roles (set by your
2632
+ // auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
2633
+ // you can try each via the x-tenant header with zero auth setup:
2634
+ resolveRoles: (subject) =>
2635
+ ({ acme: ['admin'], editors: ['editor'], readers: ['viewer'] })[subject.tenantId ?? ''] ?? ['viewer'],
2636
+ })
2637
+ ```
2638
+
2639
+ ## Guard with `permission()`
2640
+
2641
+ ```ts
2642
+ // notes.create.mutation.server.ts
2643
+ import { permission } from '@voltro/plugin-rbac'
2644
+
2645
+ export default (input, ctx) => Effect.gen(function* () {
2646
+ yield* permission(ctx, 'notes:write') // fails typed Forbidden for viewer
2647
+ const store = yield* EffectStore
2648
+ return yield* store.insert('notes', { title: input.title, body: input.body })
2649
+ })
2650
+ ```
2651
+
2652
+ Declare `error: Forbidden` (from the browser-safe `@voltro/plugin-rbac/errors`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
2653
+
2654
+ ## Try it
2655
+
2656
+ ```bash
2657
+ # admin tenant → admin:full → passes:
2658
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: acme' \
2659
+ -d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
2660
+ # → { ok:true, … }
2661
+
2662
+ # readers tenant → viewer → notes:write missing → typed Forbidden:
2663
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: readers' \
2664
+ -d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
2665
+ # → { ok:false, error:{ _tag:"Forbidden", required:"notes:write" } }
2666
+ ```
2667
+
2668
+ ## On the web side
2669
+
2670
+ ```tsx
2671
+ import { useCan } from '@voltro/plugin-rbac/web'
2672
+ const canWrite = useCan('notes:write')
2673
+ {canWrite && <NewNoteButton />} // hide affordances the user can't use
2674
+ ```
2675
+
2676
+ `useCan` is a UI affordance only — the server `permission()` guard is the enforcement.
2677
+
2678
+ ## Persisted roles
2679
+
2680
+ Set `tables: true` to store assignments in `roles` / `userRoles` tables and resolve them from the DB (multitenant-aware). The shipped template stays config-only.
2681
+
2682
+ ## Anti-patterns
2683
+
2684
+ - **Returning scopes from `resolveRoles`.** It returns role SLUGS (`['editor']`); the `roles` map turns those into scopes. Returning scopes directly bypasses the map.
2685
+ - **Scopes ≠ entitlements.** RBAC answers "may you call this"; billing entitlements (see [`api-saas`](/docs/templates/api-saas)) answer "do you have quota left". A procedure can need both.
2686
+
2687
+
2688
+
2689
+ ---
2690
+
2691
+ <!-- source: en/templates/api-moderation.md -->
2692
+ ## API · Moderation
2693
+
2694
+ _Pre-commit content moderation with @voltro/plugin-moderation — checks named input fields BEFORE the handler; a block rule rejects banned content with typed ContentRejected (write never commits), a flag rule writes it but queues it for review. keywordProvider or aiProvider. Memory store, zero infra._
2695
+
2696
+ Stop bad content before it's written. `@voltro/plugin-moderation` checks named input fields on the rpc interceptors, **before** the handler: a `block` rule rejects the call with typed `ContentRejected` (the write never commits); a `flag` rule lets the write through but queues it for review. The provider is pluggable — `keywordProvider` is a zero-dep deny-list, `aiProvider()` an optional LLM classifier. Config-only, zero infra. Template id: **`api-moderation`**.
2697
+
2698
+ ## Scaffold
2699
+
2700
+ ```bash
2701
+ voltro create-project acme --api=api-moderation
2702
+ ```
2703
+
2704
+ ## Rules as config
2705
+
2706
+ ```ts
2707
+ // app.config.ts
2708
+ import { moderationPlugin, keywordProvider } from '@voltro/plugin-moderation'
2709
+
2710
+ moderationPlugin({
2711
+ provider: keywordProvider(['spam', 'scam', 'phishing', 'malware']),
2712
+ rules: [
2713
+ { match: 'posts.create', fields: ['title', 'body'], action: 'block' }, // reject
2714
+ { match: 'comments.create', fields: ['body'], action: 'flag' }, // write + queue
2715
+ ],
2716
+ })
2717
+ ```
2718
+
2719
+ There's no moderation code in the handlers — `posts.create` and `comments.create` are plain inserts; the interceptor enforces the rules.
2720
+
2721
+ ## Block vs flag
2722
+
2723
+ - **block** — banned content fails typed `ContentRejected` (`{ tag, categories, reason }`) before the write. Declare `error: ContentRejected` (from `@voltro/plugin-moderation/errors`) on the procedure.
2724
+ - **flag** — the row IS written, but flagged; the dashboard's **Moderation** panel surfaces the review queue. For rewriting rather than rejecting, the in-handler `moderate(text)` helper returns a verdict you act on (the interceptor can't rewrite input).
2725
+
2726
+ ## Try it
2727
+
2728
+ ```bash
2729
+ # banned word in a post → blocked, nothing written:
2730
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2731
+ -d '{"tag":"posts.create","input":{"title":"hi","body":"buy cheap spam now"}}'
2732
+ # → { ok:false, error:{ _tag:"ContentRejected", reason:"matched denied term(s): spam" } }
2733
+
2734
+ # clean post → written; banned COMMENT → written but flagged (flag doesn't block):
2735
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2736
+ -d '{"tag":"comments.create","input":{"body":"this is spam"}}'
2737
+ # → { ok:true, … } (queued for review)
2738
+ ```
2739
+
2740
+ ## Swap in AI
2741
+
2742
+ Replace `keywordProvider([...])` with `aiProvider()` — the rules don't change. `aiProvider` fails OPEN (a classifier outage lets content through rather than blocking everything).
2743
+
2744
+ ## Anti-patterns
2745
+
2746
+ - **Putting the keyword list in the client.** Moderation runs server-side on the interceptor — a client check is bypassable. Keep the provider + rules on the api.
2747
+ - **Using `block` where you meant `flag`.** `block` is a hard reject (nothing written); `flag` keeps the content for human review. Pick per surface — comments often `flag`, public posts often `block`.
2748
+
2749
+
2750
+
2751
+ ---
2752
+
2753
+ <!-- source: en/templates/api-versioning.md -->
2754
+ ## API · Versioning
2755
+
2756
+ _Full row history + time-travel with @voltro/plugin-versioning — snapshots every insert/update/delete on the listed tables via the post-commit change tap; read every version with rowHistory() and the value at a past instant with rowAsOf(). audit() is who/when; this is what-changed-to-what. Memory store, zero infra._
2757
+
2758
+ Every version of a row, queryable. `@voltro/plugin-versioning` rides the post-commit ChangeEvent tap and records a FULL snapshot of every insert/update/delete on the listed tables. Read it back with `rowHistory(store, table, id)` (every version) and `rowAsOf(store, table, id, when)` (the value at a past instant — time-travel). `audit()` records WHO/WHEN; this records WHAT it changed to, so you can diff or restore. Memory store, zero infra. Template id: **`api-versioning`**.
2759
+
2760
+ ## Scaffold
2761
+
2762
+ ```bash
2763
+ voltro create-project acme --api=api-versioning
2764
+ ```
2765
+
2766
+ ## Wire it + read it back
2767
+
2768
+ ```ts
2769
+ // app.config.ts
2770
+ import { versioningPlugin } from '@voltro/plugin-versioning'
2771
+ export default { type: 'api', name: 'AcmeVer', store: 'memory',
2772
+ plugins: [versioningPlugin({ tables: ['documents'] })] }
2773
+ ```
2774
+
2775
+ The snapshotting is automatic. The point is to **read history through handlers** — the template ships two actions:
2776
+
2777
+ ```ts
2778
+ // documents.history.action.server.ts
2779
+ import { rowHistory } from '@voltro/plugin-versioning'
2780
+ export default async ({ id }, ctx) => {
2781
+ const rows = await rowHistory(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null)
2782
+ return rows.map((r) => ({ version: r.version, op: r.op, content: r.data?.content ?? null, changedAt: r.changedAt }))
2783
+ }
2784
+
2785
+ // documents.asOf.action.server.ts
2786
+ import { rowAsOf } from '@voltro/plugin-versioning'
2787
+ export default async ({ id, at }, ctx) =>
2788
+ rowAsOf(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null, at) // the row as it was at `at` (epoch ms), or null
2789
+ ```
2790
+
2791
+ ## Try it
2792
+
2793
+ ```bash
2794
+ ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2795
+ -d '{"tag":"documents.create","input":{"title":"Spec","content":"draft v1"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
2796
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2797
+ -d "{\"tag\":\"documents.update\",\"input\":{\"id\":\"$ID\",\"content\":\"final v2\"}}" >/dev/null
2798
+
2799
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2800
+ -d "{\"tag\":\"documents.history\",\"input\":{\"id\":\"$ID\"}}"
2801
+ # → [ { version:1, op:"insert", content:"draft v1", … }, { version:2, op:"update", content:"final v2", … } ]
2802
+ # documents.asOf with v1's changedAt → { content:"draft v1" } (time-travel)
2803
+ ```
2804
+
2805
+ ## audit vs versioning
2806
+
2807
+ | You want… | Use |
2808
+ |---|---|
2809
+ | WHO changed a row + WHEN | `audit()` mixin |
2810
+ | WHAT it changed to (every prior value) | `@voltro/plugin-versioning` (this) |
2811
+
2812
+ They compose — `audit()` for accountability, versioning for diff/restore.
2813
+
2814
+ ## Production
2815
+
2816
+ The memory history store is single-process. With a SQL store, history persists in `_voltro_row_history`. High-churn tables grow history fast — version what you need to diff/restore, not everything.
2817
+
2818
+ ## Anti-patterns
2819
+
2820
+ - **Listing `versioningPlugin({ tables })` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
2821
+ - **Versioning every table.** Snapshots cost storage proportional to write volume. Pick the tables whose past values you actually need.
2822
+
2823
+
2824
+
2825
+ ---
2826
+
2827
+ <!-- source: en/templates/api-backend-deactivation.md -->
2828
+ ## API · Deactivation
2829
+
2830
+ _The deactivation() schema mixin — adds deactivatedAt/deactivatedBy so a user can be locked out (your auth refuses a deactivated subject) while its row stays VISIBLE and queryable. The deliberate opposite of softDelete(), which hides + anonymises. Pure schema, zero infra._
2831
+
2832
+ Lock a user out without erasing them. The `deactivation()` schema mixin adds `deactivatedAt` + `deactivatedBy` columns. A deactivated subject can't authenticate, but its row stays **visible and queryable** — no read-scoping, no delete interception. That's the deliberate opposite of `softDelete()`, which hides + anonymises. It's a pure schema mixin (no `plugins[]` entry). Template id: **`api-backend-deactivation`**.
2833
+
2834
+ ## Scaffold
2835
+
2836
+ ```bash
2837
+ voltro create-project acme --api=api-backend-deactivation
2838
+ ```
2839
+
2840
+ ## The mixin
2841
+
2842
+ ```ts
2843
+ // database/schema.ts
2844
+ import { deactivation } from '@voltro/plugin-deactivation'
2845
+
2846
+ export const users = table('users', {
2847
+ id: id(), email: text().unique(), name: text(),
2848
+ })
2849
+ // adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
2850
+ // NO defaultWhere — a deactivated user still shows up in queries.
2851
+ .with(deactivation())
2852
+ .reactive()
2853
+ ```
2854
+
2855
+ Deactivate with a normal update — `deactivatedAt` is just a column:
2856
+
2857
+ ```ts
2858
+ // users.deactivate.mutation.server.ts
2859
+ export default async ({ id }, ctx) =>
2860
+ ctx.store.update('users', id, { deactivatedAt: new Date() })
2861
+ ```
2862
+
2863
+ ## Visible-but-locked-out
2864
+
2865
+ The point is what happens on a READ. After deactivation the row is STILL there and readable, just stamped:
2866
+
2867
+ ```bash
2868
+ ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2869
+ -d '{"tag":"users.create","input":{"email":"ada@acme.com","name":"Ada"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
2870
+
2871
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2872
+ -d "{\"tag\":\"users.deactivate\",\"input\":{\"id\":\"$ID\"}}" >/dev/null
2873
+
2874
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2875
+ -d "{\"tag\":\"users.get\",\"input\":{\"id\":\"$ID\"}}"
2876
+ # → { ok:true, result:{ …, deactivatedAt:"2026-…" } } (NOT null → still visible)
2877
+ ```
2878
+
2879
+ With `softDelete()` instead, that `users.get` would return `null` — the row would be hidden.
2880
+
2881
+ ## deactivation vs softDelete
2882
+
2883
+ | You want… | Use |
2884
+ |---|---|
2885
+ | Hide + anonymise a row (GDPR, "delete my account") | `softDelete()` |
2886
+ | Keep the row visible but mark the subject as locked out | `deactivation()` (this) |
2887
+
2888
+ Compose the full lifecycle: `users.with(audit(), softDelete(), deactivation())`.
2889
+
2890
+ ## Anti-patterns
2891
+
2892
+ - **Expecting deactivation to hide the row.** It doesn't — that's `softDelete()`. If a deactivated user must disappear from a list, filter on `deactivatedAt IS NULL` in that query yourself.
2893
+ - **Forgetting to enforce it in auth.** The mixin only stamps the column; YOUR auth resolver must refuse a subject whose `deactivatedAt` is set. The data layer keeps the row queryable on purpose.
2894
+
2895
+
2896
+
2897
+ ---
2898
+
2899
+ <!-- source: en/templates/api-governance.md -->
2900
+ ## API · Governance
2901
+
2902
+ _Data governance in one plugin with @voltro/plugin-governance — AES-256-GCM field encryption for .encrypted() columns (handler sees plaintext, ciphertext at rest), admin-gated GDPR governance.export/erase over declared subjectScopes, a consent ledger, and retention TTL sweeps. Memory store, zero infra._
2903
+
2904
+ Four compliance primitives, one plugin. `@voltro/plugin-governance` gives you **field encryption** (AES-256-GCM for `.encrypted()` columns — handlers see plaintext, the column holds `enc:v1:…` at rest), **GDPR** export/erase (admin-gated, walking declared subject scopes), a **consent ledger**, and **retention** sweeps (delete/anonymise rows past a TTL). The template boots zero-infra with a dev encryption key. Template id: **`api-governance`**.
2905
+
2906
+ ## Scaffold
2907
+
2908
+ ```bash
2909
+ voltro create-project acme --api=api-governance
2910
+ ```
2911
+
2912
+ The shipped `.env` carries a DEV `VOLTRO_FIELD_ENCRYPTION_KEY`. Generate your own (`openssl rand -hex 32`) and keep it in a secret store for production.
2913
+
2914
+ ## Field encryption
2915
+
2916
+ ```ts
2917
+ // database/schema.ts
2918
+ export const profiles = table('profiles', {
2919
+ id: id(), name: text(), email: text(),
2920
+ ssn: text().encrypted(), // AES-256-GCM at rest
2921
+ }).with(audit())
2922
+
2923
+ // app.config.ts
2924
+ governancePlugin({ fieldEncryption: true /* reads VOLTRO_FIELD_ENCRYPTION_KEY */ })
2925
+ ```
2926
+
2927
+ You pass plaintext; the store middleware encrypts on write and decrypts on read — handlers never touch the ciphertext. Boot **fails loud** if an `.encrypted()` column exists but no cipher is registered.
2928
+
2929
+ ```bash
2930
+ ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2931
+ -d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
2932
+ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
2933
+ -d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
2934
+ # → { ok:true, result:{ …, ssn:"123-45-6789" } } (plaintext — the column stored enc:v1:…)
2935
+ ```
2936
+
2937
+ ## GDPR, consent, retention
2938
+
2939
+ ```ts
2940
+ governancePlugin({
2941
+ fieldEncryption: true,
2942
+ subjectScopes: [{ table: 'profiles', subjectField: 'id' }], // GDPR walks these
2943
+ retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
2944
+ })
2945
+ ```
2946
+
2947
+ - **GDPR** — `governance.export` / `governance.erase` are **admin-gated** (call as a subject with `admin:full` — see [`api-rbac`](/docs/templates/api-rbac)); they walk `subjectScopes` to bundle or erase everything belonging to a subject. `GovernanceService` exposes the same in-handler.
2948
+ - **Consent** — `governance.consent` (a mutation) records a grant; `governance.hasConsent` (a reactive query) checks it — subscribe to it on the web, or call `GovernanceService.hasConsent(...)` in a handler.
2949
+ - **Retention** — the sweep deletes (or `anonymize`s) rows whose age past `dateField` (default `createdAt`) exceeds `ttlMs`.
2950
+
2951
+ ## Rules
2952
+
2953
+ - **Don't encrypt what you filter on.** An `.encrypted()` column is ciphertext on disk — no `WHERE` / `ORDER BY` in SQL. Encrypt fields you read back WHOLE (PII, tokens, notes).
2954
+ - **The key is everything.** GCM fails closed on a bad key — you get an error, never silent corruption. Back the key up; rotating it means re-encrypting.
2955
+ - **GDPR endpoints are admin-only by design.** Don't remove the `admin:full` gate — an unauthenticated export/erase is a data-exfiltration / griefing hole.
2956
+
2957
+
2958
+
2959
+ ---
2960
+
2961
+ <!-- source: en/templates/api-kv.md -->
2962
+ ## API · Key-value
2963
+
2964
+ _Durable ctx.kv on an external-event-sync domain — a sync cursor you can't recompute plus TTL-bounded idempotency markers, contrasted with ctx.store (the rows) and ctx.cache. Zero-infra boot._
2965
+
2966
+ The template that exercises **`ctx.kv`** — the framework's storage primitive for state you **can't recompute**. Instead of another CRUD reference, it ships a cohesive **external-event-sync** domain: a durable **cursor** (a watermark past everything already pulled) and TTL-bounded **idempotency markers** that dedupe redelivered events. Around them sit the synced rows in `ctx.store`, so the template makes the store-vs-kv-vs-cache distinction concrete. It boots with **zero infrastructure** (`store: 'memory'`). Template id: **`api-kv`**.
2967
+
2968
+ ## Scaffold
2969
+
2970
+ ```bash
2971
+ voltro create-project acme --api=api-kv
2972
+ ```
2973
+
2974
+ No env or services required. On `store: 'memory'`, `ctx.kv` runs on its `database` backend in-process — durable within the run, reset on restart. Switch `app.config.ts` to `store: 'postgres'` and the cursor + markers survive restarts and are shared across replicas.
2975
+
2976
+ ## The one idea
2977
+
2978
+ Three storage primitives, three contracts — pick by what a loss costs you:
2979
+
2980
+ | Primitive | Holds | If you lose it | Default backend |
2981
+ |---|---|---|---|
2982
+ | `ctx.cache` | recomputable derived views | free — recompute | memory (evicts for capacity) |
2983
+ | `ctx.kv` | the cursor + idempotency markers | re-process / double-process | database (durable, never evicted) |
2984
+ | `ctx.store` | the synced rows | re-fetchable from the source | your SQL store |
2985
+
2986
+ A cursor is the textbook `ctx.kv` case: it isn't derived from anything you still hold, so a cache (which evicts) would silently reset your sync, and a hand-managed table row is overkill. See [durable key-value](/docs/caching/key-value).
2987
+
2988
+ ## What ships
2989
+
2990
+ ```text
2991
+ apps/acme/api/ # dir named by the app, not the template
2992
+ ├── app.config.ts # type:api, store:'memory', defineEnv
2993
+ ├── package.json
2994
+ ├── tsconfig.json
2995
+ ├── README.md
2996
+ ├── database/
2997
+ │ └── schema.ts # actors + tenants (core) + a synced_events table
2998
+ ├── actions/
2999
+ │ ├── sync.pull.action.ts # descriptor — pull a page of events
3000
+ │ ├── sync.pull.action.server.ts # executor — getOrElse/has/set(ttl)/set
3001
+ │ ├── sync.status.action.ts # descriptor — read-only view
3002
+ │ ├── sync.status.action.server.ts # executor (Effect) — yield* Kv
3003
+ │ ├── sync.reset.action.ts # descriptor — rewind the sync
3004
+ │ └── sync.reset.action.server.ts # executor — delete + list/delete
3005
+ └── queries/
3006
+ ├── events.list.query.ts # descriptor — reactive rows (store, for contrast)
3007
+ └── events.list.query.server.ts # executor — table descriptor
3008
+ ```
3009
+
3010
+ Every primitive is **auto-discovered by file convention** — nothing is registered in `app.config.ts`. The durable KV lives in the framework's `_voltro_kv` table (created automatically); no schema of yours models the cursor.
3011
+
3012
+ ## Database — the rows, deliberately NOT the cursor
3013
+
3014
+ The schema declares the two framework core tables plus a `synced_events` table for the ingested rows. The cursor and markers are **not** modelled here — they're durable KV.
3015
+
3016
+ ```ts
3017
+ // database/schema.ts
3018
+ import {
3019
+ databaseHandle, id, integer, table, text, timestamp, type InferRow,
3020
+ } from '@voltro/database'
3021
+ import { tenant } from '@voltro/plugin-multitenancy'
3022
+
3023
+ // Core tables — required by the tenant / audit mixins.
3024
+ export const actors = table('actors', {
3025
+ id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
3026
+ displayName: text().nullable(), createdAt: timestamp().default('now'),
3027
+ })
3028
+ export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
3029
+
3030
+ // The events we ingest from the source — re-fetchable, so a table is their home.
3031
+ export const syncedEvents = table('synced_events', {
3032
+ id: id({ prefix: 'evt' }),
3033
+ externalId: text(),
3034
+ kind: text(),
3035
+ payload: text(),
3036
+ sequence: integer(),
3037
+ })
3038
+ .with(tenant()) // tenantId + audit columns, auto-stamped from the subject
3039
+ .reactive() // events.list subscription wakes on every insert
3040
+
3041
+ export type SyncedEvent = InferRow<typeof syncedEvents>
3042
+ export const database = databaseHandle({ actors, tenants, syncedEvents })
3043
+ ```
3044
+
3045
+ ## Pull — the durable cursor + idempotency markers
3046
+
3047
+ `sync.pull` is the heart of the template. The descriptor declares the schema; the executor reads the durable cursor, pulls a page, dedupes each event against a TTL marker, inserts the new rows, and advances the cursor.
3048
+
3049
+ ```ts
3050
+ // actions/sync.pull.action.ts — descriptor (browser-safe)
3051
+ import { defineAction } from '@voltro/protocol'
3052
+ import { Schema } from 'effect'
3053
+
3054
+ export const syncPull = defineAction({
3055
+ name: 'sync.pull',
3056
+ input: Schema.Struct({
3057
+ limit: Schema.optional(Schema.Int.pipe(Schema.greaterThan(0))),
3058
+ }),
3059
+ output: Schema.Struct({
3060
+ pulled: Schema.Int, // newly ingested (marker written)
3061
+ skipped: Schema.Int, // already seen within the idempotency window
3062
+ cursor: Schema.Int, // the durable watermark after this call
3063
+ }),
3064
+ })
3065
+ ```
3066
+
3067
+ ```ts
3068
+ // actions/sync.pull.action.server.ts — executor (default export)
3069
+ import type { AppContext } from '@voltro/runtime'
3070
+
3071
+ // Stand-in for a real upstream — deterministic + pure so it boots with no
3072
+ // network. A real handler would `fetch()` here.
3073
+ const fetchSince = (sequence: number, limit: number) =>
3074
+ Array.from({ length: limit }, (_unused, i) => {
3075
+ const seq = sequence + i + 1
3076
+ return {
3077
+ externalId: `ext-${seq}`,
3078
+ kind: seq % 3 === 0 ? 'updated' : 'created',
3079
+ payload: JSON.stringify({ seq }),
3080
+ sequence: seq,
3081
+ }
3082
+ })
3083
+
3084
+ const MARKER_TTL_MS = 24 * 60 * 60_000 // 24h idempotency window
3085
+
3086
+ const execute = async (input: { limit?: number }, ctx: AppContext) => {
3087
+ // KV keys are app-global — fold the tenant in so two tenants never collide.
3088
+ const tenantId = ctx.request.subject.tenantId ?? 'anon'
3089
+ const cursorKey = `sync:${tenantId}:cursor`
3090
+ const limit = Math.min(input.limit ?? 5, 50)
3091
+
3092
+ // Durable cursor — getOrElse writes the default (0) ONLY on a first-run miss.
3093
+ const cursor = await ctx.kv.getOrElse<number>(cursorKey, () => 0)
3094
+
3095
+ let pulled = 0
3096
+ let skipped = 0
3097
+ let highest = cursor
3098
+
3099
+ for (const evt of fetchSince(cursor, limit)) {
3100
+ const markerKey = `sync:${tenantId}:seen:${evt.externalId}`
3101
+ if (await ctx.kv.has(markerKey)) {
3102
+ skipped++ // the source redelivered it within the TTL window
3103
+ } else {
3104
+ await ctx.store.insert('synced_events', {
3105
+ externalId: evt.externalId, kind: evt.kind,
3106
+ payload: evt.payload, sequence: evt.sequence, tenantId,
3107
+ })
3108
+ await ctx.kv.set(markerKey, evt.sequence, { ttlMs: MARKER_TTL_MS })
3109
+ pulled++
3110
+ }
3111
+ highest = Math.max(highest, evt.sequence)
3112
+ }
3113
+
3114
+ await ctx.kv.set(cursorKey, highest) // advance the durable watermark
3115
+ return { pulled, skipped, cursor: highest }
3116
+ }
3117
+
3118
+ export default execute
3119
+ ```
3120
+
3121
+ Four KV operations carry the whole flow: **`getOrElse`** reads the cursor (writing the `0` default only on a genuine first-run miss), **`has`** checks a marker without deserializing, **`set(…, { ttlMs })`** writes a bounded idempotency marker, and **`set`** advances the cursor. The rows go to `ctx.store` because they're re-fetchable; the cursor + markers go to `ctx.kv` because they're not.
3122
+
3123
+ ## Status & reset — two handler surfaces
3124
+
3125
+ Durable KV is reachable two ways, both wired in every runtime (`voltro dev` AND
3126
+ `voltro serve`): the async **`ctx.kv`** facade (`sync.pull` / `sync.reset`) and
3127
+ the Effect-native **`Kv`** service via `yield* Kv` (`sync.status`). They share
3128
+ one resolved store — pick by whether your handler is `async` or an `Effect`.
3129
+
3130
+ ```ts
3131
+ // actions/sync.status.action.server.ts — executor (Effect mode)
3132
+ import { Effect } from 'effect'
3133
+ import { Kv } from '@voltro/kv'
3134
+ import type { AppContext } from '@voltro/runtime'
3135
+
3136
+ const execute = (_input: Record<string, never>, ctx: AppContext) =>
3137
+ Effect.gen(function* () {
3138
+ const kv = yield* Kv // the framework provides Kv to every handler runtime
3139
+ const tenantId = ctx.request.subject.tenantId ?? 'anon'
3140
+ const cursor = yield* kv.getOrElse(`sync:${tenantId}:cursor`, () => 0)
3141
+ // list(prefix) → every live (non-expired) marker under it.
3142
+ const markers = yield* kv.list(`sync:${tenantId}:seen:`)
3143
+ return { cursor, activeMarkers: markers.length }
3144
+ })
3145
+
3146
+ export default execute
3147
+ ```
3148
+
3149
+ ```ts
3150
+ // actions/sync.reset.action.server.ts — executor
3151
+ import type { AppContext } from '@voltro/runtime'
3152
+
3153
+ const execute = async (input: { markers?: boolean }, ctx: AppContext) => {
3154
+ const tenantId = ctx.request.subject.tenantId ?? 'anon'
3155
+
3156
+ // delete(key) → boolean (did it exist?).
3157
+ const cursorCleared = await ctx.kv.delete(`sync:${tenantId}:cursor`)
3158
+
3159
+ let markersCleared = 0
3160
+ if (input.markers) {
3161
+ // list(prefix) + delete() to sweep a set. `ctx.kv.clear()` would drop the
3162
+ // app's ENTIRE KV namespace — too broad for one tenant's sync keys.
3163
+ for (const key of await ctx.kv.list(`sync:${tenantId}:seen:`)) {
3164
+ if (await ctx.kv.delete(key)) markersCleared++
3165
+ }
3166
+ }
3167
+
3168
+ return { cursorCleared, markersCleared }
3169
+ }
3170
+
3171
+ export default execute
3172
+ ```
3173
+
3174
+ Resetting **only the cursor** leaves the markers alive — so a following `sync.pull` re-reads from the start but **skips everything**, proving the two KV concerns are independent. That is the template's punchline.
3175
+
3176
+ ## Try it
3177
+
3178
+ Over the rpc surface (a `voltro dev` web client, `POST /rpc`, or the inspect `invoke` endpoint):
3179
+
3180
+ ```jsonc
3181
+ { "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 5 }
3182
+ { "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 10 }
3183
+ { "tag": "sync.status", "input": {} } // → { cursor: 10, activeMarkers: 10 }
3184
+ { "tag": "sync.reset", "input": {} } // → { cursorCleared: true, markersCleared: 0 }
3185
+ { "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 0, skipped: 5, cursor: 5 } ← markers survived
3186
+ ```
3187
+
3188
+ The synced rows stream in live via the `events.list` subscription.
3189
+
3190
+ ## TTL & tenant namespacing
3191
+
3192
+ - **TTL** — markers are written with `{ ttlMs }` (a 24h idempotency window). Expiry is lazy: an expired marker reads as a miss and is dropped on the next `has`/`get`. The cursor has **no** TTL — permanent until deleted.
3193
+ - **Tenant namespacing** — `ctx.kv` keys are app-global; the facade never namespaces for you. Every key folds in `ctx.request.subject.tenantId` (`sync:${tenantId}:…`) so two tenants' cursors/markers never collide.
3194
+
3195
+ ## When to use api-kv vs. the variants
3196
+
3197
+ | You need… | Pick |
3198
+ |---|---|
3199
+ | The smallest CRUD reference to extend | [`api-backend`](/docs/templates/api-backend) |
3200
+ | Durable state you can't recompute (cursors, progress, idempotency) | `api-kv` |
3201
+ | Workflows / triggers / schedules exercised end-to-end | [`api-durable`](/docs/templates/api-durable) |
3202
+ | The advanced schema DSL + query caching | [`api-data-advanced`](/docs/templates/api-data-advanced) |
3203
+
3204
+ ## Anti-patterns
3205
+
3206
+ - **Modelling a cursor as a table row.** A watermark you hand-`UPDATE` is exactly what `ctx.kv` replaces — one durable key, no schema, no migration. Reach for a table only when the data is relational and queried.
3207
+ - **Caching a cursor.** `ctx.cache` evicts for capacity; a cache miss on your sync watermark silently rewinds the sync (or double-processes). Durable state that a miss corrupts belongs in `ctx.kv`, never the cache.
3208
+ - **Forgetting the tenant in the key.** `ctx.kv` keys are app-global. A bare `sync:cursor` collides across tenants — always fold `subject.tenantId` (or the actor) into the key.
3209
+ - **Storing large or unbounded values in KV.** `ctx.kv` is for small control state — cursors, flags, markers — not blobs or ever-growing lists. It's durable with no eviction, so bulk data never gets reclaimed; model relational/bulk data as a table instead.
3210
+
3211
+ ## Pairs well with
3212
+
3213
+ - [`store: 'postgres'`](/docs/database/dialects) — the cursor + markers survive restarts and are shared across replicas.
3214
+ - [Key-value backends](/docs/caching/kv-backends) — put just the KV on a persistent Redis with `KV_BACKEND=redis`, no handler change.