@voltro/cli 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1801 -0
- 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-h9VHtnlw.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-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
- 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-xGhRFFg2.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-C81QIkiL.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-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
- 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 +179 -17
- 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 +156 -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 +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +69 -3
- 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 +150 -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 +10 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- 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 +1700 -104
- 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-C-x9YacA.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.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-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -23,27 +23,52 @@ This guide gets you from zero to a running stack in under a minute.
|
|
|
23
23
|
|
|
24
24
|
## Scaffold a project
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
A Voltro monorepo is a pnpm workspace. `create-project` walks up from your current directory looking for `pnpm-workspace.yaml` — and **when there is none, it creates the workspace root right there** before scaffolding. So an empty directory is a perfectly good starting point:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
+
mkdir acme && cd acme
|
|
30
|
+
|
|
29
31
|
pnpx voltro create-project acme \
|
|
30
32
|
--api api-backend \
|
|
31
33
|
--web frontend-landing \
|
|
32
34
|
--port-range 5190-5199
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
You get:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
acme/
|
|
41
|
+
pnpm-workspace.yaml # the package globs + the install-script decisions
|
|
42
|
+
package.json # dev / build / test / typecheck (plain `pnpm -r` scripts)
|
|
43
|
+
.gitignore # incl. .env.local, where `voltro dev` mints your secrets
|
|
44
|
+
.git/ # unless you were already inside a repo
|
|
45
|
+
AGENTS.md + CLAUDE.md # the agent guide, seeded per project
|
|
46
|
+
apps/acme/
|
|
47
|
+
api/ # Voltro backend
|
|
48
|
+
web/ # landing page
|
|
49
|
+
project.json # the project's port range + app map
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The project name is kebab-cased (so `Acme` becomes `acme`). The `--port-range` is recorded in `project.json` so every new app added later gets a unique port without you thinking about it.
|
|
53
|
+
|
|
54
|
+
Three things worth knowing about this first run:
|
|
55
|
+
|
|
56
|
+
- **The install-script question is already answered.** pnpm refuses to finish an install that has an undecided `postinstall` (`ERR_PNPM_IGNORED_BUILDS`), and a Voltro workspace pulls three — all transitive, none of them anything you picked. `pnpm-workspace.yaml` ships the answers with a reason on each line: `esbuild: true` (vite's compiler binary), `@parcel/watcher` and `msgpackr-extract` `false` (optional native accelerators with pure-JS fallbacks, so your first install needs no C++ toolchain). Change your mind with `pnpm approve-builds`.
|
|
57
|
+
- **Already have a workspace?** Nothing is overwritten. An existing `pnpm-workspace.yaml` is left alone, and only root scripts you *don't* already define are filled in. To prepare a directory without scaffolding anything yet, run `voltro init` — it creates the same workspace root and stops there.
|
|
58
|
+
- **It registers the project with the cloud control plane** (self-hosted tracking) unless you pass `--no-register`. Offline, in CI, or just not interested: `--no-register` skips the network call entirely.
|
|
36
59
|
|
|
37
60
|
## Boot it
|
|
38
61
|
|
|
39
|
-
Install and run from the **workspace root** — there is no top-level `acme
|
|
62
|
+
Install and run from the **workspace root** — there is no top-level `acme/api` to `cd` into; the project lives under `apps/acme/`:
|
|
40
63
|
|
|
41
64
|
```bash
|
|
42
65
|
pnpm install
|
|
43
66
|
pnpm dev
|
|
44
67
|
```
|
|
45
68
|
|
|
46
|
-
The dev
|
|
69
|
+
The root `dev` script is `pnpm -r --parallel dev`: it runs every workspace package that has a `dev` script, at once. No task runner to install — and apps that own their own dev loop (an Expo mobile app, a serverless bundle) simply don't define `dev`, so they opt out by construction. To run one app on its own, `pnpm --filter @acme/api dev`.
|
|
70
|
+
|
|
71
|
+
By default:
|
|
47
72
|
|
|
48
73
|
- `api` listens on `:4000` (RPC over WebSocket on `/ws`)
|
|
49
74
|
- `web` listens on the first port in your range (e.g. `5190`)
|
|
@@ -153,10 +178,10 @@ useMutation('billing', 'invoices.pay')
|
|
|
153
178
|
Every procedure descriptor declares a globally unique `name`. That name is the **RPC tag**:
|
|
154
179
|
|
|
155
180
|
```ts
|
|
156
|
-
defineQuery({
|
|
157
|
-
defineMutation({ name: 'todos.create', /* ... */ })
|
|
158
|
-
defineAction({
|
|
159
|
-
defineStream({
|
|
181
|
+
defineQuery({ name: 'todos.list', guards: [{ scope: 'todos:read' }], /* ... */ })
|
|
182
|
+
defineMutation({ name: 'todos.create', guards: [{ scope: 'todos:write' }], /* ... */ })
|
|
183
|
+
defineAction({ name: 'support.ping', guards: [{ scope: 'support:diagnostics' }], /* ... */ })
|
|
184
|
+
defineStream({ name: 'agent.run', guards: [{ scope: 'agents:run' }], /* ... */ })
|
|
160
185
|
```
|
|
161
186
|
|
|
162
187
|
Hooks use the API name plus RPC tag. There is no hand-written client SDK per endpoint.
|
|
@@ -172,6 +197,34 @@ Procedures are split into two files:
|
|
|
172
197
|
|
|
173
198
|
This keeps the wire contract importable from the client while server code stays server-only.
|
|
174
199
|
|
|
200
|
+
## Every Procedure Declares Who May Call It
|
|
201
|
+
|
|
202
|
+
Dropping a descriptor into the tree **puts it on the wire**. So each one carries an access decision, and a descriptor that carries none is **refused at boot** — by `voltro dev`, by `voltro serve`, and by `voltro doctor` as a preflight. Exactly one of three:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
export const invoiceList = defineQuery({
|
|
206
|
+
name: 'invoices.list',
|
|
207
|
+
guards: [{ scope: 'invoices:read' }], // the caller must hold a scope
|
|
208
|
+
/* ... */
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
export const pricing = defineQuery({
|
|
212
|
+
name: 'pricing.current',
|
|
213
|
+
openAccess: 'public pricing page — reads no caller data', // anyone may, and why
|
|
214
|
+
/* ... */
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
export const stampAudit = defineMutation({
|
|
218
|
+
name: 'auditLog.stamp',
|
|
219
|
+
internal: true, // not on the wire at all
|
|
220
|
+
/* ... */
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`openAccess` takes a **reason, not a boolean**, and that is the whole design: it is what makes *"we decided this is open"* distinguishable from *"nobody looked"*. Without it, the only way to satisfy the gate would be to invent a guard — and the guard people invent is one every caller already holds, which reads as protection and enforces nothing.
|
|
225
|
+
|
|
226
|
+
The one to expect first: your **first self-written procedure file** will not boot until it has one of these three. Full rules in [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
227
|
+
|
|
175
228
|
## The Main Primitives
|
|
176
229
|
|
|
177
230
|
| Primitive | Use it for | Client hook | Durable? | Reactive? |
|
|
@@ -208,8 +261,11 @@ const { data: todos } = useSubscription('app', 'todos.list', {})
|
|
|
208
261
|
When a mutation commits, the runtime emits change events. Queries whose `source` matches the changed table can update automatically.
|
|
209
262
|
|
|
210
263
|
```ts
|
|
211
|
-
defineQuery({ name: 'todos.list', source: 'todos', /* ... */ })
|
|
212
|
-
defineMutation({
|
|
264
|
+
defineQuery({ name: 'todos.list', source: 'todos', guards: [{ scope: 'todos:read' }], /* ... */ })
|
|
265
|
+
defineMutation({
|
|
266
|
+
name: 'todos.create', target: { table: 'todos', op: 'insert' },
|
|
267
|
+
guards: [{ scope: 'todos:write' }], /* ... */
|
|
268
|
+
})
|
|
213
269
|
```
|
|
214
270
|
|
|
215
271
|
There is no `refetch` as the normal path. The subscription lives for the lifetime of the component.
|
|
@@ -287,11 +343,14 @@ You don't need Docker for dev — Voltro ships an in-memory store. You'll want D
|
|
|
287
343
|
The CLI runs from the registry without a global install:
|
|
288
344
|
|
|
289
345
|
```bash
|
|
346
|
+
mkdir my-app && cd my-app
|
|
290
347
|
pnpx voltro create-project my-app
|
|
291
348
|
```
|
|
292
349
|
|
|
293
350
|
This is the smallest blast radius — `pnpx` fetches the latest CLI release into the local cache and discards it after.
|
|
294
351
|
|
|
352
|
+
An empty directory is enough: `create-project` writes the pnpm workspace root (`pnpm-workspace.yaml`, a root `package.json` with `dev`/`build`/`test`/`typecheck`, a `.gitignore`, `git init`) when it can't find one above you. Inside an existing workspace it adds nothing but the project. `voltro init` does the workspace-root half on its own, for when you want the repo prepared before you pick templates.
|
|
353
|
+
|
|
295
354
|
### 2. Per-project dependency
|
|
296
355
|
|
|
297
356
|
Once your project exists, the CLI is already a `dependency` of the `api` app's `package.json`, so:
|
|
@@ -300,7 +359,7 @@ Once your project exists, the CLI is already a `dependency` of the `api` app's `
|
|
|
300
359
|
pnpm --filter @my-app/api exec voltro dev
|
|
301
360
|
```
|
|
302
361
|
|
|
303
|
-
The api template's own `dev` script is `voltro dev .`, so `pnpm dev` in the app runs the CLI directly.
|
|
362
|
+
The api template's own `dev` script is `voltro dev .`, so `pnpm dev` in the app runs the CLI directly. At the workspace root, `pnpm dev` is `pnpm -r --parallel dev` — every app at once. You rarely call the filtered form directly.
|
|
304
363
|
|
|
305
364
|
### 3. Global install
|
|
306
365
|
|
|
@@ -318,7 +377,7 @@ voltro version
|
|
|
318
377
|
voltro list-templates
|
|
319
378
|
```
|
|
320
379
|
|
|
321
|
-
The second command prints the app templates the CLI can scaffold
|
|
380
|
+
The second command prints the app templates the CLI can scaffold — **dozens** of them, across four kinds: `api-*` backends, `frontend-*` web apps, an `edge-functions` serverless bundle, and `mobile-app` (Expo). The output is generated from the templates the installed CLI actually ships, so it is the authority on what your version can scaffold; do not go by a list in a doc. For what each one demonstrates and when to pick it, see the [app-template reference](/docs/reference/templates).
|
|
322
381
|
|
|
323
382
|
## Editor setup
|
|
324
383
|
|
|
@@ -363,13 +422,13 @@ Voltro replaces router and registry config with **filesystem conventions**. Drop
|
|
|
363
422
|
|
|
364
423
|
| Suffix | What it is | Wired into |
|
|
365
424
|
|---|---|---|
|
|
366
|
-
| `*.query.ts` | Browser-safe reactive query descriptor: `defineQuery({ name, source, input, output })`. | Typed RPC group + client metadata. |
|
|
425
|
+
| `*.query.ts` | Browser-safe reactive query descriptor: `defineQuery({ name, source, input, output, guards })`. | Typed RPC group + client metadata. |
|
|
367
426
|
| `*.query.server.ts` | Server executor for the matching query descriptor. | Reactive subscription runtime. |
|
|
368
|
-
| `*.mutation.ts` | Browser-safe mutation descriptor: `defineMutation({ name, target, input, output, error })`. | Typed RPC group + auto-optimistic metadata. |
|
|
427
|
+
| `*.mutation.ts` | Browser-safe mutation descriptor: `defineMutation({ name, target, input, output, error, guards })`. | Typed RPC group + auto-optimistic metadata. |
|
|
369
428
|
| `*.mutation.server.ts` | Server executor for the matching mutation descriptor. | Transactional mutation runner. |
|
|
370
|
-
| `*.action.ts` | Browser-safe action descriptor: `defineAction({ name, input, output, error })`. | Typed RPC group. |
|
|
429
|
+
| `*.action.ts` | Browser-safe action descriptor: `defineAction({ name, input, output, error, guards })`. | Typed RPC group. |
|
|
371
430
|
| `*.action.server.ts` | Server executor for the matching action descriptor. | Non-transactional action runner. |
|
|
372
|
-
| `*.stream.ts` | Browser-safe one-shot stream descriptor: `defineStream({ name, input, element, error })`. | Typed streaming RPC group. |
|
|
431
|
+
| `*.stream.ts` | Browser-safe one-shot stream descriptor: `defineStream({ name, input, element, error, guards })`. | Typed streaming RPC group. |
|
|
373
432
|
| `*.stream.server.ts` | Server executor returning an Effect `Stream`. | Plain server-to-client element streams. |
|
|
374
433
|
| `*.workflow.tsx` | A durable Effect workflow. Survives restarts. | `@effect/workflow` runtime. |
|
|
375
434
|
| `*.agent.tsx` | Browser-safe AI agent **descriptor**: `defineAgent({ name, input })`. Codegen-typed routes. | Agent runtime + client types. |
|
|
@@ -381,6 +440,18 @@ Voltro replaces router and registry config with **filesystem conventions**. Drop
|
|
|
381
440
|
|
|
382
441
|
Procedure descriptors are intentionally separate from server executors. Descriptor files are safe for browser imports and codegen; `.server.ts` files can import the database, file system, SDK clients, secrets, and other server-only modules. Each descriptor has exactly one matching `.server.ts` file with the same primitive suffix. Workflows follow the same split: a browser-safe `*.workflow.tsx` descriptor (importing `workflow` from `@voltro/workflow/define`) paired with a `*.workflow.server.tsx` executor.
|
|
383
442
|
|
|
443
|
+
### The access decision is not optional
|
|
444
|
+
|
|
445
|
+
`guards` appears in all four procedure signatures above because dropping a file into the tree **puts it on the wire**, and a wire-exposed procedure has to say who may call it. Exactly one of three:
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
guards: [{ scope: 'notes:read' }] // the caller must hold a scope
|
|
449
|
+
openAccess: 'public pricing page — reads no caller data' // anyone may call it, and why
|
|
450
|
+
internal: true // not on the wire at all
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
A descriptor that declares none of them is **refused at boot** — by `voltro dev`, by `voltro serve`, and by `voltro doctor` as a preflight. This is the one field a newly-created procedure file is most likely to be missing, and the failure is a boot refusal naming the file rather than a subtle runtime surprise. Full rules, including `openAccess`'s required reason and the per-app `security.defaultDeny` switch: [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
454
|
+
|
|
384
455
|
The browser-safe rule is **transitive**, and that is where it usually breaks. The codegen pulls every descriptor (and every workflow descriptor) value-level into `rpcGroup.generated.ts`, which the web client loads — so a descriptor plus *everything it imports* must stay free of server-only code (`node:*`, the `database` handle, `@voltro/ai`, cluster, plugins, `@voltro/protocol/session`). The classic mistake is not a literal `import 'node:crypto'` but a descriptor importing a shared typed-error or helper from a `lib/` file that *also* imports the database — which drags the whole schema graph into the browser bundle. Keep typed errors, Schemas, and pure helpers in files with zero server imports; put DB-backed guards in `.server.ts`. A leak shows up as the web app fetching hundreds of modules / tens of MB on first load, or crashing with `Module "node:crypto" has been externalized for browser compatibility`.
|
|
385
456
|
|
|
386
457
|
### Declaring a shared file browser-safe: `*.client.ts`
|
|
@@ -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, [
|
|
27
|
-
>
|
|
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
|
|
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
|
-
| **
|
|
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
|
|
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.
|