@voltro/cli 0.33.0 → 0.34.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 (337) hide show
  1. package/CHANGELOG.md +1801 -0
  2. package/bin/nodeEnvironment.d.mts +30 -0
  3. package/bin/nodeEnvironment.mjs +158 -0
  4. package/bin/voltro.mjs +69 -5
  5. package/dist/addCommand-BNeoeSxe.js +124 -0
  6. package/dist/addCommand-aXSQveak.js +2 -0
  7. package/dist/agentsMd-BTchIZku.js +2 -0
  8. package/dist/agentsMd-mhQMF1bx.js +254 -0
  9. package/dist/apiBuild-BICVGnEC.js +2 -0
  10. package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
  11. package/dist/appPort-B_HpJ_ck.js +48 -0
  12. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  13. package/dist/baselineCommand-DIttzO8A.js +227 -0
  14. package/dist/bin.js +71 -28
  15. package/dist/build-CE7Ca9gm.js +711 -0
  16. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  17. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  18. package/dist/checkCommand-Dv8r9tGO.js +231 -0
  19. package/dist/checkCommand-YZDmkAGd.js +2 -0
  20. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  21. package/dist/cliError-BmdYnghb.js +10 -0
  22. package/dist/cliOutput-D1tSBoRM.js +15 -0
  23. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  24. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  25. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  26. package/dist/clusterCmd-CNXKlcvD.js +54 -0
  27. package/dist/codegen-CYM3Zqrf.js +605 -0
  28. package/dist/codegen-ChBi_hVa.js +2 -0
  29. package/dist/codegenCommand-DC6w2tNZ.js +30 -0
  30. package/dist/codemodRunner-DRRqXR74.js +5243 -0
  31. package/dist/commandRunner-BLAEFLjp.js +47 -0
  32. package/dist/commands-gutsz-Ac.js +808 -0
  33. package/dist/connectionConfig-UFlIEiys.js +66 -0
  34. package/dist/dashboardCommand-3YG8p-UA.js +25 -0
  35. package/dist/dataCommand-qL0r7fPO.js +535 -0
  36. package/dist/dataProfile-dW-PsfLB.js +15 -0
  37. package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
  38. package/dist/dbCommand-CpdKLeQq.js +2 -0
  39. package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
  40. package/dist/dev-CoG-ZPx8.js +3 -0
  41. package/dist/devActivity-Dx_3nnGv.js +100 -0
  42. package/dist/devActivity.js +1 -1
  43. package/dist/dialectDriver-CgXnDfec.js +39 -0
  44. package/dist/discover-C9XKJDco.js +25 -0
  45. package/dist/doctorCommand-BvqGBwNG.js +2 -0
  46. package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
  47. package/dist/dormancyCommand-Dszo57d6.js +69 -0
  48. package/dist/e2eCmd-BRabZww-.js +147 -0
  49. package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
  50. package/dist/envCommand-DPgdV1Bq.js +60 -0
  51. package/dist/evalCommand-6RUfPen4.js +118 -0
  52. package/dist/evolveCommand-DHpkgjgH.js +281 -0
  53. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  54. package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
  55. package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
  56. package/dist/generateCommand-oibemh97.js +147 -0
  57. package/dist/index.d.ts +45 -0
  58. package/dist/index.js +4 -3
  59. package/dist/infoCommand-BJw9nLUR.js +60 -0
  60. package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
  61. package/dist/inspect-C_T_WGvl.js +2 -0
  62. package/dist/inspectCmd-Bppy-GGw.js +224 -0
  63. package/dist/inspectFetch-Cm8_wVvp.js +151 -0
  64. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  65. package/dist/loadEnv-D9nEOClM.js +44 -0
  66. package/dist/logFileSink-C_D2wRN1.js +105 -0
  67. package/dist/logsCmd-CCca3KRZ.js +260 -0
  68. package/dist/manifestBuild-ChsKAhmn.js +2 -0
  69. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
  70. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  71. package/dist/migrate-CBwOt_iV.js +83 -0
  72. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  73. package/dist/newCommand-COWOJ1_E.js +156 -0
  74. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  75. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  76. package/dist/pageConvention-cEiRxdab.js +5 -0
  77. package/dist/privacyCommand-C-Df56U_.js +146 -0
  78. package/dist/projectScaffold-DmzEKHib.js +2 -0
  79. package/dist/projectScaffold-LMMtaavR.js +814 -0
  80. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  81. package/dist/renderProfile-1OWWAAtx.js +81 -0
  82. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  83. package/dist/runtimeTrace-CRxalXTs.js +91 -0
  84. package/dist/scheduleCmd--jksTrf6.js +69 -0
  85. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  86. package/dist/schemaIr-UJybUUZW.js +103 -0
  87. package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
  88. package/dist/seedRunner-TFHHiToI.js +329 -0
  89. package/dist/serveCommand-B_isw7q4.js +1647 -0
  90. package/dist/serveCommand-DOvbgRnQ.js +2 -0
  91. package/dist/serveEntry.js +5 -5
  92. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  93. package/dist/start-9LiUOfES.js +1087 -0
  94. package/dist/start-B-9Nsp-S.js +3 -0
  95. package/dist/startEntry.js +2 -2
  96. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  97. package/dist/storageCommand-Co6NfLqN.js +42 -0
  98. package/dist/templates-De8IR5-c.js +102 -0
  99. package/dist/test-CI6iDsYc.js +115 -0
  100. package/dist/tracesCmd-CkEZQrtt.js +232 -0
  101. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  102. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  103. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  104. package/dist/updateCommand-Bkptutss.js +585 -0
  105. package/dist/updateCommand-us1_hdIC.js +2 -0
  106. package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
  107. package/dist/webDev-Cg-fFiyd2.js +2 -0
  108. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  109. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  110. package/dist/webhooksCommand-CID96Rga.js +267 -0
  111. package/dist/workflowsCmd-D1VTmLMY.js +608 -0
  112. package/package.json +179 -17
  113. package/templates/AGENTS.core.md +58 -3
  114. package/templates/AGENTS.md +64 -7
  115. package/templates/agent-docs/_index.md +6 -4
  116. package/templates/agent-docs/_manifest.json +22 -5
  117. package/templates/agent-docs/ai.md +370 -0
  118. package/templates/agent-docs/authentication.md +265 -31
  119. package/templates/agent-docs/caching.md +6 -0
  120. package/templates/agent-docs/cli.md +794 -50
  121. package/templates/agent-docs/data.md +550 -11
  122. package/templates/agent-docs/database/migrations.md +174 -25
  123. package/templates/agent-docs/database/misc.md +156 -40
  124. package/templates/agent-docs/database/querying.md +19 -1
  125. package/templates/agent-docs/database/scaling.md +60 -0
  126. package/templates/agent-docs/database/schema.md +1 -0
  127. package/templates/agent-docs/database/seedsdialects.md +208 -19
  128. package/templates/agent-docs/database/transactions.md +68 -0
  129. package/templates/agent-docs/deployment.md +69 -3
  130. package/templates/agent-docs/introduction.md +87 -16
  131. package/templates/agent-docs/local-first-mobile.md +79 -4
  132. package/templates/agent-docs/multi-tenancy.md +95 -20
  133. package/templates/agent-docs/observability.md +58 -3
  134. package/templates/agent-docs/plugins/ai-flows.md +161 -2
  135. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  136. package/templates/agent-docs/plugins/audit.md +37 -1
  137. package/templates/agent-docs/plugins/auth-social.md +143 -0
  138. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  139. package/templates/agent-docs/plugins/auth.md +131 -6
  140. package/templates/agent-docs/plugins/billing.md +132 -15
  141. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  142. package/templates/agent-docs/plugins/clickhouse.md +1 -1
  143. package/templates/agent-docs/plugins/duckdb.md +1 -1
  144. package/templates/agent-docs/plugins/flags.md +132 -0
  145. package/templates/agent-docs/plugins/governance.md +105 -7
  146. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  147. package/templates/agent-docs/plugins/presence.md +13 -2
  148. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  149. package/templates/agent-docs/plugins/search.md +157 -6
  150. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  151. package/templates/agent-docs/plugins/webhooks.md +105 -0
  152. package/templates/agent-docs/plugins.md +150 -18
  153. package/templates/agent-docs/reference.md +60 -3
  154. package/templates/agent-docs/releases.md +1117 -0
  155. package/templates/agent-docs/routing.md +43 -25
  156. package/templates/agent-docs/scheduling.md +10 -0
  157. package/templates/agent-docs/schema-driven-ui.md +92 -12
  158. package/templates/agent-docs/security.md +426 -0
  159. package/templates/agent-docs/templates/apibackends.md +87 -18
  160. package/templates/agent-docs/templates/appshells.md +32 -14
  161. package/templates/agent-docs/templates/overview.md +13 -8
  162. package/templates/agent-docs/testing.md +211 -14
  163. package/templates/agent-docs/whats-new.md +1700 -104
  164. package/templates/agent-docs/workflows.md +130 -14
  165. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  166. package/templates/apps/api-ai/package.json +8 -7
  167. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  168. package/templates/apps/api-auth/package.json +9 -8
  169. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  170. package/templates/apps/api-backend/package.json +12 -8
  171. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  172. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  173. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  174. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  175. package/templates/apps/api-backend-deactivation/package.json +8 -7
  176. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  177. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  178. package/templates/apps/api-backend-mail/package.json +9 -8
  179. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  180. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  181. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  182. package/templates/apps/api-backend-mariadb/package.json +10 -9
  183. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  184. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  185. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  186. package/templates/apps/api-backend-sqlite/package.json +9 -8
  187. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  188. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  189. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  190. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  191. package/templates/apps/api-backend-storage/package.json +9 -8
  192. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  193. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  194. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  195. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  196. package/templates/apps/api-cms/app.config.ts +19 -0
  197. package/templates/apps/api-cms/authz.ts +63 -0
  198. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  199. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  200. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  201. package/templates/apps/api-cms/package.json +11 -10
  202. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  203. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  204. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  205. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  206. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  207. package/templates/apps/api-collab/package.json +9 -8
  208. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  209. package/templates/apps/api-data-advanced/package.json +9 -8
  210. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  211. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  212. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  213. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  214. package/templates/apps/api-durable/package.json +9 -8
  215. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  216. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  217. package/templates/apps/api-feature-flags/package.json +10 -9
  218. package/templates/apps/api-governance/README.md +30 -8
  219. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  220. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  221. package/templates/apps/api-governance/database/schema.ts +16 -4
  222. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  223. package/templates/apps/api-governance/package.json +9 -8
  224. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  225. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  226. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  227. package/templates/apps/api-kv/package.json +9 -8
  228. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  229. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  230. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  231. package/templates/apps/api-moderation/package.json +9 -8
  232. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  233. package/templates/apps/api-observability/package.json +9 -8
  234. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  235. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  236. package/templates/apps/api-ratelimit/package.json +9 -8
  237. package/templates/apps/api-rbac/package.json +9 -8
  238. package/templates/apps/api-rest/package.json +8 -7
  239. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  240. package/templates/apps/api-saas/package.json +12 -11
  241. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  242. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  243. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  244. package/templates/apps/api-saas-starter/authz.ts +75 -0
  245. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  246. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  247. package/templates/apps/api-saas-starter/package.json +15 -11
  248. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  249. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  250. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  251. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  252. package/templates/apps/api-search/package.json +9 -8
  253. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  254. package/templates/apps/api-status/README.md +10 -3
  255. package/templates/apps/api-status/app.config.ts +8 -3
  256. package/templates/apps/api-status/authz.ts +5 -3
  257. package/templates/apps/api-status/package.json +9 -8
  258. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  259. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  260. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  261. package/templates/apps/api-status/tests/status.test.ts +9 -1
  262. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  263. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  264. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  265. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  266. package/templates/apps/api-versioning/package.json +9 -8
  267. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  268. package/templates/apps/api-webhooks/package.json +10 -9
  269. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  270. package/templates/apps/changelog/package.json +8 -6
  271. package/templates/apps/edge-functions/package.json +2 -2
  272. package/templates/apps/frontend-admin/package.json +10 -8
  273. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  274. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  275. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  276. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  277. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  278. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  279. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  280. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  281. package/templates/apps/frontend-app/app.config.ts +4 -3
  282. package/templates/apps/frontend-app/package.json +11 -8
  283. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  284. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  285. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  286. package/templates/apps/frontend-auth/package.json +10 -8
  287. package/templates/apps/frontend-blank/package.json +9 -7
  288. package/templates/apps/frontend-cms/package.json +11 -9
  289. package/templates/apps/frontend-collab/package.json +12 -9
  290. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  291. package/templates/apps/frontend-contact/package.json +9 -7
  292. package/templates/apps/frontend-dashboard/package.json +9 -7
  293. package/templates/apps/frontend-docs/package.json +9 -7
  294. package/templates/apps/frontend-i18n/package.json +8 -6
  295. package/templates/apps/frontend-landing/package.json +9 -7
  296. package/templates/apps/frontend-portal/package.json +10 -8
  297. package/templates/apps/frontend-saas/app.config.ts +10 -6
  298. package/templates/apps/frontend-saas/package.json +10 -8
  299. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  300. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  301. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  302. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  303. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  304. package/templates/apps/frontend-spa/package.json +9 -7
  305. package/templates/apps/frontend-ssr/package.json +9 -7
  306. package/templates/apps/frontend-ssr-api/package.json +10 -8
  307. package/templates/apps/frontend-static-blog/package.json +8 -6
  308. package/templates/apps/frontend-status/package.json +10 -8
  309. package/templates/apps/mobile-app/README.md +1 -0
  310. package/templates/apps/mobile-app/package.json +4 -2
  311. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  312. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  313. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  314. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  315. package/templates/baselines/bare/.env.example +14 -0
  316. package/templates/baselines/bare/baseline.json +4 -4
  317. package/templates/baselines/compose/.env.example +14 -0
  318. package/templates/baselines/compose/README.md +1 -1
  319. package/templates/baselines/compose/baseline.json +5 -5
  320. package/templates/baselines/compose-mariadb/.env.example +14 -0
  321. package/templates/baselines/compose-mariadb/README.md +1 -1
  322. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  323. package/templates/baselines/helm/.env.example +14 -0
  324. package/templates/baselines/helm/baseline.json +4 -4
  325. package/dist/apiBuild-C-x9YacA.js +0 -2
  326. package/dist/checkCommand-DRovTKza.js +0 -2
  327. package/dist/commands-CJfepbm4.js +0 -11541
  328. package/dist/dbCommand-b1gum4td.js +0 -2
  329. package/dist/dev-iiMtlkfs.js +0 -3
  330. package/dist/devActivity-BhIu6ncs.js +0 -159
  331. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  332. package/dist/inspect-mmBuRXmy.js +0 -2
  333. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  334. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  335. package/dist/serveCommand-DdaM4Hup.js +0 -1608
  336. package/dist/start-C0koT0UO.js +0 -1084
  337. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -41,6 +41,29 @@ Verify in the boot log:
41
41
  That's it. Reads now flow to the replica unless RYW pins them back.
42
42
  Writes always go to primary.
43
43
 
44
+ ## Replica pools take the primary's connection settings
45
+
46
+ A replica is a **connection pool in the same process**, and it is configured by
47
+ the same environment variables the primary reads — `DB_MAX_CONNECTIONS`,
48
+ `PG_SSL`, `DB_SCHEMA`, `DB_STATEMENT_TIMEOUT_MS`, `DB_ACQUIRE_TIMEOUT_MS`. The
49
+ replica URL decides only WHICH server it talks to.
50
+
51
+ That matters for two reasons an operator has to plan for:
52
+
53
+ - **Connection budget.** `DB_REPLICA_URLS` with two entries means this process
54
+ opens **three** pools of `DB_MAX_CONNECTIONS` each, not one. The boot line
55
+ says so:
56
+
57
+ ```
58
+ db pool: max=10 per replica (DB_MAX_CONNECTIONS) → max × 3 = 30 per pod × 4 replicas = up to 120 connections.
59
+ This process opens 3 pools of that size — 1 primary + 2 read replica(s) (DB_REPLICA_URLS), each with its own DB_MAX_CONNECTIONS.
60
+ ```
61
+
62
+ - **TLS.** `PG_SSL=require` now applies to replica connections as well. A
63
+ `?sslmode=require` in the replica URL still works and still wins over the
64
+ driver default, but an explicit `PG_SSL` outranks it — so the TLS decision is
65
+ made once, for every connection the process opens.
66
+
44
67
  ## Read-your-writes (RYW)
45
68
 
46
69
  After a mutation commits, the subject who wrote is **pinned to
@@ -319,6 +342,43 @@ will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis /
319
342
 
320
343
  When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — the own-origin skip dedups, so there's no double-emit. The native path is the primary; the bus is harmless redundancy.
321
344
 
345
+ ## sqlite and the memory store behind replicas
346
+
347
+ Neither dialect has a cross-instance path, and neither can get one: a local
348
+ database file and an in-process store are, by construction, **this process's**.
349
+ Putting several replicas in front of one is not a reactivity gap — the replicas
350
+ do not share a database at all, so each one is also reading its own data.
351
+
352
+ On a laptop that is the correct, normal configuration, which is why the framework
353
+ said nothing about it for a long time. It now warns when it can see evidence of
354
+ an **orchestrator**:
355
+
356
+ ```
357
+ [voltro:serve] this app runs on 'sqlite' — a local database file — but POD_IP is set
358
+ (Kubernetes), which means several replicas. Neither dialect has ANY cross-instance
359
+ change capture: a client connected to replica A never sees a write made on replica B,
360
+ for every table, and each replica is also reading its OWN data…
361
+ ```
362
+
363
+ The evidence is one of `REPLICA_COUNT > 1`, `KUBERNETES_SERVICE_HOST`, `POD_IP`,
364
+ `POD_NAME`, `FLY_ALLOC_ID`, `FLY_MACHINE_ID`, `ECS_CONTAINER_METADATA_URI[_V4]`,
365
+ `K_REVISION`, `CONTAINER_APP_REPLICA_NAME` or `RENDER_INSTANCE_ID`. `HOSTNAME`,
366
+ `NODE_ENV` and `PORT` are deliberately **not** evidence — every single-instance
367
+ container sets those too, and a warning that fires on a laptop gets filtered out
368
+ before it reaches the deployment where it is true.
369
+
370
+ Two ways to silence it, and they are not equivalent:
371
+
372
+ - `REPLICA_COUNT=1` — you are telling the framework there is exactly one process.
373
+ This is the honest one, and it is the only positive evidence *against* that
374
+ exists, so it outranks every platform signal.
375
+ - Declaring `@voltro/plugin-broadcast` with a real provider — not because a bus
376
+ fixes it (the replicas still have separate databases), but because declaring
377
+ one means you have already thought about the question.
378
+
379
+ The real fix is postgres or mysql/mariadb: a shared database with a native
380
+ cross-instance change path.
381
+
322
382
  ## Where it lives in the codebase
323
383
 
324
384
  - `voltro/packages/plugin-broadcast` — the plugin, the `BroadcastProvider` contract, the redis / nats / memory providers, `attachBroadcastBus`.
@@ -373,6 +373,7 @@ import { TableValidationFailed } from '@voltro/runtime'
373
373
 
374
374
  export const createUser = defineMutation({
375
375
  name: 'users.create',
376
+ guards: [{ scope: 'users:write' }],
376
377
  input: Schema.Struct({ email: Schema.String }),
377
378
  output: Schema.Struct({ id: Schema.String }),
378
379
  target: { table: 'users', op: 'insert' },
@@ -74,21 +74,68 @@ table. If you want one tenant's rows, say so in your own predicate.
74
74
  |---|---|---|
75
75
  | `boot` | On every `voltro dev` boot, **only if the fingerprint changed** | — |
76
76
  | `manual` | Only via `voltro db seed --id <name>` or the dashboard | — |
77
- | `onTenantCreate` | **Not wired yet** declared and validated, never triggered | |
78
- | `onSchemaChange` | **Not wired yet** declared and validated, never triggered | `watchedTables` |
79
- | `cron` | **Not wired yet** declared and validated, never triggered | `cron` |
80
-
81
- Only `boot` and `manual` actually execute today. The other three lifecycles
82
- validate at definition time (a `cron` seed without a `cron` field throws, an
83
- `onSchemaChange` seed without `watchedTables` throws) and are registered so the
84
- dashboard can list them but nothing fires them. A seed declared with one of
85
- them will never run, silently.
86
-
87
- Until they are wired, express the same intent with a primitive that does run: a
88
- `*.cron.tsx` schedule whose handler does the seeding, or an explicit `voltro db
89
- seed --id <name>` from your migration/provisioning step. This table said
90
- otherwise until 0.10.0, which is exactly the kind of promise that costs someone
91
- a debugging afternoon it is corrected here rather than quietly dropped.
77
+ | `onTenantCreate` | Every time a tenant namespace is provisioned, **into that namespace** | a store with namespace isolation |
78
+ | `onSchemaChange` | After a schema apply that touched a watched table (`voltro dev`'s boot auto-migrate, `voltro db apply`, `voltro migrate`) | `watchedTables` |
79
+ | `cron` | On the cron expression, through the coordinated scheduler one firing fleet-wide | `cron` |
80
+
81
+ ### `onTenantCreate`
82
+
83
+ Fires from `provisionTenantNamespace`, after the namespace DDL lands and before
84
+ provisioning resolves so a caller that awaits it gets a tenant whose tables
85
+ **and** reference data exist, or an error. Three properties worth knowing:
86
+
87
+ - **Steps run scoped to the new namespace**, never the shared tables. A store
88
+ without namespace isolation (sqlite, a single-schema deployment) makes the run
89
+ **refuse** rather than fall back the fallback would write one tenant's
90
+ fixture into every tenant's data. On a single-namespace deployment, declare
91
+ the seed `lifecycle: 'boot'` instead.
92
+ - **No fingerprint skip.** A new namespace has none of the data, whatever
93
+ another tenant's run recorded. The ledger row is keyed `<seedId>@<namespace>`,
94
+ so N tenants produce N rows in `_voltro_seeds`.
95
+ - **A failure fails provisioning.** Unlike a boot seed, which logs and lets the
96
+ server come up, a failed tenant-create seed rejects the provisioning call — a
97
+ tenant whose namespace exists and whose data does not, reported as success, is
98
+ worse than a loud error. Re-run provisioning (it is idempotent) or
99
+ `voltro db seed --id <name>` once the cause is fixed.
100
+
101
+ ### `onSchemaChange`
102
+
103
+ Fires from the migration applier, so it covers every path that applies a schema:
104
+ `voltro dev`'s boot auto-migrate, `voltro db apply` (bare and `--plan`) and
105
+ `voltro migrate`. Every seed whose `watchedTables` intersect the tables the apply
106
+ actually changed runs once, after the apply.
107
+
108
+ Three properties worth knowing:
109
+
110
+ - **Strictly post-apply.** It runs after the audit row is written, so on a
111
+ transactional dialect the DDL has already committed and the seed talks to its
112
+ own `DataStore` rather than the migration's connection.
113
+ - **It cannot fail the migration.** The schema landed; a fixture that throws is
114
+ logged and recorded in `_voltro_seeds`. A successful apply is never reported as
115
+ failed because reference data did not load.
116
+ - **`voltro serve` does not run it** — for the same reason it does not run boot
117
+ seeds. Serve never applies a schema, so there is no schema change for it to
118
+ react to; the pre-deploy `voltro db apply` is where it happens.
119
+
120
+ > This seam was **declared and never called** until 0.34.0: the hook installed,
121
+ > the seeds were discovered, listed and ledgered, and nothing ran. If you
122
+ > declared an `onSchemaChange` seed before that release, expect it to fire on
123
+ > your next apply.
124
+
125
+ ### `cron`
126
+
127
+ `cron` seeds are projected into real schedules — the same coordinated scheduler
128
+ `*.cron.tsx` uses, so one firing happens fleet-wide instead of one per replica.
129
+ Both `voltro dev` and `voltro serve` register them at boot and log the schedule
130
+ names they created.
131
+
132
+ The schedule is named `seed:<id>`, which is also the name its runs appear under
133
+ in `_voltro_schedule_runs` and in the dashboard — distinct from a `*.cron.tsx`
134
+ namespace, so a schedule and a seed may share an id without colliding.
135
+
136
+ A cron firing runs the seed **unconditionally**. The fingerprint skip that makes
137
+ a `boot` seed cheap does not apply here: the trigger is the clock, not a change
138
+ in the source.
92
139
 
93
140
  ```ts
94
141
  defineSeed({
@@ -96,11 +143,24 @@ defineSeed({
96
143
  name: 'Rebuild search index nightly',
97
144
  lifecycle: 'cron',
98
145
  cron: '0 3 * * *', // required for lifecycle: 'cron'
146
+ timezone: 'Europe/Berlin', // optional; defaults to an explicit 'UTC'
99
147
  steps: ({ step }) => [ /* … */ ],
100
148
  })
101
149
  ```
102
150
 
103
- `defineSeed` validates at definition time: `cron` lifecycle without a `cron` field throws, `onSchemaChange` without `watchedTables` throws, and a seed with zero steps throws.
151
+ `timezone` is defaulted rather than required (unlike `defineSchedule`'s, where
152
+ it is mandatory) — reference data rarely cares about a local wall clock. The
153
+ default is an explicit `'UTC'`, never the container's clock.
154
+
155
+ `defineSeed` validates at definition time: `cron` lifecycle without a `cron` field throws, `onSchemaChange` without `watchedTables` throws, and a seed with zero steps throws. The cron EXPRESSION is validated at boot, so a typo fails the boot naming the seed rather than never firing.
156
+
157
+ > **An app with any `*.seed.ts` gets the two schedule ledger tables**
158
+ > (`_voltro_schedule_runs`, `_voltro_schedule_claims`), even without a cron seed.
159
+ > Whether a seed is a cron seed is a field INSIDE the file, and `voltro migrate`
160
+ > never imports your modules — so the table set is decided from the FILENAME, and
161
+ > it has to be decided identically by `voltro dev`, `voltro serve`, `voltro db
162
+ > apply` and `voltro migrate` or the schema fingerprint diverges. Two empty ledger
163
+ > tables is the price of that agreement.
104
164
 
105
165
  ## Steps
106
166
 
@@ -138,6 +198,7 @@ voltro db seed --store memory # explicit opt-in: in-memory smoke run
138
198
 
139
199
  - **Reference data** (must exist in every environment): `lifecycle: 'boot'`, idempotent via `upsertByUnique`. Safe in production.
140
200
  - **Demo data** (populate a fresh dashboard): `lifecycle: 'boot'` in dev, or `lifecycle: 'manual'` so it's operator-triggered and never auto-runs in prod.
201
+ - **Per-tenant starter data** (a new tenant's default categories, roles, settings): `lifecycle: 'onTenantCreate'`, which runs scoped to the tenant's own namespace as it is provisioned.
141
202
 
142
203
  See [migrations](/docs/database/migrations) for schema changes — seeds populate data, migrations shape the tables.
143
204
 
@@ -237,6 +298,49 @@ It applies to the **runtime query path only**. Migrations (`voltro db apply`) ru
237
298
 
238
299
  **Wired for postgres today** (the default dialect), where it maps to the server-side `statement_timeout` — a real, server-enforced cancel (SQLSTATE `57014`), not a client-side disconnect that leaves the query running. Other dialects accept the variable but currently ignore it, and the reasons are honest rather than incidental: `@effect/sql-mssql` exposes only a connection-establishment timeout, not a per-request one; MySQL/MariaDB's `max_execution_time` bounds `SELECT`s only (writes stay unbounded), which would be a misleading half-guarantee; and SQLite is in-process with a single connection, so there is no pool to protect. Unset (or any non-postgres dialect) = no timeout.
239
300
 
301
+ ### Acquire timeout — bound the wait for a free connection
302
+
303
+ `DB_STATEMENT_TIMEOUT_MS` above bounds a query the **server** is running. Nothing bounded a query the client had not sent yet: a request arriving when every pooled connection is busy waited — with no error, no retry and no log line — until something else finished. Those are the two halves of "a request is stuck", and only one of them was covered.
304
+
305
+ Every dialect that can bound an acquire now does, **by default**:
306
+
307
+ | Dialect | Default | What is bounded |
308
+ |---|---|---|
309
+ | postgres | 10 s | the whole acquire — waiting in the pool's queue *and* connecting |
310
+ | mysql / mariadb | 10 s connect · 100 queued waiters | connecting; the wait is bounded by queue **length**, see below |
311
+ | mssql | 10 s | connecting (and the boot probe) |
312
+ | sqlite / turso | — | one in-process connection, no pool to exhaust |
313
+
314
+ An exhausted pool now fails with an error that **names the pool** — `Failed to acquire connection` — at the moment the pool is the cause, instead of surfacing as unexplained latency somewhere with no connection information in it.
315
+
316
+ Move it with `DB_ACQUIRE_TIMEOUT_MS`, in milliseconds. `0` restores the driver's unbounded wait; a negative or non-numeric value falls back to the 10 s default rather than to no bound at all:
317
+
318
+ ```sh
319
+ DB_ACQUIRE_TIMEOUT_MS=3000 # 0 = the driver's unbounded wait
320
+ DB_ACQUIRE_QUEUE_LIMIT=2000 # mysql / mariadb only, OPT-IN — read the hazard below
321
+ ```
322
+
323
+ Both are read on every command that opens a pool — `voltro dev`, `voltro serve`, `voltro migrate` and every `voltro db …` subcommand alike — so the variable means one thing across your deployment. (`DB_STATEMENT_TIMEOUT_MS` above is deliberately runtime-only: a migration runs legitimately long *statements*. An acquire bound fires when no connection is free at all, which a migration has no more reason to wait forever for than a request does.)
324
+
325
+ That "every command" is literal, and it is worth stating because it has not always been true. Every pool the CLI opens gets its configuration from **one** resolver, so a knob cannot be honoured by one command and ignored by the next. The only two things the command changes are the two named here: `DB_STATEMENT_TIMEOUT_MS` (runtime only) and `DB_DIRECT_URL` / `DB_MIGRATE_URL` (migration only). Everything else — pool size, `DB_SCHEMA`, TLS (`PG_SSL`), the acquire bounds — resolves identically everywhere, including in the two places that open a bare postgres client rather than a pool (the web process's ISR cache and its CDC listener).
326
+
327
+ The same value is `ConnectionConfig.acquireTimeoutMs` when you build a layer yourself:
328
+
329
+ ```typescript
330
+ import { postgresDialect } from '@voltro/sql-postgres'
331
+
332
+ const layer = postgresDialect.makeSqlLayer({
333
+ url: process.env.DB_URL!,
334
+ acquireTimeoutMs: 3_000, // 0 = the driver's unbounded wait
335
+ })
336
+ ```
337
+
338
+ **mysql/mariadb cannot express a time bound on the waiting half, and the length bound that exists is opt-in.** `mysql2`'s pool queues the waiting caller with no timer at all, so there is nothing to set. The one bound the driver has is a LENGTH — `ConnectionConfig.acquireQueueLimit` / `DB_ACQUIRE_QUEUE_LIMIT`, mysql2's `queueLimit`. **The framework does not set it for you**, and the reason is worth understanding before you do:
339
+
340
+ A time bound self-throttles. The acquire fails only once the wait has elapsed, so nothing can retry it faster than the timeout. A length bound is free: past the limit mysql2 rejects the acquire *synchronously*, so any caller that retries a failed acquire without a delay retries in the same tick — forever. The event loop is never reached again, which means no timer fires and the queue whose depth caused the rejection can never drain. The symptom is a process pinned at 100% CPU with no error, no log line, and an idle database.
341
+
342
+ That caller is not hypothetical: the workflow cluster releases shards one statement per shard (300 by default) and retries a failed release immediately. So set `DB_ACQUIRE_QUEUE_LIMIT` only if you know nothing in your process retries an acquire without backoff, and set it well above the peak concurrency of anything that might. Unset — the default — you get mysql2's unbounded queue: callers wait rather than fail. mssql's pool exposes neither knob, so only establishment is bounded there — stated rather than papered over, for the same reason `DB_STATEMENT_TIMEOUT_MS` is left unwired on the dialects that cannot enforce it honestly.
343
+
240
344
  ## Local development — bring up all five
241
345
 
242
346
  The framework ships a docker-compose at `voltro/test/docker-compose.yml` that brings up postgres + mysql + mariadb + mssql on distinct ports so per-dialect tests can run side-by-side and the dev fixture never clashes with your starter postgres on `:5432`:
@@ -378,6 +482,8 @@ The framework wraps `pg` (`node-postgres`) via Effect's typed connection layer.
378
482
  | `PG_MAX_CONNECTIONS` | `10` | pool ceiling. Tune up for high concurrency; CPU-bound workloads rarely benefit past ~2× cores. |
379
483
  | `PG_SSL` | unset | `'require'` (TLS without certificate verification — what production deployments behind RDS / Cloud SQL / Supabase want) / `'disable'` (force plaintext). Any other value — including libpq's `'prefer'`, which node-postgres cannot express (the driver has no TLS-then-plaintext fallback) — fails at boot instead of silently downgrading to plaintext. A `?sslmode=require\|disable` query on `DB_URL` works too; an explicit `PG_SSL` wins when both are set. |
380
484
 
485
+ `PG_SSL` is read by **every** command that opens a connection — `voltro dev`, `voltro serve`, `voltro start`, `voltro migrate` and every `voltro db …` subcommand — so TLS is not something one command negotiates and the next one skips.
486
+
381
487
  ## CDC — LISTEN/NOTIFY
382
488
 
383
489
  Every table gets an AFTER trigger that emits a `framework_changes` notification on each insert / update / delete. The dispatcher LISTENs on that channel once per process and fans out to subscribers in-memory.
@@ -409,10 +515,63 @@ Trade-offs of the LISTEN/NOTIFY path:
409
515
  - ✅ Sub-frame latency (60fps UIs feel real-time).
410
516
  - ✅ Zero polling load even on idle tables.
411
517
  - ❌ Requires a long-lived connection per process. Connection-pooled deploys must use a sidecar listener or pgbouncer in session mode.
412
- - ❌ NOTIFY payloads are capped at 8KB. The trigger sends the full row images (`row_to_json(OLD)`/`row_to_json(NEW)`) so subscribers get the pre/post values directly; when a wide row would exceed the cap the trigger drops the body and sends an ID-only notification (the subscriber re-queries), so a very wide reactive row degrades gracefully rather than failing.
518
+ - ❌ NOTIFY payloads are capped at 8000 bytes. The trigger sends the full row images (`row_to_json(OLD)`/`row_to_json(NEW)`) so subscribers get the pre/post values directly; a wide row that would exceed the cap falls back to a key-only notification. See the delivery guarantee below — the fallback is not free, and it is not the same for a subscription as it is for a tap.
413
519
 
414
520
  Set `CDC=0` to disable + force the inline-emit path (single-process only, no cross-process fan-out). Useful for tests + single-binary deploys.
415
521
 
522
+ ### Oversized rows — what is guaranteed
523
+
524
+ A row whose JSON image exceeds ~8000 bytes (a document, a large `json()` column,
525
+ an embedded array) cannot travel in a NOTIFY payload. The trigger keeps the
526
+ change and the **primary key**, drops the images, and the CDC consumer re-reads
527
+ the row from the database before the event reaches anything. What each consumer
528
+ gets:
529
+
530
+ | Change | What is delivered | `event.oversized` |
531
+ |---|---|---|
532
+ | insert / update | the row **re-read from the database** | `'rehydrated'` |
533
+ | delete | the **primary key only** — a tombstone | `'tombstone'` |
534
+ | key missing, re-read failed, or the row is already gone | both images null | `'unrecovered'` |
535
+
536
+ Read the marker before you treat an image as a snapshot. Three limits are real
537
+ and cannot be engineered away:
538
+
539
+ - **A re-read returns the row as it is NOW.** If a second write lands between
540
+ the change and the re-read, this event carries the newer state — and the
541
+ second change delivers it again. The stream is convergent, not
542
+ point-in-time. Postgres keeps no copy of an image the transport dropped.
543
+ - **`old` is null on an oversized update, and pk-only on an oversized delete.**
544
+ There is nowhere to read a pre-image from. A tombstone is enough to REMOVE the
545
+ row from a search index, an analytics mirror or a CDC stream; it is not a
546
+ record of what the row contained, and `@voltro/plugin-versioning` writes
547
+ `data: null` for one rather than a fabricated empty snapshot.
548
+ - **`'unrecovered'` means the content is gone.** No retry can bring it back —
549
+ it was never delivered. Subscriptions are unaffected (they re-query); taps
550
+ miss that row until the next write to it or a re-seed.
551
+
552
+ Every fallback is counted as `voltro_cdc_oversized_total{outcome=…}` (scrapeable
553
+ via `@voltro/plugin-prometheus` at `/metrics`, or `GET
554
+ /_voltro/inspect/metrics`), the first one per table is logged at `warn`, and
555
+ every `unrecovered` one is logged at `error`. **Alert on
556
+ `outcome="unrecovered"`** — a non-zero rate means the deployment is losing
557
+ changes for its taps.
558
+
559
+ Tunables (options on the postgres store, or environment):
560
+
561
+ | Env | Default | Meaning |
562
+ |---|---|---|
563
+ | `VOLTRO_CDC_REHYDRATE_TIMEOUT_MS` | `5000` | total budget for recovering one oversized change. The LISTEN consumer is serial, so this also bounds how long one oversized row can hold up the change stream. |
564
+ | `VOLTRO_CDC_REHYDRATE_RETRIES` | `2` | re-reads after the first attempt, inside that budget. |
565
+
566
+ The re-read is issued against the schema the write landed in, so it is correct
567
+ under namespace (schema-per-tenant) isolation.
568
+
569
+ **This lives in the database, so it has to be applied.** The trigger function is
570
+ DDL: a database created before this shipped still carries the old body, which
571
+ drops the key and makes every oversized change `'unrecovered'`. `voltro db
572
+ apply` replaces it (`voltro dev` reports it at boot as trigger drift, and the
573
+ `error` log line names the same remedy).
574
+
416
575
  ## Workflow cluster
417
576
 
418
577
  `@effect/cluster`'s `SqlRunnerStorage` uses `pg_advisory_lock(key)` to claim shard ownership. The framework wires this transparently — set `DB_DIALECT=postgres` + provide a SqlClient layer and `workflowEngineLayer({ runnerStorage: 'sql' })` does the rest.
@@ -504,9 +663,25 @@ DB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… DB_DATABASE=…
504
663
  # at migration time.
505
664
  ```
506
665
 
666
+ ## TLS — honoured, or refused
667
+
668
+ Ask for TLS in the connection URL:
669
+
670
+ ```sh
671
+ DB_URL=mysql://app:app@db.internal:3306/app?sslmode=require # TLS, certificate not verified
672
+ DB_URL=mysql://app:app@db.internal:3306/app?ssl=true # the provider-style alias — same thing
673
+ DB_URL=mysql://app:app@db.internal:3306/app?sslmode=disable # explicit plaintext
674
+ ```
675
+
676
+ `require` maps to mysql2's `{ rejectUnauthorized: false }` — the connection is **encrypted, the server certificate is not authenticated**. That is the mode managed providers expect, and it is the same meaning `?sslmode=require` has on the postgres dialect.
677
+
678
+ **Anything else fails at boot**, on purpose: `prefer`, `allow`, `verify-ca`, `verify-full`, a CA-profile name, `?ssl=yes`. mysql2 either sends an SSLRequest or it does not, so `prefer` is not expressible; and a verification mode cannot round-trip through the cross-dialect boolean `ssl` field, so honouring the word would mean quietly giving you something weaker than it names. A boot failure is the correct answer to a TLS request that cannot be satisfied.
679
+
680
+ > **This changed.** Before this release the mysql/mariadb dialect had **no TLS path at all** — `?ssl=true` was parsed off the URL and discarded, and the connection went out in plaintext with no warning. If your URL carries `?ssl=` or `?sslmode=`, that request is now real: verify your server accepts TLS before deploying.
681
+
507
682
  ## Driver: `@effect/sql-mysql2`
508
683
 
509
- Wraps the `mysql2` driver (Node.js mysql client). The framework's `MysqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10).
684
+ Wraps the `mysql2` driver (Node.js mysql client). The framework's `MysqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10), with a 10 s bound on **establishing** a connection. Waiting for a busy one is **not** bounded: mysql2 has no timer for that phase, and the length ceiling it does offer (`ConnectionConfig.acquireQueueLimit`) is opt-in because mysql2 rejects past the limit synchronously — which turns any zero-delay acquire retry into an event-loop-starving spin. See the [acquire-timeout table](../dialects) for the full reasoning before you set it.
510
685
 
511
686
  The driver returns BOOLEAN as `0|1` (TINYINT(1) is MySQL's underlying type). The framework's `decodeRowsFromSchema` post-processor converts back to `true|false` for any column the schema registry declares as `boolean()`. Without this, every reactive subscription opening on a BOOLEAN column would see `1` instead of `true` and fail framework Schema validators.
512
687
 
@@ -709,6 +884,8 @@ makeMysqlStore({ sqlLayer, variant: 'mariadb', changeStrategy: 'cdc', cdcConfig
709
884
 
710
885
  The variant flows through to per-operation getters (`supportsInsertReturning`, `supportsDeleteReturning`, `supportsUpdateReturning`) and to the JSON-agg compiler's dialect branch.
711
886
 
887
+ TLS and the pool bounds are shared with MySQL, driver-for-driver: `?sslmode=require` / `?ssl=true` encrypts (certificate not verified), an unsupported mode fails at boot rather than falling back to plaintext, and the acquire wait is bounded by queue length because mysql2 has no time-based bound for it. See [MySQL → TLS](./mysql) and the [acquire-timeout table](../dialects).
888
+
712
889
  ## `UPDATE … RETURNING` does NOT exist — anywhere
713
890
 
714
891
  Despite MariaDB's broad RETURNING support — `INSERT … RETURNING *` since 10.5, `DELETE … RETURNING *` since 10.0 — there is **no UPDATE … RETURNING in any MariaDB version**. The framework's first attempt at supportsReturning treated the whole RETURNING family as one flag and emitted `UPDATE … RETURNING *` on mariadb, which fails with a parse error.
@@ -914,9 +1091,21 @@ DB_URL=mssql://sa:<password>@localhost:11433/voltro_test
914
1091
 
915
1092
  The framework's docker-compose at `voltro/test/docker-compose.yml` brings up MSSQL on `:11433` with an `mssql-init` sidecar that runs `CREATE DATABASE voltro_test` once the server is healthy. mssql doesn't have a `docker-entrypoint-initdb.d` equivalent, so the framework synthesizes one.
916
1093
 
1094
+ ## TLS — honoured, or refused
1095
+
1096
+ ```sh
1097
+ DB_URL=mssql://sa:<password>@sql.internal:1433/app?sslmode=require # encrypt, don't verify the certificate
1098
+ DB_URL=mssql://sa:<password>@sql.internal:1433/app?encrypt=1 # tedious' own spelling — same thing
1099
+ DB_URL=mssql://sa:<password>@sql.internal:1433/app?sslmode=disable # explicit plaintext
1100
+ ```
1101
+
1102
+ `require` maps to tedious `encrypt: true` + `trustServerCertificate: true` — encrypted, server certificate not authenticated, which is what lets a Developer-edition instance's self-signed certificate work. Unsupported modes (`prefer`, `verify-ca`, `verify-full`, `?encrypt=yes`) **fail at boot** rather than connecting with something weaker than they name.
1103
+
1104
+ > **This changed.** `@effect/sql-mssql` defaults `encrypt` to **false** (it overrides tedious' own `true`), and the framework never read the `ssl` config or the URL query — so every mssql session was plaintext, while a hard-coded `trustServerCertificate: true` made the configuration look TLS-aware. Unset still means plaintext (unchanged); a TLS request in the URL is now real.
1105
+
917
1106
  ## Driver: `@effect/sql-mssql`
918
1107
 
919
- Wraps `tedious` (the Node.js TDS driver). The framework's `MssqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10).
1108
+ Wraps `tedious` (the Node.js TDS driver). The framework's `MssqlStore` opens a connection pool sized via `DB_MAX_CONNECTIONS` (default 10), with a 10 s bound on establishing a connection (`ConnectionConfig.acquireTimeoutMs`). Waiting for a BUSY pooled connection is not bounded here — the client's pool exposes no such knob; see the [acquire-timeout table](../dialects) for what each dialect can enforce.
920
1109
 
921
1110
  Tedious returns BIT as proper boolean (✓) and NVARCHAR as string. JSON columns are NVARCHAR(MAX) under the hood — driver returns the raw string. The framework's `decodeRowsFromSchema` JSON.parses any column declared as `json()` in the schema registry; without this, every reactive subscription reading a JSON column would see strings and fail framework Schema validators.
922
1111
 
@@ -47,6 +47,43 @@ await ctx.store.transactional(async (tx) => {
47
47
 
48
48
  `transactional()` **must not be nested** — a nested invocation throws to surface a design mistake. Use a single top-level `transactional()` per unit of work.
49
49
 
50
+ ## Typed errors survive a transaction
51
+
52
+ A `Data.TaggedError` thrown inside a transaction reaches the caller — and the browser client — **as itself**: `_tag`, payload and prototype intact, so a mutation's declared `error:` union still matches.
53
+
54
+ ```ts
55
+ import { Data } from 'effect'
56
+
57
+ class NoteNotFound extends Data.TaggedError('NoteNotFound')<{ noteId: string }> {}
58
+
59
+ // apps/api/mutations/notes.rename.mutation.ts — auto-transactional
60
+ export default async (input, ctx) => {
61
+ const note = await ctx.store.query({ table: 'notes', /* … */ })
62
+ if (!note) throw new NoteNotFound({ noteId: input.id }) // arrives typed on the client
63
+ // …
64
+ }
65
+ ```
66
+
67
+ On the client:
68
+
69
+ ```ts
70
+ const res = await client.notes.rename({ id, title })
71
+ if (res.error?._tag === 'NoteNotFound') { /* this branch fires */ }
72
+ ```
73
+
74
+ This holds on **every** dialect and on **both** tenancy topologies — shared-schema and namespace isolation — because all four dialect stores and both postgres entry points run through one shared transaction bracket. Never match on `error.message` to identify a transaction failure; the `_tag` is the contract.
75
+
76
+ ## Automatic retry on transient conflicts
77
+
78
+ A transaction that fails with a transient contention error is retried automatically: exponential backoff from 10 ms, up to 3 retries (4 attempts total). What counts as transient is per-dialect — postgres `serialization_failure` (40001) and `deadlock_detected` (40P01), mysql/mariadb deadlock and lock-wait timeout, mssql deadlock victim (1205), sqlite `SQLITE_BUSY`/`SQLITE_LOCKED`, and Turso's MVCC write-write conflict.
79
+
80
+ Two properties worth knowing:
81
+
82
+ - **A conflict raised by the COMMIT itself is retried too.** Under `SERIALIZABLE`, the engine can only detect some conflicts at commit time — those are caught and replayed like any other.
83
+ - **Each attempt gets a fresh transaction and a fresh event buffer.** A retried attempt's queued ChangeEvents are discarded with it, so subscribers see exactly one event set: the winning attempt's.
84
+
85
+ Because the body can run more than once, keep `transactional()` bodies **idempotent** — no counters incremented in JS, no external calls (see the anti-patterns below).
86
+
50
87
  ## Optimistic concurrency
51
88
 
52
89
  The fluent update builder carries an `.expectVersion(n)` guard: the update only matches rows whose `version` equals `n`, and throws `OptimisticLockError` when none match (the row was concurrently changed). Add a `version` column to the table to use it.
@@ -134,6 +171,37 @@ array is a no-op that returns `[]`.
134
171
  see each new row's delta — identical to N single inserts. Inside a
135
172
  `transactional()` the per-row events queue until commit and drop on a throw.
136
173
 
174
+ ### Large arrays are chunked for you — and stay all-or-nothing
175
+
176
+ Every engine caps what ONE statement may carry, and the caps are far apart:
177
+
178
+ | Dialect | Bind parameters per statement | Rows per `VALUES` |
179
+ | --- | --- | --- |
180
+ | postgres | 65 535 | — |
181
+ | mysql / mariadb | 65 535 | — |
182
+ | **mssql** | **2 098** | **1 000** |
183
+ | sqlite / turso | 32 766 | — |
184
+
185
+ `INSERT … VALUES` binds one parameter per **column per row**, so the row limit
186
+ is `floor(cap / columns)` — a 12-column table caps at 5 461 rows on postgres and
187
+ at **174** on mssql. Past that the driver refuses with its own error about a
188
+ limit you never chose.
189
+
190
+ `insertMany` splits the array for you at that boundary. Two properties are
191
+ guaranteed:
192
+
193
+ - **A fitting array is still ONE statement.** Nothing changes for the normal
194
+ case — no extra round-trips, no behaviour difference.
195
+ - **A chunked insert is still all-or-nothing.** When the call is not already
196
+ inside a `transactional()`, the chunks run in one transaction the framework
197
+ opens, so a failure in the last chunk rolls back the earlier ones. Without
198
+ that, chunking would quietly add partial-success-on-failure to a call that
199
+ never had it.
200
+
201
+ There is nothing to configure. If a SINGLE row is wider than the cap (a
202
+ 3 000-column table on mssql) the engine's own error is what you get — a row
203
+ cannot be split.
204
+
137
205
  ## `upsert` — insert or update on conflict
138
206
 
139
207
  The 90% case: "make this row exist with these values; if it already
@@ -300,9 +300,9 @@ cause.
300
300
  | `VOLTRO_STORAGE_SECRET` | optional (`@voltro/plugin-storage`) | Signs private-file grant tokens; falls back to the session secret if unset |
301
301
  | `AI_PROVIDER` | when using `@voltro/ai` | `anthropic` / `openai` / `mock` |
302
302
  | `AI_API_KEY` | with `AI_PROVIDER` | Provider's API key |
303
- | `SSR_CACHE` | optional | `memory` (default) or `postgres` (needs `PG_*` set too) for the ISR cache |
303
+ | `SSR_CACHE` | optional | `memory` (default) or `postgres` for the ISR cache. `postgres` needs a database in the WEB process's env (`DB_URL` / `DB_HOST` / `PG_HOST`) without one it aborts the boot in production rather than falling back to memory |
304
304
  | `VOLTRO_API_ORIGIN` / `VOLTRO_API_ORIGIN_<NAME>` | split web/api deploy with SSR pages | The api's internal origin the WEB pod uses for SSR `ctx.query` (`http://api.<ns>.svc.cluster.local`). Per-api `_<NAME>` (name upper-cased) wins over the shared one and over `serverUrl`. Never sent to the browser |
305
- | `PORT` | optional | App's listen port (overrides `app.config.ts.port`) |
305
+ | `PORT` | optional | App's listen port. Outranks `--port` and `app.config.ts` `port:` — a platform that assigns a port sets this one, so it has to win. Unset, the app binds its declared `port:`, then 4000 (api) / 5173 (web). |
306
306
  | `VOLTRO_INSPECT` | optional | `off` to disable `_voltro/inspect/*` in prod |
307
307
  | `VOLTRO_INSPECT_TOKEN` | recommended | Bearer token guard on inspect endpoints |
308
308
  | `VOLTRO_DASHBOARD_APPS` | dashboard only | JSON `[{name?,url,token?}]` — target apps + inspect tokens the deployed DevTools dashboard shows (served at runtime from `/api/dashboard/config`) |
@@ -833,6 +833,8 @@ VOLTRO_SESSION_SECRET_PREVIOUS=<old> # still verifies live cookies
833
833
 
834
834
  Keep `_PREVIOUS` in place for one session-TTL window, then drop it. Existing cookies verify against `previous` until they naturally expire — no live session is invalidated.
835
835
 
836
+ > **If you embed the framework's middleware yourself** rather than booting through `voltro serve`, there is no boot gate to catch a missing secret. In that case a presented `voltro:session` cookie that cannot be verified now logs, once per process and at error level, that no credential-expiry bound is being imposed — so realtime subscriptions on that connection will not be cut off when the session expires. It is a diagnostic, not a refusal: a stale `voltro:session` cookie from another app on the same host is a normal thing for a browser to carry, and failing the request would turn that into a denial of service.
837
+
836
838
  ## 2. Kubernetes health probes
837
839
 
838
840
  `voltro serve` exposes two unauthenticated endpoints, both handled **before** any rate-limit interceptor:
@@ -1002,12 +1004,21 @@ indexed query instead of scanning every table). Force a full re-introspect with
1002
1004
 
1003
1005
  ## 4. Request limits & DoS
1004
1006
 
1005
- `POST /rpc` (the buffered JSON endpoint the SSR loaders use) rejects an over-`Content-Length` body with **413** *before* buffering it into memory:
1007
+ `POST /rpc` (the buffered JSON endpoint the SSR loaders use) is capped, and the cap is enforced **as bytes arrive**:
1006
1008
 
1007
1009
  ```sh
1008
1010
  VOLTRO_MAX_RPC_BODY_BYTES=8388608 # default 8 MiB
1009
1011
  ```
1010
1012
 
1013
+ A declared `Content-Length` over the cap is refused up front, so an honest client
1014
+ gets its **413** without uploading anything — a courtesy, not the enforcement,
1015
+ since a `Transfer-Encoding: chunked` body declares no length at all. The byte
1016
+ counter is the enforcement: it stops accumulating the moment the running total
1017
+ crosses the limit, drains the rest of the upload rather than dropping the
1018
+ connection, and answers **413**. Both shapes therefore end in the same status
1019
+ code, and the refusal is logged under the `voltro:security` scope with the cap
1020
+ and the byte count at which the server stopped reading.
1021
+
1011
1022
  Scope: this guards the `/rpc` JSON path only. File uploads ride separate storage routes with their own `limits.maxBytes`, and WebSocket frames are capped by the `ws` library default (100 MiB).
1012
1023
 
1013
1024
  **Put per-IP rate limiting and the primary body-size cap at the ingress** — that is the correct layer: it holds per-IP state and works across replicas, which an in-process limit can't.
@@ -1020,6 +1031,51 @@ nginx.ingress.kubernetes.io/limit-rps: "20"
1020
1031
 
1021
1032
  For **app-level** throttling (per-subject / per-tenant, e.g. an expensive action), use `@voltro/plugin-ratelimit` and the plugin `onHttpRequest` interceptor seam. It complements the ingress cap — it does not replace it.
1022
1033
 
1034
+ **There is no rate limit in the box.** The body cap above is the only request guard the runtime applies by default; `@voltro/plugin-ratelimit` is opt-in, so an app that has not installed and configured it has no per-IP, per-API-key or per-tenant cap on `/rpc` at all. The one on-by-default throttle anywhere in the framework is `plugin-auth`'s [brute-force lockout](/docs/authentication/passwords#brute-force-lockout), which covers sign-in credential attempts and nothing else. Treat the ingress limit as required, not as belt-and-braces.
1035
+
1036
+ ### Per-IP limits need a trusted proxy
1037
+
1038
+ The address the runtime rate-limits, geo-blocks and audits by is
1039
+ `socket.remoteAddress` — **not** `x-forwarded-for`, which any client can write.
1040
+ Behind an ingress that means every request counts against the proxy's address —
1041
+ and every `sessions.ipAddress` row records the proxy — until you declare the hop:
1042
+
1043
+ ```ts
1044
+ // app.config.ts
1045
+ export default {
1046
+ security: {
1047
+ trustedProxies: ['private'], // or: ['loopback'] · ['10.0.0.0/8'] · ['2'] · ['*']
1048
+ },
1049
+ }
1050
+ ```
1051
+
1052
+ The same setting decides whether `x-forwarded-proto` is believed, which is what
1053
+ lets the runtime emit HSTS behind a TLS-terminating load balancer. Override it
1054
+ on a running deployment with `VOLTRO_TRUSTED_PROXIES=private` (comma-separated).
1055
+
1056
+ ### Cross-site protection and its allowlist
1057
+
1058
+ Every state-changing request — `POST /rpc`, the `/ws` upgrade, every REST route
1059
+ projected from a `publicApi:` mutation, everything in `apiConfig.restRoutes`,
1060
+ and `POST /v1/api-keys` — refuses a browser request whose `Origin` is neither
1061
+ the `Host` it was sent to nor an allowlisted origin. **A split web/api
1062
+ deployment must declare its web origin** or the browser gets a 403 on every
1063
+ mutation, REST write and socket:
1064
+
1065
+ ```ts
1066
+ // app.config.ts
1067
+ export default {
1068
+ security: {
1069
+ allowedOrigins: ['https://app.example.com'],
1070
+ },
1071
+ }
1072
+ ```
1073
+
1074
+ The environment override is `VOLTRO_ALLOWED_ORIGINS` (comma-separated).
1075
+ Server-to-server callers (SSR loaders, mobile SDKs, other services) send no
1076
+ `Origin` and are unaffected. Full detail in
1077
+ [Security](/docs/security/overview#cross-site-requests-are-refused).
1078
+
1023
1079
  ## 5. Multi-tenant isolation
1024
1080
 
1025
1081
  Tables carrying the `tenant()` mixin are auto-scoped to the request's tenant — nothing more to do there. The gap is the **anonymous** request that matches no auth strategy: by default it resolves to a tenant-less anonymous Subject that can read any non-`tenant()` table across the DB.
@@ -1079,6 +1135,12 @@ sink, stops accepting new connections while **finishing already-accepted
1079
1135
  requests**, and closes the SQL connection pool **last** (waiting for in-flight
1080
1136
  transactions). Verified under concurrent load — accepted requests complete.
1081
1137
 
1138
+ The **transactional-outbox worker** is part of that sequence: its poll timer and
1139
+ its change subscription are released, and a delivery already in flight is
1140
+ awaited, before the pool closes. An outbox row that was still pending is not
1141
+ lost — it is durable, and the next process's first pass picks it up, which is
1142
+ one of the three reasons that poll exists.
1143
+
1082
1144
  **But the app cannot drain a rolling update by itself.** The runtime
1083
1145
  (`NodeRuntime`) owns the `SIGTERM` signal and closes the HTTP listener promptly —
1084
1146
  so a request that *arrives* during shutdown is refused. Failing readiness from
@@ -1315,6 +1377,10 @@ Lower the lease for **faster failover**, at the cost of **false-positive reclaim
1315
1377
  - [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
1316
1378
  - [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
1317
1379
  - [ ] `VOLTRO_MAX_RPC_BODY_BYTES` sane; ingress caps body size + per-IP rate
1380
+ - [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
1381
+ - [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
1382
+ - [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
1383
+ - [ ] Every `*.webhook.tsx` declares its verification, and each signature-verified one has its `VOLTRO_WEBHOOK_SECRET_<ID>`
1318
1384
  - [ ] `auth.anonymousTenantRequired: true` (unless the app serves anonymous public data)
1319
1385
  - [ ] `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_SERVICE_NAME` pointed at your collector
1320
1386
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors