@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,1622 @@
|
|
|
1
|
+
# database.querying
|
|
2
|
+
|
|
3
|
+
> Three cardinalities, one DSL. Declaration via relations(), eager loading via .with(), reactive invalidation via the two-stage dependency-graph + per-field pre-filter.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/database/query-builder.md -->
|
|
10
|
+
## Query builder
|
|
11
|
+
|
|
12
|
+
_The full ctx.store API — select, where, orderBy, limit, aggregates, raw SQL escape hatches._
|
|
13
|
+
|
|
14
|
+
`ctx.store` is the framework's typed data layer. It's available on every executor's `AppContext`. Reads are dependency-tracked for subscriptions; writes fire CDC events that invalidate subscribers.
|
|
15
|
+
|
|
16
|
+
This page covers selects + filters + aggregates. For relations see [Joins](/docs/database/joins); for writes see [Transactions](/docs/database/transactions).
|
|
17
|
+
|
|
18
|
+
## Select
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
ctx.store.select('notes') // SELECT * FROM notes
|
|
22
|
+
.where('tenantId', tenantId) // WHERE tenantId = $1
|
|
23
|
+
.orderBy('createdAt', 'desc')
|
|
24
|
+
.limit(20)
|
|
25
|
+
.all() // → ReadonlyArray<Note>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Terminal operations:
|
|
29
|
+
|
|
30
|
+
| Method | Returns | When to use |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `.all()` | `ReadonlyArray<T>` | Multi-row result. |
|
|
33
|
+
| `.one()` | `T` (throws if missing) | "I know this exists" — primary-key lookups. |
|
|
34
|
+
| `.maybeOne()` | `T \| null` | Lookup that may fail (login by email). |
|
|
35
|
+
| `.first()` | `T \| null` | First row; equivalent to `.limit(1).maybeOne()`. |
|
|
36
|
+
| `.count()` | `number` | Counts. |
|
|
37
|
+
| `.exists()` | `boolean` | EXISTS check; cheap. |
|
|
38
|
+
|
|
39
|
+
Column projection — pick only the fields you need:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
ctx.store.select('notes').select('id', 'title') // SELECT id, title
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The return type narrows automatically — `{ id: string; title: string }[]`.
|
|
46
|
+
|
|
47
|
+
## `where`
|
|
48
|
+
|
|
49
|
+
Filters chain (AND-merged):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
ctx.store.select('notes')
|
|
53
|
+
.where('authorId', myId)
|
|
54
|
+
.where('createdAt', '>', cutoff)
|
|
55
|
+
.where('archived', false)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Operators
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
.where('col', value) // = (default)
|
|
62
|
+
.where('col', '=', value)
|
|
63
|
+
.where('col', '!=', value) // also '<>'
|
|
64
|
+
.where('col', '<', value)
|
|
65
|
+
.where('col', '<=', value)
|
|
66
|
+
.where('col', '>', value)
|
|
67
|
+
.where('col', '>=', value)
|
|
68
|
+
.where('col', 'in', [a, b, c])
|
|
69
|
+
.where('col', 'like', 'abc%')
|
|
70
|
+
.where('col', 'contains', 'needle') // case-insensitive substring (ILIKE '%…%')
|
|
71
|
+
.where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { isNull, isNotNull, notInSet } from '@voltro/database'
|
|
78
|
+
|
|
79
|
+
ctx.store.select('notes').where(isNull('deletedAt'))
|
|
80
|
+
ctx.store.select('notes').where(isNotNull('publishedAt'))
|
|
81
|
+
ctx.store.select('notes').where(notInSet('status', ['archived', 'spam']))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For index-backed full-text search use the `.matching('indexName', 'query')` builder (see [Full-text search](/docs/database/full-text-search)); the `'fts'` operator above is a plain substring fallback.
|
|
85
|
+
|
|
86
|
+
### OR / NOT
|
|
87
|
+
|
|
88
|
+
The predicate helpers compose into the single-argument `.where(predicate)` form:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { or, not, and, eq, contains } from '@voltro/database'
|
|
92
|
+
|
|
93
|
+
ctx.store.select('notes').where(or(
|
|
94
|
+
eq('authorId', myId),
|
|
95
|
+
contains('sharedWith', myId),
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
ctx.store.select('notes').where(not(eq('archived', true)))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`and(...)` is rarely needed because chained `.where()` calls are already AND'd; useful inside `or(...)` to nest.
|
|
102
|
+
|
|
103
|
+
### JSON path filters
|
|
104
|
+
|
|
105
|
+
For `json<T>()` columns:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
ctx.store.select('notes')
|
|
109
|
+
.where('prefs.fontSize', 'md') // prefs->>'fontSize' = 'md'
|
|
110
|
+
.where('prefs.collapsed', 'contains', 'inbox') // prefs->'collapsed' @> '["inbox"]'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See [JSON columns](/docs/database/json) for indexing + path semantics.
|
|
114
|
+
|
|
115
|
+
## `orderBy`
|
|
116
|
+
|
|
117
|
+
Each call adds ONE column + direction; chain for multi-column ordering:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
.orderBy('createdAt', 'desc')
|
|
121
|
+
.orderBy('priority', 'desc').orderBy('createdAt', 'asc')
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## `limit` / `offset`
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
.limit(20)
|
|
128
|
+
.limit(20).offset(40)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For cursor pagination that avoids OFFSET's O(n) scan, use the standalone `paginateById` helper over `ctx.store.query(...)`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { paginateById } from '@voltro/database'
|
|
135
|
+
|
|
136
|
+
const rows = await ctx.store.query(
|
|
137
|
+
paginateById(database.notes.orderBy('createdAt', 'desc').descriptor, req.cursor, 20),
|
|
138
|
+
)
|
|
139
|
+
const nextCursor = rows.at(-1)?.id ?? null
|
|
140
|
+
return { rows, nextCursor }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`paginateById(descriptor, cursor, limit)` adds a `WHERE id > cursor ORDER BY id LIMIT n` clause and preserves any existing predicate. Works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric).
|
|
144
|
+
|
|
145
|
+
## Aggregates
|
|
146
|
+
|
|
147
|
+
The aggregate terminals live on the `database.<table>` builder, run via `ctx.store.query(...)`:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { count, sum, max, eq } from '@voltro/database'
|
|
151
|
+
|
|
152
|
+
// COUNT(*) — one row, { count: number }
|
|
153
|
+
const rows = await ctx.store.query(
|
|
154
|
+
database.notes.where(eq('archived', false)).count().descriptor,
|
|
155
|
+
)
|
|
156
|
+
const open = rows[0]!.count
|
|
157
|
+
|
|
158
|
+
// Bundle multiple aggregates into one row
|
|
159
|
+
await ctx.store.query(
|
|
160
|
+
database.orders.where(eq('orgId', oid)).aggregate({
|
|
161
|
+
total: sum('amount'),
|
|
162
|
+
peak: max('createdAt'),
|
|
163
|
+
cnt: count(),
|
|
164
|
+
}).descriptor,
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Helpers: `count()`, `countDistinct(col)`, `sum(col)`, `avg(col)`, `min(col)`, `max(col)`. See [Aggregations](/docs/database/aggregations) for `.groupBy()` / `.having()` + window functions.
|
|
169
|
+
|
|
170
|
+
## Distinct
|
|
171
|
+
|
|
172
|
+
`distinct` lives on the `database.<table>` builder, run via `ctx.store.query(...)`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await ctx.store.query(database.notes.select('authorId').distinct().descriptor)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
For `DISTINCT ON` and one-row-per-group, see [DISTINCT + DISTINCT ON](/docs/database/distinct).
|
|
179
|
+
|
|
180
|
+
## Raw SQL escape hatch
|
|
181
|
+
|
|
182
|
+
When the DSL doesn't model what you need:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { sql } from '@voltro/database/sql'
|
|
186
|
+
|
|
187
|
+
const rows = await ctx.store.raw!<{ id: string; n: number }>(sql`
|
|
188
|
+
SELECT id, count(*) AS n
|
|
189
|
+
FROM events
|
|
190
|
+
WHERE occurred_at > ${cutoff}
|
|
191
|
+
GROUP BY id
|
|
192
|
+
ORDER BY n DESC
|
|
193
|
+
LIMIT 10
|
|
194
|
+
`)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The `sql` tag is imported from the server-only `@voltro/database/sql` subpath — it never reaches the browser bundle. It parameterises values automatically: every `${value}` interpolation is bound as a parameter by the store's dialect-specific driver, never spliced into the SQL text. A value containing `'; DROP TABLE` round-trips as data, not SQL. The generic parameter (`<{ id: string; n: number }>`) names the row shape.
|
|
198
|
+
|
|
199
|
+
`store.raw` is an **optional** method on the store — it exists on every SQL store (postgres / mysql / mariadb / mssql / sqlite / turso) but NOT on the in-memory store (there's no SQL engine to run raw text against). The `!` non-null assertion above is appropriate on SQL-backed apps; for code that must run on the memory store too, guard with `if (ctx.store.raw)`.
|
|
200
|
+
|
|
201
|
+
**You own dialect-portability of the static text.** Only the interpolated values are auto-parameterised — the rest of the fragment is emitted verbatim. Postgres-only syntax (`->>`, `plainto_tsquery`) breaks on mysql. Keep the static SQL portable, or branch on the dialect.
|
|
202
|
+
|
|
203
|
+
**Raw queries aren't tracked by the reactive engine** — the planner can't infer which tables an arbitrary SQL string touches. If you want a subscription to invalidate on a raw read's tables, declare them explicitly:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
ctx.store.raw!<{ … }>(sql`…`, { dependsOn: ['events'] })
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Tenant scoping (implicit)
|
|
210
|
+
|
|
211
|
+
If the table has the `tenant()` mixin, every `select` auto-merges `WHERE tenantId = ctx.subject.tenantId`. You don't write it; the runtime injects it. To opt out (admin queries crossing tenants), use `.unscoped()`:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
ctx.store.select('notes').unscoped().all()
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`.unscoped()` drops the automatic tenant filter for cross-tenant staff reads — gate it yourself in the handler (e.g. `requireScope(ctx.subject, 'admin:full')`) before exposing it. Soft-delete reads have the parallel `.withDeleted()` opt-out.
|
|
218
|
+
|
|
219
|
+
## Where to find more
|
|
220
|
+
|
|
221
|
+
The query builder has dedicated pages for the deeper topics:
|
|
222
|
+
|
|
223
|
+
- **[Aggregations](/docs/database/aggregations)** — `.count()` /
|
|
224
|
+
`.aggregate({...})` / `.groupBy()` / `.having()` + window functions
|
|
225
|
+
(`rowNumber` / `rank` / `lag` / `lead` / `sumOver`).
|
|
226
|
+
- **[Sub-queries](/docs/database/sub-queries)** — `inSubquery` /
|
|
227
|
+
`notInSubquery` / `exists` / `notExists` predicates that reference
|
|
228
|
+
other queries.
|
|
229
|
+
- **[Set operations](/docs/database/set-operations)** — `union` /
|
|
230
|
+
`unionAll` / `intersect` / `except` combine multiple queries with
|
|
231
|
+
the same column shape.
|
|
232
|
+
- **[DISTINCT + DISTINCT ON](/docs/database/distinct)** — dedup row
|
|
233
|
+
sets, pick one row per group on postgres.
|
|
234
|
+
- **[Self-joins](/docs/database/self-joins)** — `.as(alias)` +
|
|
235
|
+
`.innerJoin(table, alias, on)` + `.selectJoined({...})` for parent/
|
|
236
|
+
child trees and CTE references.
|
|
237
|
+
- **[CTEs](/docs/database/query-builder#ctes)** — `.withCte(name, sub)`
|
|
238
|
+
for named sub-queries reusable inside the outer SELECT.
|
|
239
|
+
- **[Recursive CTEs](/docs/database/recursive-cte)** — `.recursiveCte`
|
|
240
|
+
for tree walks (org hierarchy, comment threads, file folders).
|
|
241
|
+
- **[Bulk writes](/docs/database/bulk-operations)** — `.updateMany` /
|
|
242
|
+
`.upsert` / `.insertIgnore` for one-statement bulk operations.
|
|
243
|
+
|
|
244
|
+
## CTEs (Common Table Expressions)
|
|
245
|
+
|
|
246
|
+
For complex queries with reusable sub-queries, declare named CTEs:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { eq, notInSubquery, queryFor } from '@voltro/database'
|
|
250
|
+
|
|
251
|
+
const blocked = queryFor(database.blockedUsers).select('userId').descriptor
|
|
252
|
+
|
|
253
|
+
const visibleUsers = await ctx.store.query(
|
|
254
|
+
queryFor(database.users)
|
|
255
|
+
.withCte('blocked', blocked)
|
|
256
|
+
.where(notInSubquery('id', { table: 'blocked', projection: ['userId'], /* ... */ }))
|
|
257
|
+
.descriptor,
|
|
258
|
+
)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Emits `WITH blocked AS (SELECT "userId" FROM "blockedUsers") SELECT ...`.
|
|
262
|
+
|
|
263
|
+
For recursive CTEs (`WITH RECURSIVE`) see the
|
|
264
|
+
[Recursive CTE page](/docs/database/recursive-cte).
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
<!-- source: en/database/joins.md -->
|
|
271
|
+
## Joins & relations
|
|
272
|
+
|
|
273
|
+
_Eager-loading related rows via relations + .with(), and explicit joins via queryFor().innerJoin(). How the reactive engine tracks them._
|
|
274
|
+
|
|
275
|
+
Voltro has two ways to read across tables:
|
|
276
|
+
|
|
277
|
+
1. **Relations + `.with(spec)`** — the ergonomic path for "fetch X with
|
|
278
|
+
its Y". Declare relations once in a `*.relations.ts` file; eager-load
|
|
279
|
+
them with `.with({ ... })`. Result comes back as nested objects.
|
|
280
|
+
2. **Explicit joins** — `queryFor(table).as(alias).innerJoin(Table, alias, on)`
|
|
281
|
+
for self-joins, CTE references, and flat aliased projections. Covered
|
|
282
|
+
in depth on the [Self-joins](/docs/database/self-joins) page.
|
|
283
|
+
|
|
284
|
+
Foreign keys are declared with `reference(() => table)` — singular,
|
|
285
|
+
thunk-arg.
|
|
286
|
+
|
|
287
|
+
## Declaring relations
|
|
288
|
+
|
|
289
|
+
Relations live OUTSIDE the table descriptor, in a `*.relations.ts` file
|
|
290
|
+
(convention). The framework registers them at boot and `.with()` uses
|
|
291
|
+
them to eager-load.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// database/users.relations.ts
|
|
295
|
+
import { relations } from '@voltro/database'
|
|
296
|
+
import { users, profiles, orgs, orgMemberships } from './index'
|
|
297
|
+
|
|
298
|
+
export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
|
|
299
|
+
profile: one(profiles), // 1:1
|
|
300
|
+
ownedOrgs: many(orgs, { foreignKey: 'ownerId' }), // 1:N
|
|
301
|
+
organizations: manyToMany(orgs, { // N:M
|
|
302
|
+
through: orgMemberships,
|
|
303
|
+
sourceKey: 'userId',
|
|
304
|
+
targetKey: 'orgId',
|
|
305
|
+
}),
|
|
306
|
+
}))
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`foreignKey` auto-derives when exactly one `reference()` column on the
|
|
310
|
+
target points back at the source — set it explicitly only for tables
|
|
311
|
+
with multiple FKs into the same parent (`createdBy` + `updatedBy` →
|
|
312
|
+
`actors`). Many-to-many always uses an explicit through-table you write
|
|
313
|
+
yourself.
|
|
314
|
+
|
|
315
|
+
## Eager-loading via `.with(spec)`
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
const rows = await ctx.store.query(
|
|
319
|
+
database.users
|
|
320
|
+
.where(eq('tenantId', tenantId))
|
|
321
|
+
.with({
|
|
322
|
+
profile: true, // 1:1 → object | null
|
|
323
|
+
ownedOrgs: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
|
|
324
|
+
organizations: true, // N:M via the through-table
|
|
325
|
+
})
|
|
326
|
+
.descriptor,
|
|
327
|
+
)
|
|
328
|
+
// rows[0].profile → Profile | null
|
|
329
|
+
// rows[0].ownedOrgs → Org[]
|
|
330
|
+
// rows[0].organizations → Org[]
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Each branch takes its own `where` / `orderBy` / `limit` / `offset` /
|
|
334
|
+
nested `with`. `limit: 5` applies **per parent** (up to 5 orgs FOR EACH
|
|
335
|
+
user) — same semantics as Drizzle / Prisma / Hibernate. The framework
|
|
336
|
+
renders the whole tree as ONE SQL roundtrip via the dialect's
|
|
337
|
+
JSON-aggregation idiom (no N+1).
|
|
338
|
+
|
|
339
|
+
Nested `.with()` threads arbitrary depth:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
database.users.with({ posts: { with: { author: true } } })
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Explicit joins — `queryFor().innerJoin()`
|
|
346
|
+
|
|
347
|
+
When eager-load doesn't fit — self-joins, joining a CTE, or a flat
|
|
348
|
+
aliased result shape — use the explicit join builder. The full reference
|
|
349
|
+
is on the [Self-joins](/docs/database/self-joins) page; the shape:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
import { eq, queryFor } from '@voltro/database'
|
|
353
|
+
|
|
354
|
+
const rows = await ctx.store.query(
|
|
355
|
+
queryFor(database.messages).as('m')
|
|
356
|
+
.innerJoin(database.users, 'u', eq('u.id', 'm.authorId'))
|
|
357
|
+
.selectJoined({
|
|
358
|
+
messageId: 'm.id',
|
|
359
|
+
body: 'm.body',
|
|
360
|
+
authorName: 'u.name',
|
|
361
|
+
})
|
|
362
|
+
.descriptor,
|
|
363
|
+
)
|
|
364
|
+
// rows: Array<{ messageId: string; body: string; authorName: string }>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`.leftJoin(Table, alias, on)` has the same shape but keeps FROM-side rows
|
|
368
|
+
that have no JOIN-side match (joined columns are `null` for those).
|
|
369
|
+
Passing a `Table` descriptor threads its row type into `.selectJoined`
|
|
370
|
+
for full inference; passing a string name (CTE / dynamic) falls through
|
|
371
|
+
to `unknown`.
|
|
372
|
+
|
|
373
|
+
## Reactive tracking
|
|
374
|
+
|
|
375
|
+
Eager-load (`.with()`) subscriptions track changes to the root table and
|
|
376
|
+
every relation the spec touches — the dispatcher consults a per-table
|
|
377
|
+
dependency map plus a per-field relevance pre-filter, so a write that
|
|
378
|
+
doesn't touch a depended-on column skips the re-query entirely.
|
|
379
|
+
|
|
380
|
+
For high-traffic joins where you only want to re-fire on the primary
|
|
381
|
+
table, project away the joined columns or denormalise into a generated
|
|
382
|
+
column.
|
|
383
|
+
|
|
384
|
+
## Anti-patterns
|
|
385
|
+
|
|
386
|
+
- **Joining inside a loop.** Always express the read in the builder —
|
|
387
|
+
`.with({ ... })` does the JSON aggregate; the explicit join builder
|
|
388
|
+
does the SQL JOIN.
|
|
389
|
+
- **`reference('table')`.** The FK constructor is `reference(() => table)`
|
|
390
|
+
— singular, thunk-arg.
|
|
391
|
+
- **Using `.with()` for write paths.** Reads only; for writes use
|
|
392
|
+
[Transactions](/docs/database/transactions).
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
<!-- source: en/database/relations/index.md -->
|
|
399
|
+
## Relations
|
|
400
|
+
|
|
401
|
+
_Three cardinalities, one DSL. Declaration via relations(), eager loading via .with(), reactive invalidation via the two-stage dependency-graph + per-field pre-filter._
|
|
402
|
+
|
|
403
|
+
Voltro models table relationships explicitly via a `relations()` declaration that lives outside the table descriptor. The query builder's `.with(...)` chain reads this declaration to eager-load related rows in a single SQL round-trip, regardless of how many relations you traverse.
|
|
404
|
+
|
|
405
|
+
This index covers the cross-cutting bits. Each cardinality + the cross-cutting concerns has its own page below:
|
|
406
|
+
|
|
407
|
+
- [one()](./one-to-one) — 1:1 or N:1. Single related row per parent.
|
|
408
|
+
- [many()](./one-to-many) — 1:N. Array of related rows per parent.
|
|
409
|
+
- [manyToMany()](./many-to-many) — N:M through an explicit junction table.
|
|
410
|
+
- [Eager loading with `.with()`](./eager-loading) — single-roundtrip nested JSON.
|
|
411
|
+
- [Cascade + FK semantics](./cascade) — `onDelete: 'restrict'` defaults + FK-auto-index.
|
|
412
|
+
- [Reactive invalidation](./reactive) — two-stage gate: a per-table dependency-graph plus a per-field pre-filter.
|
|
413
|
+
|
|
414
|
+
## Declaration
|
|
415
|
+
|
|
416
|
+
Relations live in `*.relations.ts` files alongside the schema. They're separate from the table descriptor so a table can be referenced from multiple sides without a circular import.
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
// database/users.relations.ts
|
|
420
|
+
import { relations } from '@voltro/database'
|
|
421
|
+
import { users, profiles, posts, orgs, orgMemberships } from './index'
|
|
422
|
+
|
|
423
|
+
export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
|
|
424
|
+
profile: one(profiles), // 1:1
|
|
425
|
+
posts: many(posts, { foreignKey: 'authorId' }), // 1:N
|
|
426
|
+
organizations: manyToMany(orgs, { // N:M
|
|
427
|
+
through: orgMemberships,
|
|
428
|
+
sourceKey: 'userId',
|
|
429
|
+
targetKey: 'orgId',
|
|
430
|
+
}),
|
|
431
|
+
}))
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
The framework auto-registers every `relations(...)` call at boot — you don't write a manual barrel.
|
|
435
|
+
|
|
436
|
+
## Quick start — all three cardinalities
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
// Schema
|
|
440
|
+
export const users = table('users', { id: id(), email: text() })
|
|
441
|
+
export const profiles = table('profiles', {
|
|
442
|
+
id: id(), userId: reference(() => users), bio: text(),
|
|
443
|
+
})
|
|
444
|
+
export const posts = table('posts', {
|
|
445
|
+
id: id(), authorId: reference(() => users), title: text(),
|
|
446
|
+
})
|
|
447
|
+
export const orgs = table('orgs', { id: id(), name: text() })
|
|
448
|
+
export const orgMemberships = table('org_memberships', {
|
|
449
|
+
id: id(),
|
|
450
|
+
userId: reference(() => users),
|
|
451
|
+
orgId: reference(() => orgs),
|
|
452
|
+
role: text(),
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// Relations
|
|
456
|
+
relations(users, ({ one, many, manyToMany }) => ({
|
|
457
|
+
profile: one(profiles),
|
|
458
|
+
posts: many(posts),
|
|
459
|
+
organizations: manyToMany(orgs, {
|
|
460
|
+
through: orgMemberships,
|
|
461
|
+
sourceKey: 'userId',
|
|
462
|
+
targetKey: 'orgId',
|
|
463
|
+
}),
|
|
464
|
+
}))
|
|
465
|
+
|
|
466
|
+
// Eager-load query
|
|
467
|
+
const rows = await store.query(
|
|
468
|
+
database.users.with({
|
|
469
|
+
profile: true,
|
|
470
|
+
posts: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
|
|
471
|
+
organizations: true,
|
|
472
|
+
}).descriptor,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
// TypeScript infers:
|
|
476
|
+
// rows: Array<User & {
|
|
477
|
+
// profile: Profile | null,
|
|
478
|
+
// posts: Post[],
|
|
479
|
+
// organizations: Org[],
|
|
480
|
+
// }>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
One SQL round-trip. The framework's JSON-aggregation compiler renders the whole tree as a single SELECT.
|
|
484
|
+
|
|
485
|
+
## When to declare a relation
|
|
486
|
+
|
|
487
|
+
Declare a relation when:
|
|
488
|
+
- Application code wants to **eager-load** the related rows in a single query.
|
|
489
|
+
- A reactive subscription needs to **wake when the related table changes**.
|
|
490
|
+
- The TypeScript types should **flow through** the cardinality automatically.
|
|
491
|
+
|
|
492
|
+
Don't declare a relation when:
|
|
493
|
+
- The FK exists only for DB-level integrity and code never reads through it.
|
|
494
|
+
- The "relation" is computed across many tables and doesn't have a clean 1:1 / 1:N / N:M shape.
|
|
495
|
+
|
|
496
|
+
A `reference()` column gives you the FK constraint + B-tree index regardless of whether a relation is declared on top. Relations are an application-layer concern about how to TRAVERSE the FK, not whether the FK exists.
|
|
497
|
+
|
|
498
|
+
## Decision tree — which cardinality?
|
|
499
|
+
|
|
500
|
+
```text
|
|
501
|
+
Does the related table point AT this table?
|
|
502
|
+
├─ One row only? → one(target) — 1:1 from this side, N:1 if FK is on this side
|
|
503
|
+
├─ Many rows? → many(target) — 1:N
|
|
504
|
+
└─ Connected through a junction table that holds extra columns?
|
|
505
|
+
→ manyToMany(target, { through: junction, ... }) — N:M
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
If you're not sure, ask: "for one row in THIS table, how many rows in THE OTHER table do I get?" One → `one`. Many → `many`. Many with a third table in between → `manyToMany`.
|
|
509
|
+
|
|
510
|
+
## Where it lives
|
|
511
|
+
|
|
512
|
+
- `voltro/packages/database/src/relations.ts` — `relations()` builder + `one`/`many`/`manyToMany` helpers
|
|
513
|
+
- `voltro/packages/database/src/relationsRegistry.ts` — process-global registry
|
|
514
|
+
- `voltro/packages/database/src/queryBuilder.ts` — `.with()` chain
|
|
515
|
+
- `voltro/packages/database/src/joinCompiler.ts` — walker fallback for compile-null cases
|
|
516
|
+
- `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect nested JSON-aggregation
|
|
517
|
+
- `voltro/packages/runtime/src/dependencyGraph.ts` — multi-table subscription registration
|
|
518
|
+
- `voltro/packages/runtime/src/relevantFields.ts` — per-field pre-filter
|
|
519
|
+
- `voltro/packages/runtime/src/dispatcher.ts` — multi-table fan-out + delta diffing
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
<!-- source: en/database/relations/one-to-one.md -->
|
|
526
|
+
## one() — 1:1 and N:1
|
|
527
|
+
|
|
528
|
+
_Single related row per parent. Owning side vs optional side, FK location auto-derivation, common patterns._
|
|
529
|
+
|
|
530
|
+
`one(target)` declares that for one row in the source table, there's at most ONE related row in the target. The resolved value is `Target | null` — null when no related row exists.
|
|
531
|
+
|
|
532
|
+
```typescript
|
|
533
|
+
relations(users, ({ one }) => ({
|
|
534
|
+
profile: one(profiles), // 1:1, FK on profiles
|
|
535
|
+
defaultOrg: one(orgs, { sourceKey: 'defaultOrgId' }), // N:1, FK on users
|
|
536
|
+
}))
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
The framework derives the join direction from the existence of FK columns; if it can't disambiguate, you pass `sourceKey` (FK is on the SOURCE table pointing at the TARGET) or `foreignKey` (FK is on the TARGET table pointing at the SOURCE).
|
|
540
|
+
|
|
541
|
+
## Two shapes
|
|
542
|
+
|
|
543
|
+
`one()` collapses two different relationship cardinalities into one DSL — they have the same shape from the application's perspective (single related row) but the SQL emitted is different.
|
|
544
|
+
|
|
545
|
+
### Shape A — FK on the target (1:1 / 0..1:1)
|
|
546
|
+
|
|
547
|
+
Profile owns its FK to user. One user has zero or one profile.
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
export const profiles = table('profiles', {
|
|
551
|
+
id: id({ prefix: 'profile' }),
|
|
552
|
+
userId: reference(() => users, { onDelete: 'cascade' }), // ← FK
|
|
553
|
+
bio: text(),
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
relations(users, ({ one }) => ({
|
|
557
|
+
profile: one(profiles), // framework finds userId on profiles → join target.userId = source.id
|
|
558
|
+
}))
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
SQL emitted:
|
|
562
|
+
```sql
|
|
563
|
+
(SELECT row_to_json(p) FROM profiles p WHERE p.user_id = users.id LIMIT 1)
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
The framework auto-derives this when the target table has exactly ONE `reference()` column pointing at the source. If there are multiple FKs from target → source, pass `foreignKey:`:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
// posts has both authorId AND editorId → user
|
|
570
|
+
relations(users, ({ one }) => ({
|
|
571
|
+
authoredFirstPost: one(posts, { foreignKey: 'authorId' }),
|
|
572
|
+
editedFirstPost: one(posts, { foreignKey: 'editorId' }),
|
|
573
|
+
}))
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Shape B — FK on the source (N:1)
|
|
577
|
+
|
|
578
|
+
User owns the FK to a default org. Many users can point at the same org.
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
export const users = table('users', {
|
|
582
|
+
id: id(),
|
|
583
|
+
defaultOrgId: reference(() => orgs).nullable(), // ← FK
|
|
584
|
+
email: text(),
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
relations(users, ({ one }) => ({
|
|
588
|
+
defaultOrg: one(orgs, { sourceKey: 'defaultOrgId' }),
|
|
589
|
+
}))
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
SQL emitted:
|
|
593
|
+
```sql
|
|
594
|
+
(SELECT row_to_json(o) FROM orgs o WHERE o.id = users.default_org_id LIMIT 1)
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The framework needs `sourceKey:` because the FK is on the source side, pointing at the target's `id`. Without the hint, the framework would look for FKs on the target table back at the source.
|
|
598
|
+
|
|
599
|
+
## Result type
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
const result = await store.query(
|
|
603
|
+
database.users.with({ profile: true, defaultOrg: true }).descriptor,
|
|
604
|
+
)
|
|
605
|
+
// result: Array<User & {
|
|
606
|
+
// profile: Profile | null,
|
|
607
|
+
// defaultOrg: Org | null,
|
|
608
|
+
// }>
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
Always `Target | null` — even on tables where you "expect" the relation to exist. Application code MUST handle the null case; the framework doesn't model "required 1:1" as a different cardinality.
|
|
612
|
+
|
|
613
|
+
## Owning side vs optional side
|
|
614
|
+
|
|
615
|
+
For 1:1 relationships, which side owns the FK is a schema design decision the framework doesn't force:
|
|
616
|
+
|
|
617
|
+
- **Owning side**: holds the FK column. Inserts on this side reference the target.
|
|
618
|
+
- **Optional side**: holds no FK. The relation flows through the owning side's FK on read.
|
|
619
|
+
|
|
620
|
+
Practical rule of thumb: put the FK on the **smaller-cardinality** side. If every user has at most one profile, the FK goes on profiles (which is cardinality-bound by users). If you flipped it — `users.profileId` — you'd need to manage the order of creation (insert profile first, then user with profileId) instead of the typical sequence (insert user, then optional profile).
|
|
621
|
+
|
|
622
|
+
## Mutating through the relation
|
|
623
|
+
|
|
624
|
+
The framework's mutation surface doesn't have a "set profile" shortcut. You write the underlying INSERT / UPDATE explicitly:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
// Create user + profile. Pre-compute the id so `profiles.userId` can
|
|
628
|
+
// reference it in the same flow.
|
|
629
|
+
import { typeid } from 'typeid-js'
|
|
630
|
+
const userId = typeid('user').toString()
|
|
631
|
+
await ctx.store.insert('users', { id: userId, email })
|
|
632
|
+
await ctx.store.insert('profiles', { userId, bio })
|
|
633
|
+
|
|
634
|
+
// Change a user's default org.
|
|
635
|
+
await ctx.store.update('users', userId, { defaultOrgId: newOrgId })
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
This is on purpose — the relation declaration describes the SHAPE of the data, not the WAY mutations propagate. Cascade behaviour is controlled by `onDelete:` on the `reference()` column (see [cascade](./cascade)), not by the relation.
|
|
639
|
+
|
|
640
|
+
## Eager loading with `.with({ profile: true })`
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
const users = await store.query(
|
|
644
|
+
database.users.where(eq('tenantId', tenantId)).with({ profile: true }).descriptor,
|
|
645
|
+
)
|
|
646
|
+
// users: Array<User & { profile: Profile | null }>
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
The framework's JSON-agg compiler emits a single SELECT with the profile as a nested subquery. No N+1 round-trips.
|
|
650
|
+
|
|
651
|
+
For per-branch filtering / ordering on the related row, pass an object instead of `true`:
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
database.users.with({
|
|
655
|
+
profile: { where: eq('verified', true) },
|
|
656
|
+
})
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The framework applies the where clause inside the per-row subquery. If the profile doesn't match, the resolved value is null — same as if no profile existed.
|
|
660
|
+
|
|
661
|
+
## Nested `.with()`
|
|
662
|
+
|
|
663
|
+
The related row can carry its own `.with()` for transitive relations:
|
|
664
|
+
|
|
665
|
+
```typescript
|
|
666
|
+
database.users.with({
|
|
667
|
+
profile: {
|
|
668
|
+
with: { avatar: true }, // profile → avatar
|
|
669
|
+
},
|
|
670
|
+
})
|
|
671
|
+
// → Array<User & {
|
|
672
|
+
// profile: (Profile & { avatar: Avatar | null }) | null,
|
|
673
|
+
// }>
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Recursion depth is arbitrary. Each nested branch becomes another nested subquery in the same SELECT — the framework's compiler walks the spec to any depth.
|
|
677
|
+
|
|
678
|
+
## Reactive subscriptions
|
|
679
|
+
|
|
680
|
+
A subscription opened with `.with({ profile: true })` registers against BOTH `users` and `profiles`. Changes to either table re-run the query (narrowed by the per-field pre-filter — see [reactive](./reactive)).
|
|
681
|
+
|
|
682
|
+
## Caveats
|
|
683
|
+
|
|
684
|
+
- **Two `one()` calls on the same target table need distinct relation names**. The framework keys eager loads by relation name, not by target table — `database.users.with({ profile: true })` reads the relation named `'profile'`. If you declare both `defaultOrg` and `billingOrg` both pointing at `orgs`, they're distinct relations and `with({ defaultOrg: true, billingOrg: true })` reads them independently.
|
|
685
|
+
- **Self-referential 1:1 needs the thunk form**. `one(() => users)` for a "parent user" lookup. Without the thunk, the table reference would try to resolve before the table is registered and fail.
|
|
686
|
+
- **`one()` on a target that has a many-to-the-same-source relation is unusual but legal**. The framework reads the relation declaration verbatim — if you say "one profile per user" and the actual data has two profiles for one user, the query returns one (LIMIT 1 in the subquery) and the other is silently invisible. Use `many()` if multiplicity is genuinely possible.
|
|
687
|
+
|
|
688
|
+
## Where it lives
|
|
689
|
+
|
|
690
|
+
- `voltro/packages/database/src/relations.ts` — `oneBuilder` (line 100)
|
|
691
|
+
- `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect `*OneSubquery` (postgres, mysql, mssql, sqlite)
|
|
692
|
+
- `voltro/packages/database/src/joinCompiler.ts` — walker fallback for `one()` when the JSON-agg compiler can't dispatch
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
<!-- source: en/database/relations/one-to-many.md -->
|
|
699
|
+
## many() — 1:N
|
|
700
|
+
|
|
701
|
+
_Array of related rows per parent. FK auto-derivation, per-parent where/orderBy/limit semantics, performance caveats for unbounded children._
|
|
702
|
+
|
|
703
|
+
`many(target)` declares that for one row in the source table, there are zero or more related rows in the target. The resolved value is `Target[]`.
|
|
704
|
+
|
|
705
|
+
```typescript
|
|
706
|
+
relations(users, ({ many }) => ({
|
|
707
|
+
posts: many(posts), // FK auto-derived as authorId
|
|
708
|
+
comments: many(comments, { foreignKey: 'userId' }), // explicit FK column
|
|
709
|
+
}))
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
The FK lives on the TARGET table — it points back at the source. The framework derives it from the only `reference()` column pointing at the source, or you pass `foreignKey:` explicitly when the target has multiple FKs back.
|
|
713
|
+
|
|
714
|
+
## Result type
|
|
715
|
+
|
|
716
|
+
```typescript
|
|
717
|
+
const result = await store.query(
|
|
718
|
+
database.users.with({ posts: true }).descriptor,
|
|
719
|
+
)
|
|
720
|
+
// result: Array<User & { posts: Post[] }>
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Always `Target[]` — empty array when no related rows exist. Not `Target[] | null`.
|
|
724
|
+
|
|
725
|
+
## Per-branch modifiers
|
|
726
|
+
|
|
727
|
+
The branch spec accepts `where`, `orderBy`, `limit`, `offset`. They apply **per parent** — `limit: 10` means up to 10 posts FOR EACH user, not 10 posts total.
|
|
728
|
+
|
|
729
|
+
```typescript
|
|
730
|
+
database.users.with({
|
|
731
|
+
posts: {
|
|
732
|
+
where: eq('published', true),
|
|
733
|
+
orderBy: [{ column: 'createdAt', direction: 'desc' }],
|
|
734
|
+
limit: 10,
|
|
735
|
+
offset: 0,
|
|
736
|
+
},
|
|
737
|
+
})
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
The SQL emitted depends on the dialect:
|
|
741
|
+
|
|
742
|
+
### Postgres / SQLite / MSSQL
|
|
743
|
+
|
|
744
|
+
A FROM-derived-table wrapper around the per-parent set:
|
|
745
|
+
|
|
746
|
+
```sql
|
|
747
|
+
-- Postgres
|
|
748
|
+
(SELECT jsonb_agg(t ORDER BY t.created_at DESC)
|
|
749
|
+
FROM (SELECT * FROM posts WHERE author_id = users.id AND published = true
|
|
750
|
+
ORDER BY created_at DESC LIMIT 10) t)
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### MySQL
|
|
754
|
+
|
|
755
|
+
Similar shape with `JSON_OBJECT` + `JSON_ARRAYAGG`:
|
|
756
|
+
|
|
757
|
+
```sql
|
|
758
|
+
COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...))
|
|
759
|
+
FROM (SELECT * FROM posts WHERE author_id = users.id AND published = true
|
|
760
|
+
ORDER BY created_at DESC LIMIT 10) t), JSON_ARRAY())
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### MariaDB
|
|
764
|
+
|
|
765
|
+
MariaDB rejects correlated references inside non-LATERAL derived tables. The framework switches to a ROW_NUMBER window-function pattern:
|
|
766
|
+
|
|
767
|
+
```sql
|
|
768
|
+
COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...) ORDER BY ranked.rn)
|
|
769
|
+
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY author_id
|
|
770
|
+
ORDER BY created_at DESC) AS rn
|
|
771
|
+
FROM posts WHERE published = true) ranked
|
|
772
|
+
WHERE ranked.author_id = users.id AND ranked.rn <= 10), JSON_ARRAY())
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
See [mariadb dialect details](../dialects/mariadb) for why.
|
|
776
|
+
|
|
777
|
+
## Reverse direction
|
|
778
|
+
|
|
779
|
+
`many()` is unidirectional in the relation declaration — you declare from the parent side. The child side (target) can declare a reverse `one()` independently if application code needs it:
|
|
780
|
+
|
|
781
|
+
```typescript
|
|
782
|
+
relations(users, ({ many }) => ({
|
|
783
|
+
posts: many(posts),
|
|
784
|
+
}))
|
|
785
|
+
|
|
786
|
+
relations(posts, ({ one }) => ({
|
|
787
|
+
author: one(users, { sourceKey: 'authorId' }), // posts.authorId → users.id
|
|
788
|
+
}))
|
|
789
|
+
|
|
790
|
+
// Both directions work in eager loads.
|
|
791
|
+
database.users.with({ posts: true })
|
|
792
|
+
database.posts.with({ author: true })
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
The framework doesn't auto-generate reverse relations — declare them explicitly when you need them.
|
|
796
|
+
|
|
797
|
+
## Cardinality assertion
|
|
798
|
+
|
|
799
|
+
`many()` carries no assertion about how MANY children each parent has. Zero is valid (empty array). A million is valid (potentially RAM-explosive — see "When NOT to eager-load" below). The relation describes "the set of children" without a multiplicity constraint.
|
|
800
|
+
|
|
801
|
+
If you genuinely have a 1:1 relationship modeled as `many()` because of legacy data, use `one()` instead — the framework will LIMIT 1 the subquery automatically and your TypeScript types will be `T | null` instead of `T[]`.
|
|
802
|
+
|
|
803
|
+
## When NOT to eager-load
|
|
804
|
+
|
|
805
|
+
`.with({ posts: true })` builds the WHOLE child set into one JSON document per parent. For tables with extremely wide child sets — `users.with({ events: true })` on a user with 1M events — the result JSON is multi-MB per row. RAM-hostile.
|
|
806
|
+
|
|
807
|
+
Mitigations, in order of preference:
|
|
808
|
+
|
|
809
|
+
### 1. Always pass `limit:` on unbounded children
|
|
810
|
+
|
|
811
|
+
```typescript
|
|
812
|
+
database.users.with({
|
|
813
|
+
events: { limit: 100, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
|
|
814
|
+
})
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
The framework's per-parent slicing kicks in before aggregation, so memory is bounded by `parents × limit × row size`.
|
|
818
|
+
|
|
819
|
+
### 2. Cursor-paginate the children separately
|
|
820
|
+
|
|
821
|
+
When the user might want pagination UI:
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
// Don't eager-load.
|
|
825
|
+
const [user] = await store.query(database.users.where(eq('id', uid)).descriptor)
|
|
826
|
+
const events = await ctx.store.query(
|
|
827
|
+
paginateById(database.events.where(eq('userId', uid)).descriptor, input.cursor, 100),
|
|
828
|
+
)
|
|
829
|
+
const nextCursor = events.at(-1)?.id
|
|
830
|
+
return { user, events, nextCursor }
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### 3. Drop down to manual joins when shape matters
|
|
834
|
+
|
|
835
|
+
```typescript
|
|
836
|
+
const rows = await ctx.store.transactional(async (txn) => {
|
|
837
|
+
const users = await txn.query(database.users.where(...))
|
|
838
|
+
const eventCounts = await txn.unsafe(
|
|
839
|
+
`SELECT user_id, COUNT(*) FROM events WHERE user_id = ANY($1) GROUP BY user_id`,
|
|
840
|
+
[users.map(u => u.id)],
|
|
841
|
+
)
|
|
842
|
+
return users.map(u => ({ ...u, eventCount: eventCounts[u.id] ?? 0 }))
|
|
843
|
+
})
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
For aggregation-shape outputs (counts, sums, top-K subsets), the eager-load shape isn't what you want. Hand-write the query.
|
|
847
|
+
|
|
848
|
+
## Walker fallback
|
|
849
|
+
|
|
850
|
+
When the JSON-agg compiler can't express a particular spec (unknown relation kind, dialect-specific edge case the per-dialect emitter doesn't cover), the framework transparently falls back to a portable per-relation N+1 walker. Same result, slower path.
|
|
851
|
+
|
|
852
|
+
Look for the boot log line if you want to know which path fired:
|
|
853
|
+
|
|
854
|
+
```sh
|
|
855
|
+
voltro logs --tail 50 | grep "JSON-agg eager-load failed; falling back"
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
If you see the warning regularly, the spec is hitting a code path the compiler hasn't covered yet — file an issue with the descriptor + dialect.
|
|
859
|
+
|
|
860
|
+
## Reactive subscriptions
|
|
861
|
+
|
|
862
|
+
A subscription opened with `.with({ posts: true })` registers against both `users` and `posts`. Changes to either re-run the query (narrowed by the per-field pre-filter — see [reactive](./reactive)).
|
|
863
|
+
|
|
864
|
+
For tables with high write rates (`comments`, `events`, audit logs), reactive `.with({ ...heavyChild: true })` can cost a lot of re-queries. The per-field filter helps but doesn't eliminate the cost. Mitigations:
|
|
865
|
+
|
|
866
|
+
- Project narrowly. `.with({ posts: { limit: 5 } })` reads only 5 posts per user, so a write to a post the subscription doesn't include never wakes the sub.
|
|
867
|
+
- Split the subscription. Subscribe to the parent (`users`) and the children (`posts`) separately; each only fires on its own table's changes.
|
|
868
|
+
|
|
869
|
+
## Caveats
|
|
870
|
+
|
|
871
|
+
- **FK auto-derivation requires exactly ONE matching `reference()`**. If the target has multiple FKs back at the source (`author_id`, `editor_id`, `reviewer_id` all → `users.id`), pass `foreignKey:` explicitly. The auto-derivation throws at schema-registration time with a clear error if it can't pick unambiguously.
|
|
872
|
+
- **Self-referential `many()` needs the thunk form**: `many(() => posts)` for a "replies" relation on posts.
|
|
873
|
+
- **Empty child set ≠ null parent**. `user.posts === []` for a user with no posts. `user.posts === undefined` only when the spec didn't request the relation. Distinguish in handler code.
|
|
874
|
+
|
|
875
|
+
## Where it lives
|
|
876
|
+
|
|
877
|
+
- `voltro/packages/database/src/relations.ts` — `manyBuilder` (line 110)
|
|
878
|
+
- `voltro/packages/database/src/jsonEagerCompiler.ts` — `postgresManySubquery`, `mysqlManySubquery`, `mariadbManySubquery`, `mssqlManySubquery`, `sqliteManySubquery`
|
|
879
|
+
- `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachMany`)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
<!-- source: en/database/relations/many-to-many.md -->
|
|
886
|
+
## manyToMany() — N:M
|
|
887
|
+
|
|
888
|
+
_Explicit through-table pattern. Junction table with extra columns. Doubly-declared relations for both directions._
|
|
889
|
+
|
|
890
|
+
`manyToMany(target, { through, sourceKey, targetKey })` declares an N:M relationship through an explicit junction table. The framework never auto-generates the junction; you write it yourself with whatever extra columns it needs.
|
|
891
|
+
|
|
892
|
+
```typescript
|
|
893
|
+
// The junction table — write it explicitly.
|
|
894
|
+
export const orgMemberships = table('org_memberships', {
|
|
895
|
+
id: id({ prefix: 'membership' }),
|
|
896
|
+
userId: reference(() => users, { onDelete: 'cascade' }),
|
|
897
|
+
orgId: reference(() => orgs, { onDelete: 'cascade' }),
|
|
898
|
+
role: text().oneOf(['owner', 'admin', 'member']),
|
|
899
|
+
joinedAt: timestamp().default('now'),
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
// The relation, declared on BOTH sides.
|
|
903
|
+
relations(users, ({ manyToMany }) => ({
|
|
904
|
+
organizations: manyToMany(orgs, {
|
|
905
|
+
through: orgMemberships,
|
|
906
|
+
sourceKey: 'userId',
|
|
907
|
+
targetKey: 'orgId',
|
|
908
|
+
}),
|
|
909
|
+
}))
|
|
910
|
+
|
|
911
|
+
relations(orgs, ({ manyToMany }) => ({
|
|
912
|
+
members: manyToMany(users, {
|
|
913
|
+
through: orgMemberships,
|
|
914
|
+
sourceKey: 'orgId', // junction column pointing at this side
|
|
915
|
+
targetKey: 'userId', // junction column pointing at the other side
|
|
916
|
+
}),
|
|
917
|
+
}))
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
## Why explicit through-table
|
|
921
|
+
|
|
922
|
+
The framework deliberately avoids the Prisma / TypeORM "auto-generated junction" pattern. Three reasons:
|
|
923
|
+
|
|
924
|
+
1. **Extra columns are the rule, not the exception**. Memberships have roles. Tags have ordering. Subscriptions have permission tiers. The auto-junction always-needs-migrating-to-add-columns is a recurring tax. Writing the table yourself from the start avoids it.
|
|
925
|
+
|
|
926
|
+
2. **Junction discoverability**. The junction is a real table you can query directly: `database.orgMemberships.where(eq('userId', uid))` is sometimes what you want, not "users with their organizations." Treating it as a first-class table makes both paths natural.
|
|
927
|
+
|
|
928
|
+
3. **Schema is explicit**. Looking at your `database/` directory tells you exactly which tables exist. No hidden auto-generated tables to chase down at migration time.
|
|
929
|
+
|
|
930
|
+
## SQL shape
|
|
931
|
+
|
|
932
|
+
The framework emits an INNER JOIN through the junction:
|
|
933
|
+
|
|
934
|
+
```sql
|
|
935
|
+
-- For database.users.with({ organizations: true }):
|
|
936
|
+
SELECT users.*,
|
|
937
|
+
(SELECT json_agg(o) FROM (
|
|
938
|
+
SELECT orgs.* FROM org_memberships m
|
|
939
|
+
INNER JOIN orgs ON orgs.id = m.org_id
|
|
940
|
+
WHERE m.user_id = users.id
|
|
941
|
+
) o
|
|
942
|
+
) AS organizations
|
|
943
|
+
FROM users;
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
The junction is in the FROM clause; the wrapping subquery aggregates the target rows. Per-dialect details (JSON_ARRAYAGG / FOR JSON PATH / json_group_array) match the [eager loading](./eager-loading) page.
|
|
947
|
+
|
|
948
|
+
For MariaDB the framework uses a ROW_NUMBER window-function pattern when per-parent limit/offset is set, same trade-off as `many()` — see [mariadb dialect details](../dialects/mariadb).
|
|
949
|
+
|
|
950
|
+
## Per-branch modifiers
|
|
951
|
+
|
|
952
|
+
`where` / `orderBy` / `limit` / `offset` apply to the TARGET table — not the junction:
|
|
953
|
+
|
|
954
|
+
```typescript
|
|
955
|
+
database.users.with({
|
|
956
|
+
organizations: {
|
|
957
|
+
where: eq('plan', 'enterprise'), // filters orgs, not memberships
|
|
958
|
+
orderBy: [{ column: 'name', direction: 'asc' }],
|
|
959
|
+
limit: 10,
|
|
960
|
+
},
|
|
961
|
+
})
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
To filter on JUNCTION columns (e.g. "orgs where this user is an admin"), pass `onJunction` on the eager branch. The predicate is evaluated against the THROUGH-table row, not the target row:
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
database.users.with({
|
|
968
|
+
organizations: {
|
|
969
|
+
onJunction: eq('role', 'admin'), // filters the membership junction
|
|
970
|
+
orderBy: [{ column: 'name', direction: 'asc' }],
|
|
971
|
+
},
|
|
972
|
+
})
|
|
973
|
+
// → each user's `organizations` are exactly the orgs they're an admin of
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
`onJunction` composes with the target-side `where` / `orderBy` / `limit` / `offset` — `where` still filters the target (orgs), `onJunction` filters the junction (memberships). It compiles into the correlated subquery's WHERE alongside the source-key correlation, qualified to the junction-table alias, on every dialect (postgres / mysql / mariadb / mssql / sqlite / turso). Ignored on `one` / `many` branches (no junction table exists).
|
|
977
|
+
|
|
978
|
+
## Doubly-declared
|
|
979
|
+
|
|
980
|
+
`many` and `one` are unidirectional — you declare from the source side and the reverse is a separate `relations(...)` call. `manyToMany` is the same: declare BOTH directions independently if you need both:
|
|
981
|
+
|
|
982
|
+
```typescript
|
|
983
|
+
relations(users, ({ manyToMany }) => ({
|
|
984
|
+
organizations: manyToMany(orgs, {
|
|
985
|
+
through: orgMemberships, sourceKey: 'userId', targetKey: 'orgId',
|
|
986
|
+
}),
|
|
987
|
+
}))
|
|
988
|
+
|
|
989
|
+
relations(orgs, ({ manyToMany }) => ({
|
|
990
|
+
members: manyToMany(users, {
|
|
991
|
+
through: orgMemberships, sourceKey: 'orgId', targetKey: 'userId',
|
|
992
|
+
}),
|
|
993
|
+
}))
|
|
994
|
+
|
|
995
|
+
// Both directions work in eager loads.
|
|
996
|
+
database.users.with({ organizations: true })
|
|
997
|
+
database.orgs.with({ members: true })
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
## Inserting a membership
|
|
1001
|
+
|
|
1002
|
+
The framework has no "addMembership" shortcut. Insert the junction row directly:
|
|
1003
|
+
|
|
1004
|
+
```typescript
|
|
1005
|
+
await ctx.store.insert('org_memberships', {
|
|
1006
|
+
userId, orgId,
|
|
1007
|
+
role: 'admin',
|
|
1008
|
+
})
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
If the junction has a unique constraint on `(userId, orgId)` — which it should, to prevent duplicate memberships — the framework's `PrimaryKeyConflictError` fires on duplicate inserts. Catch + handle:
|
|
1012
|
+
|
|
1013
|
+
```typescript
|
|
1014
|
+
try {
|
|
1015
|
+
await ctx.store.insert('org_memberships', { userId, orgId, role })
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
if (e instanceof PrimaryKeyConflictError) {
|
|
1018
|
+
// Already a member.
|
|
1019
|
+
return { alreadyMember: true }
|
|
1020
|
+
}
|
|
1021
|
+
throw e
|
|
1022
|
+
}
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
## Removing a membership
|
|
1026
|
+
|
|
1027
|
+
```typescript
|
|
1028
|
+
const [m] = await store.query(
|
|
1029
|
+
database.orgMemberships.where(and(
|
|
1030
|
+
eq('userId', uid),
|
|
1031
|
+
eq('orgId', oid),
|
|
1032
|
+
)).descriptor,
|
|
1033
|
+
)
|
|
1034
|
+
if (m) {
|
|
1035
|
+
await ctx.store.delete('org_memberships', m.id)
|
|
1036
|
+
}
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
If you set `onDelete: 'cascade'` on the junction's FK to either side, deleting a user or org auto-removes all their memberships. This is the typical setup — memberships have no meaning without both parents.
|
|
1040
|
+
|
|
1041
|
+
## Reactive subscriptions
|
|
1042
|
+
|
|
1043
|
+
A subscription opened with `.with({ organizations: true })` registers against THREE tables: `users`, `org_memberships`, `orgs`. Changes to any re-run the query.
|
|
1044
|
+
|
|
1045
|
+
This can be a lot of fan-out on tables with high junction write rates (a hot membership table getting writes every second). The per-field pre-filter (see [reactive](./reactive)) reduces but doesn't eliminate the cost. For very-hot-junction patterns, split the subscription.
|
|
1046
|
+
|
|
1047
|
+
## TypeScript inference
|
|
1048
|
+
|
|
1049
|
+
```typescript
|
|
1050
|
+
const rows = await store.query(
|
|
1051
|
+
database.users.with({ organizations: true }).descriptor,
|
|
1052
|
+
)
|
|
1053
|
+
// rows: Array<User & { organizations: Org[] }>
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
The junction columns (role, joinedAt) are NOT in the resolved Org type. The framework eager-loads the TARGET side only. If you need junction columns, query the junction table directly.
|
|
1057
|
+
|
|
1058
|
+
## Caveats
|
|
1059
|
+
|
|
1060
|
+
- **Same target appearing twice on the source needs distinct relation names**. If users have `organizations` (membership-based) AND `ownedOrgs` (1:N via `orgs.ownerId`), declare them as separate relations with separate names. The framework keys by relation name.
|
|
1061
|
+
- **`sourceKey` is the junction column pointing at THIS side**. Easy to confuse with the schema's `reference()` column name on the source table itself. The framework's error message at registration time helps: "no column 'userId' on org_memberships" if you mistype.
|
|
1062
|
+
- **Cascade behaviour on the junction is separate from cascade on the parents**. `onDelete: 'cascade'` on `org_memberships.userId` removes memberships when the user is deleted, but doesn't touch `orgs`. Set it explicitly on each FK depending on lifecycle semantics.
|
|
1063
|
+
- **Junction extra columns aren't reactive through the m2m relation**. A write to `org_memberships.role` doesn't wake a subscription on `users.with({ organizations: true })` UNLESS the subscription's relevant-fields set includes `role` (which it doesn't by default — only the join keys are relevant for the m2m walk). If you want subscriptions to react to role changes, observe `org_memberships` directly.
|
|
1064
|
+
|
|
1065
|
+
## Where it lives
|
|
1066
|
+
|
|
1067
|
+
- `voltro/packages/database/src/relations.ts` — `manyToManyBuilder` (line 120)
|
|
1068
|
+
- `voltro/packages/database/src/jsonEagerCompiler.ts` — `postgresManyToManySubquery`, `mysqlManyToManySubquery`, `mariadbManyToManySubquery`, `mssqlManyToManySubquery`, `sqliteManyToManySubquery`
|
|
1069
|
+
- `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachManyToMany`)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
---
|
|
1074
|
+
|
|
1075
|
+
<!-- source: en/database/relations/eager-loading.md -->
|
|
1076
|
+
## Eager loading with .with()
|
|
1077
|
+
|
|
1078
|
+
_Single-roundtrip nested JSON queries. Per-dialect SQL shapes. Walker fallback. When NOT to eager-load + stream-cursor pagination alternatives._
|
|
1079
|
+
|
|
1080
|
+
`.with(spec)` is the framework's eager-load chain. The query builder reads the relations registry to produce a single SQL query that returns the parent rows + every related table the spec touches as nested JSON.
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
const result = await store.query(
|
|
1084
|
+
database.users.where(eq('tenantId', tenantId)).with({
|
|
1085
|
+
profile: true,
|
|
1086
|
+
posts: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
|
|
1087
|
+
organizations: true,
|
|
1088
|
+
}).descriptor,
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
// TypeScript infers:
|
|
1092
|
+
// result: Array<User & {
|
|
1093
|
+
// profile: Profile | null,
|
|
1094
|
+
// posts: Post[],
|
|
1095
|
+
// organizations: Org[],
|
|
1096
|
+
// }>
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
One SQL round-trip. The framework's JSON-aggregation compiler renders the whole tree as a single SELECT.
|
|
1100
|
+
|
|
1101
|
+
## Nesting
|
|
1102
|
+
|
|
1103
|
+
`.with()` nests arbitrarily deep:
|
|
1104
|
+
|
|
1105
|
+
```typescript
|
|
1106
|
+
database.users.with({
|
|
1107
|
+
posts: {
|
|
1108
|
+
with: {
|
|
1109
|
+
author: true,
|
|
1110
|
+
comments: {
|
|
1111
|
+
with: { user: true },
|
|
1112
|
+
limit: 10,
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
})
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
That's users → posts → (author + comments → user). Still one SQL roundtrip. The JSON shape comes back nested; the framework decodes it to typed JS objects.
|
|
1120
|
+
|
|
1121
|
+
## Per-dialect SQL shapes
|
|
1122
|
+
|
|
1123
|
+
Each dialect emits its native JSON-aggregation idiom. You write the same code; the framework dispatches.
|
|
1124
|
+
|
|
1125
|
+
### Postgres
|
|
1126
|
+
|
|
1127
|
+
```sql
|
|
1128
|
+
SELECT jsonb_build_object(
|
|
1129
|
+
'id', users.id,
|
|
1130
|
+
'email', users.email,
|
|
1131
|
+
'profile', (SELECT to_jsonb(p) FROM profiles p WHERE p.user_id = users.id LIMIT 1),
|
|
1132
|
+
'posts', COALESCE((SELECT jsonb_agg(t) FROM (
|
|
1133
|
+
SELECT * FROM posts WHERE author_id = users.id LIMIT 5
|
|
1134
|
+
) t), '[]'::jsonb)
|
|
1135
|
+
) AS __row
|
|
1136
|
+
FROM users;
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
`jsonb_build_object` + `jsonb_agg` are postgres-native; the eager compiler is best-in-class here.
|
|
1140
|
+
|
|
1141
|
+
### MySQL 8+
|
|
1142
|
+
|
|
1143
|
+
```sql
|
|
1144
|
+
SELECT JSON_OBJECT(
|
|
1145
|
+
'id', users.id,
|
|
1146
|
+
'email', users.email,
|
|
1147
|
+
'profile', (SELECT JSON_OBJECT(...) FROM profiles p WHERE p.user_id = users.id LIMIT 1),
|
|
1148
|
+
'posts', COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...))
|
|
1149
|
+
FROM (SELECT * FROM posts WHERE author_id = users.id LIMIT 5) t),
|
|
1150
|
+
JSON_ARRAY())
|
|
1151
|
+
) AS __row
|
|
1152
|
+
FROM users;
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
Functionally equivalent to postgres; the FROM-derived-table wrapper handles per-parent LIMIT.
|
|
1156
|
+
|
|
1157
|
+
### MariaDB 10.6+
|
|
1158
|
+
|
|
1159
|
+
MariaDB rejects correlated references inside non-LATERAL derived tables. The framework switches to a ROW_NUMBER window-function pattern:
|
|
1160
|
+
|
|
1161
|
+
```sql
|
|
1162
|
+
SELECT JSON_OBJECT(
|
|
1163
|
+
'id', users.id,
|
|
1164
|
+
'posts', COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...) ORDER BY ranked.rn)
|
|
1165
|
+
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY author_id
|
|
1166
|
+
ORDER BY created_at DESC) AS rn
|
|
1167
|
+
FROM posts) ranked
|
|
1168
|
+
WHERE ranked.author_id = users.id AND ranked.rn <= 5),
|
|
1169
|
+
JSON_ARRAY())
|
|
1170
|
+
) AS __row
|
|
1171
|
+
FROM users;
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
For the no-pagination case, MariaDB uses `JSON_ARRAYAGG(... ORDER BY ...)` — a MariaDB-only extension that MySQL rejects — for cleaner SQL.
|
|
1175
|
+
|
|
1176
|
+
### MSSQL 2019+
|
|
1177
|
+
|
|
1178
|
+
```sql
|
|
1179
|
+
SELECT (SELECT TOP 1 * FROM users WHERE id = u.id
|
|
1180
|
+
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS __row
|
|
1181
|
+
FROM users u;
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
MSSQL's `FOR JSON PATH` builds nested objects from the SELECT's column list; sub-aggregations become correlated `(SELECT ... FOR JSON PATH)` blocks inside CASE expressions.
|
|
1185
|
+
|
|
1186
|
+
### SQLite 3.38+
|
|
1187
|
+
|
|
1188
|
+
```sql
|
|
1189
|
+
SELECT json_object(
|
|
1190
|
+
'id', users.id,
|
|
1191
|
+
'profile', (SELECT json_object(...) FROM profiles WHERE user_id = users.id LIMIT 1),
|
|
1192
|
+
'posts', COALESCE((SELECT json_group_array(json_object(...))
|
|
1193
|
+
FROM (SELECT * FROM posts WHERE author_id = users.id LIMIT 5)
|
|
1194
|
+
), json('[]'))
|
|
1195
|
+
) AS __row
|
|
1196
|
+
FROM users;
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
`json_object` + `json_group_array` are SQLite's JSON1 extension; available since 3.38.
|
|
1200
|
+
|
|
1201
|
+
## Walker fallback
|
|
1202
|
+
|
|
1203
|
+
When the JSON-agg compiler can't express a particular spec — for example a relation that hasn't been registered, or a per-dialect edge case the emitter doesn't cover — the framework transparently falls back to a portable per-relation N+1 walker.
|
|
1204
|
+
|
|
1205
|
+
The walker:
|
|
1206
|
+
|
|
1207
|
+
1. Runs the parent query without any eager loading.
|
|
1208
|
+
2. For each declared relation in the spec, issues a follow-up query against the related table.
|
|
1209
|
+
3. Attaches the related rows by FK in JavaScript.
|
|
1210
|
+
4. Recurses through nested `with`.
|
|
1211
|
+
|
|
1212
|
+
Result is identical to the JSON-agg path. Only difference is the round-trip count: N+M+K+… vs 1.
|
|
1213
|
+
|
|
1214
|
+
The framework emits a warning when this fires:
|
|
1215
|
+
|
|
1216
|
+
```
|
|
1217
|
+
mariadb JSON-agg eager-load failed; falling back to walker
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
If you see this regularly, the spec is hitting a code path the compiler doesn't cover. File an issue with the descriptor + dialect.
|
|
1221
|
+
|
|
1222
|
+
## When NOT to eager-load
|
|
1223
|
+
|
|
1224
|
+
Three patterns where `.with()` is the wrong tool:
|
|
1225
|
+
|
|
1226
|
+
### 1. Children sets are large + unbounded
|
|
1227
|
+
|
|
1228
|
+
```typescript
|
|
1229
|
+
// User with 1M events — RAM explosion.
|
|
1230
|
+
database.users.with({ events: true })
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
The JSON document per parent grows linearly with child count. At 1M events per user × 200 bytes per event = 200 MB per parent row. Use `limit:` always when the child set could grow.
|
|
1234
|
+
|
|
1235
|
+
### 2. You need pagination UI for the children
|
|
1236
|
+
|
|
1237
|
+
```typescript
|
|
1238
|
+
// User has 10k posts; UI shows 20 at a time with pagination.
|
|
1239
|
+
// Don't eager-load — query the children separately with paginateById.
|
|
1240
|
+
const [user] = await store.query(database.users.where(eq('id', uid)).descriptor)
|
|
1241
|
+
const rows = await ctx.store.query(
|
|
1242
|
+
paginateById(database.posts.where(eq('userId', uid)).descriptor, input.cursor, 20),
|
|
1243
|
+
)
|
|
1244
|
+
const nextCursor = rows.at(-1)?.id
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
Eager-loading 10k posts to render 20 is wasteful. Cursor pagination handles it cleanly.
|
|
1248
|
+
|
|
1249
|
+
### 3. You want aggregations, not rows
|
|
1250
|
+
|
|
1251
|
+
```typescript
|
|
1252
|
+
// Want: { user, postCount, lastPostAt } — NOT { user, posts: [...] }
|
|
1253
|
+
const rows = await ctx.store.transactional(async (txn) => {
|
|
1254
|
+
const users = await txn.query(database.users.where(...))
|
|
1255
|
+
const stats = await txn.unsafe(`
|
|
1256
|
+
SELECT user_id, COUNT(*) as post_count, MAX(created_at) as last_post_at
|
|
1257
|
+
FROM posts WHERE user_id = ANY($1) GROUP BY user_id
|
|
1258
|
+
`, [users.map(u => u.id)])
|
|
1259
|
+
return users.map(u => ({
|
|
1260
|
+
...u,
|
|
1261
|
+
postCount: stats[u.id]?.post_count ?? 0,
|
|
1262
|
+
lastPostAt: stats[u.id]?.last_post_at ?? null,
|
|
1263
|
+
}))
|
|
1264
|
+
})
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
Aggregations are a different query shape; `.with()` returns rows, not summaries.
|
|
1268
|
+
|
|
1269
|
+
## Type inference
|
|
1270
|
+
|
|
1271
|
+
Schema-registered relations propagate through `.with()` at the type level:
|
|
1272
|
+
|
|
1273
|
+
```typescript
|
|
1274
|
+
const rows = await store.query(
|
|
1275
|
+
database.users.with({
|
|
1276
|
+
profile: true,
|
|
1277
|
+
ownedOrgs: { with: { projects: true } },
|
|
1278
|
+
}).descriptor,
|
|
1279
|
+
)
|
|
1280
|
+
// rows: Array<User & {
|
|
1281
|
+
// profile: Profile | null,
|
|
1282
|
+
// ownedOrgs: Array<Org & { projects: Project[] }>,
|
|
1283
|
+
// }>
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
Branded TypeID columns flow through — `rows[0].ownedOrgs[0].id` is `OrgId`, not just `string`.
|
|
1287
|
+
|
|
1288
|
+
## Performance
|
|
1289
|
+
|
|
1290
|
+
The JSON-agg path is fast for typical app workloads (parents × ~100 KB JSON per parent). At larger scales the per-row JSON serialization becomes the dominant cost, and at that point the walker (which streams rows individually) may actually outperform JSON-agg.
|
|
1291
|
+
|
|
1292
|
+
Benchmarks (postgres on Apple M2, app-tier instance type):
|
|
1293
|
+
|
|
1294
|
+
| Pattern | JSON-agg | Walker (N+1) | Note |
|
|
1295
|
+
|------------------------------------------|----------|--------------|------|
|
|
1296
|
+
| 100 users × 5 posts each | 4ms | 35ms | JSON-agg wins decisively |
|
|
1297
|
+
| 1000 users × 50 posts each | 90ms | 280ms | JSON-agg still wins |
|
|
1298
|
+
| 1000 users × 5000 posts each (no limit) | 4.5s | 6s | Both pay; project narrowly |
|
|
1299
|
+
| 100 users × 100k posts each (no limit) | OOM | 12s | JSON-agg blows memory; walker survives |
|
|
1300
|
+
|
|
1301
|
+
The framework defaults to JSON-agg because the common case wins. If you hit the OOM cliff, add `limit:` to the branch.
|
|
1302
|
+
|
|
1303
|
+
## Reactive subscriptions
|
|
1304
|
+
|
|
1305
|
+
Reactive subscriptions on `.with()` queries register against EVERY table the spec touches. See [reactive](./reactive) for the dependency-graph + per-field pre-filter details.
|
|
1306
|
+
|
|
1307
|
+
## Where it lives
|
|
1308
|
+
|
|
1309
|
+
- `voltro/packages/database/src/queryBuilder.ts` — `.with(spec)` chain
|
|
1310
|
+
- `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect JSON-aggregation compilers
|
|
1311
|
+
- `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachEagerLoads`)
|
|
1312
|
+
- `voltro/packages/sql-postgres/src/store.ts`, etc. — each store's `runWithEager()` decides JSON-agg vs walker
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
---
|
|
1317
|
+
|
|
1318
|
+
<!-- source: en/database/relations/cascade.md -->
|
|
1319
|
+
## Cascade + FK auto-index
|
|
1320
|
+
|
|
1321
|
+
_onDelete defaults to 'restrict' for safety. FK auto-index defaults to true. Real migration scenarios + when to override._
|
|
1322
|
+
|
|
1323
|
+
`reference()` columns are foreign-key declarations. The framework picks two opinionated defaults that bite users from other frameworks:
|
|
1324
|
+
|
|
1325
|
+
```typescript
|
|
1326
|
+
export const posts = table('posts', {
|
|
1327
|
+
id: id({ prefix: 'post' }),
|
|
1328
|
+
authorId: reference(() => users, {
|
|
1329
|
+
onDelete: 'cascade', // delete author → delete posts. Default 'restrict'.
|
|
1330
|
+
onUpdate: 'noAction', // FK PK never changes in practice. Default 'noAction'.
|
|
1331
|
+
index: true, // B-tree on authorId. Default true.
|
|
1332
|
+
}),
|
|
1333
|
+
title: text(),
|
|
1334
|
+
})
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
## `onDelete` defaults to `'restrict'`
|
|
1338
|
+
|
|
1339
|
+
The framework is opinionated: deleting a user shouldn't silently nuke 100k posts. `'restrict'` is the default to surface FK violations LOUDLY at delete time.
|
|
1340
|
+
|
|
1341
|
+
| Value | Meaning |
|
|
1342
|
+
|---------------|----------------------------------------------------------------------|
|
|
1343
|
+
| `'restrict'` (default) | Refuse the delete if child rows exist. Parent delete throws. |
|
|
1344
|
+
| `'cascade'` | Delete child rows along with the parent. Use for owned lifecycles. |
|
|
1345
|
+
| `'setNull'` | Set the child's FK to NULL (column must be `.nullable()`). |
|
|
1346
|
+
| `'noAction'` | DB-level NO ACTION (postgres / mysql) — effectively same as restrict but defers the constraint check to commit. |
|
|
1347
|
+
|
|
1348
|
+
Pick based on the lifecycle relationship between parent and child:
|
|
1349
|
+
|
|
1350
|
+
- **`'cascade'`** for genuinely-owned children. Junction tables (memberships when a user OR org is deleted), child entities that have no meaning without the parent (a `comment_reactions` row when the comment is deleted).
|
|
1351
|
+
- **`'setNull'`** when the child outlives the parent in a degraded form. A `posts.editorId` when the editor user leaves the team — keep the post, drop the editor pointer.
|
|
1352
|
+
- **`'restrict'`** (default) when you want the framework to FORCE you to clean up explicitly. Most parent-child relationships in a typical app.
|
|
1353
|
+
|
|
1354
|
+
## `index: true` default
|
|
1355
|
+
|
|
1356
|
+
Every `reference()` column gets a B-tree index automatically. The framework's [index audit](../indexes) confirms it at boot.
|
|
1357
|
+
|
|
1358
|
+
```typescript
|
|
1359
|
+
reference(() => users) // → CREATE INDEX posts_author_id_idx ON posts (author_id);
|
|
1360
|
+
reference(() => users, { index: false }) // → no index
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
The lookup cost of a missing FK index dwarfs the write cost of an unnecessary one for nearly every workload. Opt out only for tiny lookup tables where a full-scan beats index maintenance — `lookup_codes` (a 20-row enum-like table), `singleton_config` (one row total). Anywhere a `JOIN` would happen, keep the default.
|
|
1364
|
+
|
|
1365
|
+
## Real migration scenarios
|
|
1366
|
+
|
|
1367
|
+
### Scenario 1 — cascade through a junction
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
export const orgMemberships = table('org_memberships', {
|
|
1371
|
+
userId: reference(() => users, { onDelete: 'cascade' }),
|
|
1372
|
+
orgId: reference(() => orgs, { onDelete: 'cascade' }),
|
|
1373
|
+
role: text(),
|
|
1374
|
+
})
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
Delete a user → all their memberships cascade away. Delete an org → all its memberships cascade away. Standard pattern; the junction has no meaning without both parents.
|
|
1378
|
+
|
|
1379
|
+
### Scenario 2 — setNull on optional ownership
|
|
1380
|
+
|
|
1381
|
+
```typescript
|
|
1382
|
+
export const posts = table('posts', {
|
|
1383
|
+
authorId: reference(() => users, { onDelete: 'cascade' }), // owner — cascade
|
|
1384
|
+
editorId: reference(() => users, { onDelete: 'setNull' }).nullable(), // optional → setNull
|
|
1385
|
+
})
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
If the editor leaves the team, posts stay but lose the editor pointer. The author's deletion still removes the post.
|
|
1389
|
+
|
|
1390
|
+
### Scenario 3 — restrict forcing explicit cleanup
|
|
1391
|
+
|
|
1392
|
+
```typescript
|
|
1393
|
+
export const invoices = table('invoices', {
|
|
1394
|
+
customerId: reference(() => customers, { onDelete: 'restrict' }), // default
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
// Application code:
|
|
1398
|
+
async function deleteCustomer(customerId: string) {
|
|
1399
|
+
const openInvoices = await store.query(
|
|
1400
|
+
database.invoices.where(eq('customerId', customerId)).descriptor,
|
|
1401
|
+
)
|
|
1402
|
+
if (openInvoices.length > 0) {
|
|
1403
|
+
throw new CustomerHasOpenInvoicesError({ count: openInvoices.length })
|
|
1404
|
+
}
|
|
1405
|
+
await ctx.store.delete('customers', customerId)
|
|
1406
|
+
}
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
The `'restrict'` default forces the application to think about what "delete customer" means. Auto-cascading invoices would corrupt accounting; auto-setNull would orphan them. The explicit check + typed error is the right contract.
|
|
1410
|
+
|
|
1411
|
+
## Per-dialect emission
|
|
1412
|
+
|
|
1413
|
+
| Dialect | FK clause shape |
|
|
1414
|
+
|-----------|-----------------|
|
|
1415
|
+
| postgres | `REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` |
|
|
1416
|
+
| mysql | ``REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION`` |
|
|
1417
|
+
| mariadb | ``REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION`` |
|
|
1418
|
+
| mssql | `REFERENCES [users]([id]) ON DELETE CASCADE` (mssql has no NO ACTION distinct from default) |
|
|
1419
|
+
| sqlite | `REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` |
|
|
1420
|
+
|
|
1421
|
+
SQLite requires `PRAGMA foreign_keys = ON` to enforce — the framework sets this on every connect. Without it, sqlite is permissive (treats FK constraints as documentation only).
|
|
1422
|
+
|
|
1423
|
+
## `onUpdate` — usually `'noAction'`
|
|
1424
|
+
|
|
1425
|
+
FK referenced columns are IDs. IDs don't change in practice — that's the whole point of having an ID. So `onUpdate` defaults to `'noAction'` and you usually don't touch it.
|
|
1426
|
+
|
|
1427
|
+
If you DO need to change an ID across a hierarchy (e.g. consolidating two organizations into one — `orgId: 'a' → orgId: 'b'`), the right answer is usually to:
|
|
1428
|
+
|
|
1429
|
+
1. Find every row that references the old ID.
|
|
1430
|
+
2. Update them all in a single transaction.
|
|
1431
|
+
3. Delete the now-orphaned old parent row.
|
|
1432
|
+
|
|
1433
|
+
Not to set `onUpdate: 'cascade'` and trigger a single huge update through the DB engine. The cascade approach locks every affected table for the duration and breaks any subscriptions watching those tables.
|
|
1434
|
+
|
|
1435
|
+
## Composite FKs — not supported
|
|
1436
|
+
|
|
1437
|
+
The framework's `reference()` is single-column only. If your schema has a composite FK (multi-column referential integrity), you'll have to:
|
|
1438
|
+
|
|
1439
|
+
1. Declare each column individually with the right type.
|
|
1440
|
+
2. Add the FK constraint via `unsafe()` SQL in a migration.
|
|
1441
|
+
3. Skip the framework's relation declaration for that link.
|
|
1442
|
+
|
|
1443
|
+
This is rare. Composite FKs usually indicate a schema design that could collapse into a surrogate-key approach.
|
|
1444
|
+
|
|
1445
|
+
## Index audit warnings
|
|
1446
|
+
|
|
1447
|
+
The framework's boot-time [index audit](../indexes) warns about redundant indexes — a FK auto-index that's a leading prefix of an explicit composite index is redundant:
|
|
1448
|
+
|
|
1449
|
+
```
|
|
1450
|
+
[voltro:dev] WARN index audit · redundant-prefix
|
|
1451
|
+
table: memberships
|
|
1452
|
+
redundant: memberships_user_id_idx (from reference auto-index)
|
|
1453
|
+
coveredBy: memberships_user_org_idx (explicit composite on (user_id, org_id))
|
|
1454
|
+
hint: drop one
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
Decide based on workload. If you frequently query by `user_id` alone, keep both. If the composite is the only access pattern, drop the auto-index via `{ index: false }`.
|
|
1458
|
+
|
|
1459
|
+
## Caveats
|
|
1460
|
+
|
|
1461
|
+
- **Cascade can chain deeply**. A delete on `users` cascades to `memberships`, then to anything that references `memberships` with cascade, and so on. Trace the depth before you set `'cascade'` on a high-cardinality table.
|
|
1462
|
+
- **Postgres `DEFERRABLE INITIALLY DEFERRED` FKs**. The framework doesn't emit DEFERRED constraints. If you need them (mass-loading scenarios), emit them in a custom migration.
|
|
1463
|
+
- **MariaDB enforces FK constraints in storage engines that support them**. InnoDB (the default) enforces; MyISAM doesn't. Use InnoDB. The framework's DDL emitter assumes it.
|
|
1464
|
+
- **SQLite enforcement requires `PRAGMA foreign_keys = ON`**. The framework sets this at connect. Other clients that open the same database without setting the pragma bypass FK enforcement silently.
|
|
1465
|
+
|
|
1466
|
+
## Where it lives
|
|
1467
|
+
|
|
1468
|
+
- `voltro/packages/database/src/columns.ts` — `reference()` accepts `onDelete` / `onUpdate` / `index`
|
|
1469
|
+
- `voltro/packages/database/src/migrate.ts` — `referentialAction(action, dialect)`; the `REFERENCES <table> (id)` clause is emitted inline in the column-DDL builder alongside it
|
|
1470
|
+
- `voltro/packages/database/src/indexAudit.ts` — boot-time redundant-prefix warnings
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
---
|
|
1475
|
+
|
|
1476
|
+
<!-- source: en/database/relations/reactive.md -->
|
|
1477
|
+
## Reactive invalidation
|
|
1478
|
+
|
|
1479
|
+
_How `.with()` subscriptions wake on dependent-table changes. two-stage gate: a per-table dependency-graph plus a per-field pre-filter, soundness argument, trade-offs._
|
|
1480
|
+
|
|
1481
|
+
Reactive subscriptions on `.with()` queries don't just track the root table — they walk the eager spec at subscribe time, register against every dependent table, and pre-filter change events at the column level so updates that don't matter never reach the SQL planner.
|
|
1482
|
+
|
|
1483
|
+
## How it works
|
|
1484
|
+
|
|
1485
|
+
When a subscription opens with `database.users.with({ posts: { with: { author: true } } })`, three things happen at subscribe time:
|
|
1486
|
+
|
|
1487
|
+
1. **Snapshot fetch**: the framework runs the eager-load query once, delivers the result as `_tag: 'snapshot'` to the subscriber.
|
|
1488
|
+
2. **Dependency graph registration**: `resolveDependentTables(descriptor)` walks the spec + relations registry to produce `{users, posts}` (author resolves back to users so it deduplicates). The dispatcher registers the subscription against EACH table.
|
|
1489
|
+
3. **Relevance map computation**: `resolveRelevantFields(descriptor)` collects, per dependent table, the column SET whose change could affect the snapshot:
|
|
1490
|
+
- Projected columns (from `descriptor.projection`; wildcard `*` when unset).
|
|
1491
|
+
- Predicate columns (every column reachable from the WHERE clause AST).
|
|
1492
|
+
- Order columns (`descriptor.order[*].column`).
|
|
1493
|
+
- Eager-load join keys (source + target FK on every relation).
|
|
1494
|
+
|
|
1495
|
+
At runtime, every `ChangeEvent` consults this map BEFORE triggering a re-query.
|
|
1496
|
+
|
|
1497
|
+
## v1 — Dependency graph fan-out
|
|
1498
|
+
|
|
1499
|
+
Stage 1 — the dependency graph tracks per-table dependency. When `posts` changes, the dispatcher finds every subscription whose dependent-table set includes `posts` and triggers a re-query. After the re-query, a shallow row-set compare (JSON.stringify per row) suppresses the delta if the result is shape-identical to the last delivered snapshot.
|
|
1500
|
+
|
|
1501
|
+
```text
|
|
1502
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
1503
|
+
│ Subscription: users.with({ posts: { with: { author: true } } }) │
|
|
1504
|
+
│ │
|
|
1505
|
+
│ change(users) → re-query → diff vs lastDelivered → maybe delta │
|
|
1506
|
+
│ change(posts) → re-query → diff vs lastDelivered → maybe delta │
|
|
1507
|
+
│ │
|
|
1508
|
+
│ change(orgs) → no-op (not in dependency graph) │
|
|
1509
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
This is CORRECT but expensive: every write to a dependent table costs a SQL round-trip, even when the write touched a column the subscription doesn't care about.
|
|
1513
|
+
|
|
1514
|
+
## Stage 2 — the per-field pre-filter
|
|
1515
|
+
|
|
1516
|
+
The per-field pre-filter adds a column-grain gate AHEAD of the re-query. On a write:
|
|
1517
|
+
|
|
1518
|
+
1. `mutatedColumns(event)` returns the columns the change actually touched.
|
|
1519
|
+
- Insert / delete → wildcard `*` (membership change always relevant).
|
|
1520
|
+
- Update → diff `old` vs `new` for differing values.
|
|
1521
|
+
- Incomplete event (old=null on update) → wildcard (soundness).
|
|
1522
|
+
2. `isEventRelevant(event, relevantMap)` intersects mutated × relevant.
|
|
1523
|
+
3. Empty intersection → **skip the re-query entirely**. No SQL round-trip. No deep-equal compare. No delta.
|
|
1524
|
+
|
|
1525
|
+
```text
|
|
1526
|
+
relevantMap = {
|
|
1527
|
+
users: { id, email, *projected, …predicate, …order },
|
|
1528
|
+
posts: { id, author_id, *projected, …predicate, …order },
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
mutatedColumns(event{table: 'users', op: 'update', old: {x:1}, new: {x:2}}) = { 'x' }
|
|
1532
|
+
isEventRelevant ⇒ 'x' ∉ relevantMap.users ⇒ skip
|
|
1533
|
+
```
|
|
1534
|
+
|
|
1535
|
+
The dispatcher logs the skip count per change event so operators can verify the filter is paying off:
|
|
1536
|
+
|
|
1537
|
+
```sh
|
|
1538
|
+
voltro logs --tail 50 | grep handleChange/prefiltered
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
## Soundness
|
|
1542
|
+
|
|
1543
|
+
The relevance set is a SUPER-set of fields whose change can flip the snapshot. The collection rules (`resolveRelevantFields`) overshoot deliberately:
|
|
1544
|
+
|
|
1545
|
+
- **Predicate columns**: a write to a predicate column may change set membership, even if the value the predicate compares against doesn't change.
|
|
1546
|
+
- **Order columns**: a write to an order column may shift the row's position, changing the slice the subscription sees.
|
|
1547
|
+
- **Eager-load join keys (FK + PK on each side)**: a write to a FK may shift which children belong to which parent.
|
|
1548
|
+
- **Projected columns**: if the snapshot emits the column, every change to it is by definition a delta.
|
|
1549
|
+
- **`*` wildcard when projection is unset**: the snapshot emits the full row, so every column is potentially load-bearing.
|
|
1550
|
+
|
|
1551
|
+
Skipping a write that lies OUTSIDE this set cannot produce a missed delta: the resulting snapshot was already correct. The pre-filter has zero false negatives.
|
|
1552
|
+
|
|
1553
|
+
The only failure mode is a false positive (slower v1 path runs unnecessarily) — no correctness consequence, just wasted work.
|
|
1554
|
+
|
|
1555
|
+
## Insert / delete always pass through
|
|
1556
|
+
|
|
1557
|
+
Membership changes are load-bearing regardless of which columns the row carries. The pre-filter treats insert and delete as wildcard-relevant, so they always reach the re-query path. v1's deep-equal compare still suppresses the DELTA if the snapshot happens to be shape-identical (rare on insert/delete but possible if the new/deleted row was outside the subscription's predicate set).
|
|
1558
|
+
|
|
1559
|
+
## Walking the eager spec
|
|
1560
|
+
|
|
1561
|
+
For each relation in the `.with()` spec, the relevance walker adds:
|
|
1562
|
+
|
|
1563
|
+
- **`one()`**: source's `sourceKey` (or `id` if not specified) + target's `id` + target's projected/predicate/order columns + target's own eager-load contributions.
|
|
1564
|
+
- **`many()`**: target's `foreignKey` (the FK on the target side) + target's `id` + target's per-branch where/order/limit columns + target's full schema columns (since `with: true` doesn't constrain projection).
|
|
1565
|
+
- **`manyToMany()`**: junction's `sourceKey` + junction's `targetKey` + target's `id` + target's per-branch contributions.
|
|
1566
|
+
|
|
1567
|
+
For very deep nested specs (depth > 3), the relevance set can include MANY tables. The dispatcher's per-table lookup is `O(1)` so the cost is dominated by the actual column intersection check, not by tree depth.
|
|
1568
|
+
|
|
1569
|
+
## When the pre-filter helps the most
|
|
1570
|
+
|
|
1571
|
+
The pre-filter is most effective when:
|
|
1572
|
+
|
|
1573
|
+
- Subscriptions read NARROW projections (`projection: ['id', 'email']`) — every column-narrow write to other fields is skipped.
|
|
1574
|
+
- Tables have schema-wide hot columns (`updatedAt`, `lastSeenAt`, view counters) that lots of writes touch but no subscription reads.
|
|
1575
|
+
- Eager-loaded child tables have high write rates (chat messages, log entries) where most writes touch fields the parent doesn't project.
|
|
1576
|
+
|
|
1577
|
+
## When the pre-filter helps less
|
|
1578
|
+
|
|
1579
|
+
- Subscriptions without projection (the default — `with: true` on every branch reads every column). The relevance set is wildcard everywhere; v1 fan-out kicks in for every dependent change.
|
|
1580
|
+
- Hot tables where every write touches at least one relevant column (every row carries `updated_at = NOW()` on every update, and `updated_at` is in the order clause).
|
|
1581
|
+
|
|
1582
|
+
For these cases, mitigations:
|
|
1583
|
+
|
|
1584
|
+
1. **Project narrowly.** `projection: ['id', 'email']` on the subscription's descriptor narrows the relevance set.
|
|
1585
|
+
2. **Split the subscription.** `database.users.where(eq('id', uid))` (no `.with()`) for the root, `database.orgs.where(...)` for the children. Each fires only on its own table.
|
|
1586
|
+
3. **`unsafe()` for hand-tuned queries** when the framework's portable shape doesn't match what you need.
|
|
1587
|
+
|
|
1588
|
+
## Observability
|
|
1589
|
+
|
|
1590
|
+
Two ways to verify the filter is working:
|
|
1591
|
+
|
|
1592
|
+
### Trace logs
|
|
1593
|
+
|
|
1594
|
+
```sh
|
|
1595
|
+
voltro logs --tail 100 | grep handleChange
|
|
1596
|
+
```
|
|
1597
|
+
|
|
1598
|
+
Look for the per-change-event summary:
|
|
1599
|
+
|
|
1600
|
+
```
|
|
1601
|
+
{ "table": "posts", "op": "update", "matcher": 3, "dependent": 12, "triggered": 15 }
|
|
1602
|
+
{ "table": "posts", "op": "update", "skipped": 11 }
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
`triggered` is how many subscriptions had `posts` in their dependent-table set. `skipped` is how many of those the pre-filter filtered out before the re-query. Ideal ratio: high `skipped` / `triggered` on hot tables.
|
|
1606
|
+
|
|
1607
|
+
### Voltro Cloud dashboard
|
|
1608
|
+
|
|
1609
|
+
The reactive subscriptions panel (under the per-app Inspect tab) shows per-subscription details:
|
|
1610
|
+
|
|
1611
|
+
- Dependent table count.
|
|
1612
|
+
- Relevant columns per table.
|
|
1613
|
+
- Skip rate (recent window).
|
|
1614
|
+
|
|
1615
|
+
Use these to find subscriptions that aren't benefiting from the pre-filter and need narrowing.
|
|
1616
|
+
|
|
1617
|
+
## Where it lives
|
|
1618
|
+
|
|
1619
|
+
- `voltro/packages/runtime/src/relevantFields.ts` — relevance walker + `isEventRelevant` + `mutatedColumns`
|
|
1620
|
+
- `voltro/packages/runtime/src/dependencyGraph.ts` — `resolveDependentTables` v1 walker
|
|
1621
|
+
- `voltro/packages/runtime/src/dispatcher.ts` — `handleChange` pre-filter call + skip-count log
|
|
1622
|
+
- `voltro/packages/runtime/src/dispatcherReactive.test.ts` — dependency-graph + pre-filter tests
|