@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,3214 @@
|
|
|
1
|
+
# templates.apiBackends
|
|
2
|
+
|
|
3
|
+
> The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/templates/api-backend.md -->
|
|
10
|
+
## API · Backend
|
|
11
|
+
|
|
12
|
+
_The minimal Voltro backend — app.config + schema + one streaming query + one tenant-guarded mutation. Tenant-aware out of the box._
|
|
13
|
+
|
|
14
|
+
The minimal but complete api template: an `app.config.ts`, a one-table schema, one streaming query, and one tenant-guarded mutation. It's the smallest reference that shows the framework's core file convention — the **descriptor / `.server.ts` executor split** — and the tenant-scoping guard. Start here when you're not sure which api template fits. Template id: **`api-backend`**.
|
|
15
|
+
|
|
16
|
+
## Scaffold
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
voltro create-project acme --api=api-backend
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What ships
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
26
|
+
├── app.config.ts # type:api, name, store:'memory'
|
|
27
|
+
├── package.json
|
|
28
|
+
├── tsconfig.json
|
|
29
|
+
├── README.md
|
|
30
|
+
├── database/
|
|
31
|
+
│ └── schema.ts # actors + tenants (core) + a notes table
|
|
32
|
+
├── queries/
|
|
33
|
+
│ ├── notes.query.ts # descriptor — browser-safe
|
|
34
|
+
│ └── notes.query.server.ts # server executor — default export
|
|
35
|
+
└── mutations/
|
|
36
|
+
├── notes.create.mutation.ts # descriptor — browser-safe
|
|
37
|
+
└── notes.create.mutation.server.ts # server executor — default export
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
No actions, workflows, agents, or `AGENTS.md` ship — this template is deliberately the smallest working shape. The mail / storage / mariadb variants build on this same `notes` base.
|
|
41
|
+
|
|
42
|
+
## The descriptor / `.server.ts` split
|
|
43
|
+
|
|
44
|
+
Every query / mutation / action is **two files paired by basename**: a browser-safe descriptor (`*.query.ts` / `*.mutation.ts`) holding `defineQuery` / `defineMutation`, and a server-only executor (`*.query.server.ts` / `*.mutation.server.ts`) with a default export. The framework pairs them at boot. The descriptor file must never import `node:*` / database / runtime modules — it's what the web client pulls through codegen.
|
|
45
|
+
|
|
46
|
+
### Query — `queries/notes.query.ts` + `.server.ts`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// notes.query.ts — descriptor (browser-safe)
|
|
50
|
+
import { defineQuery } from '@voltro/protocol'
|
|
51
|
+
import { Schema } from 'effect'
|
|
52
|
+
|
|
53
|
+
export const listNotes = defineQuery({
|
|
54
|
+
name: 'notes.list',
|
|
55
|
+
input: Schema.Struct({}),
|
|
56
|
+
output: Schema.Struct({
|
|
57
|
+
id: Schema.String, title: Schema.String, body: Schema.String,
|
|
58
|
+
done: Schema.Boolean, tenantId: Schema.String, createdAt: Schema.Date,
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// notes.query.server.ts — executor (default export)
|
|
65
|
+
import type { AppContext } from '@voltro/runtime'
|
|
66
|
+
|
|
67
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
|
|
68
|
+
descriptor: {
|
|
69
|
+
table: 'notes' as const,
|
|
70
|
+
order: [{ column: 'createdAt' as const, direction: 'desc' as const }],
|
|
71
|
+
take: 100,
|
|
72
|
+
// tenant scope is AND-merged by the runtime — no manual eq('tenantId', …)
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export default execute
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Mutation — `mutations/notes.create.mutation.ts` + `.server.ts`
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// notes.create.mutation.ts — descriptor
|
|
83
|
+
import { defineMutation } from '@voltro/protocol'
|
|
84
|
+
import { TenantMismatch } from '@voltro/plugin-multitenancy'
|
|
85
|
+
import { Schema } from 'effect'
|
|
86
|
+
|
|
87
|
+
export const createNote = defineMutation({
|
|
88
|
+
name: 'notes.create',
|
|
89
|
+
target: { table: 'notes', op: 'insert',
|
|
90
|
+
shape: (input: { tenantId: string; title: string; body: string }) => ({
|
|
91
|
+
title: input.title, body: input.body, done: false,
|
|
92
|
+
tenantId: input.tenantId, createdAt: new Date(),
|
|
93
|
+
}) },
|
|
94
|
+
input: Schema.Struct({ tenantId: Schema.String, title: Schema.NonEmptyString, body: Schema.String }),
|
|
95
|
+
output: Schema.Struct({ /* id, title, body, done, tenantId, createdAt */ }),
|
|
96
|
+
error: TenantMismatch,
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// notes.create.mutation.server.ts — executor
|
|
102
|
+
import { assertOwnTenant } from '@voltro/plugin-multitenancy'
|
|
103
|
+
import type { AppContext } from '@voltro/runtime'
|
|
104
|
+
|
|
105
|
+
const execute = async (input: { tenantId: string; title: string; body: string }, ctx: AppContext) => {
|
|
106
|
+
// Cross-tenant write guard — input.tenantId MUST match the subject's,
|
|
107
|
+
// else the rpc layer surfaces a typed TenantMismatch.
|
|
108
|
+
assertOwnTenant(input.tenantId, ctx.request.subject)
|
|
109
|
+
// The framework auto-injects a `note_…` TypeID from the table's id() decl.
|
|
110
|
+
return ctx.store.insert('notes', {
|
|
111
|
+
title: input.title, body: input.body, done: false,
|
|
112
|
+
tenantId: input.tenantId, createdAt: new Date(),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default execute
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Database
|
|
120
|
+
|
|
121
|
+
The shipped schema declares the two framework core tables plus one example table:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// database/schema.ts
|
|
125
|
+
import { boolean, databaseHandle, id, table, text, timestamp, type InferRow } from '@voltro/database'
|
|
126
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
127
|
+
|
|
128
|
+
// Core tables — required by the audit / tenant mixins.
|
|
129
|
+
export const actors = table('actors', {
|
|
130
|
+
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
131
|
+
displayName: text().nullable(), createdAt: timestamp().default('now'),
|
|
132
|
+
})
|
|
133
|
+
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
|
|
134
|
+
|
|
135
|
+
// Application table — rename / replace with your own.
|
|
136
|
+
export const notes = table('notes', {
|
|
137
|
+
id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
|
|
138
|
+
})
|
|
139
|
+
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
140
|
+
.reactive() // subscriptions get a fresh snapshot/delta on every write to notes
|
|
141
|
+
|
|
142
|
+
export type Note = InferRow<typeof notes>
|
|
143
|
+
export const database = databaseHandle({ actors, tenants, notes })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate, so cross-tenant reads can't leak.
|
|
147
|
+
|
|
148
|
+
## Store — memory by default
|
|
149
|
+
|
|
150
|
+
`app.config.ts` ships `store: 'memory'` — fast startup, no Docker, data resets on restart. Good for dev, tests, and a first impression. Switch to a real SQL backend by setting the dialect + connection env (or `store:` in `app.config.ts`):
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
DB_DIALECT=postgres DB_URL=postgres://app:app@localhost:5432/acme voltro dev .
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`voltro dev` against a SQL store auto-migrates the discovered schema before any handler boots. See the SQL dialects guide for `mysql` / `mariadb` / `mssql` / `sqlite` / `turso`.
|
|
157
|
+
|
|
158
|
+
## Discovery
|
|
159
|
+
|
|
160
|
+
`voltro dev .` walks the app for the file conventions — `*.query.ts` + `*.query.server.ts`, `*.mutation.ts` + `*.mutation.server.ts`, `*.action.ts` + `*.action.server.ts`, `*.workflow.tsx`, `*.cron.tsx`, `schema.ts` / `*.entity.ts` — pairs each descriptor with its `.server.ts` executor, and rewrites `rpcGroup.generated.ts` (the typed client). Drop more files anywhere in the tree; nothing is registered manually.
|
|
161
|
+
|
|
162
|
+
## When to use api-backend vs. the variants
|
|
163
|
+
|
|
164
|
+
| You need… | Pick |
|
|
165
|
+
|---|---|
|
|
166
|
+
| The smallest reference to extend | `api-backend` |
|
|
167
|
+
| Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
|
|
168
|
+
| File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
|
|
169
|
+
| MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
|
|
170
|
+
|
|
171
|
+
All four share this `notes` base; the variants just wire a plugin / store on top.
|
|
172
|
+
|
|
173
|
+
## Pairs well with
|
|
174
|
+
|
|
175
|
+
- Any web template — `api-backend` is the most generic api.
|
|
176
|
+
- [`@voltro/plugin-auth`](/docs/plugins/auth) — wire it in for real sign-in.
|
|
177
|
+
|
|
178
|
+
## Anti-patterns
|
|
179
|
+
|
|
180
|
+
- **Putting `node:*` / database imports in a descriptor (`*.query.ts`).** Those files reach the browser bundle through codegen. Keep server-only code in the `.server.ts` half.
|
|
181
|
+
- **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — without it a client authenticated as A can submit `tenantId: 'B'` and the row lands in B's data.
|
|
182
|
+
- **Discovery walker as a security boundary.** It picks up everything matching the pattern. Every discovered descriptor is exposed via the rpc client — don't put secrets in one.
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
<!-- source: en/templates/api-backend-mail.md -->
|
|
189
|
+
## API · Backend + Mail
|
|
190
|
+
|
|
191
|
+
_The api-backend base plus transactional email wired — @voltro/plugin-mail and a React-Email welcome template you preview and send from the dashboard._
|
|
192
|
+
|
|
193
|
+
The same minimal `notes` base as [`api-backend`](/docs/templates/api-backend), plus transactional email wired out of the box via [`@voltro/plugin-mail`](/docs/plugins/mail). It ships a React-Email welcome template and a `mail.sendWelcome` action. Template id: **`api-backend-mail`**.
|
|
194
|
+
|
|
195
|
+
## Scaffold
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
voltro create-project acme --api=api-backend-mail
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## What ships
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
205
|
+
├── app.config.ts # api + mailPlugin({ provider: 'console' })
|
|
206
|
+
├── package.json
|
|
207
|
+
├── tsconfig.json
|
|
208
|
+
├── README.md
|
|
209
|
+
├── database/
|
|
210
|
+
│ └── schema.ts # actors + tenants + notes (same as api-backend)
|
|
211
|
+
├── queries/
|
|
212
|
+
│ ├── notes.query.ts + .query.server.ts
|
|
213
|
+
├── mutations/
|
|
214
|
+
│ └── notes.create.mutation.ts + .mutation.server.ts
|
|
215
|
+
├── emails/
|
|
216
|
+
│ └── welcome.email.tsx # a React-Email welcome template
|
|
217
|
+
└── actions/
|
|
218
|
+
└── sendWelcome.action.ts + .action.server.ts # mail.sendWelcome
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## The wiring
|
|
222
|
+
|
|
223
|
+
`app.config.ts` adds the mail plugin with the zero-config `console` provider:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { mailPlugin } from '@voltro/plugin-mail'
|
|
227
|
+
|
|
228
|
+
export default {
|
|
229
|
+
type: 'api' as const,
|
|
230
|
+
name: '{{projectNamePascal}}{{appNamePascal}}',
|
|
231
|
+
store: 'memory' as const,
|
|
232
|
+
plugins: [
|
|
233
|
+
mailPlugin({ provider: 'console', from: '{{projectNamePascal}} <hello@example.com>' }),
|
|
234
|
+
],
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The `console` provider **logs** what would be sent (no API key) and captures it in the dashboard outbox. For real delivery, switch `provider` to `'resend'` / `'postmark'` / `'sendgrid'` / `'smtp'` and set the matching env var (e.g. `RESEND_API_KEY`).
|
|
239
|
+
|
|
240
|
+
## The email template — `emails/welcome.email.tsx`
|
|
241
|
+
|
|
242
|
+
Authored with `defineEmail` + `@react-email/components`. Auto-discovered by `voltro dev` and registered with the plugin, so `mail.send({ template: 'welcome', props })` works and the dashboard Mail panel previews it:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
import { defineEmail } from '@voltro/plugin-mail'
|
|
246
|
+
import { Body, Button, Container, Heading, Html, Text } from '@react-email/components'
|
|
247
|
+
import { Schema } from 'effect'
|
|
248
|
+
|
|
249
|
+
export const welcome = defineEmail({
|
|
250
|
+
name: 'welcome',
|
|
251
|
+
props: Schema.Struct({ name: Schema.String }),
|
|
252
|
+
preview: { name: 'Mario' }, // pre-fills the dashboard preview box
|
|
253
|
+
subject: (p) => `Welcome, ${p.name}!`,
|
|
254
|
+
render: (p) => (
|
|
255
|
+
<Html><Body><Container>
|
|
256
|
+
<Heading>Welcome, {p.name} 👋</Heading>
|
|
257
|
+
<Text>Your workspace is ready.</Text>
|
|
258
|
+
<Button href="https://example.com/dashboard">Open your dashboard</Button>
|
|
259
|
+
</Container></Body></Html>
|
|
260
|
+
),
|
|
261
|
+
})
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The shipped template carries `prefers-color-scheme` dark-mode handling and inline styles for email-client compatibility — replace the copy and brand for your product.
|
|
265
|
+
|
|
266
|
+
## Sending — `actions/sendWelcome.action.ts` + `.server.ts`
|
|
267
|
+
|
|
268
|
+
Email is external I/O, so it's an **action**, not a mutation. The descriptor declares the wire shape; the `.server.ts` executor calls `MailService`:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
// sendWelcome.action.server.ts
|
|
272
|
+
import { Effect } from 'effect'
|
|
273
|
+
import { MailService } from '@voltro/plugin-mail'
|
|
274
|
+
|
|
275
|
+
const execute = (input: { email: string; name: string }) =>
|
|
276
|
+
Effect.gen(function* () {
|
|
277
|
+
const mail = yield* MailService
|
|
278
|
+
const res = yield* mail.send({ to: input.email, template: 'welcome', props: { name: input.name } })
|
|
279
|
+
return { id: res.id, provider: res.provider }
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
export default execute
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Preview templates and inspect the dev outbox in the dashboard's **Mail** tab.
|
|
286
|
+
|
|
287
|
+
## Pairs well with
|
|
288
|
+
|
|
289
|
+
- Any web template — wire the `mail.sendWelcome` action behind a signup flow.
|
|
290
|
+
|
|
291
|
+
## See also
|
|
292
|
+
|
|
293
|
+
- [`@voltro/plugin-mail`](/docs/plugins/mail) — providers, templates, suppression, durable delivery.
|
|
294
|
+
- [`api-backend`](/docs/templates/api-backend) — the base this builds on.
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
<!-- source: en/templates/api-backend-storage.md -->
|
|
301
|
+
## API · Backend + Storage
|
|
302
|
+
|
|
303
|
+
_The api-backend base plus file storage wired — @voltro/plugin-storage with public (CDN-direct) and private (policy + grants) objects, with upload examples._
|
|
304
|
+
|
|
305
|
+
The same minimal `notes` base as [`api-backend`](/docs/templates/api-backend), plus file storage wired out of the box via [`@voltro/plugin-storage`](/docs/plugins/storage). It ships two upload actions demonstrating the public and private object classes. Template id: **`api-backend-storage`**.
|
|
306
|
+
|
|
307
|
+
## Scaffold
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
voltro create-project acme --api=api-backend-storage
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## What ships
|
|
314
|
+
|
|
315
|
+
```text
|
|
316
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
317
|
+
├── app.config.ts # api + storagePlugin({ provider: 'memory' })
|
|
318
|
+
├── package.json
|
|
319
|
+
├── tsconfig.json
|
|
320
|
+
├── README.md
|
|
321
|
+
├── database/
|
|
322
|
+
│ └── schema.ts # actors + tenants + notes (same as api-backend)
|
|
323
|
+
├── queries/
|
|
324
|
+
│ └── notes.query.ts + .query.server.ts
|
|
325
|
+
├── mutations/
|
|
326
|
+
│ └── notes.create.mutation.ts + .mutation.server.ts
|
|
327
|
+
└── actions/
|
|
328
|
+
├── uploadAvatar.action.ts + .action.server.ts # storage.uploadAvatar — PUBLIC
|
|
329
|
+
└── uploadDocument.action.ts + .action.server.ts # storage.uploadDocument — PRIVATE
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## The wiring
|
|
333
|
+
|
|
334
|
+
`app.config.ts` adds the storage plugin with the zero-config `memory` provider:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { storagePlugin } from '@voltro/plugin-storage'
|
|
338
|
+
|
|
339
|
+
export default {
|
|
340
|
+
type: 'api' as const,
|
|
341
|
+
name: '{{projectNamePascal}}{{appNamePascal}}',
|
|
342
|
+
store: 'memory' as const,
|
|
343
|
+
plugins: [
|
|
344
|
+
storagePlugin({ provider: 'memory' }),
|
|
345
|
+
],
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The `memory` provider keeps blobs in-process (dev only). Swap to `'s3'` / `'minio'` (any S3-compatible bucket via `endpoint` — AWS, R2, GCS, B2, Wasabi), `'azure'`, `'filesystem'`, or `'database'` for real object storage. Set `cdnBaseUrl` on the plugin for production public delivery.
|
|
350
|
+
|
|
351
|
+
## Two object classes
|
|
352
|
+
|
|
353
|
+
- **PUBLIC** (`visibility: 'public'`) — served direct from the bucket/CDN; the returned `url` is what you put in an `<img src>`. The app's serve route is only a dev fallback.
|
|
354
|
+
- **PRIVATE** (default) — gated by an **access policy** (any-of rules over `owner` / `roles` / `groups` / `scopes` / `tenant` / `apiKey` / `password` / a custom `guard`) PLUS **per-object grants** (share a file with a specific user / group / api-key, optionally expiring).
|
|
355
|
+
|
|
356
|
+
### `actions/uploadAvatar.action.server.ts` — a PUBLIC object
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { Effect } from 'effect'
|
|
360
|
+
import type { AppContext } from '@voltro/runtime'
|
|
361
|
+
import { StorageService } from '@voltro/plugin-storage'
|
|
362
|
+
|
|
363
|
+
const execute = (input: { bytesBase64: string; contentType: string }, ctx: AppContext) =>
|
|
364
|
+
Effect.gen(function* () {
|
|
365
|
+
const storage = yield* StorageService
|
|
366
|
+
const bytes = new Uint8Array(Buffer.from(input.bytesBase64, 'base64'))
|
|
367
|
+
const ref = yield* storage.put({
|
|
368
|
+
bytes, contentType: input.contentType,
|
|
369
|
+
visibility: 'public',
|
|
370
|
+
ownerId: ctx.request.subject.id, tenantId: ctx.request.subject.tenantId,
|
|
371
|
+
key: `avatars/${ctx.request.subject.id ?? 'anon'}/avatar`,
|
|
372
|
+
})
|
|
373
|
+
const url = yield* storage.getUrl(ref.id)
|
|
374
|
+
return { id: ref.id, url }
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
export default execute
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The shipped `uploadDocument` action mirrors this with `visibility: 'private'` so the file is access-checked on read. File upload is external I/O → an action, not a mutation. For large files prefer a presigned direct-to-bucket upload via the plugin's built-in `storage.mintUploadUrl` route.
|
|
381
|
+
|
|
382
|
+
## Built-in routes + serve endpoint
|
|
383
|
+
|
|
384
|
+
The plugin ships typed client routes — `storage.upload`, `storage.mintUrl`, `storage.mintUploadUrl`, `storage.share`, `storage.revoke`, `storage.listGrants` — plus the serve endpoint `GET /_voltro/storage/:id` (public → 302 to CDN; private → access-checked). Browse and share stored objects from the dashboard's **Storage** tab. `_voltro_storage_refs` + `_voltro_storage_grants` are auto-migrated on a SQL store.
|
|
385
|
+
|
|
386
|
+
## Pairs well with
|
|
387
|
+
|
|
388
|
+
- Any web template — wire the upload actions behind a file picker.
|
|
389
|
+
|
|
390
|
+
## See also
|
|
391
|
+
|
|
392
|
+
- [`@voltro/plugin-storage`](/docs/plugins/storage) — providers, access policies, grants, upload constraints, virus scanning, image transforms.
|
|
393
|
+
- [`api-backend`](/docs/templates/api-backend) — the base this builds on.
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
<!-- source: en/templates/api-backend-mariadb.md -->
|
|
400
|
+
## API · Backend (MariaDB)
|
|
401
|
+
|
|
402
|
+
_MariaDB-backed Voltro backend — binlog CDC real-time across replicas plus file storage. The dogfooded shape for K8s multi-replica apps._
|
|
403
|
+
|
|
404
|
+
The `notes` base wired for **MariaDB**: `store: 'mariadb'` turns on the binlog CDC reader so reactive subscriptions fan out across every replica via the database's binary log — no Redis/NATS, the binlog IS the message bus. File storage is on by default. This is the local-dev mirror of the `helm` MariaDB production shape. Template id: **`api-backend-mariadb`**.
|
|
405
|
+
|
|
406
|
+
## Scaffold
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
voltro create-project acme --api=api-backend-mariadb --baseline=compose-mariadb
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Pair it with the `compose-mariadb` baseline (or `helm`): MariaDB must run with `binlog_format=ROW` + `binlog_row_image=FULL` and a user holding `REPLICATION SLAVE, REPLICATION CLIENT`, plus MinIO/S3 for storage. The `compose-mariadb` baseline sets all of this up with one `pnpm db:up`.
|
|
413
|
+
|
|
414
|
+
## What ships
|
|
415
|
+
|
|
416
|
+
```text
|
|
417
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
418
|
+
├── app.config.ts # store:'mariadb' + storagePlugin()
|
|
419
|
+
├── .env.example # DB / storage / AI / auth / cluster env
|
|
420
|
+
├── package.json
|
|
421
|
+
├── tsconfig.json
|
|
422
|
+
├── README.md
|
|
423
|
+
├── database/
|
|
424
|
+
│ └── schema.ts # actors + tenants + notes (same as api-backend)
|
|
425
|
+
├── queries/
|
|
426
|
+
│ └── notes.query.ts + .query.server.ts
|
|
427
|
+
└── mutations/
|
|
428
|
+
└── notes.create.mutation.ts + .mutation.server.ts
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## The wiring
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// app.config.ts
|
|
435
|
+
import { storagePlugin } from '@voltro/plugin-storage'
|
|
436
|
+
|
|
437
|
+
export default {
|
|
438
|
+
type: 'api' as const,
|
|
439
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
440
|
+
store: 'mariadb' as const, // turns on binlog CDC
|
|
441
|
+
plugins: [
|
|
442
|
+
// Blobs in object storage (MinIO locally, S3 in prod); metadata in
|
|
443
|
+
// _voltro_storage_refs. Provider resolved from STORAGE_PROVIDER.
|
|
444
|
+
storagePlugin(),
|
|
445
|
+
],
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The shipped `app.config.ts` also carries commented-out `jwtBearerStrategy` + `apiKeyStrategy` blocks under an `auth:` key — uncomment and configure them to wire your IdP / API keys.
|
|
450
|
+
|
|
451
|
+
## Environment — `.env.example`
|
|
452
|
+
|
|
453
|
+
Copy to `.env` and adjust. The key vars:
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
# Database (MariaDB + binlog CDC)
|
|
457
|
+
DB_DIALECT=mariadb
|
|
458
|
+
DB_URL=mysql://app:app@localhost:3307/{{projectNameSnake}}
|
|
459
|
+
CDC=1 # push-based real-time across replicas (set 0 for inline-only)
|
|
460
|
+
|
|
461
|
+
# File storage — MinIO locally, S3 in prod
|
|
462
|
+
STORAGE_PROVIDER=minio
|
|
463
|
+
S3_ENDPOINT=http://localhost:9000
|
|
464
|
+
S3_BUCKET={{projectNameSnake}}
|
|
465
|
+
S3_ACCESS_KEY_ID=minioadmin
|
|
466
|
+
S3_SECRET_ACCESS_KEY=minioadmin
|
|
467
|
+
S3_FORCE_PATH_STYLE=1
|
|
468
|
+
|
|
469
|
+
# Auth — HMAC key for signed sessions (REQUIRED in production)
|
|
470
|
+
VOLTRO_SESSION_SECRET=dev-only-change-me
|
|
471
|
+
|
|
472
|
+
# Cluster (K8s, >1 replica) — advertise a routable host so a workflow
|
|
473
|
+
# can resume on another pod. Inject POD_IP via the downward API in prod.
|
|
474
|
+
# VOLTRO_WORKFLOW_RUNNER_HOST=
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Why MariaDB CDC
|
|
478
|
+
|
|
479
|
+
Behind a load balancer each live client WebSocket lives on one replica. A mutation runs on one replica; without cross-instance fan-out, other replicas' clients go stale. MariaDB's ROW-format binlog is one of the two dialects (with postgres `LISTEN/NOTIFY`) that observe writes across instances natively — `CDC=1` reads the binlog and injects remote change events into every replica's store. With `CDC=0` it falls back to single-process inline emit.
|
|
480
|
+
|
|
481
|
+
## Boot
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
pnpm install
|
|
485
|
+
cp .env.example .env # then adjust DB_URL / secrets
|
|
486
|
+
# Start MariaDB (binlog ROW) + MinIO — the compose-mariadb baseline does this:
|
|
487
|
+
pnpm db:up
|
|
488
|
+
pnpm --filter @acme/api dev
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`voltro dev` against MariaDB auto-migrates the schema before any handler boots, and the boot log confirms `CDC: binlog CDC (ROW)`.
|
|
492
|
+
|
|
493
|
+
## Pairs well with
|
|
494
|
+
|
|
495
|
+
- The [`compose-mariadb` baseline](/docs/templates/overview#baselines-the-deploy-layer) — local MariaDB + MinIO.
|
|
496
|
+
- The `helm` baseline — the K8s production target (per-pod `server_id` + workflow-runner host).
|
|
497
|
+
|
|
498
|
+
## See also
|
|
499
|
+
|
|
500
|
+
- [`api-backend`](/docs/templates/api-backend) — the base this builds on.
|
|
501
|
+
- [`@voltro/plugin-storage`](/docs/plugins/storage) — the storage plugin wired here.
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
<!-- source: en/templates/api-durable.md -->
|
|
508
|
+
## API · Durable
|
|
509
|
+
|
|
510
|
+
_A cohesive order-fulfillment domain that exercises the whole durable surface — a workflow with a human-approval gate, an event trigger, a signal-injecting mutation, a cron, a subscriber, an aggregate, and a startup hook. Zero-infra boot._
|
|
511
|
+
|
|
512
|
+
The template that exercises the framework's **durable-execution surface** end-to-end. Instead of a one-table CRUD reference, it ships a cohesive **order-fulfillment** domain: placing an order emits a domain event, a trigger starts a durable workflow, the workflow reserves stock → sleeps → **parks on a human-approval gate**, and a second mutation injects the approval signal that wakes it. Around that flow it wires a nightly cron, a table subscriber, a materialized aggregate, and a run-once startup hook — every durable / scheduled / reactive primitive in one app. It boots with **zero infrastructure** (`store: 'memory'`). Template id: **`api-durable`**.
|
|
513
|
+
|
|
514
|
+
## Scaffold
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
voltro create-project acme --api=api-durable
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
No env or services required. `store: 'memory'` runs the in-process durable workflow engine (replay within one process). Switch `app.config.ts` to `store: 'postgres'` (and `docker compose up`) when you want durable state across restarts + multi-instance cluster workflow runners.
|
|
521
|
+
|
|
522
|
+
## What ships
|
|
523
|
+
|
|
524
|
+
```text
|
|
525
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
526
|
+
├── app.config.ts # type:api, store:'memory', defineEnv
|
|
527
|
+
├── package.json
|
|
528
|
+
├── tsconfig.json
|
|
529
|
+
├── README.md
|
|
530
|
+
├── database/
|
|
531
|
+
│ └── schema.ts # actors + tenants (core) + an orders table
|
|
532
|
+
├── mutations/
|
|
533
|
+
│ ├── orders.place.mutation.ts # descriptor — insert + emit order.placed
|
|
534
|
+
│ ├── orders.place.mutation.server.ts # executor — tenant guard + post-commit emit
|
|
535
|
+
│ ├── orders.approve.mutation.ts # descriptor — the human-approval surface
|
|
536
|
+
│ └── orders.approve.mutation.server.ts # executor — inject the approval signal
|
|
537
|
+
├── triggers/
|
|
538
|
+
│ └── order.placed.trigger.tsx # event order.placed → start orders.fulfill
|
|
539
|
+
├── workflows/
|
|
540
|
+
│ ├── order.fulfill.workflow.tsx # descriptor — browser-safe contract
|
|
541
|
+
│ └── order.fulfill.workflow.server.tsx # executor — step → sleep → awaitSignal → step
|
|
542
|
+
├── schedules/
|
|
543
|
+
│ └── nightlyReport.cron.tsx # cron 0 2 * * * (UTC) — per-status tally
|
|
544
|
+
├── subscribers/
|
|
545
|
+
│ └── orderChanges.subscribe.ts # post-commit reaction to every orders write
|
|
546
|
+
├── aggregates/
|
|
547
|
+
│ └── orderStats.aggregate.ts # materialized per-tenant/per-status roll-up
|
|
548
|
+
└── startup/
|
|
549
|
+
└── warm.startup.tsx # run-once boot hook + onShutdown teardown
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Every primitive is **auto-discovered by file convention** — nothing is registered in `app.config.ts`. Compared to [`api-backend`](/docs/templates/api-backend) (the smallest CRUD reference), this template adds the workflow / trigger / schedule / subscriber / aggregate / startup conventions on top of the same descriptor-split mutation shape.
|
|
553
|
+
|
|
554
|
+
## The end-to-end flow
|
|
555
|
+
|
|
556
|
+
1. **Place an order** → `orders.place` inserts the row, then emits `order.placed` *after the transaction commits* — so a rollback never starts a workflow for an order that doesn't exist.
|
|
557
|
+
2. **The trigger fires** → `order.placed.trigger.tsx` starts the durable `orders.fulfill` workflow with `{ orderId, tenantId }`.
|
|
558
|
+
3. **The workflow runs durably** → reserves stock (with saga-style compensation), waits a journaled delay, then **parks on `awaitSignal('approval')`**.
|
|
559
|
+
4. **A human approves** → `orders.approve` re-derives the run's deterministic executionId and injects the `approval` signal; the parked run wakes (sub-second) and either ships or marks the order rejected.
|
|
560
|
+
|
|
561
|
+
Meanwhile the **subscriber** logs every `orders` write, the **aggregate** keeps a live per-status tally, the **schedule** reports nightly, and the **startup hook** holds a process-lifetime resource.
|
|
562
|
+
|
|
563
|
+
## Database
|
|
564
|
+
|
|
565
|
+
The schema declares the two framework core tables plus an `orders` table carrying the order lifecycle:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
// database/schema.ts
|
|
569
|
+
import {
|
|
570
|
+
databaseHandle, id, integer, table, text, timestamp, type InferRow,
|
|
571
|
+
} from '@voltro/database'
|
|
572
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
573
|
+
|
|
574
|
+
// Core tables — required by the audit / tenant mixins.
|
|
575
|
+
export const actors = table('actors', {
|
|
576
|
+
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
577
|
+
displayName: text().nullable(), createdAt: timestamp().default('now'),
|
|
578
|
+
})
|
|
579
|
+
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
|
|
580
|
+
|
|
581
|
+
// Application table — the order-fulfillment domain.
|
|
582
|
+
export const orders = table('orders', {
|
|
583
|
+
id: id({ prefix: 'order' }),
|
|
584
|
+
// The fulfillment lifecycle. `oneOf` narrows the row type to the literal
|
|
585
|
+
// union AND emits a cross-dialect CHECK constraint.
|
|
586
|
+
status: text().oneOf(['placed', 'reserved', 'approved', 'rejected', 'shipped']).default('placed'),
|
|
587
|
+
customerName: text(),
|
|
588
|
+
amountCents: integer(),
|
|
589
|
+
})
|
|
590
|
+
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
591
|
+
.reactive() // subscriber + live subscriptions wake on every write to orders
|
|
592
|
+
|
|
593
|
+
export type Order = InferRow<typeof orders>
|
|
594
|
+
export const database = databaseHandle({ actors, tenants, orders })
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. `.reactive()` opts the table into the matcher engine so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
|
|
598
|
+
|
|
599
|
+
## Place — mutation that emits a domain event
|
|
600
|
+
|
|
601
|
+
`orders.place` is the entry point. The descriptor declares the optimistic `target` + typed error; the executor writes the row, guards the tenant, and emits `order.placed` **after commit**.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
// orders.place.mutation.ts — descriptor (browser-safe)
|
|
605
|
+
import { defineMutation } from '@voltro/protocol'
|
|
606
|
+
import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
|
|
607
|
+
import { Schema } from 'effect'
|
|
608
|
+
|
|
609
|
+
export const placeOrder = defineMutation({
|
|
610
|
+
name: 'orders.place',
|
|
611
|
+
// Auto-optimistic: every query whose `source: 'orders'` matches gets a
|
|
612
|
+
// placeholder row prepended; the server delta replaces it on commit.
|
|
613
|
+
target: {
|
|
614
|
+
table: 'orders', op: 'insert',
|
|
615
|
+
shape: (input: { tenantId: string; customerName: string; amountCents: number }) => ({
|
|
616
|
+
status: 'placed', customerName: input.customerName,
|
|
617
|
+
amountCents: input.amountCents, tenantId: input.tenantId,
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
input: Schema.Struct({
|
|
621
|
+
tenantId: Schema.String,
|
|
622
|
+
customerName: Schema.NonEmptyString,
|
|
623
|
+
amountCents: Schema.Int.pipe(Schema.greaterThan(0)),
|
|
624
|
+
}),
|
|
625
|
+
output: Schema.Struct({
|
|
626
|
+
id: Schema.String, status: Schema.String, customerName: Schema.String,
|
|
627
|
+
amountCents: Schema.Number, tenantId: Schema.String,
|
|
628
|
+
}),
|
|
629
|
+
error: TenantMismatch,
|
|
630
|
+
})
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
// orders.place.mutation.server.ts — executor (default export)
|
|
635
|
+
import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
|
|
636
|
+
import type { AppContext } from '@voltro/runtime'
|
|
637
|
+
|
|
638
|
+
const execute = async (
|
|
639
|
+
input: { tenantId: string; customerName: string; amountCents: number },
|
|
640
|
+
ctx: AppContext,
|
|
641
|
+
) => {
|
|
642
|
+
// Cross-tenant write guard — reject a caller spoofing input.tenantId.
|
|
643
|
+
assertOwnTenant(input.tenantId, ctx.request.subject)
|
|
644
|
+
|
|
645
|
+
// Framework auto-injects an `order_…` id + the tenant/audit columns.
|
|
646
|
+
const row = await ctx.store.insert('orders', {
|
|
647
|
+
status: 'placed', customerName: input.customerName,
|
|
648
|
+
amountCents: input.amountCents, tenantId: input.tenantId,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
// Post-commit fan-out → the `order.placed` trigger starts the
|
|
652
|
+
// `orders.fulfill` workflow. Guard `ctx.events` so a unit-test ctx
|
|
653
|
+
// without it doesn't throw.
|
|
654
|
+
await ctx.events?.emit('order.placed', {
|
|
655
|
+
orderId: row['id'] as string, tenantId: input.tenantId,
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
id: row['id'] as string, status: row['status'] as string,
|
|
660
|
+
customerName: row['customerName'] as string,
|
|
661
|
+
amountCents: row['amountCents'] as number, tenantId: row['tenantId'] as string,
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export default execute
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
`TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.emit(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
|
|
669
|
+
|
|
670
|
+
## Event trigger — start a workflow from a domain event
|
|
671
|
+
|
|
672
|
+
The trigger maps the `order.placed` event to a workflow start. It's auto-discovered (`*.trigger.tsx`); no `app.config.ts` wiring. See [event triggers](/docs/workflows/event-triggers).
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
// triggers/order.placed.trigger.tsx
|
|
676
|
+
import { defineEventTrigger } from '@voltro/runtime'
|
|
677
|
+
|
|
678
|
+
export default defineEventTrigger<
|
|
679
|
+
{ orderId: string; tenantId: string }
|
|
680
|
+
>({
|
|
681
|
+
event: 'order.placed',
|
|
682
|
+
workflow: 'orders.fulfill',
|
|
683
|
+
// Dedup deliveries: a re-delivered event (crash + replay) with the same
|
|
684
|
+
// key short-circuits the second start.
|
|
685
|
+
idempotencyKey: (event) => `order.placed:${event.data.orderId}`,
|
|
686
|
+
})
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
The event payload IS the workflow payload here (both `{ orderId, tenantId }`), so no `payload` mapper is needed — pass one when the shapes differ.
|
|
690
|
+
|
|
691
|
+
## Durable workflow — step → sleep → awaitSignal → step
|
|
692
|
+
|
|
693
|
+
Like the rpc primitives, a workflow is **two files paired by basename**: a browser-safe `*.workflow.tsx` descriptor (the `workflow({...})` contract — schema + idempotency only) and a server-only `*.workflow.server.tsx` executor. See [workflows](/docs/workflows/overview).
|
|
694
|
+
|
|
695
|
+
```tsx
|
|
696
|
+
// workflows/order.fulfill.workflow.tsx — descriptor (browser-safe)
|
|
697
|
+
import { workflow } from '@voltro/workflow/define'
|
|
698
|
+
import { Schema } from 'effect'
|
|
699
|
+
|
|
700
|
+
export const FulfillOrder = workflow({
|
|
701
|
+
name: 'orders.fulfill',
|
|
702
|
+
payload: {
|
|
703
|
+
orderId: Schema.String,
|
|
704
|
+
tenantId: Schema.String,
|
|
705
|
+
},
|
|
706
|
+
success: Schema.Struct({
|
|
707
|
+
orderId: Schema.String,
|
|
708
|
+
outcome: Schema.Literal('shipped', 'rejected'),
|
|
709
|
+
}),
|
|
710
|
+
// Dedup concurrent starts for the same order — after a crash + replay the
|
|
711
|
+
// trigger could fan out twice; the same key collapses them to one run.
|
|
712
|
+
idempotencyKey: ({ orderId }) => `fulfill:${orderId}`,
|
|
713
|
+
})
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
The descriptor imports `workflow` from the browser-safe `@voltro/workflow/define` subpath so the codegen can value-import it for the client; the executor imports `step` / `sleep` / `awaitSignal` from the full `@voltro/workflow`.
|
|
717
|
+
|
|
718
|
+
```tsx
|
|
719
|
+
// workflows/order.fulfill.workflow.server.tsx — executor (server-only)
|
|
720
|
+
import { step, sleep, awaitSignal, withCompensation } from '@voltro/workflow'
|
|
721
|
+
import { Effect, Schema } from 'effect'
|
|
722
|
+
import type { AppContext } from '@voltro/runtime'
|
|
723
|
+
|
|
724
|
+
const Decision = Schema.Struct({ approved: Schema.Boolean })
|
|
725
|
+
|
|
726
|
+
const buildExecute = (ctx: AppContext) =>
|
|
727
|
+
(payload: { orderId: string; tenantId: string }, _executionId: string) =>
|
|
728
|
+
Effect.gen(function* () {
|
|
729
|
+
const { orderId } = payload
|
|
730
|
+
|
|
731
|
+
// 1. Reserve stock. `withCompensation` registers an undo that runs
|
|
732
|
+
// only if the workflow as a whole fails after this point.
|
|
733
|
+
yield* withCompensation(
|
|
734
|
+
step({
|
|
735
|
+
name: 'reserve-stock',
|
|
736
|
+
input: { orderId },
|
|
737
|
+
success: Schema.Struct({ orderId: Schema.String }),
|
|
738
|
+
execute: Effect.tryPromise({
|
|
739
|
+
try: async () => {
|
|
740
|
+
await ctx.store.update('orders', orderId, { status: 'reserved' })
|
|
741
|
+
return { orderId }
|
|
742
|
+
},
|
|
743
|
+
catch: (error) => error as never,
|
|
744
|
+
}),
|
|
745
|
+
}),
|
|
746
|
+
// Saga rollback: release the reservation on whole-workflow failure.
|
|
747
|
+
() =>
|
|
748
|
+
Effect.promise(() =>
|
|
749
|
+
ctx.store.update('orders', orderId, { status: 'placed' }).then(() => undefined),
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
// 2. Durable delay. The wake is journaled — survives a restart.
|
|
754
|
+
yield* sleep({ name: 'settle-window', duration: '5 seconds' })
|
|
755
|
+
|
|
756
|
+
// 3. Human-approval gate. The run parks here until an external caller
|
|
757
|
+
// sends a matching `approval` signal; the parsed payload is
|
|
758
|
+
// journaled, so a post-signal replay returns it instantly.
|
|
759
|
+
const decision = yield* awaitSignal(ctx, {
|
|
760
|
+
name: 'approval',
|
|
761
|
+
schema: Decision,
|
|
762
|
+
// pollIntervalMs defaults to 200 (backoff → 5s); timeoutMs 24h.
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
if (!decision.approved) {
|
|
766
|
+
yield* step({
|
|
767
|
+
name: 'mark-rejected',
|
|
768
|
+
input: { orderId },
|
|
769
|
+
success: Schema.Struct({ orderId: Schema.String }),
|
|
770
|
+
execute: Effect.tryPromise({
|
|
771
|
+
try: async () => {
|
|
772
|
+
await ctx.store.update('orders', orderId, { status: 'rejected' })
|
|
773
|
+
return { orderId }
|
|
774
|
+
},
|
|
775
|
+
catch: (error) => error as never,
|
|
776
|
+
}),
|
|
777
|
+
})
|
|
778
|
+
return { orderId, outcome: 'rejected' as const }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 4. Approved → ship.
|
|
782
|
+
yield* step({
|
|
783
|
+
name: 'ship',
|
|
784
|
+
input: { orderId },
|
|
785
|
+
success: Schema.Struct({ orderId: Schema.String }),
|
|
786
|
+
execute: Effect.tryPromise({
|
|
787
|
+
try: async () => {
|
|
788
|
+
await ctx.store.update('orders', orderId, { status: 'shipped' })
|
|
789
|
+
return { orderId }
|
|
790
|
+
},
|
|
791
|
+
catch: (error) => error as never,
|
|
792
|
+
}),
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
return { orderId, outcome: 'shipped' as const }
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
export default buildExecute
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
Three durable primitives in one body: each `step()` is journaled (replayed from its cached result on a crash, never re-run); [`sleep`](/docs/workflows/sleep) is a journaled delay that survives a restart; `awaitSignal` parks the run until an external decision arrives. `withCompensation` adds a saga-style finalizer that runs only if the whole workflow later fails — releasing the reservation so a downstream failure doesn't strand held stock. The default export is a **factory** `(ctx) => execute`; the CLI calls `workflow.toLayer(execute)` so the executor closes over the live `AppContext`.
|
|
802
|
+
|
|
803
|
+
## Approve — inject the signal that wakes the parked run
|
|
804
|
+
|
|
805
|
+
`orders.approve` is the human-approval surface. It pokes the workflow rather than writing a row, so it declares no optimistic `target`.
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
// orders.approve.mutation.ts — descriptor (browser-safe)
|
|
809
|
+
import { defineMutation } from '@voltro/protocol'
|
|
810
|
+
import { Schema } from 'effect'
|
|
811
|
+
|
|
812
|
+
export const approveOrder = defineMutation({
|
|
813
|
+
name: 'orders.approve',
|
|
814
|
+
input: Schema.Struct({
|
|
815
|
+
orderId: Schema.String,
|
|
816
|
+
tenantId: Schema.String,
|
|
817
|
+
approved: Schema.Boolean,
|
|
818
|
+
}),
|
|
819
|
+
output: Schema.Struct({
|
|
820
|
+
orderId: Schema.String,
|
|
821
|
+
eventId: Schema.String,
|
|
822
|
+
}),
|
|
823
|
+
})
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
// orders.approve.mutation.server.ts — executor
|
|
828
|
+
import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
|
|
829
|
+
import type { AppContext } from '@voltro/runtime'
|
|
830
|
+
|
|
831
|
+
const execute = async (
|
|
832
|
+
input: { orderId: string; tenantId: string; approved: boolean },
|
|
833
|
+
ctx: AppContext,
|
|
834
|
+
) => {
|
|
835
|
+
assertOwnTenant(input.tenantId, ctx.request.subject)
|
|
836
|
+
|
|
837
|
+
if (!ctx.workflows) {
|
|
838
|
+
throw new Error('orders.approve: workflow runtime is not available in this context')
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Deterministic executionId for the running fulfillment workflow — same
|
|
842
|
+
// payload the `order.placed` trigger started it with.
|
|
843
|
+
const executionId = await ctx.workflows.executionId('orders.fulfill', {
|
|
844
|
+
orderId: input.orderId,
|
|
845
|
+
tenantId: input.tenantId,
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const { eventId } = await ctx.workflows.signal(
|
|
849
|
+
{ executionId },
|
|
850
|
+
'approval',
|
|
851
|
+
{ approved: input.approved },
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
return { orderId: input.orderId, eventId }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export default execute
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
Because the workflow's `idempotencyKey` (`fulfill:<id>`) makes its executionId a deterministic function of the payload, the approve mutation re-derives it from `{ orderId, tenantId }` and addresses the signal by executionId — no need to track the run handle from the place mutation. `ctx.workflows.signal(...)` writes a `signal-sent` row to the run's events log; the workflow's `awaitSignal('approval')` poll picks it up and parses it against its `{ approved: boolean }` schema.
|
|
861
|
+
|
|
862
|
+
## Schedule — a nightly cron
|
|
863
|
+
|
|
864
|
+
A single-file `*.cron.tsx` schedule runs periodic work. `timezone` is mandatory; the cron is validated at boot. See [scheduling](/docs/scheduling/overview).
|
|
865
|
+
|
|
866
|
+
```tsx
|
|
867
|
+
// schedules/nightlyReport.cron.tsx
|
|
868
|
+
import { defineSchedule } from '@voltro/runtime'
|
|
869
|
+
import { count, eq } from '@voltro/database'
|
|
870
|
+
import { database } from '../database/schema'
|
|
871
|
+
|
|
872
|
+
export default defineSchedule({
|
|
873
|
+
name: 'nightlyReport',
|
|
874
|
+
cron: '0 2 * * *', // 02:00 every day
|
|
875
|
+
timezone: 'UTC',
|
|
876
|
+
description: 'Logs a per-status order tally each night.',
|
|
877
|
+
handler: async (ctx) => {
|
|
878
|
+
// Tally placed-but-not-yet-shipped orders as a simple health signal.
|
|
879
|
+
const open = await ctx.app.store.query(
|
|
880
|
+
database.orders.where(eq('status', 'placed')).aggregate({ total: count() }).descriptor,
|
|
881
|
+
)
|
|
882
|
+
const total = (open[0] as { total?: number } | undefined)?.total ?? 0
|
|
883
|
+
console.info(`[nightlyReport] fired for ${ctx.scheduledAt.toISOString()} — ${total} order(s) still 'placed'`)
|
|
884
|
+
},
|
|
885
|
+
// optional, all default-safe:
|
|
886
|
+
onOverlap: 'skip',
|
|
887
|
+
maxRuntimeMs: 5 * 60_000,
|
|
888
|
+
})
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
The handler is "a mutation the clock invokes" — `ctx.app` is the same `AppContext` shape a mutation gets. Zero-config coordination: on `store: 'memory'` it runs single-process; on a multi-instance SQL store the framework advisory-lock coordinates so exactly one replica fires each tick. For durable multi-step work, prefer the `workflow: { name, payload }` form over a handler.
|
|
892
|
+
|
|
893
|
+
## Subscriber — react to every write post-commit
|
|
894
|
+
|
|
895
|
+
A single-file `*.subscribe.ts` reacts to table writes after they commit. See [subscribers](/docs/data/subscribers).
|
|
896
|
+
|
|
897
|
+
```ts
|
|
898
|
+
// subscribers/orderChanges.subscribe.ts
|
|
899
|
+
import { defineSubscriber } from '@voltro/runtime'
|
|
900
|
+
|
|
901
|
+
export default defineSubscriber({
|
|
902
|
+
table: 'orders',
|
|
903
|
+
on: 'any', // 'insert' | 'update' | 'delete' | 'any' | a list
|
|
904
|
+
handler: async (event, ctx) => {
|
|
905
|
+
const row = (event.new ?? event.old) as { id?: string; status?: string } | null
|
|
906
|
+
ctx.log.info('order changed', {
|
|
907
|
+
op: event.op,
|
|
908
|
+
id: row?.id,
|
|
909
|
+
status: row?.status,
|
|
910
|
+
})
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
It fires AFTER commit (the row IS written), best-effort + fire-and-forget — a throw logs and the next event still arrives; it can't back-pressure the change stream. `event.new` is present on insert + update (null on delete); `event.old` on update + delete (null on insert). For crash-safe multi-step reactions, have the subscriber start a workflow instead.
|
|
916
|
+
|
|
917
|
+
## Aggregate — a materialized roll-up
|
|
918
|
+
|
|
919
|
+
A `*.aggregate.ts` is a pre-defined query the framework refreshes on a schedule, caches, and serves through `ctx.aggregates.<name>.read(...)`. See [aggregates](/docs/data/aggregates).
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
// aggregates/orderStats.aggregate.ts
|
|
923
|
+
import { defineAggregate } from '@voltro/runtime'
|
|
924
|
+
import { column, count } from '@voltro/database'
|
|
925
|
+
import { Schema } from 'effect'
|
|
926
|
+
import { database } from '../database/schema'
|
|
927
|
+
|
|
928
|
+
export const OrderStat = Schema.Struct({
|
|
929
|
+
tenantId: Schema.String,
|
|
930
|
+
status: Schema.String,
|
|
931
|
+
orders: Schema.Number,
|
|
932
|
+
})
|
|
933
|
+
export type OrderStat = Schema.Schema.Type<typeof OrderStat>
|
|
934
|
+
|
|
935
|
+
export default defineAggregate({
|
|
936
|
+
name: 'orderStats',
|
|
937
|
+
refresh: '1m', // re-run every minute (interval shorthand)
|
|
938
|
+
output: OrderStat,
|
|
939
|
+
build: async (ctx) => {
|
|
940
|
+
const rows = await ctx.store.query(
|
|
941
|
+
database.orders
|
|
942
|
+
.groupBy(['tenantId', 'status'])
|
|
943
|
+
// Grouped columns must be EXPLICITLY projected via `column(...)` in
|
|
944
|
+
// the aggregate spec — they are NOT auto-included (SQL rule: a
|
|
945
|
+
// selected non-aggregate column must be in GROUP BY).
|
|
946
|
+
.aggregate({
|
|
947
|
+
tenantId: column('tenantId'),
|
|
948
|
+
status: column('status'),
|
|
949
|
+
orders: count(),
|
|
950
|
+
})
|
|
951
|
+
.descriptor,
|
|
952
|
+
)
|
|
953
|
+
return rows.map((r) => ({
|
|
954
|
+
tenantId: (r as { tenantId: string }).tenantId,
|
|
955
|
+
status: (r as { status: string }).status,
|
|
956
|
+
orders: (r as { orders: number }).orders,
|
|
957
|
+
}))
|
|
958
|
+
},
|
|
959
|
+
})
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
`output` MUST match each returned row's shape. `build` runs as the system (no per-request subject), so it reads across every tenant; `tenantId` is part of the grouping so reads stay per-tenant. Reach for an aggregate when the source query is expensive but the result is small + bounded.
|
|
963
|
+
|
|
964
|
+
## Startup — a run-once boot hook
|
|
965
|
+
|
|
966
|
+
A `*.startup.tsx` default-exports a function (no `define*` wrapper). It runs once after migrations + seeds, when the rpc server is listening, and holds a resource for the process lifetime.
|
|
967
|
+
|
|
968
|
+
```tsx
|
|
969
|
+
// startup/warm.startup.tsx
|
|
970
|
+
import type { StartupContext } from '@voltro/cli/startup'
|
|
971
|
+
|
|
972
|
+
export default async ({ store, log, onShutdown, id }: StartupContext) => {
|
|
973
|
+
log.info(`startup '${id}': warming order-fulfillment caches`)
|
|
974
|
+
|
|
975
|
+
// Example long-lived resource: a heartbeat interval. Replace with a real
|
|
976
|
+
// warm-up (preload a dashboard cache, open a consumer, …).
|
|
977
|
+
const timer = setInterval(() => {
|
|
978
|
+
void store // `store` is the already-migrated DataStore, ready to use.
|
|
979
|
+
}, 60_000)
|
|
980
|
+
|
|
981
|
+
// Teardown runs on SIGTERM / SIGINT, LIFO across all startups, each
|
|
982
|
+
// awaited with a hard 5s timeout. Always release what you acquire.
|
|
983
|
+
onShutdown(() => {
|
|
984
|
+
clearInterval(timer)
|
|
985
|
+
log.info(`startup '${id}': torn down`)
|
|
986
|
+
})
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
A throw here does NOT block boot — it's logged; the rpc surface stays up. Distinct from `*.seed.ts` (runs once and RETURNS) and `*.cron.tsx` (periodic): a startup HOLDS a resource until shutdown. `StartupContext` is imported from the `@voltro/cli/startup` subpath.
|
|
991
|
+
|
|
992
|
+
## Try it
|
|
993
|
+
|
|
994
|
+
Over the rpc surface (a `voltro dev` web client, `POST /rpc`, or the inspect `invoke` endpoint):
|
|
995
|
+
|
|
996
|
+
```jsonc
|
|
997
|
+
// 1. place — inserts the order and starts the fulfillment workflow via order.placed
|
|
998
|
+
{ "tag": "orders.place", "input": { "tenantId": "acme", "customerName": "Ada", "amountCents": 4200 } }
|
|
999
|
+
// 2. approve — wakes the parked awaitSignal('approval') gate
|
|
1000
|
+
{ "tag": "orders.approve", "input": { "tenantId": "acme", "orderId": "order_…", "approved": true } }
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
Watch the run in the dashboard's **Workflows** tab (the `awaitSignal` gate also has a "Send signal…" button) and the order status walk `placed → reserved → shipped` live.
|
|
1004
|
+
|
|
1005
|
+
## When to use api-durable vs. the variants
|
|
1006
|
+
|
|
1007
|
+
| You need… | Pick |
|
|
1008
|
+
|---|---|
|
|
1009
|
+
| The smallest CRUD reference to extend | [`api-backend`](/docs/templates/api-backend) |
|
|
1010
|
+
| Workflows / triggers / schedules / aggregates exercised end-to-end | `api-durable` |
|
|
1011
|
+
| Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
|
|
1012
|
+
| File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
|
|
1013
|
+
| MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
|
|
1014
|
+
|
|
1015
|
+
`api-durable` is the durable-execution showcase; the `notes`-based variants build CRUD + one plugin on top of [`api-backend`](/docs/templates/api-backend).
|
|
1016
|
+
|
|
1017
|
+
## Pairs well with
|
|
1018
|
+
|
|
1019
|
+
- Any web template — the durable api streams its order status over the same reactive subscriptions every web template consumes.
|
|
1020
|
+
- [`store: 'postgres'`](/docs/database/dialects) — switch the store for durable state across restarts + cluster workflow runners.
|
|
1021
|
+
|
|
1022
|
+
## Anti-patterns
|
|
1023
|
+
|
|
1024
|
+
- **Emitting the domain event before the write commits.** `orders.place` emits `order.placed` *after* `ctx.store.insert` returns — `ctx.events.emit` is post-commit safe so a rolled-back transaction never starts a workflow for an order that doesn't exist. Don't emit eagerly inside the same expression as the insert.
|
|
1025
|
+
- **Doing external I/O inside a mutation.** The place mutation only inserts + emits. HTTP calls, payments, and other side effects belong in a workflow `step()` (journaled + replayed) or an action — a mutation runs in a transaction and can't roll back an HTTP side effect.
|
|
1026
|
+
- **Tracking the workflow run handle to send a signal.** `orders.approve` re-derives the executionId from the payload via the deterministic `idempotencyKey` instead. Storing the handle from the place mutation is unnecessary and breaks across restarts.
|
|
1027
|
+
- **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — and `TenantMismatch` must be imported from `@voltro/plugin-multitenancy/guard` in the descriptor, never the package root (the root leaks `@voltro/database` into the browser bundle).
|
|
1028
|
+
- **Putting server-only imports in a workflow descriptor.** `*.workflow.tsx` is value-imported into the client rpcGroup. Keep `step` / database / cluster imports in the `.server.tsx` executor; the descriptor imports only `@voltro/workflow/define`.
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
---
|
|
1033
|
+
|
|
1034
|
+
<!-- source: en/templates/api-ai.md -->
|
|
1035
|
+
## API · AI agent
|
|
1036
|
+
|
|
1037
|
+
_A RAG support agent over a docs knowledge base — vectorEmbedding() auto-embedded docs, a retrieval tool, a real defineAgent/defineAgentExecutor model loop, and a generateObject action. Boots zero-infra; needs an AI key only to RUN the model._
|
|
1038
|
+
|
|
1039
|
+
The AI template: a **RAG support agent** over a docs knowledge base. It exercises the framework's AI surface end-to-end — a `vectorEmbedding()` `docs` table that auto-embeds on every write, a `defineTool` the agent calls to retrieve docs, a real `defineAgent` / `defineAgentExecutor` model loop, and a `generateObject` action for structured output. It **boots and discovers every primitive with zero infra and no AI key** (`store: 'memory'`); you only need a provider key to actually RUN the agent or the summarize action. Template id: **`api-ai`**.
|
|
1040
|
+
|
|
1041
|
+
## Scaffold
|
|
1042
|
+
|
|
1043
|
+
```bash
|
|
1044
|
+
voltro create-project acme --api=api-ai
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
## What ships
|
|
1048
|
+
|
|
1049
|
+
```text
|
|
1050
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
1051
|
+
├── app.config.ts # type:api, store:'memory', AI env declared
|
|
1052
|
+
├── package.json # adds @voltro/ai to the api deps
|
|
1053
|
+
├── tsconfig.json
|
|
1054
|
+
├── README.md
|
|
1055
|
+
├── database/
|
|
1056
|
+
│ └── schema.ts # actors + tenants (core) + a docs table with vectorEmbedding()
|
|
1057
|
+
├── agents/
|
|
1058
|
+
│ ├── support.agent.tsx # descriptor — browser-safe (name + input)
|
|
1059
|
+
│ └── support.agent.server.tsx # executor — system prompt + tools + maxSteps
|
|
1060
|
+
├── tools/
|
|
1061
|
+
│ └── searchDocs.tool.tsx # retrieval tool — nearestNeighbours over docs
|
|
1062
|
+
├── actions/
|
|
1063
|
+
│ ├── summarize.action.ts # descriptor — browser-safe
|
|
1064
|
+
│ └── summarize.action.server.tsx # executor — generateObject structured output
|
|
1065
|
+
└── seeds/
|
|
1066
|
+
└── docs.seed.ts # boot seed of sample docs (auto-embedded on insert)
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
No queries or mutations ship — the agent's two routes are SYNTHESIZED (see below). Builds on the same `actors` + `tenants` core as [`api-backend`](/docs/templates/api-backend), but the example table is `docs` (vector-embedded), not `notes`.
|
|
1070
|
+
|
|
1071
|
+
## The agent — descriptor / `.server.ts` split
|
|
1072
|
+
|
|
1073
|
+
Like every rpc primitive, an agent is **two files paired by basename**: a browser-safe descriptor (`*.agent.tsx`, `defineAgent` from `@voltro/ai/agent`) carrying just `name` + `input`, and a server-only executor (`*.agent.server.tsx`, `defineAgentExecutor` from `@voltro/ai`) carrying the system prompt, tools, model, and `maxSteps`. The descriptor is value-imported into `rpcGroup.generated.ts` so the web client is typed end-to-end; the executor holds the secrets and the server imports.
|
|
1074
|
+
|
|
1075
|
+
### Descriptor — `agents/support.agent.tsx`
|
|
1076
|
+
|
|
1077
|
+
```tsx
|
|
1078
|
+
// support.agent.tsx — DESCRIPTOR (browser-safe)
|
|
1079
|
+
import { defineAgent } from '@voltro/ai/agent'
|
|
1080
|
+
import { Schema } from 'effect'
|
|
1081
|
+
|
|
1082
|
+
export const support = defineAgent({
|
|
1083
|
+
name: 'support',
|
|
1084
|
+
input: Schema.Struct({
|
|
1085
|
+
prompt: Schema.String,
|
|
1086
|
+
// Optional locale to steer the system prompt's reply language.
|
|
1087
|
+
locale: Schema.optional(Schema.String),
|
|
1088
|
+
}),
|
|
1089
|
+
})
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
### Executor — `agents/support.agent.server.tsx`
|
|
1093
|
+
|
|
1094
|
+
```tsx no-check
|
|
1095
|
+
// support.agent.server.tsx — EXECUTOR (server-only)
|
|
1096
|
+
import { defineAgentExecutor } from '@voltro/ai'
|
|
1097
|
+
import { support } from './support.agent'
|
|
1098
|
+
import { searchDocs } from '../tools/searchDocs.tool'
|
|
1099
|
+
|
|
1100
|
+
export default defineAgentExecutor(support, {
|
|
1101
|
+
system: (input) =>
|
|
1102
|
+
[
|
|
1103
|
+
'You are a friendly, precise support agent for this product.',
|
|
1104
|
+
'Answer ONLY from the knowledge base. ALWAYS call the `searchDocs`',
|
|
1105
|
+
'tool first to retrieve relevant docs, then answer using their',
|
|
1106
|
+
'content. If the docs do not cover the question, say so plainly',
|
|
1107
|
+
'instead of guessing.',
|
|
1108
|
+
`Reply in this locale: ${input.locale ?? 'en'}.`,
|
|
1109
|
+
].join(' '),
|
|
1110
|
+
tools: { searchDocs },
|
|
1111
|
+
// No `model` → inherit AI_PROVIDER / AI_MODEL + the key var from env.
|
|
1112
|
+
maxSteps: 6,
|
|
1113
|
+
})
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
Omitting `model` makes the agent inherit the provider from env (`AI_PROVIDER` / `AI_MODEL` + the provider's standard key var). That keeps the secret server-side and lets the same template run against any provider via `.env`.
|
|
1117
|
+
|
|
1118
|
+
### Two synthesized routes — you write neither
|
|
1119
|
+
|
|
1120
|
+
From the agent descriptor the framework SYNTHESIZES two procedures into `rpcGroup.generated.ts`:
|
|
1121
|
+
|
|
1122
|
+
- **`support.send`** — an **action** that appends the user turn and drives a streaming assistant turn (delta-persisted to `agent_messages`). Wire input: the descriptor's `input` fields plus `threadId` + `order`.
|
|
1123
|
+
- **`support.messages`** — a **reactive query** (`source: 'agent_messages'`) that streams the persisted turns, including the live `streaming:true` row being patched, to the client.
|
|
1124
|
+
|
|
1125
|
+
The thread tables (`agent_threads`, `agent_messages`) are auto-provided and auto-migrated because this app ships an `*.agent.tsx` — no schema file needed. Drive them from a web client:
|
|
1126
|
+
|
|
1127
|
+
```tsx
|
|
1128
|
+
// mint a thread id, subscribe to the live feed, then send a turn
|
|
1129
|
+
const threadId = `thread_${crypto.randomUUID().replace(/-/g, '')}`
|
|
1130
|
+
const { data: messages } = useSubscription('app', ['support.messages', { threadId }], { threadId })
|
|
1131
|
+
const send = useAction('app', 'support.send')
|
|
1132
|
+
await send.run({ threadId, order: messages?.length ?? 0, prompt: 'How do I reset my password?' })
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
As `support.send` writes token deltas to the streaming row, the `support.messages` subscription re-fires and the client sees each chunk — the live typewriter bubble is just a row whose `streaming` flag is `true`. No streaming RPC, no manual ws handling.
|
|
1136
|
+
|
|
1137
|
+
## The retrieval tool — `tools/searchDocs.tool.tsx`
|
|
1138
|
+
|
|
1139
|
+
A `defineTool` (the `*.tool.tsx` convention) is a named DESCRIPTOR with Schema-typed `input` / `output` and NO body, plus a default-exported `(input, ctx) => …` handler. The framework wires the default export onto the descriptor at discovery, and the agent's `tools: { searchDocs }` imports the SAME module instance. This file is SERVER-ONLY — it's referenced from the agent executor, never a browser-loaded descriptor, so importing the `database` handle here is safe.
|
|
1140
|
+
|
|
1141
|
+
```tsx
|
|
1142
|
+
import { defineTool } from '@voltro/ai'
|
|
1143
|
+
import type { AppContext } from '@voltro/runtime'
|
|
1144
|
+
import { Schema } from 'effect'
|
|
1145
|
+
import { database } from '../database/schema'
|
|
1146
|
+
|
|
1147
|
+
const MAX_RESULTS = 5
|
|
1148
|
+
const SNIPPET_LEN = 280
|
|
1149
|
+
|
|
1150
|
+
export const searchDocs = defineTool({
|
|
1151
|
+
name: 'searchDocs',
|
|
1152
|
+
description:
|
|
1153
|
+
'Search the support knowledge base for documents relevant to a question. ' +
|
|
1154
|
+
'Returns up to 5 results, each with a title and a short snippet of the body.',
|
|
1155
|
+
input: Schema.Struct({ query: Schema.String }),
|
|
1156
|
+
output: Schema.Array(
|
|
1157
|
+
Schema.Struct({ title: Schema.String, snippet: Schema.String }),
|
|
1158
|
+
),
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
export default async (
|
|
1162
|
+
{ query }: { query: string },
|
|
1163
|
+
ctx: AppContext,
|
|
1164
|
+
): Promise<ReadonlyArray<{ title: string; snippet: string }>> => {
|
|
1165
|
+
try {
|
|
1166
|
+
const rows = await ctx.store.query(
|
|
1167
|
+
database.docs.nearestNeighbours(query, MAX_RESULTS).descriptor,
|
|
1168
|
+
)
|
|
1169
|
+
return rows.map((row) => {
|
|
1170
|
+
const body = String((row as { body?: unknown }).body ?? '')
|
|
1171
|
+
return {
|
|
1172
|
+
title: String((row as { title?: unknown }).title ?? ''),
|
|
1173
|
+
snippet: body.length > SNIPPET_LEN ? `${body.slice(0, SNIPPET_LEN)}…` : body,
|
|
1174
|
+
}
|
|
1175
|
+
})
|
|
1176
|
+
} catch {
|
|
1177
|
+
// No AI key / embed failure / cold table → degrade to "no results"
|
|
1178
|
+
// rather than aborting the agent's tool loop.
|
|
1179
|
+
return []
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
Because `docs` carries `vectorEmbedding()`, the STRING overload of `nearestNeighbours(query, k)` is valid — the runtime embeds `query` before searching. Any failure (no AI key, embed error) is caught and returns `[]`, so a tool hiccup surfaces to the model as "no results" instead of aborting the whole agent turn.
|
|
1185
|
+
|
|
1186
|
+
## The structured-output action — `actions/summarize.action.ts` + `.server.tsx`
|
|
1187
|
+
|
|
1188
|
+
AI inference is external I/O, so it's an **action**, not a mutation. This one uses `generateObject` to take free-text and produce STRUCTURED output — a typed `{ title, summary, keyPoints, sentiment }` object instead of prose. The `output` Schema is exported from the descriptor so the executor can reuse the SAME schema to constrain the model: one source of truth for both the wire output and the shape the model must satisfy.
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
// summarize.action.ts — DESCRIPTOR (browser-safe)
|
|
1192
|
+
import { defineAction } from '@voltro/protocol'
|
|
1193
|
+
import { Schema } from 'effect'
|
|
1194
|
+
|
|
1195
|
+
// The structured shape the model must emit. Top-level `Schema.Struct`
|
|
1196
|
+
// (generateObject requires a JSON `object` at the root — an array/scalar
|
|
1197
|
+
// must be wrapped in a field, as `keyPoints` is here).
|
|
1198
|
+
export const SummaryResult = Schema.Struct({
|
|
1199
|
+
title: Schema.String,
|
|
1200
|
+
summary: Schema.String,
|
|
1201
|
+
keyPoints: Schema.Array(Schema.String),
|
|
1202
|
+
sentiment: Schema.Literal('positive', 'neutral', 'negative'),
|
|
1203
|
+
})
|
|
1204
|
+
export type SummaryResult = Schema.Schema.Type<typeof SummaryResult>
|
|
1205
|
+
|
|
1206
|
+
export const summarize = defineAction({
|
|
1207
|
+
name: 'support.summarize',
|
|
1208
|
+
input: Schema.Struct({
|
|
1209
|
+
// The raw text to summarise (a support thread, a doc, a transcript).
|
|
1210
|
+
text: Schema.String,
|
|
1211
|
+
}),
|
|
1212
|
+
output: SummaryResult,
|
|
1213
|
+
})
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
```ts
|
|
1217
|
+
// summarize.action.server.tsx — EXECUTOR (server-only)
|
|
1218
|
+
import { Effect } from 'effect'
|
|
1219
|
+
import { generateObject } from '@voltro/ai'
|
|
1220
|
+
import { SummaryResult } from './summarize.action'
|
|
1221
|
+
|
|
1222
|
+
const execute = (input: { text: string }) =>
|
|
1223
|
+
Effect.gen(function* () {
|
|
1224
|
+
const { object } = yield* generateObject({
|
|
1225
|
+
schema: SummaryResult,
|
|
1226
|
+
system:
|
|
1227
|
+
'Summarise the given support text. Produce a short title, a 1-2 sentence ' +
|
|
1228
|
+
'summary, the key points as a list, and the overall sentiment.',
|
|
1229
|
+
prompt: input.text,
|
|
1230
|
+
maxTokens: 500,
|
|
1231
|
+
})
|
|
1232
|
+
return object
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
export default execute
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
`generateObject` converts the `SummaryResult` Effect Schema to JSON Schema for the provider, then decodes the result back so brands/refinements hold. A provider failure or malformed output surfaces as a typed `AiError` (`reason: 'generation' | 'decode'`) on the Effect failure channel — declare it on the descriptor's `error:` to surface it typed to the client, or `Effect.catchTag('AiError', …)` to handle it in the executor.
|
|
1239
|
+
|
|
1240
|
+
## Database — auto-embedded `docs`
|
|
1241
|
+
|
|
1242
|
+
The schema declares the two framework core tables plus one vector-embedded knowledge-base table:
|
|
1243
|
+
|
|
1244
|
+
```ts
|
|
1245
|
+
// database/schema.ts
|
|
1246
|
+
import {
|
|
1247
|
+
databaseHandle,
|
|
1248
|
+
id,
|
|
1249
|
+
table,
|
|
1250
|
+
text,
|
|
1251
|
+
vectorEmbedding,
|
|
1252
|
+
type InferRow,
|
|
1253
|
+
} from '@voltro/database'
|
|
1254
|
+
|
|
1255
|
+
// Core tables — required by the audit / tenant mixins.
|
|
1256
|
+
export const actors = table('actors', {
|
|
1257
|
+
id: id(),
|
|
1258
|
+
kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
1259
|
+
displayName: text().nullable(),
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
export const tenants = table('tenants', {
|
|
1263
|
+
id: id(),
|
|
1264
|
+
name: text(),
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
// Knowledge base — vectorEmbedding({ from: 'body' }) adds the `embedding`
|
|
1268
|
+
// vector column + an HNSW index, and registers a re-embed hook that calls
|
|
1269
|
+
// @voltro/ai's `embed` on every insert/update of `body` — so a plain
|
|
1270
|
+
// ctx.store.insert('docs', { title, body }) auto-embeds, no app code.
|
|
1271
|
+
export const docs = table('docs', {
|
|
1272
|
+
id: id({ prefix: 'doc' }),
|
|
1273
|
+
title: text(),
|
|
1274
|
+
body: text(),
|
|
1275
|
+
})
|
|
1276
|
+
.with(
|
|
1277
|
+
vectorEmbedding({
|
|
1278
|
+
from: 'body',
|
|
1279
|
+
model: 'text-embedding-3-small',
|
|
1280
|
+
dimensions: 1536,
|
|
1281
|
+
}),
|
|
1282
|
+
)
|
|
1283
|
+
// Opt into the matcher engine so subscriptions over `docs` re-fire on write.
|
|
1284
|
+
.reactive()
|
|
1285
|
+
|
|
1286
|
+
export type Doc = InferRow<typeof docs>
|
|
1287
|
+
|
|
1288
|
+
export const database = databaseHandle({ actors, tenants, docs })
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
`dimensions` MUST match the embedding model's output width — `1536` is OpenAI's `text-embedding-3-small`. If you point at a different embedding model, change it to match (e.g. `768` / `3072`) or the vector column size won't line up. See [Vectors / RAG](/docs/database/vectors) for `nearestNeighbours`, `hybridSearch`, and the `vectorEmbedding()` mixin in depth.
|
|
1292
|
+
|
|
1293
|
+
## The seed — `seeds/docs.seed.ts`
|
|
1294
|
+
|
|
1295
|
+
`defineSeed` with `lifecycle: 'boot'` runs once per `voltro dev` boot, re-running only when the seed file's content fingerprint changes. `upsertByUnique` keys on `title`, so re-runs UPDATE rather than duplicate — idempotent. Because `docs` carries `vectorEmbedding({ from: 'body' })`, each insert/update AUTO-EMBEDS the body — no embedding code here.
|
|
1296
|
+
|
|
1297
|
+
```ts
|
|
1298
|
+
import { defineSeed } from '@voltro/database'
|
|
1299
|
+
|
|
1300
|
+
const SAMPLE_DOCS: ReadonlyArray<{ title: string; body: string }> = [
|
|
1301
|
+
{ title: 'Resetting your password', body: '…' },
|
|
1302
|
+
{ title: 'Inviting teammates', body: '…' },
|
|
1303
|
+
{ title: 'Exporting your data', body: '…' },
|
|
1304
|
+
{ title: 'API rate limits', body: '…' },
|
|
1305
|
+
]
|
|
1306
|
+
|
|
1307
|
+
export default defineSeed({
|
|
1308
|
+
id: 'docs',
|
|
1309
|
+
name: 'Sample knowledge-base docs',
|
|
1310
|
+
lifecycle: 'boot',
|
|
1311
|
+
steps: ({ step }) => [
|
|
1312
|
+
step('seed-docs', async ({ upsertByUnique }) => {
|
|
1313
|
+
let rowsTouched = 0
|
|
1314
|
+
for (const doc of SAMPLE_DOCS) {
|
|
1315
|
+
await upsertByUnique('docs', { title: doc.title }, { title: doc.title, body: doc.body })
|
|
1316
|
+
rowsTouched += 1
|
|
1317
|
+
}
|
|
1318
|
+
return { rowsTouched }
|
|
1319
|
+
}),
|
|
1320
|
+
],
|
|
1321
|
+
})
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
The auto-embed needs a provider key. Without one the rows still seed (title + body persist) but the `embedding` column stays empty. Set a key, then fill the gaps:
|
|
1325
|
+
|
|
1326
|
+
```bash
|
|
1327
|
+
voltro embeddings backfill docs --text body --vector embedding
|
|
1328
|
+
# add --model <m> if you changed the embedding model from the default
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
This (re-)embeds existing rows the `vectorEmbedding()` mixin missed — the same command to run after a model change.
|
|
1332
|
+
|
|
1333
|
+
## Store + AI provider — env
|
|
1334
|
+
|
|
1335
|
+
`app.config.ts` ships `store: 'memory'` — fast startup, no Docker, data resets on restart. The vector column is still stored, but ANN falls back to a sequential scan (correct, just unindexed). Switch to `store: 'postgres'` for a real pgvector HNSW index.
|
|
1336
|
+
|
|
1337
|
+
`@voltro/ai` reads the provider/model/key from env. All are OPTIONAL for booting; they're only needed to RUN the model. The template declares them with `defineEnv` so they show up in `voltro env` and `.env.example`:
|
|
1338
|
+
|
|
1339
|
+
```ts
|
|
1340
|
+
// app.config.ts (excerpt)
|
|
1341
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
1342
|
+
|
|
1343
|
+
export const env = defineEnv({
|
|
1344
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
1345
|
+
AI_PROVIDER: envVar.string({ access: 'public', optional: true }), // e.g. 'openai' | 'anthropic'
|
|
1346
|
+
AI_MODEL: envVar.string({ access: 'public', optional: true }), // e.g. 'gpt-4o-mini'
|
|
1347
|
+
AI_API_KEY: envVar.string({ access: 'secret', optional: true }), // server-only
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
export default {
|
|
1351
|
+
type: 'api' as const,
|
|
1352
|
+
name: 'acmeApi',
|
|
1353
|
+
store: 'memory' as const,
|
|
1354
|
+
env,
|
|
1355
|
+
}
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
```bash
|
|
1359
|
+
# .env — @voltro/ai also accepts the provider-standard var (OPENAI_API_KEY / ANTHROPIC_API_KEY)
|
|
1360
|
+
AI_PROVIDER=openai
|
|
1361
|
+
AI_MODEL=gpt-4o-mini
|
|
1362
|
+
AI_API_KEY=sk-...
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
Editing `.env` hard-restarts `voltro dev` automatically. Run `voltro env` to see the resolved manifest.
|
|
1366
|
+
|
|
1367
|
+
## When to use api-ai vs. the variants
|
|
1368
|
+
|
|
1369
|
+
| You need… | Pick |
|
|
1370
|
+
|---|---|
|
|
1371
|
+
| RAG agent + tools + structured output (the AI showcase) | `api-ai` |
|
|
1372
|
+
| The smallest generic backend to extend | [`api-backend`](/docs/templates/api-backend) |
|
|
1373
|
+
| Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
|
|
1374
|
+
| File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
|
|
1375
|
+
|
|
1376
|
+
## Pairs well with
|
|
1377
|
+
|
|
1378
|
+
- Any web template — pair `support.send` / `support.messages` with a chat UI for the live typewriter feed.
|
|
1379
|
+
- [AI agents](/docs/ai/agents) — the `defineAgent` / `defineAgentExecutor` model loop, synthesized routes, thread persistence.
|
|
1380
|
+
- [AI tools](/docs/ai/tools) — `defineTool` shape, how tool bodies reach app services.
|
|
1381
|
+
- [RAG](/docs/ai/rag) and [Vectors](/docs/database/vectors) — `vectorEmbedding()`, `nearestNeighbours`, `hybridSearch`.
|
|
1382
|
+
|
|
1383
|
+
## Anti-patterns
|
|
1384
|
+
|
|
1385
|
+
- **Hardcoding a model or API key in the descriptor.** The model + key live in the `.server.tsx` executor (or env), never the browser-safe `*.agent.tsx`. Omitting `model` to inherit from env keeps the secret server-side and lets one template run against any provider.
|
|
1386
|
+
- **Making the AI call a mutation.** AI inference is external I/O — use an **action**. A mutation runs in a transaction and can't roll back the model call's side effect; `summarize` is correctly an action.
|
|
1387
|
+
- **Letting a tool failure abort the agent turn.** `searchDocs` catches every error and returns `[]` so a missing key or embed failure degrades to "no results" instead of crashing the whole tool loop.
|
|
1388
|
+
- **Mismatching `dimensions` with the embedding model.** The `vector` column width is fixed at declaration. If `dimensions` doesn't match the model's output width, inserts/queries won't line up — change it together with the model.
|
|
1389
|
+
- **Hand-writing thread/send/list routes.** The agent path SYNTHESIZES `support.send` + `support.messages` and types them through codegen. Don't reinvent them just to get a typed client.
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
---
|
|
1394
|
+
|
|
1395
|
+
<!-- source: en/templates/api-data-advanced.md -->
|
|
1396
|
+
## API · Data (advanced)
|
|
1397
|
+
|
|
1398
|
+
_A library/catalog api that tours the advanced schema DSL — the entity/relations split with eager loading, full-text search, dbEnum, array + generated + encrypted columns, and declarative query caching._
|
|
1399
|
+
|
|
1400
|
+
A small **library/catalog** (authors + books) that closes the advanced schema-DSL gaps — the features that are documented but shown in no other api template: the `*.entity.ts` / `*.relations.ts` split with eager `.with()` loading, full-text search, `dbEnum`, array / generated / encrypted columns, and declarative query result caching. Reach for it as a worked reference when you're modelling something richer than a single flat table. Template id: **`api-data-advanced`**.
|
|
1401
|
+
|
|
1402
|
+
## Scaffold
|
|
1403
|
+
|
|
1404
|
+
```bash
|
|
1405
|
+
voltro create-project acme --api=api-data-advanced
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
## What ships
|
|
1409
|
+
|
|
1410
|
+
```text
|
|
1411
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
1412
|
+
├── app.config.ts # type:api, store:'memory', governancePlugin({ fieldEncryption: true })
|
|
1413
|
+
├── .env # DEV-ONLY VOLTRO_FIELD_ENCRYPTION_KEY (see below)
|
|
1414
|
+
├── package.json
|
|
1415
|
+
├── tsconfig.json
|
|
1416
|
+
├── README.md
|
|
1417
|
+
├── database/
|
|
1418
|
+
│ ├── actors.entity.ts # core audit-subject table
|
|
1419
|
+
│ ├── tenants.entity.ts # core tenant boundary
|
|
1420
|
+
│ ├── authors.entity.ts # name + .encrypted() bio
|
|
1421
|
+
│ ├── books.entity.ts # dbEnum genre, array tags, generated slug, FTS index
|
|
1422
|
+
│ ├── authors.relations.ts # author → many books
|
|
1423
|
+
│ ├── books.relations.ts # book → one author
|
|
1424
|
+
│ └── index.ts # databaseHandle({...}) + relation registration
|
|
1425
|
+
├── queries/
|
|
1426
|
+
│ ├── books.search.query.ts(.server) # FTS via .matching(...) + cache
|
|
1427
|
+
│ └── authors.withBooks.query.ts(.server) # eager-load via .with({ books: true })
|
|
1428
|
+
└── seeds/
|
|
1429
|
+
└── catalog.seed.ts # demo authors + books
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
`store: 'memory'` keeps boot zero-infra (no Docker, data resets on restart). The schema DSL is identical across every SQL backend — switch `app.config.ts` to `store: 'postgres'` to see the native DDL the migrator emits (`CREATE TYPE … ENUM`, `text[]`, the STORED generated column, the tsvector + GIN FTS index). On `memory` the same features run through the in-process store: FTS degrades to a substring scan, arrays round-trip as JS arrays, encryption still applies. See the [SQL dialects guide](/docs/database/dialects).
|
|
1433
|
+
|
|
1434
|
+
## The `*.entity.ts` / `*.relations.ts` split
|
|
1435
|
+
|
|
1436
|
+
Tables are declared **one per file** with the `*.entity.ts` extension. Relations are declared OUTSIDE the table descriptor, in a sibling `*.relations.ts` file — the framework registers them at boot and the query builder's `.with()` chain uses them to eager-load.
|
|
1437
|
+
|
|
1438
|
+
```ts
|
|
1439
|
+
// database/authors.entity.ts
|
|
1440
|
+
import { id, table, text } from '@voltro/database'
|
|
1441
|
+
import { tenant } from '@voltro/plugin-multitenancy/mixin'
|
|
1442
|
+
|
|
1443
|
+
export const authors = table('authors', {
|
|
1444
|
+
id: id({ prefix: 'author' }),
|
|
1445
|
+
name: text(),
|
|
1446
|
+
// Encrypted at rest. Requires governancePlugin({ fieldEncryption: true }).
|
|
1447
|
+
bio: text().encrypted().nullable(),
|
|
1448
|
+
})
|
|
1449
|
+
// tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
1450
|
+
.with(tenant())
|
|
1451
|
+
.reactive()
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
```ts
|
|
1455
|
+
// database/authors.relations.ts — declared OUTSIDE the table descriptor
|
|
1456
|
+
import { relations } from '@voltro/database'
|
|
1457
|
+
import { authors } from './authors.entity'
|
|
1458
|
+
import { books } from './books.entity'
|
|
1459
|
+
|
|
1460
|
+
// An author has MANY books. `foreignKey` auto-derives because exactly one
|
|
1461
|
+
// reference() column on `books` (`authorId`) points back at `authors`.
|
|
1462
|
+
export const authorsRelations = relations(authors, ({ many }) => ({
|
|
1463
|
+
books: many(books),
|
|
1464
|
+
}))
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
```ts
|
|
1468
|
+
// database/books.relations.ts — the inverse, a book belongs to ONE author
|
|
1469
|
+
import { relations } from '@voltro/database'
|
|
1470
|
+
import { authors } from './authors.entity'
|
|
1471
|
+
import { books } from './books.entity'
|
|
1472
|
+
|
|
1473
|
+
export const booksRelations = relations(books, ({ one }) => ({
|
|
1474
|
+
author: one(authors),
|
|
1475
|
+
}))
|
|
1476
|
+
```
|
|
1477
|
+
|
|
1478
|
+
The handle barrel imports the `*.relations.ts` files (side-effecting) so the eager-load walker can resolve them:
|
|
1479
|
+
|
|
1480
|
+
```ts
|
|
1481
|
+
// database/index.ts
|
|
1482
|
+
import { databaseHandle, type InferRow } from '@voltro/database'
|
|
1483
|
+
import { actors } from './actors.entity'
|
|
1484
|
+
import { tenants } from './tenants.entity'
|
|
1485
|
+
import { authors } from './authors.entity'
|
|
1486
|
+
import { books } from './books.entity'
|
|
1487
|
+
|
|
1488
|
+
// Register relations (side-effecting imports).
|
|
1489
|
+
import './authors.relations'
|
|
1490
|
+
import './books.relations'
|
|
1491
|
+
|
|
1492
|
+
export { actors, tenants, authors, books }
|
|
1493
|
+
export type Author = InferRow<typeof authors>
|
|
1494
|
+
export type Book = InferRow<typeof books>
|
|
1495
|
+
|
|
1496
|
+
export const database = databaseHandle({ actors, tenants, authors, books })
|
|
1497
|
+
```
|
|
1498
|
+
|
|
1499
|
+
See [Relations](/docs/database/relations).
|
|
1500
|
+
|
|
1501
|
+
## Eager loading — `.with({ books: true })`
|
|
1502
|
+
|
|
1503
|
+
`authors.withBooks` returns each author with their books attached as an array, in ONE SQL roundtrip (the dialect's JSON-aggregation idiom — postgres `jsonb_agg`, sqlite `json_group_array`, mysql `JSON_ARRAYAGG`, mssql `FOR JSON PATH`). The per-branch `orderBy`/`limit` apply PER author.
|
|
1504
|
+
|
|
1505
|
+
```ts
|
|
1506
|
+
// queries/authors.withBooks.query.server.ts — executor (server-only)
|
|
1507
|
+
import type { AppContext } from '@voltro/runtime'
|
|
1508
|
+
import { database } from '../database/index'
|
|
1509
|
+
|
|
1510
|
+
const execute = (_input: Record<string, never>, _ctx: AppContext) =>
|
|
1511
|
+
database.authors.with({
|
|
1512
|
+
books: { orderBy: [{ column: 'title', direction: 'asc' }], limit: 50 },
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
export default execute
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
The descriptor's `output` carries the nested `books` array, and `source: ['authors', 'books']` makes the reactive subscription re-run when EITHER table changes — a new book under an author pushes a fresh snapshot:
|
|
1519
|
+
|
|
1520
|
+
```ts
|
|
1521
|
+
// queries/authors.withBooks.query.ts — descriptor (browser-safe)
|
|
1522
|
+
import { defineQuery } from '@voltro/protocol'
|
|
1523
|
+
import { Schema } from 'effect'
|
|
1524
|
+
|
|
1525
|
+
const Book = Schema.Struct({
|
|
1526
|
+
id: Schema.String, authorId: Schema.String, title: Schema.String,
|
|
1527
|
+
summary: Schema.String, genre: Schema.String,
|
|
1528
|
+
tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
|
|
1529
|
+
})
|
|
1530
|
+
|
|
1531
|
+
export const authorsWithBooks = defineQuery({
|
|
1532
|
+
name: 'authors.withBooks',
|
|
1533
|
+
source: ['authors', 'books'],
|
|
1534
|
+
input: Schema.Struct({}),
|
|
1535
|
+
output: Schema.Struct({
|
|
1536
|
+
id: Schema.String, name: Schema.String,
|
|
1537
|
+
// `bio` is the .encrypted() column — handlers + the wire see plaintext.
|
|
1538
|
+
bio: Schema.NullOr(Schema.String), tenantId: Schema.String,
|
|
1539
|
+
books: Schema.Array(Book), // eager-loaded relation
|
|
1540
|
+
}),
|
|
1541
|
+
})
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
See [Eager loading](/docs/database/relations/eager-loading).
|
|
1545
|
+
|
|
1546
|
+
## Full-text search — `.fullTextIndex` + `.matching`
|
|
1547
|
+
|
|
1548
|
+
`books` declares one FTS index over `title` + `summary`. That single declaration compiles to a different backend per dialect (postgres tsvector + GIN, mysql/mariadb `FULLTEXT`, sqlite FTS5, mssql ranked-LIKE fallback). The `books` table also carries the other specialized columns — `dbEnum`, `array(text())`, and a stored `.generatedAs(...)`:
|
|
1549
|
+
|
|
1550
|
+
```ts
|
|
1551
|
+
// database/books.entity.ts
|
|
1552
|
+
import { array, dbEnum, id, reference, table, text } from '@voltro/database'
|
|
1553
|
+
import { tenant } from '@voltro/plugin-multitenancy/mixin'
|
|
1554
|
+
import { authors } from './authors.entity'
|
|
1555
|
+
|
|
1556
|
+
// Native ENUM type — declared module-level as a reusable handle. `as const`
|
|
1557
|
+
// is required for the literal-union type. postgres → CREATE TYPE … ENUM
|
|
1558
|
+
// (ADD VALUE is O(1)); mysql/mariadb → native ENUM(...); mssql/sqlite → CHECK.
|
|
1559
|
+
export const bookGenre = dbEnum('book_genre', [
|
|
1560
|
+
'fiction', 'nonfiction', 'fantasy', 'sciFi', 'mystery', 'biography',
|
|
1561
|
+
] as const)
|
|
1562
|
+
|
|
1563
|
+
export const books = table('books', {
|
|
1564
|
+
id: id({ prefix: 'book' }),
|
|
1565
|
+
authorId: reference(() => authors), // FK → authors, auto-indexed
|
|
1566
|
+
title: text(),
|
|
1567
|
+
summary: text(),
|
|
1568
|
+
genre: bookGenre.column().default('fiction'), // native ENUM column
|
|
1569
|
+
tags: array(text()).default([]), // text[] on postgres; JSON codec elsewhere
|
|
1570
|
+
// DB-computed STORED column derived from `title` on INSERT + UPDATE.
|
|
1571
|
+
// `stored: true` persists it on disk so it's indexable. Same-row columns
|
|
1572
|
+
// only. Raw SQL emitted verbatim — keep it portable (quoted identifier).
|
|
1573
|
+
slug: text().generatedAs(`lower("title")`, { stored: true }),
|
|
1574
|
+
})
|
|
1575
|
+
.fullTextIndex('bookSearch', ['title', 'summary'], {
|
|
1576
|
+
config: 'english',
|
|
1577
|
+
weights: { title: 'A', summary: 'B' },
|
|
1578
|
+
})
|
|
1579
|
+
.with(tenant())
|
|
1580
|
+
.reactive()
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
The query executor compiles the FTS predicate via `.matching('bookSearch', q)` — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; `books` carries `tenant()`, so the runtime AND-merges `eq('tenantId', subject.tenantId)`:
|
|
1584
|
+
|
|
1585
|
+
```ts
|
|
1586
|
+
// queries/books.search.query.server.ts — executor (server-only)
|
|
1587
|
+
import type { AppContext } from '@voltro/runtime'
|
|
1588
|
+
import { database } from '../database/index'
|
|
1589
|
+
|
|
1590
|
+
const execute = (input: { q: string }, _ctx: AppContext) =>
|
|
1591
|
+
database.books.matching('bookSearch', input.q).limit(50)
|
|
1592
|
+
|
|
1593
|
+
export default execute
|
|
1594
|
+
```
|
|
1595
|
+
|
|
1596
|
+
See [Full-text search](/docs/database/full-text-search), [Enums](/docs/database/enums), [Arrays & intervals](/docs/database/arrays-intervals), and [Generated columns](/docs/database/generated-columns).
|
|
1597
|
+
|
|
1598
|
+
## Field encryption — `.encrypted()` + the DEV-ONLY `.env`
|
|
1599
|
+
|
|
1600
|
+
`authors.bio` is flagged `.encrypted()`. The store middleware transparently encrypts it on write (AES-256-GCM) and decrypts on read — handlers always see plaintext while the column stores an opaque `enc:v1:…` string on disk on every dialect. An `.encrypted()` column is ciphertext in SQL, so you can't filter or sort by its plaintext — encrypt only what you read back whole (PII, tokens, notes). A book search never touches `bio`, so this is safe.
|
|
1601
|
+
|
|
1602
|
+
The column ONLY works because `app.config.ts` wires `governancePlugin({ fieldEncryption: true })`, which registers the cipher. Boot fails loud if an `.encrypted()` column exists but no cipher is registered.
|
|
1603
|
+
|
|
1604
|
+
```ts
|
|
1605
|
+
// app.config.ts
|
|
1606
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
1607
|
+
import { governancePlugin } from '@voltro/plugin-governance'
|
|
1608
|
+
|
|
1609
|
+
export const env = defineEnv({
|
|
1610
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
1611
|
+
// NO `default` on purpose — see the note below.
|
|
1612
|
+
VOLTRO_FIELD_ENCRYPTION_KEY: envVar.string({ access: 'secret' }),
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
export default {
|
|
1616
|
+
type: 'api' as const,
|
|
1617
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
1618
|
+
store: 'memory' as const,
|
|
1619
|
+
env,
|
|
1620
|
+
plugins: [
|
|
1621
|
+
governancePlugin({ fieldEncryption: true }), // reads secret VOLTRO_FIELD_ENCRYPTION_KEY
|
|
1622
|
+
],
|
|
1623
|
+
}
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
**Where the key actually comes from is the load-bearing detail.** `governancePlugin({ fieldEncryption: true })` resolves the AES-256-GCM key through the **Secrets-Resolver** — i.e. the process environment — NOT through a `defineEnv` `default`. A `defineEnv` default would satisfy the boot validation gate while the cipher still failed to resolve a key. That's why the env declaration above deliberately has no `default`, and the template instead ships a `.env` carrying a **DEV-ONLY** placeholder so `voltro dev` boots out of the box:
|
|
1627
|
+
|
|
1628
|
+
```bash
|
|
1629
|
+
# .env — DEV-ONLY. `voltro dev` loads this into process.env at boot, BEFORE
|
|
1630
|
+
# the env-validation gate and BEFORE plugins activate, which is how the
|
|
1631
|
+
# governance plugin's cipher resolves its key from the Secrets-Resolver.
|
|
1632
|
+
VOLTRO_FIELD_ENCRYPTION_KEY=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
|
|
1633
|
+
```
|
|
1634
|
+
|
|
1635
|
+
> **Before production**, replace this placeholder with a real key (`openssl rand -hex 32`) and move it into your deployment's secret store — never ship the shipped value. **Lose the key → lose the data** (GCM fails closed, never silent corruption); **rotating it** makes every existing `.encrypted()` value unreadable.
|
|
1636
|
+
|
|
1637
|
+
See [Field encryption](/docs/plugins/governance) and the [columns reference](/docs/database/columns).
|
|
1638
|
+
|
|
1639
|
+
## Declarative query caching
|
|
1640
|
+
|
|
1641
|
+
`books.search` adds `cache` to its `defineQuery`. The framework caches the server snapshot and auto-invalidates it whenever a mutation writes any table the query depends on (here, `books`) — zero manual busting. `scope` is REQUIRED and is a security decision: this query is tenant-filtered (the `tenant()` mixin AND-merges the caller's `tenantId`), so it depends on the caller → `scope: 'subject'` (the cache key includes the subject id, no cross-tenant leak). NEVER `global` on a subject-filtered query.
|
|
1642
|
+
|
|
1643
|
+
```ts
|
|
1644
|
+
// queries/books.search.query.ts — descriptor (browser-safe)
|
|
1645
|
+
import { defineQuery } from '@voltro/protocol'
|
|
1646
|
+
import { Schema } from 'effect'
|
|
1647
|
+
|
|
1648
|
+
export const searchBooks = defineQuery({
|
|
1649
|
+
name: 'books.search',
|
|
1650
|
+
source: 'books',
|
|
1651
|
+
input: Schema.Struct({ q: Schema.String }),
|
|
1652
|
+
output: Schema.Struct({
|
|
1653
|
+
id: Schema.String, authorId: Schema.String, title: Schema.String,
|
|
1654
|
+
summary: Schema.String, genre: Schema.String,
|
|
1655
|
+
tags: Schema.Array(Schema.String), slug: Schema.String, tenantId: Schema.String,
|
|
1656
|
+
}),
|
|
1657
|
+
cache: { ttl: '30s', swr: '5m', scope: 'subject' },
|
|
1658
|
+
})
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
The `authors.withBooks` query deliberately has NO `cache` — it's a join-shaped result, so the template lets the live dispatcher keep it fresh rather than cache a snapshot. See [Query result caching](/docs/caching/query-cache).
|
|
1662
|
+
|
|
1663
|
+
## Boot seed
|
|
1664
|
+
|
|
1665
|
+
`catalog.seed.ts` is a boot-lifecycle seed that idempotently `upsertByUnique`s a demo tenant, two authors, and three books, so the catalog has data the moment `voltro dev` boots. The seed store is the RAW store (no authenticated subject), so `tenant()` auto-fill doesn't fire — it passes `tenantId: 'acme'` explicitly (matching the dev AuthMiddleware default, which resolves `acme` from the `x-tenant` header). It passes `bio` as plaintext (the middleware encrypts it), `tags` as a JS array, and OMITS `slug` (the `.generatedAs(...)` STORED column is filled by the DB engine). See [Seeds](/docs/database/seeds).
|
|
1666
|
+
|
|
1667
|
+
## Try it
|
|
1668
|
+
|
|
1669
|
+
Subscribe from a web app via `useSubscription('app', 'books.search', { q: 'magic' })` and `useSubscription('app', 'authors.withBooks')`, or invoke them over HTTP through `POST /_voltro/inspect/invoke` for a one-shot snapshot.
|
|
1670
|
+
|
|
1671
|
+
## When to use api-data-advanced vs. the variants
|
|
1672
|
+
|
|
1673
|
+
| You need… | Pick |
|
|
1674
|
+
|---|---|
|
|
1675
|
+
| The smallest reference to extend | [`api-backend`](/docs/templates/api-backend) |
|
|
1676
|
+
| A worked tour of the advanced schema DSL (relations + eager loading, FTS, enum/array/generated/encrypted columns, query cache) | `api-data-advanced` |
|
|
1677
|
+
| Transactional email wired (React-Email) | [`api-backend-mail`](/docs/templates/api-backend-mail) |
|
|
1678
|
+
| File storage wired (public + private objects) | [`api-backend-storage`](/docs/templates/api-backend-storage) |
|
|
1679
|
+
| MariaDB binlog CDC + storage (K8s shape) | [`api-backend-mariadb`](/docs/templates/api-backend-mariadb) |
|
|
1680
|
+
|
|
1681
|
+
## Pairs well with
|
|
1682
|
+
|
|
1683
|
+
- Any web template — `api-data-advanced` exposes its queries over the standard rpc client.
|
|
1684
|
+
- [`@voltro/plugin-governance`](/docs/plugins/governance) — already wired here for field encryption; it also ships retention sweeps, GDPR export/erase, and a consent ledger.
|
|
1685
|
+
|
|
1686
|
+
## Anti-patterns
|
|
1687
|
+
|
|
1688
|
+
- **Shipping the placeholder `VOLTRO_FIELD_ENCRYPTION_KEY` to production.** The `.env` value is a public, DEV-ONLY constant. Generate a real key (`openssl rand -hex 32`), store it in your deployment's secret store, and never commit it. Lose the key and the ciphertext is gone for good.
|
|
1689
|
+
- **Expecting a `defineEnv` `default` to feed the cipher.** It won't — the cipher reads the Secrets-Resolver (process env), not the typed-env default. A default would pass the boot gate while the cipher still fails to resolve, so the key must reach `process.env` (here via `.env`).
|
|
1690
|
+
- **Filtering or sorting by an `.encrypted()` column's plaintext in SQL.** It's ciphertext on disk — those predicates can't run. Encrypt only fields you read back whole.
|
|
1691
|
+
- **Adding `tenantId` filters by hand in the query executors.** `authors` and `books` carry `tenant()`, so the runtime scopes reads automatically. Doing both works but signals you don't trust the framework.
|
|
1692
|
+
- **Passing `slug` in a write.** It's a `.generatedAs(...)` STORED column — the DB engine fills it from `title`. Passing a value fights the generator.
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
---
|
|
1697
|
+
|
|
1698
|
+
<!-- source: en/templates/api-auth.md -->
|
|
1699
|
+
## API · Auth
|
|
1700
|
+
|
|
1701
|
+
_Real user authentication wired turnkey with @voltro/plugin-auth — password sign-up/in/out over HttpOnly session cookies, a strategy that resolves the session into a typed Subject, and a session.me action proving it. Zero-infra boot._
|
|
1702
|
+
|
|
1703
|
+
Real user authentication, wired turnkey. Password **sign-up / sign-in / sign-out** over HttpOnly **session cookies**, a strategy that resolves the session into a typed `Subject` on every rpc call, and a `session.me` action that proves the loop end-to-end. Boots with **zero infra** (`memoryUserStore()`). Template id: **`api-auth`**.
|
|
1704
|
+
|
|
1705
|
+
## Scaffold
|
|
1706
|
+
|
|
1707
|
+
```bash
|
|
1708
|
+
voltro create-project acme --api=api-auth
|
|
1709
|
+
```
|
|
1710
|
+
|
|
1711
|
+
The shipped `.env` supplies a DEV-ONLY `VOLTRO_SESSION_SECRET` so it boots out of the box — replace it for production (see Environment).
|
|
1712
|
+
|
|
1713
|
+
## What ships
|
|
1714
|
+
|
|
1715
|
+
```text
|
|
1716
|
+
apps/acme/api/
|
|
1717
|
+
├── app.config.ts # authRoutesPlugin + voltroPasswordStrategy + memoryUserStore
|
|
1718
|
+
├── .env # DEV-ONLY VOLTRO_SESSION_SECRET
|
|
1719
|
+
├── package.json # + @voltro/plugin-auth
|
|
1720
|
+
├── database/schema.ts # core actors + tenants (users live in the memory store)
|
|
1721
|
+
└── actions/
|
|
1722
|
+
├── me.action.ts # session.me descriptor — browser-safe
|
|
1723
|
+
└── me.action.server.ts # returns ctx.request.subject
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
## The two halves
|
|
1727
|
+
|
|
1728
|
+
`app.config.ts` wires both, sharing ONE secret:
|
|
1729
|
+
|
|
1730
|
+
```ts
|
|
1731
|
+
import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
|
|
1732
|
+
|
|
1733
|
+
const SECRET = process.env.VOLTRO_SESSION_SECRET ?? 'dev-only-unsafe-session-secret-change-me'
|
|
1734
|
+
|
|
1735
|
+
export default {
|
|
1736
|
+
type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
|
|
1737
|
+
plugins: [
|
|
1738
|
+
authRoutesPlugin({
|
|
1739
|
+
store: memoryUserStore(),
|
|
1740
|
+
secret: SECRET,
|
|
1741
|
+
defaultTenantId: 'acme',
|
|
1742
|
+
cookieSecure: process.env.NODE_ENV === 'production', // ← see the gotcha below
|
|
1743
|
+
}),
|
|
1744
|
+
],
|
|
1745
|
+
auth: { strategies: [voltroPasswordStrategy({ secret: SECRET })] },
|
|
1746
|
+
}
|
|
1747
|
+
```
|
|
1748
|
+
|
|
1749
|
+
- **`authRoutesPlugin`** mounts the HTTP auth surface under `/auth/*` (`POST /auth/sign-up`, `/auth/sign-in`, `/auth/sign-out`, `GET /auth/csrf`, password-reset, magic-link, `/auth/sessions`, tenant memberships) and **signs** the `voltro:session` cookie on success.
|
|
1750
|
+
- **`voltroPasswordStrategy`** runs in the [AuthMiddleware](/docs/authentication/overview) chain on every rpc/ws call, **verifies** that cookie, and resolves it to a `Subject` — so handlers read the user via `ctx.request.subject`.
|
|
1751
|
+
|
|
1752
|
+
They MUST share the same `secret` (one signs, the other verifies); the template reads `VOLTRO_SESSION_SECRET` once and passes it to both.
|
|
1753
|
+
|
|
1754
|
+
### Reading the Subject — `session.me`
|
|
1755
|
+
|
|
1756
|
+
```ts
|
|
1757
|
+
// actions/me.action.server.ts
|
|
1758
|
+
const execute = async (_input, ctx) => {
|
|
1759
|
+
const subject = ctx.request.subject as { type: string; id: string | null; tenantId: string | null }
|
|
1760
|
+
return { type: subject.type, id: subject.id ?? null, tenantId: subject.tenantId ?? null }
|
|
1761
|
+
}
|
|
1762
|
+
```
|
|
1763
|
+
|
|
1764
|
+
Anonymous before sign-in (`type: 'anonymous'`, `id: null`); a `user` Subject once the session cookie resolves.
|
|
1765
|
+
|
|
1766
|
+
## Gotcha — `cookieSecure` in dev
|
|
1767
|
+
|
|
1768
|
+
`Secure` cookies are **HTTPS-only** — over `http://localhost` a browser (and `curl`) silently drops a `Secure` session cookie, so the sign-in → session → authenticated-call loop never authenticates. Setting `cookieSecure: process.env.NODE_ENV === 'production'` is what makes the loop work locally (off in dev, on over HTTPS in prod).
|
|
1769
|
+
|
|
1770
|
+
## Try the loop (curl)
|
|
1771
|
+
|
|
1772
|
+
State-changing routes are CSRF-protected; `session.me` is an rpc procedure (invoke it over HTTP via the dev inspect endpoint, which forwards your cookie):
|
|
1773
|
+
|
|
1774
|
+
```bash
|
|
1775
|
+
# 1. CSRF token (+ csrf cookie into the jar)
|
|
1776
|
+
curl -s -c jar.txt http://localhost:4000/auth/csrf # → { "csrfToken": "…" }
|
|
1777
|
+
|
|
1778
|
+
# 2. Sign up (sets the HttpOnly session cookie)
|
|
1779
|
+
curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
|
|
1780
|
+
-H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
|
|
1781
|
+
-d '{"email":"ada@example.com","password":"hunter2hunter2"}'
|
|
1782
|
+
# → { "ok": true, "subject": { "type": "user", "id": "u_…", "tenantId": "acme" } }
|
|
1783
|
+
|
|
1784
|
+
# 3. session.me with the cookie → you're a user
|
|
1785
|
+
curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
1786
|
+
-H 'content-type: application/json' -d '{"tag":"session.me","input":{}}'
|
|
1787
|
+
# → { "ok": true, "result": { "type": "user", "id": "u_…", "tenantId": "acme" } }
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
## Going to production
|
|
1791
|
+
|
|
1792
|
+
| Want… | Do |
|
|
1793
|
+
|---|---|
|
|
1794
|
+
| Durable accounts | `postgresUserStore({ sql })` + `store: 'postgres'` (manages its own auth tables) |
|
|
1795
|
+
| Magic-link / password-reset email | pass `sendEmail: mailSender(mailService)` (from [`@voltro/plugin-mail`](/docs/plugins/mail)) to `authRoutesPlugin` |
|
|
1796
|
+
| A login UI | pair with [`frontend-app`](/docs/templates/app) — a form POSTing to `/auth/sign-in`; authenticated rpc carries the cookie automatically |
|
|
1797
|
+
|
|
1798
|
+
## Anti-patterns
|
|
1799
|
+
|
|
1800
|
+
- **Forgetting `cookieSecure` in dev.** The `Secure` default drops the cookie over http — auth silently fails. See the gotcha above.
|
|
1801
|
+
- **Different secrets for the routes plugin vs. the strategy.** One signs, the other verifies — they must match. Read `VOLTRO_SESSION_SECRET` once and pass it to both.
|
|
1802
|
+
- **Shipping the DEV-ONLY `.env` secret.** Generate a real one (`openssl rand -hex 32`); rotating it invalidates every session.
|
|
1803
|
+
- **Trusting `x-tenant` in production.** The dev resolver reads it unauthenticated — this template's session strategy is the real path; don't leave the header as your auth.
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
---
|
|
1808
|
+
|
|
1809
|
+
<!-- source: en/templates/api-rest.md -->
|
|
1810
|
+
## API · REST + OpenAPI
|
|
1811
|
+
|
|
1812
|
+
_A public REST API built on defineRestRoute — GET/POST/DELETE with query, path, and body parsing, scope guards, Idempotency-Key dedup, and an OpenAPI 3.1 spec + Swagger UI auto-generated from the route descriptors. Zero-infra boot._
|
|
1813
|
+
|
|
1814
|
+
A turnkey **public REST API** — plain raw-HTTP endpoints a third party calls with a URL + JSON, the opposite end from the reactive rpc/WebSocket surface ([`api-backend`](/docs/templates/api-backend)). Four `defineRestRoute` endpoints over a `products` catalog, **scope-guarded** writes, **`Idempotency-Key`** dedup, and an **OpenAPI 3.1 spec + Swagger UI** generated from the descriptors. Boots with **zero infra** (`store: 'memory'`). Template id: **`api-rest`**.
|
|
1815
|
+
|
|
1816
|
+
## Scaffold
|
|
1817
|
+
|
|
1818
|
+
```bash
|
|
1819
|
+
voltro create-project acme --api=api-rest
|
|
1820
|
+
```
|
|
1821
|
+
|
|
1822
|
+
Boots on `http://localhost:4000`; open `http://localhost:4000/docs` for the Swagger UI.
|
|
1823
|
+
|
|
1824
|
+
## What ships
|
|
1825
|
+
|
|
1826
|
+
```text
|
|
1827
|
+
apps/acme/api/
|
|
1828
|
+
├── app.config.ts # restRoutes + openapiPlugin + apiKeyStrategy + idempotency
|
|
1829
|
+
├── package.json # + @voltro/plugin-openapi
|
|
1830
|
+
├── database/schema.ts # a plain `products` table (no mixins — the routes own auth)
|
|
1831
|
+
├── lib/product.ts # shared wire Schema + row→wire mapper (one source of truth)
|
|
1832
|
+
└── routes/v1/
|
|
1833
|
+
├── products.list.route.tsx # GET /v1/products?limit=&cursor= (public, paginated)
|
|
1834
|
+
├── products.get.route.tsx # GET /v1/products/:id (public, 404)
|
|
1835
|
+
├── products.create.route.tsx # POST /v1/products (guarded + idempotent)
|
|
1836
|
+
└── products.delete.route.tsx # DELETE /v1/products/:id (guarded)
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
REST routes are the **one** primitive that is NOT auto-discovered — they're registered explicitly via `restRoutes` in `app.config.ts`.
|
|
1840
|
+
|
|
1841
|
+
## A route — query / path / body in, typed JSON out
|
|
1842
|
+
|
|
1843
|
+
```tsx
|
|
1844
|
+
// routes/v1/products.create.route.tsx
|
|
1845
|
+
import { defineRestRoute, requireScope } from '@voltro/protocol/rest'
|
|
1846
|
+
import { Schema } from 'effect'
|
|
1847
|
+
import { Product, toProduct } from '../../lib/product'
|
|
1848
|
+
|
|
1849
|
+
export default defineRestRoute({
|
|
1850
|
+
method: 'POST',
|
|
1851
|
+
path: '/v1/products',
|
|
1852
|
+
input: Schema.Struct({ // the desugar parses { query?, params?, body? }
|
|
1853
|
+
body: Schema.Struct({
|
|
1854
|
+
name: Schema.NonEmptyString,
|
|
1855
|
+
priceCents: Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0)),
|
|
1856
|
+
}),
|
|
1857
|
+
}),
|
|
1858
|
+
output: Product,
|
|
1859
|
+
summary: 'Create a product', // OpenAPI metadata
|
|
1860
|
+
guards: [requireScope('products:write')], // → 403 when the subject lacks the scope
|
|
1861
|
+
handler: async ({ body }, ctx) => { /* ctx.subject + ctx.store injected by serve */ },
|
|
1862
|
+
})
|
|
1863
|
+
```
|
|
1864
|
+
|
|
1865
|
+
The desugar, per request: method gate (`405`), input decode (`400` on a bad shape), guards (`403`), `await handler`, output encode (`200` JSON). A handler may `throw { status, message }` for a specific code (the `get`/`delete` routes `throw { status: 404 }`).
|
|
1866
|
+
|
|
1867
|
+
## OpenAPI + Swagger UI — zero hand-written docs
|
|
1868
|
+
|
|
1869
|
+
```ts
|
|
1870
|
+
// app.config.ts
|
|
1871
|
+
import { openapiPlugin } from '@voltro/plugin-openapi'
|
|
1872
|
+
|
|
1873
|
+
const routes = [listProducts, getProduct, createProduct, deleteProduct]
|
|
1874
|
+
|
|
1875
|
+
export default {
|
|
1876
|
+
type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
|
|
1877
|
+
plugins: [openapiPlugin({ routes, info: { title: 'Acme API', version: '1.0.0' } })],
|
|
1878
|
+
restRoutes: routes, // the SAME array mounts the routes AND documents them
|
|
1879
|
+
idempotency: true, // Idempotency-Key dedup for the mutating routes
|
|
1880
|
+
}
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
`openapiPlugin` serves `GET /openapi.json` (the spec) + `GET /docs` (Swagger UI), built from the descriptors — `:id` paths render as OpenAPI `{id}`, and refined types (`NonEmptyString`, …) resolve under `components.schemas`. The route descriptors ARE the spec; there is nothing to hand-maintain.
|
|
1884
|
+
|
|
1885
|
+
## Auth — the demo key is DEV-ONLY
|
|
1886
|
+
|
|
1887
|
+
The guarded routes gate on a scope. `app.config.ts` wires an [`apiKeyStrategy`](/docs/authentication/overview) so `Authorization: Bearer <key>` resolves to a scoped `Subject`, and ships ONE hardcoded demo key (`restdemo_devkey` → `products:read` + `products:write`) so the write path works on first boot:
|
|
1888
|
+
|
|
1889
|
+
```ts
|
|
1890
|
+
import { apiKeyStrategy } from '@voltro/protocol/apikey'
|
|
1891
|
+
// sha256('restdemo_devkey') → a fixed scoped Subject. DEV-ONLY.
|
|
1892
|
+
const DEMO_KEY_SHA256 = 'c891…41cd'
|
|
1893
|
+
auth: { strategies: [apiKeyStrategy({
|
|
1894
|
+
prefix: 'restdemo_',
|
|
1895
|
+
resolveKey: (hash) => hash === DEMO_KEY_SHA256
|
|
1896
|
+
? { id: 'svc_demo', tenantId: 'acme', scopes: ['products:read', 'products:write'] }
|
|
1897
|
+
: null,
|
|
1898
|
+
})] }
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
Before deploying, **delete the demo constant** and look the token hash up against your own key store (store key **hashes**, never raw tokens) — or flip on the framework's first-class API keys (`apiKeys: true`) for admin-gated issue/list/revoke against `_voltro_api_keys`.
|
|
1902
|
+
|
|
1903
|
+
## Try it (curl)
|
|
1904
|
+
|
|
1905
|
+
```bash
|
|
1906
|
+
# The spec + interactive docs
|
|
1907
|
+
curl -s http://localhost:4000/openapi.json | jq '.info, (.paths | keys)'
|
|
1908
|
+
open http://localhost:4000/docs
|
|
1909
|
+
|
|
1910
|
+
# Public reads — no auth
|
|
1911
|
+
curl -s http://localhost:4000/v1/products # → { "data": [], "nextCursor": null }
|
|
1912
|
+
|
|
1913
|
+
# Guarded create — the demo key carries products:write
|
|
1914
|
+
curl -s -X POST http://localhost:4000/v1/products \
|
|
1915
|
+
-H 'Authorization: Bearer restdemo_devkey' -H 'content-type: application/json' \
|
|
1916
|
+
-d '{"name":"Widget","priceCents":1999}' # → 201 the created product
|
|
1917
|
+
|
|
1918
|
+
# Without the key → 403 (the guard rejects)
|
|
1919
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/v1/products \
|
|
1920
|
+
-H 'content-type: application/json' -d '{"name":"x","priceCents":1}' # → 403
|
|
1921
|
+
|
|
1922
|
+
# Idempotency — same key header twice → the first response replayed, not re-inserted
|
|
1923
|
+
curl -s -X POST http://localhost:4000/v1/products \
|
|
1924
|
+
-H 'Authorization: Bearer restdemo_devkey' -H 'Idempotency-Key: order-42' \
|
|
1925
|
+
-H 'content-type: application/json' -d '{"name":"Once","priceCents":500}'
|
|
1926
|
+
```
|
|
1927
|
+
|
|
1928
|
+
## Going to production
|
|
1929
|
+
|
|
1930
|
+
| Want… | Do |
|
|
1931
|
+
|---|---|
|
|
1932
|
+
| Durable data | `store: 'memory'` → `store: 'postgres'` (data survives restarts) |
|
|
1933
|
+
| Real API keys | delete the demo key; hash-lookup your own store, or set `apiKeys: true` |
|
|
1934
|
+
| Multi-tenant scoping | add `tenant()` to the table + scope each handler by the key's `tenantId` |
|
|
1935
|
+
| Versioning / sunset | a descriptor's `deprecated` sets a `Deprecation:` header; `sunset` sets `Sunset:` + `410`s past the date |
|
|
1936
|
+
|
|
1937
|
+
## Anti-patterns
|
|
1938
|
+
|
|
1939
|
+
- **Forgetting to register the route.** REST routes are NOT auto-discovered — add every `defineRestRoute` to the `restRoutes` array (and the `openapiPlugin` `routes` array) in `app.config.ts`, or it's neither mounted nor documented.
|
|
1940
|
+
- **Shipping the DEV-ONLY demo key.** It's a sample credential, like a README password — delete it and back the strategy with a real hash store before deploying.
|
|
1941
|
+
- **Reaching for REST when an action fits.** A REST route is for EXTERNAL HTTP callers. For your own reactive frontend, a [mutation](/docs/data/mutations) / [action](/docs/data/actions) over the rpc WebSocket is typed end-to-end and auto-optimistic.
|
|
1942
|
+
- **Expecting `Idempotency-Key` on the rpc transport.** It rides the HTTP surface only; double-submit from your own frontend is a UI concern (disable the button), not server idempotency.
|
|
1943
|
+
|
|
1944
|
+
## See also
|
|
1945
|
+
|
|
1946
|
+
- [App templates](/docs/reference/templates) — the full catalogue
|
|
1947
|
+
- [`@voltro/plugin-openapi`](/docs/plugins/overview) — the spec + Swagger generator
|
|
1948
|
+
- [`api-auth`](/docs/templates/api-auth) — real user sessions (the rpc-side auth counterpart)
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
---
|
|
1953
|
+
|
|
1954
|
+
<!-- source: en/templates/api-saas.md -->
|
|
1955
|
+
## API · SaaS bundle
|
|
1956
|
+
|
|
1957
|
+
_The SaaS plugin bundle in one domain — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One projects.create mutation exercises billing + analytics + notifications together. Zero-infra boot._
|
|
1958
|
+
|
|
1959
|
+
The four cross-cutting **SaaS plugins** wired turnkey in one cohesive domain — the billing / notifications / analytics / presence slice of the framework end to end, the way [`api-durable`](/docs/templates/api-durable) bundles the durable surface. A single `projects.create` mutation pulls three of them together. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-saas`**.
|
|
1960
|
+
|
|
1961
|
+
## Scaffold
|
|
1962
|
+
|
|
1963
|
+
```bash
|
|
1964
|
+
voltro create-project acme --api=api-saas
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
## What ships
|
|
1968
|
+
|
|
1969
|
+
```text
|
|
1970
|
+
apps/acme/api/
|
|
1971
|
+
├── app.config.ts # billingPlugin + notificationsPlugin + presencePlugin
|
|
1972
|
+
├── package.json # + the four SaaS plugins
|
|
1973
|
+
├── database/schema.ts # a tenant-scoped `projects` table
|
|
1974
|
+
├── queries/projects.list.query.ts # reactive list (+ .server.ts)
|
|
1975
|
+
└── mutations/projects.create.mutation.ts # the bundle in one handler (+ .server.ts)
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
Each plugin contributes its **own** routes + tables automatically — you wire them in `app.config.ts` and consume their services in handlers. The boot banner shows them all: `billing.subscription/startCheckout/…`, `notifications.inbox/markRead/…`, `presence.heartbeat/list/leave`.
|
|
1979
|
+
|
|
1980
|
+
## The four plugins
|
|
1981
|
+
|
|
1982
|
+
```ts
|
|
1983
|
+
// app.config.ts
|
|
1984
|
+
import { billingPlugin } from '@voltro/plugin-billing'
|
|
1985
|
+
import { presencePlugin } from '@voltro/plugin-presence'
|
|
1986
|
+
import { notificationsPlugin, consoleChannel } from '@voltro/plugin-notifications'
|
|
1987
|
+
|
|
1988
|
+
plugins: [
|
|
1989
|
+
billingPlugin({
|
|
1990
|
+
provider: 'mock', // in-memory; no Stripe key
|
|
1991
|
+
plans: { // tier → quota, in code
|
|
1992
|
+
free: { entitlements: { projects: 3 } },
|
|
1993
|
+
pro: { priceId: 'price_demo_pro', entitlements: { projects: 'unlimited' } },
|
|
1994
|
+
},
|
|
1995
|
+
}),
|
|
1996
|
+
notificationsPlugin({ channels: [consoleChannel()] }), // + a durable in-app inbox
|
|
1997
|
+
presencePlugin(), // presence.heartbeat / list / leave
|
|
1998
|
+
]
|
|
1999
|
+
```
|
|
2000
|
+
|
|
2001
|
+
- **Billing** — subscriptions + entitlements; `plans` is the single source of tier→quota truth. The `free` default caps `projects` at 3.
|
|
2002
|
+
- **Notifications** — one `NotificationService.send(...)` across channels + an in-app inbox (`useInbox()` / `useUnreadCount()` on the client).
|
|
2003
|
+
- **Presence** — ephemeral "who's online" per channel, plus the `usePresence(channel)` hook.
|
|
2004
|
+
|
|
2005
|
+
## The bundle, in one handler
|
|
2006
|
+
|
|
2007
|
+
`projects.create` is Effect-mode so it can `yield*` the plugin services the framework provides in the per-request stack:
|
|
2008
|
+
|
|
2009
|
+
```ts
|
|
2010
|
+
// mutations/projects.create.mutation.server.ts
|
|
2011
|
+
import { EffectStore, useAnalytics } from '@voltro/runtime'
|
|
2012
|
+
import { requireEntitlement } from '@voltro/plugin-billing'
|
|
2013
|
+
import { NotificationService } from '@voltro/plugin-notifications'
|
|
2014
|
+
|
|
2015
|
+
const execute = (input: { name: string }, ctx) =>
|
|
2016
|
+
Effect.gen(function* () {
|
|
2017
|
+
yield* requireEntitlement(ctx, 'projects', 1) // 1. BILLING — quota gate
|
|
2018
|
+
const store = yield* EffectStore
|
|
2019
|
+
const row = yield* store.insert('projects', { name: input.name }) // tenant() auto-stamps
|
|
2020
|
+
yield* (yield* useAnalytics()).track({ // 2. ANALYTICS — event
|
|
2021
|
+
name: 'project_created', subjectId: ctx.request.subject.id, properties: { name: input.name },
|
|
2022
|
+
})
|
|
2023
|
+
yield* Effect.promise(() => (yield* NotificationService).send({ // 3. NOTIFICATIONS — inbox + console
|
|
2024
|
+
to: ctx.request.subject.id ?? 'system', category: 'project',
|
|
2025
|
+
title: 'Project created', body: `"${input.name}" is live.`,
|
|
2026
|
+
}))
|
|
2027
|
+
return { id: row['id'] as string, name: row['name'] as string, tenantId: row['tenantId'] as string }
|
|
2028
|
+
})
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
The billing plugin auto-merges its typed `EntitlementExceeded` (+ `BillingError`) into this procedure's wire-error union — the client decodes the over-quota failure typed, **without** a manual `error:` declaration.
|
|
2032
|
+
|
|
2033
|
+
## Try it (curl)
|
|
2034
|
+
|
|
2035
|
+
`projects.create` and the plugin routes are rpc procedures — call them with `useMutation` / `useSubscription` from a web app, or over HTTP via the dev inspect endpoint:
|
|
2036
|
+
|
|
2037
|
+
```bash
|
|
2038
|
+
# Create projects — the free plan allows 3
|
|
2039
|
+
for i in 1 2 3; do
|
|
2040
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
2041
|
+
-H 'content-type: application/json' \
|
|
2042
|
+
-d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
|
|
2043
|
+
done
|
|
2044
|
+
# → { "ok": true, "result": { "id": "proj_…", … } }
|
|
2045
|
+
|
|
2046
|
+
# The 4th trips the billing entitlement
|
|
2047
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
|
|
2048
|
+
-H 'content-type: application/json' \
|
|
2049
|
+
-d '{"tag":"projects.create","input":{"name":"Project 4"}}'
|
|
2050
|
+
# → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, "used": 3 } }
|
|
2051
|
+
```
|
|
2052
|
+
|
|
2053
|
+
Each successful create also logs `[notify:project] → …: Project created` (console channel) and persists a `notification_inbox` row.
|
|
2054
|
+
|
|
2055
|
+
## Enable durable analytics
|
|
2056
|
+
|
|
2057
|
+
`useAnalytics().track(...)` is **noop-safe** with no sink configured (it never crashes), so the event API is reachable on the zero-infra memory store. To actually STORE + query events (`aggregate` / `timeseries` / `topN`), add a sink AND a SQL store — the postgres-lite sink needs a `SqlClient`:
|
|
2058
|
+
|
|
2059
|
+
```ts
|
|
2060
|
+
// app.config.ts
|
|
2061
|
+
import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
|
|
2062
|
+
|
|
2063
|
+
export default {
|
|
2064
|
+
type: 'api' as const, name: 'AcmeApi',
|
|
2065
|
+
store: 'postgres' as const, // or 'sqlite' (file:./.voltro/db.sqlite)
|
|
2066
|
+
analytics: postgresAnalytics(), // events → _voltro_events
|
|
2067
|
+
plugins: [ /* … */ ],
|
|
2068
|
+
}
|
|
2069
|
+
```
|
|
2070
|
+
|
|
2071
|
+
## Going to production
|
|
2072
|
+
|
|
2073
|
+
| Want… | Do |
|
|
2074
|
+
|---|---|
|
|
2075
|
+
| Real subscriptions / checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
|
|
2076
|
+
| Email / Slack / SMS notifications | add `emailChannel()` / `webhookChannel()` / `smsChannel()` to `channels` |
|
|
2077
|
+
| Durable analytics at scale | `postgresAnalytics()` on postgres, or swap to `@voltro/plugin-clickhouse` / `@voltro/plugin-duckdb` |
|
|
2078
|
+
| Live presence roster in the UI | subscribe a reactive query over `_voltro_presence`, or use `usePresence(channel)` |
|
|
2079
|
+
|
|
2080
|
+
## Anti-patterns
|
|
2081
|
+
|
|
2082
|
+
- **Hand-rolling `notifications.*` / `billing.*` / `presence.*` procedures.** The plugins own those tag namespaces — boot fails on a collision. Use the plugin routes + services.
|
|
2083
|
+
- **Expecting analytics to persist on `memory`.** The postgres-lite sink needs a `SqlClient`. `track()` is reachable + safe on memory; events land in `_voltro_events` only once you wire a sink + a SQL store.
|
|
2084
|
+
- **Skipping the entitlement gate on a paid action.** A scope says "may you call this"; an entitlement says "do you have quota left" — orthogonal. A paid action needs both.
|
|
2085
|
+
|
|
2086
|
+
## See also
|
|
2087
|
+
|
|
2088
|
+
- [Plugins overview](/docs/plugins/overview) — the full plugin catalogue + status
|
|
2089
|
+
- [`api-durable`](/docs/templates/api-durable) — the durable + reactive bundle (the sibling showcase)
|
|
2090
|
+
- [App templates](/docs/reference/templates) — the full catalogue
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
---
|
|
2095
|
+
|
|
2096
|
+
<!-- source: en/templates/api-observability.md -->
|
|
2097
|
+
## API · Observability
|
|
2098
|
+
|
|
2099
|
+
_Production-readiness in one backend — Prometheus metrics (GET /metrics + a custom counter), Sentry error tracking (inert without a DSN), distributed tracing, and a @voltro/testing unit test (makeTestContext + mockStore, run with voltro test). Zero-infra boot._
|
|
2100
|
+
|
|
2101
|
+
The production-readiness slice in one backend: **Prometheus metrics**, **Sentry error tracking**, **distributed tracing**, and a **`@voltro/testing` unit test** — so a real service is observable AND covered from day one. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-observability`**.
|
|
2102
|
+
|
|
2103
|
+
## Scaffold
|
|
2104
|
+
|
|
2105
|
+
```bash
|
|
2106
|
+
voltro create-project acme --api=api-observability
|
|
2107
|
+
```
|
|
2108
|
+
|
|
2109
|
+
## What ships
|
|
2110
|
+
|
|
2111
|
+
```text
|
|
2112
|
+
apps/acme/api/
|
|
2113
|
+
├── app.config.ts # prometheusPlugin() + sentryPlugin()
|
|
2114
|
+
├── package.json # + the plugins; devDeps: @voltro/testing, vitest
|
|
2115
|
+
├── database/schema.ts # a tenant-scoped `notes` table
|
|
2116
|
+
├── queries/notes.list.query.ts # reactive list (+ .server.ts)
|
|
2117
|
+
├── mutations/notes.create.mutation.ts # create + a custom metric (+ .server.ts)
|
|
2118
|
+
└── tests/notes.create.test.ts # makeTestContext + mockStore
|
|
2119
|
+
```
|
|
2120
|
+
|
|
2121
|
+
## Metrics — `GET /metrics` + your own counters
|
|
2122
|
+
|
|
2123
|
+
```ts
|
|
2124
|
+
// app.config.ts
|
|
2125
|
+
import { prometheusPlugin } from '@voltro/plugin-prometheus'
|
|
2126
|
+
import { sentryPlugin } from '@voltro/plugin-sentry'
|
|
2127
|
+
|
|
2128
|
+
plugins: [ prometheusPlugin(), sentryPlugin() ]
|
|
2129
|
+
```
|
|
2130
|
+
|
|
2131
|
+
The framework already records `voltro_rpc_*` / `voltro_http_*` / subscription / cache metrics into Effect's global `MetricRegistry`; `prometheusPlugin()` exports them at `/metrics`. A **custom** counter shows up there automatically — declare it at module level, bump it in a handler:
|
|
2132
|
+
|
|
2133
|
+
```ts
|
|
2134
|
+
// mutations/notes.create.mutation.server.ts
|
|
2135
|
+
import { Effect, Metric } from 'effect'
|
|
2136
|
+
import { counter } from '@voltro/runtime'
|
|
2137
|
+
|
|
2138
|
+
const notesCreated = counter('app_notes_created_total', 'Notes created.')
|
|
2139
|
+
Effect.runSync(Metric.increment(notesCreated)) // → /metrics + the DevTools Metrics panel
|
|
2140
|
+
```
|
|
2141
|
+
|
|
2142
|
+
```bash
|
|
2143
|
+
curl -s http://localhost:4000/metrics | grep -E 'app_notes_created_total|voltro_rpc_requests_total'
|
|
2144
|
+
# app_notes_created_total 1
|
|
2145
|
+
# voltro_rpc_requests_total{status="ok",tag="mutation.notes.create"} 1
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
Point Prometheus / Grafana at `/metrics`; gate a public deploy with `prometheusPlugin({ token })` / `PROMETHEUS_TOKEN`.
|
|
2149
|
+
|
|
2150
|
+
## Errors — Sentry, inert until you set a DSN
|
|
2151
|
+
|
|
2152
|
+
`sentryPlugin()` reports rpc / REST / workflow / schedule / subscriber / render errors, correlated to the active **`traceId`** — and is **inert without `SENTRY_DSN`**, so it ships wired. Set the env to turn it on; no code change. The plugin subscribes to the framework's server-error bus once, so every primitive is covered. For the full browser→backend waterfall, add the `sentry` field to a paired web app's `app.config.ts`.
|
|
2153
|
+
|
|
2154
|
+
## Tracing — on by default
|
|
2155
|
+
|
|
2156
|
+
`voltro dev` runs a buffer-only tracer that powers the DevTools **Traces** panel and stamps `fields.traceId` on every log line (`voltro logs --trace <id>`). Set `OTEL_EXPORTER_OTLP_ENDPOINT` to ship spans to Jaeger / Tempo / Honeycomb — no code change.
|
|
2157
|
+
|
|
2158
|
+
## Testing — `makeTestContext`, no DB, no server
|
|
2159
|
+
|
|
2160
|
+
```ts
|
|
2161
|
+
// tests/notes.create.test.ts
|
|
2162
|
+
import { makeTestContext, mockStore } from '@voltro/testing'
|
|
2163
|
+
import createNote from '../mutations/notes.create.mutation.server'
|
|
2164
|
+
|
|
2165
|
+
const ctx = makeTestContext({
|
|
2166
|
+
subject: { type: 'user', id: 'user_1', tenantId: 'acme' },
|
|
2167
|
+
store: mockStore({ notes: [] }),
|
|
2168
|
+
})
|
|
2169
|
+
const row = await createNote({ title: 'Hello', body: 'World' }, ctx)
|
|
2170
|
+
expect(row.tenantId).toBe('acme') // tenant() stamped it — the REAL store, in-memory
|
|
2171
|
+
```
|
|
2172
|
+
|
|
2173
|
+
`ctx.store` is the **same** mixin-wrapped store production uses, so tenant auto-scoping, soft-delete filtering, and audit auto-fill behave identically — in milliseconds, no docker. `ctx.withTenant('t2', (c) => c.store.query(…))` re-scopes the same data to prove cross-tenant isolation. Run it:
|
|
2174
|
+
|
|
2175
|
+
```bash
|
|
2176
|
+
voltro test
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
The package also ships `MockClock` / `MockEmail` / `mockAi` / `makeWorkflowRunner` and `runDialectParity` (the `@voltro/testing/dialect` subpath).
|
|
2180
|
+
|
|
2181
|
+
## Going to production
|
|
2182
|
+
|
|
2183
|
+
| Want… | Do |
|
|
2184
|
+
|---|---|
|
|
2185
|
+
| Metrics on a public deploy | `prometheusPlugin({ token })` + scrape over TLS |
|
|
2186
|
+
| Errors in Sentry | set `SENTRY_DSN`; add `sentryPlugin({ traces: true })` for performance traces |
|
|
2187
|
+
| Spans in your tracer | `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318` |
|
|
2188
|
+
| Vendor-native (Datadog) | add `@voltro/plugin-datadog` — agentless metrics + correlated logs/traces |
|
|
2189
|
+
| A CI gate | run `voltro test`; add `runDialectParity` for hand-written SQL |
|
|
2190
|
+
|
|
2191
|
+
## Anti-patterns
|
|
2192
|
+
|
|
2193
|
+
- **Re-creating the counter per call.** Declare `counter(...)` at MODULE level — a per-call `counter()` muddies the series.
|
|
2194
|
+
- **Wiring Sentry per primitive.** The plugin covers them all via the server-error bus — you just set the DSN.
|
|
2195
|
+
- **A real DB in unit tests.** `makeTestContext` + `mockStore` give real store behaviour in-memory; save a live database for `voltro e2e`.
|
|
2196
|
+
|
|
2197
|
+
## See also
|
|
2198
|
+
|
|
2199
|
+
- [Observability](/docs/observability/overview) · [Distributed tracing](/docs/observability/distributed-tracing) · [Unit testing](/docs/testing/unit-testing)
|
|
2200
|
+
- [App templates](/docs/reference/templates) — the full catalogue
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
---
|
|
2205
|
+
|
|
2206
|
+
<!-- source: en/templates/api-webhooks.md -->
|
|
2207
|
+
## API · Webhooks
|
|
2208
|
+
|
|
2209
|
+
_First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineOutgoingEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
|
|
2210
|
+
|
|
2211
|
+
Webhooks **both ways**, wired turnkey: a **signature-verified incoming receiver** that rejects forged traffic before your code runs, and an **outgoing event** a mutation emits to subscribed targets through a durable, signed, retried delivery workflow. Webhooks are **file-convention** (drop a `*.webhook.tsx`, it's auto-discovered) — not a `plugins:[]` entry. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-webhooks`**.
|
|
2212
|
+
|
|
2213
|
+
## Scaffold
|
|
2214
|
+
|
|
2215
|
+
```bash
|
|
2216
|
+
voltro create-project acme --api=api-webhooks
|
|
2217
|
+
```
|
|
2218
|
+
|
|
2219
|
+
The shipped `.env` supplies a DEV-ONLY `VOLTRO_WEBHOOK_SECRET_ORDERS` so the incoming receiver boots with a key.
|
|
2220
|
+
|
|
2221
|
+
## What ships
|
|
2222
|
+
|
|
2223
|
+
```text
|
|
2224
|
+
apps/acme/api/
|
|
2225
|
+
├── .env # VOLTRO_WEBHOOK_SECRET_ORDERS (dev signing secret)
|
|
2226
|
+
├── database/schema.ts # orders + webhookTables() bookkeeping
|
|
2227
|
+
├── webhooks/orders.webhook.tsx # INCOMING — signature-verified receiver
|
|
2228
|
+
├── events/order.completed.webhook.tsx # OUTGOING — defineOutgoingEvent
|
|
2229
|
+
├── mutations/orders.fulfill.mutation.ts # create order + emit (+ .server.ts)
|
|
2230
|
+
└── queries/orders.list.query.ts # reactive list (+ .server.ts)
|
|
2231
|
+
```
|
|
2232
|
+
|
|
2233
|
+
## Incoming — verified before your handler runs
|
|
2234
|
+
|
|
2235
|
+
```tsx
|
|
2236
|
+
// webhooks/orders.webhook.tsx → mounts at POST /webhooks/orders
|
|
2237
|
+
import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
|
|
2238
|
+
import { genericProvider } from '@voltro/plugin-webhooks/providers'
|
|
2239
|
+
|
|
2240
|
+
export default defineIncomingWebhook({
|
|
2241
|
+
id: 'orders', provider: genericProvider(),
|
|
2242
|
+
payload: Schema.Struct({ event: Schema.String, orderId: Schema.String, sku: Schema.String, totalCents: Schema.Number }),
|
|
2243
|
+
handler: async (ctx) => { /* ctx.body is validated; ctx.idempotencyKey set */ },
|
|
2244
|
+
})
|
|
2245
|
+
```
|
|
2246
|
+
|
|
2247
|
+
The framework's incoming middleware runs **before** `handler`:
|
|
2248
|
+
|
|
2249
|
+
1. verify the HMAC over `<ts>.<rawBody>` with `VOLTRO_WEBHOOK_SECRET_ORDERS` → **`401`** on mismatch
|
|
2250
|
+
2. reject a `t` older than the replay window → **`401`**
|
|
2251
|
+
3. decode the body against `payload` → **`422`** on a bad shape
|
|
2252
|
+
4. claim the `Idempotency-Key` → a replay returns **`200 {duplicate:true}`** and the handler is **skipped**
|
|
2253
|
+
|
|
2254
|
+
Only a request that passes all of that reaches `handler`. You never hand-roll a signature check, and forged traffic never runs your code. Built-in presets: `stripeProvider` / `githubProvider` / `slackProvider` / `genericProvider` (or `defineWebhookProvider` for a custom partner). The signing secret is `VOLTRO_WEBHOOK_SECRET_<UPPER_ID>` — here `_ORDERS`.
|
|
2255
|
+
|
|
2256
|
+
### Try it (curl)
|
|
2257
|
+
|
|
2258
|
+
```bash
|
|
2259
|
+
SECRET='devonly-webhook-secret-change-me'
|
|
2260
|
+
BODY='{"event":"order.created","orderId":"o_1","sku":"WIDGET","totalCents":1999}'
|
|
2261
|
+
TS=$(date +%s)
|
|
2262
|
+
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= //')
|
|
2263
|
+
|
|
2264
|
+
# valid signature → 200, handler runs
|
|
2265
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
|
|
2266
|
+
-H "X-Webhook-Signature: t=$TS,v1=$SIG" -H 'content-type: application/json' \
|
|
2267
|
+
-H 'Idempotency-Key: evt_1' --data "$BODY" # → 200
|
|
2268
|
+
|
|
2269
|
+
# no signature → 401 · forged → 401 · valid sig + wrong shape → 422
|
|
2270
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
|
|
2271
|
+
-H 'content-type: application/json' --data "$BODY" # → 401
|
|
2272
|
+
```
|
|
2273
|
+
|
|
2274
|
+
## Outgoing — emit to subscribed targets
|
|
2275
|
+
|
|
2276
|
+
```ts
|
|
2277
|
+
// events/order.completed.webhook.tsx
|
|
2278
|
+
export default defineOutgoingEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
|
|
2279
|
+
|
|
2280
|
+
// mutations/orders.fulfill.mutation.server.ts — after the row commits:
|
|
2281
|
+
const { eventId, deliveries } = await useWebhooks(ctx).emit('order.completed', { orderId, tenantId, /* … */ })
|
|
2282
|
+
```
|
|
2283
|
+
|
|
2284
|
+
External systems subscribe at runtime — `ctx.webhooks.subscribe({ event: 'order.completed', url })` (a row in `_voltro_webhook_targets`). On `emit`, the framework fans out to every matching target through a **durable delivery workflow** — HMAC-signing the outbound request, retrying with backoff, honouring `Retry-After`; each attempt lands in `_voltro_webhook_deliveries`. `emit` returns immediately (the POSTs run in the background); `deliveries` is one entry per matched target (empty until someone subscribes).
|
|
2285
|
+
|
|
2286
|
+
```bash
|
|
2287
|
+
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2288
|
+
-d '{"tag":"orders.fulfill","input":{"sku":"WIDGET","totalCents":4999}}'
|
|
2289
|
+
# → the order; the log shows: order.completed event=… → 0 target(s)
|
|
2290
|
+
```
|
|
2291
|
+
|
|
2292
|
+
## Going to production
|
|
2293
|
+
|
|
2294
|
+
| Want… | Do |
|
|
2295
|
+
|---|---|
|
|
2296
|
+
| A real partner (Stripe/GitHub/Slack) | swap `genericProvider()` → `stripeProvider()` etc.; set `VOLTRO_WEBHOOK_SECRET_<ID>` |
|
|
2297
|
+
| Durable targets + delivery history | `store: 'postgres'` (the `_voltro_webhook_*` tables survive restarts) |
|
|
2298
|
+
| A subscribe API | a mutation calling `ctx.webhooks.subscribe({ event, url, secret?, retry? })` |
|
|
2299
|
+
| A custom signature scheme | `defineWebhookProvider({ id, signature, idempotency })` |
|
|
2300
|
+
|
|
2301
|
+
## Anti-patterns
|
|
2302
|
+
|
|
2303
|
+
- **Trusting webhook input without verification.** The framework rejects unsigned incoming requests by default — don't set `signature: undefined` unless a network trust boundary gates the route.
|
|
2304
|
+
- **Mutating domain state synchronously in the outgoing emit path.** Mutate in the mutation, THEN emit — delivery is async (rollback drops the queued send).
|
|
2305
|
+
- **Re-using the idempotency TTL as the provider's retry window.** Pick TTL ≥ 2× the provider's max retry window so the dedup catches the slowest retry.
|
|
2306
|
+
|
|
2307
|
+
## See also
|
|
2308
|
+
|
|
2309
|
+
- [Plugins overview](/docs/plugins/overview) — `@voltro/plugin-webhooks` + the full catalogue
|
|
2310
|
+
- [App templates](/docs/reference/templates) — the full catalogue
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
---
|
|
2315
|
+
|
|
2316
|
+
<!-- source: en/templates/api-search.md -->
|
|
2317
|
+
## API · Search
|
|
2318
|
+
|
|
2319
|
+
_Full-text search that stays in sync — @voltro/plugin-search mirrors every table write into an index via the post-commit change tap; the synthesized search.query rpc returns tenant-scoped hits. Memory backend (zero infra); swap one line for Typesense/Meilisearch/Algolia._
|
|
2320
|
+
|
|
2321
|
+
Search that never drifts from your data. `@voltro/plugin-search` taps the post-commit ChangeEvent stream: every insert/update to a source table upserts its doc into a search index, every delete removes it — no app code, no cron, no manual reindex. The plugin synthesizes a `search.query` rpc that returns hits **auto-filtered to the caller's tenant**. The template runs on `memoryBackend()` (in-process, zero infra) so it boots and validates with nothing installed; going to production is one line in `lib/search.ts`. Template id: **`api-search`**.
|
|
2322
|
+
|
|
2323
|
+
## Scaffold
|
|
2324
|
+
|
|
2325
|
+
```bash
|
|
2326
|
+
voltro create-project acme --api=api-search
|
|
2327
|
+
voltro add-app search --template=api-search --to acme
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
## What ships
|
|
2331
|
+
|
|
2332
|
+
```text
|
|
2333
|
+
apps/acme/api/
|
|
2334
|
+
├── app.config.ts # store:'memory', plugins: [searchPlugin({ backend, indexes })]
|
|
2335
|
+
├── lib/
|
|
2336
|
+
│ └── search.ts # the SHARED backend instance + the articles index spec
|
|
2337
|
+
├── database/schema.ts # actors, tenants, articles (tenant-scoped, reactive)
|
|
2338
|
+
├── mutations/articles.create.* # writes that auto-index via the tap
|
|
2339
|
+
├── queries/articles.list.* # a live list to sit next to the search box
|
|
2340
|
+
├── seeds/articles.seed.ts # demo data
|
|
2341
|
+
├── startup/searchBackfill.startup.tsx # backfillIndex() on boot
|
|
2342
|
+
├── package.json # + @voltro/plugin-search
|
|
2343
|
+
└── tsconfig.json
|
|
2344
|
+
```
|
|
2345
|
+
|
|
2346
|
+
## The index — `searchPlugin` + a shared backend
|
|
2347
|
+
|
|
2348
|
+
`lib/search.ts` holds the wiring, shared so `app.config.ts` (the plugin) and the boot startup (the backfill) use the **same** backend instance — `memoryBackend()` is a closure over a Map, so two calls would be two separate indexes:
|
|
2349
|
+
|
|
2350
|
+
```ts
|
|
2351
|
+
// lib/search.ts
|
|
2352
|
+
import { memoryBackend, type IndexSpec, type SearchDoc } from '@voltro/plugin-search'
|
|
2353
|
+
|
|
2354
|
+
export const searchBackend = memoryBackend()
|
|
2355
|
+
|
|
2356
|
+
export const articlesIndex: IndexSpec = {
|
|
2357
|
+
index: 'articles',
|
|
2358
|
+
// Flatten a row → a search doc (must include `id`).
|
|
2359
|
+
map: (row): SearchDoc => ({
|
|
2360
|
+
id: row['id'] as string,
|
|
2361
|
+
title: row['title'] as string,
|
|
2362
|
+
body: row['body'] as string,
|
|
2363
|
+
tag: row['tag'] as string,
|
|
2364
|
+
tenantId: row['tenantId'] as string,
|
|
2365
|
+
}),
|
|
2366
|
+
// Makes search.query auto-filter to the caller's tenant — no leakage.
|
|
2367
|
+
tenantField: 'tenantId',
|
|
2368
|
+
}
|
|
2369
|
+
```
|
|
2370
|
+
|
|
2371
|
+
```ts
|
|
2372
|
+
// app.config.ts
|
|
2373
|
+
import { searchPlugin } from '@voltro/plugin-search'
|
|
2374
|
+
import { searchBackend, articlesIndex } from './lib/search'
|
|
2375
|
+
|
|
2376
|
+
export default {
|
|
2377
|
+
type: 'api' as const, name: 'AcmeSearch', store: 'memory' as const,
|
|
2378
|
+
plugins: [searchPlugin({ backend: searchBackend, indexes: { articles: articlesIndex } })],
|
|
2379
|
+
}
|
|
2380
|
+
```
|
|
2381
|
+
|
|
2382
|
+
## Live sync — the change tap
|
|
2383
|
+
|
|
2384
|
+
There is **no indexing code** in your handlers. The plugin registers an `onChangeEvent` tap; when a mutation commits a write to `articles`, the plugin maps the row and upserts it (or removes it on delete). So a freshly-created article is searchable on the very next request:
|
|
2385
|
+
|
|
2386
|
+
```bash
|
|
2387
|
+
# create an article — indexed on commit
|
|
2388
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2389
|
+
-d '{"tag":"articles.create","input":{"tenantId":"acme","title":"Reactive queries","body":"Subscriptions push deltas live.","tag":"guide"}}'
|
|
2390
|
+
# …then find it
|
|
2391
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2392
|
+
-d '{"tag":"search.query","input":{"index":"articles","q":"reactive"}}'
|
|
2393
|
+
# → { ok:true, result:[ { id, title:"Reactive queries", … } ] }
|
|
2394
|
+
```
|
|
2395
|
+
|
|
2396
|
+
## `search.query` — tenant-scoped by construction
|
|
2397
|
+
|
|
2398
|
+
The plugin synthesizes the `search.query` rpc — input `{ index, q, limit?, filters? }`, output the matching docs. Because the index spec set `tenantField: 'tenantId'`, the query auto-filters to the caller's tenant: a request resolved to tenant `acme` only ever sees `acme` docs, even though every tenant's docs share one index. Searching the same term as a different tenant returns nothing.
|
|
2399
|
+
|
|
2400
|
+
## Backfill existing rows
|
|
2401
|
+
|
|
2402
|
+
The change tap covers writes AFTER boot. `startup/searchBackfill.startup.tsx` seeds the index from rows already in the table — the demo seed's articles, or every row after a restart or a switch to a new engine. It uses the same `searchBackend` instance, so the docs it writes are exactly what `search.query` reads:
|
|
2403
|
+
|
|
2404
|
+
```tsx
|
|
2405
|
+
import { backfillIndex } from '@voltro/plugin-search'
|
|
2406
|
+
import { database } from '../database/schema'
|
|
2407
|
+
import { searchBackend, articlesIndex } from '../lib/search'
|
|
2408
|
+
|
|
2409
|
+
export default async ({ store, log }) => {
|
|
2410
|
+
const rows = await store.query(database.articles.descriptor)
|
|
2411
|
+
const count = await backfillIndex(searchBackend, articlesIndex, rows)
|
|
2412
|
+
log.info(`search: backfilled ${count} article(s) into the index`)
|
|
2413
|
+
}
|
|
2414
|
+
```
|
|
2415
|
+
|
|
2416
|
+
## On the web side
|
|
2417
|
+
|
|
2418
|
+
```tsx
|
|
2419
|
+
import { useSearch } from '@voltro/plugin-search/web'
|
|
2420
|
+
const { results, run, pending } = useSearch('articles')
|
|
2421
|
+
// run('reactive') → results auto-scoped to the signed-in tenant
|
|
2422
|
+
```
|
|
2423
|
+
|
|
2424
|
+
The `/web` subpath imports nothing server-only — it's just a query over the `search.query` rpc, browser-safe.
|
|
2425
|
+
|
|
2426
|
+
## Going to production — one line
|
|
2427
|
+
|
|
2428
|
+
`memoryBackend()` is in-process: great for dev, single-instance only. For real deployments swap it in `lib/search.ts` for a vendor engine — the indexing + query + backfill code is identical:
|
|
2429
|
+
|
|
2430
|
+
```ts
|
|
2431
|
+
import { typesenseBackend } from '@voltro/plugin-search' // or meilisearch / algolia
|
|
2432
|
+
export const searchBackend = typesenseBackend({ url: process.env.TYPESENSE_URL!, apiKey: process.env.TYPESENSE_KEY! })
|
|
2433
|
+
```
|
|
2434
|
+
|
|
2435
|
+
## When to use api-search vs. the other backends
|
|
2436
|
+
|
|
2437
|
+
| You want… | Pick |
|
|
2438
|
+
|---|---|
|
|
2439
|
+
| Full-text search kept in sync with a table | `api-search` |
|
|
2440
|
+
| Semantic / vector search + a RAG agent | [`api-ai`](/docs/templates/api-ai) (a `vectorEmbedding()` index + an agent) |
|
|
2441
|
+
| The minimal CRUD backend | [`api-backend`](/docs/templates/api-backend) |
|
|
2442
|
+
|
|
2443
|
+
`api-search` is keyword/full-text (Typesense-style); `api-ai` is embeddings/semantic. They compose — `hybridSearch` fuses both.
|
|
2444
|
+
|
|
2445
|
+
## Pairs well with
|
|
2446
|
+
|
|
2447
|
+
- Any **web** template — drop a search box wired to `useSearch('articles')`.
|
|
2448
|
+
- [`api-backend`](/docs/templates/api-backend) patterns for the rest of the CRUD surface around the searchable table.
|
|
2449
|
+
|
|
2450
|
+
## Anti-patterns
|
|
2451
|
+
|
|
2452
|
+
- **Two `memoryBackend()` instances.** It's a closure over a Map — calling it twice gives two separate indexes, so a backfill into one is invisible to a `search.query` reading the other. Create ONE instance in `lib/search.ts` and share it (this template's whole reason for that file).
|
|
2453
|
+
- **Hand-indexing in your mutation.** Don't `searchBackend.upsert(...)` inside `articles.create` — the change tap already does it on commit. Doubling up risks drift and races. Let the tap own the index.
|
|
2454
|
+
- **Forgetting `tenantField`.** Without it, `search.query` returns every tenant's hits — a cross-tenant leak. Set `tenantField` on any index over a `tenant()`-scoped table.
|
|
2455
|
+
- **Relying on the seed's rows being indexed without the backfill.** The seed writes through the RAW store (no subject); whether that fires the tap is store-dependent. The boot `backfillIndex` is what guarantees pre-existing rows are in the index — keep it.
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
---
|
|
2460
|
+
|
|
2461
|
+
<!-- source: en/templates/api-feature-flags.md -->
|
|
2462
|
+
## API · Feature flags
|
|
2463
|
+
|
|
2464
|
+
_Feature flags wired turnkey with @voltro/plugin-flags — flags as code (kill-switch / % rollout / targeting), a declarative gatedBy map that fails typed FlagDisabled before the handler, an in-handler requireFlag guard, and flags.evaluate for useFlag() on the web. Memory store, zero infra._
|
|
2465
|
+
|
|
2466
|
+
Feature flags without a service. `@voltro/plugin-flags` declares flags **as code** and gates rpc calls two ways — declaratively (a `gatedBy` map fails `FlagDisabled` before the handler) and in-handler (`requireFlag(ctx, key)`). A flag is a bare boolean (kill-switch) or a rich definition with a deterministic `% rollout` and OR-of-rules `targeting`. The client gets the same evaluation via `useFlag()`. Memory store is zero-infra; `postgres` adds runtime toggles. Template id: **`api-feature-flags`**.
|
|
2467
|
+
|
|
2468
|
+
## Scaffold
|
|
2469
|
+
|
|
2470
|
+
```bash
|
|
2471
|
+
voltro create-project acme --api=api-feature-flags
|
|
2472
|
+
```
|
|
2473
|
+
|
|
2474
|
+
## Flags as code
|
|
2475
|
+
|
|
2476
|
+
```ts
|
|
2477
|
+
// app.config.ts
|
|
2478
|
+
import { flagsPlugin } from '@voltro/plugin-flags'
|
|
2479
|
+
|
|
2480
|
+
flagsPlugin({
|
|
2481
|
+
flags: {
|
|
2482
|
+
newEditor: true, // kill-switch (on)
|
|
2483
|
+
betaExport: false, // kill-switch (off)
|
|
2484
|
+
proDashboard: { targeting: [{ metadata: { plan: 'pro' } }] }, // targeting
|
|
2485
|
+
gradualRollout: { rollout: 25 }, // deterministic % rollout
|
|
2486
|
+
},
|
|
2487
|
+
gatedBy: { 'notes.export': 'betaExport' }, // tag → flag
|
|
2488
|
+
store: 'memory',
|
|
2489
|
+
})
|
|
2490
|
+
```
|
|
2491
|
+
|
|
2492
|
+
## Two ways to gate
|
|
2493
|
+
|
|
2494
|
+
- **Declarative** — the `gatedBy` map points `notes.export` at `betaExport`. The framework fails typed `FlagDisabled` **before** the handler when the flag is off. No code in the handler.
|
|
2495
|
+
- **In-handler** — `notes.create` calls the Effect-native guard:
|
|
2496
|
+
|
|
2497
|
+
```ts
|
|
2498
|
+
// notes.create.mutation.server.ts
|
|
2499
|
+
import { requireFlag } from '@voltro/plugin-flags'
|
|
2500
|
+
|
|
2501
|
+
export default (input, ctx) => Effect.gen(function* () {
|
|
2502
|
+
yield* requireFlag(ctx, 'newEditor') // fails typed FlagDisabled when off
|
|
2503
|
+
const store = yield* EffectStore
|
|
2504
|
+
return yield* store.insert('notes', { title: input.title, body: input.body })
|
|
2505
|
+
})
|
|
2506
|
+
```
|
|
2507
|
+
|
|
2508
|
+
Declare `error: FlagDisabled` (from the browser-safe `@voltro/plugin-flags/errors`) on the procedure so the client decodes it typed.
|
|
2509
|
+
|
|
2510
|
+
## Try it
|
|
2511
|
+
|
|
2512
|
+
```bash
|
|
2513
|
+
# newEditor ON → requireFlag passes → created:
|
|
2514
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2515
|
+
-d '{"tag":"notes.create","input":{"tenantId":"acme","title":"hi","body":"x"}}'
|
|
2516
|
+
# betaExport OFF → gatedBy blocks → typed FlagDisabled:
|
|
2517
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2518
|
+
-d '{"tag":"notes.export","input":{}}'
|
|
2519
|
+
# → { ok:false, error:{ _tag:"FlagDisabled", flag:"betaExport" } }
|
|
2520
|
+
```
|
|
2521
|
+
|
|
2522
|
+
## On the web side
|
|
2523
|
+
|
|
2524
|
+
```tsx
|
|
2525
|
+
import { useFlag } from '@voltro/plugin-flags/web'
|
|
2526
|
+
const showBeta = useFlag('betaExport') // same targeting + rollout as the server
|
|
2527
|
+
{showBeta && <BetaExportButton />}
|
|
2528
|
+
```
|
|
2529
|
+
|
|
2530
|
+
## Runtime toggles
|
|
2531
|
+
|
|
2532
|
+
`store: 'postgres'` overlays runtime-toggleable flags (`_voltro_feature_flags`) on the code baseline — flip a kill-switch without a deploy. The dashboard ships a **Flags** panel for it.
|
|
2533
|
+
|
|
2534
|
+
## Anti-patterns
|
|
2535
|
+
|
|
2536
|
+
- **Trusting `useFlag` for enforcement.** It's a UI affordance; the server guard (`gatedBy` / `requireFlag`) is the real gate. Never gate a write on the client alone.
|
|
2537
|
+
- **A `% rollout` keyed on a non-stable id.** Rollout buckets deterministically per subject (or tenant via `rolloutBy: 'tenant'`) — an anonymous/idless caller buckets on `'anon'`, so don't expect per-call variation.
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
---
|
|
2542
|
+
|
|
2543
|
+
<!-- source: en/templates/api-ratelimit.md -->
|
|
2544
|
+
## API · Rate limiting
|
|
2545
|
+
|
|
2546
|
+
_Per-endpoint / per-subject / per-tenant rpc rate limiting with @voltro/plugin-ratelimit — a default fallback + rules (exact tag or regex), sliding-window / token-bucket algorithms, composite keying, and a typed RateLimited error auto-merged into the wire error union. Memory store, zero infra._
|
|
2547
|
+
|
|
2548
|
+
Throttle rpc calls without touching a handler. `@voltro/plugin-ratelimit` runs on the rpc interceptors — the one surface that sees every call AND the resolved subject. A `default` fallback applies everywhere; `rules` override per endpoint with a choice of algorithm and bucket key. Over-limit calls fail a typed `RateLimited` error the plugin merges into every procedure's wire error union. Memory store is single-node; postgres/redis for a cluster. Template id: **`api-ratelimit`**.
|
|
2549
|
+
|
|
2550
|
+
## Scaffold
|
|
2551
|
+
|
|
2552
|
+
```bash
|
|
2553
|
+
voltro create-project acme --api=api-ratelimit
|
|
2554
|
+
```
|
|
2555
|
+
|
|
2556
|
+
## Limits as config
|
|
2557
|
+
|
|
2558
|
+
```ts
|
|
2559
|
+
// app.config.ts
|
|
2560
|
+
import { rateLimitPlugin } from '@voltro/plugin-ratelimit'
|
|
2561
|
+
|
|
2562
|
+
rateLimitPlugin({
|
|
2563
|
+
default: { limit: 100, window: '1m' }, // global fallback, per subject
|
|
2564
|
+
rules: [
|
|
2565
|
+
// 3/min per tenant, token-bucket (starts full → burst of 3, then refills)
|
|
2566
|
+
{ match: 'notes.create', limit: 3, window: '1m', algorithm: 'token-bucket', burst: 3, by: 'tenant' },
|
|
2567
|
+
],
|
|
2568
|
+
store: 'memory',
|
|
2569
|
+
})
|
|
2570
|
+
```
|
|
2571
|
+
|
|
2572
|
+
- **`match`** — an exact tag or a `/regex/`; **`kind`** narrows to mutation/query/action.
|
|
2573
|
+
- **`algorithm`** — `sliding-window` (default), `fixed-window`, or `token-bucket` (with `burst`).
|
|
2574
|
+
- **`by`** — the bucket key: `subject` (default), `tenant`, `apiKey`, `global`, or a composite array.
|
|
2575
|
+
|
|
2576
|
+
## Typed `RateLimited`
|
|
2577
|
+
|
|
2578
|
+
There's no rate-limit code in the handler — the interceptor enforces it. An over-limit call fails `RateLimited` (`{ tag, limit, retryAfterMs, resetAtMs }`), auto-merged into the procedure's wire error union, so the client decodes it typed and can show a "try again in N seconds".
|
|
2579
|
+
|
|
2580
|
+
## Try it
|
|
2581
|
+
|
|
2582
|
+
```bash
|
|
2583
|
+
# Fire notes.create four times for tenant 'acme' — the 4th trips the limit:
|
|
2584
|
+
for i in 1 2 3 4; do
|
|
2585
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2586
|
+
-d "{\"tag\":\"notes.create\",\"input\":{\"tenantId\":\"acme\",\"title\":\"n$i\",\"body\":\"x\"}}"
|
|
2587
|
+
done
|
|
2588
|
+
# → calls 1-3: { ok:true } ; call 4: { ok:false, error:{ _tag:"RateLimited", … } }
|
|
2589
|
+
```
|
|
2590
|
+
|
|
2591
|
+
## Multi-node
|
|
2592
|
+
|
|
2593
|
+
`store: 'memory'` is single-process. Switch to `'postgres'` (state under a row lock, reuses `DB_*`/`PG_*`) or `'redis'` (fastest — one atomic Lua script; reads `CACHE_REDIS_URL` / `REDIS_URL`). All shared stores **fail open** on a backend error — a limiter outage degrades to "no limit", never to "deny everything".
|
|
2594
|
+
|
|
2595
|
+
## Anti-patterns
|
|
2596
|
+
|
|
2597
|
+
- **Rate-limiting reads you didn't mean to.** The `default` applies to queries too. Use `kind: 'mutation'` on a rule (or a tighter default) if you only want to throttle writes.
|
|
2598
|
+
- **Reusing the cache store as the limiter store.** A cache is get/set; a limiter needs an ATOMIC increment. They're separate backends — `store` here is independent of `@voltro/cache`.
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
---
|
|
2603
|
+
|
|
2604
|
+
<!-- source: en/templates/api-rbac.md -->
|
|
2605
|
+
## API · RBAC
|
|
2606
|
+
|
|
2607
|
+
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and an in-handler permission(ctx, 'notes:write') guard that fails typed Forbidden. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
|
|
2608
|
+
|
|
2609
|
+
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `Forbidden`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
|
|
2610
|
+
|
|
2611
|
+
## Scaffold
|
|
2612
|
+
|
|
2613
|
+
```bash
|
|
2614
|
+
voltro create-project acme --api=api-rbac
|
|
2615
|
+
```
|
|
2616
|
+
|
|
2617
|
+
## Roles as config
|
|
2618
|
+
|
|
2619
|
+
```ts
|
|
2620
|
+
// app.config.ts
|
|
2621
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
2622
|
+
|
|
2623
|
+
const roles = {
|
|
2624
|
+
viewer: ['notes:read'],
|
|
2625
|
+
editor: ['notes:read', 'notes:write'],
|
|
2626
|
+
admin: ['admin:full'], // blanket bypass — passes every check
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
rbacPlugin({
|
|
2630
|
+
roles,
|
|
2631
|
+
// PRODUCTION: the default resolver reads subject.metadata.roles (set by your
|
|
2632
|
+
// auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
|
|
2633
|
+
// you can try each via the x-tenant header with zero auth setup:
|
|
2634
|
+
resolveRoles: (subject) =>
|
|
2635
|
+
({ acme: ['admin'], editors: ['editor'], readers: ['viewer'] })[subject.tenantId ?? ''] ?? ['viewer'],
|
|
2636
|
+
})
|
|
2637
|
+
```
|
|
2638
|
+
|
|
2639
|
+
## Guard with `permission()`
|
|
2640
|
+
|
|
2641
|
+
```ts
|
|
2642
|
+
// notes.create.mutation.server.ts
|
|
2643
|
+
import { permission } from '@voltro/plugin-rbac'
|
|
2644
|
+
|
|
2645
|
+
export default (input, ctx) => Effect.gen(function* () {
|
|
2646
|
+
yield* permission(ctx, 'notes:write') // fails typed Forbidden for viewer
|
|
2647
|
+
const store = yield* EffectStore
|
|
2648
|
+
return yield* store.insert('notes', { title: input.title, body: input.body })
|
|
2649
|
+
})
|
|
2650
|
+
```
|
|
2651
|
+
|
|
2652
|
+
Declare `error: Forbidden` (from the browser-safe `@voltro/plugin-rbac/errors`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
|
|
2653
|
+
|
|
2654
|
+
## Try it
|
|
2655
|
+
|
|
2656
|
+
```bash
|
|
2657
|
+
# admin tenant → admin:full → passes:
|
|
2658
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: acme' \
|
|
2659
|
+
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
|
|
2660
|
+
# → { ok:true, … }
|
|
2661
|
+
|
|
2662
|
+
# readers tenant → viewer → notes:write missing → typed Forbidden:
|
|
2663
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: readers' \
|
|
2664
|
+
-d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
|
|
2665
|
+
# → { ok:false, error:{ _tag:"Forbidden", required:"notes:write" } }
|
|
2666
|
+
```
|
|
2667
|
+
|
|
2668
|
+
## On the web side
|
|
2669
|
+
|
|
2670
|
+
```tsx
|
|
2671
|
+
import { useCan } from '@voltro/plugin-rbac/web'
|
|
2672
|
+
const canWrite = useCan('notes:write')
|
|
2673
|
+
{canWrite && <NewNoteButton />} // hide affordances the user can't use
|
|
2674
|
+
```
|
|
2675
|
+
|
|
2676
|
+
`useCan` is a UI affordance only — the server `permission()` guard is the enforcement.
|
|
2677
|
+
|
|
2678
|
+
## Persisted roles
|
|
2679
|
+
|
|
2680
|
+
Set `tables: true` to store assignments in `roles` / `userRoles` tables and resolve them from the DB (multitenant-aware). The shipped template stays config-only.
|
|
2681
|
+
|
|
2682
|
+
## Anti-patterns
|
|
2683
|
+
|
|
2684
|
+
- **Returning scopes from `resolveRoles`.** It returns role SLUGS (`['editor']`); the `roles` map turns those into scopes. Returning scopes directly bypasses the map.
|
|
2685
|
+
- **Scopes ≠ entitlements.** RBAC answers "may you call this"; billing entitlements (see [`api-saas`](/docs/templates/api-saas)) answer "do you have quota left". A procedure can need both.
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
|
|
2689
|
+
---
|
|
2690
|
+
|
|
2691
|
+
<!-- source: en/templates/api-moderation.md -->
|
|
2692
|
+
## API · Moderation
|
|
2693
|
+
|
|
2694
|
+
_Pre-commit content moderation with @voltro/plugin-moderation — checks named input fields BEFORE the handler; a block rule rejects banned content with typed ContentRejected (write never commits), a flag rule writes it but queues it for review. keywordProvider or aiProvider. Memory store, zero infra._
|
|
2695
|
+
|
|
2696
|
+
Stop bad content before it's written. `@voltro/plugin-moderation` checks named input fields on the rpc interceptors, **before** the handler: a `block` rule rejects the call with typed `ContentRejected` (the write never commits); a `flag` rule lets the write through but queues it for review. The provider is pluggable — `keywordProvider` is a zero-dep deny-list, `aiProvider()` an optional LLM classifier. Config-only, zero infra. Template id: **`api-moderation`**.
|
|
2697
|
+
|
|
2698
|
+
## Scaffold
|
|
2699
|
+
|
|
2700
|
+
```bash
|
|
2701
|
+
voltro create-project acme --api=api-moderation
|
|
2702
|
+
```
|
|
2703
|
+
|
|
2704
|
+
## Rules as config
|
|
2705
|
+
|
|
2706
|
+
```ts
|
|
2707
|
+
// app.config.ts
|
|
2708
|
+
import { moderationPlugin, keywordProvider } from '@voltro/plugin-moderation'
|
|
2709
|
+
|
|
2710
|
+
moderationPlugin({
|
|
2711
|
+
provider: keywordProvider(['spam', 'scam', 'phishing', 'malware']),
|
|
2712
|
+
rules: [
|
|
2713
|
+
{ match: 'posts.create', fields: ['title', 'body'], action: 'block' }, // reject
|
|
2714
|
+
{ match: 'comments.create', fields: ['body'], action: 'flag' }, // write + queue
|
|
2715
|
+
],
|
|
2716
|
+
})
|
|
2717
|
+
```
|
|
2718
|
+
|
|
2719
|
+
There's no moderation code in the handlers — `posts.create` and `comments.create` are plain inserts; the interceptor enforces the rules.
|
|
2720
|
+
|
|
2721
|
+
## Block vs flag
|
|
2722
|
+
|
|
2723
|
+
- **block** — banned content fails typed `ContentRejected` (`{ tag, categories, reason }`) before the write. Declare `error: ContentRejected` (from `@voltro/plugin-moderation/errors`) on the procedure.
|
|
2724
|
+
- **flag** — the row IS written, but flagged; the dashboard's **Moderation** panel surfaces the review queue. For rewriting rather than rejecting, the in-handler `moderate(text)` helper returns a verdict you act on (the interceptor can't rewrite input).
|
|
2725
|
+
|
|
2726
|
+
## Try it
|
|
2727
|
+
|
|
2728
|
+
```bash
|
|
2729
|
+
# banned word in a post → blocked, nothing written:
|
|
2730
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2731
|
+
-d '{"tag":"posts.create","input":{"title":"hi","body":"buy cheap spam now"}}'
|
|
2732
|
+
# → { ok:false, error:{ _tag:"ContentRejected", reason:"matched denied term(s): spam" } }
|
|
2733
|
+
|
|
2734
|
+
# clean post → written; banned COMMENT → written but flagged (flag doesn't block):
|
|
2735
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2736
|
+
-d '{"tag":"comments.create","input":{"body":"this is spam"}}'
|
|
2737
|
+
# → { ok:true, … } (queued for review)
|
|
2738
|
+
```
|
|
2739
|
+
|
|
2740
|
+
## Swap in AI
|
|
2741
|
+
|
|
2742
|
+
Replace `keywordProvider([...])` with `aiProvider()` — the rules don't change. `aiProvider` fails OPEN (a classifier outage lets content through rather than blocking everything).
|
|
2743
|
+
|
|
2744
|
+
## Anti-patterns
|
|
2745
|
+
|
|
2746
|
+
- **Putting the keyword list in the client.** Moderation runs server-side on the interceptor — a client check is bypassable. Keep the provider + rules on the api.
|
|
2747
|
+
- **Using `block` where you meant `flag`.** `block` is a hard reject (nothing written); `flag` keeps the content for human review. Pick per surface — comments often `flag`, public posts often `block`.
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
|
|
2751
|
+
---
|
|
2752
|
+
|
|
2753
|
+
<!-- source: en/templates/api-versioning.md -->
|
|
2754
|
+
## API · Versioning
|
|
2755
|
+
|
|
2756
|
+
_Full row history + time-travel with @voltro/plugin-versioning — snapshots every insert/update/delete on the listed tables via the post-commit change tap; read every version with rowHistory() and the value at a past instant with rowAsOf(). audit() is who/when; this is what-changed-to-what. Memory store, zero infra._
|
|
2757
|
+
|
|
2758
|
+
Every version of a row, queryable. `@voltro/plugin-versioning` rides the post-commit ChangeEvent tap and records a FULL snapshot of every insert/update/delete on the listed tables. Read it back with `rowHistory(store, table, id)` (every version) and `rowAsOf(store, table, id, when)` (the value at a past instant — time-travel). `audit()` records WHO/WHEN; this records WHAT it changed to, so you can diff or restore. Memory store, zero infra. Template id: **`api-versioning`**.
|
|
2759
|
+
|
|
2760
|
+
## Scaffold
|
|
2761
|
+
|
|
2762
|
+
```bash
|
|
2763
|
+
voltro create-project acme --api=api-versioning
|
|
2764
|
+
```
|
|
2765
|
+
|
|
2766
|
+
## Wire it + read it back
|
|
2767
|
+
|
|
2768
|
+
```ts
|
|
2769
|
+
// app.config.ts
|
|
2770
|
+
import { versioningPlugin } from '@voltro/plugin-versioning'
|
|
2771
|
+
export default { type: 'api', name: 'AcmeVer', store: 'memory',
|
|
2772
|
+
plugins: [versioningPlugin({ tables: ['documents'] })] }
|
|
2773
|
+
```
|
|
2774
|
+
|
|
2775
|
+
The snapshotting is automatic. The point is to **read history through handlers** — the template ships two actions:
|
|
2776
|
+
|
|
2777
|
+
```ts
|
|
2778
|
+
// documents.history.action.server.ts
|
|
2779
|
+
import { rowHistory } from '@voltro/plugin-versioning'
|
|
2780
|
+
export default async ({ id }, ctx) => {
|
|
2781
|
+
const rows = await rowHistory(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null)
|
|
2782
|
+
return rows.map((r) => ({ version: r.version, op: r.op, content: r.data?.content ?? null, changedAt: r.changedAt }))
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// documents.asOf.action.server.ts
|
|
2786
|
+
import { rowAsOf } from '@voltro/plugin-versioning'
|
|
2787
|
+
export default async ({ id, at }, ctx) =>
|
|
2788
|
+
rowAsOf(ctx.store, 'documents', id, ctx.request.subject.tenantId ?? null, at) // the row as it was at `at` (epoch ms), or null
|
|
2789
|
+
```
|
|
2790
|
+
|
|
2791
|
+
## Try it
|
|
2792
|
+
|
|
2793
|
+
```bash
|
|
2794
|
+
ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2795
|
+
-d '{"tag":"documents.create","input":{"title":"Spec","content":"draft v1"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
|
|
2796
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2797
|
+
-d "{\"tag\":\"documents.update\",\"input\":{\"id\":\"$ID\",\"content\":\"final v2\"}}" >/dev/null
|
|
2798
|
+
|
|
2799
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2800
|
+
-d "{\"tag\":\"documents.history\",\"input\":{\"id\":\"$ID\"}}"
|
|
2801
|
+
# → [ { version:1, op:"insert", content:"draft v1", … }, { version:2, op:"update", content:"final v2", … } ]
|
|
2802
|
+
# documents.asOf with v1's changedAt → { content:"draft v1" } (time-travel)
|
|
2803
|
+
```
|
|
2804
|
+
|
|
2805
|
+
## audit vs versioning
|
|
2806
|
+
|
|
2807
|
+
| You want… | Use |
|
|
2808
|
+
|---|---|
|
|
2809
|
+
| WHO changed a row + WHEN | `audit()` mixin |
|
|
2810
|
+
| WHAT it changed to (every prior value) | `@voltro/plugin-versioning` (this) |
|
|
2811
|
+
|
|
2812
|
+
They compose — `audit()` for accountability, versioning for diff/restore.
|
|
2813
|
+
|
|
2814
|
+
## Production
|
|
2815
|
+
|
|
2816
|
+
The memory history store is single-process. With a SQL store, history persists in `_voltro_row_history`. High-churn tables grow history fast — version what you need to diff/restore, not everything.
|
|
2817
|
+
|
|
2818
|
+
## Anti-patterns
|
|
2819
|
+
|
|
2820
|
+
- **Listing `versioningPlugin({ tables })` and stopping there.** That records history but never shows it. The value is in the READS — wire `rowHistory` / `rowAsOf` into a handler (or the dashboard) like this template does.
|
|
2821
|
+
- **Versioning every table.** Snapshots cost storage proportional to write volume. Pick the tables whose past values you actually need.
|
|
2822
|
+
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
---
|
|
2826
|
+
|
|
2827
|
+
<!-- source: en/templates/api-backend-deactivation.md -->
|
|
2828
|
+
## API · Deactivation
|
|
2829
|
+
|
|
2830
|
+
_The deactivation() schema mixin — adds deactivatedAt/deactivatedBy so a user can be locked out (your auth refuses a deactivated subject) while its row stays VISIBLE and queryable. The deliberate opposite of softDelete(), which hides + anonymises. Pure schema, zero infra._
|
|
2831
|
+
|
|
2832
|
+
Lock a user out without erasing them. The `deactivation()` schema mixin adds `deactivatedAt` + `deactivatedBy` columns. A deactivated subject can't authenticate, but its row stays **visible and queryable** — no read-scoping, no delete interception. That's the deliberate opposite of `softDelete()`, which hides + anonymises. It's a pure schema mixin (no `plugins[]` entry). Template id: **`api-backend-deactivation`**.
|
|
2833
|
+
|
|
2834
|
+
## Scaffold
|
|
2835
|
+
|
|
2836
|
+
```bash
|
|
2837
|
+
voltro create-project acme --api=api-backend-deactivation
|
|
2838
|
+
```
|
|
2839
|
+
|
|
2840
|
+
## The mixin
|
|
2841
|
+
|
|
2842
|
+
```ts
|
|
2843
|
+
// database/schema.ts
|
|
2844
|
+
import { deactivation } from '@voltro/plugin-deactivation'
|
|
2845
|
+
|
|
2846
|
+
export const users = table('users', {
|
|
2847
|
+
id: id(), email: text().unique(), name: text(),
|
|
2848
|
+
})
|
|
2849
|
+
// adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
|
|
2850
|
+
// NO defaultWhere — a deactivated user still shows up in queries.
|
|
2851
|
+
.with(deactivation())
|
|
2852
|
+
.reactive()
|
|
2853
|
+
```
|
|
2854
|
+
|
|
2855
|
+
Deactivate with a normal update — `deactivatedAt` is just a column:
|
|
2856
|
+
|
|
2857
|
+
```ts
|
|
2858
|
+
// users.deactivate.mutation.server.ts
|
|
2859
|
+
export default async ({ id }, ctx) =>
|
|
2860
|
+
ctx.store.update('users', id, { deactivatedAt: new Date() })
|
|
2861
|
+
```
|
|
2862
|
+
|
|
2863
|
+
## Visible-but-locked-out
|
|
2864
|
+
|
|
2865
|
+
The point is what happens on a READ. After deactivation the row is STILL there and readable, just stamped:
|
|
2866
|
+
|
|
2867
|
+
```bash
|
|
2868
|
+
ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2869
|
+
-d '{"tag":"users.create","input":{"email":"ada@acme.com","name":"Ada"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
|
|
2870
|
+
|
|
2871
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2872
|
+
-d "{\"tag\":\"users.deactivate\",\"input\":{\"id\":\"$ID\"}}" >/dev/null
|
|
2873
|
+
|
|
2874
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2875
|
+
-d "{\"tag\":\"users.get\",\"input\":{\"id\":\"$ID\"}}"
|
|
2876
|
+
# → { ok:true, result:{ …, deactivatedAt:"2026-…" } } (NOT null → still visible)
|
|
2877
|
+
```
|
|
2878
|
+
|
|
2879
|
+
With `softDelete()` instead, that `users.get` would return `null` — the row would be hidden.
|
|
2880
|
+
|
|
2881
|
+
## deactivation vs softDelete
|
|
2882
|
+
|
|
2883
|
+
| You want… | Use |
|
|
2884
|
+
|---|---|
|
|
2885
|
+
| Hide + anonymise a row (GDPR, "delete my account") | `softDelete()` |
|
|
2886
|
+
| Keep the row visible but mark the subject as locked out | `deactivation()` (this) |
|
|
2887
|
+
|
|
2888
|
+
Compose the full lifecycle: `users.with(audit(), softDelete(), deactivation())`.
|
|
2889
|
+
|
|
2890
|
+
## Anti-patterns
|
|
2891
|
+
|
|
2892
|
+
- **Expecting deactivation to hide the row.** It doesn't — that's `softDelete()`. If a deactivated user must disappear from a list, filter on `deactivatedAt IS NULL` in that query yourself.
|
|
2893
|
+
- **Forgetting to enforce it in auth.** The mixin only stamps the column; YOUR auth resolver must refuse a subject whose `deactivatedAt` is set. The data layer keeps the row queryable on purpose.
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
|
|
2897
|
+
---
|
|
2898
|
+
|
|
2899
|
+
<!-- source: en/templates/api-governance.md -->
|
|
2900
|
+
## API · Governance
|
|
2901
|
+
|
|
2902
|
+
_Data governance in one plugin with @voltro/plugin-governance — AES-256-GCM field encryption for .encrypted() columns (handler sees plaintext, ciphertext at rest), admin-gated GDPR governance.export/erase over declared subjectScopes, a consent ledger, and retention TTL sweeps. Memory store, zero infra._
|
|
2903
|
+
|
|
2904
|
+
Four compliance primitives, one plugin. `@voltro/plugin-governance` gives you **field encryption** (AES-256-GCM for `.encrypted()` columns — handlers see plaintext, the column holds `enc:v1:…` at rest), **GDPR** export/erase (admin-gated, walking declared subject scopes), a **consent ledger**, and **retention** sweeps (delete/anonymise rows past a TTL). The template boots zero-infra with a dev encryption key. Template id: **`api-governance`**.
|
|
2905
|
+
|
|
2906
|
+
## Scaffold
|
|
2907
|
+
|
|
2908
|
+
```bash
|
|
2909
|
+
voltro create-project acme --api=api-governance
|
|
2910
|
+
```
|
|
2911
|
+
|
|
2912
|
+
The shipped `.env` carries a DEV `VOLTRO_FIELD_ENCRYPTION_KEY`. Generate your own (`openssl rand -hex 32`) and keep it in a secret store for production.
|
|
2913
|
+
|
|
2914
|
+
## Field encryption
|
|
2915
|
+
|
|
2916
|
+
```ts
|
|
2917
|
+
// database/schema.ts
|
|
2918
|
+
export const profiles = table('profiles', {
|
|
2919
|
+
id: id(), name: text(), email: text(),
|
|
2920
|
+
ssn: text().encrypted(), // AES-256-GCM at rest
|
|
2921
|
+
}).with(audit())
|
|
2922
|
+
|
|
2923
|
+
// app.config.ts
|
|
2924
|
+
governancePlugin({ fieldEncryption: true /* reads VOLTRO_FIELD_ENCRYPTION_KEY */ })
|
|
2925
|
+
```
|
|
2926
|
+
|
|
2927
|
+
You pass plaintext; the store middleware encrypts on write and decrypts on read — handlers never touch the ciphertext. Boot **fails loud** if an `.encrypted()` column exists but no cipher is registered.
|
|
2928
|
+
|
|
2929
|
+
```bash
|
|
2930
|
+
ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2931
|
+
-d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
|
|
2932
|
+
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
|
|
2933
|
+
-d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
|
|
2934
|
+
# → { ok:true, result:{ …, ssn:"123-45-6789" } } (plaintext — the column stored enc:v1:…)
|
|
2935
|
+
```
|
|
2936
|
+
|
|
2937
|
+
## GDPR, consent, retention
|
|
2938
|
+
|
|
2939
|
+
```ts
|
|
2940
|
+
governancePlugin({
|
|
2941
|
+
fieldEncryption: true,
|
|
2942
|
+
subjectScopes: [{ table: 'profiles', subjectField: 'id' }], // GDPR walks these
|
|
2943
|
+
retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
|
|
2944
|
+
})
|
|
2945
|
+
```
|
|
2946
|
+
|
|
2947
|
+
- **GDPR** — `governance.export` / `governance.erase` are **admin-gated** (call as a subject with `admin:full` — see [`api-rbac`](/docs/templates/api-rbac)); they walk `subjectScopes` to bundle or erase everything belonging to a subject. `GovernanceService` exposes the same in-handler.
|
|
2948
|
+
- **Consent** — `governance.consent` (a mutation) records a grant; `governance.hasConsent` (a reactive query) checks it — subscribe to it on the web, or call `GovernanceService.hasConsent(...)` in a handler.
|
|
2949
|
+
- **Retention** — the sweep deletes (or `anonymize`s) rows whose age past `dateField` (default `createdAt`) exceeds `ttlMs`.
|
|
2950
|
+
|
|
2951
|
+
## Rules
|
|
2952
|
+
|
|
2953
|
+
- **Don't encrypt what you filter on.** An `.encrypted()` column is ciphertext on disk — no `WHERE` / `ORDER BY` in SQL. Encrypt fields you read back WHOLE (PII, tokens, notes).
|
|
2954
|
+
- **The key is everything.** GCM fails closed on a bad key — you get an error, never silent corruption. Back the key up; rotating it means re-encrypting.
|
|
2955
|
+
- **GDPR endpoints are admin-only by design.** Don't remove the `admin:full` gate — an unauthenticated export/erase is a data-exfiltration / griefing hole.
|
|
2956
|
+
|
|
2957
|
+
|
|
2958
|
+
|
|
2959
|
+
---
|
|
2960
|
+
|
|
2961
|
+
<!-- source: en/templates/api-kv.md -->
|
|
2962
|
+
## API · Key-value
|
|
2963
|
+
|
|
2964
|
+
_Durable ctx.kv on an external-event-sync domain — a sync cursor you can't recompute plus TTL-bounded idempotency markers, contrasted with ctx.store (the rows) and ctx.cache. Zero-infra boot._
|
|
2965
|
+
|
|
2966
|
+
The template that exercises **`ctx.kv`** — the framework's storage primitive for state you **can't recompute**. Instead of another CRUD reference, it ships a cohesive **external-event-sync** domain: a durable **cursor** (a watermark past everything already pulled) and TTL-bounded **idempotency markers** that dedupe redelivered events. Around them sit the synced rows in `ctx.store`, so the template makes the store-vs-kv-vs-cache distinction concrete. It boots with **zero infrastructure** (`store: 'memory'`). Template id: **`api-kv`**.
|
|
2967
|
+
|
|
2968
|
+
## Scaffold
|
|
2969
|
+
|
|
2970
|
+
```bash
|
|
2971
|
+
voltro create-project acme --api=api-kv
|
|
2972
|
+
```
|
|
2973
|
+
|
|
2974
|
+
No env or services required. On `store: 'memory'`, `ctx.kv` runs on its `database` backend in-process — durable within the run, reset on restart. Switch `app.config.ts` to `store: 'postgres'` and the cursor + markers survive restarts and are shared across replicas.
|
|
2975
|
+
|
|
2976
|
+
## The one idea
|
|
2977
|
+
|
|
2978
|
+
Three storage primitives, three contracts — pick by what a loss costs you:
|
|
2979
|
+
|
|
2980
|
+
| Primitive | Holds | If you lose it | Default backend |
|
|
2981
|
+
|---|---|---|---|
|
|
2982
|
+
| `ctx.cache` | recomputable derived views | free — recompute | memory (evicts for capacity) |
|
|
2983
|
+
| `ctx.kv` | the cursor + idempotency markers | re-process / double-process | database (durable, never evicted) |
|
|
2984
|
+
| `ctx.store` | the synced rows | re-fetchable from the source | your SQL store |
|
|
2985
|
+
|
|
2986
|
+
A cursor is the textbook `ctx.kv` case: it isn't derived from anything you still hold, so a cache (which evicts) would silently reset your sync, and a hand-managed table row is overkill. See [durable key-value](/docs/caching/key-value).
|
|
2987
|
+
|
|
2988
|
+
## What ships
|
|
2989
|
+
|
|
2990
|
+
```text
|
|
2991
|
+
apps/acme/api/ # dir named by the app, not the template
|
|
2992
|
+
├── app.config.ts # type:api, store:'memory', defineEnv
|
|
2993
|
+
├── package.json
|
|
2994
|
+
├── tsconfig.json
|
|
2995
|
+
├── README.md
|
|
2996
|
+
├── database/
|
|
2997
|
+
│ └── schema.ts # actors + tenants (core) + a synced_events table
|
|
2998
|
+
├── actions/
|
|
2999
|
+
│ ├── sync.pull.action.ts # descriptor — pull a page of events
|
|
3000
|
+
│ ├── sync.pull.action.server.ts # executor — getOrElse/has/set(ttl)/set
|
|
3001
|
+
│ ├── sync.status.action.ts # descriptor — read-only view
|
|
3002
|
+
│ ├── sync.status.action.server.ts # executor (Effect) — yield* Kv
|
|
3003
|
+
│ ├── sync.reset.action.ts # descriptor — rewind the sync
|
|
3004
|
+
│ └── sync.reset.action.server.ts # executor — delete + list/delete
|
|
3005
|
+
└── queries/
|
|
3006
|
+
├── events.list.query.ts # descriptor — reactive rows (store, for contrast)
|
|
3007
|
+
└── events.list.query.server.ts # executor — table descriptor
|
|
3008
|
+
```
|
|
3009
|
+
|
|
3010
|
+
Every primitive is **auto-discovered by file convention** — nothing is registered in `app.config.ts`. The durable KV lives in the framework's `_voltro_kv` table (created automatically); no schema of yours models the cursor.
|
|
3011
|
+
|
|
3012
|
+
## Database — the rows, deliberately NOT the cursor
|
|
3013
|
+
|
|
3014
|
+
The schema declares the two framework core tables plus a `synced_events` table for the ingested rows. The cursor and markers are **not** modelled here — they're durable KV.
|
|
3015
|
+
|
|
3016
|
+
```ts
|
|
3017
|
+
// database/schema.ts
|
|
3018
|
+
import {
|
|
3019
|
+
databaseHandle, id, integer, table, text, timestamp, type InferRow,
|
|
3020
|
+
} from '@voltro/database'
|
|
3021
|
+
import { tenant } from '@voltro/plugin-multitenancy'
|
|
3022
|
+
|
|
3023
|
+
// Core tables — required by the tenant / audit mixins.
|
|
3024
|
+
export const actors = table('actors', {
|
|
3025
|
+
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
|
|
3026
|
+
displayName: text().nullable(), createdAt: timestamp().default('now'),
|
|
3027
|
+
})
|
|
3028
|
+
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
|
|
3029
|
+
|
|
3030
|
+
// The events we ingest from the source — re-fetchable, so a table is their home.
|
|
3031
|
+
export const syncedEvents = table('synced_events', {
|
|
3032
|
+
id: id({ prefix: 'evt' }),
|
|
3033
|
+
externalId: text(),
|
|
3034
|
+
kind: text(),
|
|
3035
|
+
payload: text(),
|
|
3036
|
+
sequence: integer(),
|
|
3037
|
+
})
|
|
3038
|
+
.with(tenant()) // tenantId + audit columns, auto-stamped from the subject
|
|
3039
|
+
.reactive() // events.list subscription wakes on every insert
|
|
3040
|
+
|
|
3041
|
+
export type SyncedEvent = InferRow<typeof syncedEvents>
|
|
3042
|
+
export const database = databaseHandle({ actors, tenants, syncedEvents })
|
|
3043
|
+
```
|
|
3044
|
+
|
|
3045
|
+
## Pull — the durable cursor + idempotency markers
|
|
3046
|
+
|
|
3047
|
+
`sync.pull` is the heart of the template. The descriptor declares the schema; the executor reads the durable cursor, pulls a page, dedupes each event against a TTL marker, inserts the new rows, and advances the cursor.
|
|
3048
|
+
|
|
3049
|
+
```ts
|
|
3050
|
+
// actions/sync.pull.action.ts — descriptor (browser-safe)
|
|
3051
|
+
import { defineAction } from '@voltro/protocol'
|
|
3052
|
+
import { Schema } from 'effect'
|
|
3053
|
+
|
|
3054
|
+
export const syncPull = defineAction({
|
|
3055
|
+
name: 'sync.pull',
|
|
3056
|
+
input: Schema.Struct({
|
|
3057
|
+
limit: Schema.optional(Schema.Int.pipe(Schema.greaterThan(0))),
|
|
3058
|
+
}),
|
|
3059
|
+
output: Schema.Struct({
|
|
3060
|
+
pulled: Schema.Int, // newly ingested (marker written)
|
|
3061
|
+
skipped: Schema.Int, // already seen within the idempotency window
|
|
3062
|
+
cursor: Schema.Int, // the durable watermark after this call
|
|
3063
|
+
}),
|
|
3064
|
+
})
|
|
3065
|
+
```
|
|
3066
|
+
|
|
3067
|
+
```ts
|
|
3068
|
+
// actions/sync.pull.action.server.ts — executor (default export)
|
|
3069
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3070
|
+
|
|
3071
|
+
// Stand-in for a real upstream — deterministic + pure so it boots with no
|
|
3072
|
+
// network. A real handler would `fetch()` here.
|
|
3073
|
+
const fetchSince = (sequence: number, limit: number) =>
|
|
3074
|
+
Array.from({ length: limit }, (_unused, i) => {
|
|
3075
|
+
const seq = sequence + i + 1
|
|
3076
|
+
return {
|
|
3077
|
+
externalId: `ext-${seq}`,
|
|
3078
|
+
kind: seq % 3 === 0 ? 'updated' : 'created',
|
|
3079
|
+
payload: JSON.stringify({ seq }),
|
|
3080
|
+
sequence: seq,
|
|
3081
|
+
}
|
|
3082
|
+
})
|
|
3083
|
+
|
|
3084
|
+
const MARKER_TTL_MS = 24 * 60 * 60_000 // 24h idempotency window
|
|
3085
|
+
|
|
3086
|
+
const execute = async (input: { limit?: number }, ctx: AppContext) => {
|
|
3087
|
+
// KV keys are app-global — fold the tenant in so two tenants never collide.
|
|
3088
|
+
const tenantId = ctx.request.subject.tenantId ?? 'anon'
|
|
3089
|
+
const cursorKey = `sync:${tenantId}:cursor`
|
|
3090
|
+
const limit = Math.min(input.limit ?? 5, 50)
|
|
3091
|
+
|
|
3092
|
+
// Durable cursor — getOrElse writes the default (0) ONLY on a first-run miss.
|
|
3093
|
+
const cursor = await ctx.kv.getOrElse<number>(cursorKey, () => 0)
|
|
3094
|
+
|
|
3095
|
+
let pulled = 0
|
|
3096
|
+
let skipped = 0
|
|
3097
|
+
let highest = cursor
|
|
3098
|
+
|
|
3099
|
+
for (const evt of fetchSince(cursor, limit)) {
|
|
3100
|
+
const markerKey = `sync:${tenantId}:seen:${evt.externalId}`
|
|
3101
|
+
if (await ctx.kv.has(markerKey)) {
|
|
3102
|
+
skipped++ // the source redelivered it within the TTL window
|
|
3103
|
+
} else {
|
|
3104
|
+
await ctx.store.insert('synced_events', {
|
|
3105
|
+
externalId: evt.externalId, kind: evt.kind,
|
|
3106
|
+
payload: evt.payload, sequence: evt.sequence, tenantId,
|
|
3107
|
+
})
|
|
3108
|
+
await ctx.kv.set(markerKey, evt.sequence, { ttlMs: MARKER_TTL_MS })
|
|
3109
|
+
pulled++
|
|
3110
|
+
}
|
|
3111
|
+
highest = Math.max(highest, evt.sequence)
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
await ctx.kv.set(cursorKey, highest) // advance the durable watermark
|
|
3115
|
+
return { pulled, skipped, cursor: highest }
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
export default execute
|
|
3119
|
+
```
|
|
3120
|
+
|
|
3121
|
+
Four KV operations carry the whole flow: **`getOrElse`** reads the cursor (writing the `0` default only on a genuine first-run miss), **`has`** checks a marker without deserializing, **`set(…, { ttlMs })`** writes a bounded idempotency marker, and **`set`** advances the cursor. The rows go to `ctx.store` because they're re-fetchable; the cursor + markers go to `ctx.kv` because they're not.
|
|
3122
|
+
|
|
3123
|
+
## Status & reset — two handler surfaces
|
|
3124
|
+
|
|
3125
|
+
Durable KV is reachable two ways, both wired in every runtime (`voltro dev` AND
|
|
3126
|
+
`voltro serve`): the async **`ctx.kv`** facade (`sync.pull` / `sync.reset`) and
|
|
3127
|
+
the Effect-native **`Kv`** service via `yield* Kv` (`sync.status`). They share
|
|
3128
|
+
one resolved store — pick by whether your handler is `async` or an `Effect`.
|
|
3129
|
+
|
|
3130
|
+
```ts
|
|
3131
|
+
// actions/sync.status.action.server.ts — executor (Effect mode)
|
|
3132
|
+
import { Effect } from 'effect'
|
|
3133
|
+
import { Kv } from '@voltro/kv'
|
|
3134
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3135
|
+
|
|
3136
|
+
const execute = (_input: Record<string, never>, ctx: AppContext) =>
|
|
3137
|
+
Effect.gen(function* () {
|
|
3138
|
+
const kv = yield* Kv // the framework provides Kv to every handler runtime
|
|
3139
|
+
const tenantId = ctx.request.subject.tenantId ?? 'anon'
|
|
3140
|
+
const cursor = yield* kv.getOrElse(`sync:${tenantId}:cursor`, () => 0)
|
|
3141
|
+
// list(prefix) → every live (non-expired) marker under it.
|
|
3142
|
+
const markers = yield* kv.list(`sync:${tenantId}:seen:`)
|
|
3143
|
+
return { cursor, activeMarkers: markers.length }
|
|
3144
|
+
})
|
|
3145
|
+
|
|
3146
|
+
export default execute
|
|
3147
|
+
```
|
|
3148
|
+
|
|
3149
|
+
```ts
|
|
3150
|
+
// actions/sync.reset.action.server.ts — executor
|
|
3151
|
+
import type { AppContext } from '@voltro/runtime'
|
|
3152
|
+
|
|
3153
|
+
const execute = async (input: { markers?: boolean }, ctx: AppContext) => {
|
|
3154
|
+
const tenantId = ctx.request.subject.tenantId ?? 'anon'
|
|
3155
|
+
|
|
3156
|
+
// delete(key) → boolean (did it exist?).
|
|
3157
|
+
const cursorCleared = await ctx.kv.delete(`sync:${tenantId}:cursor`)
|
|
3158
|
+
|
|
3159
|
+
let markersCleared = 0
|
|
3160
|
+
if (input.markers) {
|
|
3161
|
+
// list(prefix) + delete() to sweep a set. `ctx.kv.clear()` would drop the
|
|
3162
|
+
// app's ENTIRE KV namespace — too broad for one tenant's sync keys.
|
|
3163
|
+
for (const key of await ctx.kv.list(`sync:${tenantId}:seen:`)) {
|
|
3164
|
+
if (await ctx.kv.delete(key)) markersCleared++
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
return { cursorCleared, markersCleared }
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
export default execute
|
|
3172
|
+
```
|
|
3173
|
+
|
|
3174
|
+
Resetting **only the cursor** leaves the markers alive — so a following `sync.pull` re-reads from the start but **skips everything**, proving the two KV concerns are independent. That is the template's punchline.
|
|
3175
|
+
|
|
3176
|
+
## Try it
|
|
3177
|
+
|
|
3178
|
+
Over the rpc surface (a `voltro dev` web client, `POST /rpc`, or the inspect `invoke` endpoint):
|
|
3179
|
+
|
|
3180
|
+
```jsonc
|
|
3181
|
+
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 5 }
|
|
3182
|
+
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 10 }
|
|
3183
|
+
{ "tag": "sync.status", "input": {} } // → { cursor: 10, activeMarkers: 10 }
|
|
3184
|
+
{ "tag": "sync.reset", "input": {} } // → { cursorCleared: true, markersCleared: 0 }
|
|
3185
|
+
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 0, skipped: 5, cursor: 5 } ← markers survived
|
|
3186
|
+
```
|
|
3187
|
+
|
|
3188
|
+
The synced rows stream in live via the `events.list` subscription.
|
|
3189
|
+
|
|
3190
|
+
## TTL & tenant namespacing
|
|
3191
|
+
|
|
3192
|
+
- **TTL** — markers are written with `{ ttlMs }` (a 24h idempotency window). Expiry is lazy: an expired marker reads as a miss and is dropped on the next `has`/`get`. The cursor has **no** TTL — permanent until deleted.
|
|
3193
|
+
- **Tenant namespacing** — `ctx.kv` keys are app-global; the facade never namespaces for you. Every key folds in `ctx.request.subject.tenantId` (`sync:${tenantId}:…`) so two tenants' cursors/markers never collide.
|
|
3194
|
+
|
|
3195
|
+
## When to use api-kv vs. the variants
|
|
3196
|
+
|
|
3197
|
+
| You need… | Pick |
|
|
3198
|
+
|---|---|
|
|
3199
|
+
| The smallest CRUD reference to extend | [`api-backend`](/docs/templates/api-backend) |
|
|
3200
|
+
| Durable state you can't recompute (cursors, progress, idempotency) | `api-kv` |
|
|
3201
|
+
| Workflows / triggers / schedules exercised end-to-end | [`api-durable`](/docs/templates/api-durable) |
|
|
3202
|
+
| The advanced schema DSL + query caching | [`api-data-advanced`](/docs/templates/api-data-advanced) |
|
|
3203
|
+
|
|
3204
|
+
## Anti-patterns
|
|
3205
|
+
|
|
3206
|
+
- **Modelling a cursor as a table row.** A watermark you hand-`UPDATE` is exactly what `ctx.kv` replaces — one durable key, no schema, no migration. Reach for a table only when the data is relational and queried.
|
|
3207
|
+
- **Caching a cursor.** `ctx.cache` evicts for capacity; a cache miss on your sync watermark silently rewinds the sync (or double-processes). Durable state that a miss corrupts belongs in `ctx.kv`, never the cache.
|
|
3208
|
+
- **Forgetting the tenant in the key.** `ctx.kv` keys are app-global. A bare `sync:cursor` collides across tenants — always fold `subject.tenantId` (or the actor) into the key.
|
|
3209
|
+
- **Storing large or unbounded values in KV.** `ctx.kv` is for small control state — cursors, flags, markers — not blobs or ever-growing lists. It's durable with no eviction, so bulk data never gets reclaimed; model relational/bulk data as a table instead.
|
|
3210
|
+
|
|
3211
|
+
## Pairs well with
|
|
3212
|
+
|
|
3213
|
+
- [`store: 'postgres'`](/docs/database/dialects) — the cursor + markers survive restarts and are shared across replicas.
|
|
3214
|
+
- [Key-value backends](/docs/caching/kv-backends) — put just the KV on a persistent Redis with `KV_BACKEND=redis`, no handler change.
|