@voltro/cli 0.33.0 → 0.35.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 (343) hide show
  1. package/CHANGELOG.md +1968 -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-B8aoJvuw.js +2 -0
  10. package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
  11. package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
  12. package/dist/appGraph-zuMGKVYX.js +2 -0
  13. package/dist/appPort-B_HpJ_ck.js +48 -0
  14. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  15. package/dist/baselineCommand-DIttzO8A.js +227 -0
  16. package/dist/bin.js +71 -28
  17. package/dist/build-CD8K4XOr.js +711 -0
  18. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  19. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  20. package/dist/checkCommand-Ct9xkTrS.js +232 -0
  21. package/dist/checkCommand-DKpDLlqu.js +2 -0
  22. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  23. package/dist/cliError-BmdYnghb.js +10 -0
  24. package/dist/cliOutput-D1tSBoRM.js +15 -0
  25. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  26. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  27. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  28. package/dist/clusterCmd-D5wsCmA_.js +54 -0
  29. package/dist/codegen-CYM3Zqrf.js +605 -0
  30. package/dist/codegen-ChBi_hVa.js +2 -0
  31. package/dist/codegenCommand-C4YoQIc2.js +30 -0
  32. package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
  33. package/dist/commandRunner-BLAEFLjp.js +47 -0
  34. package/dist/commands-BE8E7zF3.js +816 -0
  35. package/dist/connectionConfig-UFlIEiys.js +66 -0
  36. package/dist/dashboardCommand-D7SgZGaN.js +25 -0
  37. package/dist/dataCommand-BhYwDgg-.js +537 -0
  38. package/dist/dataProfile-dW-PsfLB.js +15 -0
  39. package/dist/dbCommand-DS4b97Is.js +2 -0
  40. package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
  41. package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
  42. package/dist/dev-D2BikO7a.js +3 -0
  43. package/dist/devActivity-Dx_3nnGv.js +100 -0
  44. package/dist/devActivity.js +1 -1
  45. package/dist/dialectDriver-CgXnDfec.js +39 -0
  46. package/dist/discover-C9XKJDco.js +25 -0
  47. package/dist/doctorCommand-CM4Ch9C7.js +2 -0
  48. package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
  49. package/dist/dormancyCommand-QewYug_s.js +69 -0
  50. package/dist/e2eCmd-BRabZww-.js +147 -0
  51. package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
  52. package/dist/envCommand-CCGPRQY1.js +60 -0
  53. package/dist/evalCommand-6RUfPen4.js +118 -0
  54. package/dist/evolveCommand-CHsLCtDf.js +281 -0
  55. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  56. package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
  57. package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
  58. package/dist/generateCommand-DbgcUpGw.js +147 -0
  59. package/dist/index.d.ts +45 -0
  60. package/dist/index.js +4 -3
  61. package/dist/infoCommand-DwOgK1t6.js +60 -0
  62. package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
  63. package/dist/inspect-P4pxoMaV.js +2 -0
  64. package/dist/inspectCmd-EHFZ9yYu.js +224 -0
  65. package/dist/inspectFetch-EMuhTG_9.js +151 -0
  66. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  67. package/dist/loadEnv-D9nEOClM.js +44 -0
  68. package/dist/logFileSink-C_D2wRN1.js +105 -0
  69. package/dist/logsCmd-D36xK7Zu.js +260 -0
  70. package/dist/manifestBuild-COkJoyAr.js +2 -0
  71. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
  72. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  73. package/dist/migrate-BV7I-ZHZ.js +83 -0
  74. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  75. package/dist/newCommand-COWOJ1_E.js +156 -0
  76. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  77. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  78. package/dist/pageConvention-cEiRxdab.js +5 -0
  79. package/dist/privacyCommand-C-Df56U_.js +146 -0
  80. package/dist/probeCommand-CZfaaUOZ.js +122 -0
  81. package/dist/projectScaffold-DmzEKHib.js +2 -0
  82. package/dist/projectScaffold-LMMtaavR.js +814 -0
  83. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  84. package/dist/renderProfile-1OWWAAtx.js +81 -0
  85. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  86. package/dist/runtimeTrace-CH3eUiMw.js +91 -0
  87. package/dist/scheduleCmd-DQRu6BZC.js +149 -0
  88. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  89. package/dist/schemaIr-UJybUUZW.js +103 -0
  90. package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
  91. package/dist/seedRunner-ZmLSqNe2.js +333 -0
  92. package/dist/serveCommand-CbDHU6l-.js +2 -0
  93. package/dist/serveCommand-iwlUBNS1.js +1766 -0
  94. package/dist/serveEntry.js +5 -5
  95. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  96. package/dist/start-BgN62boB.js +3 -0
  97. package/dist/start-T4VesWiM.js +1087 -0
  98. package/dist/startEntry.js +2 -2
  99. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  100. package/dist/storageCommand-Co6NfLqN.js +42 -0
  101. package/dist/templates-De8IR5-c.js +102 -0
  102. package/dist/test-CI6iDsYc.js +115 -0
  103. package/dist/tracesCmd-DStmCJPi.js +232 -0
  104. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  105. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  106. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  107. package/dist/updateCommand-BlMZhWgO.js +2 -0
  108. package/dist/updateCommand-x0pI_x-B.js +585 -0
  109. package/dist/webDev-BcykISYQ2.js +2 -0
  110. package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
  111. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  112. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  113. package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
  114. package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
  115. package/package.json +209 -17
  116. package/templates/AGENTS.core.md +58 -3
  117. package/templates/AGENTS.md +64 -7
  118. package/templates/agent-docs/_index.md +6 -4
  119. package/templates/agent-docs/_manifest.json +23 -6
  120. package/templates/agent-docs/ai.md +370 -0
  121. package/templates/agent-docs/authentication.md +313 -31
  122. package/templates/agent-docs/caching.md +6 -0
  123. package/templates/agent-docs/cli.md +853 -50
  124. package/templates/agent-docs/data.md +608 -12
  125. package/templates/agent-docs/database/migrations.md +223 -25
  126. package/templates/agent-docs/database/misc.md +156 -40
  127. package/templates/agent-docs/database/querying.md +19 -1
  128. package/templates/agent-docs/database/scaling.md +60 -0
  129. package/templates/agent-docs/database/schema.md +1 -0
  130. package/templates/agent-docs/database/seedsdialects.md +208 -19
  131. package/templates/agent-docs/database/transactions.md +68 -0
  132. package/templates/agent-docs/deployment.md +284 -25
  133. package/templates/agent-docs/introduction.md +88 -17
  134. package/templates/agent-docs/local-first-mobile.md +79 -4
  135. package/templates/agent-docs/multi-tenancy.md +188 -42
  136. package/templates/agent-docs/observability.md +58 -3
  137. package/templates/agent-docs/plugins/ai-flows.md +247 -2
  138. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  139. package/templates/agent-docs/plugins/audit.md +37 -1
  140. package/templates/agent-docs/plugins/auth-social.md +143 -0
  141. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  142. package/templates/agent-docs/plugins/auth.md +131 -6
  143. package/templates/agent-docs/plugins/billing.md +132 -15
  144. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  145. package/templates/agent-docs/plugins/clickhouse.md +32 -1
  146. package/templates/agent-docs/plugins/duckdb.md +1 -1
  147. package/templates/agent-docs/plugins/flags.md +132 -0
  148. package/templates/agent-docs/plugins/governance.md +105 -7
  149. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  150. package/templates/agent-docs/plugins/presence.md +13 -2
  151. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  152. package/templates/agent-docs/plugins/search.md +162 -8
  153. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  154. package/templates/agent-docs/plugins/storage.md +11 -0
  155. package/templates/agent-docs/plugins/webhooks.md +105 -0
  156. package/templates/agent-docs/plugins.md +152 -18
  157. package/templates/agent-docs/reference.md +60 -3
  158. package/templates/agent-docs/releases.md +1117 -0
  159. package/templates/agent-docs/routing.md +43 -25
  160. package/templates/agent-docs/scheduling.md +27 -0
  161. package/templates/agent-docs/schema-driven-ui.md +92 -12
  162. package/templates/agent-docs/security.md +426 -0
  163. package/templates/agent-docs/templates/apibackends.md +87 -18
  164. package/templates/agent-docs/templates/appshells.md +32 -14
  165. package/templates/agent-docs/templates/overview.md +13 -8
  166. package/templates/agent-docs/testing.md +211 -14
  167. package/templates/agent-docs/whats-new.md +98 -136
  168. package/templates/agent-docs/workflows.md +231 -14
  169. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  170. package/templates/apps/api-ai/package.json +8 -7
  171. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  172. package/templates/apps/api-auth/package.json +9 -8
  173. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  174. package/templates/apps/api-backend/package.json +12 -8
  175. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  176. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  177. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  178. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  179. package/templates/apps/api-backend-deactivation/package.json +8 -7
  180. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  181. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  182. package/templates/apps/api-backend-mail/package.json +9 -8
  183. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  184. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  185. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  186. package/templates/apps/api-backend-mariadb/package.json +10 -9
  187. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  188. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  189. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  190. package/templates/apps/api-backend-sqlite/package.json +9 -8
  191. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  192. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  193. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  194. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  195. package/templates/apps/api-backend-storage/package.json +9 -8
  196. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  197. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  198. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  199. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  200. package/templates/apps/api-cms/app.config.ts +19 -0
  201. package/templates/apps/api-cms/authz.ts +63 -0
  202. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  203. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  204. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  205. package/templates/apps/api-cms/package.json +11 -10
  206. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  207. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  208. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  209. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  210. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  211. package/templates/apps/api-collab/package.json +9 -8
  212. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  213. package/templates/apps/api-data-advanced/package.json +9 -8
  214. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  215. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  216. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  217. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  218. package/templates/apps/api-durable/package.json +9 -8
  219. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  220. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  221. package/templates/apps/api-feature-flags/package.json +10 -9
  222. package/templates/apps/api-governance/README.md +30 -8
  223. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  224. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  225. package/templates/apps/api-governance/database/schema.ts +16 -4
  226. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  227. package/templates/apps/api-governance/package.json +9 -8
  228. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  229. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  230. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  231. package/templates/apps/api-kv/package.json +9 -8
  232. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  233. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  234. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  235. package/templates/apps/api-moderation/package.json +9 -8
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  237. package/templates/apps/api-observability/package.json +9 -8
  238. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  239. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  240. package/templates/apps/api-ratelimit/package.json +9 -8
  241. package/templates/apps/api-rbac/package.json +9 -8
  242. package/templates/apps/api-rest/package.json +8 -7
  243. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  244. package/templates/apps/api-saas/package.json +12 -11
  245. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  246. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  247. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  248. package/templates/apps/api-saas-starter/authz.ts +75 -0
  249. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  250. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  251. package/templates/apps/api-saas-starter/package.json +15 -11
  252. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  253. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  254. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  255. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  256. package/templates/apps/api-search/package.json +9 -8
  257. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  258. package/templates/apps/api-status/README.md +10 -3
  259. package/templates/apps/api-status/app.config.ts +8 -3
  260. package/templates/apps/api-status/authz.ts +5 -3
  261. package/templates/apps/api-status/package.json +9 -8
  262. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  263. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  264. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  265. package/templates/apps/api-status/tests/status.test.ts +9 -1
  266. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  267. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  268. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  269. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  270. package/templates/apps/api-versioning/package.json +9 -8
  271. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  272. package/templates/apps/api-webhooks/package.json +10 -9
  273. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  274. package/templates/apps/changelog/package.json +8 -6
  275. package/templates/apps/edge-functions/package.json +2 -2
  276. package/templates/apps/frontend-admin/package.json +10 -8
  277. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  278. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  279. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  280. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  281. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  282. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  283. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  284. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  285. package/templates/apps/frontend-app/app.config.ts +4 -3
  286. package/templates/apps/frontend-app/package.json +11 -8
  287. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  288. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  289. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  290. package/templates/apps/frontend-auth/package.json +10 -8
  291. package/templates/apps/frontend-blank/package.json +9 -7
  292. package/templates/apps/frontend-cms/package.json +11 -9
  293. package/templates/apps/frontend-collab/package.json +12 -9
  294. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  295. package/templates/apps/frontend-contact/package.json +9 -7
  296. package/templates/apps/frontend-dashboard/package.json +9 -7
  297. package/templates/apps/frontend-docs/package.json +9 -7
  298. package/templates/apps/frontend-i18n/package.json +8 -6
  299. package/templates/apps/frontend-landing/package.json +9 -7
  300. package/templates/apps/frontend-portal/package.json +10 -8
  301. package/templates/apps/frontend-saas/app.config.ts +10 -6
  302. package/templates/apps/frontend-saas/package.json +10 -8
  303. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  304. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  305. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  306. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  307. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  308. package/templates/apps/frontend-spa/package.json +9 -7
  309. package/templates/apps/frontend-ssr/package.json +9 -7
  310. package/templates/apps/frontend-ssr-api/package.json +10 -8
  311. package/templates/apps/frontend-static-blog/package.json +8 -6
  312. package/templates/apps/frontend-status/package.json +10 -8
  313. package/templates/apps/mobile-app/README.md +1 -0
  314. package/templates/apps/mobile-app/package.json +4 -2
  315. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  316. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  317. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  318. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  319. package/templates/baselines/bare/.env.example +14 -0
  320. package/templates/baselines/bare/baseline.json +4 -4
  321. package/templates/baselines/compose/.env.example +14 -0
  322. package/templates/baselines/compose/README.md +1 -1
  323. package/templates/baselines/compose/baseline.json +5 -5
  324. package/templates/baselines/compose-mariadb/.env.example +14 -0
  325. package/templates/baselines/compose-mariadb/README.md +1 -1
  326. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  327. package/templates/baselines/helm/.env.example +14 -0
  328. package/templates/baselines/helm/baseline.json +4 -4
  329. package/dist/apiBuild-C-x9YacA.js +0 -2
  330. package/dist/appGraph-CvQCte0z.js +0 -2
  331. package/dist/checkCommand-DRovTKza.js +0 -2
  332. package/dist/commands-CJfepbm4.js +0 -11541
  333. package/dist/dbCommand-b1gum4td.js +0 -2
  334. package/dist/dev-iiMtlkfs.js +0 -3
  335. package/dist/devActivity-BhIu6ncs.js +0 -159
  336. package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
  337. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  338. package/dist/inspect-mmBuRXmy.js +0 -2
  339. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  340. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  341. package/dist/serveCommand-DdaM4Hup.js +0 -1608
  342. package/dist/start-C0koT0UO.js +0 -1084
  343. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -23,11 +23,13 @@ imports it directly. The React wrappers live behind `@voltro/local-first/react`
23
23
  > [`crdtText()` database column](#the-crdttext-database-column) (with its
24
24
  > authoritative server-side merge on the write path), the
25
25
  > [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
26
- > transport, [presence/awareness](#presence--awareness) via `usePresence`,
27
- > [durable IndexedDB persistence](#durable-persistence), and the
26
+ > transport, [`useCrdtText`](#a-collaborative-text-field-usecrdttext) the React
27
+ > binding for a collaborative text field — [presence/awareness](#presence--awareness)
28
+ > via `usePresence`, [durable IndexedDB persistence](#durable-persistence), and the
28
29
  > [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is a thin
29
30
  > [runtime binding](#whats-shipped-vs-a-runtime-seam) to provisioned infra
30
- > (a broker at scale, a running app's transport) not un-built framework code.
31
+ > (a broker at scale) plus the two app-specific tags `useCrdtText` is pointed at
32
+ > not un-built framework code.
31
33
 
32
34
  ## CRDT text: `crdtText` + `mergeCrdtStates`
33
35
 
@@ -139,6 +141,69 @@ Everything below the two transport functions — the drain loop, retry/attempt
139
141
  counting, optimistic local merge, durable persistence — is in the client and
140
142
  tested against an in-memory dispatcher that mirrors the server's merge.
141
143
 
144
+ ## A collaborative text field: `useCrdtText`
145
+
146
+ `useCrdtText` is the React binding over that wire — one `crdtText()` cell, bound
147
+ to the mutation that writes it and the reactive query that streams it:
148
+
149
+ ```tsx
150
+ import { useMutation, useSubscription } from '@voltro/client'
151
+ import { useCrdtText } from '@voltro/local-first/react'
152
+
153
+ function BodyEditor({ id }: { id: string }) {
154
+ const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
155
+ const save = useMutation<{ id: string; body: Uint8Array }>('app', 'documents.setBody')
156
+
157
+ const body = useCrdtText({
158
+ cell: { table: 'documents', id, column: 'body' },
159
+ remote: row.data?.body ?? null, // what the server currently holds
160
+ push: (w) => save.mutate({ id: w.id, body: w.update }), // deliver a local edit
161
+ })
162
+
163
+ return (
164
+ <>
165
+ <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
166
+ {body.synced ? null : <em>saving… ({body.outstanding})</em>}
167
+ </>
168
+ )
169
+ }
170
+ ```
171
+
172
+ The hook owns one `SyncClient` per `(table, id, column)` cell — created and
173
+ closed with the component — re-renders on a local edit, an ack or incoming
174
+ merged state, and folds the streamed row back in. It returns `text`,
175
+ `insert(index, text)`, `delete(index, length)`, `setText(next)`, the encoded
176
+ `state`, `outstanding` / `synced`, and `setOnline`.
177
+
178
+ <Callout type="warn">
179
+ **`setText` is a span diff, and that is the whole point.** A `<textarea>` hands
180
+ you the entire new string, so the obvious binding is "clear the document,
181
+ insert the new text" — a delete-all/insert-all, which is exactly the
182
+ last-write-wins behaviour a CRDT is chosen to prevent: two people typing in
183
+ different paragraphs each erase the other's, and the text looks right on
184
+ whichever peer typed last. `useCrdtText` diffs the common prefix and suffix and
185
+ emits ONE delete plus ONE insert, so an edit outside the changed span survives.
186
+ The diff is exported as `crdtTextEdit(before, after)` if you drive the document
187
+ yourself.
188
+ </Callout>
189
+
190
+ Two things stay yours to name, because nothing can derive them: **which
191
+ mutation** writes the column and **which query** streams the row. Voltro
192
+ generates no per-table CRUD surface, so the hook takes those two as `push` and
193
+ `remote` — the same shape as `usePresence`'s injected channel. Everything under
194
+ them (client lifecycle, optimistic merge, offline queue, bounded retry, durable
195
+ persistence, the edit encoding) is framework code.
196
+
197
+ The descriptors on the other end declare the column as bytes-over-base64 —
198
+ `Uint8Array` in the handler, a base64 string on the wire:
199
+
200
+ ```ts
201
+ import { Schema } from 'effect'
202
+
203
+ // documents.byId output (and documents.setBody input)
204
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64)
205
+ ```
206
+
142
207
  ## Presence & awareness
143
208
 
144
209
  `usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
@@ -168,6 +233,16 @@ Redis/NATS at scale (both already shipped). `createInMemoryPresenceChannel()` is
168
233
  the local/test transport. Join/leave, announce-back discovery, cursor
169
234
  propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps.
170
235
 
236
+ > **Two different hooks share the name `usePresence`, and they are not
237
+ > interchangeable.** THIS one (`@voltro/local-first/react`) is peer-to-peer
238
+ > awareness over a pub/sub channel — `usePresence(roomId, self, { channel })` →
239
+ > `{ presence, others, setPresence }` — for high-frequency cursor/selection
240
+ > state that must never touch the database.
241
+ > [`@voltro/plugin-presence`](/docs/plugins/presence)'s is a server-backed
242
+ > roster — `usePresence(channel, options)` → the list of members whose heartbeat
243
+ > is fresh, plus a `useTyping` indicator, through the app's own rpc. Reach for
244
+ > the plugin for "who is here"; reach for this one for "where is their cursor".
245
+
171
246
  ## The offline sync queue
172
247
 
173
248
  `useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
@@ -252,7 +327,7 @@ code already speaks:
252
327
 
253
328
  | Runtime seam | What it binds | Why it's a binding, not code |
254
329
  | --- | --- | --- |
255
- | **Sync transport a running app** | The `SyncClient`'s `push`/`onRemoteState` to one app's `useMutation` + `useSubscription`. | The two functions are app-specific glue; the drain loop, merge, retry, and persistence all ship and are tested. |
330
+ | **Two app-specific tags** | Which mutation writes the `crdtText()` column, and which reactive query streams the row, in [`useCrdtText`](#a-collaborative-text-field-usecrdttext). | Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
256
331
  | **Presence channel → a broker at scale** | The `PresenceChannel` to a provisioned Redis/NATS broker. | It's a network hop over an already-shipped broker; the awareness logic ships and is tested over the in-memory channel. |
257
332
  | **wa-sqlite / Turso adapter** *(optional)* | A SQL durable adapter for cross-tab queries, behind `PersistenceAdapter`. | IndexedDB is the durable default today; a SQL backing is a sibling factory, nothing above it changes. |
258
333
 
@@ -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.
81
120
 
82
- Reads always have a single answer: "what does this subject see?" AND the tenant. Auto-scopable.
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.
83
122
 
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.
123
+ ## What is NOT auto-decided: which tenant an insert claims
85
124
 
86
- Defaulting to "auto-reject mismatching writes" would still work for 99% of mutations + would make the 1% impossible. We picked "assert explicitly" insteadslightly more code, full flexibility.
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.
126
+
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
 
@@ -117,6 +158,20 @@ export default {
117
158
 
118
159
  Or via env — `VOLTRO_TENANT_ISOLATION=namespace` — which overrides the config field. The same flag is read by `voltro dev` and `voltro serve`, so the topology can't drift between dev and prod.
119
160
 
161
+ ### Provisioning — a new tenant's first request creates its namespace
162
+
163
+ The namespace is provisioned **lazily, on first use**: a tenant nobody has seen
164
+ before gets its schema and tables created — and its
165
+ [`lifecycle: 'onTenantCreate'` seeds](/docs/data/queries) fired — the first time
166
+ a request touches its store, memoised per process afterwards. There is nothing
167
+ to pre-register, and a failed provision is retried on the next request rather
168
+ than cached.
169
+
170
+ Eager provisioning is **your** move, because only your app knows its tenants:
171
+ call `provisionTenantNamespace(tables, namespace, sqlLayer, dialect)` from a
172
+ seed or startup file over your own tenant table when you want the DDL paid at
173
+ deploy time instead of on a tenant's first request.
174
+
120
175
  ### One mechanism, per-dialect mapping
121
176
 
122
177
  Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sanitised-id>`, derived from `subject.tenantId` — mapped to each dialect's native physical container:
@@ -129,9 +184,25 @@ Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sa
129
184
 
130
185
  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
186
 
132
- ### Postgres fast-path — `SET LOCAL search_path`
187
+ ### Postgres — reads are one statement, writes take a `SET LOCAL search_path` transaction
188
+
189
+ 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.
190
+
191
+ 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
192
 
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`).
193
+ 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.
194
+
195
+ 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).
196
+
197
+ ### Same transaction guarantees as the shared schema
198
+
199
+ 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:
200
+
201
+ - a typed error thrown inside it arrives typed (`_tag`, payload, prototype intact), so a mutation's declared `error:` union matches;
202
+ - a transient conflict (serialization failure / deadlock, including one raised at COMMIT) is retried with backoff;
203
+ - the caller's write attribution (`traceId` / `subjectId` / `procedure`) is carried onto every ChangeEvent the transaction produces.
204
+
205
+ 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
206
 
136
207
  ### Fail closed on a missing tenant
137
208
 
@@ -174,6 +245,7 @@ export const notes = table('notes', {
174
245
  2. **An auto-index** on `tenantId` (`indexes: [{ fields: ['tenantId'] }]`). The name is auto-generated as `<tableName>_tenantId_idx`.
175
246
  3. **Read scoping** — the runtime AND-merges `WHERE tenantId = ctx.subject.tenantId` into every subscription against this table.
176
247
  4. **Insert auto-fill** — when an insert's row payload omits `tenantId`, the runtime stamps it from the request subject.
248
+ 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
249
 
178
250
  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
251
 
@@ -214,9 +286,26 @@ If you DON'T pass `tenantId`, the runtime auto-fills it from `ctx.subject.tenant
214
286
 
215
287
  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
288
 
289
+ ## Keyed writes resolve inside your tenant
290
+
291
+ `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:
292
+
293
+ ```ts
294
+ export default async (input, ctx) => {
295
+ // input.id is client-supplied. Another tenant's id → TenantRowNotFound.
296
+ return ctx.store.update('notes', input.id, { title: input.title })
297
+ }
298
+ ```
299
+
300
+ 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.
301
+
302
+ 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.
303
+
304
+ 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.
305
+
217
306
  ## What it does NOT do
218
307
 
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:
308
+ - **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
309
 
221
310
  ```ts
222
311
  import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
@@ -227,7 +316,7 @@ When you DO pass an explicit `tenantId` (an admin writing into another tenant),
227
316
  }
228
317
  ```
229
318
 
230
- Declare `error: TenantMismatch` on the mutation descriptor so the rpc layer surfaces the rejection typed.
319
+ 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
320
 
232
321
  - **Apply to raw SQL.** A hand-written `@effect/sql` query bypasses the mixin. Write the filter yourself.
233
322
 
@@ -582,43 +671,100 @@ tenant's data.
582
671
  > `TenantResidencyUnresolved`. Home region not served here → typed
583
672
  > `TenantRegionUnavailable`. Both fail closed; that's the whole point.
584
673
 
585
- ## Configure it
674
+ ## Declare it
675
+
676
+ Residency is declared in `app.config.ts` and wired by both boot paths — `voltro
677
+ dev` and `voltro serve` open one store per servable region and route every
678
+ request through it. There is nothing to call from your own code.
586
679
 
587
680
  ```ts
588
- import { setResidencyConfig } from '@voltro/database'
589
-
590
- setResidencyConfig({
591
- // tenant → home region (+ optional named connection for the home DB).
592
- homes: [
593
- { tenantId: 'acme', region: 'eu-west' },
594
- { tenantId: 'globex', region: 'us-east' },
595
- ],
596
- // The regions THIS deployment can actually serve (it holds their stores).
597
- servableRegions: ['us-east'],
681
+ export default defineApiApp({
682
+ tenancy: {
683
+ isolation: 'namespace',
684
+ residency: {
685
+ // The regions THIS deployment holds stores for.
686
+ servableRegions: ['eu-west'],
687
+ // region the NAME of the env var holding that region's database URL.
688
+ regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
689
+ // tenant home region. An array, or a function resolving one at boot.
690
+ homes: [
691
+ { tenantId: 'acme', region: 'eu-west' },
692
+ { tenantId: 'globex', region: 'us-east' },
693
+ ],
694
+ },
695
+ },
598
696
  })
599
697
  ```
600
698
 
601
- A tenant mapped to two different regions is rejected at config time (ambiguous
602
- routing), and `servableRegions` must be non-empty.
699
+ `regionUrlEnv` takes a variable NAME, not a URL: a connection string is a
700
+ secret and `app.config.ts` is committed. Everything else about a region's store
701
+ — pool bounds, TLS, `search_path`, statement timeouts — is inherited from the
702
+ primary connection, so a region cannot silently run with different limits than
703
+ the deployment it belongs to.
704
+
705
+ `homes` may be a function (`() => Promise<TenantHome[]>`) if the mapping lives
706
+ in your own table or control plane. It is resolved **once, at boot**: adding a
707
+ tenant home needs a restart. That is deliberate — the alternative is a cache
708
+ with a staleness window on a decision whose entire value is that it is never
709
+ wrong.
603
710
 
604
- ## Resolve + bind per request
711
+ ### It requires namespace isolation, and refuses to boot without it
605
712
 
606
- `bindResidentStore` picks the store for the subject's home region from the
607
- per-region handles this deployment holds failing closed if the home is
608
- unresolved or not served here:
713
+ `residency` without `isolation: 'namespace'` is a **boot refusal**, not a
714
+ warning. The region keeps regions apart; the namespace keeps tenants apart
715
+ inside a region. With only the first, every tenant in a region would share one
716
+ set of tables — the region boundary held and the tenant boundary dropped, which
717
+ looks like residency and is not.
718
+
719
+ The other boot refusals, all for the same reason (a deployment that looks like
720
+ it enforces residency and does not is worse than one that will not start):
721
+
722
+ - a servable region with no `regionUrlEnv` entry;
723
+ - a servable region whose env var is unset;
724
+ - an empty `servableRegions`;
725
+ - a tenant mapped to two different regions.
726
+
727
+ ## What a request gets
728
+
729
+ Every request resolves its subject's tenant → home region → **that region's
730
+ store**, and only then binds the tenant's namespace inside it. Residency picks
731
+ which database; the namespace picks which tenant's tables in it.
732
+
733
+ Three refusals, none of which falls back:
734
+
735
+ | Situation | Result |
736
+ |---|---|
737
+ | Subject has no resolvable tenant | `TenantResidencyUnresolved` |
738
+ | Tenant has no home mapped | `TenantResidencyUnresolved` |
739
+ | Tenant homed in a region this deployment does not serve | `TenantRegionUnavailable`, naming the region so a gateway can route it |
740
+
741
+ `ctx.storeForTenant(id)` resolves residency for **that** tenant, not the
742
+ caller's — so a handler acting on another tenant either reaches that tenant's
743
+ region or is refused. It is the seam background work must use: a schedule or a
744
+ workflow runs with no request and, under the system subject, no tenant, so
745
+ `ctx.store` on those paths is the primary store. A job that touches one tenant's
746
+ rows has to say which tenant, and `storeForTenant` is how it says so.
747
+
748
+ A transaction is never re-routed. When a mutation hands its transaction-scoped
749
+ store to a nested call, that store is used as given — it already went through
750
+ residency to exist, and moving writes off the connection holding the lock would
751
+ be a worse failure than the one residency prevents.
752
+
753
+ ## Driving it yourself
754
+
755
+ The resolvers are exported for a control plane that owns the mapping itself —
756
+ `setResidencyConfig`, `residentPlacement`, `bindResidentStore`. The declaration
757
+ above calls the first two for you; reach for them directly only if you are
758
+ building the region topology outside the framework.
609
759
 
610
760
  ```ts
611
761
  import { bindResidentStore } from '@voltro/database'
612
762
 
613
- // stores: ReadonlyMap<region, StoreHandle> — what THIS deployment wired.
763
+ // stores: ReadonlyMap<region, StoreHandle>
614
764
  const { store, placement } = bindResidentStore(subject, config, stores)
615
765
  // placement = { region, namespace, connectionKey? }
616
- // throws TenantRegionUnavailable for an EU-homed tenant on a US deployment.
617
766
  ```
618
767
 
619
- `residentPlacement(subject, config)` gives the placement alone (region +
620
- the tenant's namespace within that region's store) without needing the handles.
621
-
622
768
  ## Provision a new resident tenant
623
769
 
624
770
  `provisionResidentTenant` runs an injected provisioner against the tenant's HOME
@@ -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.