@voltro/cli 0.32.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 (339) hide show
  1. package/CHANGELOG.md +2006 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -29
  3. package/bin/nodeEnvironment.d.mts +30 -0
  4. package/bin/nodeEnvironment.mjs +158 -0
  5. package/bin/voltro.mjs +69 -5
  6. package/dist/addCommand-BNeoeSxe.js +124 -0
  7. package/dist/addCommand-aXSQveak.js +2 -0
  8. package/dist/agentsMd-BTchIZku.js +2 -0
  9. package/dist/agentsMd-mhQMF1bx.js +254 -0
  10. package/dist/apiBuild-BICVGnEC.js +2 -0
  11. package/dist/{apiBuild-C4uEbs3s.js → apiBuild-DZF_f0_I.js} +46 -46
  12. package/dist/appPort-B_HpJ_ck.js +48 -0
  13. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  14. package/dist/baselineCommand-DIttzO8A.js +227 -0
  15. package/dist/bin.js +71 -28
  16. package/dist/build-CE7Ca9gm.js +711 -0
  17. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  18. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  19. package/dist/checkCommand-Dv8r9tGO.js +231 -0
  20. package/dist/checkCommand-YZDmkAGd.js +2 -0
  21. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  22. package/dist/cliError-BmdYnghb.js +10 -0
  23. package/dist/cliOutput-D1tSBoRM.js +15 -0
  24. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  25. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  26. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  27. package/dist/clusterCmd-CNXKlcvD.js +54 -0
  28. package/dist/codegen-CYM3Zqrf.js +605 -0
  29. package/dist/codegen-ChBi_hVa.js +2 -0
  30. package/dist/codegenCommand-DC6w2tNZ.js +30 -0
  31. package/dist/codemodRunner-DRRqXR74.js +5243 -0
  32. package/dist/commandRunner-BLAEFLjp.js +47 -0
  33. package/dist/commands-gutsz-Ac.js +808 -0
  34. package/dist/connectionConfig-UFlIEiys.js +66 -0
  35. package/dist/dashboardCommand-3YG8p-UA.js +25 -0
  36. package/dist/dataCommand-qL0r7fPO.js +535 -0
  37. package/dist/dataProfile-dW-PsfLB.js +15 -0
  38. package/dist/dbCommand-B6X0FZbq.js +1621 -0
  39. package/dist/dbCommand-CpdKLeQq.js +2 -0
  40. package/dist/{dev-5ficNnvF.js → dev-CerMd0mW.js} +3155 -3110
  41. package/dist/dev-CoG-ZPx8.js +3 -0
  42. package/dist/devActivity-Dx_3nnGv.js +100 -0
  43. package/dist/devActivity.js +1 -1
  44. package/dist/dialectDriver-CgXnDfec.js +39 -0
  45. package/dist/discover-C9XKJDco.js +25 -0
  46. package/dist/doctorCommand-BvqGBwNG.js +2 -0
  47. package/dist/{checkCommand-dm7OHtPt.js → doctorCommand-CnDXQxfa.js} +520 -1278
  48. package/dist/dormancyCommand-Dszo57d6.js +69 -0
  49. package/dist/e2eCmd-BRabZww-.js +147 -0
  50. package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
  51. package/dist/envCommand-DPgdV1Bq.js +60 -0
  52. package/dist/evalCommand-6RUfPen4.js +118 -0
  53. package/dist/evolveCommand-DHpkgjgH.js +281 -0
  54. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  55. package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
  56. package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
  57. package/dist/generateCommand-oibemh97.js +147 -0
  58. package/dist/index.d.ts +45 -0
  59. package/dist/index.js +4 -3
  60. package/dist/infoCommand-BJw9nLUR.js +60 -0
  61. package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
  62. package/dist/inspect-C_T_WGvl.js +2 -0
  63. package/dist/inspectCmd-Bppy-GGw.js +224 -0
  64. package/dist/inspectFetch-Cm8_wVvp.js +151 -0
  65. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  66. package/dist/loadEnv-D9nEOClM.js +44 -0
  67. package/dist/logFileSink-C_D2wRN1.js +105 -0
  68. package/dist/logsCmd-CCca3KRZ.js +260 -0
  69. package/dist/manifestBuild-ChsKAhmn.js +2 -0
  70. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
  71. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  72. package/dist/migrate-CBwOt_iV.js +83 -0
  73. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  74. package/dist/newCommand-COWOJ1_E.js +156 -0
  75. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  76. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  77. package/dist/pageConvention-cEiRxdab.js +5 -0
  78. package/dist/privacyCommand-C-Df56U_.js +146 -0
  79. package/dist/projectScaffold-DmzEKHib.js +2 -0
  80. package/dist/projectScaffold-LMMtaavR.js +814 -0
  81. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  82. package/dist/renderProfile-1OWWAAtx.js +81 -0
  83. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  84. package/dist/runtimeTrace-CRxalXTs.js +91 -0
  85. package/dist/scheduleCmd--jksTrf6.js +69 -0
  86. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  87. package/dist/schemaIr-UJybUUZW.js +103 -0
  88. package/dist/{sdkgen-B_5mHQS2.js → sdkgen-CYJscZC7.js} +111 -209
  89. package/dist/seedRunner-TFHHiToI.js +329 -0
  90. package/dist/serveCommand-B_isw7q4.js +1647 -0
  91. package/dist/serveCommand-DOvbgRnQ.js +2 -0
  92. package/dist/serveEntry.js +5 -5
  93. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  94. package/dist/start-9LiUOfES.js +1087 -0
  95. package/dist/start-B-9Nsp-S.js +3 -0
  96. package/dist/startEntry.js +2 -2
  97. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  98. package/dist/storageCommand-Co6NfLqN.js +42 -0
  99. package/dist/templates-De8IR5-c.js +102 -0
  100. package/dist/test-CI6iDsYc.js +115 -0
  101. package/dist/tracesCmd-CkEZQrtt.js +232 -0
  102. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  103. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  104. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  105. package/dist/updateCommand-Bkptutss.js +585 -0
  106. package/dist/updateCommand-us1_hdIC.js +2 -0
  107. package/dist/{inspectMetrics-BqO4E9G0.js → webDev-CBYvPqQr.js} +1006 -1567
  108. package/dist/webDev-Cg-fFiyd2.js +2 -0
  109. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  110. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  111. package/dist/webhooksCommand-CID96Rga.js +267 -0
  112. package/dist/workflowsCmd-D1VTmLMY.js +608 -0
  113. package/package.json +193 -18
  114. package/templates/AGENTS.core.md +58 -3
  115. package/templates/AGENTS.md +64 -7
  116. package/templates/agent-docs/_index.md +6 -4
  117. package/templates/agent-docs/_manifest.json +22 -5
  118. package/templates/agent-docs/ai.md +370 -0
  119. package/templates/agent-docs/authentication.md +265 -31
  120. package/templates/agent-docs/caching.md +6 -0
  121. package/templates/agent-docs/cli.md +794 -50
  122. package/templates/agent-docs/data.md +550 -11
  123. package/templates/agent-docs/database/migrations.md +174 -25
  124. package/templates/agent-docs/database/misc.md +193 -40
  125. package/templates/agent-docs/database/querying.md +19 -1
  126. package/templates/agent-docs/database/scaling.md +60 -0
  127. package/templates/agent-docs/database/schema.md +5 -2
  128. package/templates/agent-docs/database/seedsdialects.md +208 -19
  129. package/templates/agent-docs/database/transactions.md +68 -0
  130. package/templates/agent-docs/deployment.md +156 -4
  131. package/templates/agent-docs/introduction.md +87 -16
  132. package/templates/agent-docs/local-first-mobile.md +79 -4
  133. package/templates/agent-docs/multi-tenancy.md +95 -20
  134. package/templates/agent-docs/observability.md +58 -3
  135. package/templates/agent-docs/plugins/ai-flows.md +161 -2
  136. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  137. package/templates/agent-docs/plugins/audit.md +37 -1
  138. package/templates/agent-docs/plugins/auth-social.md +143 -0
  139. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  140. package/templates/agent-docs/plugins/auth.md +131 -6
  141. package/templates/agent-docs/plugins/billing.md +132 -15
  142. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  143. package/templates/agent-docs/plugins/clickhouse.md +1 -1
  144. package/templates/agent-docs/plugins/duckdb.md +1 -1
  145. package/templates/agent-docs/plugins/flags.md +132 -0
  146. package/templates/agent-docs/plugins/governance.md +105 -7
  147. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  148. package/templates/agent-docs/plugins/presence.md +13 -2
  149. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  150. package/templates/agent-docs/plugins/search.md +157 -6
  151. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  152. package/templates/agent-docs/plugins/webhooks.md +105 -0
  153. package/templates/agent-docs/plugins.md +200 -18
  154. package/templates/agent-docs/reference.md +60 -3
  155. package/templates/agent-docs/releases.md +1117 -0
  156. package/templates/agent-docs/routing.md +43 -25
  157. package/templates/agent-docs/scheduling.md +14 -1
  158. package/templates/agent-docs/schema-driven-ui.md +92 -12
  159. package/templates/agent-docs/security.md +449 -2
  160. package/templates/agent-docs/templates/apibackends.md +87 -18
  161. package/templates/agent-docs/templates/appshells.md +32 -14
  162. package/templates/agent-docs/templates/overview.md +13 -8
  163. package/templates/agent-docs/testing.md +211 -14
  164. package/templates/agent-docs/whats-new.md +1722 -84
  165. package/templates/agent-docs/workflows.md +130 -14
  166. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  167. package/templates/apps/api-ai/package.json +8 -7
  168. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  169. package/templates/apps/api-auth/package.json +9 -8
  170. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  171. package/templates/apps/api-backend/package.json +12 -8
  172. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  173. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  174. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  175. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  176. package/templates/apps/api-backend-deactivation/package.json +8 -7
  177. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  178. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  179. package/templates/apps/api-backend-mail/package.json +9 -8
  180. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  181. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  182. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  183. package/templates/apps/api-backend-mariadb/package.json +10 -9
  184. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  185. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  186. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  187. package/templates/apps/api-backend-sqlite/package.json +9 -8
  188. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  189. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  190. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  191. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  192. package/templates/apps/api-backend-storage/package.json +9 -8
  193. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  194. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  195. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  196. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  197. package/templates/apps/api-cms/app.config.ts +19 -0
  198. package/templates/apps/api-cms/authz.ts +63 -0
  199. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  200. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  201. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  202. package/templates/apps/api-cms/package.json +11 -10
  203. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  204. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  205. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  206. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  207. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  208. package/templates/apps/api-collab/package.json +9 -8
  209. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  210. package/templates/apps/api-data-advanced/package.json +9 -8
  211. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  212. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  213. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  214. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  215. package/templates/apps/api-durable/package.json +9 -8
  216. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  217. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  218. package/templates/apps/api-feature-flags/package.json +10 -9
  219. package/templates/apps/api-governance/README.md +30 -8
  220. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  221. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  222. package/templates/apps/api-governance/database/schema.ts +16 -4
  223. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  224. package/templates/apps/api-governance/package.json +9 -8
  225. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  226. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  227. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  228. package/templates/apps/api-kv/package.json +9 -8
  229. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  230. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  231. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  232. package/templates/apps/api-moderation/package.json +9 -8
  233. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  234. package/templates/apps/api-observability/package.json +9 -8
  235. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  236. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  237. package/templates/apps/api-ratelimit/package.json +9 -8
  238. package/templates/apps/api-rbac/package.json +9 -8
  239. package/templates/apps/api-rest/package.json +8 -7
  240. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  241. package/templates/apps/api-saas/package.json +12 -11
  242. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  243. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  244. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  245. package/templates/apps/api-saas-starter/authz.ts +75 -0
  246. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  247. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  248. package/templates/apps/api-saas-starter/package.json +15 -11
  249. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  250. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  251. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  252. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  253. package/templates/apps/api-search/package.json +9 -8
  254. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  255. package/templates/apps/api-status/README.md +10 -3
  256. package/templates/apps/api-status/app.config.ts +8 -3
  257. package/templates/apps/api-status/authz.ts +5 -3
  258. package/templates/apps/api-status/package.json +9 -8
  259. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  260. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  261. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  262. package/templates/apps/api-status/tests/status.test.ts +9 -1
  263. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  264. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  265. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  266. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  267. package/templates/apps/api-versioning/package.json +9 -8
  268. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  269. package/templates/apps/api-webhooks/package.json +10 -9
  270. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  271. package/templates/apps/changelog/package.json +8 -6
  272. package/templates/apps/edge-functions/package.json +2 -2
  273. package/templates/apps/frontend-admin/package.json +10 -8
  274. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  275. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  276. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  277. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  278. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  279. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  280. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  281. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  282. package/templates/apps/frontend-app/app.config.ts +4 -3
  283. package/templates/apps/frontend-app/package.json +11 -8
  284. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  285. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  286. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  287. package/templates/apps/frontend-auth/package.json +10 -8
  288. package/templates/apps/frontend-blank/package.json +9 -7
  289. package/templates/apps/frontend-cms/package.json +11 -9
  290. package/templates/apps/frontend-collab/package.json +12 -9
  291. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  292. package/templates/apps/frontend-contact/package.json +9 -7
  293. package/templates/apps/frontend-dashboard/package.json +9 -7
  294. package/templates/apps/frontend-docs/package.json +9 -7
  295. package/templates/apps/frontend-i18n/package.json +8 -6
  296. package/templates/apps/frontend-landing/package.json +9 -7
  297. package/templates/apps/frontend-portal/package.json +10 -8
  298. package/templates/apps/frontend-saas/app.config.ts +10 -6
  299. package/templates/apps/frontend-saas/package.json +10 -8
  300. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  301. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  302. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  303. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  304. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  305. package/templates/apps/frontend-spa/package.json +9 -7
  306. package/templates/apps/frontend-ssr/package.json +9 -7
  307. package/templates/apps/frontend-ssr-api/package.json +10 -8
  308. package/templates/apps/frontend-static-blog/package.json +8 -6
  309. package/templates/apps/frontend-status/package.json +10 -8
  310. package/templates/apps/mobile-app/README.md +1 -0
  311. package/templates/apps/mobile-app/package.json +4 -2
  312. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  313. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  314. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  315. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  316. package/templates/baselines/bare/.env.example +14 -0
  317. package/templates/baselines/bare/baseline.json +4 -4
  318. package/templates/baselines/compose/.env.example +14 -0
  319. package/templates/baselines/compose/README.md +1 -1
  320. package/templates/baselines/compose/baseline.json +5 -5
  321. package/templates/baselines/compose-mariadb/.env.example +14 -0
  322. package/templates/baselines/compose-mariadb/README.md +1 -1
  323. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  324. package/templates/baselines/helm/.env.example +14 -0
  325. package/templates/baselines/helm/baseline.json +4 -4
  326. package/dist/apiBuild-OJEjtwcn.js +0 -2
  327. package/dist/checkCommand-CwMrzAgV.js +0 -2
  328. package/dist/commands-C0nEePif.js +0 -11457
  329. package/dist/dbCommand-By__Ev0R.js +0 -2
  330. package/dist/dbCommand-ifOMafuG.js +0 -1311
  331. package/dist/dev-rc3fwPSZ.js +0 -3
  332. package/dist/devActivity-BhIu6ncs.js +0 -159
  333. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  334. package/dist/inspect-mmBuRXmy.js +0 -2
  335. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  336. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  337. package/dist/serveCommand-PxMmn96o.js +0 -1578
  338. package/dist/start-D1-8eKrO.js +0 -1084
  339. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -11,7 +11,7 @@
11
11
 
12
12
  _Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates._
13
13
 
14
- Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro treats it as a runtime primitive: drop the `tenant()` mixin on a table, and the framework guarantees cross-tenant data leakage is structurally impossible for **reads**. Writes get a typed `TenantMismatch` error if you forget the explicit check.
14
+ Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro treats it as a runtime primitive: drop the `tenant()` mixin on a table, and the framework makes cross-tenant access structurally impossible for **reads and writes alike** — including a write keyed by a row id that came straight from request input.
15
15
 
16
16
  ## The model
17
17
 
@@ -19,18 +19,27 @@ Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro t
19
19
  Subject (tenantId: 'acme')
20
20
 
21
21
 
22
- ┌─────────────────────────┐
23
- Queries (read) │ ← runtime AND-merges
24
- ctx.store.select(...) │ WHERE tenantId = subject.tenantId
25
- └─────────────────────────┘
26
- ┌─────────────────────────┐
27
- Mutations (write) │ ← you call
28
- ctx.store.insert(...) assertOwnTenant(input.tenantId, subject)
29
- └─────────────────────────┘
22
+ ┌──────────────────────────────┐
23
+ Reads │ ← AND-merged
24
+ │ select / query / subscribe │ WHERE tenantId = subject.tenantId
25
+ └──────────────────────────────┘
26
+ ┌──────────────────────────────┐
27
+ Inserts │ ← auto-stamped from the subject,
28
+ │ store.insert(...) refused when there is no tenant
29
+ └──────────────────────────────┘
30
+ ┌──────────────────────────────┐
31
+ │ Set-based writes │ ← AND-merged onto your WHERE
32
+ │ updateMany / deleteMany │ (same predicate as reads)
33
+ │ update(t).where(...) │
34
+ └──────────────────────────────┘
35
+ ┌──────────────────────────────┐
36
+ │ Keyed-by-id writes │ ← the row is RESOLVED inside your
37
+ │ store.update(t, id, patch) │ tenant first; TenantRowNotFound
38
+ │ store.delete(t, id) │ when it isn't there
39
+ └──────────────────────────────┘
30
40
  ```
31
41
 
32
- Reads are auto-scoped because the subject + table mixin contain enough information.
33
- Writes are NOT auto-scoped because the input gets to *propose* a tenant — your code decides whether to honour it (typical: never) or assert against the subject (typical: always).
42
+ Every path is enforced by the framework, not by remembering a helper. The keyed row was the last one that wasn't: `update(table, id, patch)` addressed the row by primary key alone, so a mutation that took an id from request input could write into another tenant with no error and nothing in the code to review.
34
43
 
35
44
  ## What's in this section
36
45
 
@@ -54,7 +63,11 @@ export const notes = table('notes', {
54
63
  Query (auto-scoped):
55
64
 
56
65
  ```tsx
57
- export const listNotes = defineQuery({ name: 'notes.list', input: Schema.Struct({}) })
66
+ export const listNotes = defineQuery({
67
+ name: 'notes.list',
68
+ guards: [{ scope: 'notes:read' }], // WHO may open it; tenant() decides WHICH rows
69
+ input: Schema.Struct({}),
70
+ })
58
71
  export default async (_input, ctx) => ctx.store.select('notes').all()
59
72
  // SQL: SELECT * FROM notes WHERE tenantId = $1 (with subject.tenantId)
60
73
  ```
@@ -66,6 +79,7 @@ import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
66
79
 
67
80
  export const createNote = defineMutation({
68
81
  name: 'notes.create',
82
+ guards: [{ scope: 'notes:write' }],
69
83
  input: Schema.Struct({ tenantId: Schema.String, title: Schema.String }),
70
84
  error: TenantMismatch,
71
85
  })
@@ -77,13 +91,40 @@ export default async (input, ctx) => {
77
91
 
78
92
  If a client posts `{ tenantId: 'their-tenant', title: 'hack' }` while their cookie's subject says `tenantId: 'acme'`, the mutation throws `TenantMismatch`. The audit log records it; the client sees a typed error variant.
79
93
 
80
- ## Why the asymmetry
94
+ **`guards:` and `tenant()` answer different questions, and both descriptors above need the first one.** `tenant()` decides **which rows** a call may touch; `guards:` decides **who may make the call at all** — and a wire-exposed procedure that declares neither `guards:`, `openAccess: '<reason>'` nor `internal: true` is refused at boot. Tenant scoping is not a substitute: it confines an *anonymous* caller to whatever tenant the request resolved to, which shapes the result rather than authorizing anybody. Get both, and a revoked membership also stops an open subscription mid-session, because guards are re-checked on every delivery. Full rules: [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
95
+
96
+ ## What a keyed write does now
97
+
98
+ A keyed write resolves its target row inside `subject.tenantId` before it writes. When there is no such row, it fails with `TenantRowNotFound` from `@voltro/runtime` — it does **not** return `null` / `false`:
99
+
100
+ ```ts
101
+ // notes.rename.mutation.server.ts — `input.id` comes from the client
102
+ export default async (input, ctx) => {
103
+ // Another tenant's note id → TenantRowNotFound. Nothing to remember.
104
+ return ctx.store.update('notes', input.id, { title: input.title })
105
+ }
106
+ ```
107
+
108
+ ```ts
109
+ import { TenantRowNotFound } from '@voltro/runtime'
110
+ // declare it to surface the refusal typed at the client
111
+ export default defineMutation({
112
+ name: 'notes.rename',
113
+ guards: [{ scope: 'notes:write' }],
114
+ input: RenameInput,
115
+ error: TenantRowNotFound,
116
+ })
117
+ ```
118
+
119
+ **The error is deliberately ambiguous, and that is the design.** It is raised identically whether the row does not exist at all or belongs to another tenant, and it carries nothing that separates the two. Reporting "forbidden" for a foreign row and "not found" for a missing one would turn every keyed write into a cross-tenant *existence oracle*: a caller walks ids and learns which are real in someone else's tenant. Failing loudly and identically gives your handler a signal to act on and gives an attacker one bit they already had — the id they themselves sent is not theirs.
120
+
121
+ The alternative — silently affecting zero rows — is worse than either. It reads to the handler as "the row is gone" rather than "you may not touch it", so a genuine isolation breach shows up as a confusing empty branch and never as a security signal.
81
122
 
82
- Reads always have a single answer: "what does this subject see?" → AND the tenant. Auto-scopable.
123
+ ## What is NOT auto-decided: which tenant an insert claims
83
124
 
84
- Writes have an open question: "which tenant should this go in?" The input gets to claim sometimes legitimately (impersonation by an admin, switching active tenant). Your code decides whether to honour the claim. The framework can't auto-enforce because legitimate exceptions exist.
125
+ The open question is only ever on the way IN. An insert that omits `tenantId` is stamped from the subject; an insert that *sets* one is not silently substituted, because a legitimate cross-tenant write exists (admin tooling, impersonation). That is where `assertOwnTenant` earns its place — it rejects a *claimed* `input.tenantId` that isn't the subject's, at the top of the executor and with a typed `TenantMismatch`. It is an ergonomic early check, no longer the thing standing between you and a cross-tenant write.
85
126
 
86
- Defaulting to "auto-reject mismatching writes" would still work for 99% of mutations + would make the 1% impossible. We picked "assert explicitly" instead slightly more code, full flexibility.
127
+ A genuine cross-tenant write runs as the system subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)) a subject with `tenantId: null`, for which every merge above is skipped by construction.
87
128
 
88
129
  ## Tenant scoping covers more than just the database
89
130
 
@@ -129,9 +170,25 @@ Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sa
129
170
 
130
171
  Database-per-tenant falls out of the same seam for free — only the namespace id differs; the mapping to a physical container is a per-dialect detail. Isolation is **physical**: it no longer depends on a predicate being present, so a query that forgets the tenant filter — or a table that never carried the `tenant()` mixin at all — still cannot read another tenant's rows.
131
172
 
132
- ### Postgres fast-path — `SET LOCAL search_path`
173
+ ### Postgres — reads are one statement, writes take a `SET LOCAL search_path` transaction
174
+
175
+ On postgres a namespaced **read** compiles the namespace straight into the identifier — `"tenant_<id>"."todos"` — and runs as a single statement outside any transaction. That is the same mechanism the other dialects have always used, and it is one round trip.
176
+
177
+ A namespaced **write** (and `raw()`) still runs inside a transaction whose first statement is `SET LOCAL search_path TO "tenant_<id>"`. Because it's `SET LOCAL` (transaction-scoped), the setting **resets at commit** — mandatory on a pooled connection, where a bare `SET search_path` would persist and leak into the next request that checks out the same connection. A write wants its transaction anyway; `raw()` executes SQL text you wrote, which the framework cannot qualify on your behalf.
133
178
 
134
- On postgres each request runs inside a transaction whose first statement is `SET LOCAL search_path TO "tenant_<id>"`. Because it's `SET LOCAL` (transaction-scoped), the setting **resets at commit** mandatory on a pooled connection, where a bare `SET search_path` would persist and leak into the next request that checks out the same connection. The other dialects qualify identifiers directly (`tenant_<id>.todos`).
179
+ Reads used to take the transaction too, which made every tenant read `BEGIN` + `SET LOCAL` + `SELECT` + `COMMIT` four round trips holding one pooled connection for all four. Measured against a local postgres, that cost **2.2×** a shared-schema read, and the same factor applied to how long the connection was held, so effective pool capacity under tenant isolation was materially lower than the pool size suggested. Qualifying the identifier also removes the leak surface rather than managing it: nothing is set on the connection, so there is nothing to reset.
180
+
181
+ One consequence worth knowing: an **eager** (`with:`) read under namespace isolation uses the portable multi-query walker rather than the single-roundtrip JSON aggregate, because the JSON-aggregate compiler does not qualify relation tables. That has always been true on mysql / mssql / sqlite; postgres now matches them. It shows up as `voltro_db_eager_fallback_total{reason="not-compilable"}` — see [Database metrics](/docs/observability/overview).
182
+
183
+ ### Same transaction guarantees as the shared schema
184
+
185
+ Writes and explicit `transactional()` blocks run inside a transaction, and it is worth stating explicitly what that transaction gives you — it is **exactly** what a shared-schema transaction gives you, with no exceptions:
186
+
187
+ - a typed error thrown inside it arrives typed (`_tag`, payload, prototype intact), so a mutation's declared `error:` union matches;
188
+ - a transient conflict (serialization failure / deadlock, including one raised at COMMIT) is retried with backoff;
189
+ - the caller's write attribution (`traceId` / `subjectId` / `procedure`) is carried onto every ChangeEvent the transaction produces.
190
+
191
+ There is one transaction bracket behind both topologies, so there is no "namespace mode is a bit different" caveat to remember. See [Transactions](/docs/database/transactions).
135
192
 
136
193
  ### Fail closed on a missing tenant
137
194
 
@@ -174,6 +231,7 @@ export const notes = table('notes', {
174
231
  2. **An auto-index** on `tenantId` (`indexes: [{ fields: ['tenantId'] }]`). The name is auto-generated as `<tableName>_tenantId_idx`.
175
232
  3. **Read scoping** — the runtime AND-merges `WHERE tenantId = ctx.subject.tenantId` into every subscription against this table.
176
233
  4. **Insert auto-fill** — when an insert's row payload omits `tenantId`, the runtime stamps it from the request subject.
234
+ 5. **Write scoping** — `updateMany` / `deleteMany` and the fluent `update(t).where(...)` / `delete(t).where(...)` builders get the same predicate AND-merged onto their `WHERE`, and a **keyed-by-id** write (`update(t, id, patch)`, `delete(t, id)`, `hardDelete`, `patchJson`) resolves its target row inside the caller's tenant before writing — see [What it does NOT do](#what-it-does-not-do).
177
235
 
178
236
  The mixin's stable id is `voltro/tenant`. The execution lives in the runtime's `wrapStoreWithMixinBehaviour` (write side) and the CLI's `applyTenantScope` (read side) — both key off that id. The mixin source is `voltro/packages/plugin-multitenancy/src/mixin.ts`.
179
237
 
@@ -214,9 +272,26 @@ If you DON'T pass `tenantId`, the runtime auto-fills it from `ctx.subject.tenant
214
272
 
215
273
  When you DO pass an explicit `tenantId` (an admin writing into another tenant), the framework does NOT silently substitute the subject's value — silent substitution is a footgun. Guard the write with `assertOwnTenant` (see below); a genuine cross-tenant write runs as the `system` subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)).
216
274
 
275
+ ## Keyed writes resolve inside your tenant
276
+
277
+ `ctx.store.update(table, id, patch)`, `delete(table, id)`, `hardDelete(table, id)` and `patchJson(table, id, ...)` address a row by primary key. On a `tenant()` table the runtime resolves that key **inside `subject.tenantId`** before writing, so an id that came straight from request input cannot reach another tenant's row:
278
+
279
+ ```ts
280
+ export default async (input, ctx) => {
281
+ // input.id is client-supplied. Another tenant's id → TenantRowNotFound.
282
+ return ctx.store.update('notes', input.id, { title: input.title })
283
+ }
284
+ ```
285
+
286
+ When the row is not in your tenant the call **fails** with `TenantRowNotFound` (`@voltro/runtime`) rather than returning `null` / `false`. Declare it in the descriptor's `error:` to surface it typed at the client.
287
+
288
+ The error is raised **identically** whether the row is missing or belongs to another tenant, and carries nothing that separates them — reporting the two differently would let a caller probe for row ids in other tenants. Do not try to recover the distinction; there is nothing on the wire to recover it from, on purpose.
289
+
290
+ Not affected: subjects with no tenant at all — a schedule firing, a resumed workflow, a `*.subscribe.ts` handler — still span tenants by design, and so does a write through the raw store.
291
+
217
292
  ## What it does NOT do
218
293
 
219
- - **Auto-guard writes.** Read scoping is automatic; mutations receive user-supplied input including `tenantId`. A client authenticated as tenant A can post `tenantId: 'B'` and the row lands in B without a guard. Call `assertOwnTenant` at the top of the executor:
294
+ - **Decide which tenant an insert claims.** An insert that omits `tenantId` is stamped from the subject, but one that *sets* it is not silently substituted a legitimate cross-tenant write exists. `assertOwnTenant` is the early, typed check for a handler that means to USE a claimed `input.tenantId`:
220
295
 
221
296
  ```ts
222
297
  import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
@@ -227,7 +302,7 @@ When you DO pass an explicit `tenantId` (an admin writing into another tenant),
227
302
  }
228
303
  ```
229
304
 
230
- Declare `error: TenantMismatch` on the mutation descriptor so the rpc layer surfaces the rejection typed.
305
+ Declare `error: TenantMismatch` on the mutation descriptor so the rpc layer surfaces the rejection typed. It checks a *claimed* `tenantId` — a mutation whose input carries none never reaches it, which is why it is no longer what stands between you and a cross-tenant write.
231
306
 
232
307
  - **Apply to raw SQL.** A hand-written `@effect/sql` query bypasses the mixin. Write the filter yourself.
233
308
 
@@ -71,7 +71,7 @@ Even with no exporter, `voltro dev` installs a **buffer-only** tracer — that's
71
71
 
72
72
  ## Metrics export
73
73
 
74
- Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
74
+ Separate from tracing, the framework records **metrics** into Effect's global `MetricRegistry` — one source of truth (`voltro_rpc_*`, `voltro_http_*`, `voltro_plugin_hook_*`, `voltro_subscription_*` + `voltro_subscriptions_active`, `voltro_db_*`, plus `effect_fiber_*` and any custom metric). Three ways to get them out:
75
75
 
76
76
  ```bash
77
77
  # OTLP metrics — the SAME OTEL endpoint that enables trace export also enables
@@ -94,6 +94,55 @@ Two pull/push consumers read the SAME registry, so they can't disagree:
94
94
  - **[`@voltro/plugin-datadog`](/docs/plugins/datadog)** — agentless push to Datadog's `/api/v2/series` (for setups without an Agent / OTLP collector). With an Agent, prefer pointing `OTEL_EXPORTER_OTLP_ENDPOINT` at it instead.
95
95
  - The dashboard **Metrics** panel reads the same snapshot via `GET /_voltro/inspect/metrics`.
96
96
 
97
+ ## Database metrics (`voltro_db_*`)
98
+
99
+ Every dialect store emits the same five series, so a dashboard built against one
100
+ database keeps working after a migration to another. The labels are `dialect`
101
+ (`postgres` · `mysql` · `mariadb` · `mssql` · `sqlite` · `turso`) and `op`
102
+ (`select` · `insert` · `update` · `delete` · `upsert` · `raw` · `transaction`
103
+ · `ddl`).
104
+
105
+ | Series | Type | What it answers |
106
+ | --- | --- | --- |
107
+ | `voltro_db_queries_total` | counter | Query rate, split by operation kind. |
108
+ | `voltro_db_query_duration_seconds` | histogram | p50/p95/p99 per operation — `histogram_quantile` over the buckets. |
109
+ | `voltro_db_errors_total` | counter | Statement failure rate. |
110
+ | `voltro_db_operations_in_flight` | gauge | Concurrency the framework is holding right now. |
111
+ | `voltro_db_eager_fallback_total` | counter | Eager loads that dropped off the single-roundtrip fast path. |
112
+
113
+ **No table name is ever a label.** Table names grow with your schema, and a
114
+ label that grows with the schema is how a scrape target falls over. The table
115
+ appears in the log line instead.
116
+
117
+ **`voltro_db_operations_in_flight` is not the driver's pool queue.** It counts
118
+ operations the framework currently has in flight, which is an upper bound on the
119
+ connections it holds — the same number on every dialect. Your driver's own
120
+ `waiting` count is not exposed. In practice you alert on this gauge sitting near
121
+ your pool size *together with* the duration histogram's tail growing: a starved
122
+ pool shows up as acquire time inside the query timing.
123
+
124
+ ### `voltro_db_eager_fallback_total` — the one to alert on
125
+
126
+ An `eager:` query normally compiles to **one** round trip (a JSON aggregate).
127
+ When it can't, the framework silently uses the portable multi-query walker
128
+ instead — correct, and one round trip per relation level, on every call. That is
129
+ a permanent per-query cliff with no error attached to it, which is why it is
130
+ counted. The `reason` label separates the two very different cases:
131
+
132
+ - `not-compilable` — the query shape can never take the fast path (an
133
+ unregistered relation, an ambiguous inferred foreign key, or an eager read
134
+ under physical tenant isolation). Steady state. Worth knowing about, not worth
135
+ paging on.
136
+ - `execute-failed` — the fast path compiled, **ran, and threw**, so the query
137
+ paid for both paths. This is the one to alert on. It usually means a database
138
+ or driver upgrade changed something under the JSON-aggregate query.
139
+
140
+ Both also log: a `warn` the first time a given table and reason are seen, then
141
+ again at most every 5 minutes while it persists (`VOLTRO_DB_EAGER_FALLBACK_WARN_INTERVAL_MS`,
142
+ `0` = once only). The counter is never rate-limited — the log line answers "is
143
+ this happening now", the counter answers "has this been happening since the
144
+ deploy three weeks ago".
145
+
97
146
  ## Routing traces to a vendor
98
147
 
99
148
  The env-driven OTLP path above ships traces to any OTLP/HTTP collector. For **deep, opt-in vendor integration** — install one plugin, get traces + errors + logs all correlated by the same `traceId`, zero `OTEL_*` env — a plugin can contribute to the framework's tracer directly via `contributeObservability`:
@@ -122,7 +171,11 @@ Inside a mutation / action / workflow executor, just use Effect:
122
171
  ```typescript
123
172
  import { Effect } from 'effect'
124
173
 
125
- export const myAction = defineAction({ ... })
174
+ export const myAction = defineAction({
175
+ name: 'reports.rebuild',
176
+ guards: [{ scope: 'reports:write' }],
177
+ /* input, output */
178
+ })
126
179
 
127
180
  export default (input, ctx) =>
128
181
  Effect.gen(function* () {
@@ -172,7 +225,9 @@ client.mutation.placeOrder traceId=4bf92f35…
172
225
  The in-memory ring always keeps EVERY span for live debugging. SEPARATELY, on postgres, the framework can durably mirror a SUBSET of spans into `_voltro_traces` so the in-app Traces dashboard survives restarts. This is a **dev convenience** — at real volume durable tracing belongs in an **OTLP backend (Tempo / Honeycomb / Datadog), not your OLTP postgres** (set `OTEL_EXPORTER_OTLP_ENDPOINT`). A row-per-span firehose into postgres is what turned this table into ~85% of a production DB. So persistence is **environment-aware + fully configurable**:
173
226
 
174
227
  - **Default OFF in production, `interesting` in dev.** `voltro dev` (not `NODE_ENV=production`) persists only "interesting" spans. `voltro serve` / `voltro start` default `NODE_ENV=production` when it's unset, so they persist NOTHING and **`_voltro_traces` is not even created** — use OTLP in prod. An explicit `NODE_ENV` is never overridden.
175
- - **`VOLTRO_TRACING_PERSIST`** = `off` | `errors` | `interesting` | `all` (overrides the env default everywhere). `interesting` = error + slow + per-trace root spans, MINUS the high-volume subscription delivery (snapshot/delta) spans. `errors` = error spans only. `all` = every non-delivery span. `off` = no persistence and no table.
228
+ - **`VOLTRO_TRACING_PERSIST`** = `off` | `errors` | `interesting` | `all` (overrides the env default everywhere). `interesting` = error + slow + per-trace root spans, MINUS the high-volume subscription delivery (snapshot/delta) spans, MINUS the framework's own background-task reads. `errors` = error spans only. `all` = every non-delivery span. `off` = no persistence and no table.
229
+
230
+ **Why background tasks are excluded from "slow".** The framework polls four of its own tables on a timer (`_voltro_schedule_claims`, `_voltro_workflow_pending`, `_voltro_workflow_pauses`, `_voltro_ai_inferences`). Those reads are slow exactly when the database is under pressure — which is when persisting them costs the most. A consumer measured the loop closing on itself: `_voltro_traces` at 476 571 rows / 335 MB, writing ~11 INSERTs/s onto the same 15-slot pooler the app read through, 99 % of it framework poller spans. Pool pressure makes the spans slow, slow spans are "interesting", persisting them costs pool. They are still kept when they ERROR, and `all` mode still keeps everything. A request-path framework table like `_voltro_api_keys` is NOT excluded — a slow lookup there is a real user waiting.
176
231
  - **`VOLTRO_TRACING_SLOW_MS`** (default `500`) — the "slow" threshold used by `interesting`.
177
232
  - **`VOLTRO_TRACING_PERSIST_DELIVERY`** (default off) — also persist the subscription delivery spans (the firehose; rarely wanted).
178
233
  - **`VOLTRO_TRACING_SAMPLE`** (`0`–`1`, default `1`) — per-*trace* sampling (a kept trace keeps all its eligible spans; errors are never sampled out). `=0` with no explicit mode means off.
@@ -41,6 +41,32 @@ import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
41
41
  export default { plugins: [aiFlowsPlugin()] } // contributes _voltro_ai_flows + _voltro_ai_flow_runs
42
42
  ```
43
43
 
44
+ Three tunables, all with defaults — set them when the defaults don't match your
45
+ process:
46
+
47
+ ```ts
48
+ // app.config.ts
49
+ import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
50
+
51
+ export default {
52
+ plugins: [
53
+ aiFlowsPlugin({
54
+ // How long a `human` step parks before the run fails. Default 7 days;
55
+ // `0` = wait forever. A flow's `humanTimeoutMs` and a step's own
56
+ // `timeoutMs` both outrank this.
57
+ humanReviewTimeoutMs: 14 * 24 * 60 * 60 * 1000,
58
+ // How long a FINISHED run is kept. Default 90 days.
59
+ runsTtlMs: 180 * 24 * 60 * 60 * 1000,
60
+ // Ceiling on rows one `/flows` or `/runs` inspect call returns.
61
+ inspectPageMax: 200,
62
+ }),
63
+ ],
64
+ }
65
+ ```
66
+
67
+ Each also has a deploy-time env override, for an operator who can't edit source:
68
+ `VOLTRO_AI_FLOW_HUMAN_REVIEW_TIMEOUT_HOURS` and `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`.
69
+
44
70
  ## Author a flow (code-first)
45
71
 
46
72
  ```ts
@@ -65,10 +91,39 @@ Each step writes `context[outputKey]`; later steps reference it as `{{outputKey}
65
91
 
66
92
  - **`agent`** — delegate to a sub-agent (its system prompt + model), text-only.
67
93
  - **`generate`** — `text` / `image` / `video` / `audio` via `@voltro/ai`.
68
- - **`structured`** — a JSON object.
94
+ - **`structured`** — a JSON object, validated against the step's JSON Schema.
69
95
  - **`note`** — the interpolated prompt itself is the output (no model call).
70
96
  - **`human`** — pause for review (`approve` / `choice` / `text`).
71
97
 
98
+ ### `structured` — the schema is real
99
+
100
+ A `structured` step's `schema` is a JSON Schema, and it is **both** what the model
101
+ is told to emit and what the result is validated against:
102
+
103
+ ```ts
104
+ import { flowStep } from '@voltro/plugin-ai-flows'
105
+
106
+ flowStep.structured({
107
+ prompt: 'Extract the invoice fields from: {{document}}',
108
+ outputKey: 'invoice',
109
+ schema: {
110
+ type: 'object',
111
+ required: ['total', 'currency'],
112
+ properties: {
113
+ total: { type: 'number', description: 'Gross total' },
114
+ currency: { type: 'string', enum: ['EUR', 'USD'] },
115
+ lines: { type: 'array', items: { type: 'string' } },
116
+ },
117
+ },
118
+ })
119
+ ```
120
+
121
+ Objects (with `required`), arrays, the four scalars, `enum`, `const`, both
122
+ nullability spellings and `anyOf` / `oneOf` unions are modelled;
123
+ `description` / `title` ride along as annotations. A construct the adapter does
124
+ not model (`$ref`, `allOf`, …) degrades to "unknown" for **that node** rather
125
+ than failing the step, and a step with no `schema` behaves as it always did.
126
+
72
127
  ## Human-in-the-loop
73
128
 
74
129
  A `human` step sets the run to `waiting` and **parks the workflow without holding
@@ -76,6 +131,40 @@ a worker** (the durable suspend). The user answers via `respondToFlow`; a reject
76
131
  ends the run cancelled, otherwise the answer lands in `context` and the run
77
132
  resumes. Thousands of runs can wait on review for days at no runtime cost.
78
133
 
134
+ **Several reviews in one flow are independent.** Each human step parks on its own
135
+ durable signal, derived from that step's position in the plan the run's journal
136
+ pinned at step 0 — so the identity is the same on every replay, and answering the
137
+ first review cannot resolve the second. `respondToFlow` reads the parked step off
138
+ the run row's live timeline and addresses that step, which is why the UI never
139
+ has to track a step index; it returns the `stepIndex` it answered.
140
+
141
+ ### The park is bounded, and the bound is yours
142
+
143
+ An approval left over a weekend used to fail the whole flow: the park inherited
144
+ `@voltro/workflow`'s 24-hour default, which no flow author chose or could change.
145
+ It resolves most-specific-first now — **step → flow → plugin option → env →
146
+ 7 days** — and `0` at any level means *wait forever* (the park is slot-free, so
147
+ an unbounded wait costs no worker):
148
+
149
+ ```ts
150
+ import { defineFlow, flowStep } from '@voltro/plugin-ai-flows'
151
+
152
+ defineFlow({
153
+ name: 'contract-review',
154
+ // Flow-wide default for every human step that sets none.
155
+ humanTimeoutMs: 30 * 24 * 60 * 60 * 1000,
156
+ steps: [
157
+ flowStep.human({ reviewMode: 'approve', prompt: 'Legal sign-off?' }),
158
+ // This one is the CFO's, and it waits as long as it takes.
159
+ flowStep.human({ reviewMode: 'approve', prompt: 'Budget sign-off?', timeoutMs: 0 }),
160
+ ],
161
+ })
162
+ ```
163
+
164
+ A flow authored as DATA carries the same setting in `_voltro_ai_flows.humanTimeoutMs`.
165
+ When the bound does expire, the run row is written `status: 'failed'` naming the
166
+ bound that elapsed — it no longer dies with the row still reading `waiting`.
167
+
79
168
  ## Chaining & cadence
80
169
 
81
170
  - **`chainTo`** launches a follow-up flow on success (result ⊕ input → the child's
@@ -90,6 +179,58 @@ resumes. Thousands of runs can wait on review for days at no runtime cost.
90
179
  `costMicroUsd`, …) as it runs, and the client's subscription streams the timeline
91
180
  over CDC — no polling, no sockets.
92
181
 
182
+ ## Driving a flow from the UI
183
+
184
+ `@voltro/plugin-ai-flows/web` is the browser-safe half: launch → observe →
185
+ respond, without hand-rolling a subscription.
186
+
187
+ ```tsx
188
+ import { useFlowReview, useLaunchFlow } from '@voltro/plugin-ai-flows/web'
189
+
190
+ export function ArticleFlow({ runId }: { runId?: string }) {
191
+ const { launch, pending } = useLaunchFlow()
192
+ const { steps, status, awaitingReview, prompt, approve, reject, done } = useFlowReview(runId)
193
+
194
+ if (runId === undefined) {
195
+ return (
196
+ <button disabled={pending} onClick={() => launch({ flowRef: 'blog-article', input: { topic: 'Effect' } })}>
197
+ Start
198
+ </button>
199
+ )
200
+ }
201
+ return (
202
+ <div>
203
+ <ol>{steps.map((s) => <li key={s.id}>{s.title ?? s.type} — {s.status}</li>)}</ol>
204
+ {awaitingReview && (
205
+ <div>
206
+ <p>{prompt}</p>
207
+ <button onClick={() => approve(runId)}>Approve</button>
208
+ <button onClick={() => reject(runId)}>Reject</button>
209
+ </div>
210
+ )}
211
+ {done && <p>Finished: {status}</p>}
212
+ </div>
213
+ )
214
+ }
215
+ ```
216
+
217
+ The full set: `useLaunchFlow`, `useFlowRun` (the reactive run row projected into
218
+ a timeline — steps, status, the pending review, `done`), `useFlowRuns`,
219
+ `useFlows`, `useRetryFlow`, `useCancelFlow`, `useRespondToFlow`
220
+ (`approve` / `reject` / `choose` / `submitText`) and `useFlowReview`, which is
221
+ the whole review widget in one call.
222
+
223
+ **Every hook takes the tag set.** This plugin ships no fixed RPC routes — its
224
+ procedures are helpers you wire into your own thin rpc files (below) — so the
225
+ hooks default to `aiFlows.launch` / `.run` / `.respond` / … and accept an
226
+ override plus an `apiName`:
227
+
228
+ ```tsx
229
+ import { useFlowRun } from '@voltro/plugin-ai-flows/web'
230
+
231
+ const run = useFlowRun(runId, { apiName: 'admin', tags: { run: 'flows.oneRun' } })
232
+ ```
233
+
93
234
  ## Wiring (once per app)
94
235
 
95
236
  The engine runs as one durable workflow; the operations are server helpers (a
@@ -138,8 +279,26 @@ Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit histo
138
279
  planner), so a mid-run crash re-plans (`retry` = fresh run).
139
280
  - **Cadence + scale-to-zero**: `dormancy: 'sleep'` won't fire an in-process tick —
140
281
  use an external waker or `trigger: 'external'`.
282
+ - **Run retention**: `_voltro_ai_flow_runs` is bounded by the framework sweep at
283
+ **90 days** (`runsTtlMs` / `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`). Only **terminal**
284
+ runs are swept (`succeeded | failed | cancelled`) — a run parked on a human
285
+ review is live state, not history, and a plain time-TTL would delete pending
286
+ approvals. `_voltro_ai_flows` (the DEFINITIONS) is deliberately unbounded: its
287
+ size tracks how many flows a team writes, not traffic.
288
+
289
+ **Upgrading a live app:** the first sweep runs ~30 s after boot and deletes
290
+ every terminal run older than the TTL, in batches, until the backlog drains. An
291
+ app that has been running flows for more than 90 days loses that history at
292
+ once — set the env var (or `runsTtlMs`) BEFORE deploying if you need it. An
293
+ app's own `registerRetention` for the table also outranks the plugin's.
141
294
  - **Media retention**: not automatic — add a `*.cron.tsx` deleting old artifacts
142
- via `storage.delete(id)` (a plain retention sweep would orphan the blobs).
295
+ via `storage.delete(id)` (a plain retention sweep would orphan the blobs). Note
296
+ this interacts with the run TTL: a run's steps carry hosted URLs whose blobs
297
+ belong to the storage plugin, so deleting the row orphans them. Keep the run
298
+ TTL at or above your media-purge window, or purge by run id first.
299
+ - **Inspect page size**: `/flows` and `/runs` take a `?take=`, clamped by
300
+ `inspectPageMax` (default 200). It matters here more than usual — a run row
301
+ carries every step's full text output.
143
302
  - **Media tenant on resume**: `generateMedia` receives `run.tenantId`, read from
144
303
  the durable run row. Pin persistence to it (as above) rather than the caller
145
304
  subject — a resumed run executes under a tenant-less system subject, so reading
@@ -53,7 +53,7 @@ plugin's `_voltro_events` table is auto-merged into your schema, so
53
53
  | Option | Type | Default | Notes |
54
54
  |---|---|---|---|
55
55
  | `sqlClient` | `SqlClient.SqlClient` | the main DataStore's client | Override to land events in a SEPARATE database (e.g. a dedicated analytics Postgres while the app runs MySQL). You must create the events table there yourself. |
56
- | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data }`) so analytical queries can JOIN events against live user data. |
56
+ | `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data, version, is_deleted }`) so analytical queries can JOIN events against live user data. Deletes write a tombstone — filter `is_deleted = false`. |
57
57
  | `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
58
58
 
59
59
  ```ts
@@ -73,7 +73,7 @@ auditPlugin({
73
73
  // custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
74
74
  ```
75
75
 
76
- `'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns.
76
+ `'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence--the-hash-chain)).
77
77
 
78
78
  The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
79
79
 
@@ -176,6 +176,42 @@ const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit
176
176
 
177
177
  `auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
178
178
 
179
+ ### Tamper-evidence — the hash chain
180
+
181
+ Append-only is a convention, not a guarantee. An actor with `UPDATE` on the database could rewrite what a call did, or `DELETE` the row that recorded a refusal, and no read of the table would notice — an audit trail whose integrity rests on "nobody has database access" is exactly as trustworthy as the thing it exists to check.
182
+
183
+ Every row written by `sink: 'datastore'` therefore carries its position in a hash chain: `chainId`, `seq`, `prevHash` and `hash`, where `hash` covers the row's own content **and** the previous row's hash. Altering any row invalidates every row after it; removing one leaves a hole.
184
+
185
+ ```ts
186
+ import { verifyAuditChain } from '@voltro/plugin-audit'
187
+
188
+ const verdict = await verifyAuditChain(ctx.store)
189
+ // { ok, rowsChecked, unchainedRows, keyed, chains: [{ chainId, from, to, tip, prunedPrefix }], issues: [...] }
190
+ ```
191
+
192
+ `issues` names what does not add up, and the kinds are not interchangeable:
193
+
194
+ | Kind | Meaning |
195
+ |---|---|
196
+ | `tampered` | The row's content does not hash to its stored `hash`. A column was altered. |
197
+ | `broken-link` | The row's `prevHash` is not the previous row's `hash`. Reordering, or a row substituted for another. |
198
+ | `gap` | `seq` jumped. A row was deleted, or its write failed. |
199
+
200
+ A gap at the **start** of a chain is not an issue — the retention sweep prunes oldest-first, so a pruned prefix is reported as `prunedPrefix: true`. Rows written before chaining shipped carry no `hash` at all and are counted as `unchainedRows` rather than passed over silently.
201
+
202
+ #### The chain is per WRITER, and that is the concurrency answer
203
+
204
+ One global chain would need every insert to know the current tip — a serialization point across every process writing audit rows. Two replicas racing on one chain **fork**, and a fork is indistinguishable from tampering. A chain that breaks under normal operation is worse than no chain at all, because the first false positive is what teaches everyone to ignore the verifier. A per-tenant chain has the identical problem one level down.
205
+
206
+ So each process mints its own `chainId` at boot and allocates `seq`/`prevHash`/`hash` in a synchronous, `await`-free step — atomic against any number of concurrent events. What you get in exchange is stated plainly: N replicas produce N chains, so verification attests *"every chain is intact"*, not *"the log is complete"*.
207
+
208
+ #### Read the guarantee before you quote it
209
+
210
+ Unkeyed (the default), the chain detects any change that does **not** recompute it: a hand-run `UPDATE`, a botched migration, storage corruption, a script that scrubs one row. It does **not** stop an adversary who knows the scheme and rewrites the chain forward — SHA-256 is public, so with write access they can. Two things close that, both available:
211
+
212
+ - **`VOLTRO_AUDIT_CHAIN_SECRET`** — set it and the chain is HMAC-SHA256. An actor with the database but not the key cannot forge a link. There is no default value and nothing is minted for you; keep the key where the database is not.
213
+ - **Anchor the tips.** `verifyAuditChain` returns each chain's `tip`. Publish it on a schedule to somewhere append-only you do not control (an object-lock bucket, a log shipper, a compliance mailbox). This is also the *only* defence against tail truncation — deleting the newest N rows of a chain is undetectable from the table alone, for any hash chain.
214
+
179
215
  ### Retention, and the GDPR interaction
180
216
 
181
217
  `sink: 'datastore'` registers its own retention: **365 days by default**, tunable with `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. An append-only trail with no ceiling is the one that eventually takes the database down.