@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,1273 @@
|
|
|
1
|
+
# Plugins
|
|
2
|
+
|
|
3
|
+
> How Voltro plugins compose into the runtime, what they can intercept, the catalogue, and writing your own.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/overview.md -->
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
_How Voltro plugins compose into the runtime, what they can intercept, the catalogue, and writing your own._
|
|
13
|
+
|
|
14
|
+
A Voltro plugin is a server-side extension that hooks into the runtime. Plugins can intercept mutations / queries / actions, contribute schema mixins + tables + migrations, mount raw-HTTP and inspect routes, contribute an Effect service layer, and run install / activate / deactivate lifecycles.
|
|
15
|
+
|
|
16
|
+
The framework ships some plugins; you write your own; the contract is small enough to learn in a single read.
|
|
17
|
+
|
|
18
|
+
## What's in this section
|
|
19
|
+
|
|
20
|
+
- [The plugin contract](/docs/plugins/contract) — `definePlugin`, lifecycle hooks, rpc interceptors, framework-version compatibility
|
|
21
|
+
- [plugin-audit](/docs/plugins/audit) — mutation audit log + `audit()` mixin
|
|
22
|
+
- [plugin-auth](/docs/plugins/auth) — full auth suite: password (rehash-on-verify) + sessions (multi-key rotation + sliding-window) + magic-link/reset + passkeys (atomic clone detection, BYO multi-replica challenge store) + CSRF + session revocation + memberships/switch-tenant + TOTP/MFA (sign-in enforcement + recovery codes), mounted by `authRoutesPlugin()` (see also the [Authentication section](/docs/authentication/overview))
|
|
23
|
+
- [plugin-multitenancy](/docs/plugins/multitenancy) — `tenant()` schema mixin + `assertOwnTenant` write-guard + `TenantMismatch`
|
|
24
|
+
- [plugin-soft-delete](/docs/plugins/soft-delete) — `softDelete()` schema mixin (hide on delete, `hardDelete()` bypass)
|
|
25
|
+
- [plugin-rbac](/docs/plugins/rbac) — roles + permissions + the `permission()` guard
|
|
26
|
+
- [plugin-ratelimit](/docs/plugins/ratelimit) — per-endpoint / per-subject / per-tenant request limits
|
|
27
|
+
- [plugin-billing](/docs/plugins/billing) — subscriptions, plans, entitlements + usage metering (Stripe + mock provider); seats, mid-cycle proration + dunning; money as integer minor units
|
|
28
|
+
- [plugin-licensing](/docs/plugins/licensing) — offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version
|
|
29
|
+
- [plugin-mail](/docs/plugins/mail) — transactional email (Resend / Postmark / SendGrid / SES / Mailgun / SMTP, templates, suppression, scheduling, batch, idempotency)
|
|
30
|
+
- [plugin-storage](/docs/plugins/storage) — file storage: public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem
|
|
31
|
+
- [plugin-ai-flows](/docs/plugins/ai-flows) — durable multi-step AI pipelines (deterministic + agentic) with human-in-the-loop, chaining, and cadence; code-first `defineFlow` or data-driven rows
|
|
32
|
+
- [plugin-postgis](/docs/plugins/postgis) — postgres-native `geography` / `geometry` columns + spatial operators
|
|
33
|
+
- [plugin-broadcast](/docs/plugins/broadcast) — cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects
|
|
34
|
+
- [plugin-webhooks](/docs/plugins/webhooks) — durable incoming + outgoing webhooks (HMAC signing, retries, idempotency)
|
|
35
|
+
- [plugin-atlassian](/docs/plugins/atlassian) — `JiraService` + `ConfluenceService` over the Atlassian APIs
|
|
36
|
+
- [plugin-deactivation](/docs/plugins/deactivation) — `deactivation()` schema mixin (visible, can't log in)
|
|
37
|
+
- [plugin-prometheus](/docs/plugins/prometheus) — Prometheus exporter at `GET /metrics`; scrapes the unified Metrics-API (the same source the dashboard Metrics panel reads)
|
|
38
|
+
- [plugin-datadog](/docs/plugins/datadog) — deep Datadog integration; agentless metrics + opt-in logs + traces (OTLP→Agent) + profiler, trace-correlated
|
|
39
|
+
- [plugin-sentry](/docs/plugins/sentry) — deep Sentry integration; trace-correlated errors + breadcrumbs from the log sink + opt-in performance traces
|
|
40
|
+
- [plugin-flags](/docs/plugins/flags) — feature flags: per-subject / per-tenant targeting, deterministic % rollouts, kill-switch, declarative rpc gating + client UI gating
|
|
41
|
+
- [plugin-notifications](/docs/plugins/notifications) — unified notifications: one send API across email / Slack / SMS / push (first-class APNs/FCM factory) / in-app, channel preferences + in-app inbox, digest/batching, quiet hours, broadcast/topics, durable DataStore-backed store by default
|
|
42
|
+
- [plugin-logship](/docs/plugins/logship) — ship structured logs to Better Stack / Axiom / Loki / any HTTP sink; batched, redacted, fail-soft
|
|
43
|
+
- [plugin-moderation](/docs/plugins/moderation) — moderate user content before commit: keyword or AI provider, block / flag via interceptor + in-handler redact
|
|
44
|
+
- [plugin-search](/docs/plugins/search) — keep an external index (Typesense / Meilisearch / Algolia) in sync via the ChangeEvent tap; tenant-scoped `search.query` + hook
|
|
45
|
+
- [plugin-cdc-out](/docs/plugins/cdc-out) — declarative reverse-ETL: mirror table changes outward to a webhook / Kafka / Snowflake / BigQuery sink through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered
|
|
46
|
+
- [plugin-governance](/docs/plugins/governance) — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
|
|
47
|
+
- [plugin-openapi](/docs/plugins/openapi) — OpenAPI 3.1 spec + Swagger-UI docs generated from your `defineRestRoute` descriptors and (opt-in) rpc procedures
|
|
48
|
+
- [plugin-versioning](/docs/plugins/versioning) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
|
|
49
|
+
- [plugin-presence](/docs/plugins/presence) — ephemeral realtime presence: heartbeat roster per channel + `usePresence` / `useTyping` hooks, cross-instance
|
|
50
|
+
- [plugin-scim](/docs/plugins/scim) — SCIM 2.0 provisioning (Users + Groups at `/scim/v2`) so an enterprise IdP can create/deactivate users
|
|
51
|
+
- [plugin-sso-saml](/docs/plugins/sso-saml) — enterprise SAML 2.0 SSO: SP-initiated login + Single Logout, ACS, metadata (+ IdP-metadata-URL auto cert rotation, encrypted assertions, SP request signing); mints a framework session
|
|
52
|
+
- [API keys](/docs/configuration/api-keys) — **first-class** (not a plugin): `apiKeys: true` enables Bearer-key auth + admin-gated issue/list/revoke
|
|
53
|
+
- [Analytics & warehouse sinks](/docs/plugins/analytics) — `AnalyticsSink` contract + five first-party sink plugins (postgres-lite, DuckDB, ClickHouse, Tinybird, PostHog)
|
|
54
|
+
- [External identity providers](/docs/authentication/external-idp) — the six auth-adapter packages (WorkOS, Kinde, Clerk, Auth0, Supabase, generic OIDC)
|
|
55
|
+
|
|
56
|
+
## The catalogue at a glance
|
|
57
|
+
|
|
58
|
+
Status legend: ✓ shipped · ◐ partial · — planned.
|
|
59
|
+
|
|
60
|
+
| Plugin | Status | What it does |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `@voltro/plugin-audit` | ✓ | Mutation audit log + `audit()` mixin |
|
|
63
|
+
| `@voltro/plugin-auth` | ✓ | Full auth suite via `authRoutesPlugin()`: password (rehash-on-verify), sessions (multi-key rotation + sliding-window), magic-link + password-reset, passkeys/WebAuthn (atomic clone detection, BYO multi-replica challenge store), CSRF, session enumeration + revocation, memberships + switch-tenant, TOTP/MFA (sign-in enforcement + recovery codes); `authTables` schemas |
|
|
64
|
+
| `@voltro/plugin-multitenancy` | ✓ | `tenant()` schema mixin (read-scope + write-fill) + `assertOwnTenant` guard + typed `TenantMismatch` |
|
|
65
|
+
| `@voltro/plugin-soft-delete` | ✓ | `softDelete()` schema mixin — `deletedAt` / `deletedBy`; `delete` → UPDATE, `hardDelete()` bypass |
|
|
66
|
+
| `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `Forbidden` |
|
|
67
|
+
| `@voltro/plugin-ratelimit` | ✓ | Per-endpoint / per-subject / per-tenant limits; sliding-window / fixed-window / token-bucket; memory / postgres / redis stores |
|
|
68
|
+
| `@voltro/plugin-billing` | ✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing + mid-cycle proration + dunning (failed-payment retries); `requireEntitlement()` guard + `enforce` interceptor; `/billing/webhook` via plugin-webhooks; money as integer minor units |
|
|
69
|
+
| `@voltro/plugin-mail` | ✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
|
|
70
|
+
| `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
|
|
71
|
+
| `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
|
|
72
|
+
| `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
|
|
73
|
+
| `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineOutgoingEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
|
|
74
|
+
| `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
|
|
75
|
+
| `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics#voltroplugin-analytics-postgres) |
|
|
76
|
+
| `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics#voltroplugin-duckdb) |
|
|
77
|
+
| `@voltro/plugin-clickhouse` | ✓ | Production OLAP via the official ClickHouse client. [→ details](/docs/plugins/analytics#voltroplugin-clickhouse) |
|
|
78
|
+
| `@voltro/plugin-tinybird` | ✓ | Hosted ClickHouse via Events API + Pipes. [→ details](/docs/plugins/analytics#voltroplugin-tinybird) |
|
|
79
|
+
| `@voltro/plugin-posthog` | ✓ | Product analytics — track-only; compose with another sink for reads. [→ details](/docs/plugins/analytics#voltroplugin-posthog) |
|
|
80
|
+
| `@voltro/plugin-atlassian` | ✓ | `JiraService` + `ConfluenceService` over the Atlassian REST / Greenhopper / Agile APIs — PAT **or** OAuth 2.0 (3LO) auth, transient retry, SSRF guard, comment-write, signature-verified inbound webhooks, avatar proxy, per-tenant cache. [→ details](/docs/plugins/atlassian) |
|
|
81
|
+
| `@voltro/plugin-deactivation` | ✓ | `deactivation()` schema mixin — `deactivatedAt` + `deactivatedBy` (→ Actor); subject can't log in but data stays visible. [→ details](/docs/plugins/deactivation) |
|
|
82
|
+
| `@voltro/plugin-prometheus` | ✓ | Prometheus exporter — `GET /metrics` in text exposition format over the unified Metrics-API (Effect `MetricRegistry`); counters / histograms / gauges + custom metrics, optional bearer gate + node process metrics. [→ details](/docs/plugins/prometheus) |
|
|
83
|
+
| `@voltro/plugin-datadog` | ✓ | Deep Datadog integration — agentless metrics push to `/api/v2/series` + opt-in logs (`/api/v2/logs`, `dd.trace_id`-correlated) + traces (OTLP→Agent) + dd-trace profiler; `DD_API_KEY`/`DD_SITE` + unified service tagging, fail-soft. [→ details](/docs/plugins/datadog) |
|
|
84
|
+
| `@voltro/plugin-sentry` | ✓ | Deep Sentry integration — mutation/query/action errors reported correlated to the active trace (trace_id + span_id) + breadcrumbs from the framework log sink; opt-in performance traces (`SentrySpanProcessor`, OTel-consumer mode) + profiler. `@sentry/*` optional + lazy. [→ details](/docs/plugins/sentry) |
|
|
85
|
+
| `@voltro/plugin-flags` | ✓ | Feature flags — per-subject / per-tenant targeting + deterministic % rollout (FNV-1a bucket) + kill-switch; multivariate variant flags + scheduled / ramping rollouts + a durable kill-switch audit trail; declarative `gatedBy` (typed `FlagDisabled`) + `requireFlag` guard + `flags.evaluate` / `flags.variants` routes + `useFlags`/`useFlag`/`useVariant` hooks; memory / postgres store. [→ details](/docs/plugins/flags) |
|
|
86
|
+
| `@voltro/plugin-notifications` | ✓ | Unified notifications — one `send` across email / Slack / SMS / push (first-class `pushChannel` APNs/FCM factory) / in-app channels, per-user channel preferences, in-app inbox + unread count + delivery records; digest/batching rollup, per-subject quiet hours (DND), broadcast/topic fan-out; `NotificationService` + `useInbox`/`useUnreadCount`/`useMarkRead`/`useTopicSubscription`/`useQuietHours` hooks; durable DataStore-backed store by default (auto-migrated `notification_*` tables). [→ details](/docs/plugins/notifications) |
|
|
87
|
+
| `@voltro/plugin-logship` | ✓ | Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — rides the log-sink hook, batched + redacted + fail-soft, trace-correlated. [→ details](/docs/plugins/logship) |
|
|
88
|
+
| `@voltro/plugin-moderation` | ✓ | Content moderation — keyword denylist or AI provider (fails open), block (typed `ContentRejected`) / flag via rpc interceptor + in-handler `moderate()` redact helper. [→ details](/docs/plugins/moderation) |
|
|
89
|
+
| `@voltro/plugin-search` | ✓ | External search index sync — rides the ChangeEvent tap to mirror tables into Typesense / Meilisearch / Algolia (memory default), tenant-scoped `search.query` action (facets · highlighting · fuzziness · range/negation filters · engine-param passthrough) + `useSearch` hook + `backfillIndex` + durable cross-replica sync stats. [→ details](/docs/plugins/search) |
|
|
90
|
+
| `@voltro/plugin-cdc-out` | ◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox; ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. Engine + memory/webhook sinks shipped; warehouse connectors implement the `CdcSink` interface. |
|
|
91
|
+
| `@voltro/plugin-governance` | ✓ | Data governance — retention TTL sweep (delete / anonymise), GDPR subject export + erasure (admin-gated routes + `GovernanceService`), consent ledger, field encryption. [→ details](/docs/plugins/governance) |
|
|
92
|
+
| `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
|
|
93
|
+
| `@voltro/plugin-versioning` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete on listed tables into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/versioning) |
|
|
94
|
+
| `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator, swept `_voltro_presence` table, cross-instance. [→ details](/docs/plugins/presence) |
|
|
95
|
+
| `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `scim_users`/`scim_groups`. [→ details](/docs/plugins/scim) |
|
|
96
|
+
| `@voltro/plugin-sso-saml` | ✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under `/saml`; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via `@node-saml/node-saml` (optional+lazy), mints a framework session. [→ details](/docs/plugins/sso-saml) |
|
|
97
|
+
|
|
98
|
+
API keys are **first-class** (not a plugin): `apiKeys: true` in `app.config.ts` → Bearer-key auth + admin-gated `/v1/api-keys` management, hash-only storage. [→ details](/docs/configuration/api-keys)
|
|
99
|
+
|
|
100
|
+
Every plugin carries a design doc in the framework's `plans/` directory before it ships.
|
|
101
|
+
|
|
102
|
+
## Configuration shape
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// app.config.ts
|
|
106
|
+
import { auditPlugin } from '@voltro/plugin-audit'
|
|
107
|
+
import { rateLimitPlugin } from '@voltro/plugin-ratelimit'
|
|
108
|
+
|
|
109
|
+
export default {
|
|
110
|
+
type: 'api' as const,
|
|
111
|
+
name: 'api',
|
|
112
|
+
plugins: [
|
|
113
|
+
rateLimitPlugin({ default: { limit: 60, window: '1m' } }),
|
|
114
|
+
auditPlugin({ sink: 'console' }),
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Order matters: the framework composes outer→inner, so the rate-limit interceptor runs *before* the audit interceptor sees the request. Rejected requests never enter the audit log.
|
|
120
|
+
|
|
121
|
+
## What plugins can do
|
|
122
|
+
|
|
123
|
+
| Surface | What it lets you do |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `interceptMutation` / `interceptQuery` / `interceptAction` | Wrap every mutation / query-setup / action — gate, audit, transform input/output. |
|
|
126
|
+
| `extendSchema` | Contribute tables + custom SQL migrations (tracked in `_voltro_plugin_migrations`). |
|
|
127
|
+
| `services` | Provide an Effect `Layer` whose Tags every handler can `yield*` (e.g. `MailService`, `StorageService`). |
|
|
128
|
+
| `routes` | Register plugin-owned rpc queries / mutations / actions (alias-prefixed tags). |
|
|
129
|
+
| `httpRoutes` | Serve public raw-HTTP endpoints on the framework listener (e.g. `GET /_voltro/storage/:id`). |
|
|
130
|
+
| `inspectEndpoints` | Mount tooling under `/_voltro/inspect/plugins/<alias>/…`. |
|
|
131
|
+
| `onScheduleFire` / `onWorkflowStep` / `onHttpRequest` | Wrap every cron firing, every workflow `step()`, every pre-auth HTTP request. |
|
|
132
|
+
| `onInstall` / `onActivate` / `onDeactivate` / `onUninstall` | Lifecycle hooks at first-install, boot, shutdown, and removal. |
|
|
133
|
+
| Schema mixins (`defineMixin`) | The OTHER plugin shape — `audit()`, `tenant()`, `softDelete()` — declared in `@voltro/database`, not via the runtime contract. |
|
|
134
|
+
|
|
135
|
+
## When NOT to write a plugin
|
|
136
|
+
|
|
137
|
+
- **One-off side effect** — just call it from the mutation directly.
|
|
138
|
+
- **App-specific behaviour** — keep it in app code, not a reusable plugin.
|
|
139
|
+
- **Anything cross-cutting that only affects ONE mutation** — a single `await ctx.audit.log(...)` call beats a plugin's hook.
|
|
140
|
+
|
|
141
|
+
Plugins are for cross-cutting concerns. Audit-log every write, rate-limit every mutation, send a `user.created` event from every sign-up: that's plugin territory.
|
|
142
|
+
|
|
143
|
+
## Where to read next
|
|
144
|
+
|
|
145
|
+
- [The plugin contract](/docs/plugins/contract) — write your own
|
|
146
|
+
- [plugin-audit](/docs/plugins/audit) — most complete reference implementation
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
<!-- source: en/plugins/contract.md -->
|
|
153
|
+
## Plugin contract
|
|
154
|
+
|
|
155
|
+
_definePlugin, lifecycle hooks, rpc interceptors, framework-version compatibility — the surface every Voltro plugin implements._
|
|
156
|
+
|
|
157
|
+
A **plugin** is the unit of cross-cutting framework extension. Examples in the wild:
|
|
158
|
+
|
|
159
|
+
- `@voltro/plugin-audit` records every mutation invocation to a sink.
|
|
160
|
+
- `@voltro/plugin-multitenancy` adds the `tenant()` schema mixin + write-guard.
|
|
161
|
+
- `@voltro/plugin-webhooks` ships incoming + outgoing webhook tables + workflow.
|
|
162
|
+
- `@voltro/plugin-auth-workos` (and 5 siblings) plug an external IdP into the auth chain.
|
|
163
|
+
|
|
164
|
+
Plugins live as npm packages, get listed in `app.config.ts`'s `plugins:` array, and the framework wires their hooks at boot. The contract is intentionally narrow — a plugin is NOT a full app extension; it's a focused cross-cutting concern that pairs with the existing query / mutation / action / workflow primitives.
|
|
165
|
+
|
|
166
|
+
## The shape
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { definePlugin } from '@voltro/protocol'
|
|
170
|
+
|
|
171
|
+
export const myPlugin = (options: MyOptions) =>
|
|
172
|
+
definePlugin({
|
|
173
|
+
name: '@vendor/my-plugin', // stable id — surfaced in boot logs + dashboard
|
|
174
|
+
description: 'rate-limits outbound HTTP per tenant',
|
|
175
|
+
framework: '^1.0.0', // semver range — soft-checked at boot
|
|
176
|
+
interceptMutation: async (next, ctx) => { /* wraps every mutation */ },
|
|
177
|
+
interceptAction: async (next, ctx) => { /* wraps every action */ },
|
|
178
|
+
onActivate: (lifecycle) => { /* one-shot boot: open pools, register metrics */ },
|
|
179
|
+
onDeactivate: (lifecycle) => { /* graceful shutdown */ },
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`definePlugin` is an identity function with type-level enforcement — it returns the input unchanged at runtime, but the type-checker catches missing required fields, misnamed hooks, and excess properties at the declaration site. Always use it over plain object literals.
|
|
184
|
+
|
|
185
|
+
## Hooks
|
|
186
|
+
|
|
187
|
+
### `interceptMutation` / `interceptAction` (Effect-native)
|
|
188
|
+
|
|
189
|
+
Wrap every mutation or action call respectively. **Both are Effect-based** —
|
|
190
|
+
the framework is Effect end-to-end and the plugin boundary preserves that
|
|
191
|
+
shape so tracing, interruption, and the typed error channel flow through
|
|
192
|
+
without round-tripping to Promise:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
type RpcInterceptor = (
|
|
196
|
+
next: Effect.Effect<unknown, unknown>,
|
|
197
|
+
context: {
|
|
198
|
+
readonly tag: string // 'todos.create', 'support.ping', …
|
|
199
|
+
readonly kind: 'mutation' | 'action' // discriminator
|
|
200
|
+
readonly input: unknown // the validated payload
|
|
201
|
+
readonly subject: Subject // resolved by AuthMiddleware
|
|
202
|
+
readonly traceId: string // matches OTel spans + logs
|
|
203
|
+
},
|
|
204
|
+
) => Effect.Effect<unknown, unknown>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The interceptor MUST flow `next` through somehow — yield it, pipe through it,
|
|
208
|
+
or return it. Whatever the returned Effect produces becomes the final result
|
|
209
|
+
UNLESS the interceptor substitutes a different one (cache hits, etc).
|
|
210
|
+
Failing the returned Effect skips the rest of the chain and surfaces the
|
|
211
|
+
error to the rpc layer.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// Pre-only: short-circuit before the executor runs.
|
|
215
|
+
const guard: RpcInterceptor = (next, ctx) =>
|
|
216
|
+
ctx.tag.startsWith('admin.') && ctx.subject.type !== 'user'
|
|
217
|
+
? Effect.fail(new Forbidden({ tag: ctx.tag }))
|
|
218
|
+
: next
|
|
219
|
+
|
|
220
|
+
// Post-only: tap the success/failure channels.
|
|
221
|
+
const log: RpcInterceptor = (next, ctx) =>
|
|
222
|
+
next.pipe(
|
|
223
|
+
Effect.tap((result) => recordAudit(ctx.tag, ctx.input, result, ctx.subject)),
|
|
224
|
+
Effect.tapError((err) => recordAuditError(ctx.tag, err)),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
// Tracing: spans nest automatically.
|
|
228
|
+
const trace: RpcInterceptor = (next, ctx) =>
|
|
229
|
+
next.pipe(Effect.withSpan(`plugin.${ctx.tag}`))
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Composition.** When multiple plugins each install `interceptMutation`, the framework composes them in declaration order: the first plugin in `app.config.ts`'s `plugins:` array is the OUTERMOST wrapper, the last is closest to the executor. That mirrors how middleware composition normally reads top-to-bottom in user code.
|
|
233
|
+
|
|
234
|
+
**Why three hooks instead of one.** A plugin that only wants to govern outbound HTTP (rate limiting, tenant-scoped IO quotas) installs `interceptAction` without touching mutations. A plugin that records writes (audit log) installs `interceptMutation` without touching reads or actions. The split lets plugins opt in narrowly.
|
|
235
|
+
|
|
236
|
+
`interceptQuery` wraps subscription **setup** — the one-time call that produces the `QueryDescriptor`. Use it for pre-setup authz (deny before the subscription opens), per-tenant subscription-rate-limiting, or subscription-open audit logging. **It does NOT wrap every snapshot/delta delivery** — per-delta observability flows through the framework's OTel spans (`subscription.<tag>.snapshot` / `.delta`); wrapping every delivery would add per-event latency the streaming model is specifically designed to avoid.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
interceptQuery: (next, ctx) =>
|
|
240
|
+
ctx.subject.type === 'anonymous' && ctx.tag.startsWith('admin.')
|
|
241
|
+
? Effect.fail(new Unauthenticated({ reason: 'admin queries require authentication' }))
|
|
242
|
+
: next,
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
A failure inside an `interceptQuery` aborts the subscription open; the rpc layer surfaces it to the client as a subscription error. Substituting the return Effect is supported but rare — usually interceptors do pre-only or post-only side effects.
|
|
246
|
+
|
|
247
|
+
**Per-plugin observability is automatic.** Every interceptor is wrapped at compose time with `Effect.withSpan('plugin.<name>.intercept-<kind>')` + a `plugin.*` metric sample — no opt-in. The DevTools / cloud dashboard's Plugins panel filters `/_voltro/inspect/metrics` by the `plugin.*` prefix to render per-plugin latency + count buckets, and the trace waterfall shows the plugin layer as a nested span inside the handler boundary.
|
|
248
|
+
|
|
249
|
+
### `onChangeEvent` — the post-commit ChangeEvent tap
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
onChangeEvent: (event: PluginChangeEvent) => Effect.Effect<void, unknown>
|
|
253
|
+
// PluginChangeEvent = {
|
|
254
|
+
// table, op: 'insert'|'update'|'delete', new: Row | null, old: Row | null,
|
|
255
|
+
// origin?: 'inline' | 'injected', changeScope: 'local' | 'fleet',
|
|
256
|
+
// }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
A tap on the store's post-commit change stream — the plugin sees every committed insert/update/delete. It returns an `Effect<void, E>` that the runtime **supervises**: it forks the Effect under the plugin's supervision scope (so the tap is non-blocking and can never back-pressure the change stream or the writing mutation) and routes the Effect's failure channel to the plugin-scoped logger. That gives a change-tap a real, typed error channel — `Effect.retry`, `Effect.timeout`, `Effect.catchTag`, a durable enqueue — instead of fire-and-forget glue.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
onChangeEvent: (event) =>
|
|
263
|
+
event.table !== 'orders'
|
|
264
|
+
? Effect.void // not my table — no-op
|
|
265
|
+
: Effect.tryPromise(() => mirror(event)).pipe( // failure is logged by the runtime
|
|
266
|
+
Effect.retry({ times: 3 }),
|
|
267
|
+
)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Runs under BOTH `voltro dev` and `voltro serve` (the prod serve path fans out the same way). Requires the `'store:changes:read'` permission. It is NOT durable at the framework layer — a crash between commit and the fork loses the event; build durability INSIDE the Effect (insert into an outbox and retry against the typed error channel, the way `@voltro/plugin-cdc-out` does). Exactly-once / change-scope semantics are unchanged: read `event.origin` + `event.changeScope` inside the Effect to act once per change fleet-wide (skip `origin: 'injected'` on `'local'` scope; elect one worker on `'fleet'`). Used by `@voltro/plugin-search` to mirror rows into an external index. For in-transaction reactions use a mutation; for best-effort per-table reactions in app code prefer a `*.subscribe.ts` — `onChangeEvent` is the plugin-level equivalent.
|
|
271
|
+
|
|
272
|
+
### `onInstall` / `onUninstall` / `onActivate` / `onDeactivate` lifecycle
|
|
273
|
+
|
|
274
|
+
Four lifecycle hooks, all accepting Effect / Promise / sync return values.
|
|
275
|
+
The framework awaits each in turn — sync returns settle synchronously,
|
|
276
|
+
Promises and Effects are awaited.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
interface PluginLifecycleContext {
|
|
280
|
+
readonly app: {
|
|
281
|
+
readonly name: string
|
|
282
|
+
readonly type: 'api' | 'web'
|
|
283
|
+
readonly voltroVersion: string
|
|
284
|
+
}
|
|
285
|
+
readonly logger: { info; warn; error; debug } // scoped to the plugin's name
|
|
286
|
+
readonly env: NodeJS.ProcessEnv
|
|
287
|
+
readonly config: unknown // validated against configSchema if declared
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- **`onInstall`** runs ONCE per process per `plugin@version` — the
|
|
292
|
+
FIRST time the host sees this plugin. Use for schema migrations + resource
|
|
293
|
+
provisioning that survive across deactivate/activate cycles. In v1 the
|
|
294
|
+
install marker is in-memory; persisted state (`_voltro_plugin_installs`
|
|
295
|
+
table) lands with the migration runner. Failure aborts boot.
|
|
296
|
+
- **`onUninstall`** is the teardown mirror of `onInstall` — declare it
|
|
297
|
+
to drop tables, delete queues, etc. when the plugin is removed from
|
|
298
|
+
the app. Keep it idempotent (re-running is a no-op once torn down).
|
|
299
|
+
- **`onActivate`** runs every boot. Use for warming caches, opening
|
|
300
|
+
connection pools, registering metrics sinks. The framework awaits each
|
|
301
|
+
hook sequentially in declaration order. **Throwing/failing aborts boot** —
|
|
302
|
+
better to surface a misconfigured plugin loudly than start the rpc
|
|
303
|
+
server with half-wired plugins.
|
|
304
|
+
- **`onDeactivate`** runs in **REVERSE declaration order** (mirror of
|
|
305
|
+
activate — the most-recently-activated plugin tears down first). Each
|
|
306
|
+
hook has a **5-second per-plugin grace window** and the whole sequence
|
|
307
|
+
is capped at **30 seconds total**. A plugin that throws or exceeds its
|
|
308
|
+
grace window logs a warning + the sequence moves on; shutdown never
|
|
309
|
+
blocks on a stuck plugin. **Idempotent** — repeated SIGTERM doesn't
|
|
310
|
+
re-run hooks. Hookup covers SIGINT + SIGTERM in both `voltro dev` and
|
|
311
|
+
`voltro start`.
|
|
312
|
+
|
|
313
|
+
### `framework` compatibility
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
definePlugin({
|
|
317
|
+
name: '@vendor/x',
|
|
318
|
+
framework: '^1.0.0', // accepts 1.x, rejects 0.x and 2.x
|
|
319
|
+
// OR: '~1.2.0', '>=1.0.0 <2.0.0', '1.2.3' (exact), '*' (any)
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
At boot the framework runs `checkFrameworkCompat(plugin.name, plugin.framework, runningVoltroVersion)`. Mismatch logs a `WARN` with the constraint + the running version, and **boot continues by default** — the operator decides whether to pin a different version or upgrade. Set `VOLTRO_STRICT_PLUGIN_COMPAT=1` to make a mismatch a hard boot-abort instead (the plugin throws at boot) — for CI or regulated deployments that must refuse to run a plugin built against a different framework version.
|
|
324
|
+
|
|
325
|
+
Absent `framework` field → no compat check. Suitable for in-tree plugins that ship lockstep with the framework.
|
|
326
|
+
|
|
327
|
+
## What plugins CAN'T do (initial policy)
|
|
328
|
+
|
|
329
|
+
- **Mutate other plugins' state.** Plugins don't talk to each other directly. If two plugins need to coordinate, it's via the rpc layer (one plugin's interceptor sees the other's `subject.metadata`, for example).
|
|
330
|
+
- **Bypass tenant scoping.** Interceptors run AFTER the runtime's tenant predicate merge. A mutation interceptor can't query rows from another tenant by manipulating `subject.tenantId`.
|
|
331
|
+
- **Access raw secrets directly.** Plugins get config via their own factory function's options object. `lifecycle.env` exposes `process.env` but only the plugin's own factory chooses which env vars to read.
|
|
332
|
+
- **Modify the core schema DSL or query builder.** Schema extension happens through schema mixins (the `*.mixin.ts` pattern in `@voltro/database`); not through plugin runtime hooks.
|
|
333
|
+
|
|
334
|
+
These boundaries hold for v1; some may relax for verified plugins once the marketplace ships.
|
|
335
|
+
|
|
336
|
+
## Stacking
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
// app.config.ts
|
|
340
|
+
import { auditPlugin } from '@voltro/plugin-audit'
|
|
341
|
+
import { rateLimitPlugin } from '@vendor/rate-limit'
|
|
342
|
+
import { metricsPlugin } from '@vendor/metrics'
|
|
343
|
+
|
|
344
|
+
export default {
|
|
345
|
+
type: 'api' as const,
|
|
346
|
+
name: 'myApi',
|
|
347
|
+
plugins: [
|
|
348
|
+
metricsPlugin({ sink: 'datadog' }), // OUTERMOST — sees every request first
|
|
349
|
+
rateLimitPlugin({ perTenant: 100 }),
|
|
350
|
+
auditPlugin({ sink: 'console' }), // INNERMOST — closest to executor
|
|
351
|
+
],
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The first plugin's `interceptMutation` wraps the second's, which wraps the third's, which wraps the executor. Same composition for `interceptAction`. The chain runs in deterministic order independent of import order or filesystem walk — only `plugins:` array order matters.
|
|
356
|
+
|
|
357
|
+
### `services: Layer` — contribute Tags into the per-request runtime
|
|
358
|
+
|
|
359
|
+
A plugin can provide an Effect Layer whose Tags become available to
|
|
360
|
+
EVERY handler in the app. Handlers `yield* MyTag` to read; plugins own
|
|
361
|
+
the implementation; consumers don't import the plugin directly.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { Effect, Layer } from 'effect'
|
|
365
|
+
import { definePlugin, definePluginService } from '@voltro/protocol'
|
|
366
|
+
|
|
367
|
+
interface AuditService {
|
|
368
|
+
readonly record: (event: { tag: string; actor: string }) => Effect.Effect<void>
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const { Tag: Audit, Live: AuditLive } = definePluginService<AuditService, AuditService>(
|
|
372
|
+
'@vendor/audit/Service',
|
|
373
|
+
{
|
|
374
|
+
record: (event) => Effect.sync(() => console.log('AUDIT', event)),
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
export const auditPlugin = (): VoltroPlugin =>
|
|
379
|
+
definePlugin({
|
|
380
|
+
name: '@vendor/audit',
|
|
381
|
+
services: AuditLive,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// In any handler:
|
|
385
|
+
// import { Audit } from '@vendor/audit'
|
|
386
|
+
// const audit = yield* Audit
|
|
387
|
+
// yield* audit.record({ tag: 'todos.create', actor: 'u_42' })
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Layer composition is independent of declaration order across plugins
|
|
391
|
+
(Effect's `mergeAll` is commutative on Tag identity); collisions on the
|
|
392
|
+
SAME Tag resolve to the LAST layer in the merge list, so a user layer
|
|
393
|
+
in `apiConfig.layers` can override a plugin layer with the same Tag.
|
|
394
|
+
|
|
395
|
+
### `routes: PluginRpcRoute[]` — plugins ship their own RPC endpoints
|
|
396
|
+
|
|
397
|
+
Plugins can register their own queries / mutations / actions alongside
|
|
398
|
+
user-authored queries. Same wire protocol, same dashboard surface, same
|
|
399
|
+
interceptor + tracing wiring. Query tags carry a plugin-derived prefix
|
|
400
|
+
so plugin queries never collide with user queries:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { definePlugin, definePluginRoute } from '@voltro/protocol'
|
|
404
|
+
import { Effect, Schema } from 'effect'
|
|
405
|
+
|
|
406
|
+
export const auditPlugin = (): VoltroPlugin =>
|
|
407
|
+
definePlugin({
|
|
408
|
+
name: '@voltro/plugin-audit',
|
|
409
|
+
routes: [
|
|
410
|
+
definePluginRoute({
|
|
411
|
+
kind: 'query',
|
|
412
|
+
name: 'list', // tag = 'audit.list' (plugin alias prepended)
|
|
413
|
+
input: Schema.Struct({ limit: Schema.optional(Schema.Number) }),
|
|
414
|
+
output: Schema.Array(AuditEventSchema),
|
|
415
|
+
execute: (input) => Effect.gen(function* () {
|
|
416
|
+
const buffer = yield* AuditBuffer // service Tag from plugin's `services` layer
|
|
417
|
+
return buffer.read(input.limit ?? 100)
|
|
418
|
+
}),
|
|
419
|
+
}),
|
|
420
|
+
definePluginRoute({
|
|
421
|
+
kind: 'mutation',
|
|
422
|
+
name: 'clear', // tag = 'audit.clear'
|
|
423
|
+
input: Schema.Struct({}),
|
|
424
|
+
output: Schema.Struct({ cleared: Schema.Number }),
|
|
425
|
+
execute: () => Effect.sync(() => {
|
|
426
|
+
// clear logic — runs inside the framework's transactional wrap
|
|
427
|
+
return { cleared: 0 }
|
|
428
|
+
}),
|
|
429
|
+
}),
|
|
430
|
+
],
|
|
431
|
+
})
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Tag derivation: `<plugin-alias>.<query.name>` unless `query.name` contains
|
|
435
|
+
a dot (escape hatch). Plugin alias strips `@scope/` + the `plugin-` prefix
|
|
436
|
+
and kebab→camelCase:
|
|
437
|
+
|
|
438
|
+
| Plugin name | Alias |
|
|
439
|
+
|---|---|
|
|
440
|
+
| `@voltro/plugin-audit` | `audit` |
|
|
441
|
+
| `@scope/plugin-rateLimit` | `rateLimit` |
|
|
442
|
+
| `@scope/plugin-rate-limit` | `rateLimit` |
|
|
443
|
+
| `plain-name` | `plainName` |
|
|
444
|
+
| `@voltro/audit` | `audit` (no `plugin-` to strip) |
|
|
445
|
+
|
|
446
|
+
Boot fails with a clear error on tag collisions (between two plugins, or
|
|
447
|
+
with a user-authored tag).
|
|
448
|
+
|
|
449
|
+
#### Reactive plugin queries — `source`
|
|
450
|
+
|
|
451
|
+
A plugin query can be **push-driven** instead of poll-only. Add a `source`
|
|
452
|
+
table (or tables) to a `kind: 'query'` route and the framework re-runs the
|
|
453
|
+
executor and pushes a fresh result over the SAME subscription/WS transport
|
|
454
|
+
app reactive queries use — every time one of those tables changes. No new
|
|
455
|
+
push system: it reuses the framework's computed-reactive-query machinery, so
|
|
456
|
+
the client just subscribes and receives live updates.
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
definePluginRoute({
|
|
460
|
+
kind: 'query',
|
|
461
|
+
name: 'list',
|
|
462
|
+
source: '_voltro_presence', // ← reactive: re-run + push on any change to this table
|
|
463
|
+
input: Schema.Struct({ channel: Schema.String }),
|
|
464
|
+
output: Schema.Array(MemberSchema),
|
|
465
|
+
// The executor returns the query's VALUE (the same shape it returns when
|
|
466
|
+
// polled); the framework recomputes + pushes it on change.
|
|
467
|
+
execute: (input, ctx) => Effect.gen(function* () {
|
|
468
|
+
// read the table, shape the roster, return it
|
|
469
|
+
return roster
|
|
470
|
+
}),
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`source` must name a table declared `.reactive()`. Omit `source` for a plain
|
|
475
|
+
poll-only plugin query, a mutation, or an action. This is exactly how
|
|
476
|
+
`@voltro/plugin-presence` makes `presence.list` push-driven — the
|
|
477
|
+
`usePresence` roster updates live with no client polling.
|
|
478
|
+
|
|
479
|
+
### `permissions: PluginPermission[]` + `configSchema: Schema` — manifest fields
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
definePlugin({
|
|
483
|
+
name: '@vendor/audit',
|
|
484
|
+
version: '1.2.3',
|
|
485
|
+
permissions: [
|
|
486
|
+
'rpc:intercept:mutation', // matches interceptMutation
|
|
487
|
+
'rpc:intercept:query', // matches interceptQuery
|
|
488
|
+
'secrets:read:audit:*', // pattern-perm (runtime check)
|
|
489
|
+
],
|
|
490
|
+
configSchema: Schema.Struct({
|
|
491
|
+
sink: Schema.Literal('console', 'memory', 'postgres'),
|
|
492
|
+
verbose: Schema.optional(Schema.Boolean),
|
|
493
|
+
}),
|
|
494
|
+
})
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
`permissions` is a **typed enum**. The framework audits every plugin's
|
|
498
|
+
declared set against its hook surfaces at boot — a plugin that ships
|
|
499
|
+
`onHttpRequest` without declaring `'http:intercept'` (or any other
|
|
500
|
+
hook-without-perm combination) fails boot with the specific scope name.
|
|
501
|
+
No silent strip; no advisory warnings; the operator either grants the
|
|
502
|
+
perm or the plugin doesn't run.
|
|
503
|
+
|
|
504
|
+
The static surface is a union of well-known scopes; pattern perms
|
|
505
|
+
(`'secrets:read:foo:*'`, `'network:outbound:api.stripe.com'`,
|
|
506
|
+
`'plugin:hook:<other-plugin>'`) fall through the template-literal arm.
|
|
507
|
+
Runtime resource checks use `permissionMatches(declared, required)` —
|
|
508
|
+
a declared `'secrets:read:auth0:*'` covers a required
|
|
509
|
+
`'secrets:read:auth0:clientSecret'`.
|
|
510
|
+
|
|
511
|
+
| Hook surface | Required permission |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `interceptMutation` | `'rpc:intercept:mutation'` |
|
|
514
|
+
| `interceptQuery` | `'rpc:intercept:query'` |
|
|
515
|
+
| `interceptAction` | `'rpc:intercept:action'` |
|
|
516
|
+
| `onScheduleFire` | `'schedule:fire'` |
|
|
517
|
+
| `onWorkflowStep` | `'workflow:step'` |
|
|
518
|
+
| `onHttpRequest` | `'http:intercept'` |
|
|
519
|
+
| `onChangeEvent` | `'store:changes:read'` |
|
|
520
|
+
| `inspectEndpoints` (non-empty) | `'inspect:read'` |
|
|
521
|
+
| `dashboard` (non-empty) | `'dashboard:mount'` |
|
|
522
|
+
| `extendSchema.tables` (non-empty) | `'store:write'` |
|
|
523
|
+
| `extendSchema.migrations` (non-empty) | `'migration:run'` |
|
|
524
|
+
|
|
525
|
+
`configSchema` decodes the operator's user-supplied config payload at
|
|
526
|
+
boot. Decode failures abort boot with a typed error pointing at the
|
|
527
|
+
plugin. The decoded value lands in `PluginLifecycleContext.config` so
|
|
528
|
+
lifecycle hooks consume the already-validated shape.
|
|
529
|
+
|
|
530
|
+
## Writing a plugin — minimal example
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
// packages/plugin-rate-limit/src/index.ts
|
|
534
|
+
import { Effect } from 'effect'
|
|
535
|
+
import { definePlugin, type VoltroPlugin } from '@voltro/protocol'
|
|
536
|
+
|
|
537
|
+
interface RateLimitOptions {
|
|
538
|
+
readonly perTenant: number // requests/minute
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const buckets = new Map<string, { count: number; windowStart: number }>()
|
|
542
|
+
|
|
543
|
+
export const rateLimitPlugin = (options: RateLimitOptions): VoltroPlugin =>
|
|
544
|
+
definePlugin({
|
|
545
|
+
name: '@vendor/rate-limit',
|
|
546
|
+
framework: '^1.0.0',
|
|
547
|
+
interceptMutation: (next, ctx) => {
|
|
548
|
+
if (ctx.subject.type === 'anonymous') return next // no per-tenant limit
|
|
549
|
+
const key = ctx.subject.tenantId
|
|
550
|
+
const now = Date.now()
|
|
551
|
+
const bucket = buckets.get(key) ?? { count: 0, windowStart: now }
|
|
552
|
+
if (now - bucket.windowStart > 60_000) {
|
|
553
|
+
bucket.count = 0
|
|
554
|
+
bucket.windowStart = now
|
|
555
|
+
}
|
|
556
|
+
bucket.count++
|
|
557
|
+
buckets.set(key, bucket)
|
|
558
|
+
if (bucket.count > options.perTenant) {
|
|
559
|
+
return Effect.fail(new Error(`rate limit exceeded for tenant ${key}`))
|
|
560
|
+
}
|
|
561
|
+
return next
|
|
562
|
+
},
|
|
563
|
+
onActivate: ({ logger }) => {
|
|
564
|
+
logger.info('rate-limit plugin armed', { perTenant: options.perTenant })
|
|
565
|
+
},
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
That's the whole plugin. Drop it in `app.config.ts`, `voltro dev` picks it up, every mutation goes through the rate limiter.
|
|
570
|
+
|
|
571
|
+
## Schema mixins — the OTHER plugin shape
|
|
572
|
+
|
|
573
|
+
Not every cross-cutting concern needs runtime hooks. Schema extensions live in their own surface — `defineMixin({...})` from `@voltro/database` — and don't go through the `VoltroPlugin` contract at all. See [database/mixins](/docs/database/mixins) for that pattern.
|
|
574
|
+
|
|
575
|
+
A package can ship BOTH a schema mixin AND a runtime plugin (`@voltro/plugin-audit` does — the `audit()` mixin adds columns; the `auditPlugin()` factory installs the mutation interceptor). They're independent — apps that want the schema but not the interceptor `.with(audit())` without `plugins: [auditPlugin()]`, and vice versa.
|
|
576
|
+
|
|
577
|
+
## More extension surfaces
|
|
578
|
+
|
|
579
|
+
The plugin contract carries eight additional surfaces beyond the
|
|
580
|
+
interceptors + lifecycle + manifest fields covered above:
|
|
581
|
+
|
|
582
|
+
### `inspectEndpoints: PluginInspectEndpoint[]` — plugin-mounted HTTP endpoints
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
import { definePlugin } from '@voltro/protocol'
|
|
586
|
+
import { Effect } from 'effect'
|
|
587
|
+
|
|
588
|
+
export const auditDebugPlugin = (): VoltroPlugin =>
|
|
589
|
+
definePlugin({
|
|
590
|
+
name: '@voltro/plugin-audit-debug',
|
|
591
|
+
inspectEndpoints: [
|
|
592
|
+
{
|
|
593
|
+
method: 'GET',
|
|
594
|
+
path: 'buffer', // → /_voltro/inspect/plugins/auditDebug/buffer
|
|
595
|
+
description: 'Returns the last 1000 audit events',
|
|
596
|
+
handler: () => Effect.sync(() => ({
|
|
597
|
+
kind: 'json',
|
|
598
|
+
data: { events: readAuditBuffer() },
|
|
599
|
+
})),
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
method: 'POST',
|
|
603
|
+
path: 'clear',
|
|
604
|
+
handler: () => Effect.sync(() => {
|
|
605
|
+
clearAuditBuffer()
|
|
606
|
+
return { kind: 'json' as const, data: { cleared: true } }
|
|
607
|
+
}),
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
})
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Plugin endpoints inherit the framework's auth resolver — plugins can't
|
|
614
|
+
bypass `VOLTRO_INSPECT_TOKEN`. Path: `/_voltro/inspect/plugins/<plugin-alias>/<path>`.
|
|
615
|
+
Boot fails loudly on collisions (two plugins claiming the same path+method).
|
|
616
|
+
|
|
617
|
+
### `onScheduleFire: ScheduleFireInterceptor` — wrap every cron firing
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
definePlugin({
|
|
621
|
+
name: '@vendor/schedule-gate',
|
|
622
|
+
onScheduleFire: async (next, ctx) => {
|
|
623
|
+
// Suppress all firings of `nightlyBilling` when the kill-switch is on.
|
|
624
|
+
if (ctx.name === 'nightlyBilling' && (await isKilled('billing'))) {
|
|
625
|
+
return // skip — handler doesn't run
|
|
626
|
+
}
|
|
627
|
+
await next()
|
|
628
|
+
},
|
|
629
|
+
})
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Composed across plugins in declaration order; runs INSIDE the schedule's
|
|
633
|
+
`maxRuntimeMs` watchdog. Per-plugin span (`plugin.<name>.schedule-fire`)
|
|
634
|
+
+ metric auto-emitted.
|
|
635
|
+
|
|
636
|
+
### `onWorkflowStep: WorkflowStepInterceptor` — wrap every `step()`
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
import { Effect } from 'effect'
|
|
640
|
+
|
|
641
|
+
definePlugin({
|
|
642
|
+
name: '@vendor/step-observer',
|
|
643
|
+
onWorkflowStep: (next, ctx) =>
|
|
644
|
+
next.pipe(
|
|
645
|
+
Effect.withSpan(`vendor.workflow.${ctx.stepName}`, {
|
|
646
|
+
attributes: { 'workflow.attempt': ctx.attempt },
|
|
647
|
+
}),
|
|
648
|
+
Effect.tap((output) => Effect.sync(() => recordStep(ctx, output))),
|
|
649
|
+
),
|
|
650
|
+
})
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Wraps every `step()` (== `Activity.make`) inside a workflow body. Effect-
|
|
654
|
+
native — the interceptor sees `next` as an Effect and can pipe through
|
|
655
|
+
`tap`/`retry`/`withSpan`. **Bypassed on workflow REPLAY** — the interceptor
|
|
656
|
+
only fires on the first execution per step; on resume after a crash, the
|
|
657
|
+
cached Activity output is replayed without re-running the user effect or
|
|
658
|
+
the plugin interceptor.
|
|
659
|
+
|
|
660
|
+
### `codegen: PluginCodegen` — emit typed bindings into `rpcGroup.generated.ts`
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
definePlugin({
|
|
664
|
+
name: '@voltro/plugin-audit',
|
|
665
|
+
codegen: (ctx) => `
|
|
666
|
+
// Typed accessor for the audit plugin's inspect endpoint
|
|
667
|
+
export const useAuditBuffer = () =>
|
|
668
|
+
fetch('/_voltro/inspect/plugins/${ctx.pluginAlias}/buffer')
|
|
669
|
+
.then((r) => r.json() as Promise<{ events: ReadonlyArray<AuditEvent> }>)
|
|
670
|
+
`,
|
|
671
|
+
})
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
The framework emits the returned string into `rpcGroup.generated.ts`
|
|
675
|
+
between `// <plugin:@voltro/plugin-audit>` / `// </plugin:@voltro/plugin-audit>`
|
|
676
|
+
markers. The plugin sees the api name + the list of discovered user-query
|
|
677
|
+
rpc tags so it can emit per-rpc bindings. Returning `null` contributes
|
|
678
|
+
nothing.
|
|
679
|
+
|
|
680
|
+
### `templates: PluginTemplate[]` — ship `voltro init` templates
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
definePlugin({
|
|
684
|
+
name: '@vendor/plugin-stripe-webhooks',
|
|
685
|
+
templates: [
|
|
686
|
+
{
|
|
687
|
+
id: 'stripe-receiver',
|
|
688
|
+
title: 'Stripe webhook receiver',
|
|
689
|
+
description: 'Verified Stripe-webhook endpoint with replay protection',
|
|
690
|
+
kind: 'api',
|
|
691
|
+
sourcePath: 'templates/stripe-receiver',
|
|
692
|
+
postInstallSteps: [
|
|
693
|
+
'Set VOLTRO_WEBHOOK_SECRET_STRIPE in your env',
|
|
694
|
+
'Configure the endpoint URL in your Stripe dashboard',
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
})
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
Templates surface under the plugin alias in `voltro list-templates`. The
|
|
702
|
+
registration shape + manifest are part of the public contract, and a
|
|
703
|
+
plugin-provided template is scaffolded exactly like a built-in one —
|
|
704
|
+
`scaffoldFromTemplate` copies the declared tree and substitutes the
|
|
705
|
+
`{{…}}` tokens in both file content AND file/directory names.
|
|
706
|
+
|
|
707
|
+
### `onHttpRequest: HttpRequestInterceptor` — pre-auth HTTP-pipeline hook
|
|
708
|
+
|
|
709
|
+
Fires at the **very top** of every HTTP request — BEFORE auth resolution,
|
|
710
|
+
BEFORE rpc routing, BEFORE inspect. Composed across plugins in
|
|
711
|
+
declaration order (first listed = outermost). Returning a
|
|
712
|
+
`HttpInterceptResponse` short-circuits the pipeline; returning `null`
|
|
713
|
+
(or calling `next()` and returning its result) lets the request flow
|
|
714
|
+
through to the framework's normal handlers.
|
|
715
|
+
|
|
716
|
+
This is **not** an auth replacement — `AuthMiddleware` still runs on
|
|
717
|
+
the rpc path. Use this hook for concerns that need to fire BEFORE auth:
|
|
718
|
+
rate-limit, geo-block, bot detection, header injection for downstream
|
|
719
|
+
observability. Requires the `'http:intercept'` permission.
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
import { definePlugin } from '@voltro/protocol'
|
|
723
|
+
|
|
724
|
+
export const rateLimitPlugin = (opts: { perMinute: number }) =>
|
|
725
|
+
definePlugin({
|
|
726
|
+
name: '@vendor/plugin-rate-limit',
|
|
727
|
+
permissions: ['http:intercept'],
|
|
728
|
+
onHttpRequest: async (next, ctx) => {
|
|
729
|
+
const remoteAddr = ctx.headers['x-forwarded-for'] ?? ctx.remoteAddr ?? 'unknown'
|
|
730
|
+
if (buckets.consume(remoteAddr, opts.perMinute) === 'exhausted') {
|
|
731
|
+
return {
|
|
732
|
+
status: 429,
|
|
733
|
+
headers: { 'retry-after': '60', 'content-type': 'application/json' },
|
|
734
|
+
body: JSON.stringify({ error: 'rate_limit_exceeded' }),
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return next() // let the request continue
|
|
738
|
+
},
|
|
739
|
+
})
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
A per-plugin `plugin.<name>.http-intercept` metric is auto-emitted so
|
|
743
|
+
the dashboard's Plugins panel surfaces HTTP-intercept latency next to
|
|
744
|
+
RPC-intercept latency.
|
|
745
|
+
|
|
746
|
+
### `extendSchema: { tables, migrations }` — contribute schema + migrations
|
|
747
|
+
|
|
748
|
+
A plugin contributes BOTH declarative table descriptors AND custom SQL
|
|
749
|
+
migrations. Tables merge into the user's schema and flow through the
|
|
750
|
+
same idempotent `applySchema()` path. Migrations run after `applySchema`
|
|
751
|
+
against the live `SqlClient` and are tracked in
|
|
752
|
+
`_voltro_plugin_migrations` so each runs exactly once per app database.
|
|
753
|
+
|
|
754
|
+
The ledger key is `<plugin-alias>__<migration.id>` so two plugins can
|
|
755
|
+
each ship `'001-init'` without collision. Failure aborts boot;
|
|
756
|
+
re-runs are no-ops.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import { Effect, Schema } from 'effect'
|
|
760
|
+
import { definePlugin } from '@voltro/protocol'
|
|
761
|
+
import { table, id, text, timestamp } from '@voltro/database'
|
|
762
|
+
|
|
763
|
+
const auditLogs = table('audit_logs', {
|
|
764
|
+
id: id({ prefix: 'audit' }),
|
|
765
|
+
actorId: text(),
|
|
766
|
+
action: text(),
|
|
767
|
+
payload: text(),
|
|
768
|
+
createdAt: timestamp().default('now'),
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
export const auditPlugin = () =>
|
|
772
|
+
definePlugin({
|
|
773
|
+
name: '@voltro/plugin-audit',
|
|
774
|
+
permissions: ['store:write', 'migration:run', 'rpc:intercept:mutation'],
|
|
775
|
+
extendSchema: {
|
|
776
|
+
tables: [auditLogs],
|
|
777
|
+
migrations: [
|
|
778
|
+
{
|
|
779
|
+
id: '001-pgcrypto-extension',
|
|
780
|
+
description: 'enable pgcrypto for hashing actor ids',
|
|
781
|
+
up: (sql) => Effect.gen(function* () {
|
|
782
|
+
yield* sql`CREATE EXTENSION IF NOT EXISTS pgcrypto;`.pipe(Effect.orDie)
|
|
783
|
+
}),
|
|
784
|
+
},
|
|
785
|
+
],
|
|
786
|
+
},
|
|
787
|
+
})
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
`tables` requires `'store:write'`; `migrations` requires
|
|
791
|
+
`'migration:run'`. Either one missing → boot fails with the specific
|
|
792
|
+
permission name.
|
|
793
|
+
|
|
794
|
+
### `dashboard: PluginDashboardMount[]` — remote-mounted dashboard surfaces
|
|
795
|
+
|
|
796
|
+
Plugins surface UI in the cloud / devtools dashboard by declaring
|
|
797
|
+
remote-loaded ESM modules. The host runtime fetches the bundle URL at
|
|
798
|
+
mount time, dynamic-imports it, and renders the exported component
|
|
799
|
+
inline (NOT iframe — true in-process mount).
|
|
800
|
+
|
|
801
|
+
Trade-offs vs iframe:
|
|
802
|
+
- The plugin's bundle MUST be ESM with React/Effect as peerDeps; the
|
|
803
|
+
host pins versions. Mismatch logs a warning at mount time but still
|
|
804
|
+
mounts.
|
|
805
|
+
- No CSS isolation. Plugin authors are expected to use scoped Tailwind
|
|
806
|
+
classes (the dashboard ships the framework token set) or CSS modules.
|
|
807
|
+
- Plugin code shares the host JS realm — honor-system sandboxing. The
|
|
808
|
+
`dashboard:mount` permission gate keeps the operator audit trail
|
|
809
|
+
explicit; signing/marketplace verification is the longer-term answer.
|
|
810
|
+
|
|
811
|
+
```ts
|
|
812
|
+
definePlugin({
|
|
813
|
+
name: '@vendor/plugin-audit',
|
|
814
|
+
permissions: ['dashboard:mount'],
|
|
815
|
+
dashboard: [
|
|
816
|
+
{
|
|
817
|
+
id: 'overview',
|
|
818
|
+
slot: 'page', // 'page' | 'widget' | 'nav'
|
|
819
|
+
query: 'overview', // mounted at /plugins/audit/overview
|
|
820
|
+
label: 'Audit log',
|
|
821
|
+
icon: 'shield', // optional lucide-react icon name
|
|
822
|
+
bundleUrl: 'https://cdn.example.com/audit/v1/dashboard.mjs',
|
|
823
|
+
exportName: 'AuditOverview', // optional; defaults to 'default'
|
|
824
|
+
dashboardVersion: '^1.0.0', // optional compat range
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: 'recent-events',
|
|
828
|
+
slot: 'widget', // card on the dashboard home
|
|
829
|
+
label: 'Recent audit events',
|
|
830
|
+
bundleUrl: 'https://cdn.example.com/audit/v1/widget.mjs',
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
})
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
The registry surfaces at `/_voltro/inspect/plugins/dashboard-mounts`:
|
|
837
|
+
|
|
838
|
+
```bash
|
|
839
|
+
curl http://localhost:4000/_voltro/inspect/plugins/dashboard-mounts | jq
|
|
840
|
+
# { "mounts": [...], "capturedAt": 1717142400000 }
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Boot validates within-plugin id uniqueness AND that `slot: 'page'`
|
|
844
|
+
mounts carry a `query`. Cross-plugin ids are namespaced as
|
|
845
|
+
`<plugin-alias>:<mount.id>` (e.g. `audit:overview`).
|
|
846
|
+
|
|
847
|
+
The actual dashboard-host runtime — the React shell that dynamic-imports
|
|
848
|
+
+ mounts the component — lives in `voltro-cloud-dashboard` +
|
|
849
|
+
`voltro-devtools`. The framework owns the registration shape + manifest;
|
|
850
|
+
the consumers own the renderer.
|
|
851
|
+
|
|
852
|
+
### `bindDataStore(store, ctx)` — bind DB-backed resources + framework handles
|
|
853
|
+
|
|
854
|
+
`onActivate` runs BEFORE the app's `DataStore` exists (the framework
|
|
855
|
+
activates plugins, THEN opens the store). `bindDataStore` is the
|
|
856
|
+
post-store hook: the framework calls it once, after the store + pool are
|
|
857
|
+
open, so a plugin can bind a resource it couldn't build from static
|
|
858
|
+
config — e.g. swap an in-memory ref store for a `DataStore`-backed one.
|
|
859
|
+
|
|
860
|
+
The second argument, `ctx: PluginBindContext`, hands the plugin the
|
|
861
|
+
framework's ALREADY-OPEN handles so it never rebuilds them:
|
|
862
|
+
|
|
863
|
+
- **`ctx.sql`** — the framework's live `SqlClient` (from `@effect/sql`),
|
|
864
|
+
the SAME pool the app's store uses. Run raw SQL through it instead of
|
|
865
|
+
standing up your OWN `ManagedRuntime` + pool from env. `undefined` on
|
|
866
|
+
the in-memory store (no SQL engine) — guard with `if (ctx.sql)`.
|
|
867
|
+
- **`ctx.scheduleCoordinated(name, intervalMs, effect)`** — run a periodic
|
|
868
|
+
task on ONLY ONE replica per tick, cluster-coordinated via the same
|
|
869
|
+
claim-table exactly-once gate the cron scheduler uses. Replaces the
|
|
870
|
+
hand-rolled `setInterval` a plugin would otherwise run inside
|
|
871
|
+
`bindDataStore` — which fires on EVERY replica, so a 10-pod deployment
|
|
872
|
+
runs the same full-table sweep 10× per interval. On a single-process /
|
|
873
|
+
memory / sqlite deployment it simply runs every tick locally (correct —
|
|
874
|
+
one process needs no fan-out dedup). Returns a handle whose `stop()`
|
|
875
|
+
cancels the task; the framework also stops every armed task at shutdown.
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
import { definePlugin } from '@voltro/protocol'
|
|
879
|
+
|
|
880
|
+
definePlugin({
|
|
881
|
+
name: '@vendor/plugin-presence',
|
|
882
|
+
permissions: ['store:write'],
|
|
883
|
+
bindDataStore: (store, ctx) => {
|
|
884
|
+
// Reuse the framework's open pool — no connFromEnv, no second pool.
|
|
885
|
+
if (ctx?.sql) {
|
|
886
|
+
// ctx.sql is the app's live SqlClient (Effect-native).
|
|
887
|
+
}
|
|
888
|
+
// One coordinated sweep fleet-wide, not one setInterval per replica.
|
|
889
|
+
ctx?.scheduleCoordinated('presence.sweep', 60_000, async () => {
|
|
890
|
+
await store.deleteMany('_voltro_presence', {
|
|
891
|
+
where: { column: 'lastSeen', op: 'lt', value: Date.now() - 120_000 },
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
},
|
|
895
|
+
})
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
`store` is typed as the framework `DataStore`; `ctx` is optional in the
|
|
899
|
+
type (the framework always passes it) so an older single-argument
|
|
900
|
+
`bindDataStore(store)` still compiles. Requires no extra permission
|
|
901
|
+
beyond whatever the store operations themselves need (`store:write` for
|
|
902
|
+
writes).
|
|
903
|
+
|
|
904
|
+
## Inspecting plugins at runtime
|
|
905
|
+
|
|
906
|
+
Every running app exposes its plugin manifest at `/_voltro/inspect/plugins`:
|
|
907
|
+
|
|
908
|
+
```bash
|
|
909
|
+
curl http://localhost:4000/_voltro/inspect/plugins | jq
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
Returns one entry per plugin in `app.config.ts`'s `plugins:` array with:
|
|
913
|
+
- `name`, `version`, `description`, `framework` semver range
|
|
914
|
+
- `permissions: string[]` — declared scopes
|
|
915
|
+
- `hooks` — which interceptor + lifecycle hooks the plugin installed
|
|
916
|
+
- `queries` — `kind:name` tuples for every plugin-contributed query
|
|
917
|
+
- `services: boolean` — whether the plugin contributes a service layer
|
|
918
|
+
- `hasConfigSchema: boolean` — whether `configSchema` is declared
|
|
919
|
+
- `activated`, `installed` — runtime state
|
|
920
|
+
|
|
921
|
+
The DevTools + cloud dashboard's Plugins panel consumes this; AI agents
|
|
922
|
+
grep it to answer "what is this deployment running, with what permissions".
|
|
923
|
+
|
|
924
|
+
Per-plugin metrics surface under the SAME `/_voltro/inspect/metrics`
|
|
925
|
+
endpoint that carries `rpc.*` / `request.*` buckets — plugin buckets
|
|
926
|
+
have `kind: 'plugin'` and tag `plugin.<name>.intercept-<mutation|query|action>`.
|
|
927
|
+
Filter client-side by the `plugin.` prefix to render the per-plugin
|
|
928
|
+
latency table.
|
|
929
|
+
|
|
930
|
+
## Building integration plugins — `@voltro/integration-http`
|
|
931
|
+
|
|
932
|
+
A plugin that talks to a third-party REST API (Jira, GitHub, GitLab, …) needs
|
|
933
|
+
the same server-side transport concerns every time: token auth, transient
|
|
934
|
+
retry with backoff, per-request timeout, an SSRF host guard, and typed errors.
|
|
935
|
+
`@voltro/integration-http` is that transport core — one implementation shared
|
|
936
|
+
by `@voltro/plugin-atlassian` and any integration plugin you write, so you
|
|
937
|
+
don't re-invent it.
|
|
938
|
+
|
|
939
|
+
`makeHttpClient` binds a base URL, an auth strategy, a retry/timeout policy,
|
|
940
|
+
and a `fetch` impl once, then hands you typed request methods. Every method
|
|
941
|
+
returns `Effect.Effect<A, YourError>` — failures land on **your** error
|
|
942
|
+
channel via the `makeError` you supply, so each integration keeps its own
|
|
943
|
+
`Schema.TaggedError`:
|
|
944
|
+
|
|
945
|
+
```ts
|
|
946
|
+
import { makeHttpClient, type FetchLike } from '@voltro/integration-http'
|
|
947
|
+
import { Schema } from 'effect'
|
|
948
|
+
|
|
949
|
+
class GithubError extends Schema.TaggedError<GithubError>()('GithubError', {
|
|
950
|
+
message: Schema.String,
|
|
951
|
+
transient: Schema.Boolean,
|
|
952
|
+
status: Schema.optional(Schema.Number),
|
|
953
|
+
code: Schema.optional(Schema.Literal('session_expired')),
|
|
954
|
+
}) {}
|
|
955
|
+
|
|
956
|
+
const github = makeHttpClient<GithubError>({
|
|
957
|
+
baseUrl: 'https://api.github.com',
|
|
958
|
+
auth: () => ({
|
|
959
|
+
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
960
|
+
accept: 'application/vnd.github+json',
|
|
961
|
+
}),
|
|
962
|
+
makeError: (a) => new GithubError(a),
|
|
963
|
+
fetchImpl: fetch as unknown as FetchLike,
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
// Each method returns Effect.Effect<…, GithubError>.
|
|
967
|
+
const repo = yield* github.getJson('/repos/acme/widgets')
|
|
968
|
+
const created = yield* github.postJson('/repos/acme/widgets/issues', {
|
|
969
|
+
body: { title: 'bug', body: 'it broke' },
|
|
970
|
+
})
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
What the core does for you: retries `408/425/429/5xx` with capped exponential
|
|
974
|
+
backoff (jittered, honouring `Retry-After` within the `maxDelayMs` ceiling),
|
|
975
|
+
fails a hanging request as transient after the timeout, pins every request to
|
|
976
|
+
the base URL's host (an off-host or malformed URL is rejected **before** any
|
|
977
|
+
fetch, fail-closed), maps a `401` to a non-transient `session_expired`
|
|
978
|
+
(re-auth, never retried), and wraps each request in an
|
|
979
|
+
`integration-http.request` span (method + host only — never a token). When the
|
|
980
|
+
token is resolved per request (per-subject credentials), give the client a
|
|
981
|
+
context type and pass `ctx` on each call:
|
|
982
|
+
|
|
983
|
+
```ts
|
|
984
|
+
const client = makeHttpClient<GithubError, { token: string }>({
|
|
985
|
+
baseUrl: 'https://api.github.com',
|
|
986
|
+
auth: (ctx) => ({ authorization: `Bearer ${ctx.token}` }),
|
|
987
|
+
makeError: (a) => new GithubError(a),
|
|
988
|
+
fetchImpl: fetch as unknown as FetchLike,
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
const me = yield* client.getJson('/user', { ctx: { token: perSubjectToken } })
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
> **Server-side only.** This module performs outbound network I/O — never
|
|
995
|
+
> import it (or a module that imports it) from a browser-loaded query /
|
|
996
|
+
> mutation / action / workflow descriptor.
|
|
997
|
+
|
|
998
|
+
## Related
|
|
999
|
+
|
|
1000
|
+
- [Schema mixins](/docs/database/mixins) — the OTHER plugin shape (schema-only, no runtime hooks).
|
|
1001
|
+
- [Auth strategies](/docs/authentication/strategies) — a specialised plugin pattern for identity providers.
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
1007
|
+
<!-- source: en/plugins/analytics.md -->
|
|
1008
|
+
## Analytics & warehouse sinks
|
|
1009
|
+
|
|
1010
|
+
_AnalyticsSink contract — narrow cross-provider API for track / aggregate / timeseries / topN — plus the five first-party sink plugins (postgres-lite, DuckDB, ClickHouse, Tinybird, PostHog) and composeAnalytics for dual-write._
|
|
1011
|
+
|
|
1012
|
+
Voltro ships a narrow, cross-provider analytics contract (`AnalyticsSink`) and five first-party plugins that implement it. Mirror of the auth-strategy pattern: one typed interface, multiple providers, swap them in `app.config.ts` without touching handler code.
|
|
1013
|
+
|
|
1014
|
+
The intent is **"go very far before you reach for an external tool"** — but make external tools a one-line install when you do. The first-party lite plugin (`postgres-analytics`) works on day 1 with zero external services and carries you up to ~10M events/day before queries get slow.
|
|
1015
|
+
|
|
1016
|
+
## The contract — `AnalyticsSink`
|
|
1017
|
+
|
|
1018
|
+
Four methods. Effect-typed throughout. Every sink implements all four; sinks that genuinely can't support a capability return `AnalyticsCapabilityNotSupported` on the typed error channel rather than throwing.
|
|
1019
|
+
|
|
1020
|
+
```ts
|
|
1021
|
+
import { useAnalytics } from '@voltro/runtime'
|
|
1022
|
+
|
|
1023
|
+
export default (input, ctx) => Effect.gen(function* () {
|
|
1024
|
+
const analytics = yield* useAnalytics()
|
|
1025
|
+
|
|
1026
|
+
yield* analytics.track({
|
|
1027
|
+
name: 'match_completed',
|
|
1028
|
+
subjectId: ctx.request.subject.id,
|
|
1029
|
+
properties: { mapId: 'dust2', durationSec: 1284, mvp: 'player_abc' },
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
// Total over a window
|
|
1033
|
+
const total = yield* analytics.aggregate({
|
|
1034
|
+
event: 'match_completed',
|
|
1035
|
+
metric: 'count',
|
|
1036
|
+
range: { from: hoursAgo(24) },
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
// Time-bucketed series
|
|
1040
|
+
const daily = yield* analytics.timeseries({
|
|
1041
|
+
event: 'match_completed',
|
|
1042
|
+
metric: 'count',
|
|
1043
|
+
bucket: 'day',
|
|
1044
|
+
range: { from: daysAgo(30) },
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
// Top-N by a property
|
|
1048
|
+
const topMaps = yield* analytics.topN({
|
|
1049
|
+
event: 'match_completed',
|
|
1050
|
+
groupBy: 'mapId',
|
|
1051
|
+
metric: 'count',
|
|
1052
|
+
n: 10,
|
|
1053
|
+
range: { from: daysAgo(30) },
|
|
1054
|
+
})
|
|
1055
|
+
})
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
The contract is deliberately narrow. **No raw SQL, no funnels, no cohorts, no custom dashboards.** Anything provider-specific lives outside the contract — call the provider's API/client directly where you need it (the plugins expose no raw-client escape hatch).
|
|
1059
|
+
|
|
1060
|
+
## Plugins at a glance
|
|
1061
|
+
|
|
1062
|
+
| Plugin | Type | Best for | Ceiling |
|
|
1063
|
+
|---|---|---|---|
|
|
1064
|
+
| [`@voltro/plugin-analytics-postgres`](#voltroplugin-analytics-postgres) | Lite | Day-1 zero-setup, dev + early production | ~10M events/day |
|
|
1065
|
+
| [`@voltro/plugin-duckdb`](#voltroplugin-duckdb) | Embedded OLAP | Real column-store performance, no external service | Vertical scale: ~hundreds of GB in one process |
|
|
1066
|
+
| [`@voltro/plugin-clickhouse`](#voltroplugin-clickhouse) | External OLAP | Production-scale analytics, self-hosted or ClickHouse Cloud | Billions of events comfortably |
|
|
1067
|
+
| [`@voltro/plugin-tinybird`](#voltroplugin-tinybird) | Hosted ClickHouse | Pay-as-you-go without operating ClickHouse | Tinybird's own limits |
|
|
1068
|
+
| [`@voltro/plugin-posthog`](#voltroplugin-posthog) | Product analytics | Sessions, feature flags, funnels in PostHog's UI | `track()` only — compose with another sink for reads |
|
|
1069
|
+
|
|
1070
|
+
## Picking one
|
|
1071
|
+
|
|
1072
|
+
Decision rubric in order:
|
|
1073
|
+
|
|
1074
|
+
1. **First service you don't want to operate?** → `postgres-analytics`. Works against the main DataStore. Cross-dialect.
|
|
1075
|
+
2. **First-party experience but real OLAP performance?** → `duckdb`. Embedded sidecar, no external service to run.
|
|
1076
|
+
3. **Past 10M events/day OR want billions-of-rows queries to stay under a second?** → `clickhouse` (self-hosted or Cloud) or `tinybird` (hosted).
|
|
1077
|
+
4. **Already invested in PostHog for product analytics?** → `posthog` for ingest (alongside a primary sink for aggregates).
|
|
1078
|
+
|
|
1079
|
+
Switching providers later is a one-line config change. Handlers stay identical because they consume the same `AnalyticsSink` contract.
|
|
1080
|
+
|
|
1081
|
+
## `@voltro/plugin-analytics-postgres`
|
|
1082
|
+
|
|
1083
|
+
First-party lite. Stores events in `_voltro_events` on the main DataStore. Cross-dialect: works on postgres / mysql / mariadb / mssql / sqlite / turso via `sql.onDialectOrElse`.
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
// app.config.ts
|
|
1087
|
+
import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
|
|
1088
|
+
|
|
1089
|
+
export default defineApi({
|
|
1090
|
+
name: 'myApi',
|
|
1091
|
+
store: 'postgres',
|
|
1092
|
+
analytics: postgresAnalytics(),
|
|
1093
|
+
})
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
What the plugin owns:
|
|
1097
|
+
|
|
1098
|
+
- The `_voltro_events` table — auto-created on first boot (numeric `id`, `name`, `subject_id`, `properties` JSONB, `occurred_at`). Two composite indexes (`name+occurred_at`, `subject_id+occurred_at`) for the hot read paths.
|
|
1099
|
+
- All four methods: `track` writes a row; `aggregate` / `timeseries` / `topN` compile to `date_trunc` / JSON-extract SQL that runs across all six SQL dialects.
|
|
1100
|
+
|
|
1101
|
+
**Honest ceiling**: by ~10M events/day, time-range aggregates over 30 days take >5s on Postgres. The `_voltro_events` table is bounded by a retention sweep (`VOLTRO_EVENTS_TTL_HOURS`, default 365 days) so it never grows without limit; past ~10M events/day, swap to a real OLAP sink before queries get slow.
|
|
1102
|
+
|
|
1103
|
+
**Tenant-scoped reads**: `useAnalytics()` stamps the caller's `subject.tenantId` onto every `track` and every `aggregate` / `timeseries` / `topN`, so a handler can't write or read across tenants. A system/background context with no subject gets the raw (cross-tenant) sink; set an explicit `tenantId` on the event/query only for a deliberate cross-tenant op.
|
|
1104
|
+
|
|
1105
|
+
## `@voltro/plugin-duckdb`
|
|
1106
|
+
|
|
1107
|
+
Embedded DuckDB sidecar via `@duckdb/node-api`. Real column-store + vectorized execution, in-process — no external service.
|
|
1108
|
+
|
|
1109
|
+
```ts
|
|
1110
|
+
import { duckdbAnalytics } from '@voltro/plugin-duckdb'
|
|
1111
|
+
|
|
1112
|
+
analytics: duckdbAnalytics({
|
|
1113
|
+
path: '.voltro/analytics.duckdb', // or omit / ':memory:' for ephemeral
|
|
1114
|
+
}),
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
DuckDB is the quiet win for ~80% of growth-stage apps: real OLAP performance with zero external service to deploy. The plugin owns its own `voltro_events` table inside the DuckDB instance and queries against it.
|
|
1118
|
+
|
|
1119
|
+
Scope:
|
|
1120
|
+
|
|
1121
|
+
- **Events + opt-in CDC-mirror.** `track()` writes to DuckDB; queries read from DuckDB. Pass `mirrorTables` to stream the main DataStore's reactive-table changes into `voltro_mirror_<table>` tables inside DuckDB so analytical queries can JOIN events against live user data (see [CDC-mirror](#cdc-mirror-of-reactive-tables) below). Without `mirrorTables` the sink is events-only.
|
|
1122
|
+
- **Single-process.** DuckDB can't open the same file from multiple workers. For multi-instance deployments either pin analytics traffic to one replica or use `clickhouse` instead.
|
|
1123
|
+
|
|
1124
|
+
## `@voltro/plugin-clickhouse`
|
|
1125
|
+
|
|
1126
|
+
Production OLAP via the official `@clickhouse/client`. Self-hosted ClickHouse OR ClickHouse Cloud.
|
|
1127
|
+
|
|
1128
|
+
```ts
|
|
1129
|
+
import { clickhouseAnalytics } from '@voltro/plugin-clickhouse'
|
|
1130
|
+
|
|
1131
|
+
analytics: clickhouseAnalytics({
|
|
1132
|
+
url: process.env.CLICKHOUSE_URL!,
|
|
1133
|
+
database: 'voltro_events',
|
|
1134
|
+
username: process.env.CLICKHOUSE_USER,
|
|
1135
|
+
password: process.env.CLICKHOUSE_PASSWORD,
|
|
1136
|
+
}),
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
The plugin owns the events table schema (MergeTree engine, `ORDER BY (name, occurred_at, id)`, `LowCardinality(String)` on the event name, ZSTD-compressed properties JSON). First boot creates it; the plugin pings the cluster to fail-fast on bad config.
|
|
1140
|
+
|
|
1141
|
+
**Opt-in batching.** By default every `track()` is one immediate HTTP insert. Pass `batch: { maxSize?, flushIntervalMs? }` to buffer rows and flush them in ONE multi-row insert by size (default 1000), on a timer (default 5000 ms), and on shutdown (a graceful drain before the client closes). This trades per-event delivery confirmation for far fewer round-trips under load — with batching a successful `track()` means "buffered", and a later flush failure is logged + the batch dropped (best-effort), so leave `batch` unset when you need per-event delivery confirmation. Retention (a `TTL` on the events table) stays the operator's job.
|
|
1142
|
+
|
|
1143
|
+
There is no raw-client escape hatch: HyperLogLog, dictionaries, materialised views — the things you actually picked ClickHouse for — live outside the cross-provider contract, and the plugin exposes no handle to the raw `@clickhouse/client`. Where you need them, query ClickHouse with your own client instance against the same tables. Stay on `useAnalytics()` for code that should remain provider-portable.
|
|
1144
|
+
|
|
1145
|
+
## `@voltro/plugin-tinybird`
|
|
1146
|
+
|
|
1147
|
+
Hosted ClickHouse via Tinybird's Events API + Pipes.
|
|
1148
|
+
|
|
1149
|
+
```ts
|
|
1150
|
+
import { tinybirdAnalytics } from '@voltro/plugin-tinybird'
|
|
1151
|
+
|
|
1152
|
+
analytics: tinybirdAnalytics({
|
|
1153
|
+
token: process.env.TINYBIRD_TOKEN!,
|
|
1154
|
+
region: 'eu', // 'eu' | 'us-east' | 'us-west' | 'asia-southeast'
|
|
1155
|
+
datasource: 'voltro_events',
|
|
1156
|
+
// pipes: { aggregate: 'my_agg', timeseries: 'my_ts', topN: 'my_top' },
|
|
1157
|
+
}),
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
**The plugin expects three canonical pipes to exist in your workspace:**
|
|
1161
|
+
|
|
1162
|
+
- `events_aggregate` — receives `event`, `from`, `to`, `metric_expr`, `filter_sql`
|
|
1163
|
+
- `events_timeseries` — same + `bucket_fn`
|
|
1164
|
+
- `events_topn` — same + `group_expr`, `limit`
|
|
1165
|
+
|
|
1166
|
+
We don't synthesize pipes from the framework — Tinybird's `.pipe` DSL is too rich to generate from a generic spec. Push the three canonical pipes once via the `tb` CLI; override names with `pipes: { ... }` if your team uses a different convention.
|
|
1167
|
+
|
|
1168
|
+
## `@voltro/plugin-posthog`
|
|
1169
|
+
|
|
1170
|
+
PostHog forwards `track()` only. Funnels / cohorts / sessions / feature flags live in PostHog's own UI and SQL — the plugin's `aggregate` / `timeseries` / `topN` return `AnalyticsCapabilityNotSupported` so callers fall back cleanly.
|
|
1171
|
+
|
|
1172
|
+
```ts
|
|
1173
|
+
import { posthogAnalytics } from '@voltro/plugin-posthog'
|
|
1174
|
+
|
|
1175
|
+
analytics: posthogAnalytics({
|
|
1176
|
+
apiKey: process.env.POSTHOG_KEY!,
|
|
1177
|
+
host: 'https://eu.posthog.com', // optional, default app.posthog.com
|
|
1178
|
+
}),
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
Typical wiring is **compose** with a primary sink that handles reads (see below) — PostHog mirrors every event for product-analytics insights while your primary sink owns the typed aggregate queries.
|
|
1182
|
+
|
|
1183
|
+
## composeAnalytics — multi-sink
|
|
1184
|
+
|
|
1185
|
+
For dual-write (e.g. postgres-lite for typed reads + PostHog for product analytics):
|
|
1186
|
+
|
|
1187
|
+
```ts
|
|
1188
|
+
import { composeAnalytics } from '@voltro/runtime'
|
|
1189
|
+
import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'
|
|
1190
|
+
import { posthogAnalytics } from '@voltro/plugin-posthog'
|
|
1191
|
+
|
|
1192
|
+
export default defineApi({
|
|
1193
|
+
name: 'myApi',
|
|
1194
|
+
store: 'postgres',
|
|
1195
|
+
analytics: composeAnalytics([
|
|
1196
|
+
postgresAnalytics(), // primary — handles reads
|
|
1197
|
+
posthogAnalytics({ apiKey: process.env.POSTHOG_KEY! }), // mirrors every track()
|
|
1198
|
+
]),
|
|
1199
|
+
})
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
Semantics:
|
|
1203
|
+
|
|
1204
|
+
- **`track()`** fans out to every sink in parallel. Failures are isolated per sink — one provider returning HTTP 503 doesn't fail the postgres insert.
|
|
1205
|
+
- **`aggregate` / `timeseries` / `topN`** query to the **first sink that supports the op**. PostHog doesn't support these → composition queries to the next sink (postgres-lite).
|
|
1206
|
+
- If no sink supports a read op, the composite returns `AnalyticsCapabilityNotSupported({ provider: 'compose' })`.
|
|
1207
|
+
|
|
1208
|
+
## Capability-not-supported error handling
|
|
1209
|
+
|
|
1210
|
+
The cross-provider contract intentionally surfaces capability gaps on the typed error channel. Catch them when you want graceful fallback:
|
|
1211
|
+
|
|
1212
|
+
```ts
|
|
1213
|
+
import { Effect } from 'effect'
|
|
1214
|
+
|
|
1215
|
+
const result = yield* analytics.aggregate({
|
|
1216
|
+
event: 'match_completed',
|
|
1217
|
+
metric: 'count',
|
|
1218
|
+
range: { from: daysAgo(7) },
|
|
1219
|
+
}).pipe(
|
|
1220
|
+
Effect.catchTag('AnalyticsCapabilityNotSupported', () => Effect.succeed(0)),
|
|
1221
|
+
Effect.catchTag('AnalyticsError', (err) => Effect.gen(function* () {
|
|
1222
|
+
yield* Effect.logWarning('analytics aggregate failed', { provider: err.provider, cause: err.cause })
|
|
1223
|
+
return 0
|
|
1224
|
+
})),
|
|
1225
|
+
)
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
The two typed errors:
|
|
1229
|
+
|
|
1230
|
+
- **`AnalyticsCapabilityNotSupported`** — the sink doesn't implement the operation (e.g. PostHog returning this for `aggregate`).
|
|
1231
|
+
- **`AnalyticsError`** — the underlying provider raised something (HTTP failure, query syntax, connection lost).
|
|
1232
|
+
|
|
1233
|
+
## CDC-mirror of reactive tables
|
|
1234
|
+
|
|
1235
|
+
By default a sink stores only the events you `track()` — the main DataStore's reactive tables aren't in the warehouse, so analytical queries can't JOIN events against user data. Opt in with `mirrorTables`: the framework subscribes to the store's change stream and upserts/deletes the changed rows into a corresponding warehouse table, idempotently (keyed on the primary key).
|
|
1236
|
+
|
|
1237
|
+
```ts
|
|
1238
|
+
import { duckdbAnalytics } from '@voltro/plugin-duckdb'
|
|
1239
|
+
|
|
1240
|
+
analytics: duckdbAnalytics({
|
|
1241
|
+
path: '.voltro/analytics.duckdb',
|
|
1242
|
+
mirrorTables: ['users', 'teams'], // reactive tables to mirror
|
|
1243
|
+
// mirrorPrimaryKey: 'id', // default 'id'
|
|
1244
|
+
}),
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
The postgres-lite (`postgresAnalytics({ mirrorTables: [...] })`) and ClickHouse (`clickhouseAnalytics({ url, mirrorTables: [...] })`) sinks take the same options. Each mirrored table lands as `_voltro_mirror_<table>` (`voltro_mirror_<table>` on DuckDB / ClickHouse) holding `{ id, data }` — `id` is the source row's primary key, `data` is the full row as JSON. Analytical queries JOIN events against the mirror:
|
|
1248
|
+
|
|
1249
|
+
```sql
|
|
1250
|
+
-- DuckDB: events per user tier
|
|
1251
|
+
SELECT json_extract_string(m.data, '$.tier'), COUNT(*)
|
|
1252
|
+
FROM voltro_events e
|
|
1253
|
+
JOIN voltro_mirror_users m ON m.id = e.subject_id
|
|
1254
|
+
GROUP BY 1
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
The mirror is **opt-in** (omit `mirrorTables` → events-only) and **idempotent** — inserts/updates upsert by primary key, deletes remove by key, so a re-delivered change (e.g. after a reconnect) is a no-op-equivalent overwrite. Per-change failures are isolated into the log channel: the OLTP write that produced the change already committed, so a warehouse hiccup never surfaces to the request. On DuckDB the mirror table is a plain `(id, data)` table; on ClickHouse it's a `ReplacingMergeTree(version)` so re-inserts collapse to the latest version on merge (deletes write a `is_deleted = 1` tombstone — filter `is_deleted = 0` or use `FINAL`).
|
|
1258
|
+
|
|
1259
|
+
## Default — no sink configured
|
|
1260
|
+
|
|
1261
|
+
If `app.config.ts` doesn't set `analytics`, the framework provides a no-op sink. `track()` calls drop silently (logged once at boot so operators notice); `aggregate` / `timeseries` / `topN` fail with `AnalyticsCapabilityNotSupported({ provider: 'noop' })`. Apps without analytics setup never crash on `useAnalytics().track(...)`.
|
|
1262
|
+
|
|
1263
|
+
## Anti-patterns
|
|
1264
|
+
|
|
1265
|
+
- **Don't bypass `useAnalytics()` and call provider clients directly** unless you specifically need a provider-locked feature. Going through the contract keeps handler code provider-portable.
|
|
1266
|
+
- **Don't compose two reading-capable sinks expecting both to be queried.** Reads query to the first capable sink — that's by design (different sinks may hold different views of the data). If you genuinely need cross-sink reads, query each provider directly with its own client.
|
|
1267
|
+
- **Don't dump millions of properties per event.** Sink storage is JSON; cardinality at the property level slows JSON-extract queries. If you have unbounded dimensions, restructure into a separate events table that the analytical query reads through joins.
|
|
1268
|
+
- **Don't expect aggregate queries to reflect new tracks instantly on every sink.** By default ClickHouse and Tinybird ingest each `track()` as an immediate HTTP request, but the providers themselves make rows visible asynchronously (Tinybird's Events API acknowledges before the row is queryable). Two sinks add an opt-in client-side `batch` option (`clickhouseAnalytics({ batch })`, `posthogAnalytics({ batch })`) that buffers events and flushes them in one request by size / on a timer / on shutdown — a successful `track()` then means "buffered", not "delivered". Postgres-lite reflects writes immediately because they're synchronous to the main DB.
|
|
1269
|
+
|
|
1270
|
+
## Related
|
|
1271
|
+
|
|
1272
|
+
- The cross-cutting [aggregate convention](/docs/data/aggregates) covers `*.aggregate.ts` files — pre-defined queries that materialise their result on a schedule. An aggregate's build function gets `ctx.analytics` (the configured sink) alongside `ctx.store`, so it can query the warehouse (`ctx.analytics.topN(...)`) for the "reduce 10B-row warehouse to a 100-row leaderboard" pattern. See the [cross-plan section](/docs/data/aggregates#cross-plan-querying-the-warehouse-from-a-build-function).
|
|
1273
|
+
- [`*.subscribe.ts`](/docs/data/subscribers) covers best-effort per-row reactive callbacks that fire after every commit. Different shape from `track()` (subscribe fires from CDC; track is explicit ingest).
|