@voltro/cli 0.1.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 +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20236 -0
- package/bin/voltro.mjs +43 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +9 -0
- package/dist/commands-CfPH2Wf4.js +18061 -0
- package/dist/frameworkInspectState-CX2250XB.js +86 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +5 -0
- package/dist/inspectState.d.ts +116 -0
- package/dist/inspectState.js +2 -0
- package/dist/startup.d.ts +26 -0
- package/dist/startup.js +2 -0
- package/dist/startupRunner-CRhuUl91.js +71 -0
- package/package.json +88 -0
- package/templates/AGENTS.core.md +258 -0
- package/templates/AGENTS.md +351 -0
- package/templates/agent-docs/_index.md +93 -0
- package/templates/agent-docs/_manifest.json +655 -0
- package/templates/agent-docs/ai.md +1845 -0
- package/templates/agent-docs/authentication.md +1788 -0
- package/templates/agent-docs/caching.md +624 -0
- package/templates/agent-docs/cli.md +1650 -0
- package/templates/agent-docs/configuration.md +295 -0
- package/templates/agent-docs/data.md +2172 -0
- package/templates/agent-docs/database/advancedqueries.md +1583 -0
- package/templates/agent-docs/database/columntypes.md +1200 -0
- package/templates/agent-docs/database/hosting.md +881 -0
- package/templates/agent-docs/database/migrations.md +2938 -0
- package/templates/agent-docs/database/misc.md +270 -0
- package/templates/agent-docs/database/overview.md +108 -0
- package/templates/agent-docs/database/querying.md +1622 -0
- package/templates/agent-docs/database/scaling.md +331 -0
- package/templates/agent-docs/database/schema.md +1458 -0
- package/templates/agent-docs/database/seedsdialects.md +1235 -0
- package/templates/agent-docs/database/transactions.md +285 -0
- package/templates/agent-docs/deployment.md +999 -0
- package/templates/agent-docs/internationalization.md +359 -0
- package/templates/agent-docs/introduction.md +438 -0
- package/templates/agent-docs/multi-tenancy.md +610 -0
- package/templates/agent-docs/observability.md +350 -0
- package/templates/agent-docs/plugins.md +1273 -0
- package/templates/agent-docs/reference.md +978 -0
- package/templates/agent-docs/routing.md +1553 -0
- package/templates/agent-docs/scheduling.md +666 -0
- package/templates/agent-docs/schema-driven-ui.md +607 -0
- package/templates/agent-docs/security.md +42 -0
- package/templates/agent-docs/templates/apibackends.md +3214 -0
- package/templates/agent-docs/templates/appshells.md +2062 -0
- package/templates/agent-docs/templates/custom.md +128 -0
- package/templates/agent-docs/templates/overview.md +122 -0
- package/templates/agent-docs/templates/serverless.md +315 -0
- package/templates/agent-docs/testing.md +376 -0
- package/templates/agent-docs/workflows.md +1351 -0
- package/templates/apps/api-ai/README.md +105 -0
- package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
- package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
- package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
- package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
- package/templates/apps/api-ai/app.config.ts +57 -0
- package/templates/apps/api-ai/database/schema.ts +67 -0
- package/templates/apps/api-ai/package.json +27 -0
- package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
- package/templates/apps/api-ai/template.json +6 -0
- package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
- package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
- package/templates/apps/api-ai/tsconfig.json +5 -0
- package/templates/apps/api-auth/.env +17 -0
- package/templates/apps/api-auth/README.md +109 -0
- package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
- package/templates/apps/api-auth/actions/me.action.ts +19 -0
- package/templates/apps/api-auth/app.config.ts +59 -0
- package/templates/apps/api-auth/database/schema.ts +26 -0
- package/templates/apps/api-auth/package.json +28 -0
- package/templates/apps/api-auth/template.json +6 -0
- package/templates/apps/api-auth/tests/me.test.ts +37 -0
- package/templates/apps/api-auth/tsconfig.json +5 -0
- package/templates/apps/api-backend/README.md +25 -0
- package/templates/apps/api-backend/app.config.ts +39 -0
- package/templates/apps/api-backend/database/schema.ts +57 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend/package.json +28 -0
- package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend/template.json +6 -0
- package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend/tsconfig.json +5 -0
- package/templates/apps/api-backend-deactivation/README.md +49 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
- package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
- package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
- package/templates/apps/api-backend-deactivation/package.json +28 -0
- package/templates/apps/api-backend-deactivation/template.json +6 -0
- package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
- package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
- package/templates/apps/api-backend-mail/README.md +39 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
- package/templates/apps/api-backend-mail/app.config.ts +34 -0
- package/templates/apps/api-backend-mail/database/schema.ts +57 -0
- package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mail/package.json +30 -0
- package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mail/template.json +6 -0
- package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mail/tsconfig.json +5 -0
- package/templates/apps/api-backend-mariadb/.env.example +35 -0
- package/templates/apps/api-backend-mariadb/README.md +31 -0
- package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
- package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mariadb/package.json +30 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mariadb/template.json +6 -0
- package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/README.md +86 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
- package/templates/apps/api-backend-storage/app.config.ts +38 -0
- package/templates/apps/api-backend-storage/database/schema.ts +57 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-storage/package.json +27 -0
- package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-storage/template.json +6 -0
- package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-storage/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/.env +17 -0
- package/templates/apps/api-data-advanced/README.md +88 -0
- package/templates/apps/api-data-advanced/app.config.ts +56 -0
- package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
- package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
- package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
- package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
- package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
- package/templates/apps/api-data-advanced/database/index.ts +23 -0
- package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
- package/templates/apps/api-data-advanced/package.json +28 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
- package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
- package/templates/apps/api-data-advanced/template.json +6 -0
- package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
- package/templates/apps/api-data-advanced/tsconfig.json +5 -0
- package/templates/apps/api-durable/README.md +81 -0
- package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
- package/templates/apps/api-durable/app.config.ts +41 -0
- package/templates/apps/api-durable/database/schema.ts +65 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
- package/templates/apps/api-durable/package.json +28 -0
- package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
- package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
- package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
- package/templates/apps/api-durable/template.json +6 -0
- package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
- package/templates/apps/api-durable/tsconfig.json +5 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
- package/templates/apps/api-feature-flags/README.md +63 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
- package/templates/apps/api-feature-flags/app.config.ts +41 -0
- package/templates/apps/api-feature-flags/database/schema.ts +37 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
- package/templates/apps/api-feature-flags/package.json +29 -0
- package/templates/apps/api-feature-flags/template.json +6 -0
- package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
- package/templates/apps/api-feature-flags/tsconfig.json +5 -0
- package/templates/apps/api-governance/.env +4 -0
- package/templates/apps/api-governance/README.md +61 -0
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
- package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
- package/templates/apps/api-governance/app.config.ts +35 -0
- package/templates/apps/api-governance/database/schema.ts +41 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
- package/templates/apps/api-governance/package.json +29 -0
- package/templates/apps/api-governance/template.json +6 -0
- package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
- package/templates/apps/api-governance/tsconfig.json +5 -0
- package/templates/apps/api-kv/README.md +100 -0
- package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
- package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
- package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
- package/templates/apps/api-kv/app.config.ts +48 -0
- package/templates/apps/api-kv/database/schema.ts +62 -0
- package/templates/apps/api-kv/package.json +28 -0
- package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
- package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
- package/templates/apps/api-kv/template.json +6 -0
- package/templates/apps/api-kv/tests/sync.test.ts +103 -0
- package/templates/apps/api-kv/tsconfig.json +5 -0
- package/templates/apps/api-moderation/README.md +46 -0
- package/templates/apps/api-moderation/app.config.ts +33 -0
- package/templates/apps/api-moderation/database/schema.ts +45 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
- package/templates/apps/api-moderation/package.json +29 -0
- package/templates/apps/api-moderation/template.json +6 -0
- package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
- package/templates/apps/api-moderation/tsconfig.json +5 -0
- package/templates/apps/api-observability/README.md +106 -0
- package/templates/apps/api-observability/app.config.ts +32 -0
- package/templates/apps/api-observability/database/schema.ts +38 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
- package/templates/apps/api-observability/package.json +29 -0
- package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
- package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
- package/templates/apps/api-observability/template.json +6 -0
- package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
- package/templates/apps/api-observability/tsconfig.json +5 -0
- package/templates/apps/api-ratelimit/README.md +49 -0
- package/templates/apps/api-ratelimit/app.config.ts +42 -0
- package/templates/apps/api-ratelimit/database/schema.ts +37 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
- package/templates/apps/api-ratelimit/package.json +29 -0
- package/templates/apps/api-ratelimit/template.json +6 -0
- package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
- package/templates/apps/api-ratelimit/tsconfig.json +5 -0
- package/templates/apps/api-rbac/README.md +59 -0
- package/templates/apps/api-rbac/app.config.ts +48 -0
- package/templates/apps/api-rbac/database/schema.ts +37 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
- package/templates/apps/api-rbac/package.json +29 -0
- package/templates/apps/api-rbac/template.json +6 -0
- package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
- package/templates/apps/api-rbac/tsconfig.json +5 -0
- package/templates/apps/api-rest/README.md +85 -0
- package/templates/apps/api-rest/app.config.ts +70 -0
- package/templates/apps/api-rest/database/schema.ts +52 -0
- package/templates/apps/api-rest/lib/product.ts +30 -0
- package/templates/apps/api-rest/package.json +27 -0
- package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
- package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
- package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
- package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
- package/templates/apps/api-rest/template.json +6 -0
- package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
- package/templates/apps/api-rest/tsconfig.json +5 -0
- package/templates/apps/api-saas/README.md +106 -0
- package/templates/apps/api-saas/app.config.ts +51 -0
- package/templates/apps/api-saas/database/schema.ts +50 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas/package.json +31 -0
- package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
- package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas/template.json +6 -0
- package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
- package/templates/apps/api-saas/tsconfig.json +5 -0
- package/templates/apps/api-search/README.md +68 -0
- package/templates/apps/api-search/app.config.ts +31 -0
- package/templates/apps/api-search/database/schema.ts +47 -0
- package/templates/apps/api-search/lib/search.ts +27 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
- package/templates/apps/api-search/package.json +29 -0
- package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
- package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
- package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
- package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
- package/templates/apps/api-search/template.json +6 -0
- package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
- package/templates/apps/api-search/tsconfig.json +5 -0
- package/templates/apps/api-versioning/README.md +51 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
- package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
- package/templates/apps/api-versioning/app.config.ts +24 -0
- package/templates/apps/api-versioning/database/schema.ts +38 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
- package/templates/apps/api-versioning/package.json +29 -0
- package/templates/apps/api-versioning/template.json +6 -0
- package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
- package/templates/apps/api-versioning/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/.env +6 -0
- package/templates/apps/api-webhooks/README.md +106 -0
- package/templates/apps/api-webhooks/app.config.ts +16 -0
- package/templates/apps/api-webhooks/database/schema.ts +49 -0
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
- package/templates/apps/api-webhooks/package.json +28 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
- package/templates/apps/api-webhooks/template.json +6 -0
- package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
- package/templates/apps/api-webhooks/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
- package/templates/apps/changelog/README.md +77 -0
- package/templates/apps/changelog/app.config.ts +27 -0
- package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
- package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
- package/templates/apps/changelog/package.json +30 -0
- package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
- package/templates/apps/changelog/src/globals.css +66 -0
- package/templates/apps/changelog/src/globals.d.ts +16 -0
- package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
- package/templates/apps/changelog/src/lib/locale.ts +55 -0
- package/templates/apps/changelog/src/lib/releases.ts +21 -0
- package/templates/apps/changelog/src/locales/de.ts +22 -0
- package/templates/apps/changelog/src/locales/en.ts +30 -0
- package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
- package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
- package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
- package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
- package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
- package/templates/apps/changelog/src/pages/index.tsx +55 -0
- package/templates/apps/changelog/src/pages/layout.tsx +84 -0
- package/templates/apps/changelog/template.json +6 -0
- package/templates/apps/changelog/tsconfig.json +11 -0
- package/templates/apps/edge-functions/README.md +55 -0
- package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
- package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
- package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
- package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
- package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
- package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
- package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
- package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
- package/templates/apps/edge-functions/package.json +21 -0
- package/templates/apps/edge-functions/template.json +6 -0
- package/templates/apps/edge-functions/tsconfig.json +10 -0
- package/templates/apps/frontend-admin/README.md +40 -0
- package/templates/apps/frontend-admin/app.config.ts +37 -0
- package/templates/apps/frontend-admin/package.json +30 -0
- package/templates/apps/frontend-admin/src/config.ts +8 -0
- package/templates/apps/frontend-admin/src/globals.css +76 -0
- package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
- package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
- package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
- package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
- package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
- package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
- package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
- package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
- package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
- package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-admin/template.json +6 -0
- package/templates/apps/frontend-admin/tsconfig.json +11 -0
- package/templates/apps/frontend-app/README.md +98 -0
- package/templates/apps/frontend-app/app.config.ts +43 -0
- package/templates/apps/frontend-app/package.json +30 -0
- package/templates/apps/frontend-app/src/locales/de.ts +36 -0
- package/templates/apps/frontend-app/src/locales/en.ts +43 -0
- package/templates/apps/frontend-app/src/locales/index.ts +15 -0
- package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
- package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
- package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
- package/templates/apps/frontend-app/template.json +6 -0
- package/templates/apps/frontend-app/tsconfig.json +11 -0
- package/templates/apps/frontend-blank/README.md +18 -0
- package/templates/apps/frontend-blank/app.config.ts +29 -0
- package/templates/apps/frontend-blank/package.json +29 -0
- package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
- package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
- package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
- package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
- package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
- package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
- package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
- package/templates/apps/frontend-blank/template.json +6 -0
- package/templates/apps/frontend-blank/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/README.md +65 -0
- package/templates/apps/frontend-contact/app.config.ts +25 -0
- package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
- package/templates/apps/frontend-contact/package.json +33 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
- package/templates/apps/frontend-contact/src/config.ts +12 -0
- package/templates/apps/frontend-contact/src/globals.css +84 -0
- package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
- package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
- package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
- package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
- package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
- package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
- package/templates/apps/frontend-contact/template.json +6 -0
- package/templates/apps/frontend-contact/tsconfig.json +11 -0
- package/templates/apps/frontend-dashboard/README.md +54 -0
- package/templates/apps/frontend-dashboard/app.config.ts +39 -0
- package/templates/apps/frontend-dashboard/package.json +29 -0
- package/templates/apps/frontend-dashboard/src/config.ts +8 -0
- package/templates/apps/frontend-dashboard/src/globals.css +72 -0
- package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
- package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
- package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
- package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
- package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-dashboard/template.json +6 -0
- package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
- package/templates/apps/frontend-docs/README.md +19 -0
- package/templates/apps/frontend-docs/app.config.ts +25 -0
- package/templates/apps/frontend-docs/package.json +29 -0
- package/templates/apps/frontend-docs/src/globals.css +32 -0
- package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
- package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
- package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
- package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
- package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-docs/template.json +6 -0
- package/templates/apps/frontend-docs/tsconfig.json +11 -0
- package/templates/apps/frontend-i18n/README.md +61 -0
- package/templates/apps/frontend-i18n/app.config.ts +33 -0
- package/templates/apps/frontend-i18n/package.json +28 -0
- package/templates/apps/frontend-i18n/src/globals.css +46 -0
- package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
- package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
- package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
- package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
- package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
- package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
- package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
- package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
- package/templates/apps/frontend-i18n/template.json +6 -0
- package/templates/apps/frontend-i18n/tsconfig.json +11 -0
- package/templates/apps/frontend-landing/README.md +17 -0
- package/templates/apps/frontend-landing/app.config.ts +25 -0
- package/templates/apps/frontend-landing/package.json +29 -0
- package/templates/apps/frontend-landing/src/globals.css +23 -0
- package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
- package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
- package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
- package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
- package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-landing/template.json +6 -0
- package/templates/apps/frontend-landing/tsconfig.json +11 -0
- package/templates/apps/frontend-spa/README.md +45 -0
- package/templates/apps/frontend-spa/app.config.ts +27 -0
- package/templates/apps/frontend-spa/package.json +29 -0
- package/templates/apps/frontend-spa/src/globals.css +84 -0
- package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
- package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
- package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
- package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
- package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
- package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-spa/template.json +6 -0
- package/templates/apps/frontend-spa/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr/README.md +68 -0
- package/templates/apps/frontend-ssr/app.config.ts +32 -0
- package/templates/apps/frontend-ssr/package.json +29 -0
- package/templates/apps/frontend-ssr/src/globals.css +67 -0
- package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
- package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
- package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
- package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
- package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
- package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
- package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
- package/templates/apps/frontend-ssr/template.json +6 -0
- package/templates/apps/frontend-ssr/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr-api/README.md +50 -0
- package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
- package/templates/apps/frontend-ssr-api/package.json +30 -0
- package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
- package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
- package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
- package/templates/apps/frontend-ssr-api/template.json +6 -0
- package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
- package/templates/apps/frontend-static-blog/README.md +49 -0
- package/templates/apps/frontend-static-blog/app.config.ts +34 -0
- package/templates/apps/frontend-static-blog/package.json +28 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
- package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
- package/templates/apps/frontend-static-blog/src/globals.css +75 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
- package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
- package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
- package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
- package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
- package/templates/apps/frontend-static-blog/template.json +6 -0
- package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
- package/templates/baselines/bare/.env.example +37 -0
- package/templates/baselines/bare/README.md +47 -0
- package/templates/baselines/bare/baseline.json +31 -0
- package/templates/baselines/bare/deploy/README.md +43 -0
- package/templates/baselines/bare/deploy/voltro.service.example +36 -0
- package/templates/baselines/compose/.env.example +46 -0
- package/templates/baselines/compose/README.md +69 -0
- package/templates/baselines/compose/baseline.json +51 -0
- package/templates/baselines/compose/docker/.dockerignore +38 -0
- package/templates/baselines/compose/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose/docker-compose.dev.yml +89 -0
- package/templates/baselines/compose/docker-compose.prod.yml +87 -0
- package/templates/baselines/compose/docker-compose.yml +41 -0
- package/templates/baselines/compose-mariadb/.env.example +57 -0
- package/templates/baselines/compose-mariadb/README.md +78 -0
- package/templates/baselines/compose-mariadb/baseline.json +51 -0
- package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
- package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
- package/templates/baselines/helm/.env.example +39 -0
- package/templates/baselines/helm/README.md +98 -0
- package/templates/baselines/helm/baseline.json +53 -0
- package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
- package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
- package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
- package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
- package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
- package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
- package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
- package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
- package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
- package/templates/baselines/helm/deploy/README.md +94 -0
- package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# Multi-tenancy
|
|
2
|
+
|
|
3
|
+
> Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/multi-tenancy/overview.md -->
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
_Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates._
|
|
13
|
+
|
|
14
|
+
Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro treats it as a runtime primitive: drop the `tenant()` mixin on a table, and the framework guarantees cross-tenant data leakage is structurally impossible for **reads**. Writes get a typed `TenantMismatch` error if you forget the explicit check.
|
|
15
|
+
|
|
16
|
+
## The model
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Subject (tenantId: 'acme')
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
┌─────────────────────────┐
|
|
23
|
+
│ Queries (read) │ ← runtime AND-merges
|
|
24
|
+
│ ctx.store.select(...) │ WHERE tenantId = subject.tenantId
|
|
25
|
+
└─────────────────────────┘
|
|
26
|
+
┌─────────────────────────┐
|
|
27
|
+
│ Mutations (write) │ ← you call
|
|
28
|
+
│ ctx.store.insert(...) │ assertOwnTenant(input.tenantId, subject)
|
|
29
|
+
└─────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Reads are auto-scoped because the subject + table mixin contain enough information.
|
|
33
|
+
Writes are NOT auto-scoped because the input gets to *propose* a tenant — your code decides whether to honour it (typical: never) or assert against the subject (typical: always).
|
|
34
|
+
|
|
35
|
+
## What's in this section
|
|
36
|
+
|
|
37
|
+
- [The tenant() mixin](/docs/multi-tenancy/mixin) — how it works under the hood, when scoping kicks in, when it doesn't
|
|
38
|
+
- [Edge cases](/docs/multi-tenancy/edge-cases) — public queries, cross-tenant admins, anonymous subjects, x-tenant header resolution, vector + storage isolation
|
|
39
|
+
|
|
40
|
+
## The shortest possible end-to-end
|
|
41
|
+
|
|
42
|
+
Schema:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { table, id, text } from '@voltro/database'
|
|
46
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
47
|
+
|
|
48
|
+
export const notes = table('notes', {
|
|
49
|
+
id: id(),
|
|
50
|
+
title: text(),
|
|
51
|
+
}).with(tenant())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Query (auto-scoped):
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
export const listNotes = defineQuery({ name: 'notes.list', input: Schema.Struct({}) })
|
|
58
|
+
export default async (_input, ctx) => ctx.store.select('notes').all()
|
|
59
|
+
// SQL: SELECT * FROM notes WHERE tenantId = $1 (with subject.tenantId)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Mutation (explicit gate):
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
|
|
66
|
+
|
|
67
|
+
export const createNote = defineMutation({
|
|
68
|
+
name: 'notes.create',
|
|
69
|
+
input: Schema.Struct({ tenantId: Schema.String, title: Schema.String }),
|
|
70
|
+
error: TenantMismatch,
|
|
71
|
+
})
|
|
72
|
+
export default async (input, ctx) => {
|
|
73
|
+
assertOwnTenant(input.tenantId, ctx.subject)
|
|
74
|
+
return ctx.store.insert('notes', input)
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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
|
+
|
|
80
|
+
## Why the asymmetry
|
|
81
|
+
|
|
82
|
+
Reads always have a single answer: "what does this subject see?" → AND the tenant. Auto-scopable.
|
|
83
|
+
|
|
84
|
+
Writes have an open question: "which tenant should this go in?" The input gets to claim — sometimes legitimately (impersonation by an admin, switching active tenant). Your code decides whether to honour the claim. The framework can't auto-enforce because legitimate exceptions exist.
|
|
85
|
+
|
|
86
|
+
Defaulting to "auto-reject mismatching writes" would still work for 99% of mutations + would make the 1% impossible. We picked "assert explicitly" instead — slightly more code, full flexibility.
|
|
87
|
+
|
|
88
|
+
## Tenant scoping covers more than just the database
|
|
89
|
+
|
|
90
|
+
| Surface | Scoped by |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `ctx.store` selects | `tenant()` mixin's subscription filter |
|
|
93
|
+
| `*.query.ts` subscriptions | Same — mixin applies inside the query's read tracker |
|
|
94
|
+
| Vector search (pgvector) | Same |
|
|
95
|
+
| `@voltro/plugin-storage` keys | Prefix convention: `<tenantId>/<key>` |
|
|
96
|
+
| `@voltro/plugin-search` indexes | Per-tenant index (or filter, depending on backend) |
|
|
97
|
+
| Workflows + agents | Inherit calling subject |
|
|
98
|
+
| AI audit log | `ctx.subject.tenantId` recorded on every call |
|
|
99
|
+
|
|
100
|
+
The mixin is the lever — every adjacent plugin reads from the same subject + the same column.
|
|
101
|
+
|
|
102
|
+
## Isolation model
|
|
103
|
+
|
|
104
|
+
The framework ships **two** isolation topologies. The default is **shared-schema**: one DB, one schema, a `tenantId` column kept apart by the `tenant()` mixin's WHERE filter. Opt into **namespace** isolation for *physical* separation — a per-request namespace, resolved from the request's tenant, into which every table reference is qualified. The runtime API is **identical** across both: the `tenant()` mixin, handler code, and `ctx.store` calls don't change. Only store resolution differs.
|
|
105
|
+
|
|
106
|
+
### Opting in
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// app.config.ts
|
|
110
|
+
export default {
|
|
111
|
+
type: 'api' as const,
|
|
112
|
+
name: 'myApi',
|
|
113
|
+
store: 'postgres' as const,
|
|
114
|
+
tenancy: { isolation: 'namespace' }, // default: 'shared-schema'
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or via env — `VOLTRO_TENANT_ISOLATION=namespace` — which overrides the config field. The same flag is read by `voltro dev` and `voltro serve`, so the topology can't drift between dev and prod.
|
|
119
|
+
|
|
120
|
+
### One mechanism, per-dialect mapping
|
|
121
|
+
|
|
122
|
+
Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sanitised-id>`, derived from `subject.tenantId` — mapped to each dialect's native physical container:
|
|
123
|
+
|
|
124
|
+
| Dialect | Namespace is a… | Table reference |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| postgres / mssql | **schema** | `tenant_<id>.todos` |
|
|
127
|
+
| mysql / mariadb | **database** (SCHEMA ≡ DATABASE — this *is* database-per-tenant) | `tenant_<id>.todos` |
|
|
128
|
+
| sqlite | **attached database** (`ATTACH DATABASE '<id>.db' AS tenant_<id>`) | `tenant_<id>.todos` |
|
|
129
|
+
|
|
130
|
+
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
|
+
|
|
132
|
+
### Postgres fast-path — `SET LOCAL search_path`
|
|
133
|
+
|
|
134
|
+
On postgres each request runs inside a transaction whose first statement is `SET LOCAL search_path TO "tenant_<id>"`. Because it's `SET LOCAL` (transaction-scoped), the setting **resets at commit** — mandatory on a pooled connection, where a bare `SET search_path` would persist and leak into the next request that checks out the same connection. The other dialects qualify identifiers directly (`tenant_<id>.todos`).
|
|
135
|
+
|
|
136
|
+
### Fail closed on a missing tenant
|
|
137
|
+
|
|
138
|
+
A request with **no resolvable tenant** does NOT fall back to a shared or default namespace (which could read another tenant's data) — it **fails closed**: the store refuses the operation and throws `TenantNamespaceUnresolved`. The tenant id is sanitised into a safe identifier (`tenant_<id>`, `[a-z0-9_]` only); anything that could break out of an identifier position is rejected or escaped before it reaches SQL.
|
|
139
|
+
|
|
140
|
+
### Provisioning a tenant's namespace
|
|
141
|
+
|
|
142
|
+
When namespace isolation is on, the auto-migrate DDL fans out per tenant: it creates the container (`CREATE SCHEMA` / `CREATE DATABASE` / `ATTACH DATABASE`) and runs the table DDL inside it. Provision a new tenant's namespace eagerly at migrate time or lazily on first use via `provisionTenantNamespace(tables, namespace, sqlLayer, dialect)` from `@voltro/database/sql`.
|
|
143
|
+
|
|
144
|
+
### CDC namespace tagging
|
|
145
|
+
|
|
146
|
+
The postgres `LISTEN/NOTIFY` payload carries the writing schema (`TG_TABLE_SCHEMA`) so a write in tenant A's namespace doesn't spuriously wake tenant B's subscriptions on the same-named table. mariadb's binlog already carries the database name; mysql / mssql / sqlite emit through the framework's own path, which already knows the namespace. A spurious wake is **not** a leak — the re-query runs against the woken subscription's OWN namespace — so suppressing cross-namespace wakes is purely a wasted-work optimization.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
<!-- source: en/multi-tenancy/mixin.md -->
|
|
153
|
+
## The tenant() mixin
|
|
154
|
+
|
|
155
|
+
_What tenant() adds — the tenantId reference, the auto-index, the read scoping — and how it composes with other mixins._
|
|
156
|
+
|
|
157
|
+
The `tenant()` mixin is the lever that turns a normal table into a tenant-scoped one. This page covers exactly what it does.
|
|
158
|
+
|
|
159
|
+
## What it adds
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { table, id, text } from '@voltro/database'
|
|
163
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
164
|
+
|
|
165
|
+
export const notes = table('notes', {
|
|
166
|
+
id: id(),
|
|
167
|
+
title: text(),
|
|
168
|
+
}).with(tenant())
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`tenant()` takes **no arguments**. It returns a `MixinDefinition` you apply with `.with(...)` — never spread it into the field object. The mixin contributes:
|
|
172
|
+
|
|
173
|
+
1. **A `tenantId` column** — `reference(requireTenants())`, an FK into your app's `tenants` table (not a bare text column).
|
|
174
|
+
2. **An auto-index** on `tenantId` (`indexes: [{ fields: ['tenantId'] }]`). The name is auto-generated as `<tableName>_tenantId_idx`.
|
|
175
|
+
3. **Read scoping** — the runtime AND-merges `WHERE tenantId = ctx.subject.tenantId` into every subscription against this table.
|
|
176
|
+
4. **Insert auto-fill** — when an insert's row payload omits `tenantId`, the runtime stamps it from the request subject.
|
|
177
|
+
|
|
178
|
+
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
|
+
|
|
180
|
+
## tenant() requires audit()
|
|
181
|
+
|
|
182
|
+
`tenant()` transitively requires `audit()` — every tenant-scoped row is also a who/when-stamped artefact in the audit trail. The dependency resolver dedupes if you apply both explicitly, so `.with(tenant())` alone is enough.
|
|
183
|
+
|
|
184
|
+
## Composing with other mixins
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { table, id, text } from '@voltro/database'
|
|
188
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
189
|
+
import { audit } from '@voltro/plugin-audit'
|
|
190
|
+
import { softDelete } from '@voltro/plugin-soft-delete'
|
|
191
|
+
|
|
192
|
+
export const notes = table('notes', {
|
|
193
|
+
id: id(),
|
|
194
|
+
title: text(),
|
|
195
|
+
}).with(softDelete(), tenant(), audit()) // tenant() pulls in audit() anyway
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The behaviors compose. A read against `notes`:
|
|
199
|
+
|
|
200
|
+
- Filters by `tenantId` (from `tenant()`)
|
|
201
|
+
- ALSO filters out `deletedAt IS NOT NULL` (from `softDelete()`)
|
|
202
|
+
- Returns the audit columns alongside
|
|
203
|
+
|
|
204
|
+
Order in the `.with(...)` chain doesn't matter for these — mixin read predicates are AND-merged.
|
|
205
|
+
|
|
206
|
+
## The auto-fill behaviour
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// Mutation:
|
|
210
|
+
ctx.store.insert('notes', { title: 'Hello' })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
If you DON'T pass `tenantId`, the runtime auto-fills it from `ctx.subject.tenantId`. The row is created in the caller's tenant. This is the safe default — it's hard to accidentally create a cross-tenant row.
|
|
214
|
+
|
|
215
|
+
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
|
+
|
|
217
|
+
## What it does NOT do
|
|
218
|
+
|
|
219
|
+
- **Auto-guard writes.** Read scoping is automatic; mutations receive user-supplied input including `tenantId`. A client authenticated as tenant A can post `tenantId: 'B'` and the row lands in B without a guard. Call `assertOwnTenant` at the top of the executor:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
|
|
223
|
+
|
|
224
|
+
export default async (input, ctx) => {
|
|
225
|
+
assertOwnTenant(input.tenantId, ctx.request.subject) // throws TenantMismatch on spoof
|
|
226
|
+
return ctx.store.insert('notes', input)
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Declare `error: TenantMismatch` on the mutation descriptor so the rpc layer surfaces the rejection typed.
|
|
231
|
+
|
|
232
|
+
- **Apply to raw SQL.** A hand-written `@effect/sql` query bypasses the mixin. Write the filter yourself.
|
|
233
|
+
|
|
234
|
+
## Performance considerations
|
|
235
|
+
|
|
236
|
+
The auto-injected `tenantId = $1` filter is fast — the mixin's single-column index covers it. For high-cardinality tables (events, logs, audits), add a composite index with `tenantId` as the leftmost column on the actual hot query:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
table('messages', {
|
|
240
|
+
id: id(),
|
|
241
|
+
channelId: text(),
|
|
242
|
+
body: text(),
|
|
243
|
+
createdAt: timestamp().default('now'),
|
|
244
|
+
})
|
|
245
|
+
.with(tenant())
|
|
246
|
+
.index('messages_tenant_channel_created',
|
|
247
|
+
['tenantId', 'channelId', { col: 'createdAt', order: 'desc' }])
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Now `WHERE tenantId = $1 AND channelId = $2 ORDER BY createdAt DESC LIMIT 50` is served from the index. Indexes are declared at the table level — there is no column-level `.index()` modifier.
|
|
251
|
+
|
|
252
|
+
## When NOT to use the mixin
|
|
253
|
+
|
|
254
|
+
- **Truly global tables** — feature flags, system config, audit retention policies. These don't belong to any single tenant. Leave them un-mixin'd.
|
|
255
|
+
- **Cross-tenant aggregates** — usage reports, cross-tenant leaderboards, admin dashboards. Run the read as the `system` subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)); a system subject has `tenantId: null` by construction and the AND-merge is skipped.
|
|
256
|
+
|
|
257
|
+
The mixin is opt-in per table. You declare it for the tables that should be scoped + leave the rest free.
|
|
258
|
+
|
|
259
|
+
## See also
|
|
260
|
+
|
|
261
|
+
- [Overview](/docs/multi-tenancy/overview) — the read/write asymmetry model
|
|
262
|
+
- [Edge cases](/docs/multi-tenancy/edge-cases) — cross-tenant reads, anonymous subjects, storage isolation
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
<!-- source: en/multi-tenancy/edge-cases.md -->
|
|
269
|
+
## Edge cases
|
|
270
|
+
|
|
271
|
+
_Cross-tenant admins, anonymous subjects, public queries, x-tenant header resolution, vector + storage isolation._
|
|
272
|
+
|
|
273
|
+
The `tenant()` mixin handles the 95% case. The remaining 5% is here.
|
|
274
|
+
|
|
275
|
+
## Cross-tenant reads — `.unscoped()`
|
|
276
|
+
|
|
277
|
+
For staff that need to read across tenants (support, billing ops) the
|
|
278
|
+
fluent `ctx.store.select(...)` builder exposes `.unscoped()`, which drops
|
|
279
|
+
the automatic `tenantId` filter:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
const all = await ctx.store.select('notes').unscoped().all()
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`.unscoped()` is a raw capability — it is NOT role-gated by the
|
|
286
|
+
framework. Whether the caller is *allowed* to read cross-tenant is YOUR
|
|
287
|
+
decision: gate on a scope before you call it.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import { hasScope } from '@voltro/protocol'
|
|
291
|
+
|
|
292
|
+
if (!hasScope(ctx.request.subject, 'admin:full')) {
|
|
293
|
+
throw new Error('cross-tenant read requires admin scope')
|
|
294
|
+
}
|
|
295
|
+
const all = await ctx.store.select('notes').unscoped().all()
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The subject carries `scopes` (resolved by the auth strategy), never a
|
|
299
|
+
`roles` field. Use `hasScope` / `requireScope` from `@voltro/protocol`;
|
|
300
|
+
`admin:full` is the blanket bypass.
|
|
301
|
+
|
|
302
|
+
The same `.unscoped()` modifier exists on the `update(...)` and
|
|
303
|
+
`delete(...)` builders for cross-tenant writes — both equally ungated, so
|
|
304
|
+
guard them the same way.
|
|
305
|
+
|
|
306
|
+
## Anonymous subjects
|
|
307
|
+
|
|
308
|
+
Anonymous requests have a `null` `id` and may carry a `tenantId` or
|
|
309
|
+
`null`. The fluent `select` only AND-merges the tenant filter when the
|
|
310
|
+
subject's `tenantId` is non-null, so an anonymous subject WITHOUT a
|
|
311
|
+
tenant reads with no tenant filter — be deliberate about which tables
|
|
312
|
+
you expose to it.
|
|
313
|
+
|
|
314
|
+
For **truly public** tables that don't need scoping, drop the mixin:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
const publicArticles = table('public_articles', {
|
|
318
|
+
id: id(),
|
|
319
|
+
body: text(),
|
|
320
|
+
// NO .with(tenant()) — anyone can read
|
|
321
|
+
})
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
For tables that ARE tenant-scoped but should serve **anonymous** users
|
|
325
|
+
with the tenant inferred from a request header, the anonymous fallback
|
|
326
|
+
in `composeAuthStrategies` produces an `anonymousSubject(tenantId)` from
|
|
327
|
+
the `x-tenant` header when no strategy matches:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { anonymousSubject } from '@voltro/protocol'
|
|
331
|
+
// the composer's default fallback reads `x-tenant` and yields
|
|
332
|
+
// { type: 'anonymous', id: null, tenantId: 'acme' }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
A request with `x-tenant: acme` reads tenant-scoped tables under that
|
|
336
|
+
tenant. Use this for per-tenant marketing pages, public listings filtered
|
|
337
|
+
by tenant slug, status pages. Never expose any table that should require
|
|
338
|
+
auth this way — the header is client-controlled and unauthenticated.
|
|
339
|
+
|
|
340
|
+
## Subdomain-based tenant resolution
|
|
341
|
+
|
|
342
|
+
For `tenant1.your-product.com` / `tenant2.your-product.com`, write a
|
|
343
|
+
custom strategy and add it to the chain in `app.config.ts`:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
// app.config.ts
|
|
347
|
+
import { composeAuthStrategies } from '@voltro/protocol'
|
|
348
|
+
import { anonymousSubject } from '@voltro/protocol'
|
|
349
|
+
|
|
350
|
+
export default {
|
|
351
|
+
type: 'api' as const,
|
|
352
|
+
name: 'myApi',
|
|
353
|
+
auth: {
|
|
354
|
+
strategies: [
|
|
355
|
+
{
|
|
356
|
+
id: 'subdomain',
|
|
357
|
+
resolve: async (input) => {
|
|
358
|
+
const host = input.headers.host ?? ''
|
|
359
|
+
const sub = host.split('.')[0]
|
|
360
|
+
const tenant = await lookupTenantBySubdomain(sub)
|
|
361
|
+
return tenant
|
|
362
|
+
? { kind: 'matched', subject: anonymousSubject(tenant.id) }
|
|
363
|
+
: { kind: 'skip' }
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The built-in signed-cookie password strategy always runs FIRST, so when
|
|
372
|
+
the user signs in, the cookie subject (with their own `tenantId`)
|
|
373
|
+
overrides the anonymous subdomain one.
|
|
374
|
+
|
|
375
|
+
## Public queries that bypass the mixin
|
|
376
|
+
|
|
377
|
+
For a `*.query.ts` that serves data from a tenant-scoped table to
|
|
378
|
+
anonymous users (read public articles for tenant X):
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
export default async (input, ctx) => {
|
|
382
|
+
// input.tenantId comes from the URL slug
|
|
383
|
+
return ctx.store.select('articles')
|
|
384
|
+
.unscoped() // drop the auto-filter
|
|
385
|
+
.where('tenantId', input.tenantId) // … and add ours explicitly
|
|
386
|
+
.where('published', true)
|
|
387
|
+
.all()
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The `unscoped()` + explicit `where('tenantId', …)` pattern makes intent
|
|
392
|
+
obvious — the next reader sees exactly which tenant the query serves.
|
|
393
|
+
|
|
394
|
+
## Anonymous vector search
|
|
395
|
+
|
|
396
|
+
Same pattern for pgvector:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
ctx.store.select('public_docs')
|
|
400
|
+
.unscoped()
|
|
401
|
+
.where('tenantId', publicTenantId)
|
|
402
|
+
.nearestNeighbours('embedding', query)
|
|
403
|
+
.limit(5)
|
|
404
|
+
.all()
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
For multi-tenant SaaS where each tenant has its own knowledge base plus
|
|
408
|
+
an anonymous "marketing" tenant ID for public demos.
|
|
409
|
+
|
|
410
|
+
## Object storage (R2 / S3) keys
|
|
411
|
+
|
|
412
|
+
`@voltro/plugin-storage` keys are tenant-prefixed. The stored object key
|
|
413
|
+
is composed as `<tenantPrefix>/<tenantId | 'global'>/<key>` — the
|
|
414
|
+
`tenantId` comes from the request subject, and `tenantPrefix` is an
|
|
415
|
+
optional static app/env namespace set in `storagePlugin({ tenantPrefix })`.
|
|
416
|
+
Two tenants uploading the same `key` land at distinct paths, so one
|
|
417
|
+
tenant can't read another's object through the service.
|
|
418
|
+
|
|
419
|
+
## Backup + restore considerations
|
|
420
|
+
|
|
421
|
+
Shared-schema multi-tenancy means **one backup covers all tenants**:
|
|
422
|
+
|
|
423
|
+
- Restoring a snapshot brings every tenant back to that point —
|
|
424
|
+
including tenants that weren't asking for the restore.
|
|
425
|
+
- Per-tenant point-in-time recovery is not possible with shared schema.
|
|
426
|
+
It IS possible with the namespace isolation topology (schema- /
|
|
427
|
+
database-per-tenant), see [Overview](/docs/multi-tenancy/overview).
|
|
428
|
+
|
|
429
|
+
If a single tenant wants to "roll back" their data, you need a
|
|
430
|
+
`tenant_snapshots` table you maintain explicitly, or a schema-per-tenant
|
|
431
|
+
deployment topology.
|
|
432
|
+
|
|
433
|
+
## Soft-delete + multi-tenancy
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const notes = table('notes', {
|
|
437
|
+
id: id(),
|
|
438
|
+
title: text(),
|
|
439
|
+
}).with(softDelete(), tenant())
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A scoped read filters both predicates:
|
|
443
|
+
|
|
444
|
+
```sql
|
|
445
|
+
WHERE tenantId = subject.tenantId
|
|
446
|
+
AND deletedAt IS NULL
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A row soft-deleted in tenant A is gone for subjects in A and invisible to
|
|
450
|
+
any other tenant (they couldn't see A's rows anyway). To bring a soft-
|
|
451
|
+
deleted row back, use the `update` builder's `.restore()` (clears
|
|
452
|
+
`deletedAt`); read soft-deleted rows with `.withDeleted()`:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
await ctx.store.update('notes').where('id', noteId).restore()
|
|
456
|
+
const trash = await ctx.store.select('notes').withDeleted().all()
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Workflows that need to cross tenants
|
|
460
|
+
|
|
461
|
+
Some workflows legitimately cross — billing aggregation, cross-tenant
|
|
462
|
+
analytics jobs. Run them as the `system` subject:
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
// scheduled job
|
|
466
|
+
import { runAsSystem } from '@voltro/runtime'
|
|
467
|
+
|
|
468
|
+
await runAsSystem(async (ctx) => {
|
|
469
|
+
const usage = await ctx.store.select('events')
|
|
470
|
+
.unscoped()
|
|
471
|
+
.where('createdAt', '>', cutoff)
|
|
472
|
+
.all()
|
|
473
|
+
// aggregate, write, etc.
|
|
474
|
+
}, { id: 'job:billing-aggregator' })
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`runAsSystem` binds `ctx.subject` to a `system` subject
|
|
478
|
+
(`{ type: 'system', id: 'job:billing-aggregator', tenantId: null }`). A
|
|
479
|
+
system subject has `tenantId: null` by construction, so the tenant
|
|
480
|
+
AND-merge is skipped; the `.unscoped()` above additionally drops the
|
|
481
|
+
soft-delete filter when the table carries `softDelete()`. The system
|
|
482
|
+
subject defaults to the `admin:full` scope; pass `{ scopes }` to narrow,
|
|
483
|
+
and `{ id }` for a named job (default `'system'`).
|
|
484
|
+
|
|
485
|
+
## Tests + multi-tenancy
|
|
486
|
+
|
|
487
|
+
Test contexts can fake any subject:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
import { makeTestContext } from '@voltro/testing'
|
|
491
|
+
|
|
492
|
+
const ctx = makeTestContext({
|
|
493
|
+
subject: { type: 'user', id: 'usr_test', tenantId: 'tenant-a' },
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// All ctx.store calls now scoped to tenant-a
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
For cross-tenant isolation tests:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
const ctxA = makeTestContext({ subject: { type: 'user', id: 'a', tenantId: 'A' } })
|
|
503
|
+
const ctxB = makeTestContext({ subject: { type: 'user', id: 'b', tenantId: 'B' } })
|
|
504
|
+
|
|
505
|
+
await ctxA.store.insert('notes', { title: 'A note' })
|
|
506
|
+
const fromB = await ctxB.store.select('notes').all()
|
|
507
|
+
expect(fromB).toEqual([]) // B cannot see A's row
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
This is the kind of test you write ONCE per table with the `tenant()`
|
|
511
|
+
mixin.
|
|
512
|
+
|
|
513
|
+
## What can still go wrong
|
|
514
|
+
|
|
515
|
+
- **A raw `@effect/sql` query that forgets the tenant filter** — the
|
|
516
|
+
mixin doesn't intercept hand-written SQL. Audit raw queries carefully.
|
|
517
|
+
- **An `.unscoped()` call without a scope check in front of it** — the
|
|
518
|
+
modifier is ungated by design. A `requireScope` / `hasScope` guard
|
|
519
|
+
belongs immediately before every cross-tenant read or write.
|
|
520
|
+
- **A subject leaked across requests** — the framework's request-scoped
|
|
521
|
+
subject resolution makes this hard, but a custom strategy can do it.
|
|
522
|
+
If you write one, return a fresh subject each call.
|
|
523
|
+
- **Plugin code that bypasses `ctx.store`** — third-party plugins should
|
|
524
|
+
use `ctx.store` only. If they reach for `@effect/sql` directly, they
|
|
525
|
+
skip the mixin. Audit third-party plugins.
|
|
526
|
+
|
|
527
|
+
For high-stakes deploys (compliance, healthcare), add a CI check that
|
|
528
|
+
scans for `.unscoped()` calls and reviews each one. The framework can't
|
|
529
|
+
catch the cases where you legitimately opt out — that's a code-review
|
|
530
|
+
responsibility.
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
<!-- source: en/multi-tenancy/residency.md -->
|
|
537
|
+
## Data residency
|
|
538
|
+
|
|
539
|
+
_Pin each tenant's data to a HOME region and fail closed everywhere else — a deployment never serves or provisions a tenant homed in a region it doesn't hold, so a US deployment can never touch an EU-homed tenant's rows._
|
|
540
|
+
|
|
541
|
+
Data residency pins each tenant's data to a **home region** and makes every other
|
|
542
|
+
deployment **fail closed**. A deployment declares which regions it can serve (it
|
|
543
|
+
holds their stores); a request for a tenant homed elsewhere is refused, not
|
|
544
|
+
served from a fallback — the gateway is expected to route it to the home region's
|
|
545
|
+
deployment. So a US deployment can **never** read, bind, or provision an EU-homed
|
|
546
|
+
tenant's data.
|
|
547
|
+
|
|
548
|
+
> Residency **never falls back to a default store.** No home mapped → typed
|
|
549
|
+
> `TenantResidencyUnresolved`. Home region not served here → typed
|
|
550
|
+
> `TenantRegionUnavailable`. Both fail closed; that's the whole point.
|
|
551
|
+
|
|
552
|
+
## Configure it
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
import { setResidencyConfig } from '@voltro/database'
|
|
556
|
+
|
|
557
|
+
setResidencyConfig({
|
|
558
|
+
// tenant → home region (+ optional named connection for the home DB).
|
|
559
|
+
homes: [
|
|
560
|
+
{ tenantId: 'acme', region: 'eu-west' },
|
|
561
|
+
{ tenantId: 'globex', region: 'us-east' },
|
|
562
|
+
],
|
|
563
|
+
// The regions THIS deployment can actually serve (it holds their stores).
|
|
564
|
+
servableRegions: ['us-east'],
|
|
565
|
+
})
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
A tenant mapped to two different regions is rejected at config time (ambiguous
|
|
569
|
+
routing), and `servableRegions` must be non-empty.
|
|
570
|
+
|
|
571
|
+
## Resolve + bind per request
|
|
572
|
+
|
|
573
|
+
`bindResidentStore` picks the store for the subject's home region from the
|
|
574
|
+
per-region handles this deployment holds — failing closed if the home is
|
|
575
|
+
unresolved or not served here:
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
import { bindResidentStore } from '@voltro/database'
|
|
579
|
+
|
|
580
|
+
// stores: ReadonlyMap<region, StoreHandle> — what THIS deployment wired.
|
|
581
|
+
const { store, placement } = bindResidentStore(subject, config, stores)
|
|
582
|
+
// placement = { region, namespace, connectionKey? }
|
|
583
|
+
// throws TenantRegionUnavailable for an EU-homed tenant on a US deployment.
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
`residentPlacement(subject, config)` gives the placement alone (region +
|
|
587
|
+
the tenant's namespace within that region's store) without needing the handles.
|
|
588
|
+
|
|
589
|
+
## Provision a new resident tenant
|
|
590
|
+
|
|
591
|
+
`provisionResidentTenant` runs an injected provisioner against the tenant's HOME
|
|
592
|
+
store + namespace, behind the SAME fail-closed guards as binding:
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
import { provisionResidentTenant } from '@voltro/database'
|
|
596
|
+
|
|
597
|
+
await provisionResidentTenant(subject, config, stores, async (store, placement) => {
|
|
598
|
+
// e.g. provisionTenantNamespace(tables, placement.namespace, sqlLayer, dialect)
|
|
599
|
+
})
|
|
600
|
+
// A US deployment provisioning an EU-homed tenant → TenantRegionUnavailable.
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
## How it composes with the `tenant()` mixin
|
|
604
|
+
|
|
605
|
+
Residency is the **physical** placement (which region's store); the
|
|
606
|
+
[`tenant()` mixin](/docs/multi-tenancy/mixin) is the **logical** scope (the
|
|
607
|
+
`WHERE tenantId = …` filter within a store). They stack: residency routes the
|
|
608
|
+
request to the right region's store, then the mixin scopes the rows inside it.
|
|
609
|
+
Namespace isolation within a region uses the same `resolveTenantNamespace` the
|
|
610
|
+
mixin's physical-isolation mode uses.
|