@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.
- package/CHANGELOG.md +2006 -0
- package/THIRD-PARTY-NOTICES.md +1 -29
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-BICVGnEC.js +2 -0
- package/dist/{apiBuild-C4uEbs3s.js → apiBuild-DZF_f0_I.js} +46 -46
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CE7Ca9gm.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Dv8r9tGO.js +231 -0
- package/dist/checkCommand-YZDmkAGd.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-CNXKlcvD.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-DC6w2tNZ.js +30 -0
- package/dist/codemodRunner-DRRqXR74.js +5243 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-gutsz-Ac.js +808 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-3YG8p-UA.js +25 -0
- package/dist/dataCommand-qL0r7fPO.js +535 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-B6X0FZbq.js +1621 -0
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-5ficNnvF.js → dev-CerMd0mW.js} +3155 -3110
- package/dist/dev-CoG-ZPx8.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-BvqGBwNG.js +2 -0
- package/dist/{checkCommand-dm7OHtPt.js → doctorCommand-CnDXQxfa.js} +520 -1278
- package/dist/dormancyCommand-Dszo57d6.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
- package/dist/envCommand-DPgdV1Bq.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-DHpkgjgH.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
- package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
- package/dist/generateCommand-oibemh97.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-BJw9nLUR.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
- package/dist/inspect-C_T_WGvl.js +2 -0
- package/dist/inspectCmd-Bppy-GGw.js +224 -0
- package/dist/inspectFetch-Cm8_wVvp.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-CCca3KRZ.js +260 -0
- package/dist/manifestBuild-ChsKAhmn.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-CBwOt_iV.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CRxalXTs.js +91 -0
- package/dist/scheduleCmd--jksTrf6.js +69 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-B_5mHQS2.js → sdkgen-CYJscZC7.js} +111 -209
- package/dist/seedRunner-TFHHiToI.js +329 -0
- package/dist/serveCommand-B_isw7q4.js +1647 -0
- package/dist/serveCommand-DOvbgRnQ.js +2 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-9LiUOfES.js +1087 -0
- package/dist/start-B-9Nsp-S.js +3 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-CkEZQrtt.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-Bkptutss.js +585 -0
- package/dist/updateCommand-us1_hdIC.js +2 -0
- package/dist/{inspectMetrics-BqO4E9G0.js → webDev-CBYvPqQr.js} +1006 -1567
- package/dist/webDev-Cg-fFiyd2.js +2 -0
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-CID96Rga.js +267 -0
- package/dist/workflowsCmd-D1VTmLMY.js +608 -0
- package/package.json +193 -18
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +22 -5
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +265 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +794 -50
- package/templates/agent-docs/data.md +550 -11
- package/templates/agent-docs/database/migrations.md +174 -25
- package/templates/agent-docs/database/misc.md +193 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +5 -2
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +156 -4
- package/templates/agent-docs/introduction.md +87 -16
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +95 -20
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +161 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +1 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +157 -6
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +200 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +14 -1
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +449 -2
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +1722 -84
- package/templates/agent-docs/workflows.md +130 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-OJEjtwcn.js +0 -2
- package/dist/checkCommand-CwMrzAgV.js +0 -2
- package/dist/commands-C0nEePif.js +0 -11457
- package/dist/dbCommand-By__Ev0R.js +0 -2
- package/dist/dbCommand-ifOMafuG.js +0 -1311
- package/dist/dev-rc3fwPSZ.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-PxMmn96o.js +0 -1578
- package/dist/start-D1-8eKrO.js +0 -1084
- /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
|
|
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
|
-
│
|
|
24
|
-
│
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
│
|
|
28
|
-
│
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
## What is NOT auto-decided: which tenant an insert claims
|
|
83
124
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|