@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,1583 @@
|
|
|
1
|
+
# database.advancedQueries
|
|
2
|
+
|
|
3
|
+
> Btree, GIN, GiST, BRIN, HNSW — when to use what, plus partial + expression + composite indexes.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/database/indexes.md -->
|
|
10
|
+
## Indexes
|
|
11
|
+
|
|
12
|
+
_Btree, GIN, GiST, BRIN, HNSW — when to use what, plus partial + expression + composite indexes._
|
|
13
|
+
|
|
14
|
+
Indexes accelerate reads at the cost of write throughput + storage. Voltro's DSL lets you declare every Postgres index type with explicit intent.
|
|
15
|
+
|
|
16
|
+
## Single-column index
|
|
17
|
+
|
|
18
|
+
Indexes are declared at the table level — there is no column-level
|
|
19
|
+
`.index()` modifier. The single surface is `.index(...)`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const notes = table('notes', {
|
|
23
|
+
id: id(),
|
|
24
|
+
authorId: text(),
|
|
25
|
+
tenantId: text(),
|
|
26
|
+
})
|
|
27
|
+
.index(['authorId']) // auto-named → "notes_authorId_idx"
|
|
28
|
+
.index(['tenantId'])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Each generates `CREATE INDEX notes_<col>_idx ON notes (<col>);` — btree, default opclass.
|
|
32
|
+
|
|
33
|
+
## Named composite index
|
|
34
|
+
|
|
35
|
+
For multi-column lookups + ORDER BY pagination:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { table, id, text, timestamp } from '@voltro/database'
|
|
39
|
+
|
|
40
|
+
const notes = table('notes', {
|
|
41
|
+
id: id(),
|
|
42
|
+
tenantId: text(),
|
|
43
|
+
createdAt: timestamp(),
|
|
44
|
+
})
|
|
45
|
+
// Chained `.index(name, [cols], options?)` on the table — leftmost column
|
|
46
|
+
// is the most-selective filter. Index fields are plain column names
|
|
47
|
+
// (or `{ expr: '…' }` for an expression).
|
|
48
|
+
.index('notes_tenant_created', ['tenantId', 'createdAt'])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Column order matters — leftmost is the most-selective filter. The framework warns when an index's leftmost column is also covered by a single-column index (redundancy).
|
|
52
|
+
|
|
53
|
+
## Indexing mixin-contributed columns
|
|
54
|
+
|
|
55
|
+
`.index([...])` / `.unique([...])` keys accept the table's **own** columns
|
|
56
|
+
plus the columns each **directly applied** mixin contributes — `tenant()` →
|
|
57
|
+
`tenantId`, `audit()` → `createdAt` / `updatedAt` / `createdBy` / `updatedBy`,
|
|
58
|
+
`softDelete()` → `deletedAt` / `deletedBy`. **Order matters:** declare the
|
|
59
|
+
index or unique constraint **after** `.with(...)`, so the mixin columns are in
|
|
60
|
+
scope. Calling `.index([...])` *before* `.with(...)` sees only the own columns
|
|
61
|
+
(the mixin hasn't been applied yet).
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export const memberships = table('memberships', {
|
|
65
|
+
id: id(),
|
|
66
|
+
userId: reference(() => users),
|
|
67
|
+
})
|
|
68
|
+
.with(audit(), tenant()) // applies tenantId + audit columns
|
|
69
|
+
.unique(['tenantId', 'userId']) // ✅ mixin column tenantId is addressable
|
|
70
|
+
.index(['createdAt']) // ✅ audit column, applied above
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Typos are still caught — the key is the real merged column set, not an
|
|
74
|
+
"accept any string" escape hatch. `tenant()` already ships its OWN `tenantId`
|
|
75
|
+
index, so a plain per-tenant lookup needs nothing extra; this only matters for
|
|
76
|
+
the additional composite keys you author yourself.
|
|
77
|
+
|
|
78
|
+
**Transitive caveat.** Only **directly applied** mixin columns enter the key
|
|
79
|
+
union. `.with(tenant())` adds `tenantId` but NOT `audit()`'s columns — even
|
|
80
|
+
though `tenant()` requires `audit()` and those columns exist at runtime. To
|
|
81
|
+
index a transitively-required mixin's column, apply that mixin explicitly:
|
|
82
|
+
`.with(audit(), tenant())`.
|
|
83
|
+
|
|
84
|
+
## Index types
|
|
85
|
+
|
|
86
|
+
Every index kind is selected with the `{ kind }` option on the
|
|
87
|
+
table-level `.index(name, [cols], { kind })` (or `.expressionIndex(...)`).
|
|
88
|
+
The default is `btree`.
|
|
89
|
+
|
|
90
|
+
### Btree *(default)*
|
|
91
|
+
|
|
92
|
+
Equality + range + ordering. The right choice 90% of the time. Leave
|
|
93
|
+
`kind` off to get it:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
table('notes', { id: id(), authorId: text() })
|
|
97
|
+
.index('notes_author', ['authorId']) // btree, default opclass
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### GIN
|
|
101
|
+
|
|
102
|
+
For arrays, JSONB containment, and full-text search:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { table, id, json } from '@voltro/database'
|
|
106
|
+
|
|
107
|
+
table('docs', { id: id(), tags: json<string[]>() })
|
|
108
|
+
.index('docs_tags', ['tags'], { kind: 'gin' })
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
On postgres this emits `USING GIN`. GIN is postgres-only — on
|
|
112
|
+
mysql / mariadb a btree can't serve containment so the migrator skips
|
|
113
|
+
the index + warns (denormalise the path you query into a generated
|
|
114
|
+
column and index THAT instead); on mssql / sqlite it falls back to a
|
|
115
|
+
plain btree + a warning.
|
|
116
|
+
|
|
117
|
+
### GiST
|
|
118
|
+
|
|
119
|
+
For range types, geometric types (PostGIS), and full-text search where
|
|
120
|
+
you need ranking:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
table('venues', { id: id(), location: text() }) // geography(...) in real PostGIS code
|
|
124
|
+
.expressionIndex('venues_loc_gist', [{ expr: '"location"' }], { kind: 'gist' })
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Postgres-only; the migrator warns + falls back to btree on other
|
|
128
|
+
dialects. See [PostGIS](/docs/database/postgis) for the spatial story.
|
|
129
|
+
|
|
130
|
+
### BRIN
|
|
131
|
+
|
|
132
|
+
Block-range index — extremely cheap for very large append-mostly tables
|
|
133
|
+
sorted by an indexed column (time-series, logs):
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
table('events', { id: id(), occurredAt: timestamp() })
|
|
137
|
+
.index('events_occurred', ['occurredAt'], { kind: 'brin' })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
On postgres this emits `USING BRIN` — tiny (~1% of the table size) +
|
|
141
|
+
fast for range scans on append-ordered data, useless for random-access
|
|
142
|
+
lookups. Other dialects have no block-range method; the migrator falls
|
|
143
|
+
back to a plain btree + a warning (the range scan still works, just
|
|
144
|
+
without BRIN's size win).
|
|
145
|
+
|
|
146
|
+
### HNSW (pgvector)
|
|
147
|
+
|
|
148
|
+
For vector similarity search. The index must cover exactly one
|
|
149
|
+
`vector()` column — declaring `kind: 'hnsw'` on anything else throws at
|
|
150
|
+
declaration time:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
table('embeddings', { id: id(), embedding: vector(1536) })
|
|
154
|
+
.index('emb_hnsw', ['embedding'], { kind: 'hnsw' })
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Defaults to `m=16`, `ef_construction=64`, opclass `vector_cosine_ops`.
|
|
158
|
+
Override the tuning knobs via `kindOptions.hnsw`:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
table('embeddings', { id: id(), embedding: vector(1536) })
|
|
162
|
+
.index('emb_hnsw', ['embedding'], {
|
|
163
|
+
kind: 'hnsw',
|
|
164
|
+
kindOptions: { hnsw: { m: 24, efConstruction: 128 } },
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Postgres + pgvector only; on every other dialect a btree on a vector is
|
|
169
|
+
meaningless, so the migrator emits no index + warns (ANN queries fall
|
|
170
|
+
back to a sequential scan). The distance metric → opclass mapping
|
|
171
|
+
(`vector_cosine_ops` / `vector_l2_ops` / `vector_ip_ops`) is covered on
|
|
172
|
+
the [Vector columns](/docs/database/vectors) page.
|
|
173
|
+
|
|
174
|
+
### Per-dialect support matrix
|
|
175
|
+
|
|
176
|
+
`warn+btree` = the migrator emits a plain btree index + a
|
|
177
|
+
`[voltro:migrate]` warning (the intended method was unavailable but a
|
|
178
|
+
btree still helps). `skip+warn` = no index is emitted + a warning (a
|
|
179
|
+
btree on that column would be useless).
|
|
180
|
+
|
|
181
|
+
| `kind` | postgres | mysql | mariadb | mssql | sqlite |
|
|
182
|
+
|--------------------|-------------------------------------------------------|-------------|-------------|------------|------------|
|
|
183
|
+
| `btree` *(default)*| `USING btree` (implicit) | implicit | implicit | implicit | implicit |
|
|
184
|
+
| `gist` | `USING GIST` | warn+btree | warn+btree | warn+btree | warn+btree |
|
|
185
|
+
| `gin` | `USING GIN` | skip+warn | skip+warn | warn+btree | warn+btree |
|
|
186
|
+
| `brin` | `USING BRIN` | warn+btree | warn+btree | warn+btree | warn+btree |
|
|
187
|
+
| `hnsw` | `USING hnsw (col <opclass>) WITH (m=…, ef_construction=…)` | skip+warn | skip+warn | skip+warn | skip+warn |
|
|
188
|
+
|
|
189
|
+
A "jsonb index" is not a separate kind — it's a `json()` column (already
|
|
190
|
+
`JSONB` on postgres) plus `{ kind: 'gin' }`, i.e. the `gin` row above.
|
|
191
|
+
|
|
192
|
+
## Partial indexes
|
|
193
|
+
|
|
194
|
+
Index only rows matching a predicate — drastically smaller + faster when most rows wouldn't match. Pass `{ where }`:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
table('notes', { id: id(), updatedAt: timestamp(), archived: boolean() })
|
|
198
|
+
.index('notes_unarchived', ['updatedAt'], { where: `"archived" = false` })
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Useful for soft-delete tables (`WHERE deletedAt IS NULL`) and status-filtered queries. See [Partial indexes (WHERE clause)](#partial-indexes-where-clause) below for the full cross-dialect story.
|
|
202
|
+
|
|
203
|
+
## Expression indexes
|
|
204
|
+
|
|
205
|
+
Index a computed value, not a column — use `.expressionIndex(...)` with an `{ expr }` entry:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
table('users', { id: id(), email: text() })
|
|
209
|
+
.expressionIndex('users_email_lower', [{ expr: 'lower("email")' }])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then `WHERE lower("email") = ?` uses the index. The query builder doesn't auto-rewrite `WHERE email ILIKE 'foo'` to use this — you call out the expression explicitly.
|
|
213
|
+
|
|
214
|
+
## Unique indexes
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
text().unique() // single-column, on the column
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Multi-column uniqueness is declared at the table level with `.unique(name, [cols])` — see [Composite UNIQUE constraints](#composite-unique-constraints) below.
|
|
221
|
+
|
|
222
|
+
## When NOT to index
|
|
223
|
+
|
|
224
|
+
- Tables with <10k rows on a fast disk — the cost of maintaining the index outweighs the seq-scan cost.
|
|
225
|
+
- Columns with very low selectivity (boolean flags, `status` enums where one value dominates). Use a partial index instead.
|
|
226
|
+
- Write-heavy hot paths. Every index is a synchronous write on every insert/update.
|
|
227
|
+
|
|
228
|
+
## Partial indexes (WHERE clause)
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
table('orders', {
|
|
232
|
+
id: id(),
|
|
233
|
+
orgId: text(),
|
|
234
|
+
status: text(),
|
|
235
|
+
createdAt: timestamp(),
|
|
236
|
+
})
|
|
237
|
+
.index('byOpenStatus', ['orgId', 'createdAt'], {
|
|
238
|
+
where: `"status" IN ('pending', 'approved')`,
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The DDL emits `CREATE INDEX ... WHERE ...` — only rows matching the
|
|
243
|
+
predicate participate in the index. Use cases:
|
|
244
|
+
|
|
245
|
+
- **Index only non-soft-deleted rows**: `where: \`"deletedAt" IS NULL\``
|
|
246
|
+
- **Index only active users**: `where: \`"banned" = false\``
|
|
247
|
+
- **Index only open tickets**: `where: \`"status" IN ('open', 'pending')\``
|
|
248
|
+
|
|
249
|
+
The result: a much smaller B-tree (faster reads, smaller cache
|
|
250
|
+
footprint) at the cost of one extra `WHERE` clause the query
|
|
251
|
+
planner has to match against.
|
|
252
|
+
|
|
253
|
+
Cross-dialect:
|
|
254
|
+
|
|
255
|
+
| Dialect | Support |
|
|
256
|
+
|---------------------|----------------------------------------------------|
|
|
257
|
+
| postgres / sqlite | native `CREATE INDEX ... WHERE ...` |
|
|
258
|
+
| mssql | native "filtered index" |
|
|
259
|
+
| mysql / mariadb | NOT supported → drops the WHERE + warns at migrate |
|
|
260
|
+
|
|
261
|
+
The `where` clause is emitted verbatim — caller is responsible for
|
|
262
|
+
quoting identifiers per the target dialect.
|
|
263
|
+
|
|
264
|
+
## Expression indexes (functions on columns)
|
|
265
|
+
|
|
266
|
+
For predicates that compute on the column rather than match exact
|
|
267
|
+
values, use `.expressionIndex()`:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
table('users', { id: id(), email: text() })
|
|
271
|
+
.expressionIndex('byEmailCi', [{ expr: 'lower("email")' }])
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Now `WHERE lower("email") = ?` uses the index. Without it,
|
|
275
|
+
case-insensitive email lookup is a sequential scan.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
// Mix columns + expressions
|
|
279
|
+
.expressionIndex('byOrgCreatedMonth', [
|
|
280
|
+
'orgId',
|
|
281
|
+
{ expr: \`date_trunc('month', "createdAt")\` },
|
|
282
|
+
])
|
|
283
|
+
|
|
284
|
+
// Combine with partial-where
|
|
285
|
+
.expressionIndex('byActiveEmailCi',
|
|
286
|
+
[{ expr: 'lower("email")' }],
|
|
287
|
+
{ where: \`"active" = true\` },
|
|
288
|
+
)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Why a separate method from `.index([...])`:
|
|
292
|
+
|
|
293
|
+
- `.index([cols])` validates the column names against the row type
|
|
294
|
+
at compile time. Typos fail at `tsc --noEmit`.
|
|
295
|
+
- `.expressionIndex(name, [...])` accepts arbitrary expression
|
|
296
|
+
strings — by definition the framework can't type-check them.
|
|
297
|
+
Keeping the two methods separate preserves the compile-time
|
|
298
|
+
safety of the regular form.
|
|
299
|
+
|
|
300
|
+
## Index names are unique per SCHEMA, not per table
|
|
301
|
+
|
|
302
|
+
Every dialect keys index names per schema (postgres `pg_class`, mysql /
|
|
303
|
+
mariadb `information_schema`, mssql `sys.indexes`, sqlite `sqlite_master`)
|
|
304
|
+
— **not per table**. So a hand-picked name reused on two tables collides:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
table('ab_tests', { /* … */ }).index('byStatusStart', ['status', 'startAt'])
|
|
308
|
+
table('tournaments', { /* … */ }).index('byStatusStart', ['status', 'startAt'])
|
|
309
|
+
// ❌ throws — 'byStatusStart' would exist twice in one schema
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The DB creates only the first; every later `CREATE INDEX … IF NOT EXISTS
|
|
313
|
+
<name>` is a silent no-op, so `db plan` re-emits the un-created ones
|
|
314
|
+
forever and never reaches "up to date". The framework catches this when
|
|
315
|
+
the full schema is snapshotted (boot / `db plan`) and fails loud, naming
|
|
316
|
+
both tables + a suggested fix. **Auto-named** indexes (`.index([col])` →
|
|
317
|
+
`<table>_<col>_idx`) are table-prefixed and can't collide — only
|
|
318
|
+
explicit names can. Give each a distinct, table-scoped name
|
|
319
|
+
(`abTestsByStatusStart`, `tournamentsByStatusStart`).
|
|
320
|
+
|
|
321
|
+
## Composite UNIQUE constraints
|
|
322
|
+
|
|
323
|
+
Multi-column uniqueness — `(orgId, slug)` must be unique so two
|
|
324
|
+
orgs can both have a `/dashboard` slug but neither can have two of
|
|
325
|
+
their own:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
table('org_slugs', {
|
|
329
|
+
id: id(),
|
|
330
|
+
orgId: reference(() => orgs),
|
|
331
|
+
slug: text(),
|
|
332
|
+
})
|
|
333
|
+
.unique(['orgId', 'slug']) // auto-named
|
|
334
|
+
.unique('byOrgSlug', ['orgId', 'slug']) // explicit name
|
|
335
|
+
.unique('byOrgSlug', ['orgId', 'slug'], { dedup: 'suffix-counter' }) // with backfill policy
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Distinct from the column-level `.unique()` modifier (single-column
|
|
339
|
+
only, lives on the column). Composite UNIQUE MUST be declared at
|
|
340
|
+
the table level.
|
|
341
|
+
|
|
342
|
+
The `dedup` policy tells the migration planner what to do when the
|
|
343
|
+
constraint is added to a populated table with duplicates:
|
|
344
|
+
|
|
345
|
+
- `'fail'` (default) — refuse with the conflicting rows surfaced
|
|
346
|
+
- `'suffix-counter'` — UPDATE conflicts to `<value>-2`, `<value>-3`, ...
|
|
347
|
+
- `sql\`...\`` — custom SQL fragment
|
|
348
|
+
|
|
349
|
+
Emits `CONSTRAINT <name> UNIQUE (col1, col2, ...)` inline in CREATE
|
|
350
|
+
TABLE on every dialect. Standard SQL.
|
|
351
|
+
|
|
352
|
+
This is what backs `ctx.store.upsert(..., { conflictColumns: ['a', 'b'] })`
|
|
353
|
+
— see [Bulk operations](/docs/database/bulk-operations#upsert).
|
|
354
|
+
|
|
355
|
+
## GiST indexes (PostGIS spatial)
|
|
356
|
+
|
|
357
|
+
`{ kind: 'gist' }` on `.expressionIndex()` emits `USING GIST`:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
table('venues', {
|
|
361
|
+
id: id(),
|
|
362
|
+
location: geography('Point', 4326), // from @voltro/plugin-postgis
|
|
363
|
+
}).expressionIndex(
|
|
364
|
+
'venues_loc_gist',
|
|
365
|
+
[{ expr: '"location"' }],
|
|
366
|
+
{ kind: 'gist' },
|
|
367
|
+
)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
GiST is the access method PostGIS needs for spatial predicates
|
|
371
|
+
(`ST_DWithin`, `ST_Contains`, etc.) — a regular B-tree index can't
|
|
372
|
+
serve them. Postgres-only; the migrator warns + falls back to
|
|
373
|
+
B-tree on other dialects.
|
|
374
|
+
|
|
375
|
+
See [PostGIS](/docs/database/postgis) for the full spatial story.
|
|
376
|
+
|
|
377
|
+
## Full-text indexes
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
table('posts', { id: id(), title: text(), body: text() })
|
|
381
|
+
.fullTextIndex('postSearch', ['title', 'body'], {
|
|
382
|
+
config: 'english',
|
|
383
|
+
weights: { title: 'A', body: 'B' },
|
|
384
|
+
})
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Higher-level abstraction over expression indexes — see
|
|
388
|
+
[Full-text search](/docs/database/full-text-search) for the full
|
|
389
|
+
shape.
|
|
390
|
+
|
|
391
|
+
## Inspecting
|
|
392
|
+
|
|
393
|
+
```sh
|
|
394
|
+
# The migrations introspection endpoint carries the full live schema —
|
|
395
|
+
# every table's columns + indexes as the runtime sees them.
|
|
396
|
+
curl http://localhost:5191/_voltro/inspect/migrations
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Or via the dashboard's Database tab.
|
|
400
|
+
|
|
401
|
+
For Postgres-side inspection: `pg_stat_user_indexes` shows scans + tuples read per index. Indexes with `idx_scan = 0` after weeks of traffic are deadweight — drop them.
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
<!-- source: en/database/json.md -->
|
|
408
|
+
## JSON columns
|
|
409
|
+
|
|
410
|
+
_Typed jsonb columns, path queries, GIN indexing, and when to denormalise into real columns._
|
|
411
|
+
|
|
412
|
+
`json<T>()` declares a `jsonb` column whose JSON shape is typed by `T`. The runtime decodes on read + validates on write — typos in your object literals fail the TypeScript check, not at runtime.
|
|
413
|
+
|
|
414
|
+
## Declaring a typed JSON column
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { table, id, json } from '@voltro/database'
|
|
418
|
+
|
|
419
|
+
interface NotePrefs {
|
|
420
|
+
readonly fontSize: 'sm' | 'md' | 'lg'
|
|
421
|
+
readonly collapsed: ReadonlyArray<string>
|
|
422
|
+
readonly autoSave?: boolean
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const notes = table('notes', {
|
|
426
|
+
id: id(),
|
|
427
|
+
prefs: json<NotePrefs>().default({ fontSize: 'md', collapsed: [] }),
|
|
428
|
+
})
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`ctx.store.select('notes')` returns `prefs: NotePrefs` — autocomplete works in your IDE, mutation inputs are checked too.
|
|
432
|
+
|
|
433
|
+
## Storage + validation per dialect
|
|
434
|
+
|
|
435
|
+
`json()` maps to each engine's native JSON type, and Voltro enforces JSON validity **on write, on every dialect** — invalid JSON is rejected by the database, not only by TypeScript:
|
|
436
|
+
|
|
437
|
+
| Dialect | Column type | Validated on write |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| postgres | `JSONB` (binary, GIN-indexable) | yes (native) |
|
|
440
|
+
| mysql / mariadb | `JSON` | yes (mariadb's `JSON` is `LONGTEXT` + an auto `json_valid` CHECK) |
|
|
441
|
+
| mssql | `NVARCHAR(MAX)` + `CHECK (ISJSON(col)=1)` | yes |
|
|
442
|
+
| sqlite | `TEXT` + `CHECK (json_valid(col))` | yes |
|
|
443
|
+
|
|
444
|
+
On postgres the binary `JSONB` form is what makes path queries + GIN indexing fast; the other engines store JSON as text (mariadb's `information_schema` reports the `JSON` column as `longtext` — that *is* what the JSON type is there). Either way, the `jsonField(...)` filters below and write-validation behave identically across all of them, and reads always come back as parsed objects/arrays — never raw strings.
|
|
445
|
+
|
|
446
|
+
> On postgres, prefer `jsonb` over `json` (Voltro always emits `jsonb`): binary storage, GIN-indexable, faster. Plain `json` only preserves exact byte / whitespace / key-order — never what you want for app data.
|
|
447
|
+
|
|
448
|
+
## Path filters
|
|
449
|
+
|
|
450
|
+
Filter on a value *inside* a `json()` column with `jsonField(column, ...path)`:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import { jsonField } from '@voltro/database'
|
|
454
|
+
|
|
455
|
+
database.users.where(jsonField('preferences', 'theme').eq('dark'))
|
|
456
|
+
database.events.where(jsonField('payload', 'amount').gt(1000)) // numeric, not lexical
|
|
457
|
+
database.docs.where(jsonField('meta', 'tags', 0).eq('urgent')) // nested key + array index
|
|
458
|
+
database.users.where(jsonField('preferences', 'locale').inSet(['de', 'en']))
|
|
459
|
+
database.users.where(jsonField('preferences', 'theme').isNotNull())
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Path segments are object keys (string) or array indices (number) → `$.theme` / `$.tags[0]`.
|
|
463
|
+
|
|
464
|
+
| Operators | Compare the extracted value as |
|
|
465
|
+
|---|---|
|
|
466
|
+
| `eq` `neq` `inSet` `notInSet` `contains` `isNull` `isNotNull` | **text** |
|
|
467
|
+
| `gt` `gte` `lt` `lte` | **number** (numeric ordering, not lexical) |
|
|
468
|
+
|
|
469
|
+
The same `jsonField(...)` expression is **portable across every backend** — it lowers to each dialect's json accessor: postgres `#>>`, mysql/mariadb `JSON_EXTRACT` (+ `JSON_UNQUOTE`), mssql `JSON_VALUE`, sqlite / turso `json_extract`.
|
|
470
|
+
|
|
471
|
+
Reactive subscriptions filtered by a JSON path stay live, but the matcher treats the leaf as **non-indexable**: it's re-checked on every change to the table (the same way `contains` is). For a high-traffic filter, denormalise into a real indexed column — or index the specific path (see [Indexing JSON columns](#indexing-json-columns) below).
|
|
472
|
+
|
|
473
|
+
> **Scope.** JSON-path filtering is a **server-side** query-DSL feature (query / mutation handlers). Clients can't yet send JSON-path filters over rpc — that's a deliberate later phase (it needs path allow-listing + validation). For arbitrary expressions the DSL doesn't model, drop to the `store.raw` escape hatch (see the [query builder](/docs/database/query-builder#raw-sql-escape-hatch)).
|
|
474
|
+
|
|
475
|
+
## Indexing JSON columns
|
|
476
|
+
|
|
477
|
+
### GIN index — broad coverage
|
|
478
|
+
|
|
479
|
+
A GIN index covers arbitrary containment queries on the whole `jsonb`
|
|
480
|
+
document. Declare it with the table-level `.index(name, [col], { kind: 'gin' })`:
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
import { table, id, json } from '@voltro/database'
|
|
484
|
+
|
|
485
|
+
const events = table('events', {
|
|
486
|
+
id: id(),
|
|
487
|
+
payload: json<unknown>(),
|
|
488
|
+
})
|
|
489
|
+
.index('events_payload', ['payload'], { kind: 'gin' })
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
On postgres this emits `CREATE INDEX "events_payload" ON "events" USING GIN ("payload")`,
|
|
493
|
+
so any containment query on `payload` is indexed.
|
|
494
|
+
|
|
495
|
+
GIN is postgres-only. On mysql / mariadb there is no GIN access method
|
|
496
|
+
and a btree on a JSON column can't serve containment — the migrator
|
|
497
|
+
**skips** the index and warns, pointing you at the single-path approach
|
|
498
|
+
below. On mssql / sqlite it falls back to a plain btree + a warning.
|
|
499
|
+
GIN is also large (often 30-50% of the table size for wide JSON); use
|
|
500
|
+
the next option when you only filter on one specific path.
|
|
501
|
+
|
|
502
|
+
### Index a JSON path — `jsonIndex`
|
|
503
|
+
|
|
504
|
+
When 99% of your queries look like `jsonField('payload', 'kind').eq('X')`,
|
|
505
|
+
index just that path with `jsonIndex(column, ...path)` inside
|
|
506
|
+
`.expressionIndex(...)`. It mirrors `jsonField` exactly — same column, same
|
|
507
|
+
segments, lowered to the same per-dialect accessor — so a filter on that
|
|
508
|
+
path can use the index:
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
import { table, id, json, jsonIndex } from '@voltro/database'
|
|
512
|
+
|
|
513
|
+
const events = table('events', { id: id(), payload: json<unknown>() })
|
|
514
|
+
.expressionIndex('events_kind', [jsonIndex('payload', 'kind')])
|
|
515
|
+
.expressionIndex('events_amount', [jsonIndex('payload', 'amount').numeric()])
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Text extraction (the default) backs `eq` / `neq` / `inSet` / `contains`;
|
|
519
|
+
`.numeric()` backs the range ops (`gt` / `gte` / `lt` / `lte`). Because the
|
|
520
|
+
index expression is byte-identical to the WHERE expression `jsonField(...)`
|
|
521
|
+
compiles to, the optimiser actually picks it up.
|
|
522
|
+
|
|
523
|
+
> **Dialect support is uneven — this is a hard engine limit, not a Voltro
|
|
524
|
+
> choice.** Only some engines can index an expression directly:
|
|
525
|
+
>
|
|
526
|
+
> | Dialect | `jsonIndex` | Notes |
|
|
527
|
+
> |---|---|---|
|
|
528
|
+
> | postgres | ✓ expression index | `((col #>> '{path}'::text[]))` |
|
|
529
|
+
> | mysql (8.0.13+) | ✓ functional index | `((JSON_UNQUOTE(JSON_EXTRACT(...))))` |
|
|
530
|
+
> | sqlite | ✓ expression index | `(json_extract(col, '$.path'))` |
|
|
531
|
+
> | mariadb | — skipped + warned | no expression-index support |
|
|
532
|
+
> | mssql | — skipped + warned | no expression-index support |
|
|
533
|
+
>
|
|
534
|
+
> On **mariadb / mssql** the migrator skips a `jsonIndex` and warns rather
|
|
535
|
+
> than emit DDL the engine rejects. The idiomatic indexed-JSON path there is
|
|
536
|
+
> a **generated / computed column** (which Voltro already supports), then
|
|
537
|
+
> filter on that column instead of `jsonField(...)`:
|
|
538
|
+
>
|
|
539
|
+
> ```ts
|
|
540
|
+
> table('events', {
|
|
541
|
+
> id: id(),
|
|
542
|
+
> payload: json<unknown>(),
|
|
543
|
+
> // materialise the path into a real, indexable column
|
|
544
|
+
> kind: text().generatedAs(`JSON_UNQUOTE(JSON_EXTRACT(\`payload\`, '$.kind'))`),
|
|
545
|
+
> }).index('events_kind', ['kind'])
|
|
546
|
+
> // query: ctx.store.select('events').where('kind', 'click').all()
|
|
547
|
+
> ```
|
|
548
|
+
|
|
549
|
+
For an arbitrary expression the `jsonIndex` shorthand doesn't model, drop to
|
|
550
|
+
the raw `.expressionIndex(...)` form with an `{ expr }` entry:
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
table('events', { id: id(), payload: json<unknown>() })
|
|
554
|
+
.expressionIndex('events_kind', [{ expr: `(payload->>'kind')` }])
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
There you own per-dialect correctness (the `expr` string is emitted
|
|
558
|
+
verbatim; the postgres `->>` form is shown).
|
|
559
|
+
|
|
560
|
+
## Writing JSON
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
ctx.store.insert('notes', {
|
|
564
|
+
prefs: { fontSize: 'lg', collapsed: ['archive'] },
|
|
565
|
+
})
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
A plain `update` writes the WHOLE JSON value. To change one field
|
|
569
|
+
without rewriting the rest, use **`patchJson`** — a server-side in-place
|
|
570
|
+
JSON merge (no read-modify-write round-trip):
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
// Merge an object over the top level of the column:
|
|
574
|
+
await ctx.store.patchJson('notes', id, 'prefs', { autoSave: true })
|
|
575
|
+
|
|
576
|
+
// Set a nested path (dot-separated; the column is the first segment):
|
|
577
|
+
await ctx.store.patchJson('notes', id, 'prefs.theme', 'dark')
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
`patchJson(table, pk, path, value)` returns the post-image (or `null`
|
|
581
|
+
when the row doesn't exist) and emits an `update` ChangeEvent so reactive
|
|
582
|
+
subscribers see the change. The merge is server-side on every dialect —
|
|
583
|
+
postgres `jsonb_set` / `||`, mysql + mariadb `JSON_SET`, mssql
|
|
584
|
+
`JSON_MODIFY`, sqlite `json_set` / `json_patch`.
|
|
585
|
+
|
|
586
|
+
When you need a full read-modify-write (e.g. computing the new value from
|
|
587
|
+
the old in JS), read the row, change the object, and write it back inside
|
|
588
|
+
the mutation's transaction:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
const note = await ctx.store.select('notes').where('id', id).one()
|
|
592
|
+
await ctx.store.update('notes', id, {
|
|
593
|
+
prefs: { ...note.prefs, autoSave: true },
|
|
594
|
+
})
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
## Validating JSON shape
|
|
598
|
+
|
|
599
|
+
Two layers of validation apply:
|
|
600
|
+
|
|
601
|
+
1. **JSON validity** — that the stored bytes are well-formed JSON — is enforced automatically by the database on every dialect (see [Storage + validation per dialect](#storage--validation-per-dialect)). You don't declare anything.
|
|
602
|
+
2. **JSON *shape*** — that the value matches your expected structure — is up to you: enforce it at the table level with `table().validate(Schema)`:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
import { Schema } from 'effect'
|
|
606
|
+
|
|
607
|
+
table('notes', { id: id(), prefs: json<NotePrefs>() })
|
|
608
|
+
.validate(Schema.Struct({
|
|
609
|
+
prefs: Schema.Struct({ fontSize: Schema.Literal('sm', 'md', 'lg') }),
|
|
610
|
+
}))
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Decode failure throws a typed `TableValidationFailed` before the INSERT runs. There is no `.check()` modifier on a JSON column.
|
|
614
|
+
|
|
615
|
+
## When JSON is the wrong choice
|
|
616
|
+
|
|
617
|
+
JSON is great for:
|
|
618
|
+
|
|
619
|
+
- Free-form user-configurable data (preferences, layout configs)
|
|
620
|
+
- Sparse extensions (every row has different shape)
|
|
621
|
+
- Foreign-system payloads (Stripe webhook bodies, Slack message JSON)
|
|
622
|
+
|
|
623
|
+
JSON is a footgun for:
|
|
624
|
+
|
|
625
|
+
- **Anything you filter on heavily.** Denormalise into real columns — they're cheaper to query, easier to index, simpler to constrain.
|
|
626
|
+
- **Anything with strict schema.** A real column with a NOT NULL + CHECK is stronger than a JSON path constraint.
|
|
627
|
+
- **Joining / relating to other tables.** You can't FK from a JSON path.
|
|
628
|
+
|
|
629
|
+
Rule of thumb: if you'd write a migration to add a new field, it's a real column. If users add fields without your code changing, it's JSON.
|
|
630
|
+
|
|
631
|
+
## Anti-patterns
|
|
632
|
+
|
|
633
|
+
- **`json<any>()` everywhere.** Defeats the type-safety. Be specific.
|
|
634
|
+
- **Putting a foreign key inside JSON.** No FK constraint, no cascade, no clean join. Use a real `reference(() => table)` column.
|
|
635
|
+
- **Storing big binary as JSON.** Use the storage plugin (`@voltro/plugin-storage`) for blobs > a few KB.
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
<!-- source: en/database/recursive-cte.md -->
|
|
642
|
+
## Recursive CTEs
|
|
643
|
+
|
|
644
|
+
_WITH RECURSIVE for org hierarchies, comment threads, file-folder trees, category graphs — single query, no app-side loops._
|
|
645
|
+
|
|
646
|
+
`WITH RECURSIVE` lets a query reference itself. Used for tree-walks
|
|
647
|
+
and graph traversals that would otherwise need an app-side loop with
|
|
648
|
+
N round-trips. Voltro's `.recursiveCte()` ships the SQL standard
|
|
649
|
+
form on every supported dialect (postgres, mysql 8+, mariadb 10.2+,
|
|
650
|
+
mssql, sqlite 3.8+).
|
|
651
|
+
|
|
652
|
+
## When to use
|
|
653
|
+
|
|
654
|
+
- **Org hierarchy**: "find every descendant of org X" / "find every
|
|
655
|
+
parent up to the root"
|
|
656
|
+
- **Comment threads**: "fetch a comment + every reply, recursively"
|
|
657
|
+
- **File-folder tree**: "list everything inside this folder, any depth"
|
|
658
|
+
- **Category graphs**: "products in this category OR any sub-category"
|
|
659
|
+
- **Dependency graphs**: "what migrations does plan X transitively
|
|
660
|
+
depend on?"
|
|
661
|
+
|
|
662
|
+
When you don't have a recursive structure, plain
|
|
663
|
+
[`withCte()`](/docs/database/query-builder#ctes) is enough.
|
|
664
|
+
|
|
665
|
+
## Shape
|
|
666
|
+
|
|
667
|
+
Every recursive CTE has two arms joined by `UNION` (or `UNION ALL`):
|
|
668
|
+
|
|
669
|
+
1. **Anchor** — the seed query. Picks the starting rows
|
|
670
|
+
non-recursively. Typically `WHERE id = <root>`.
|
|
671
|
+
2. **Recursion** — references the CTE by name, joining itself to the
|
|
672
|
+
parent table to walk one level. Composed via `.innerJoin()`.
|
|
673
|
+
|
|
674
|
+
Compose with `union(anchor, recursion)` or `unionAll(...)` and pass
|
|
675
|
+
the combined descriptor to `.recursiveCte(name, ...)`.
|
|
676
|
+
|
|
677
|
+
## Example: org hierarchy
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
import { eq, queryFor, union } from '@voltro/database'
|
|
681
|
+
// `database` is YOUR project's handle — `export const database =
|
|
682
|
+
// databaseHandle({ ...tables })` in `database/index.ts`.
|
|
683
|
+
import { database } from '../database/index'
|
|
684
|
+
|
|
685
|
+
const rootId = 'org_root'
|
|
686
|
+
|
|
687
|
+
// Anchor: the root org itself
|
|
688
|
+
const anchor = queryFor(database.orgs).where(eq('id', rootId))
|
|
689
|
+
|
|
690
|
+
// Recursion: every org whose parentId is in the running set
|
|
691
|
+
const recursion = queryFor(database.orgs).as('child')
|
|
692
|
+
.innerJoin('descendants', 'parent', eq('parent.id', 'child.parentId'))
|
|
693
|
+
|
|
694
|
+
const tree = await ctx.store.query(
|
|
695
|
+
queryFor(database.orgs)
|
|
696
|
+
.recursiveCte('descendants', union(anchor, recursion).descriptor)
|
|
697
|
+
.where(eq('id', rootId))
|
|
698
|
+
.descriptor,
|
|
699
|
+
)
|
|
700
|
+
// tree: every org reachable from rootId, transitively
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Compiles to:
|
|
704
|
+
|
|
705
|
+
```sql
|
|
706
|
+
WITH RECURSIVE "descendants" AS (
|
|
707
|
+
-- anchor
|
|
708
|
+
(SELECT * FROM "orgs" WHERE "id" = $1)
|
|
709
|
+
UNION
|
|
710
|
+
-- recursion
|
|
711
|
+
(SELECT * FROM "orgs" AS "child"
|
|
712
|
+
INNER JOIN "descendants" AS "parent" ON "parent"."id" = "child"."parentId")
|
|
713
|
+
)
|
|
714
|
+
SELECT * FROM "orgs" WHERE "id" = $1
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
## Example: walk UP a tree
|
|
718
|
+
|
|
719
|
+
The recursion direction is yours to choose — join `child.parentId`
|
|
720
|
+
to walk up, or `parent.id` to walk down.
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
const node = queryFor(folders).where(eq('id', leafFolderId))
|
|
724
|
+
const ancestors = queryFor(folders).as('parent')
|
|
725
|
+
.innerJoin('chain', 'child', eq('child.parentId', 'parent.id'))
|
|
726
|
+
|
|
727
|
+
const path = await ctx.store.query(
|
|
728
|
+
queryFor(folders)
|
|
729
|
+
.recursiveCte('chain', union(node, ancestors).descriptor)
|
|
730
|
+
.descriptor,
|
|
731
|
+
)
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
## Cycle handling
|
|
735
|
+
|
|
736
|
+
Standard `UNION` semantics dedupe across iterations — if your graph
|
|
737
|
+
contains cycles, the recursion stops naturally when no new rows
|
|
738
|
+
appear in a step. For very large graphs with cycles, prefer
|
|
739
|
+
`unionAll` only when you've verified the graph is acyclic OR you
|
|
740
|
+
have a `WHERE` predicate in the recursion that prevents infinite
|
|
741
|
+
loops (e.g. a depth limit).
|
|
742
|
+
|
|
743
|
+
## Cross-dialect notes
|
|
744
|
+
|
|
745
|
+
| Dialect | Supported | Notes |
|
|
746
|
+
|------------|-----------|-------|
|
|
747
|
+
| postgres | ✓ native | Best optimizer for recursive CTEs |
|
|
748
|
+
| mysql 8+ | ✓ native | Recursion depth limited by `cte_max_recursion_depth` (default 1000) — set per-session for deeper trees |
|
|
749
|
+
| mariadb 10.2+ | ✓ native | Same as mysql |
|
|
750
|
+
| mssql | ✓ native | `OPTION (MAXRECURSION N)` hint NOT auto-emitted — set if you need >100-level recursion |
|
|
751
|
+
| sqlite 3.8+ | ✓ native | Smaller default recursion limit; check `PRAGMA recursive_triggers` |
|
|
752
|
+
|
|
753
|
+
Voltro's compiler emits identical syntax across dialects; only the
|
|
754
|
+
runtime defaults differ.
|
|
755
|
+
|
|
756
|
+
## Reactivity
|
|
757
|
+
|
|
758
|
+
Recursive CTE queries are reactive — coarsely. The engine registers
|
|
759
|
+
the subscription against every table the recursion reads (the anchor +
|
|
760
|
+
the recursive arm), so a write to any of them re-runs the tree-walk.
|
|
761
|
+
It can't pre-filter per column — a change to one ancestor can reshape
|
|
762
|
+
the whole result — so it re-queries on any contributing-table change.
|
|
763
|
+
Correct, but it re-runs the full recursion each time: fine for bounded
|
|
764
|
+
trees (org hierarchies, comment threads). For very hot or very large
|
|
765
|
+
trees, model the relationship via [Relations](/docs/database/joins) and
|
|
766
|
+
eager-load with `.with({...})` to get the per-field pre-filter.
|
|
767
|
+
|
|
768
|
+
## Limitations
|
|
769
|
+
|
|
770
|
+
- **No mutual recursion** between two CTEs in the same block. Each
|
|
771
|
+
recursive CTE references only itself.
|
|
772
|
+
- **Cycle detection without `UNION` dedup**: if you use `unionAll`,
|
|
773
|
+
ensure your recursion has a termination predicate. Voltro doesn't
|
|
774
|
+
inject a default depth limit.
|
|
775
|
+
- **No reactivity**: see above.
|
|
776
|
+
|
|
777
|
+
## See also
|
|
778
|
+
|
|
779
|
+
- [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
|
|
780
|
+
non-recursive named sub-queries
|
|
781
|
+
- [Joins](/docs/database/joins) — relation-based traversal when the
|
|
782
|
+
graph depth is fixed (e.g. parent + immediate children)
|
|
783
|
+
- [Aggregations](/docs/database/query-builder#aggregations) —
|
|
784
|
+
COUNT/SUM/AVG over a recursive CTE's result set
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
<!-- source: en/database/set-operations.md -->
|
|
791
|
+
## Set operations (UNION / INTERSECT / EXCEPT)
|
|
792
|
+
|
|
793
|
+
_Combine the results of multiple queries — UNION dedups, UNION ALL doesn't, INTERSECT keeps rows in both, EXCEPT subtracts._
|
|
794
|
+
|
|
795
|
+
`union` / `unionAll` / `intersect` / `except` combine two or more
|
|
796
|
+
queries that produce the **same column shape**. The result is one
|
|
797
|
+
unified row set you can sort, paginate, and aggregate against.
|
|
798
|
+
|
|
799
|
+
## Quick start
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
import { union, eq, queryFor } from '@voltro/database'
|
|
803
|
+
|
|
804
|
+
// Active + archived tickets for an org, treated as one list
|
|
805
|
+
const all = await ctx.store.query(
|
|
806
|
+
union(
|
|
807
|
+
queryFor(database.activeTickets).where(eq('orgId', oid)),
|
|
808
|
+
queryFor(database.archivedTickets).where(eq('orgId', oid)),
|
|
809
|
+
).orderBy('createdAt', 'desc').limit(50).descriptor,
|
|
810
|
+
)
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Compiles to:
|
|
814
|
+
|
|
815
|
+
```sql
|
|
816
|
+
(SELECT * FROM "activeTickets" WHERE "orgId" = $1)
|
|
817
|
+
UNION
|
|
818
|
+
(SELECT * FROM "archivedTickets" WHERE "orgId" = $2)
|
|
819
|
+
ORDER BY "createdAt" DESC
|
|
820
|
+
LIMIT 50
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
The outer `.orderBy()` / `.limit()` apply to the combined result —
|
|
824
|
+
each inner query keeps its own predicate but loses its own
|
|
825
|
+
ordering.
|
|
826
|
+
|
|
827
|
+
## The four operations
|
|
828
|
+
|
|
829
|
+
| Helper | Semantics |
|
|
830
|
+
|---------------|------------------------------------------------------------|
|
|
831
|
+
| `union(...)` | Rows from any input, deduplicated |
|
|
832
|
+
| `unionAll(...)` | Rows from any input, NO dedup (faster + preserves duplicates) |
|
|
833
|
+
| `intersect(...)` | Rows present in EVERY input |
|
|
834
|
+
| `except(...)` | Rows in the FIRST input, NOT in any subsequent input |
|
|
835
|
+
|
|
836
|
+
Each accepts 2+ queries. One input throws (`requires at least two
|
|
837
|
+
queries`).
|
|
838
|
+
|
|
839
|
+
## When to reach for each
|
|
840
|
+
|
|
841
|
+
- **`union`** — "show this user's items from two different sources,
|
|
842
|
+
deduped" (a notifications feed mixed with system messages).
|
|
843
|
+
- **`unionAll`** — same as `union` but you know there are no
|
|
844
|
+
duplicates OR you specifically want to keep them. Skipping the
|
|
845
|
+
dedup pass is meaningfully faster on large inputs.
|
|
846
|
+
- **`intersect`** — "users who exist in BOTH the paying-customers
|
|
847
|
+
list AND the active-this-week list".
|
|
848
|
+
- **`except`** — "all users EXCEPT those who unsubscribed". Use
|
|
849
|
+
`notInSubquery` if you only need a column-level check; `except`
|
|
850
|
+
when you're operating on full row shapes.
|
|
851
|
+
|
|
852
|
+
## Three-way and beyond
|
|
853
|
+
|
|
854
|
+
All four accept any number of inputs (≥ 2). The compiler chains them
|
|
855
|
+
with the appropriate keyword:
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
const combined = union(
|
|
859
|
+
queryFor(database.eventsA),
|
|
860
|
+
queryFor(database.eventsB),
|
|
861
|
+
queryFor(database.eventsC),
|
|
862
|
+
)
|
|
863
|
+
// (SELECT * FROM "eventsA") UNION (SELECT * FROM "eventsB") UNION (SELECT * FROM "eventsC")
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
## Column shape requirement
|
|
867
|
+
|
|
868
|
+
Every input MUST produce the same column shape. The framework
|
|
869
|
+
doesn't enforce this at TypeScript level — the DB throws at query
|
|
870
|
+
time if shapes don't line up. To narrow each input, use
|
|
871
|
+
`.select(...cols)` on the inner queries so they project the same
|
|
872
|
+
column set.
|
|
873
|
+
|
|
874
|
+
## Cross-dialect
|
|
875
|
+
|
|
876
|
+
Standard SQL — every dialect we ship supports the four set ops with
|
|
877
|
+
identical syntax. No per-dialect dispatch.
|
|
878
|
+
|
|
879
|
+
## Reactivity
|
|
880
|
+
|
|
881
|
+
Reactive — coarsely. The engine registers the subscription against
|
|
882
|
+
every branch's source table, so a write to any branch (the UNION /
|
|
883
|
+
INTERSECT / EXCEPT side) re-runs the combined query. It re-queries on
|
|
884
|
+
any contributing-table change rather than pre-filtering per column, so
|
|
885
|
+
keep the branches' result sets bounded.
|
|
886
|
+
|
|
887
|
+
## See also
|
|
888
|
+
|
|
889
|
+
- [Sub-queries](/docs/database/sub-queries) — `notInSubquery` for
|
|
890
|
+
the column-level "in A but not in B" case
|
|
891
|
+
- [Aggregations](/docs/database/aggregations) — `count()` etc. on
|
|
892
|
+
a set-op result is a common pattern
|
|
893
|
+
- [CTEs](/docs/database/query-builder#ctes) — name a complex set-op
|
|
894
|
+
result so you can reference it in a larger query
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
---
|
|
899
|
+
|
|
900
|
+
<!-- source: en/database/sub-queries.md -->
|
|
901
|
+
## Sub-queries (IN / NOT IN / EXISTS)
|
|
902
|
+
|
|
903
|
+
_Predicates that reference other queries — col IN (SELECT ...), EXISTS (SELECT ...)._
|
|
904
|
+
|
|
905
|
+
`inSubquery` / `notInSubquery` / `exists` / `notExists` let a WHERE
|
|
906
|
+
predicate reference the results of another query. Use when you'd
|
|
907
|
+
otherwise pull a list of IDs to the app and filter client-side.
|
|
908
|
+
|
|
909
|
+
## Quick start
|
|
910
|
+
|
|
911
|
+
```ts
|
|
912
|
+
import { inSubquery, eq, queryFor } from '@voltro/database'
|
|
913
|
+
|
|
914
|
+
// Find every post by an actively-banned user
|
|
915
|
+
const blockedUserIds = queryFor(database.blockedUsers).select('userId')
|
|
916
|
+
|
|
917
|
+
const banned = await ctx.store.query(
|
|
918
|
+
queryFor(database.posts)
|
|
919
|
+
.where(inSubquery('userId', blockedUserIds))
|
|
920
|
+
.descriptor,
|
|
921
|
+
)
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Compiles to:
|
|
925
|
+
|
|
926
|
+
```sql
|
|
927
|
+
SELECT * FROM "posts" WHERE "userId" IN (SELECT "userId" FROM "blockedUsers")
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
The sub-query runs as part of the same SQL statement — one round-
|
|
931
|
+
trip, the DB optimiser decides whether to materialise the inner set
|
|
932
|
+
or use a hash semi-join.
|
|
933
|
+
|
|
934
|
+
## `inSubquery` / `notInSubquery`
|
|
935
|
+
|
|
936
|
+
Both expect the sub-query to project a **single column** (use
|
|
937
|
+
`.select('colName')` on the inner query). The predicate matches rows
|
|
938
|
+
whose specified outer column appears (or doesn't) in the inner
|
|
939
|
+
result set.
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
// Not in
|
|
943
|
+
const visibleUsers = await ctx.store.query(
|
|
944
|
+
queryFor(database.users)
|
|
945
|
+
.where(notInSubquery('id', queryFor(database.blockedUsers).select('userId')))
|
|
946
|
+
.descriptor,
|
|
947
|
+
)
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
**Empty inner set semantics:** `inSubquery` against an empty set
|
|
951
|
+
matches NOTHING (no row's column is "in" an empty list).
|
|
952
|
+
`notInSubquery` against an empty set matches EVERYTHING (every row's
|
|
953
|
+
column is "not in" an empty list). The framework matches SQL
|
|
954
|
+
exactly.
|
|
955
|
+
|
|
956
|
+
## `exists` / `notExists`
|
|
957
|
+
|
|
958
|
+
Doesn't bind to a specific outer column — the predicate's truth
|
|
959
|
+
depends only on whether the sub-query produces any rows at all.
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
import { exists, queryFor } from '@voltro/database'
|
|
963
|
+
|
|
964
|
+
// Users who have at least one post
|
|
965
|
+
const authors = await ctx.store.query(
|
|
966
|
+
queryFor(database.users)
|
|
967
|
+
.where(exists(queryFor(database.posts).where(eq('userId', 'placeholder'))))
|
|
968
|
+
.descriptor,
|
|
969
|
+
)
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
`exists` is typically faster than `inSubquery` when the inner set is
|
|
973
|
+
large but you only need yes-or-no — the DB stops after the first
|
|
974
|
+
match.
|
|
975
|
+
|
|
976
|
+
## Composing with `and` / `or`
|
|
977
|
+
|
|
978
|
+
Sub-query predicates compose with the regular boolean combinators:
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
import { and, or, eq, inSubquery } from '@voltro/database'
|
|
982
|
+
|
|
983
|
+
queryFor(database.users).where(
|
|
984
|
+
and(
|
|
985
|
+
eq('tenantId', tenantId),
|
|
986
|
+
inSubquery('id', queryFor(database.bannedUsers).select('userId')),
|
|
987
|
+
),
|
|
988
|
+
)
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
## Limitations (v1)
|
|
992
|
+
|
|
993
|
+
Non-correlated only. The inner query can NOT reference outer-row
|
|
994
|
+
columns like `WHERE inner.userId = users.id`. For correlated
|
|
995
|
+
sub-queries (a common shape: "user who has at least one post created
|
|
996
|
+
in the last hour") use a [Self-join](/docs/database/self-joins) or
|
|
997
|
+
an [Eager-load](/docs/database/joins#eager-loading) — both can
|
|
998
|
+
express the same query without the correlation reference.
|
|
999
|
+
|
|
1000
|
+
## Reactivity
|
|
1001
|
+
|
|
1002
|
+
Sub-query predicates ARE reactive. The matcher tracks BOTH the outer
|
|
1003
|
+
descriptor's table AND every sub-query's table. A write to the inner
|
|
1004
|
+
table re-evaluates the outer query.
|
|
1005
|
+
|
|
1006
|
+
In the in-memory store the framework pre-materialises every
|
|
1007
|
+
sub-query before evaluating the outer predicate (one pass per
|
|
1008
|
+
sub-query, not per row). Same fast-path applies to SQL stores via
|
|
1009
|
+
the standard `IN (SELECT ...)` query plan.
|
|
1010
|
+
|
|
1011
|
+
## Cross-dialect
|
|
1012
|
+
|
|
1013
|
+
`IN (SELECT ...)` and `EXISTS (SELECT ...)` are standard SQL on
|
|
1014
|
+
every dialect we ship. No per-dialect dispatch.
|
|
1015
|
+
|
|
1016
|
+
## See also
|
|
1017
|
+
|
|
1018
|
+
- [Aggregations](/docs/database/aggregations) — sub-queries paired
|
|
1019
|
+
with `count()` etc. for "count of X where Y belongs to Z"
|
|
1020
|
+
- [Self-joins](/docs/database/self-joins) — when the relationship
|
|
1021
|
+
can be expressed as a join instead
|
|
1022
|
+
- [CTEs](/docs/database/query-builder#ctes) — for naming a
|
|
1023
|
+
sub-query you reuse multiple times in the same outer query
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
<!-- source: en/database/distinct.md -->
|
|
1030
|
+
## DISTINCT + DISTINCT ON
|
|
1031
|
+
|
|
1032
|
+
_Dedupe row sets. .distinct() is universal; .distinctOn() picks one row per group._
|
|
1033
|
+
|
|
1034
|
+
`.distinct()` dedups the result row set. `.distinctOn([cols])` picks
|
|
1035
|
+
ONE row per unique value of the listed columns — the "latest per
|
|
1036
|
+
channel" / "best per user" pattern.
|
|
1037
|
+
|
|
1038
|
+
## `.distinct()` — basic dedup
|
|
1039
|
+
|
|
1040
|
+
```ts
|
|
1041
|
+
await ctx.store.query(
|
|
1042
|
+
queryFor(database.messages)
|
|
1043
|
+
.select('userId')
|
|
1044
|
+
.distinct()
|
|
1045
|
+
.descriptor,
|
|
1046
|
+
)
|
|
1047
|
+
// → distinct user IDs that have any message
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
Cross-dialect: standard SQL, supported on every dialect we ship.
|
|
1051
|
+
|
|
1052
|
+
## `.distinctOn([cols])` — pick the first per group
|
|
1053
|
+
|
|
1054
|
+
```ts
|
|
1055
|
+
// Latest message per channel
|
|
1056
|
+
await ctx.store.query(
|
|
1057
|
+
queryFor(database.messages)
|
|
1058
|
+
.distinctOn(['channelId'])
|
|
1059
|
+
.orderBy('channelId', 'asc')
|
|
1060
|
+
.orderBy('createdAt', 'desc')
|
|
1061
|
+
.descriptor,
|
|
1062
|
+
)
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
The order matters: postgres picks the FIRST row per
|
|
1066
|
+
`distinctOn`-column combination AS DETERMINED BY THE FULL `orderBy`.
|
|
1067
|
+
Always set:
|
|
1068
|
+
|
|
1069
|
+
1. The `distinctOn` columns first in `orderBy`
|
|
1070
|
+
2. The tie-breaker column second (which row to pick when multiple
|
|
1071
|
+
match — typically a timestamp or id)
|
|
1072
|
+
|
|
1073
|
+
Without that order, postgres still picks one row but the choice is
|
|
1074
|
+
unstable.
|
|
1075
|
+
|
|
1076
|
+
## Cross-dialect
|
|
1077
|
+
|
|
1078
|
+
- **Postgres** — native `DISTINCT ON` clause.
|
|
1079
|
+
- **MySQL / MariaDB / MSSQL / SQLite** — no native `DISTINCT ON`.
|
|
1080
|
+
The framework falls back to plain `DISTINCT` and logs no warning
|
|
1081
|
+
because the fallback covers most use cases. If you specifically
|
|
1082
|
+
need one-row-per-group semantics on these dialects, use a
|
|
1083
|
+
[window function](/docs/database/aggregations#window-functions)
|
|
1084
|
+
with `ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)` and
|
|
1085
|
+
filter to `rank = 1` in a sub-query.
|
|
1086
|
+
|
|
1087
|
+
## When NOT to use `.distinct()`
|
|
1088
|
+
|
|
1089
|
+
Distinct on a row set with multiple non-unique columns is rarely
|
|
1090
|
+
what you want — it dedups by the full row shape, including columns
|
|
1091
|
+
you might not have intended to constrain. If you want one row per
|
|
1092
|
+
some-key, use `.distinctOn([key])` (postgres) or `groupBy([key])`
|
|
1093
|
+
with `aggregate({})` to be explicit.
|
|
1094
|
+
|
|
1095
|
+
## See also
|
|
1096
|
+
|
|
1097
|
+
- [Aggregations](/docs/database/aggregations) — `.groupBy()` + window
|
|
1098
|
+
functions for cases distinct can't express
|
|
1099
|
+
- [Sub-queries](/docs/database/sub-queries) — `notInSubquery` for
|
|
1100
|
+
"rows not appearing elsewhere"
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
---
|
|
1105
|
+
|
|
1106
|
+
<!-- source: en/database/self-joins.md -->
|
|
1107
|
+
## Self-joins + aliased projections
|
|
1108
|
+
|
|
1109
|
+
_Join a table to itself or to a CTE. selectJoined({...}) for aliased projections with full TypeScript inference._
|
|
1110
|
+
|
|
1111
|
+
When you need to reference the same table twice in one query —
|
|
1112
|
+
parent/child trees, before/after comparisons, follower/followee
|
|
1113
|
+
graphs — use `.as(alias)` + `.innerJoin(table, alias, on)`.
|
|
1114
|
+
|
|
1115
|
+
For non-self joins (a table joined to a DIFFERENT table) the
|
|
1116
|
+
preferred pattern is [eager-loading via relations](/docs/database/joins).
|
|
1117
|
+
This page covers the cases where eager-load doesn't fit:
|
|
1118
|
+
self-joins, joining against a CTE, or joining against a dynamically-
|
|
1119
|
+
named table.
|
|
1120
|
+
|
|
1121
|
+
## Quick start — self-join
|
|
1122
|
+
|
|
1123
|
+
```ts
|
|
1124
|
+
import { eq, queryFor } from '@voltro/database'
|
|
1125
|
+
|
|
1126
|
+
// Posts and their parent posts
|
|
1127
|
+
const tree = await ctx.store.query(
|
|
1128
|
+
queryFor(database.posts).as('children')
|
|
1129
|
+
.innerJoin(database.posts, 'parents', eq('parents.id', 'children.parentId'))
|
|
1130
|
+
.selectJoined({
|
|
1131
|
+
childId: 'children.id',
|
|
1132
|
+
childTitle: 'children.title',
|
|
1133
|
+
parentTitle: 'parents.title',
|
|
1134
|
+
})
|
|
1135
|
+
.descriptor,
|
|
1136
|
+
)
|
|
1137
|
+
// rows: Array<{ childId: string; childTitle: string; parentTitle: string }>
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
Compiles to:
|
|
1141
|
+
|
|
1142
|
+
```sql
|
|
1143
|
+
SELECT
|
|
1144
|
+
"children"."id" AS "childId",
|
|
1145
|
+
"children"."title" AS "childTitle",
|
|
1146
|
+
"parents"."title" AS "parentTitle"
|
|
1147
|
+
FROM "posts" AS "children"
|
|
1148
|
+
INNER JOIN "posts" AS "parents" ON "parents"."id" = "children"."parentId"
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
## `.as(alias)` — aliasing the FROM table
|
|
1152
|
+
|
|
1153
|
+
`.as('children')` emits `FROM "posts" AS "children"`. Required when
|
|
1154
|
+
combining with `.innerJoin(...)` so the JOIN's ON condition can
|
|
1155
|
+
disambiguate columns from both sides.
|
|
1156
|
+
|
|
1157
|
+
## `.innerJoin()` vs `.leftJoin()`
|
|
1158
|
+
|
|
1159
|
+
Same shape, different semantics:
|
|
1160
|
+
|
|
1161
|
+
- `.innerJoin(target, alias, on)` — drops rows from the FROM side
|
|
1162
|
+
that have no JOIN-side match.
|
|
1163
|
+
- `.leftJoin(target, alias, on)` — keeps rows from the FROM side
|
|
1164
|
+
even when no JOIN-side match exists. The aliased row's columns
|
|
1165
|
+
will be `null` for those rows.
|
|
1166
|
+
|
|
1167
|
+
Both have **two overloads**:
|
|
1168
|
+
|
|
1169
|
+
### Pass a `Table` descriptor → full type inference
|
|
1170
|
+
|
|
1171
|
+
```ts
|
|
1172
|
+
queryFor(database.posts).as('children')
|
|
1173
|
+
.innerJoin(database.posts, 'parents', eq('parents.id', 'children.parentId'))
|
|
1174
|
+
.selectJoined({
|
|
1175
|
+
parentTitle: 'parents.title', // ← type inferred as string
|
|
1176
|
+
})
|
|
1177
|
+
// rows[0].parentTitle is typed `string`, not `unknown`
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
The framework tracks the joined Table's row type in a hidden `Joins`
|
|
1181
|
+
type parameter. `.selectJoined({...})` reads it to resolve each
|
|
1182
|
+
`'alias.column'` source to the joined column's actual type.
|
|
1183
|
+
|
|
1184
|
+
### Pass a string name → opaque type fallback
|
|
1185
|
+
|
|
1186
|
+
```ts
|
|
1187
|
+
queryFor(database.posts).as('children')
|
|
1188
|
+
.innerJoin('some_cte', 'cte', eq('cte.userId', 'children.userId'))
|
|
1189
|
+
.selectJoined({
|
|
1190
|
+
fromCte: 'cte.value', // ← type stays `unknown`
|
|
1191
|
+
})
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
Use the string form for CTE references (the framework doesn't have
|
|
1195
|
+
the CTE's row type at compile time) or when joining against a name
|
|
1196
|
+
that's only known at runtime.
|
|
1197
|
+
|
|
1198
|
+
## `.selectJoined({...})` — picking columns
|
|
1199
|
+
|
|
1200
|
+
The chain method has TWO mutually-exclusive projection forms:
|
|
1201
|
+
|
|
1202
|
+
- `.select('col1', 'col2')` — for FROM-side rows only. Result row
|
|
1203
|
+
type narrows to `Pick<RowOf, 'col1' | 'col2'>`.
|
|
1204
|
+
- `.selectJoined({outKey: 'alias.col', ...})` — for queries with
|
|
1205
|
+
joins. Each spec entry maps a `'<alias>.<column>'` source to an
|
|
1206
|
+
output key on the result row.
|
|
1207
|
+
|
|
1208
|
+
Mixing isn't supported; pick one. The framework defaults to
|
|
1209
|
+
`SELECT *` when neither is set (returns the FROM-side row).
|
|
1210
|
+
|
|
1211
|
+
## Reading without `.selectJoined`
|
|
1212
|
+
|
|
1213
|
+
If you skip `.selectJoined`, the result row stays the FROM-side row
|
|
1214
|
+
type. The joined columns ARE on the row at runtime (postgres returns
|
|
1215
|
+
them flat with `alias.col` keys) but TypeScript doesn't see them.
|
|
1216
|
+
For ad-hoc reads:
|
|
1217
|
+
|
|
1218
|
+
```ts
|
|
1219
|
+
const rows = await ctx.store.query(
|
|
1220
|
+
queryFor(database.posts).as('c')
|
|
1221
|
+
.innerJoin(database.posts, 'p', eq('p.id', 'c.parentId'))
|
|
1222
|
+
.descriptor,
|
|
1223
|
+
)
|
|
1224
|
+
// rows[0] is typed Post
|
|
1225
|
+
// rows[0]['p.title'] exists at runtime but needs an explicit cast
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
Use `.selectJoined({...})` for the typed path; the cast escape-hatch
|
|
1229
|
+
is for one-off reads.
|
|
1230
|
+
|
|
1231
|
+
## Cross-dialect
|
|
1232
|
+
|
|
1233
|
+
Standard SQL — `INNER JOIN ... AS ... ON ...` is supported on every
|
|
1234
|
+
dialect we ship with identical syntax.
|
|
1235
|
+
|
|
1236
|
+
## When NOT to use this
|
|
1237
|
+
|
|
1238
|
+
- **Joining different tables related via FK** — use
|
|
1239
|
+
[eager-loading](/docs/database/joins) with relations + `.with({})`.
|
|
1240
|
+
More ergonomic, gives you the nested-object result shape, and
|
|
1241
|
+
reactively subscribes to the joined tables.
|
|
1242
|
+
- **Walking trees more than 1 level deep** — use a
|
|
1243
|
+
[recursive CTE](/docs/database/recursive-cte). Self-join only
|
|
1244
|
+
covers parent + immediate child.
|
|
1245
|
+
|
|
1246
|
+
## See also
|
|
1247
|
+
|
|
1248
|
+
- [Joins (eager-load)](/docs/database/joins) — the preferred way
|
|
1249
|
+
for relations-based joins
|
|
1250
|
+
- [Recursive CTE](/docs/database/recursive-cte) — for multi-level
|
|
1251
|
+
tree traversal
|
|
1252
|
+
- [Sub-queries](/docs/database/sub-queries) — for the
|
|
1253
|
+
"rows where column matches another query" pattern
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
---
|
|
1258
|
+
|
|
1259
|
+
<!-- source: en/database/aggregations.md -->
|
|
1260
|
+
## Aggregations (count / sum / avg / min / max + GROUP BY + window functions)
|
|
1261
|
+
|
|
1262
|
+
_On-demand counts, sums, group-by, having, and window functions — without escaping to raw SQL._
|
|
1263
|
+
|
|
1264
|
+
The query builder's `.count()` / `.aggregate({...})` / `.groupBy()` /
|
|
1265
|
+
`.having()` covers nearly every analytic query a typical SaaS app
|
|
1266
|
+
needs without raw SQL. Window functions (`rowNumber`, `rank`, `lag`,
|
|
1267
|
+
`lead`, `sumOver`) live in the same surface for percentile-style and
|
|
1268
|
+
running-total reads.
|
|
1269
|
+
|
|
1270
|
+
These are **on-demand reads** — they run when called, not on a
|
|
1271
|
+
schedule. For pre-computed read models that refresh periodically see
|
|
1272
|
+
[Aggregates](/docs/data/aggregates) (the `*.aggregate.ts` file
|
|
1273
|
+
convention).
|
|
1274
|
+
|
|
1275
|
+
Live — an on-demand aggregation as a computed reactive query: add or toggle a
|
|
1276
|
+
todo and the `{ open, done, total }` counts update with no refetch:
|
|
1277
|
+
|
|
1278
|
+
```tsx
|
|
1279
|
+
const stats = useSubscription('app', 'todos.stats') // reactive count roll-up
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
## Quick start
|
|
1283
|
+
|
|
1284
|
+
```ts
|
|
1285
|
+
import { count, sum, avg, max, eq, queryFor } from '@voltro/database'
|
|
1286
|
+
|
|
1287
|
+
// How many open todos does this user have right now?
|
|
1288
|
+
const rows = await ctx.store.query(
|
|
1289
|
+
queryFor(database.todos).where(eq('done', false)).count().descriptor,
|
|
1290
|
+
)
|
|
1291
|
+
const open = rows[0]!.count // → number
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
Result rows from any aggregate are always **an array with one entry
|
|
1295
|
+
per group** (or exactly one entry when there's no `groupBy`). Reach
|
|
1296
|
+
in via `[0]` for the ungrouped case.
|
|
1297
|
+
|
|
1298
|
+
## `count()` — the most common case
|
|
1299
|
+
|
|
1300
|
+
```ts
|
|
1301
|
+
// Count every row in the filtered set
|
|
1302
|
+
queryFor(database.posts).where(eq('userId', uid)).count()
|
|
1303
|
+
|
|
1304
|
+
// Count distinct values of a column
|
|
1305
|
+
import { countDistinct } from '@voltro/database'
|
|
1306
|
+
queryFor(database.posts).aggregate({
|
|
1307
|
+
authors: countDistinct('userId'),
|
|
1308
|
+
})
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
`count()` defaults to `COUNT(*)` — every matching row, including
|
|
1312
|
+
NULLs. `countDistinct(column)` emits `COUNT(DISTINCT column)`.
|
|
1313
|
+
|
|
1314
|
+
## `aggregate({...})` — bundle multiple aggregates in one query
|
|
1315
|
+
|
|
1316
|
+
```ts
|
|
1317
|
+
import { count, sum, avg, max, min } from '@voltro/database'
|
|
1318
|
+
|
|
1319
|
+
const rows = await ctx.store.query(
|
|
1320
|
+
queryFor(database.orders).where(eq('orgId', oid)).aggregate({
|
|
1321
|
+
total: sum('amount'),
|
|
1322
|
+
avgAmt: avg('amount'),
|
|
1323
|
+
peak: max('createdAt'),
|
|
1324
|
+
earliest: min('createdAt'),
|
|
1325
|
+
cnt: count(),
|
|
1326
|
+
}).descriptor,
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
const stats = rows[0]!
|
|
1330
|
+
// { total: 12_345, avgAmt: 89.5, peak: Date, earliest: Date, cnt: 138 }
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
The keys of the spec map become the column names on the result row.
|
|
1334
|
+
Each value is a helper:
|
|
1335
|
+
|
|
1336
|
+
| Helper | SQL | Returns |
|
|
1337
|
+
|---------------------------------|---------------------------|---------|
|
|
1338
|
+
| `count()` | `COUNT(*)` | number |
|
|
1339
|
+
| `count('col')` | `COUNT(col)` (non-null) | number |
|
|
1340
|
+
| `countDistinct('col')` | `COUNT(DISTINCT col)` | number |
|
|
1341
|
+
| `sum('col')` | `SUM(col)` | number / null |
|
|
1342
|
+
| `avg('col')` | `AVG(col)` | number / null |
|
|
1343
|
+
| `min('col')` | `MIN(col)` | column type / null |
|
|
1344
|
+
| `max('col')` | `MAX(col)` | column type / null |
|
|
1345
|
+
|
|
1346
|
+
**SQL semantics on empty result sets**: `count` returns 0;
|
|
1347
|
+
`sum/avg/min/max` return `null`. The framework matches this — don't
|
|
1348
|
+
write `if (rows.length === 0)` defensive code, the row is always
|
|
1349
|
+
there.
|
|
1350
|
+
|
|
1351
|
+
## Fast path: `.exists()` for "is there any?"
|
|
1352
|
+
|
|
1353
|
+
When you only need a yes/no, avoid `count() > 0` — `.exists()`
|
|
1354
|
+
short-circuits with `SELECT 1 ... LIMIT 1`:
|
|
1355
|
+
|
|
1356
|
+
```ts
|
|
1357
|
+
const rows = await ctx.store.query(
|
|
1358
|
+
queryFor(database.users).where(eq('email', e)).exists().descriptor,
|
|
1359
|
+
)
|
|
1360
|
+
const emailTaken = rows[0]!.exists // → boolean
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
On a 10M-row table this is the difference between an index-only scan
|
|
1364
|
+
that stops at the first match and a full count.
|
|
1365
|
+
|
|
1366
|
+
## `groupBy(cols)` — one row per group
|
|
1367
|
+
|
|
1368
|
+
```ts
|
|
1369
|
+
const rows = await ctx.store.query(
|
|
1370
|
+
queryFor(database.orders).where(eq('orgId', oid))
|
|
1371
|
+
.groupBy(['status'])
|
|
1372
|
+
.aggregate({ cnt: count(), total: sum('amount') })
|
|
1373
|
+
.descriptor,
|
|
1374
|
+
)
|
|
1375
|
+
// rows: [{ cnt: 12, total: 4500 }, { cnt: 5, total: 1800 }] — one row per group
|
|
1376
|
+
```
|
|
1377
|
+
|
|
1378
|
+
> **Project the group key with `column()`.** By default the result rows
|
|
1379
|
+
> carry the **aggregate aliases only**. To fold a `GROUP BY` key column
|
|
1380
|
+
> into the same row, add a `column(name)` entry to the spec — it must also
|
|
1381
|
+
> appear in `.groupBy([...])` (standard SQL):
|
|
1382
|
+
>
|
|
1383
|
+
> ```ts
|
|
1384
|
+
> import { column, count, sum } from '@voltro/database'
|
|
1385
|
+
>
|
|
1386
|
+
> queryFor(database.orders).where(eq('orgId', oid))
|
|
1387
|
+
> .groupBy(['status'])
|
|
1388
|
+
> .aggregate({ status: column<string>('status'), cnt: count(), total: sum('amount') })
|
|
1389
|
+
> // rows: [{ status: 'open', cnt: 12, total: 4500 }, …]
|
|
1390
|
+
> ```
|
|
1391
|
+
> Annotate the type (`column<string>(...)`) for a precise result row, or
|
|
1392
|
+
> rely on the default `string | number | boolean | Date | null` union.
|
|
1393
|
+
|
|
1394
|
+
Chained `.groupBy()` calls append columns — group by `orgId × status`:
|
|
1395
|
+
|
|
1396
|
+
```ts
|
|
1397
|
+
queryFor(database.orders)
|
|
1398
|
+
.groupBy(['orgId'])
|
|
1399
|
+
.groupBy(['status'])
|
|
1400
|
+
.aggregate({ cnt: count() })
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
Group-by works without `aggregate()` too (returns distinct combos),
|
|
1404
|
+
but the typical pattern is grouping + aggregating together.
|
|
1405
|
+
|
|
1406
|
+
## `having(predicate)` — filter groups after aggregation
|
|
1407
|
+
|
|
1408
|
+
`having` predicates run AFTER aggregation; they reference aggregate
|
|
1409
|
+
aliases, not raw columns. Distinct from `.where()` (which becomes
|
|
1410
|
+
`WHERE` and runs BEFORE grouping):
|
|
1411
|
+
|
|
1412
|
+
```ts
|
|
1413
|
+
import { gt } from '@voltro/database'
|
|
1414
|
+
|
|
1415
|
+
interface PostCount { userId: string; postCount: number }
|
|
1416
|
+
|
|
1417
|
+
// "How many users posted more than 10 times in the last 30 days"
|
|
1418
|
+
const heavy = await ctx.store.query(
|
|
1419
|
+
queryFor(database.posts)
|
|
1420
|
+
.where(gt('createdAt', daysAgo(30))) // WHERE — pre-aggregate
|
|
1421
|
+
.groupBy(['userId'])
|
|
1422
|
+
.having(gt<PostCount, 'postCount'>('postCount', 10)) // HAVING — post-aggregate
|
|
1423
|
+
.aggregate({ postCount: count() })
|
|
1424
|
+
.descriptor,
|
|
1425
|
+
)
|
|
1426
|
+
// rows: [{ postCount: 14 }, { postCount: 11 }, …] — one per qualifying user
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
Chained `.having()` AND-merges, same as `.where()`.
|
|
1430
|
+
|
|
1431
|
+
## Window functions
|
|
1432
|
+
|
|
1433
|
+
Window functions compute per-row aggregates over a "window" of rows
|
|
1434
|
+
without collapsing the row set — different from `groupBy`, which
|
|
1435
|
+
collapses. Use for ranking, running totals, "previous-row" deltas.
|
|
1436
|
+
|
|
1437
|
+
### `rowNumber()` / `rank()` / `denseRank()`
|
|
1438
|
+
|
|
1439
|
+
```ts
|
|
1440
|
+
import { rank, queryFor } from '@voltro/database'
|
|
1441
|
+
|
|
1442
|
+
const ranked = await ctx.store.query(
|
|
1443
|
+
queryFor(database.players).aggregate({
|
|
1444
|
+
rank: rank().over({
|
|
1445
|
+
partitionBy: ['teamId'],
|
|
1446
|
+
orderBy: [{ column: 'score', direction: 'desc' }],
|
|
1447
|
+
}),
|
|
1448
|
+
}).descriptor,
|
|
1449
|
+
)
|
|
1450
|
+
// Each row: { rank } — rank restarts at 1 per team
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
> **Note:** to carry a plain column (`id`, `name`) alongside the window
|
|
1454
|
+
> expression, add a `column(name)` entry to the spec — it projects
|
|
1455
|
+
> `"col" AS "alias"` into the same result row.
|
|
1456
|
+
|
|
1457
|
+
`rowNumber()` always returns sequential 1, 2, 3 — ties get distinct
|
|
1458
|
+
numbers. `rank()` gives ties the same number, then skips
|
|
1459
|
+
(1, 1, 3, 4…). `denseRank()` gives ties the same number, no skip
|
|
1460
|
+
(1, 1, 2, 3…).
|
|
1461
|
+
|
|
1462
|
+
### `lag()` / `lead()` — previous / next row
|
|
1463
|
+
|
|
1464
|
+
```ts
|
|
1465
|
+
import { lag, lead } from '@voltro/database'
|
|
1466
|
+
|
|
1467
|
+
queryFor(database.scores).aggregate({
|
|
1468
|
+
prevScore: lag('score').over({ partitionBy: ['userId'], orderBy: [{ column: 'createdAt', direction: 'asc' }] }),
|
|
1469
|
+
nextScore: lead('score').over({ partitionBy: ['userId'], orderBy: [{ column: 'createdAt', direction: 'asc' }] }),
|
|
1470
|
+
})
|
|
1471
|
+
```
|
|
1472
|
+
|
|
1473
|
+
Both accept an optional `offset` — `lag('score', 3)` looks 3 rows
|
|
1474
|
+
back.
|
|
1475
|
+
|
|
1476
|
+
### `sumOver()` / `avgOver()` — running totals
|
|
1477
|
+
|
|
1478
|
+
```ts
|
|
1479
|
+
import { sumOver } from '@voltro/database'
|
|
1480
|
+
|
|
1481
|
+
queryFor(database.ledger).aggregate({
|
|
1482
|
+
runningSum: sumOver('amount').over({
|
|
1483
|
+
orderBy: [{ column: 'createdAt', direction: 'asc' }],
|
|
1484
|
+
}),
|
|
1485
|
+
})
|
|
1486
|
+
// Each row: running total of `amount` up to and including this row
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
Combine `partitionBy` + `orderBy` for per-group running totals
|
|
1490
|
+
("total spend per user, sorted by date").
|
|
1491
|
+
|
|
1492
|
+
## Reactivity
|
|
1493
|
+
|
|
1494
|
+
`count()` and `aggregate()` subscriptions ARE reactive. The
|
|
1495
|
+
dispatcher subscribes to the source table; any write triggers a
|
|
1496
|
+
re-run of the same aggregate SQL.
|
|
1497
|
+
|
|
1498
|
+
Window-function queries are reactive — coarsely. The matcher can't
|
|
1499
|
+
bucket on a window result (a rank shifts when ANY partition peer
|
|
1500
|
+
changes), so the engine widens the dependency to the whole source
|
|
1501
|
+
table: a write to it re-runs the query. That's correct but coarser
|
|
1502
|
+
than a plain query's per-field pre-filter — every write to the source
|
|
1503
|
+
table re-evaluates the window. Keep the source set bounded (a top-N
|
|
1504
|
+
leaderboard, not a 10M-row scan), or materialise it on a schedule with
|
|
1505
|
+
an [aggregate](/docs/data/aggregates).
|
|
1506
|
+
|
|
1507
|
+
## Cross-dialect
|
|
1508
|
+
|
|
1509
|
+
Standard SQL — postgres, mysql 8+, mariadb 10.2+, mssql, sqlite 3.25+, turso all support these forms with identical syntax. The framework's
|
|
1510
|
+
compiler doesn't dispatch per dialect for any of the helpers above.
|
|
1511
|
+
|
|
1512
|
+
## See also
|
|
1513
|
+
|
|
1514
|
+
- [Aggregates (scheduled)](/docs/data/aggregates) — `*.aggregate.ts`
|
|
1515
|
+
for precomputed read models that refresh periodically
|
|
1516
|
+
- [Query builder](/docs/database/query-builder) — the chain methods
|
|
1517
|
+
these aggregates compose with (where, orderBy, limit, etc.)
|
|
1518
|
+
- [Sub-queries](/docs/database/sub-queries) — `inSubquery` /
|
|
1519
|
+
`exists` for predicates that reference other tables
|
|
1520
|
+
- [Set operations](/docs/database/set-operations) — UNION / INTERSECT /
|
|
1521
|
+
EXCEPT for combining multiple aggregates
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
---
|
|
1526
|
+
|
|
1527
|
+
<!-- source: en/database/views.md -->
|
|
1528
|
+
## SQL views
|
|
1529
|
+
|
|
1530
|
+
_Declare a read-only SQL VIEW with view(name, columns, select) — discovered + applied by the migrator, queried by-name like a table._
|
|
1531
|
+
|
|
1532
|
+
A **view** is a named, server-side `SELECT` you query by name exactly like a table, but which is never written to. Declare one with `view(name, columns, select)`: `columns` describes the projected row shape (types the result + drives the read decoder), and `select` is the raw SELECT body emitted verbatim into `CREATE VIEW`.
|
|
1533
|
+
|
|
1534
|
+
```ts
|
|
1535
|
+
import { view, id, text, boolean, timestamp } from '@voltro/database'
|
|
1536
|
+
|
|
1537
|
+
export const activeUsers = view(
|
|
1538
|
+
'active_users',
|
|
1539
|
+
{
|
|
1540
|
+
id: id(),
|
|
1541
|
+
email: text(),
|
|
1542
|
+
active: boolean(),
|
|
1543
|
+
createdAt: timestamp(),
|
|
1544
|
+
},
|
|
1545
|
+
`SELECT id, email, active, created_at AS "createdAt"
|
|
1546
|
+
FROM users
|
|
1547
|
+
WHERE deleted_at IS NULL`,
|
|
1548
|
+
)
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
The migrator discovers the view alongside your tables and emits it **after** the base tables it reads from — no ordering wiring needed. Pass it into the schema entity list the same way you pass tables.
|
|
1552
|
+
|
|
1553
|
+
## Querying a view
|
|
1554
|
+
|
|
1555
|
+
A view is read-only. Query it with `queryForView(...)`, which returns a query with the full `.where(...)` / `.orderBy(...)` / `.take(...)` / `.with(...)` surface but **no** mutation path (there's no INSERT/UPDATE/DELETE on a view).
|
|
1556
|
+
|
|
1557
|
+
```ts
|
|
1558
|
+
import { queryForView, eq } from '@voltro/database'
|
|
1559
|
+
|
|
1560
|
+
const rows = await ctx.store.query(
|
|
1561
|
+
queryForView(activeUsers).where(eq('email', someEmail)),
|
|
1562
|
+
)
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
The projected columns are decoded to their canonical JS shapes — a `boolean()` projection comes back a real boolean, a `json<T>()` projection a parsed object, a `decimal()` projection a string — the same read codec that runs for tables.
|
|
1566
|
+
|
|
1567
|
+
## Idempotent per dialect
|
|
1568
|
+
|
|
1569
|
+
`CREATE VIEW` is emitted idempotently so re-running a migration is a no-op:
|
|
1570
|
+
|
|
1571
|
+
| Dialect | Emission |
|
|
1572
|
+
|---|---|
|
|
1573
|
+
| Postgres / MySQL / MariaDB | `CREATE OR REPLACE VIEW` |
|
|
1574
|
+
| MSSQL | `CREATE OR ALTER VIEW` (SQL Server 2016 SP1+) |
|
|
1575
|
+
| SQLite / Turso | `DROP VIEW IF EXISTS` + `CREATE VIEW` (no `CREATE OR REPLACE`; a view holds no data, so dropping is free) |
|
|
1576
|
+
|
|
1577
|
+
## When to use a view
|
|
1578
|
+
|
|
1579
|
+
- Collapse a recurring filter/join into a named entity your handlers query directly (`active_users`, `open_orders`).
|
|
1580
|
+
- Expose a stable read shape while the underlying tables evolve.
|
|
1581
|
+
- Hand a reporting/read path a denormalized projection without duplicating the join logic in every query.
|
|
1582
|
+
|
|
1583
|
+
You own the SELECT body's cross-dialect portability — the framework emits it verbatim, the same contract as a `raw()` column or an `expressionIndex(...)` expression. Keep to standard SQL, or gate dialect-specific views behind your deployment's known backend.
|