@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,2938 @@
|
|
|
1
|
+
# database.migrations
|
|
2
|
+
|
|
3
|
+
> Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/database/migrations/index.md -->
|
|
10
|
+
## Migrations
|
|
11
|
+
|
|
12
|
+
_Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses._
|
|
13
|
+
|
|
14
|
+
Voltro's migrator does not generate or apply migration files. Instead, on every `voltro dev` boot — and any time you run `voltro db plan` — it:
|
|
15
|
+
|
|
16
|
+
1. **Introspects** the live database via `information_schema.*` (or the dialect-specific equivalent), building a `SchemaSnapshot`.
|
|
17
|
+
2. **Diffs** that against your declared schema (every `*.entity.ts` / `*.schema.ts` file in the project plus the framework's bookkeeping tables).
|
|
18
|
+
3. **Classifies** each pending DDL op into one of seven `OperationClass`es — `safe`, `needs-default`, `needs-backfill`, `needs-rename-annotation`, `lossy`, `online-required`, `multi-step`.
|
|
19
|
+
4. **Refuses** to apply anything that can't be made safe automatically. The diff output tells you exactly which DSL annotation to add (`.backfill()`, `.renamedFrom()`, `dropped()`, …).
|
|
20
|
+
5. **Applies** the rest under an advisory lock + records the result in `_voltro_migration_plans` with a fingerprint of the post-apply schema.
|
|
21
|
+
|
|
22
|
+
There are no generated SQL files to commit, no `migrations/` directory to rebase, no checksum manifest to repair. The source of truth is your schema TypeScript; the DB is the slave.
|
|
23
|
+
|
|
24
|
+
## The four-phase model
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
28
|
+
│ declare │ → │ plan (diff + │ → │ review │ → │ apply │
|
|
29
|
+
│ schema in TS │ │ classify) │ │ (CLI / UI) │ │ (dev: auto, │
|
|
30
|
+
│ │ │ │ │ │ │ prod: CLI) │
|
|
31
|
+
└───────────────┘ └─────────────────┘ └──────────────┘ └──────────────┘
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **dev**: phases 2–4 are automatic on boot. A blocked plan refuses the boot with a structured error pointing at the fix. When the declared schema is unchanged since the last apply, dev short-circuits on a **fingerprint check** (one indexed query) and skips the full introspect entirely — so reboots against a large schema stay fast. Force a full re-introspect (drift recovery) with `VOLTRO_MIGRATE_FORCE=1`.
|
|
35
|
+
- **prod**: phase 2 (planning) is automatic, but phase 4 (apply) NEVER happens during a serving process. The fingerprint of the declared schema must already match `_voltro_migration_plans.fingerprint` from a prior explicit `voltro db apply` — mismatch → refuse to boot.
|
|
36
|
+
|
|
37
|
+
This is the constraint repeated across the docs: **schema changes mid-rolling-deploy without review are not allowed**.
|
|
38
|
+
|
|
39
|
+
## A first session
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Edit apps/api/database/users.entity.ts — add a required column.
|
|
43
|
+
export const users = table('users', {
|
|
44
|
+
id: id(),
|
|
45
|
+
email: text(), // NEW: required, no default
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# 2. voltro dev. The planner classifies "ADD COLUMN email NOT NULL"
|
|
49
|
+
# on a populated table as `needs-backfill`. No backfill declared
|
|
50
|
+
# → boot refuses with:
|
|
51
|
+
#
|
|
52
|
+
# auto-migrate: REFUSED — 1 blocked operation(s):
|
|
53
|
+
# - add-column [users]: NOT NULL column on a table whose row count is unknown
|
|
54
|
+
# fix: declare `email: <type>().backfill(sql\`...\`)` OR `.default(value)`
|
|
55
|
+
# so existing rows survive the migration
|
|
56
|
+
voltro dev .
|
|
57
|
+
|
|
58
|
+
# 3. Add the backfill annotation in the schema:
|
|
59
|
+
export const users = table('users', {
|
|
60
|
+
id: id(),
|
|
61
|
+
email: text().backfill(sql`'unknown-' || id || '@local'`),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# 4. Boot again. The planner classifies the same op as `needs-backfill`
|
|
65
|
+
# with a declared backfill — applier runs ADD nullable → UPDATE via
|
|
66
|
+
# the SQL expression → SET NOT NULL inside one transaction.
|
|
67
|
+
voltro dev .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Same column. Same migration. The first attempt refuses loudly; the second succeeds silently. The DSL annotation IS the migration plan.
|
|
71
|
+
|
|
72
|
+
## The seven operation classes
|
|
73
|
+
|
|
74
|
+
Every pending op is stamped with one of seven `OperationClass` values —
|
|
75
|
+
`safe`, `needs-default`, `needs-backfill`, `needs-rename-annotation`,
|
|
76
|
+
`lossy`, `online-required`, `multi-step` — which drives whether it
|
|
77
|
+
auto-applies or refuses-to-plan pending a DSL annotation.
|
|
78
|
+
|
|
79
|
+
The full trigger + default-policy table, with a worked example and the
|
|
80
|
+
exact fix for each blocked case, lives on the dedicated
|
|
81
|
+
[Operation classes](./operation-classes.md) page.
|
|
82
|
+
|
|
83
|
+
## The CLI surface
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Planner-based (declarative diff)
|
|
87
|
+
voltro db plan # diff + color-coded classes + fix hints
|
|
88
|
+
voltro db plan --against <url> # diff vs a remote env's inspect endpoint (see cross-env-sync)
|
|
89
|
+
voltro db apply # execute (dev only — refuses on NODE_ENV=production)
|
|
90
|
+
voltro db apply --note '...' # apply with a freeform note recorded in history
|
|
91
|
+
voltro db plans [--limit 20] # history from _voltro_migration_plans, newest first
|
|
92
|
+
voltro db drift # live-vs-applied fingerprint check — exit 4 on drift
|
|
93
|
+
voltro db squash --before <date> # consolidate history into one snapshot
|
|
94
|
+
voltro db restore-snapshot <id> # restore VOLTRO_SOFT_DROP=1 columns from a plan
|
|
95
|
+
|
|
96
|
+
# File-based escape hatch — migration() up/down files under migrations/
|
|
97
|
+
voltro db files # apply pending migration() files
|
|
98
|
+
voltro db rollback-file <id> # run a migration() file's down body
|
|
99
|
+
|
|
100
|
+
# defineMigration step runner (separate system, _voltro_migrations table)
|
|
101
|
+
voltro db migrate # apply pending *.migration.ts steps
|
|
102
|
+
voltro db rollback [--to <id>] # undo applied *.migration.ts steps
|
|
103
|
+
voltro db status # list applied / pending *.migration.ts
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`plan` + `apply` are the planner-based commands. There are TWO distinct
|
|
107
|
+
file-based runners, intentionally not unified: the `migration()` runner
|
|
108
|
+
(`files` / `rollback-file`, records into `_voltro_migration_plans` with
|
|
109
|
+
`source: 'file'`) is the escape hatch the planner points you at for
|
|
110
|
+
table-splits and cross-table data moves; the `defineMigration` runner
|
|
111
|
+
(`migrate` / `rollback` / `status`, its own `_voltro_migrations` table)
|
|
112
|
+
runs hand-authored `*.migration.ts` step files. See [File-based
|
|
113
|
+
migrations](./file-based.md) for the `migration()` path — the one most
|
|
114
|
+
apps reach for.
|
|
115
|
+
|
|
116
|
+
Note: `voltro db plan` is flagless beyond `--against` / `--token` —
|
|
117
|
+
there is no `--json` or `--sql`, and `voltro db apply` takes only
|
|
118
|
+
`--note`. The prod flow is a plain `voltro db apply` run as an explicit
|
|
119
|
+
deploy step ([prod pipeline](./prod-pipeline.md)), not a pre-serialised
|
|
120
|
+
plan file.
|
|
121
|
+
|
|
122
|
+
## Where to go next
|
|
123
|
+
|
|
124
|
+
| Topic | Page |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Every operation class with concrete examples + each fix | [Operation classes](./operation-classes.md) |
|
|
127
|
+
| SQL vs JS backfill, performance, batch tuning | [Backfill](./backfill.md) |
|
|
128
|
+
| `.renamedFrom()` + `dropped()` lifecycle + when to remove the markers | [Rename and drop](./rename-and-drop.md) |
|
|
129
|
+
| MySQL implicit commit, SQLite table rewrite, per-dialect atomicity matrix | [Multi-dialect strategy](./multi-dialect.md) |
|
|
130
|
+
| CONCURRENTLY / batched backfill / shadow-column for large tables | [Online migrations](./online.md) |
|
|
131
|
+
| File-based escape hatch for table-split / merge / data moves | [File-based migrations](./file-based.md) |
|
|
132
|
+
| The plan-review-apply pipeline for production | [Prod pipeline](./prod-pipeline.md) |
|
|
133
|
+
| Local devtools dashboard walkthrough | [Devtools UI](./devtools-ui.md) |
|
|
134
|
+
| Cloud dashboard walkthrough + multi-tenant boundaries | [Cloud UI](./cloud-ui.md) |
|
|
135
|
+
| What can be reversed (and why planner plans have no auto-rollback) | [Rollback](./rollback.md) |
|
|
136
|
+
| Drift detection + recovery | [Drift](./drift.md) |
|
|
137
|
+
| Consolidating an aged history into one snapshot | [Squashing](./squashing.md) |
|
|
138
|
+
| `VOLTRO_SOFT_DROP=1` + restore-snapshot — the only path that recovers dropped-column data | [Soft-drop recovery](./rollback-snapshots.md) |
|
|
139
|
+
| `voltro db plan --against <env-url>` for pre-deploy preview | [Cross-environment sync](./cross-env-sync.md) |
|
|
140
|
+
| The most common "why is my boot refusing?" cases | [Troubleshooting](./troubleshooting.md) |
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
<!-- source: en/database/migrations/operation-classes.md -->
|
|
147
|
+
## Operation classes
|
|
148
|
+
|
|
149
|
+
_The seven classification buckets the planner sorts every diff into — what triggers each, what the planner does by default, and the exact DSL annotation that turns a blocked op into an allowed one._
|
|
150
|
+
|
|
151
|
+
Every concrete DDL operation the planner emits gets stamped with one of seven `OperationClass` values. The class drives the default policy + the refuse-to-plan message you'll see when something needs human input. This page enumerates them with a fixture-style example per class.
|
|
152
|
+
|
|
153
|
+
## safe
|
|
154
|
+
|
|
155
|
+
The op is reversible AND has no effect on existing data. Auto-applied on every dev boot + by `voltro db apply` in any env.
|
|
156
|
+
|
|
157
|
+
Triggers:
|
|
158
|
+
|
|
159
|
+
- `CREATE TABLE` (no live data possible)
|
|
160
|
+
- `ADD COLUMN <nullable>` — new column starts NULL
|
|
161
|
+
- `ADD INDEX` (small tables; large tables get promoted to `online-required`)
|
|
162
|
+
- `DROP INDEX`
|
|
163
|
+
- `ADD UNIQUE` — adding `.unique()` to an existing column whose live values are already distinct (emitted as the `<table>_<column>_key` constraint). Applies on the next boot; if the column already holds duplicates the DB rejects it **at migrate time** (fail-fast) rather than letting an `ON CONFLICT` upsert break at runtime. Pre-dedup a populated column with `.unique({ dedup })` — see the decision table. `DROP UNIQUE` (removing `.unique()`) is safe too.
|
|
164
|
+
- `ADD CHECK` on a new column
|
|
165
|
+
- Widening a type (`varchar(50)` → `varchar(255)`, `int` → `bigint`)
|
|
166
|
+
- Dropping `NOT NULL` (NULL → optional is monotonic)
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// Before:
|
|
170
|
+
export const users = table('users', { id: id(), email: text() })
|
|
171
|
+
|
|
172
|
+
// After — ADD bio (nullable) → safe.
|
|
173
|
+
export const users = table('users', {
|
|
174
|
+
id: id(),
|
|
175
|
+
email: text(),
|
|
176
|
+
bio: text().nullable(),
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Plan output:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
✓ ALTER TABLE users ADD COLUMN bio text # nullable column add — no backfill needed
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## needs-default
|
|
187
|
+
|
|
188
|
+
ADD NOT NULL column where the schema declares `.default(value)`. The planner emits `ADD COLUMN … NOT NULL DEFAULT <value>` in one statement. Postgres 11+ records the default in the catalog without rewriting the table — instant on a 100M-row table.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
export const users = table('users', {
|
|
192
|
+
id: id(),
|
|
193
|
+
plan: text().default('free'),
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Plan output:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
⊕ ALTER TABLE users ADD COLUMN plan text NOT NULL DEFAULT 'free' # literal default
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Cross-dialect note: MySQL strict mode + a `TEXT` column with a `DEFAULT` clause throws at DDL time. Use `varchar(N)` (`.maxLength(N)` on `text()`) for text columns that need a default on MySQL. The framework's [multi-dialect strategy](./multi-dialect.md) page covers the other landmines.
|
|
204
|
+
|
|
205
|
+
## needs-backfill
|
|
206
|
+
|
|
207
|
+
ADD NOT NULL column on a populated table where the schema declares `.backfill()`. The planner emits a three-step plan inside one transaction (or one forward-roll group on mysql/mariadb):
|
|
208
|
+
|
|
209
|
+
1. `ADD COLUMN <name> <type>` (nullable)
|
|
210
|
+
2. `UPDATE <table> SET <name> = <backfill-expr>`
|
|
211
|
+
3. `ALTER COLUMN <name> SET NOT NULL`
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
export const users = table('users', {
|
|
215
|
+
id: id(),
|
|
216
|
+
email: text().backfill(sql`'unknown-' || id || '@local'`),
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Plan output:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
⊕ ALTER TABLE users ADD COLUMN email text # 3-step: ADD nullable
|
|
224
|
+
⊕ UPDATE users SET email = 'unknown-' || id || '@local' # → run backfill
|
|
225
|
+
⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL # → SET NOT NULL
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Without the `.backfill()` annotation: **blocked**. The fix hint surfaces in both `voltro db plan` and the boot refuse message:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
✗ ALTER TABLE users ADD COLUMN email text # NOT NULL column on a table whose row count is unknown
|
|
232
|
+
! fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
For the SQL-vs-JS backfill trade-off + per-batch tuning see [Backfill](./backfill.md).
|
|
236
|
+
|
|
237
|
+
## needs-rename-annotation
|
|
238
|
+
|
|
239
|
+
Column X disappeared from the declared schema AND column Y appeared with similar shape. The planner won't silently turn that into `DROP X` + `ADD Y` (data loss). It refuses-to-plan unless the new column carries `.renamedFrom('X')` — the explicit signal that intent is RENAME, not DROP+ADD.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// Before:
|
|
243
|
+
export const users = table('users', { id: id(), firstName: text() })
|
|
244
|
+
|
|
245
|
+
// After — without the marker, planner refuses:
|
|
246
|
+
export const users = table('users', { id: id(), givenName: text() })
|
|
247
|
+
|
|
248
|
+
// With the marker — planner folds the diff into one RENAME op:
|
|
249
|
+
export const users = table('users', {
|
|
250
|
+
id: id(),
|
|
251
|
+
givenName: text().renamedFrom('firstName'),
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The annotation stays in the code until the rename has been applied in every env you care about (dev, staging, prod). `_voltro_migration_plans` records the applied rename so the planner won't re-emit; removing the marker earlier yields a clear refuse-to-plan ("did you remove `.renamedFrom('firstName')` before staging migration applied? Re-add the marker oder apply against staging first"). [Rename and drop](./rename-and-drop.md) covers the full lifecycle.
|
|
256
|
+
|
|
257
|
+
## lossy
|
|
258
|
+
|
|
259
|
+
The op destroys data. Refuse-to-plan unless the developer declared intent explicitly OR `VOLTRO_DESTRUCTIVE_OK=1` was set.
|
|
260
|
+
|
|
261
|
+
Triggers:
|
|
262
|
+
|
|
263
|
+
- `DROP COLUMN` (live data is gone after apply)
|
|
264
|
+
- `DROP TABLE`
|
|
265
|
+
- Narrowing a type (`varchar(255)` → `varchar(50)` with strings longer than 50)
|
|
266
|
+
- `DROP UNIQUE` constraint that other code might depend on
|
|
267
|
+
- `DROP INDEX` that an FK depends on
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// Drop a column INTENTIONALLY — declare `dropped()`:
|
|
271
|
+
export const users = table('users', {
|
|
272
|
+
id: id(),
|
|
273
|
+
legacy: dropped(), // ← explicit. Planner classifies lossy, allows apply.
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
⊕ ALTER TABLE users DROP COLUMN legacy # column dropped via `dropped()` marker — intentional
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Without the marker:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
✗ ALTER TABLE users DROP COLUMN legacy # column missing from declared schema
|
|
285
|
+
! fix: if intentional, add `legacy: dropped()` to the schema. If a typo, restore the field
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
DROP TABLE has no equivalent annotation — the table simply being missing from the declared set is the signal. Set `VOLTRO_DESTRUCTIVE_OK=1` to allow it (loud warning), or use a [file-based migration](./file-based.md) for cross-table data moves the diff can't infer.
|
|
289
|
+
|
|
290
|
+
`VOLTRO_DESTRUCTIVE_OK=1` only relaxes the refusal when EVERY blocked op is `lossy`. Rename-without-marker and NOT-NULL-without-backfill stay firm regardless — those are sloppy declarations, not intentional destruction.
|
|
291
|
+
|
|
292
|
+
## online-required
|
|
293
|
+
|
|
294
|
+
The op operates on a table whose row count exceeds the planner's online threshold (default 50k; tunable via `online-after` per project). Auto-rewritten to a non-blocking variant:
|
|
295
|
+
|
|
296
|
+
- `ADD INDEX`: Postgres `CREATE INDEX CONCURRENTLY`, MySQL/MariaDB `ALGORITHM=INPLACE LOCK=NONE`, MSSQL `WITH (ONLINE = ON)`, SQLite no-op
|
|
297
|
+
- `UPDATE` backfill: batched (default 1k rows/batch, configurable `.backfill(sql, { batchSize: 5000, sleepMs: 50 })`)
|
|
298
|
+
- Type rewrites that need shadow-column-swap (`ALTER COLUMN TYPE` on large tables)
|
|
299
|
+
|
|
300
|
+
[Online migrations](./online.md) walks through every variant with sizing + tuning guidance.
|
|
301
|
+
|
|
302
|
+
## multi-step
|
|
303
|
+
|
|
304
|
+
Operations the planner can't infer from a structural diff alone:
|
|
305
|
+
|
|
306
|
+
- Splitting a table (e.g. extract address fields to a separate `addresses` table with FK back)
|
|
307
|
+
- Merging two tables
|
|
308
|
+
- Type changes that need a custom `USING` expression (`text → integer` requires `USING col::integer`)
|
|
309
|
+
- Data moves that span multiple tables atomically
|
|
310
|
+
|
|
311
|
+
The planner refuses-to-plan these + points at the [file-based migrations](./file-based.md) escape hatch. You write the migration body explicitly (up/down SQL or Effect program) and the framework picks it up in timestamp order before the next auto-diff pass.
|
|
312
|
+
|
|
313
|
+
## Decision table at a glance
|
|
314
|
+
|
|
315
|
+
| Want to … | Add this to the schema |
|
|
316
|
+
|---|---|
|
|
317
|
+
| Add a required column with a constant default | `.default(value)` |
|
|
318
|
+
| Add a required column on a populated table | `.backfill(sql\`expr\`)` |
|
|
319
|
+
| Rename a column without losing data | `.renamedFrom('oldName')` on the new column |
|
|
320
|
+
| Drop a column intentionally | `legacy: dropped()` at the field-map slot |
|
|
321
|
+
| Drop a table intentionally | (remove from declared set) + `VOLTRO_DESTRUCTIVE_OK=1` for one apply |
|
|
322
|
+
| Add a unique constraint on a populated column with dupes | `.unique({ dedup: 'fail' / 'suffix-counter' })` |
|
|
323
|
+
| Add an FK on a populated column with orphans | `reference(() => target, { orphanPolicy: 'fail' / 'null' / 'delete' })` |
|
|
324
|
+
| Change a column type with a custom cast | `.narrowedFrom('<live type>', { using })` on the column — the planner downgrades the blocked-lossy type change to `needs-backfill` and threads the `USING` cast (see [rename and drop](./rename-and-drop.md)) |
|
|
325
|
+
| Move data across tables atomically | [file-based migration](./file-based.md) |
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
<!-- source: en/database/migrations/rename-and-drop.md -->
|
|
332
|
+
## Rename and drop
|
|
333
|
+
|
|
334
|
+
_The two annotations that gate destructive-looking changes — .renamedFrom() turns a DROP+ADD diff into a RENAME, dropped() turns a refused DROP into an allowed one. Lifecycle + when to remove the markers._
|
|
335
|
+
|
|
336
|
+
The planner refuses to silently rename or drop columns. Both ops can look identical structurally — column X gone, column Y present — but mean very different things. The annotations give you the vocabulary to declare intent.
|
|
337
|
+
|
|
338
|
+
## `.renamedFrom(oldName)`
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
// Before:
|
|
342
|
+
export const users = table('users', { id: id(), firstName: text() })
|
|
343
|
+
|
|
344
|
+
// After — without the marker:
|
|
345
|
+
export const users = table('users', { id: id(), givenName: text() })
|
|
346
|
+
// → planner classifies as DROP firstName + ADD givenName
|
|
347
|
+
// → ADD givenName lands as `needs-backfill` (blocked, no backfill declared)
|
|
348
|
+
// → boot refuses
|
|
349
|
+
|
|
350
|
+
// With the marker:
|
|
351
|
+
export const users = table('users', {
|
|
352
|
+
id: id(),
|
|
353
|
+
givenName: text().renamedFrom('firstName'),
|
|
354
|
+
})
|
|
355
|
+
// → planner folds the diff into one RENAME COLUMN op, classified `safe`
|
|
356
|
+
// → boot applies it via `ALTER TABLE users RENAME COLUMN firstName TO givenName`
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The marker says: "the column previously named `firstName` should be the column now declared as `givenName`". The planner verifies the live DB has a column called `firstName` matching the new column's shape (type, nullable, default). Mismatch → refuse with a helpful error.
|
|
360
|
+
|
|
361
|
+
## When `.renamedFrom()` doesn't fold
|
|
362
|
+
|
|
363
|
+
If the live DB doesn't have a column called `firstName`, the marker is a no-op:
|
|
364
|
+
|
|
365
|
+
- Maybe the rename was already applied → `firstName` is gone, `givenName` is there → diff is empty → no folding needed
|
|
366
|
+
- Maybe you typo'd the old name → live has `first_name` not `firstName` → planner falls back to treating `givenName` as a new column (which IS `needs-backfill` → refuses)
|
|
367
|
+
|
|
368
|
+
The marker isn't validated against the live DB at schema-build time — it would have to introspect during type-checking, which is expensive. The runtime check fires at plan time.
|
|
369
|
+
|
|
370
|
+
## Lifecycle — when to remove the marker
|
|
371
|
+
|
|
372
|
+
Keep the marker until the rename has been applied in EVERY env you care about (dev, staging, prod). The framework tracks applied ops in `_voltro_migration_plans`:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
dev ← rename applied at 2026-04-15. Marker can come out.
|
|
376
|
+
staging ← rename applied at 2026-04-18. Marker can come out.
|
|
377
|
+
prod ← rename NOT YET APPLIED.
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Pull the marker too early and the next `voltro db plan` against prod sees:
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
✗ ALTER TABLE users DROP COLUMN firstName
|
|
384
|
+
✗ ALTER TABLE users ADD COLUMN givenName text
|
|
385
|
+
! fix: did you remove `.renamedFrom('firstName')` before staging migration applied?
|
|
386
|
+
Re-add the marker OR run `voltro db apply` against staging first.
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Practical rule: the marker stays in the codebase across the rollout. Once `voltro db drift` shows clean against the last env (usually prod), the rename is fully applied + the marker can come out in a follow-up PR.
|
|
390
|
+
|
|
391
|
+
## `dropped()`
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import { dropped } from '@voltro/database'
|
|
395
|
+
|
|
396
|
+
// Drop a column on a populated table — without the marker, refused:
|
|
397
|
+
export const users = table('users', { id: id() }) // `legacy` simply gone
|
|
398
|
+
// → planner sees `users.legacy` in live but not declared → classifies lossy → blocked
|
|
399
|
+
|
|
400
|
+
// With the marker:
|
|
401
|
+
export const users = table('users', {
|
|
402
|
+
id: id(),
|
|
403
|
+
legacy: dropped(), // ← explicit intent
|
|
404
|
+
})
|
|
405
|
+
// → planner classifies lossy + ALLOWED (intent declared)
|
|
406
|
+
// → applier emits ALTER TABLE users DROP COLUMN legacy
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The marker fills the field-map slot the column used to occupy, telling the planner: "this column existed in the live DB AND is intentionally going away". The planner now classifies the drop as lossy-but-intended, which auto-applies.
|
|
410
|
+
|
|
411
|
+
`dropped()` is a column-shape no-op at runtime (the migration emitter emits a DROP COLUMN, then the column is gone). It's purely planner metadata.
|
|
412
|
+
|
|
413
|
+
## Lifecycle — when to remove `dropped()`
|
|
414
|
+
|
|
415
|
+
After the drop has been applied in every env, remove the field-map entry entirely. The next plan sees nothing to do for that column (the live DB no longer has it, the declared schema no longer references it).
|
|
416
|
+
|
|
417
|
+
If you pull the `dropped()` marker before the drop has applied to all envs, the planner sees the column in live + the column ABSENT from declared → classifies as a fresh `DROP COLUMN` → blocked again with the same "add `dropped()` marker" fix. You'd just have to re-add it; no harm, no data loss.
|
|
418
|
+
|
|
419
|
+
## Dropping a table
|
|
420
|
+
|
|
421
|
+
There's no `dropped()` equivalent for tables. The table simply being absent from the declared set IS the signal:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
// Remove the entire users.entity.ts file or its export from database/index.ts
|
|
425
|
+
// → planner sees `users` in live but not in declared → lossy DROP TABLE → blocked
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
To allow it, set `VOLTRO_DESTRUCTIVE_OK=1` on the apply:
|
|
429
|
+
|
|
430
|
+
```sh
|
|
431
|
+
VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring users table after migration to user_accounts'
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`VOLTRO_DESTRUCTIVE_OK=1` only relaxes the refusal when EVERY blocked op is `lossy`. If the plan also has a rename-without-marker or a NOT-NULL-without-backfill, those stay refused regardless.
|
|
435
|
+
|
|
436
|
+
For complex multi-table retirements (move data out, then drop), use a [file-based migration](./file-based.md) — explicit ordering + a transaction wrapped around the data move.
|
|
437
|
+
|
|
438
|
+
## `.narrowedFrom()` for type changes
|
|
439
|
+
|
|
440
|
+
A bare column type change is **refuse-to-plan** — the planner blocks it
|
|
441
|
+
(the same way a `drop-column` without `dropped()` is blocked), because a
|
|
442
|
+
raw `ALTER COLUMN … TYPE` may not be value-preserving and fails outright
|
|
443
|
+
at the DB for non-implicit casts. Acknowledge the change with
|
|
444
|
+
`.narrowedFrom(<live type>, { using })`: the planner downgrades it to
|
|
445
|
+
`needs-backfill` and threads the cast into the applier's
|
|
446
|
+
`ALTER COLUMN … TYPE … USING <using>` (and the online shadow-column copy).
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
// Before: status: text()
|
|
450
|
+
// After:
|
|
451
|
+
export const orders = table('orders', {
|
|
452
|
+
id: id(),
|
|
453
|
+
status: text().oneOf(['pending', 'shipped', 'delivered']).narrowedFrom('text', {
|
|
454
|
+
using: 'status::status_enum',
|
|
455
|
+
}),
|
|
456
|
+
})
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **`from`** is the type the LIVE DB currently has. It MUST equal the live
|
|
460
|
+
column type — a stale `from` (the column already changed, or you named
|
|
461
|
+
the wrong prior type) is ignored and the change stays blocked-lossy.
|
|
462
|
+
- **`using`** is the raw cast expression spliced verbatim into
|
|
463
|
+
`ALTER COLUMN … TYPE … USING <using>` (postgres) / the batched
|
|
464
|
+
shadow-copy (`shadow := <using>(old)`) on the online path. It's
|
|
465
|
+
developer-authored migration SQL — keep it portable or dialect-correct
|
|
466
|
+
for your target.
|
|
467
|
+
- **Omit `using`** when the conversion is implicit on the dialect (e.g.
|
|
468
|
+
`varchar → text`): the planner still downgrades the change, and the
|
|
469
|
+
applier emits a plain `ALTER COLUMN … TYPE` with no `USING`. For a
|
|
470
|
+
non-implicit cast with no `using`, the DB rejects the apply — declare
|
|
471
|
+
the cast.
|
|
472
|
+
|
|
473
|
+
## `orphanPolicy` — adding an FK to a populated column
|
|
474
|
+
|
|
475
|
+
Promoting an existing `text()` column to `reference()` (common when a
|
|
476
|
+
column already holds the target's id as a plain string — e.g. data
|
|
477
|
+
migrated from another system) is NOT a type change: a `reference` is
|
|
478
|
+
TEXT-storage on every dialect, so the planner collapses the type diff to
|
|
479
|
+
a no-op. The only real change is the FK CONSTRAINT, which `db apply` adds
|
|
480
|
+
with `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …`.
|
|
481
|
+
|
|
482
|
+
By default the FK-add just applies — classified `needs-backfill`, exactly
|
|
483
|
+
like tightening a column to NOT NULL: the existing rows must already satisfy
|
|
484
|
+
it. If a row is an **orphan** (its value points at a target that doesn't
|
|
485
|
+
exist) the `ADD CONSTRAINT` fails at the DB, the whole apply rolls back
|
|
486
|
+
atomically, and the failing statement is surfaced. `orphanPolicy` (on the
|
|
487
|
+
reference) tells the applier to clear orphans FIRST so it can't fail:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
// Was `authorId: text()`. The column already has data, possibly with orphans.
|
|
491
|
+
authorId: reference(() => users, { orphanPolicy: 'null' }).nullable(),
|
|
492
|
+
ownerId: reference(() => orgs, { orphanPolicy: 'delete' }),
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- **`'fail'` (default)** — bare `ADD CONSTRAINT`, **applies** (`needs-backfill`,
|
|
496
|
+
not blocked). The DB rejects it only if an existing row is an orphan — then
|
|
497
|
+
declare `'null'` / `'delete'` and re-apply. (The planner is pure — it can't
|
|
498
|
+
read row counts, so it can't distinguish a clean / empty table from one with
|
|
499
|
+
orphans; blocking by default would refuse every clean case too.)
|
|
500
|
+
- **`'null'`** — the applier runs `UPDATE child SET col = NULL WHERE col`
|
|
501
|
+
*references a missing target* BEFORE `ADD CONSTRAINT`. Requires the
|
|
502
|
+
column be `.nullable()` (else the NULL-out would violate NOT NULL — the
|
|
503
|
+
planner blocks it with that exact hint).
|
|
504
|
+
- **`'delete'`** — the applier runs `DELETE FROM child WHERE col`
|
|
505
|
+
*references a missing target* first (removes the orphan ROWS — destructive).
|
|
506
|
+
|
|
507
|
+
`'null'` / `'delete'` apply via plain `db apply` — the policy IS the
|
|
508
|
+
acknowledgement, exactly like `.narrowedFrom(...)` for a type change — and
|
|
509
|
+
show in the plan as `lossy` with a reason naming the orphan handling.
|
|
510
|
+
`orphanPolicy` is planner metadata only (no runtime/query effect), and once
|
|
511
|
+
applied a re-plan is a no-op (introspection reports the FK; the policy is
|
|
512
|
+
stripped from the comparison). On **sqlite** an FK change rebuilds the table,
|
|
513
|
+
so the orphan pre-step is skipped — clean the orphans yourself there.
|
|
514
|
+
|
|
515
|
+
## Markers don't pile up
|
|
516
|
+
|
|
517
|
+
Each marker maps to ONE applied op. The next migration after a rename +
|
|
518
|
+
drop has clean code:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
// Before the rollout:
|
|
522
|
+
export const users = table('users', {
|
|
523
|
+
id: id(),
|
|
524
|
+
givenName: text().renamedFrom('firstName'),
|
|
525
|
+
legacy: dropped(),
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// After the rollout finished + applied in every env:
|
|
529
|
+
export const users = table('users', {
|
|
530
|
+
id: id(),
|
|
531
|
+
givenName: text(),
|
|
532
|
+
// legacy: dropped() removed entirely — the field-map slot disappears too.
|
|
533
|
+
})
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
<!-- source: en/database/migrations/backfill.md -->
|
|
543
|
+
## Backfill
|
|
544
|
+
|
|
545
|
+
_Two backfill flavors — server-side SQL expressions and per-row JS functions. Decision rubric, batch tuning, performance trade-offs, and what happens when a backfill itself fails._
|
|
546
|
+
|
|
547
|
+
When you add a NOT NULL column to a populated table, the planner needs a value for every existing row before it can land the `SET NOT NULL` constraint. The `.backfill()` annotation declares that value. The planner classifies the op as `needs-backfill` (allowed), the applier runs the three-step plan.
|
|
548
|
+
|
|
549
|
+
Two flavors:
|
|
550
|
+
|
|
551
|
+
- **SQL backfill** — `.backfill(sql\`expression\`)`. The applier emits one `UPDATE` statement; the database does the work.
|
|
552
|
+
- **JS backfill** — `.backfill((row) => value)`. The applier streams rows in batches, calls the function locally, writes back.
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
export const users = table('users', {
|
|
556
|
+
id: id(),
|
|
557
|
+
|
|
558
|
+
// SQL backfill — an @effect/sql Statement.Fragment; one round-trip, scales linearly.
|
|
559
|
+
email: text().backfill(unknownEmailExpr),
|
|
560
|
+
|
|
561
|
+
// JS backfill — when SQL can't express what you need.
|
|
562
|
+
embedding: text().backfill(async (row) => embed(row.title), {
|
|
563
|
+
batchSize: 500, // rows per batch, default 1000
|
|
564
|
+
sleepMs: 25, // ms between batches, default 0
|
|
565
|
+
}),
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
## When to pick which
|
|
570
|
+
|
|
571
|
+
| Need | Pick |
|
|
572
|
+
|---|---|
|
|
573
|
+
| Constant value for every row | `.default(value)` — not a backfill at all, the DDL DEFAULT clause does it |
|
|
574
|
+
| Expression of existing column values (`id || '@local'`, `LOWER(email)`, `created_at + interval '1 day'`) | **SQL** |
|
|
575
|
+
| Read another table's row (`(SELECT id FROM tenants WHERE name = '...' LIMIT 1)`) | **SQL** |
|
|
576
|
+
| Call an embedding model / image classifier / external HTTP API | **JS** |
|
|
577
|
+
| Compute a value with a JS library that has no SQL equivalent (`slugify`, `tokenize`, `parse`) | **JS** |
|
|
578
|
+
|
|
579
|
+
The bias is firmly toward SQL. Performance is in different leagues — SQL backfills hit a million rows in seconds; JS backfills hit the same set in minutes-to-hours depending on what the function does.
|
|
580
|
+
|
|
581
|
+
## SQL backfill
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
email: text().backfill(sql`'unknown-' || id || '@local'`),
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
The expression goes inside `UPDATE <table> SET <col> = <expression>`. You can reference:
|
|
588
|
+
|
|
589
|
+
- Other columns of the same row (`id`, `created_at`, etc.) — by name, no aliasing
|
|
590
|
+
- Constants and literals — `'@local'`, `42`, `true`
|
|
591
|
+
- Standard SQL functions — `LOWER()`, `COALESCE()`, `EXTRACT()`, `||`, …
|
|
592
|
+
- Subqueries — `(SELECT id FROM tenants WHERE name = 'acme')` — including correlated ones
|
|
593
|
+
- Dialect-specific functions when you know which DB you're on
|
|
594
|
+
|
|
595
|
+
**Don't** reference columns the planner is about to drop / rename in the same plan — the UPDATE runs AFTER the ADD COLUMN but BEFORE any drops, so renamed columns are still under their old name at backfill time. The planner orders the plan deterministically (renames first as `RENAME COLUMN`, then ADD/ALTER); the backfill sees the post-rename names.
|
|
596
|
+
|
|
597
|
+
### Cross-dialect SQL idioms
|
|
598
|
+
|
|
599
|
+
The `sql\`...\`` fragment is the same `@effect/sql` template you use in custom handlers. Use `sql.onDialectOrElse({...})` for expressions that vary:
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
const timestampNow = sql.onDialectOrElse({
|
|
603
|
+
mysql: () => sql`NOW(6)`,
|
|
604
|
+
mssql: () => sql`SYSUTCDATETIME()`,
|
|
605
|
+
orElse: () => sql`now()`,
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
createdAt: timestamp().backfill(timestampNow),
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
The framework's `sql.onDialectOrElse` resolves at compile time, so each dialect only emits its own branch. See [Multi-dialect strategy](./multi-dialect.md).
|
|
612
|
+
|
|
613
|
+
## JS backfill
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
embedding: text().backfill(async (row) => {
|
|
617
|
+
const text = `${row.title} ${row.body}`
|
|
618
|
+
return embed(text) // calls an external embedding model
|
|
619
|
+
}, {
|
|
620
|
+
batchSize: 500,
|
|
621
|
+
sleepMs: 25,
|
|
622
|
+
}),
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The function receives the full row (with `id` and every existing column). The applier:
|
|
626
|
+
|
|
627
|
+
1. Streams rows: `SELECT id FROM <table> ORDER BY id` with cursor pagination
|
|
628
|
+
2. For each batch of `batchSize`:
|
|
629
|
+
- Calls the function for each row, in parallel
|
|
630
|
+
- Writes back: `UPDATE <table> SET <col> = $1 WHERE id = $2` for each result
|
|
631
|
+
3. `sleepMs` between batches to let normal traffic breathe
|
|
632
|
+
|
|
633
|
+
`batchSize` controls memory + concurrency (each batch holds N row promises). `sleepMs` reduces contention with concurrent writes — set it to ~20–50 ms when the function makes external API calls (the API quota matters more than throughput).
|
|
634
|
+
|
|
635
|
+
**The function MUST be deterministic + idempotent for the same input row.** A crash mid-backfill restarts the batch; non-idempotent functions double-charge external APIs or write duplicate side effects.
|
|
636
|
+
|
|
637
|
+
## Performance
|
|
638
|
+
|
|
639
|
+
Order-of-magnitude rules of thumb for postgres on a modest VM:
|
|
640
|
+
|
|
641
|
+
| Rows | SQL backfill | JS backfill (pure CPU) | JS backfill (calls 50 ms API) |
|
|
642
|
+
|---|---|---|---|
|
|
643
|
+
| 1k | < 50 ms | ~200 ms | ~25 s |
|
|
644
|
+
| 10k | ~300 ms | ~2 s | ~5 min (batched, 500-wide) |
|
|
645
|
+
| 100k | ~3 s | ~25 s | ~50 min |
|
|
646
|
+
| 1M | ~30 s | ~5 min | unfeasible — use a separate workflow |
|
|
647
|
+
| 10M | ~5 min | ~50 min | unfeasible |
|
|
648
|
+
|
|
649
|
+
JS backfill is 10×–100× slower than SQL for the same data; with external calls it's 1000× slower. The CLI surfaces an estimate ahead of apply:
|
|
650
|
+
|
|
651
|
+
```
|
|
652
|
+
⊕ needs-backfill (1) — declared
|
|
653
|
+
+ ALTER TABLE posts ADD COLUMN slug text
|
|
654
|
+
→ backfill: js fn (est. 50,000 rows, ~8 minutes — consider .backfill(sql) variant?)
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The estimate is conservative (counts rows + multiplies by a per-row JS-fn cost factor). It's a hint, not a refusal — if you genuinely need the JS function, set `--note 'backfill via embedding model, expected duration'` so the history row records WHY the slow path was chosen.
|
|
658
|
+
|
|
659
|
+
## Validate a JS backfill before applying
|
|
660
|
+
|
|
661
|
+
A JS backfill is regular TypeScript — the safest way to confirm it
|
|
662
|
+
produces sensible output before committing a long UPDATE run is to call
|
|
663
|
+
the function directly in a unit test (`voltro test`) over a handful of
|
|
664
|
+
representative rows. There is no `voltro db backfill --dry-run`
|
|
665
|
+
subcommand; `.backfill()` only runs as part of `voltro db apply`.
|
|
666
|
+
|
|
667
|
+
## Failure handling
|
|
668
|
+
|
|
669
|
+
If the backfill `UPDATE` fails mid-flight:
|
|
670
|
+
|
|
671
|
+
- **Postgres / MSSQL / SQLite**: the whole 3-step is inside one transaction. The failure rolls back the ADD COLUMN too — the schema returns to its pre-apply state. The plan stays pending; fix the backfill expression + re-apply with `voltro db apply`.
|
|
672
|
+
- **MySQL / MariaDB**: DDL is implicit-commit. The ADD COLUMN landed. The UPDATE rolled back to its savepoint, but the nullable column is now on the table. The next `voltro db plan` will see the column present but nullable + emit the remaining `SET NOT NULL` step, which `voltro db apply` then applies against the current state.
|
|
673
|
+
|
|
674
|
+
The [multi-dialect strategy](./multi-dialect.md) page covers the forward-roll mechanics in detail.
|
|
675
|
+
|
|
676
|
+
If a JS backfill function throws partway through, the applier stops the
|
|
677
|
+
batch loop, records the affected row id range in the log, leaves the
|
|
678
|
+
column nullable (no `SET NOT NULL`), and exits non-zero. Re-run
|
|
679
|
+
`voltro db apply` once you've fixed the function — because each batch's
|
|
680
|
+
`UPDATE … WHERE <col> IS NULL` only touches rows that haven't been
|
|
681
|
+
filled yet, the re-run picks up where it stopped without double-writing
|
|
682
|
+
completed rows.
|
|
683
|
+
|
|
684
|
+
## What about updating an existing column?
|
|
685
|
+
|
|
686
|
+
`.backfill()` ONLY applies to ADD-COLUMN ops. Updating values on an existing column isn't a migration concern — it's regular data work. Write a one-off mutation or a `*.subscribe.ts` handler that watches for the trigger condition, OR a workflow if it spans steps. The migration system stays out of "change data in this column" jobs.
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
<!-- source: en/database/migrations/multi-dialect.md -->
|
|
693
|
+
## Multi-dialect strategy
|
|
694
|
+
|
|
695
|
+
_How the planner + applier behave across Postgres, MySQL, MariaDB, MSSQL, SQLite — the atomicity matrix, MySQL's implicit-commit landmine + re-diff-to-recover, SQLite's table-rewrite mechanic, plus the per-dialect DDL idioms the framework hides._
|
|
696
|
+
|
|
697
|
+
The planner produces ONE `MigrationPlan` regardless of dialect. The applier executes it per-dialect, dispatching through `sql.onDialectOrElse` for every emit + falling back to runtime probes when behaviour diverges. The same `voltro db apply` invocation against the same schema produces structurally identical results on every backend.
|
|
698
|
+
|
|
699
|
+
What ISN'T uniform: **transactional DDL semantics**.
|
|
700
|
+
|
|
701
|
+
| Dialect | Transactional DDL | Advisory-lock mechanism | Backfill in tx with DDL |
|
|
702
|
+
|---|---|---|---|
|
|
703
|
+
| Postgres | ✓ all DDL atomic | `pg_advisory_lock(KEY)` | ✓ |
|
|
704
|
+
| MSSQL | ✓ all DDL atomic | `sp_getapplock` | ✓ |
|
|
705
|
+
| SQLite | ✓ all DDL atomic | process-local mutex | ✓ |
|
|
706
|
+
| **MySQL / MariaDB** | **✗ implicit commit per DDL** | `GET_LOCK('voltro_migration', N)` | ✓ but SEPARATE from DDL |
|
|
707
|
+
|
|
708
|
+
This is the operationally heaviest cross-dialect difference. The rest of the page covers what changes.
|
|
709
|
+
|
|
710
|
+
## Postgres / MSSQL / SQLite — transactional happy path
|
|
711
|
+
|
|
712
|
+
A multi-step plan runs inside one `BEGIN ... COMMIT`. Mid-flight failure rolls EVERYTHING back; the next plan diff is identical to the pre-apply one. There's nothing to resume — re-running the apply re-runs the plan from scratch.
|
|
713
|
+
|
|
714
|
+
The advisory-lock variants serialise concurrent applies — two operators running `voltro db apply` against the same DB at the same time go through serially.
|
|
715
|
+
|
|
716
|
+
## MySQL / MariaDB — re-diff-to-recover territory
|
|
717
|
+
|
|
718
|
+
Every DDL statement implicitly commits. A 5-op plan on MySQL is effectively 5 separate "atomic statements" with the prior ones already committed when a later one fails. If op 5 of 5 fails, ops 1–4 stay applied:
|
|
719
|
+
|
|
720
|
+
```
|
|
721
|
+
plan applying (mysql, env=prod):
|
|
722
|
+
1. create-table audit_logs ✓ 42ms
|
|
723
|
+
2. add-column users.email ✓ 18ms
|
|
724
|
+
3. backfill users.email ✓ 12s
|
|
725
|
+
4. alter-column users.email SET NOT NULL ✓ 8ms
|
|
726
|
+
5. add-index audit_logs(actorId) ✗ ER_DUP_KEYNAME
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
There is NO `--resume` / `--abort` flag and NO per-op partial-status row — the applier records a `_voltro_migration_plans` row only on a fully successful apply. Recovery is to re-run the apply once the cause is fixed:
|
|
730
|
+
|
|
731
|
+
```sh
|
|
732
|
+
voltro db apply
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`voltro db apply` re-introspects the live DB and diffs the declared schema against the (half-applied) live shape, so it emits ONLY the ops still missing — ops 1–4 are already in the DB and don't reappear in the diff. If someone finished op 5 out of band (psql, a corrective hot-fix), the re-diff sees it as present and skips it too. If the failed op is no longer the right answer because the schema was edited in response, the next `voltro db plan` already reflects the new declared shape — nothing to abort.
|
|
736
|
+
|
|
737
|
+
## SQLite — table rewrite mechanic
|
|
738
|
+
|
|
739
|
+
SQLite lacks `ALTER COLUMN`. For type changes, the applier auto-emits the standard pattern:
|
|
740
|
+
|
|
741
|
+
1. `CREATE TABLE <name>_new (<new column definitions>)`
|
|
742
|
+
2. `INSERT INTO <name>_new SELECT (with cast) FROM <name>`
|
|
743
|
+
3. `DROP TABLE <name>`
|
|
744
|
+
4. `ALTER TABLE <name>_new RENAME TO <name>`
|
|
745
|
+
5. Recreate every index + every FK the old table had
|
|
746
|
+
|
|
747
|
+
The plan output flags this as "rewrite table" so you know what's happening:
|
|
748
|
+
|
|
749
|
+
```
|
|
750
|
+
⊕ ALTER TABLE users ALTER COLUMN status TYPE varchar(20) # rewrite table (SQLite has no ALTER COLUMN)
|
|
751
|
+
→ recreates 3 indexes, 2 incoming FKs
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Caveats:
|
|
755
|
+
|
|
756
|
+
- **Foreign keys referencing the rewritten table** get dropped and recreated. If they declared `ON DELETE CASCADE`, the recreate restores it; ordering matters internally.
|
|
757
|
+
- **Multi-rewrite plans on SQLite are brittle.** A plan that rewrites 3 related tables in one apply may have intermediate states where a FK temporarily references a non-existent table. The framework orders them topologically; mixed rename + rewrite within one plan can hit edge cases. If a SQLite multi-rewrite refuses, [file-based migrations](./file-based.md) let you control ordering manually.
|
|
758
|
+
|
|
759
|
+
## Per-dialect DDL idioms hidden from you
|
|
760
|
+
|
|
761
|
+
The framework emits the right dialect-native idiom for every concept. You don't write these by hand:
|
|
762
|
+
|
|
763
|
+
| Concept | Postgres | MySQL / MariaDB | MSSQL | SQLite |
|
|
764
|
+
|---|---|---|---|---|
|
|
765
|
+
| Identifier quoting | `"name"` | `` `name` `` | `[name]` | `"name"` |
|
|
766
|
+
| `CREATE TABLE IF NOT EXISTS` | native | native | `IF NOT EXISTS (SELECT * FROM sys.tables...) EXEC(...)` | native |
|
|
767
|
+
| Auto-increment id | `BIGSERIAL` | `BIGINT AUTO_INCREMENT` | `BIGINT IDENTITY(1,1)` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
|
768
|
+
| Booleans | native `boolean` | `tinyint(1)` (0/1) | `bit` (0/1) | `integer` (0/1) |
|
|
769
|
+
| JSON column | `jsonb` | `json` | `nvarchar(max)` | `text` |
|
|
770
|
+
| Timestamp with tz | `timestamptz` | `datetime(6)` | `datetime2` | `datetime` |
|
|
771
|
+
| `now()` default | `now()` | `CURRENT_TIMESTAMP(6)` | `SYSUTCDATETIME()` | `current_timestamp` |
|
|
772
|
+
| `RETURNING *` on INSERT | native | NOT available (separate SELECT) | `OUTPUT INSERTED.*` | native |
|
|
773
|
+
| `LIMIT N OFFSET M` | native | native | `OFFSET M ROWS FETCH NEXT N ROWS ONLY` | native |
|
|
774
|
+
| FK with cascade | native | native | native | native (must `PRAGMA foreign_keys = ON`) |
|
|
775
|
+
|
|
776
|
+
The DDL emitter under `@voltro/database/src/migrate.ts` is one of the densest cross-dialect dispatch files in the codebase. Bug reports for "X doesn't work on dialect Y" usually trace to a missing branch there.
|
|
777
|
+
|
|
778
|
+
## Boot-log shape per dialect
|
|
779
|
+
|
|
780
|
+
`voltro dev` prints a one-line dialect summary during the auto-migrate phase:
|
|
781
|
+
|
|
782
|
+
```
|
|
783
|
+
[voltro:dev] auto-migrate: planning schema dialect=postgres env=dev tables=22
|
|
784
|
+
[voltro:dev] auto-migrate: applied 3 op(s) in 412ms [safe=3 needs-default=0 needs-backfill=0 rename=0 lossy=0] fingerprint=8f507ba1e1aadad5
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
For MySQL the line notes the non-atomic-DDL constraint (a failed op leaves earlier ops committed; re-run apply to finish):
|
|
788
|
+
|
|
789
|
+
```
|
|
790
|
+
[voltro:dev] auto-migrate: planning schema dialect=mysql env=dev tables=22 (implicit-commit DDL — re-run apply after a mid-plan failure)
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
For SQLite the line notes the single-process-only constraint:
|
|
794
|
+
|
|
795
|
+
```
|
|
796
|
+
[voltro:dev] auto-migrate: planning schema dialect=sqlite env=dev tables=22 (single-process — no concurrent appliers possible)
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
## Replication caveats during apply
|
|
800
|
+
|
|
801
|
+
If read-replicas are configured (`DB_REPLICA_URLS` set), the applier ALWAYS targets the primary. Replicas catch up via their normal replication stream. There's a window after apply where the replica fingerprint differs from primary — visible in `voltro db drift` if it's run against the replica URL during that window.
|
|
802
|
+
|
|
803
|
+
For multi-region deploys, time the apply against primary's region + accept the inter-region replication lag as the propagation time. Drift is measured against the **primary** fingerprint — the canonical schema authority that the applier always targets; replicas converge to it through their replication stream, so a transient post-apply mismatch on a replica is replication lag, not drift. To check a specific replica during that window, run `voltro db drift` against its URL.
|
|
804
|
+
|
|
805
|
+
## When the dialect rejects something the planner emitted
|
|
806
|
+
|
|
807
|
+
This shouldn't happen — the framework's per-dialect DDL emitter is the test surface for every code path. If you see a dialect-side error during an apply that looks like the framework emitted invalid SQL:
|
|
808
|
+
|
|
809
|
+
1. Capture the failing SQL from `voltro logs --trace <plan-id>`.
|
|
810
|
+
2. File an issue with: the schema diff, the dialect + version, the exact error message.
|
|
811
|
+
3. Workaround: drop down to a [file-based migration](./file-based.md) with hand-written DDL for the affected op.
|
|
812
|
+
|
|
813
|
+
The framework can't auto-fix every dialect's pathological cases (MariaDB's RETURNING gap on UPDATE, MySQL's strict-mode rejection of TEXT defaults, MSSQL's optimizer quirks with FILTER + RAISERROR). Where the test suite has caught those, the emitter has the right branch. Where it hasn't, file the report — the matrix grows from real failures.
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
---
|
|
818
|
+
|
|
819
|
+
<!-- source: en/database/migrations/online.md -->
|
|
820
|
+
## Online migrations
|
|
821
|
+
|
|
822
|
+
_Auto-promoted CONCURRENTLY indexes, batched backfills with progress reporting, and shadow-column rewrites for tables that can't be locked during apply. Threshold tuning + per-dialect mechanics._
|
|
823
|
+
|
|
824
|
+
The applier auto-rewrites ops that would block writes on large tables into their online variants. Cross the threshold and the planner promotes `safe` → `online-required`, executes a non-blocking variant, and reports progress through the CLI + boot log.
|
|
825
|
+
|
|
826
|
+
## Threshold
|
|
827
|
+
|
|
828
|
+
The threshold defaults to 50,000 rows per table. Override it with the `--online-after <n>` flag on `voltro db plan` / `voltro db apply`, or the `VOLTRO_ONLINE_THRESHOLD` env var (the flag wins). At or above `n` rows an index/column change is planned as an ONLINE (non-blocking) operation:
|
|
829
|
+
|
|
830
|
+
```bash
|
|
831
|
+
voltro db plan --online-after 10000 # treat tables ≥ 10k rows as online
|
|
832
|
+
VOLTRO_ONLINE_THRESHOLD=0 voltro db apply # every change online (most conservative)
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
The threshold check runs at plan time. The applier counts rows with a fast `EXPLAIN`-derived estimate (Postgres `reltuples`, MSSQL `sys.dm_db_partition_stats.row_count`, MySQL `information_schema.tables.table_rows`); exact counts only happen during the actual op if needed.
|
|
836
|
+
|
|
837
|
+
## ADD INDEX → online via CONCURRENTLY
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
export const posts = table('posts', {
|
|
841
|
+
...,
|
|
842
|
+
authorId: reference(() => users),
|
|
843
|
+
}).index('byAuthor', ['authorId'])
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
On a 1M-row `posts` table, the plan output marks the index add as online:
|
|
847
|
+
|
|
848
|
+
```
|
|
849
|
+
◷ CREATE INDEX byAuthor ON posts(authorId) # online — postgres CONCURRENTLY
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
Per-dialect mechanism:
|
|
853
|
+
|
|
854
|
+
- **Postgres**: `CREATE INDEX CONCURRENTLY` — no table lock, can read + write during build. ~3× slower than blocking + can't run inside a transaction. The applier moves it OUT of the main migration tx and runs it as its own step.
|
|
855
|
+
- **MySQL 8 / MariaDB 10.6+**: `ALTER TABLE ... ADD INDEX ... ALGORITHM=INPLACE LOCK=NONE`. Slightly weaker guarantee than postgres (briefly takes a metadata lock at start + end) but unblocks DML throughout.
|
|
856
|
+
- **MSSQL 2019+**: `CREATE INDEX ... WITH (ONLINE = ON)`. Enterprise Edition required for ONLINE=ON; Standard Edition falls back to blocking + the applier emits a loud warning.
|
|
857
|
+
- **SQLite**: no-op. SQLite locks anyway + the framework targets single-process deploys.
|
|
858
|
+
|
|
859
|
+
If the CONCURRENTLY build fails partway through (out of disk, killed by an admin, network blip), Postgres leaves the index in an `INVALID` state. The next plan run detects it + reissues the build:
|
|
860
|
+
|
|
861
|
+
```
|
|
862
|
+
◷ DROP INDEX byAuthor (invalid from prior partial build) + recreate
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
## Batched backfill with progress
|
|
866
|
+
|
|
867
|
+
A backfill on > threshold rows gets rewritten to batched:
|
|
868
|
+
|
|
869
|
+
```
|
|
870
|
+
⊕ ALTER TABLE posts ADD COLUMN slug text
|
|
871
|
+
→ backfill (batched, 1000 rows/batch, 50ms sleep): est. 50,000 rows, ~12s
|
|
872
|
+
⊕ ALTER TABLE posts ALTER COLUMN slug SET NOT NULL
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Per-batch progress shows up in `voltro logs --tail 50` while the apply runs:
|
|
876
|
+
|
|
877
|
+
```
|
|
878
|
+
backfill posts.slug: 12,000 / 50,000 (24.0%) — ETA 8s
|
|
879
|
+
backfill posts.slug: 24,000 / 50,000 (48.0%) — ETA 5s
|
|
880
|
+
backfill posts.slug: 36,000 / 50,000 (72.0%) — ETA 3s
|
|
881
|
+
backfill posts.slug: completed — 50,000 rows in 11.8s
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
Inside the framework the batched form is:
|
|
885
|
+
|
|
886
|
+
```sql
|
|
887
|
+
UPDATE posts
|
|
888
|
+
SET slug = LOWER(REPLACE(title, ' ', '-'))
|
|
889
|
+
WHERE id > $cursor AND slug IS NULL
|
|
890
|
+
ORDER BY id
|
|
891
|
+
LIMIT 1000
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
`$cursor` is the last batch's max id. The query plan is index-only on the primary key, so it stays fast as the table grows. `WHERE slug IS NULL` lets the same batch resume after a crash without double-updating completed rows.
|
|
895
|
+
|
|
896
|
+
Override the batching parameters per-column when you know more than the planner does:
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
slug: text().backfill(sql`lower(replace(title, ' ', '-'))`, {
|
|
900
|
+
batchSize: 5000, // big batches for cheap CPU-only updates
|
|
901
|
+
sleepMs: 0, // no breathing room needed
|
|
902
|
+
}),
|
|
903
|
+
|
|
904
|
+
embedding: text().backfill(async (row) => embed(row.title), {
|
|
905
|
+
batchSize: 100, // small batches — each row is an HTTP call
|
|
906
|
+
sleepMs: 200, // throttle external API quota
|
|
907
|
+
}),
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
## Shadow-column rewrite
|
|
911
|
+
|
|
912
|
+
For type changes on a large table that can't be done via `ALTER COLUMN TYPE` (Postgres requires a rewrite + table lock; MySQL likewise for non-trivial converts), the applier auto-emits a shadow-column pattern:
|
|
913
|
+
|
|
914
|
+
1. ADD COLUMN `<col>_new <newType>` nullable
|
|
915
|
+
2. Backfill batched: `UPDATE ... SET <col>_new = (<col>::<newType>)` in chunks
|
|
916
|
+
3. SET NOT NULL on `<col>_new` (if applicable)
|
|
917
|
+
4. Atomic swap inside a brief metadata lock: rename `<col>` → `<col>_old`, rename `<col>_new` → `<col>`, drop `<col>_old`
|
|
918
|
+
|
|
919
|
+
The atomic swap is the only blocking step + holds the lock for ~1 ms. Apps that pin connections might see a brief query error during the swap; pooled connections re-issue + succeed on the second attempt.
|
|
920
|
+
|
|
921
|
+
The plan output flags this clearly:
|
|
922
|
+
|
|
923
|
+
```
|
|
924
|
+
◷ ALTER TABLE users ALTER COLUMN created_at TYPE timestamptz # shadow-column swap (4 steps: add → backfill → swap → drop)
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
Schema dependencies: indexes + FKs referencing the column are recreated against the new column in the same swap step.
|
|
928
|
+
|
|
929
|
+
## Cross-dialect quirks
|
|
930
|
+
|
|
931
|
+
| Mechanism | Postgres | MySQL 8+ | MariaDB 10.6+ | MSSQL 2019+ Ent | SQLite |
|
|
932
|
+
|---|---|---|---|---|---|
|
|
933
|
+
| Online index build | `CONCURRENTLY` | `INPLACE LOCK=NONE` | `INPLACE LOCK=NONE` | `WITH (ONLINE = ON)` | no-op |
|
|
934
|
+
| Resumable failed index | INVALID state + recreate | partial drop + retry | partial drop + retry | requires DBA intervention | no-op |
|
|
935
|
+
| Batched backfill | identical | identical | identical | identical | identical (single-process) |
|
|
936
|
+
| Shadow-column type change | yes | yes | yes | yes | no (table rewrite from [multi-dialect](./multi-dialect.md)) |
|
|
937
|
+
|
|
938
|
+
After a large backfill you'll usually want fresh planner statistics on the affected table. The applier does NOT emit `ANALYZE` / `UPDATE STATISTICS` for you — run it yourself once the apply completes (e.g. `ANALYZE posts;` on postgres) if the post-backfill query plans look stale.
|
|
939
|
+
|
|
940
|
+
## What CONCURRENTLY doesn't help with
|
|
941
|
+
|
|
942
|
+
Online migrations are about **writes during apply**. They don't help with:
|
|
943
|
+
|
|
944
|
+
- Reads during apply — never blocked by either path
|
|
945
|
+
- Long-running transactions that hold a lock the apply needs — the apply waits regardless
|
|
946
|
+
- Replication lag — the new index propagates to replicas at their own pace
|
|
947
|
+
|
|
948
|
+
For a multi-hour migration on a 100M-row table, the right pattern often isn't "make it online" but "split into many small applies + apply during low-traffic windows". The framework's planner-based system lets you express that as multiple deploys, each with a small focused plan, instead of one monolithic migration. The [prod pipeline](./prod-pipeline.md) page covers the deploy-cadence side.
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
---
|
|
953
|
+
|
|
954
|
+
<!-- source: en/database/migrations/file-based.md -->
|
|
955
|
+
## File-based migrations
|
|
956
|
+
|
|
957
|
+
_The escape hatch for migrations the declarative diff can't infer — table splits, table merges, atomic cross-table data moves. Up/down convention, ordering, and how the planner integrates them._
|
|
958
|
+
|
|
959
|
+
The planner handles structural diffs — column additions, drops, renames, type changes — where the OLD and NEW shapes can be computed from declared schema vs live introspection. It refuses to plan migrations that need DATA SEMANTICS the diff can't see:
|
|
960
|
+
|
|
961
|
+
- Splitting one table into two (extract address fields to a new `addresses` table with FK back)
|
|
962
|
+
- Merging two tables (move both `personal_emails` + `work_emails` into one `emails` table tagged by type)
|
|
963
|
+
- Atomic data moves across tables (move all `orders` of status `archived` into `archived_orders` with a different schema)
|
|
964
|
+
- Type changes that need a custom transformation (parse a JSON column into structured columns)
|
|
965
|
+
|
|
966
|
+
For these, write an explicit migration file.
|
|
967
|
+
|
|
968
|
+
## File convention
|
|
969
|
+
|
|
970
|
+
```text
|
|
971
|
+
apps/api/migrations/
|
|
972
|
+
├── 20260415_120000_split_address_out.ts
|
|
973
|
+
├── 20260520_093000_merge_emails.ts
|
|
974
|
+
└── 20260603_140000_normalize_orders.ts
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
Filename = `<UTC-timestamp>_<slug>.ts`. The timestamp orders applies — sortable + globally unique without coordination. Slugs are for humans + filed alongside the timestamped name in `_voltro_migration_plans.id` for findability.
|
|
978
|
+
|
|
979
|
+
The file exports a default `migration()`:
|
|
980
|
+
|
|
981
|
+
```ts
|
|
982
|
+
// apps/api/migrations/20260415_120000_split_address_out.ts
|
|
983
|
+
import { migration } from '@voltro/database'
|
|
984
|
+
|
|
985
|
+
export default migration({
|
|
986
|
+
id: '20260415_120000_split_address_out',
|
|
987
|
+
description: 'Move users.address* fields into a separate addresses table with FK back.',
|
|
988
|
+
|
|
989
|
+
up: async ({ sql, log }) => {
|
|
990
|
+
// 1. CREATE the new table:
|
|
991
|
+
await sql.unsafe(`
|
|
992
|
+
CREATE TABLE addresses (
|
|
993
|
+
id text PRIMARY KEY,
|
|
994
|
+
userId text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
995
|
+
street text NOT NULL,
|
|
996
|
+
city text NOT NULL,
|
|
997
|
+
postalCode text NOT NULL,
|
|
998
|
+
createdAt timestamptz NOT NULL DEFAULT now()
|
|
999
|
+
)
|
|
1000
|
+
`)
|
|
1001
|
+
await sql.unsafe(`CREATE INDEX addresses_user_idx ON addresses(userId)`)
|
|
1002
|
+
|
|
1003
|
+
// 2. Move the data set-wise with one INSERT…SELECT — the file
|
|
1004
|
+
// context exposes the @effect/sql SqlClient, not the DataStore:
|
|
1005
|
+
await sql.unsafe(`
|
|
1006
|
+
INSERT INTO addresses (id, "userId", street, city, "postalCode")
|
|
1007
|
+
SELECT
|
|
1008
|
+
gen_random_uuid()::text,
|
|
1009
|
+
id,
|
|
1010
|
+
"addressStreet",
|
|
1011
|
+
"addressCity",
|
|
1012
|
+
COALESCE("addressPostalCode", '')
|
|
1013
|
+
FROM users
|
|
1014
|
+
WHERE "addressStreet" IS NOT NULL AND "addressCity" IS NOT NULL
|
|
1015
|
+
`)
|
|
1016
|
+
log.info('migrated address fields → addresses')
|
|
1017
|
+
|
|
1018
|
+
// 3. DROP the old columns:
|
|
1019
|
+
await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressStreet"`)
|
|
1020
|
+
await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressCity"`)
|
|
1021
|
+
await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressPostalCode"`)
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
down: async ({ sql }) => {
|
|
1025
|
+
// Reverse — required. The runner won't accept a migration without one.
|
|
1026
|
+
await sql.unsafe(`ALTER TABLE users ADD COLUMN addressStreet text`)
|
|
1027
|
+
await sql.unsafe(`ALTER TABLE users ADD COLUMN addressCity text`)
|
|
1028
|
+
await sql.unsafe(`ALTER TABLE users ADD COLUMN addressPostalCode text`)
|
|
1029
|
+
await sql.unsafe(`
|
|
1030
|
+
UPDATE users SET
|
|
1031
|
+
addressStreet = a.street,
|
|
1032
|
+
addressCity = a.city,
|
|
1033
|
+
addressPostalCode = a.postalCode
|
|
1034
|
+
FROM addresses a
|
|
1035
|
+
WHERE users.id = a.userId
|
|
1036
|
+
`)
|
|
1037
|
+
await sql.unsafe(`DROP TABLE addresses`)
|
|
1038
|
+
},
|
|
1039
|
+
})
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
The `up` body runs inside a transaction (on dialects that support it; see [multi-dialect](./multi-dialect.md) for MySQL forward-roll semantics). `down` runs the same way for rollback.
|
|
1043
|
+
|
|
1044
|
+
## What the runner provides
|
|
1045
|
+
|
|
1046
|
+
```ts
|
|
1047
|
+
up: async (ctx) => {
|
|
1048
|
+
ctx.sql // @effect/sql SqlClient with sql.unsafe / sql tagged-template / sql.onDialectOrElse
|
|
1049
|
+
ctx.log // structured logger scoped to this migration (info / warn)
|
|
1050
|
+
ctx.appliedAt // ISO-8601 string — when this migration started (deterministic across the up/down pair)
|
|
1051
|
+
}
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
The file context is `{ sql, log, appliedAt }` — there is NO `store`
|
|
1055
|
+
handle here. The escape hatch is deliberately SQL-level: you're doing
|
|
1056
|
+
the structural moves the typed DataStore can't express, so you drive
|
|
1057
|
+
them with `sql.unsafe(...)` for raw DDL and `sql\`...\`` /
|
|
1058
|
+
`sql.onDialectOrElse(...)` for parameterised statements. Move data
|
|
1059
|
+
set-wise with `INSERT … SELECT` / `UPDATE … FROM` rather than a
|
|
1060
|
+
per-row JS loop — it's one round-trip and stays inside the migration's
|
|
1061
|
+
transaction on the dialects that support transactional DDL.
|
|
1062
|
+
|
|
1063
|
+
## Ordering vs the planner
|
|
1064
|
+
|
|
1065
|
+
File-based migrations are applied IN TIMESTAMP ORDER, BEFORE the planner-based diff runs. So a typical boot looks like:
|
|
1066
|
+
|
|
1067
|
+
```
|
|
1068
|
+
[voltro:dev] migrations: file-based pending → 1
|
|
1069
|
+
[voltro:dev] migration 20260415_120000_split_address_out applying
|
|
1070
|
+
[voltro:dev] migrated 1247 users → addresses
|
|
1071
|
+
[voltro:dev] migration 20260415_120000_split_address_out applied in 482ms
|
|
1072
|
+
[voltro:dev] auto-migrate: planning schema dialect=postgres env=dev tables=23
|
|
1073
|
+
[voltro:dev] auto-migrate: schema up to date fingerprint=8f507ba1e1aadad5
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
The file ran first, dropped the columns, created the new table. The planner then diffs the (now mutated) live shape against the declared schema — and finds it up to date, because the declared schema also has `addresses` as a separate table + `users` without the address columns.
|
|
1077
|
+
|
|
1078
|
+
This ordering is critical: file-based migrations MUTATE state the planner sees. Sequence:
|
|
1079
|
+
|
|
1080
|
+
1. Edit `users.entity.ts` to remove the `address*` columns + add the new `addresses` entity file
|
|
1081
|
+
2. Write the file-based migration that physically moves the data + drops the columns
|
|
1082
|
+
3. Boot — file-based runs first (writes the new state), planner runs second (sees a clean diff against the new declared schema, no-op)
|
|
1083
|
+
|
|
1084
|
+
If you skip step 2 + just edit the schema, the planner refuses to plan: dropping `addressStreet` is `lossy` (no `dropped()` marker), creating `addresses` is `safe`. The plan would refuse + the boot would fail until you add `dropped()` markers... but then you'd lose the data. The file-based migration moves the data BEFORE the planner sees the columns are gone.
|
|
1085
|
+
|
|
1086
|
+
## Tracking
|
|
1087
|
+
|
|
1088
|
+
File-based migrations land in the same `_voltro_migration_plans` table as planner-based ones, with `source: 'file'`:
|
|
1089
|
+
|
|
1090
|
+
```
|
|
1091
|
+
plan_mig_5k78 fp=... env=dev src=file ops=1 3.2s 2026-04-15 12:00:00 by=boot:dev
|
|
1092
|
+
plan_mig_5k79 fp=... env=dev src=auto-diff ops=0 12ms 2026-04-15 12:00:03 by=boot:dev
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The `voltro db plans` command shows both side by side in the same timeline. Drift detection compares against the latest fingerprint regardless of source.
|
|
1096
|
+
|
|
1097
|
+
## When NOT to use file-based migrations
|
|
1098
|
+
|
|
1099
|
+
The escape hatch is for situations the diff genuinely can't infer. Don't reach for it for:
|
|
1100
|
+
|
|
1101
|
+
- ADD NOT NULL column — that's `.backfill()` on the column declaration
|
|
1102
|
+
- Rename column — that's `.renamedFrom()` on the new column
|
|
1103
|
+
- Drop column — that's `dropped()`
|
|
1104
|
+
- Drop table — remove from declared set + `VOLTRO_DESTRUCTIVE_OK=1` for the apply
|
|
1105
|
+
|
|
1106
|
+
A file-based migration for any of these defeats the planner's safety story. The DSL annotations carry their fix-hint into the developer's editor; the file is just "trust me, this works".
|
|
1107
|
+
|
|
1108
|
+
## Idempotency
|
|
1109
|
+
|
|
1110
|
+
File-based migrations are NOT auto-idempotent. The runner checks `_voltro_migration_plans` for a row with the same `id` and skips if found. The migration body itself must NOT assume it ran from a clean slate IF you're going to edit it after applying (the framework refuses to re-apply a modified file silently — see Drift docs).
|
|
1111
|
+
|
|
1112
|
+
Practical rule: once a file-based migration is applied in any env, it's frozen. Subsequent corrections are NEW migrations with NEW timestamps that read the half-applied state + finish the job.
|
|
1113
|
+
|
|
1114
|
+
## Rollback
|
|
1115
|
+
|
|
1116
|
+
`voltro db rollback-file <id>` invokes a file-based migration's `down`
|
|
1117
|
+
body. The `<id>` is the positional migration id (the basename minus
|
|
1118
|
+
`.ts`):
|
|
1119
|
+
|
|
1120
|
+
```sh
|
|
1121
|
+
voltro db rollback-file 20260415_120000_split_address_out
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
This is the planner-side file runner (`migration()` → `down`). It is a
|
|
1125
|
+
DIFFERENT subcommand from `voltro db rollback`, which drives the
|
|
1126
|
+
separate `defineMigration` step-based runner and takes `--to`, not a
|
|
1127
|
+
positional id — see [the overview](./index.md) for the two runners.
|
|
1128
|
+
`rollback-file` refuses on `NODE_ENV=production` (rollback runs as an
|
|
1129
|
+
explicit deploy step there).
|
|
1130
|
+
|
|
1131
|
+
If `down` throws, the rollback is treated as failed — the schema stays
|
|
1132
|
+
in the half-rolled-back state + the operator handles it manually. The
|
|
1133
|
+
framework can't auto-recover from a broken inverse.
|
|
1134
|
+
|
|
1135
|
+
## Applying file-based migrations from the CLI
|
|
1136
|
+
|
|
1137
|
+
`voltro db files` applies pending file-based migrations under
|
|
1138
|
+
`<root>/migrations/` — the same runner the boot path invokes, exposed
|
|
1139
|
+
as a CLI command for when you've set `VOLTRO_AUTO_MIGRATE=0` and apply
|
|
1140
|
+
schema as an explicit step:
|
|
1141
|
+
|
|
1142
|
+
```sh
|
|
1143
|
+
voltro db files
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
It's a distinct command from `voltro db apply` (which runs the
|
|
1147
|
+
planner-based auto-diff). The two histories both land in
|
|
1148
|
+
`_voltro_migration_plans` — file-based rows carry `source: 'file'`,
|
|
1149
|
+
planner rows `source: 'auto-diff'` — so `voltro db plans` shows them
|
|
1150
|
+
inline. On a normal boot, file-based migrations run FIRST (before the
|
|
1151
|
+
planner diff), so by the time `voltro db apply` would run, the file
|
|
1152
|
+
has already mutated the live shape.
|
|
1153
|
+
|
|
1154
|
+
The [prod pipeline](./prod-pipeline.md) page covers the deploy-step
|
|
1155
|
+
apply flow for planner-based changes.
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
|
|
1161
|
+
<!-- source: en/database/migrations/prod-pipeline.md -->
|
|
1162
|
+
## Prod pipeline
|
|
1163
|
+
|
|
1164
|
+
_How schema changes flow from a PR through review to a production database. Why `voltro db apply` runs as an explicit deploy step, never on boot. Per-env fingerprint check + the refuse-to-boot behaviour._
|
|
1165
|
+
|
|
1166
|
+
The framework's hard rule: **`voltro start` (the production runtime) NEVER auto-applies migrations**. The dev-mode behaviour where `voltro dev` boots refuse-to-start on a blocked plan + auto-apply otherwise is intentionally not extended to prod. Schema changes mid-rolling-deploy without review are the largest data-risk class the framework could create; we don't.
|
|
1167
|
+
|
|
1168
|
+
What prod boot DOES is compare the declared-schema fingerprint against the latest `_voltro_migration_plans.fingerprint`. Match → serve traffic. Mismatch → refuse to boot with a structured error.
|
|
1169
|
+
|
|
1170
|
+
## The end-to-end flow
|
|
1171
|
+
|
|
1172
|
+
```
|
|
1173
|
+
┌─────────────────────────────────┐
|
|
1174
|
+
│ 1. Developer edits schema in PR │
|
|
1175
|
+
└────────────────┬────────────────┘
|
|
1176
|
+
│
|
|
1177
|
+
▼
|
|
1178
|
+
┌─────────────────────────────────┐
|
|
1179
|
+
│ 2. CI runs `voltro db plan` │
|
|
1180
|
+
│ against a staging-style │
|
|
1181
|
+
│ snapshot. The printed plan │
|
|
1182
|
+
│ (classes + fingerprints) goes │
|
|
1183
|
+
│ into the PR for review │
|
|
1184
|
+
└────────────────┬────────────────┘
|
|
1185
|
+
│
|
|
1186
|
+
▼
|
|
1187
|
+
┌─────────────────────────────────┐
|
|
1188
|
+
│ 3. Reviewer reads the plan │
|
|
1189
|
+
│ classification, fingerprints │
|
|
1190
|
+
│ + DSL annotations │
|
|
1191
|
+
│ Approves PR │
|
|
1192
|
+
└────────────────┬────────────────┘
|
|
1193
|
+
│
|
|
1194
|
+
▼
|
|
1195
|
+
┌─────────────────────────────────┐
|
|
1196
|
+
│ 4. Merge to main │
|
|
1197
|
+
│ CI/CD deploys new image │
|
|
1198
|
+
│ BEFORE traffic switch a │
|
|
1199
|
+
│ one-shot job runs │
|
|
1200
|
+
│ `voltro db apply` against │
|
|
1201
|
+
│ the prod DB (re-diffs live) │
|
|
1202
|
+
└────────────────┬────────────────┘
|
|
1203
|
+
│
|
|
1204
|
+
▼
|
|
1205
|
+
┌─────────────────────────────────┐
|
|
1206
|
+
│ 5. Traffic switches │
|
|
1207
|
+
│ Prod boot: fingerprint match │
|
|
1208
|
+
│ → serves │
|
|
1209
|
+
└─────────────────────────────────┘
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
There's a slot for the apply step in every common deploy tool (k8s init container, ECS task pre-deploy hook, Heroku release phase, Cloud Run job-on-deploy, Fly.io `release_command`). The shape is identical: run a one-shot container/process that holds the migration credentials + executes `voltro db apply`. It re-diffs the live DB against the deployed code's declared schema and applies the resulting plan — there is no pre-serialised plan file to pass; the apply re-computes the diff at run time. The serving process never gets the migration-grade credentials.
|
|
1213
|
+
|
|
1214
|
+
## Per-env fingerprint check
|
|
1215
|
+
|
|
1216
|
+
`voltro start` (prod runtime) does this on every boot:
|
|
1217
|
+
|
|
1218
|
+
```ts
|
|
1219
|
+
const declaredFp = fingerprintSchema(declaredSnapshot(tables))
|
|
1220
|
+
const lastApplied = await sql`
|
|
1221
|
+
SELECT fingerprint FROM _voltro_migration_plans
|
|
1222
|
+
ORDER BY appliedAt DESC LIMIT 1
|
|
1223
|
+
`
|
|
1224
|
+
if (lastApplied?.fingerprint !== declaredFp) {
|
|
1225
|
+
// PROD-MISMATCH outcome → refuse-to-boot.
|
|
1226
|
+
process.exit(1)
|
|
1227
|
+
}
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
The structured error on mismatch:
|
|
1231
|
+
|
|
1232
|
+
```
|
|
1233
|
+
[voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
|
|
1234
|
+
declared = a8f2c9d10b3f4e62
|
|
1235
|
+
live = 8f507ba1e1aadad5
|
|
1236
|
+
Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
|
|
1237
|
+
exit 1
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
This propagates as a non-zero exit code, k8s + ECS + Cloud Run mark the pod/task `CrashLoopBackOff` / failed deployment → automatic rollback to the previous image. Operators see the loud crash + know to run the apply step.
|
|
1241
|
+
|
|
1242
|
+
`VOLTRO_AUTO_MIGRATE=0` skips this check entirely — useful when migrations are handled by a separate ops process + the boot doesn't need to verify. Tradeoff: a drift goes undetected until the next manual `voltro db drift` run.
|
|
1243
|
+
|
|
1244
|
+
## Previewing the plan in CI
|
|
1245
|
+
|
|
1246
|
+
Run `voltro db plan` against a snapshot of the prod schema (NOT prod
|
|
1247
|
+
itself — never expose prod credentials to CI). Add `--json` to emit the
|
|
1248
|
+
plan as a machine-readable artifact you can save and commit:
|
|
1249
|
+
|
|
1250
|
+
```sh
|
|
1251
|
+
DB_URL=<staging-snapshot-url> voltro db plan --json > plan.json
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
The reviewer reads either the printed plan or the JSON. The saved
|
|
1255
|
+
`plan.json` then becomes the input to `voltro db apply --plan plan.json`
|
|
1256
|
+
(see below), applying the EXACT reviewed diff. A bare `voltro db apply`
|
|
1257
|
+
(no `--plan`) re-diffs live at deploy time instead — both flows are
|
|
1258
|
+
supported; the plan-file flow is the one the prod refuse-to-boot message
|
|
1259
|
+
points you to.
|
|
1260
|
+
|
|
1261
|
+
```sh
|
|
1262
|
+
# CI step — runs in staging or a snapshot-replica env, against a real
|
|
1263
|
+
# connection:
|
|
1264
|
+
DB_URL=<staging-snapshot-url> voltro db plan
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
The printed plan goes into the PR for the reviewer:
|
|
1268
|
+
|
|
1269
|
+
```
|
|
1270
|
+
schema diff: 4 operations, 0 blocked
|
|
1271
|
+
|
|
1272
|
+
✓ ALTER TABLE users ADD COLUMN bio text # safe
|
|
1273
|
+
⊕ ALTER TABLE users ADD COLUMN email text # backfill: sql`...`
|
|
1274
|
+
⊕ UPDATE users SET email = 'unknown-' || id || '@local'
|
|
1275
|
+
⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL
|
|
1276
|
+
✓ CREATE INDEX users_email_idx ON users(email) # safe
|
|
1277
|
+
|
|
1278
|
+
safe: 2 needs-default: 0 needs-backfill: 1
|
|
1279
|
+
rename: 0 lossy: 0 blocked: 0
|
|
1280
|
+
fingerprint: 8f507ba1e1aadad5 → a8f2c9d10b3f4e62
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
A reviewer reads the SQL the applier will emit + checks the
|
|
1284
|
+
fingerprints. Blocked ops (red `✗`) MUST be resolved in the PR before
|
|
1285
|
+
merge — `voltro db apply` refuses any plan with a blocked op (exit 2).
|
|
1286
|
+
|
|
1287
|
+
## Applying against prod
|
|
1288
|
+
|
|
1289
|
+
The deploy job runs `voltro db apply` (re-diffing the deployed code's
|
|
1290
|
+
declared schema against the live prod DB):
|
|
1291
|
+
|
|
1292
|
+
```sh
|
|
1293
|
+
voltro db apply --note 'PR #1234 — add user emails'
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
The applier:
|
|
1297
|
+
|
|
1298
|
+
1. Introspects the live DB and plans the diff fresh (it does NOT ingest
|
|
1299
|
+
a plan file — the diff is computed against live at apply time)
|
|
1300
|
+
2. Refuses (exit 2) if any op is blocked, or refuses (exit 3) if
|
|
1301
|
+
`NODE_ENV=production` — so the apply runs in a one-shot job with
|
|
1302
|
+
`NODE_ENV` unset / `staging`, holding migration credentials, NOT in
|
|
1303
|
+
the serving process
|
|
1304
|
+
3. Acquires the advisory lock + executes the plan
|
|
1305
|
+
4. Records the result in `_voltro_migration_plans` with
|
|
1306
|
+
`source: 'auto-diff'` + `notes: 'PR #1234 — add user emails'`
|
|
1307
|
+
|
|
1308
|
+
### Applying a reviewed plan (`--plan`, works under `NODE_ENV=production`)
|
|
1309
|
+
|
|
1310
|
+
`voltro db apply --plan plan.json` applies a plan saved by `voltro db
|
|
1311
|
+
plan --json`. Unlike a bare apply it is **allowed when
|
|
1312
|
+
`NODE_ENV=production`** — because it re-introspects live and refuses
|
|
1313
|
+
unless BOTH fingerprints still match the saved plan:
|
|
1314
|
+
|
|
1315
|
+
- `fromFingerprint` — the live schema the plan was generated against.
|
|
1316
|
+
Live drifted since? → aborts (exit 2, "live schema has drifted").
|
|
1317
|
+
- `toFingerprint` — the declared schema the plan targets. Schema files
|
|
1318
|
+
changed since? → aborts (exit 2).
|
|
1319
|
+
|
|
1320
|
+
So `--plan` can only ever apply the exact diff that was reviewed — never
|
|
1321
|
+
a stale or drifted one. That safety is what lets it run directly on the
|
|
1322
|
+
prod runtime, with no `NODE_ENV`-unset dance. It still refuses any plan
|
|
1323
|
+
with a blocked op (exit 2) and records the result with `source: 'file'`.
|
|
1324
|
+
|
|
1325
|
+
```sh
|
|
1326
|
+
voltro db apply --plan plan.json --note 'PR #1234 — add user emails'
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
Because the diff is recomputed against live, an apply on an already-
|
|
1330
|
+
up-to-date DB is a clean no-op (`schema is up to date — nothing to
|
|
1331
|
+
apply`). That's what makes the apply safe to run in every pod of a
|
|
1332
|
+
stateless deploy.
|
|
1333
|
+
|
|
1334
|
+
## Apply timing relative to deploy
|
|
1335
|
+
|
|
1336
|
+
Two orderings, both common:
|
|
1337
|
+
|
|
1338
|
+
**Apply before image swap** (recommended): the new image is deployed but not serving yet. The apply runs against the live DB. Then traffic switches.
|
|
1339
|
+
|
|
1340
|
+
- New schema is in place when the new code starts serving → no version mismatches
|
|
1341
|
+
- Old code is still serving until the swap → it must tolerate the new schema for a brief window
|
|
1342
|
+
- Constraints: every migration must be backward-compatible with the OLD code for the swap window. ADD columns (the OLD code ignores them) ✓. DROP columns (the OLD code might still write to them) ✗ → requires a 2-deploy dance (deploy 1: stop writing to col, deploy 2: drop col).
|
|
1343
|
+
|
|
1344
|
+
**Apply after image swap**: traffic is on the new code, the apply runs after. The new code must tolerate the OLD schema until the apply finishes.
|
|
1345
|
+
|
|
1346
|
+
- New code is in place during apply → mid-apply rollback is easier (just rollback the apply, the new code can still talk to the old shape if you designed for it)
|
|
1347
|
+
- Migration is the LAST step → if it fails, the new code is already serving + needs the new schema. Outage.
|
|
1348
|
+
|
|
1349
|
+
For most teams the first is safer (the framework's `_voltro_migration_plans.environment` tracking expects this pattern). For specific workloads where a partial migration would be catastrophic (massive backfills, multi-hour rewrites), the apply runs first as a one-shot job, the deploy follows when it's done.
|
|
1350
|
+
|
|
1351
|
+
## Multi-instance prod
|
|
1352
|
+
|
|
1353
|
+
The advisory lock around `voltro db apply` serialises concurrent applies. Two instances of the apply job racing the same plan → one acquires the lock, the other blocks until the first finishes + observes the post-apply fingerprint matches (it's a no-op now), exits 0.
|
|
1354
|
+
|
|
1355
|
+
This is the same mechanism that lets you run the apply in EVERY pod of a stateless deploy (the second-through-Nth no-op out fast) — useful when the deploy pipeline can't single out a designated migration runner.
|
|
1356
|
+
|
|
1357
|
+
## Rollback paths
|
|
1358
|
+
|
|
1359
|
+
If the apply itself fails partway:
|
|
1360
|
+
|
|
1361
|
+
- **Postgres / MSSQL / SQLite**: the transaction rolled back → live DB unchanged → fix the migration + re-run `voltro db apply` (it re-diffs from the unchanged state)
|
|
1362
|
+
- **MySQL / MariaDB**: DDL is implicit-commit, so completed ops stayed. Re-run `voltro db apply` — it re-diffs against the half-applied live shape and emits only the remaining ops. See [multi-dialect](./multi-dialect.md).
|
|
1363
|
+
|
|
1364
|
+
If the apply succeeded but the new code is broken + needs to be rolled back:
|
|
1365
|
+
|
|
1366
|
+
- The new-code rollback ≠ the schema rollback. The image deploys can revert via your normal CI/CD path; the schema stays at the new fingerprint.
|
|
1367
|
+
- There is no `voltro db rollback <plan-id>` for a planner plan. To back out a `safe` change, ship a schema PR that re-declares the old shape and apply it as a new forward plan; for `lossy` changes the old data is gone — restore from backup. See [Rollback](./rollback.md).
|
|
1368
|
+
|
|
1369
|
+
This is why the migration story is conservative + the prod refuse-to-boot is strict: once data is gone, no automated reversal brings it back.
|
|
1370
|
+
|
|
1371
|
+
## What about staging?
|
|
1372
|
+
|
|
1373
|
+
The apply records `environment: 'staging'` when `NODE_ENV=staging`,
|
|
1374
|
+
else `dev` — each env's rows are tracked in the same
|
|
1375
|
+
`_voltro_migration_plans` table, tagged by environment. The CI flow
|
|
1376
|
+
typically applies to staging first, runs smoke tests against the new
|
|
1377
|
+
code, then to prod. The apply is idempotent against re-runs: because it
|
|
1378
|
+
re-diffs live each time, a second run against an already-migrated DB
|
|
1379
|
+
no-ops out.
|
|
1380
|
+
|
|
1381
|
+
The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
---
|
|
1386
|
+
|
|
1387
|
+
<!-- source: en/database/migrations/cross-env-sync.md -->
|
|
1388
|
+
## Cross-environment migration sync
|
|
1389
|
+
|
|
1390
|
+
_voltro db plan --against <env-url> — diff your local declared schema against a remote env's live DB before pushing, over the framework's inspect endpoint. No DB connection from the CLI._
|
|
1391
|
+
|
|
1392
|
+
`voltro db plan --against <url>` fetches a remote environment's live
|
|
1393
|
+
schema via the framework's `/_voltro/inspect/migrations` endpoint and
|
|
1394
|
+
diffs it against your LOCAL declared schema — a "what would my branch do
|
|
1395
|
+
if I shipped it to staging right now?" pre-deploy preview. No database
|
|
1396
|
+
connection from the CLI to the remote; everything goes through HTTP.
|
|
1397
|
+
|
|
1398
|
+
## How it works
|
|
1399
|
+
|
|
1400
|
+
1. Fetch `/_voltro/inspect/migrations` on the remote URL.
|
|
1401
|
+
2. Read the introspected schema from `drift.liveSnapshot` — the same
|
|
1402
|
+
`SchemaSnapshot` shape `introspectSchema` produces locally. The drift
|
|
1403
|
+
payload carries it alongside the fingerprints (`liveFingerprint`,
|
|
1404
|
+
`lastAppliedFingerprint`).
|
|
1405
|
+
3. Run the planner against (local-declared, remote-live).
|
|
1406
|
+
4. Render the plan output marked with the remote URL so you know it's
|
|
1407
|
+
not a local diff:
|
|
1408
|
+
|
|
1409
|
+
```
|
|
1410
|
+
[--against https://staging.example.com/_voltro/inspect/migrations] — diff vs remote live schema:
|
|
1411
|
+
|
|
1412
|
+
schema diff: 3 operations, 0 blocked
|
|
1413
|
+
|
|
1414
|
+
✓ CREATE INDEX users_email_lower_idx ON users (lower("email"))
|
|
1415
|
+
✓ ALTER TABLE posts ADD COLUMN searchVec tsvector ...
|
|
1416
|
+
✓ CREATE INDEX posts_search_gin ON posts (searchVec)
|
|
1417
|
+
```
|
|
1418
|
+
|
|
1419
|
+
The exit code is `2` when the plan has blocked ops, `0` otherwise — so a
|
|
1420
|
+
CI job can gate a deploy on a clean diff against the target env.
|
|
1421
|
+
|
|
1422
|
+
## Auth
|
|
1423
|
+
|
|
1424
|
+
The inspect endpoint accepts a bearer token:
|
|
1425
|
+
|
|
1426
|
+
```bash
|
|
1427
|
+
voltro db plan --against https://prod.example.com --token $PROD_INSPECT_TOKEN
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
Without `--token`, the CLI reads the `VOLTRO_INSPECT_TOKEN` env var.
|
|
1431
|
+
Without either, the request goes unauthenticated (only works if the
|
|
1432
|
+
remote has `VOLTRO_INSPECT_TOKEN` unset, which you should **NEVER** do
|
|
1433
|
+
on prod).
|
|
1434
|
+
|
|
1435
|
+
The token is the same one the remote app boots with:
|
|
1436
|
+
|
|
1437
|
+
```bash
|
|
1438
|
+
# On the remote side:
|
|
1439
|
+
VOLTRO_INSPECT_TOKEN=<secret> voltro start
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
See [Introspection](/docs/observability/inspect) for the full auth
|
|
1443
|
+
configuration.
|
|
1444
|
+
|
|
1445
|
+
## URL shape
|
|
1446
|
+
|
|
1447
|
+
The CLI accepts either form:
|
|
1448
|
+
|
|
1449
|
+
```bash
|
|
1450
|
+
voltro db plan --against https://staging.example.com
|
|
1451
|
+
voltro db plan --against https://staging.example.com/_voltro/inspect/migrations
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
If the URL doesn't end in `/_voltro/inspect/migrations`, the CLI
|
|
1455
|
+
appends it. The trailing-slash variant works too.
|
|
1456
|
+
|
|
1457
|
+
## CLI flags
|
|
1458
|
+
|
|
1459
|
+
| Flag | Required | Description |
|
|
1460
|
+
|--------------|----------|--------------------------------------------------------------|
|
|
1461
|
+
| `--against` | yes | URL of the remote env (base URL OR full inspect URL) |
|
|
1462
|
+
| `--token` | no | Bearer token. Falls back to `VOLTRO_INSPECT_TOKEN` env |
|
|
1463
|
+
|
|
1464
|
+
## See also
|
|
1465
|
+
|
|
1466
|
+
- [Migration overview](/docs/database/migrations) — the
|
|
1467
|
+
plan/apply lifecycle this slots into
|
|
1468
|
+
- [Introspection](/docs/observability/inspect) — the
|
|
1469
|
+
`/_voltro/inspect/*` surface this command consumes
|
|
1470
|
+
- [Drift detection](/docs/database/migrations/drift) — fingerprint-based
|
|
1471
|
+
drift surfacing the same inspect endpoint emits
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
---
|
|
1476
|
+
|
|
1477
|
+
<!-- source: en/database/migrations/squashing.md -->
|
|
1478
|
+
## Migration squashing
|
|
1479
|
+
|
|
1480
|
+
_voltro db squash — Rails-style consolidation. Mark every applied-before-T migration as squashed, leave a single snapshot row that new envs boot against._
|
|
1481
|
+
|
|
1482
|
+
After 2 years of incremental migrations the history table is
|
|
1483
|
+
hundreds of entries. Fresh environments take minutes to bootstrap.
|
|
1484
|
+
`voltro db squash` consolidates everything applied before a cut-off
|
|
1485
|
+
date into one synthetic snapshot row.
|
|
1486
|
+
|
|
1487
|
+
## When to squash
|
|
1488
|
+
|
|
1489
|
+
- A new environment (laptop, staging) takes >30s to boot because of
|
|
1490
|
+
the migration replay.
|
|
1491
|
+
- The migration history has hundreds of entries and is becoming
|
|
1492
|
+
unreadable in `voltro db plans`.
|
|
1493
|
+
- You've shipped a major schema overhaul and the pre-overhaul
|
|
1494
|
+
history is no longer useful for debugging.
|
|
1495
|
+
|
|
1496
|
+
Don't squash:
|
|
1497
|
+
- If you're still iterating on the schema in dev — the history is
|
|
1498
|
+
your audit trail.
|
|
1499
|
+
- Right before a major release — wait until the release lands
|
|
1500
|
+
everywhere first.
|
|
1501
|
+
- File-based migrations — squashing only affects the declarative-
|
|
1502
|
+
diff (`auto-diff`) entries. File-based migrations keep their
|
|
1503
|
+
separate rollback path.
|
|
1504
|
+
|
|
1505
|
+
## Quick start
|
|
1506
|
+
|
|
1507
|
+
```bash
|
|
1508
|
+
voltro db squash --before 2026-06-01 --note 'consolidate v1 migrations'
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
```
|
|
1512
|
+
✓ squashed 14 migration plan(s)
|
|
1513
|
+
snapshot id: plan_squash_l4f2m1
|
|
1514
|
+
fingerprint: sha256:a8f2…
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
## What the command does
|
|
1518
|
+
|
|
1519
|
+
1. **SELECT** every `auto-diff` row in `_voltro_migration_plans`
|
|
1520
|
+
applied before the `--before` cut-off (where `squashedAt IS NULL`).
|
|
1521
|
+
2. **Validates** the latest pre-squash row's fingerprint matches the
|
|
1522
|
+
current declared-schema fingerprint. If they diverge, the squash
|
|
1523
|
+
refuses — your working tree carries un-applied changes that
|
|
1524
|
+
would silently be locked in.
|
|
1525
|
+
3. **Marks** all eligible rows squashed by setting `squashedAt` to
|
|
1526
|
+
the current timestamp. The rows STAY (audit trail preserved);
|
|
1527
|
+
they just no longer participate in boot-time replay.
|
|
1528
|
+
4. **Inserts** a synthetic snapshot row with:
|
|
1529
|
+
- `source: 'squash-snapshot'`
|
|
1530
|
+
- `fingerprint: <current declared fingerprint>`
|
|
1531
|
+
- `notes: <user's --note>`
|
|
1532
|
+
- `operations: { kind: 'squash-snapshot', rowsSquashed: <N> }`
|
|
1533
|
+
|
|
1534
|
+
New environments booting against the squashed history skip past
|
|
1535
|
+
the squashed rows + use the snapshot's fingerprint as their
|
|
1536
|
+
starting point.
|
|
1537
|
+
|
|
1538
|
+
## The fingerprint check
|
|
1539
|
+
|
|
1540
|
+
The most common gotcha:
|
|
1541
|
+
|
|
1542
|
+
```
|
|
1543
|
+
$ voltro db squash --before 2026-06-01
|
|
1544
|
+
|
|
1545
|
+
squash refused: latest pre-squash fingerprint (a8f2…) differs from
|
|
1546
|
+
current declared-schema fingerprint (b3c1…). This means the working
|
|
1547
|
+
tree carries un-applied changes — squashing now would lock the drift in.
|
|
1548
|
+
|
|
1549
|
+
Fix: run `voltro db apply` first to align the live schema with the
|
|
1550
|
+
working tree, then re-squash.
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
The squash captures the fingerprint of the **most recent applied
|
|
1554
|
+
migration**. If your declared schema has uncommitted changes, the
|
|
1555
|
+
snapshot would say "this is the post-squash schema" but the actual
|
|
1556
|
+
schema (live DB) doesn't match. New envs would boot against the
|
|
1557
|
+
snapshot fingerprint, see a different declared fingerprint, and
|
|
1558
|
+
refuse to start.
|
|
1559
|
+
|
|
1560
|
+
Resolution: apply pending migrations FIRST, then squash.
|
|
1561
|
+
|
|
1562
|
+
## CLI flags
|
|
1563
|
+
|
|
1564
|
+
| Flag | Required | Description |
|
|
1565
|
+
|--------------|----------|--------------------------------------------------------------|
|
|
1566
|
+
| `--before` | yes | ISO-8601 date — rows applied before this point are squashed |
|
|
1567
|
+
| `--note` | no | Human-readable note recorded on the snapshot row |
|
|
1568
|
+
|
|
1569
|
+
## What happens on next boot
|
|
1570
|
+
|
|
1571
|
+
A new env booting against a squashed history:
|
|
1572
|
+
|
|
1573
|
+
1. Reads the latest `_voltro_migration_plans` row.
|
|
1574
|
+
2. If `source = 'squash-snapshot'`, treats its fingerprint as the
|
|
1575
|
+
baseline + skips replaying the squashed entries.
|
|
1576
|
+
3. Diffs current declared schema against the baseline.
|
|
1577
|
+
4. Applies only post-squash migrations (anything with
|
|
1578
|
+
`squashedAt IS NULL` AND `appliedAt > snapshot.createdAt`).
|
|
1579
|
+
|
|
1580
|
+
## What stays available
|
|
1581
|
+
|
|
1582
|
+
- **The squashed rows** — still in `_voltro_migration_plans`. Query
|
|
1583
|
+
them via `voltro db plans` or read the table directly.
|
|
1584
|
+
- **File-based migrations** — untouched by squash. Their up/down
|
|
1585
|
+
pairs remain available for rollback.
|
|
1586
|
+
- **Drift detection** — `voltro db drift` compares the live DB
|
|
1587
|
+
fingerprint against the most recent `_voltro_migration_plans`
|
|
1588
|
+
row (squash snapshot OR a post-squash entry).
|
|
1589
|
+
|
|
1590
|
+
## What's GONE
|
|
1591
|
+
|
|
1592
|
+
- **Rolling back a squashed migration** — the rollback path is
|
|
1593
|
+
one-way once a row is marked squashed. To recover, manually
|
|
1594
|
+
UPDATE the row's `squashedAt` back to NULL + DELETE the snapshot
|
|
1595
|
+
row. The framework doesn't auto-emit a reverse squash.
|
|
1596
|
+
- **Replay against a fresh DB** — new environments use the snapshot
|
|
1597
|
+
fingerprint, not the original migration sequence. The squashed
|
|
1598
|
+
rows are audit, not replayable.
|
|
1599
|
+
|
|
1600
|
+
## See also
|
|
1601
|
+
|
|
1602
|
+
- [Migration overview](/docs/database/migrations) — the full plan/apply
|
|
1603
|
+
lifecycle the squash slots into
|
|
1604
|
+
- [Drift detection](/docs/database/migrations/drift) — `voltro db drift`
|
|
1605
|
+
compares against whichever row is most recent (squash or apply)
|
|
1606
|
+
- [Soft-drop recovery](/docs/database/migrations/rollback-snapshots) —
|
|
1607
|
+
the orthogonal "I dropped something and want it back" path
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
---
|
|
1612
|
+
|
|
1613
|
+
<!-- source: en/database/migrations/rollback.md -->
|
|
1614
|
+
## Rollback
|
|
1615
|
+
|
|
1616
|
+
_What can be reversed and what can't. Planner-applied plans are NOT auto-reversible — rollback is file-based only. The recovery paths for an applied schema change, and why forward-fix usually beats rollback._
|
|
1617
|
+
|
|
1618
|
+
There is no rollback for a planner-applied plan. The
|
|
1619
|
+
`_voltro_migration_plans` history is an append-only record of what ran;
|
|
1620
|
+
the applier does not store an inverse plan and `voltro db` has no
|
|
1621
|
+
`rollback <plan-id>` subcommand. Reversing a planner change means
|
|
1622
|
+
either re-declaring the prior schema and applying that as a NEW forward
|
|
1623
|
+
plan, or — for destroyed data — restoring from backup.
|
|
1624
|
+
|
|
1625
|
+
What CAN be reversed mechanically is a **file-based** migration, via its
|
|
1626
|
+
explicit `down` body.
|
|
1627
|
+
|
|
1628
|
+
## What "rollback" means per change kind
|
|
1629
|
+
|
|
1630
|
+
| Change kind | Reverse path |
|
|
1631
|
+
|---|---|
|
|
1632
|
+
| Planner plan with only `safe` ops (ADD nullable, ADD index, widen type) | Re-declare the old shape in TS → `voltro db apply` runs the inverse as a NEW forward plan |
|
|
1633
|
+
| Planner plan with `lossy` ops (DROP column/table, narrow type) | Not reversible — the data is gone. Restore from backup, or [restore-snapshot](./rollback-snapshots.md) if it was applied with `VOLTRO_SOFT_DROP=1` |
|
|
1634
|
+
| `migration()` file (`migrations/<ts>_<slug>.ts`) | `voltro db rollback-file <id>` runs its `down` body |
|
|
1635
|
+
| `defineMigration` step file (`*.migration.ts`) | `voltro db rollback [--to <id>]` runs its steps' `undo` in reverse |
|
|
1636
|
+
|
|
1637
|
+
The rest of this page covers each path.
|
|
1638
|
+
|
|
1639
|
+
## Reversing a planner change — forward-apply the old shape
|
|
1640
|
+
|
|
1641
|
+
A planner plan is a function of (declared schema, live schema). To undo
|
|
1642
|
+
one, make the declared schema describe the PRIOR state again and apply:
|
|
1643
|
+
|
|
1644
|
+
```sh
|
|
1645
|
+
# 1. Revert the *.entity.ts edit (e.g. git revert the schema PR).
|
|
1646
|
+
# 2. voltro db plan shows the inverse diff:
|
|
1647
|
+
voltro db plan
|
|
1648
|
+
# ✓ ALTER TABLE users DROP COLUMN bio # inverse of the ADD that shipped
|
|
1649
|
+
# 3. Apply it as a new forward plan:
|
|
1650
|
+
voltro db apply --note 'reverting bio column — PR #1234 backed out'
|
|
1651
|
+
```
|
|
1652
|
+
|
|
1653
|
+
This works cleanly ONLY when every op in the inverse diff is itself
|
|
1654
|
+
`safe`. If the original plan added a NOT NULL column you now want gone,
|
|
1655
|
+
the inverse is a plain DROP (safe). But if the original plan DROPPED a
|
|
1656
|
+
column, the inverse is an ADD that the planner classifies
|
|
1657
|
+
`needs-backfill` — and the data that column held is already gone, so no
|
|
1658
|
+
backfill expression brings it back. That asymmetry is the whole reason
|
|
1659
|
+
lossy ops are gated behind `dropped()` / `VOLTRO_DESTRUCTIVE_OK=1` in
|
|
1660
|
+
the first place.
|
|
1661
|
+
|
|
1662
|
+
The reverting apply lands a new `_voltro_migration_plans` row. The
|
|
1663
|
+
original plan stays in history; `voltro db plans` shows the apply and
|
|
1664
|
+
its reversal as two separate rows so the timeline is auditable.
|
|
1665
|
+
|
|
1666
|
+
## What can't be recovered by forward-apply
|
|
1667
|
+
|
|
1668
|
+
### Data from lossy ops
|
|
1669
|
+
|
|
1670
|
+
```ts
|
|
1671
|
+
export const users = table('users', {
|
|
1672
|
+
id: id(),
|
|
1673
|
+
legacy: dropped(), // applied → DROP COLUMN legacy
|
|
1674
|
+
})
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
After apply, `users.legacy` and its data are gone. The applier does not
|
|
1678
|
+
snapshot column data before dropping (that would mean duplicating the
|
|
1679
|
+
table at apply time, which doesn't scale). Recovery options:
|
|
1680
|
+
|
|
1681
|
+
- If the plan was applied with `VOLTRO_SOFT_DROP=1`, the column was
|
|
1682
|
+
RENAMED to a sidecar instead of dropped — `voltro db restore-snapshot
|
|
1683
|
+
<plan-id>` brings it back. See [Soft-drop recovery](./rollback-snapshots.md).
|
|
1684
|
+
- Otherwise: restore from your DB's normal backup / PITR system.
|
|
1685
|
+
|
|
1686
|
+
This is why `dropped()` is an explicit annotation — it signals "I have
|
|
1687
|
+
a backup OR I really mean it".
|
|
1688
|
+
|
|
1689
|
+
### Backfilled values
|
|
1690
|
+
|
|
1691
|
+
A `needs-backfill` plan computed values from the SQL expression / JS
|
|
1692
|
+
function. Forward-applying a DROP of that column discards the values.
|
|
1693
|
+
The expression is preserved in the plan's `operations` JSON, so
|
|
1694
|
+
re-applying the same forward plan reproduces the same values IF the
|
|
1695
|
+
source data is unchanged — but a structural reversal does not restore
|
|
1696
|
+
them.
|
|
1697
|
+
|
|
1698
|
+
## Reversing a file-based migration
|
|
1699
|
+
|
|
1700
|
+
### `migration()` files — `rollback-file`
|
|
1701
|
+
|
|
1702
|
+
```sh
|
|
1703
|
+
voltro db rollback-file 20260415_120000_split_address_out
|
|
1704
|
+
```
|
|
1705
|
+
|
|
1706
|
+
Runs the file's `down` body. The `<id>` is positional — the migration's
|
|
1707
|
+
id (its filename minus `.ts`). `rollback-file` refuses on
|
|
1708
|
+
`NODE_ENV=production` (schema rollback runs as an explicit deploy step
|
|
1709
|
+
there). If `down` throws, the rollback is failed and the schema stays
|
|
1710
|
+
half-reverted — the framework can't auto-recover from a broken inverse.
|
|
1711
|
+
|
|
1712
|
+
A non-destructive `down` only restores STRUCTURE, not data the `up`
|
|
1713
|
+
destroyed. Design the pair so `up` MOVES data it would otherwise drop:
|
|
1714
|
+
|
|
1715
|
+
```ts
|
|
1716
|
+
up: async ({ sql }) => {
|
|
1717
|
+
await sql.unsafe(`CREATE TABLE obsolete_archive AS SELECT * FROM obsolete`)
|
|
1718
|
+
await sql.unsafe(`DROP TABLE obsolete`)
|
|
1719
|
+
},
|
|
1720
|
+
down: async ({ sql }) => {
|
|
1721
|
+
await sql.unsafe(`CREATE TABLE obsolete AS SELECT * FROM obsolete_archive`)
|
|
1722
|
+
await sql.unsafe(`DROP TABLE obsolete_archive`)
|
|
1723
|
+
},
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
Higher disk cost during apply (two copies briefly), but the rollback is
|
|
1727
|
+
meaningful.
|
|
1728
|
+
|
|
1729
|
+
### `defineMigration` step files — `rollback`
|
|
1730
|
+
|
|
1731
|
+
The separate step-based runner reverses with `voltro db rollback`
|
|
1732
|
+
(newest applied step) or `voltro db rollback --to <id>` (back through
|
|
1733
|
+
several). It re-runs each migration's `undo` effects in reverse step
|
|
1734
|
+
order against the `_voltro_migrations` table. This is a DIFFERENT runner
|
|
1735
|
+
from the `migration()` path above — see [the overview](./index.md) for
|
|
1736
|
+
why both exist.
|
|
1737
|
+
|
|
1738
|
+
## Rollback on prod
|
|
1739
|
+
|
|
1740
|
+
`voltro db rollback-file` / `voltro db rollback` refuse during a serving
|
|
1741
|
+
prod process — schema changes (forward or reverse) run as explicit
|
|
1742
|
+
deploy steps, never on boot. For a planner change you want backed out in
|
|
1743
|
+
prod, ship a schema PR that re-declares the old shape and apply it
|
|
1744
|
+
through the same [prod pipeline](./prod-pipeline.md) as any other change.
|
|
1745
|
+
|
|
1746
|
+
## When to design for reversal vs forward-fix
|
|
1747
|
+
|
|
1748
|
+
| Scenario | Recovery path |
|
|
1749
|
+
|---|---|
|
|
1750
|
+
| New code crashes on boot, needs reverting | Image rollback + (if schema is incompatible) a forward-apply of the old shape |
|
|
1751
|
+
| New code is fine but the new schema has a bug | NEW migration that fixes the bug — don't reverse to a broken intermediate |
|
|
1752
|
+
| A `lossy` apply destroyed data you needed | Restore from backup / PITR, or `restore-snapshot` if soft-dropped |
|
|
1753
|
+
| Critical bug in production, need to undo NOW | Restore from backup — faster than re-planning when speed matters |
|
|
1754
|
+
|
|
1755
|
+
For production incidents, treat backup + PITR as the first-line option,
|
|
1756
|
+
not schema reversal. Once data is gone, no forward plan brings it back —
|
|
1757
|
+
which is exactly why the framework's apply path is conservative and the
|
|
1758
|
+
prod refuse-to-boot is strict.
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
---
|
|
1763
|
+
|
|
1764
|
+
<!-- source: en/database/migrations/rollback-snapshots.md -->
|
|
1765
|
+
## Soft-drop recovery
|
|
1766
|
+
|
|
1767
|
+
_VOLTRO_SOFT_DROP=1 renames dropped columns instead of deleting them. voltro db restore-snapshot brings them back. (Distinct from Rollback — this is the one path that recovers DROP-COLUMN data.)_
|
|
1768
|
+
|
|
1769
|
+
> This page is the ONLY mechanism that recovers data from a
|
|
1770
|
+
> `DROP COLUMN`. The sibling [Rollback](./rollback.md) page covers
|
|
1771
|
+
> reversing structural changes via forward-apply and file-based `down`
|
|
1772
|
+
> bodies — neither of which brings back dropped data. If you dropped a
|
|
1773
|
+
> column and want its data back, you needed `VOLTRO_SOFT_DROP=1` set at
|
|
1774
|
+
> apply time; that's what this page is about.
|
|
1775
|
+
|
|
1776
|
+
By default Voltro's applier issues `ALTER TABLE ... DROP COLUMN`
|
|
1777
|
+
for any lossy migration. Once that runs the data is gone — your
|
|
1778
|
+
only options are a full DB restore or replaying from a backup.
|
|
1779
|
+
|
|
1780
|
+
`VOLTRO_SOFT_DROP=1` rewrites every `drop-column` op into a
|
|
1781
|
+
RENAME. The data stays in a sidecar column named
|
|
1782
|
+
`<original>__dropped_<timestamp>`. `voltro db restore-snapshot
|
|
1783
|
+
<plan-id>` walks the migration's operations + RENAMEs them back.
|
|
1784
|
+
|
|
1785
|
+
## When to use
|
|
1786
|
+
|
|
1787
|
+
Set `VOLTRO_SOFT_DROP=1` in your apply pipeline as a default
|
|
1788
|
+
safety net. The cost is one extra column per drop (data still
|
|
1789
|
+
takes disk space until GC); the benefit is a one-command recovery
|
|
1790
|
+
window.
|
|
1791
|
+
|
|
1792
|
+
Specifically helpful for:
|
|
1793
|
+
|
|
1794
|
+
- **Reversible production migrations** — drop a column, realize 30
|
|
1795
|
+
minutes later it broke a downstream report, restore it.
|
|
1796
|
+
- **Pre-release schema churn** — drop columns liberally during
|
|
1797
|
+
pre-release, recover when you change your mind.
|
|
1798
|
+
- **High-stakes drops** — set `VOLTRO_SOFT_DROP=1` per-migration
|
|
1799
|
+
via shell env var for the specific apply.
|
|
1800
|
+
|
|
1801
|
+
## Apply with soft-drop
|
|
1802
|
+
|
|
1803
|
+
```bash
|
|
1804
|
+
VOLTRO_SOFT_DROP=1 voltro db apply
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
The applier looks at every op in the plan. For `drop-column` ops
|
|
1808
|
+
it emits a RENAME instead of a DROP:
|
|
1809
|
+
|
|
1810
|
+
```sql
|
|
1811
|
+
-- Without VOLTRO_SOFT_DROP
|
|
1812
|
+
ALTER TABLE "users" DROP COLUMN IF EXISTS "legacy_email";
|
|
1813
|
+
|
|
1814
|
+
-- With VOLTRO_SOFT_DROP=1
|
|
1815
|
+
ALTER TABLE "users" RENAME COLUMN "legacy_email" TO "legacy_email__dropped_20260603145522";
|
|
1816
|
+
```
|
|
1817
|
+
|
|
1818
|
+
The migration row still says "drop-column applied" in
|
|
1819
|
+
`_voltro_migration_plans` — the planner doesn't know the rename
|
|
1820
|
+
happened. The post-apply fingerprint is what matters for boot
|
|
1821
|
+
checks (and that's based on the declared schema, where the column
|
|
1822
|
+
genuinely doesn't exist anymore).
|
|
1823
|
+
|
|
1824
|
+
## Restore
|
|
1825
|
+
|
|
1826
|
+
```bash
|
|
1827
|
+
voltro db restore-snapshot plan_01j5xkqyz...
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
```
|
|
1831
|
+
↩ restored users.legacy_email (from legacy_email__dropped_20260603145522)
|
|
1832
|
+
✓ restored 1 column(s) from plan 'plan_01j5xkqyz...'
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
The command:
|
|
1836
|
+
|
|
1837
|
+
1. Loads the migration plan by id.
|
|
1838
|
+
2. Walks every op in `operations`, finds the `drop-column` ones.
|
|
1839
|
+
3. For each `(table, column)`, queries `information_schema.columns`
|
|
1840
|
+
for a `<column>__dropped_*` match.
|
|
1841
|
+
4. RENAMEs the most-recent match back to the original name.
|
|
1842
|
+
|
|
1843
|
+
If no snapshot matches (the plan was applied without
|
|
1844
|
+
`VOLTRO_SOFT_DROP`), the command logs a warning + skips that
|
|
1845
|
+
column but continues with the others.
|
|
1846
|
+
|
|
1847
|
+
## Workflow
|
|
1848
|
+
|
|
1849
|
+
1. Apply a risky migration with soft-drop enabled.
|
|
1850
|
+
2. The dropped column survives as `<col>__dropped_<stamp>`.
|
|
1851
|
+
3. Test the post-apply state.
|
|
1852
|
+
4. Either:
|
|
1853
|
+
- **It's fine.** Wait for GC (see below) to actually drop the
|
|
1854
|
+
column.
|
|
1855
|
+
- **It broke something.** Run `voltro db restore-snapshot
|
|
1856
|
+
<plan-id>` to bring the column back.
|
|
1857
|
+
5. Re-fix your schema in code (re-declare the column in the
|
|
1858
|
+
`.entity.ts` file) + run a normal `voltro db apply` to put the
|
|
1859
|
+
schema back in shape.
|
|
1860
|
+
|
|
1861
|
+
Step 5 is important — the framework's declared schema is the
|
|
1862
|
+
source of truth. Just restoring the snapshot brings the data back
|
|
1863
|
+
but the declared schema still says the column shouldn't exist; the
|
|
1864
|
+
next apply would re-drop it.
|
|
1865
|
+
|
|
1866
|
+
## GC
|
|
1867
|
+
|
|
1868
|
+
Snapshot columns aren't automatically dropped. They survive until
|
|
1869
|
+
you:
|
|
1870
|
+
|
|
1871
|
+
- Run a manual `ALTER TABLE ... DROP COLUMN <col>__dropped_<stamp>`
|
|
1872
|
+
(or a wrapping migration that handles it).
|
|
1873
|
+
- Drop the table entirely.
|
|
1874
|
+
|
|
1875
|
+
Reclaim the space once your "is the migration confirmed safe?" review
|
|
1876
|
+
window has passed with `voltro db gc-snapshots --before <date>` — it
|
|
1877
|
+
permanently drops every `<name>__dropped_<ts>` column AND table older
|
|
1878
|
+
than the date (the `<ts>` stamp drives the comparison). Add `--dry-run`
|
|
1879
|
+
to preview:
|
|
1880
|
+
|
|
1881
|
+
```bash
|
|
1882
|
+
voltro db gc-snapshots --before 2026-01-01 --dry-run # preview
|
|
1883
|
+
voltro db gc-snapshots --before 2026-01-01 # drop them (NOT reversible)
|
|
1884
|
+
```
|
|
1885
|
+
|
|
1886
|
+
## Limitations
|
|
1887
|
+
|
|
1888
|
+
- **Restore needs `VOLTRO_SOFT_DROP=1` at apply time.** Without it the
|
|
1889
|
+
drop is a hard `DROP COLUMN` / `DROP TABLE` and the data is gone —
|
|
1890
|
+
`restore-snapshot` can only bring back what was soft-dropped (renamed
|
|
1891
|
+
to `<name>__dropped_<ts>`). It restores both columns AND tables.
|
|
1892
|
+
- **Lossy at the row level.** This recovers DROP COLUMN
|
|
1893
|
+
(everything from before the drop is in the snapshot column).
|
|
1894
|
+
It does NOT recover UPDATE/DELETE row-data — those are forever.
|
|
1895
|
+
For that, use database backups.
|
|
1896
|
+
- **No effect on planner classification.** Lossy ops are still
|
|
1897
|
+
classified as lossy by `voltro db plan`. `VOLTRO_SOFT_DROP` is
|
|
1898
|
+
about HOW the drop happens, not WHETHER it's classified safe.
|
|
1899
|
+
The lossy refusal still requires explicit `dropped()` annotation
|
|
1900
|
+
or `VOLTRO_DESTRUCTIVE_OK=1`.
|
|
1901
|
+
|
|
1902
|
+
## See also
|
|
1903
|
+
|
|
1904
|
+
- [Migration overview](/docs/database/migrations) — the full lifecycle
|
|
1905
|
+
- [Operation classes](/docs/database/migrations/operation-classes) —
|
|
1906
|
+
the 7-class taxonomy including `lossy`
|
|
1907
|
+
- [Drift detection](/docs/database/migrations/drift) — for
|
|
1908
|
+
out-of-band changes
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
---
|
|
1913
|
+
|
|
1914
|
+
<!-- source: en/database/migrations/drift.md -->
|
|
1915
|
+
## Drift detection
|
|
1916
|
+
|
|
1917
|
+
_How the framework detects schema drift (live DB ≠ last applied fingerprint), what causes drift, and how to reconcile it — with the planner's introspection, or via corrective plan, or by accepting + re-baselining._
|
|
1918
|
+
|
|
1919
|
+
Drift = the live database's schema doesn't match the fingerprint of the last applied `voltro db apply` plan. It's a passive detection — the framework only knows about drift after introspecting + comparing fingerprints. The detection itself is cheap (one COUNT + one fingerprint compare per check); the reconciliation path depends on cause.
|
|
1920
|
+
|
|
1921
|
+
## How drift gets detected
|
|
1922
|
+
|
|
1923
|
+
Three trigger paths:
|
|
1924
|
+
|
|
1925
|
+
1. **Manual** — `voltro db drift` runs the check on demand, exits 4 on drift
|
|
1926
|
+
2. **On boot (dev)** — every `voltro dev` boot runs the planner, which detects drift implicitly (the plan will be non-empty)
|
|
1927
|
+
3. **Periodic (cloud)** — the cloud dashboard polls each app's `/_voltro/inspect/migrations` endpoint; drift state is in the response
|
|
1928
|
+
|
|
1929
|
+
All three paths produce the same `DriftSnapshot` shape:
|
|
1930
|
+
|
|
1931
|
+
```ts
|
|
1932
|
+
{
|
|
1933
|
+
isDrifted: boolean,
|
|
1934
|
+
liveFingerprint: string, // current introspected user-schema fingerprint
|
|
1935
|
+
lastAppliedFingerprint?: string, // newest _voltro_migration_plans row's fp
|
|
1936
|
+
lastAppliedAt?: string, // when it was applied
|
|
1937
|
+
lastAppliedId?: string, // plan id
|
|
1938
|
+
}
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
`isDrifted: false` ↔ `liveFingerprint === lastAppliedFingerprint`.
|
|
1942
|
+
|
|
1943
|
+
User-schema only: the fingerprint excludes `_voltro_*` framework tables. Bookkeeping tables grow rows + change row counts as the app runs; including them in the fingerprint would make every boot look drifted.
|
|
1944
|
+
|
|
1945
|
+
## Common causes
|
|
1946
|
+
|
|
1947
|
+
### Manual DDL
|
|
1948
|
+
|
|
1949
|
+
Someone ran `ALTER TABLE ...` or `CREATE INDEX ...` via psql / DataGrip / Adminer instead of the framework. The live DB has changes the planner's history doesn't reflect.
|
|
1950
|
+
|
|
1951
|
+
```sh
|
|
1952
|
+
$ voltro db drift
|
|
1953
|
+
db drift: live schema DIVERGED from last applied state
|
|
1954
|
+
last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00 (plan_mig_5k78)
|
|
1955
|
+
live now: a8f2c9d10b3f4e62
|
|
1956
|
+
|
|
1957
|
+
Probable causes:
|
|
1958
|
+
- manual DDL ran out-of-band (psql session, another tool)
|
|
1959
|
+
- someone applied a plan but the row never made it into _voltro_migration_plans
|
|
1960
|
+
|
|
1961
|
+
To reconcile, run `voltro db plan` to see what your code expects vs the live DB.
|
|
1962
|
+
```
|
|
1963
|
+
|
|
1964
|
+
### Out-of-band auto-applier
|
|
1965
|
+
|
|
1966
|
+
Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through `_voltro_migration_plans`.
|
|
1967
|
+
|
|
1968
|
+
### Truncated history table
|
|
1969
|
+
|
|
1970
|
+
`_voltro_migration_plans` was truncated, restored from a backup, or the DB was restored to a point-in-time before the latest applies. The live schema is post-apply but the table doesn't know it.
|
|
1971
|
+
|
|
1972
|
+
### Replica fingerprinted instead of primary
|
|
1973
|
+
|
|
1974
|
+
The drift detector ran against a read-replica that's lagging. Wait for the replica to catch up + re-check. (The framework's drift detector targets primary by default; this only bites when the user explicitly points the check at a replica URL.)
|
|
1975
|
+
|
|
1976
|
+
## Reconciliation paths
|
|
1977
|
+
|
|
1978
|
+
### Path 1 — adopt the live state by declaring it in TS
|
|
1979
|
+
|
|
1980
|
+
When the live DB IS what you want (the manual DDL is correct, only
|
|
1981
|
+
bypassing the planner was sloppy), bring the declared schema up to the
|
|
1982
|
+
live shape: edit the `*.entity.ts` files so they describe exactly what
|
|
1983
|
+
the live DB now has. The next `voltro db plan` then diffs empty, and a
|
|
1984
|
+
`voltro db apply` records a fresh `_voltro_migration_plans` row at the
|
|
1985
|
+
new fingerprint — re-baselining history without any DDL.
|
|
1986
|
+
|
|
1987
|
+
```sh
|
|
1988
|
+
voltro db plan # confirm the diff is now empty (declared == live)
|
|
1989
|
+
voltro db apply --note 'accepting manual DDL from 2026-06-15 — see ticket #789'
|
|
1990
|
+
```
|
|
1991
|
+
|
|
1992
|
+
`voltro db apply` with an empty plan writes no DDL; it just locks in
|
|
1993
|
+
the current fingerprint with your note. The history shows it:
|
|
1994
|
+
|
|
1995
|
+
```
|
|
1996
|
+
plan_mig_5k79 fp=a8f2c9d1 env=dev src=auto-diff 0 op(s) 12ms ... by=alice
|
|
1997
|
+
[note: accepting manual DDL from 2026-06-15 — see ticket #789]
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
There is no metadata-only `--reconcile` flag — re-baselining always
|
|
2001
|
+
goes through the declare-then-apply loop, so the TS schema stays the
|
|
2002
|
+
single source of truth.
|
|
2003
|
+
|
|
2004
|
+
### Path 2 — corrective plan against drift
|
|
2005
|
+
|
|
2006
|
+
When the live DB has accumulated cruft + the declared schema is what you want:
|
|
2007
|
+
|
|
2008
|
+
```sh
|
|
2009
|
+
voltro db plan # see the diff between code + live
|
|
2010
|
+
voltro db apply # execute the diff, removing the drift
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
The plan diff will show the corrective ops:
|
|
2014
|
+
|
|
2015
|
+
```
|
|
2016
|
+
schema diff: 2 operations, 0 blocked
|
|
2017
|
+
|
|
2018
|
+
✗ DROP INDEX manual_idx_we_forgot_to_remove # safe (no FK depends on it)
|
|
2019
|
+
⊕ ALTER TABLE users ADD COLUMN missing_field text # backfill: sql`'default'`
|
|
2020
|
+
|
|
2021
|
+
fingerprint: a8f2c9d10b3f4e62 → 8f507ba1e1aadad5
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
Apply lands the corrections + the new fingerprint matches the declared schema.
|
|
2025
|
+
|
|
2026
|
+
### Path 3 — declared schema needs updates
|
|
2027
|
+
|
|
2028
|
+
The live DB has a column the declared schema doesn't reference, and you WANT to keep that column in the schema. Update the schema TS file to add it:
|
|
2029
|
+
|
|
2030
|
+
```ts
|
|
2031
|
+
// users.entity.ts
|
|
2032
|
+
export const users = table('users', {
|
|
2033
|
+
id: id(),
|
|
2034
|
+
email: text(),
|
|
2035
|
+
extra_field: text().nullable(), // add to declared
|
|
2036
|
+
})
|
|
2037
|
+
```
|
|
2038
|
+
|
|
2039
|
+
Now the live shape matches the declared shape after the next plan (which will be empty). The drift "fixed itself" through code changes.
|
|
2040
|
+
|
|
2041
|
+
## Drift on prod
|
|
2042
|
+
|
|
2043
|
+
Production drift is the most important to catch quickly because it suggests an unauthorised change to the production DB. The cloud dashboard's drift detector runs every 5 minutes against each customer's prod app + surfaces the divergence as soon as it appears.
|
|
2044
|
+
|
|
2045
|
+
Surfaces:
|
|
2046
|
+
|
|
2047
|
+
- Dashboard banner on the affected app's Migrations tab
|
|
2048
|
+
- Audit log row tagged `drift.detected`
|
|
2049
|
+
|
|
2050
|
+
Out-of-band channels (Slack, email, PagerDuty) are intentionally NOT in the framework. Subscribe to the audit log via your existing observability stack — every drift event is a row your SIEM / monitoring already consumes, and your team's incident process kicks in from there.
|
|
2051
|
+
|
|
2052
|
+
The org's incident response process kicks in from there. Common immediate actions:
|
|
2053
|
+
|
|
2054
|
+
1. Check the audit log for any non-CI DB access
|
|
2055
|
+
2. Run `voltro db drift` against a snapshot to confirm the divergence
|
|
2056
|
+
(the check is a structural fingerprint compare — it tells you THAT
|
|
2057
|
+
the schema diverged, not which rows changed)
|
|
2058
|
+
3. Decide: corrective plan or re-baseline?
|
|
2059
|
+
|
|
2060
|
+
## What the dashboard shows
|
|
2061
|
+
|
|
2062
|
+
The Migrations tab's drift banner renders when `isDrifted: true`:
|
|
2063
|
+
|
|
2064
|
+
```
|
|
2065
|
+
⚠ Schema drift detected
|
|
2066
|
+
last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00
|
|
2067
|
+
live now: a8f2c9d10b3f4e62
|
|
2068
|
+
|
|
2069
|
+
→ Run `voltro db plan` to see what your code expects vs the live DB.
|
|
2070
|
+
```
|
|
2071
|
+
|
|
2072
|
+
Click → expands to a comparison view showing the introspected live shape + the declared shape, highlighting the divergent tables. (Cloud dashboard only; local devtools shows just the banner without the comparison view.)
|
|
2073
|
+
|
|
2074
|
+
## What about replicas?
|
|
2075
|
+
|
|
2076
|
+
Each replica has its own catch-up state. The framework's drift detector compares against PRIMARY by default; replicas catch up via the normal replication stream + reach the same fingerprint within their lag window.
|
|
2077
|
+
|
|
2078
|
+
If you specifically want to monitor replica drift (rare; mostly relevant during major maintenance windows), the cloud dashboard's Settings page allows enabling "Replica drift monitoring" which polls each replica URL separately + alerts on lag > N minutes.
|
|
2079
|
+
|
|
2080
|
+
## Drift after rollback
|
|
2081
|
+
|
|
2082
|
+
`voltro db rollback` itself records a new row in `_voltro_migration_plans`, so the fingerprint of that row matches the post-rollback state. No drift gets reported as a side-effect of rollback.
|
|
2083
|
+
|
|
2084
|
+
If something else changed the live DB between the original apply + the rollback, that drift was already present + the rollback doesn't surface it differently. Run `voltro db drift` after rollback to confirm reconciliation if you're suspicious.
|
|
2085
|
+
|
|
2086
|
+
## Detecting drift is the easy part
|
|
2087
|
+
|
|
2088
|
+
The hardest part of drift response is figuring out **what** changed + **who** did it. The framework can tell you that the fingerprints differ + show the structural diff. It can't tell you who ran the DDL or why. Pair the framework's drift detector with:
|
|
2089
|
+
|
|
2090
|
+
- Database audit logs (Postgres `pgaudit`, MySQL audit plugin, MSSQL Audit, SQLite no-op)
|
|
2091
|
+
- Network access logs (who reached the DB during the drift window)
|
|
2092
|
+
- Application logs filtered by trace id (if the drift happened during a request, trace shows the caller)
|
|
2093
|
+
- The team's normal incident response (Slack channel for accidental changes, post-mortem cadence)
|
|
2094
|
+
|
|
2095
|
+
## TL;DR
|
|
2096
|
+
|
|
2097
|
+
```
|
|
2098
|
+
Detect: voltro db drift
|
|
2099
|
+
Fix code: voltro db apply (apply corrective plan from current diff)
|
|
2100
|
+
Adopt DB: edit the *.entity.ts to match live, then voltro db apply (empty plan re-baselines)
|
|
2101
|
+
Backup: if data was lost, restore from your DB backup system — the framework can't help
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
---
|
|
2107
|
+
|
|
2108
|
+
<!-- source: en/database/migrations/devtools-ui.md -->
|
|
2109
|
+
## Devtools UI
|
|
2110
|
+
|
|
2111
|
+
_Walkthrough of the local devtools dashboard's Migrations tab — drift banner, pending plan card, and history timeline. What's clickable, what's not, and when the Apply button appears._
|
|
2112
|
+
|
|
2113
|
+
The local devtools dashboard (`http://localhost:5179`) ships a per-app Migrations tab that mounts the shared `MigrationsPage` component from `@voltro/devtools-ui`. The cloud dashboard mounts the same component over different transport — see [Cloud UI](./cloud-ui.md).
|
|
2114
|
+
|
|
2115
|
+
To reach it: `voltro dev` boot launches the dashboard automatically, pick an app from the sidebar, click the **Migrations** tab between Database and Data.
|
|
2116
|
+
|
|
2117
|
+
## Layout
|
|
2118
|
+
|
|
2119
|
+
```
|
|
2120
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
2121
|
+
│ Migrations │
|
|
2122
|
+
│ App: myApi · http://localhost:4000 │
|
|
2123
|
+
├──────────────────────────────────────────────────────────────┤
|
|
2124
|
+
│ ⚠ Schema drift detected │ ← drift banner
|
|
2125
|
+
│ last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00 │ only when drifted
|
|
2126
|
+
│ live now: d32149b280101693 │
|
|
2127
|
+
│ → Run `voltro db plan` to see what your code expects. │
|
|
2128
|
+
├──────────────────────────────────────────────────────────────┤
|
|
2129
|
+
│ Pending plan — 3 ops │ ← pending card
|
|
2130
|
+
│ ✓ ALTER TABLE users ADD COLUMN bio text │ classifications +
|
|
2131
|
+
│ ⊕ ALTER TABLE users ADD COLUMN email text │ fix hints
|
|
2132
|
+
│ # NOT NULL, declared backfill: sql`'unknown-' || id` │
|
|
2133
|
+
│ ⊕ UPDATE users SET email = ... │
|
|
2134
|
+
│ │
|
|
2135
|
+
│ from 8f507ba1e1aadad5 → to a8f2c9d10b3f4e62 │
|
|
2136
|
+
├──────────────────────────────────────────────────────────────┤
|
|
2137
|
+
│ Applied history — up to 20 plans │ ← history timeline
|
|
2138
|
+
│ plan_mig_5k78 fp=d3214928 env=dev src=auto-diff ... │
|
|
2139
|
+
│ plan_mig_5k77 fp=b414a413 env=dev src=auto-diff ... │
|
|
2140
|
+
│ plan_mig_5k76 fp=bd27c13e env=dev src=auto-diff ... │
|
|
2141
|
+
│ ... │
|
|
2142
|
+
└──────────────────────────────────────────────────────────────┘
|
|
2143
|
+
```
|
|
2144
|
+
|
|
2145
|
+
Auto-refreshes every 10 seconds. The transport is HTTP — the page fetches `GET <app-url>/_voltro/inspect/migrations` directly + renders the JSON.
|
|
2146
|
+
|
|
2147
|
+
## Drift banner
|
|
2148
|
+
|
|
2149
|
+
Renders at the top in rose when `drift.isDrifted: true`. Surfaces:
|
|
2150
|
+
|
|
2151
|
+
- **last applied fingerprint** + the time it was applied
|
|
2152
|
+
- **live fingerprint** computed from the current introspection
|
|
2153
|
+
- A pointer at the CLI command to investigate
|
|
2154
|
+
|
|
2155
|
+
The banner is informational — it doesn't block anything. The Apply button below it (when present) still works; the plan diff will include reconciling changes to bring live back to declared.
|
|
2156
|
+
|
|
2157
|
+
Common drift causes:
|
|
2158
|
+
|
|
2159
|
+
- A `psql` (or equivalent) session ran DDL outside the planner
|
|
2160
|
+
- A team member applied a plan but the row didn't make it into `_voltro_migration_plans` (rare; would mean the applier crashed after DDL but before writing the row)
|
|
2161
|
+
- The `_voltro_migration_plans` table itself got truncated / restored from a backup
|
|
2162
|
+
|
|
2163
|
+
For non-trivial drift, run `voltro db drift` in the shell — same diagnosis, with a copy-pasteable fix path.
|
|
2164
|
+
|
|
2165
|
+
## Pending plan card
|
|
2166
|
+
|
|
2167
|
+
Renders the `MigrationPlan` from the planner.
|
|
2168
|
+
|
|
2169
|
+
- One line per `PlannedOperation` with a color-coded badge for its `OperationClass`:
|
|
2170
|
+
- `safe` / `needs-default` → emerald
|
|
2171
|
+
- `needs-backfill` / `needs-rename-annotation` → amber
|
|
2172
|
+
- `lossy` → rose
|
|
2173
|
+
- `online-required` → cyan
|
|
2174
|
+
- `multi-step` → fuchsia
|
|
2175
|
+
- The op description follows the badge — e.g. `ALTER TABLE users ADD COLUMN email text NOT NULL`
|
|
2176
|
+
- A dim-text reason follows: `# backfill declared (kind=sql) — applier will run 3-step add/update/set-not-null`
|
|
2177
|
+
- For blocked ops, a red `! fix:` hint underneath: `! fix: declare email: text().backfill(sql`...`) ...`
|
|
2178
|
+
|
|
2179
|
+
Below the op list, a chip row summarises counts per class. Below that, the from/to fingerprint short forms.
|
|
2180
|
+
|
|
2181
|
+
### The Apply button
|
|
2182
|
+
|
|
2183
|
+
The shared `MigrationsPage` component renders an "Apply plan" button
|
|
2184
|
+
only when BOTH the caller's `canApplyMigration` capability is set AND
|
|
2185
|
+
the host wires an optional `useApplyPlan` hook (and the pending plan has
|
|
2186
|
+
zero blocked ops). The local devtools is single-user, single-machine, so
|
|
2187
|
+
it can wire that hook; the cloud dashboard never does (see
|
|
2188
|
+
[Cloud UI](./cloud-ui.md)).
|
|
2189
|
+
|
|
2190
|
+
When the Apply button isn't wired, the page surfaces the next-best
|
|
2191
|
+
thing: run `voltro db apply` from the project root + the page
|
|
2192
|
+
auto-refreshes within 10 seconds to show the post-apply state. That CLI
|
|
2193
|
+
path carries `--note '...'`, runs with whatever credentials are in the
|
|
2194
|
+
operator's shell, and exits with a status code CI/CD can act on — and
|
|
2195
|
+
it's the only path that ever reaches production, since
|
|
2196
|
+
`voltro db apply` refuses on `NODE_ENV=production`.
|
|
2197
|
+
|
|
2198
|
+
## History timeline
|
|
2199
|
+
|
|
2200
|
+
Lists rows from `_voltro_migration_plans` newest-first, capped at 20.
|
|
2201
|
+
|
|
2202
|
+
Per-row:
|
|
2203
|
+
|
|
2204
|
+
- **plan id** — `plan_mig_5k78`, the typeid from the row
|
|
2205
|
+
- **fp** — 16-char short fingerprint of the post-apply state
|
|
2206
|
+
- **env** — dev / staging / prod (color-coded chip)
|
|
2207
|
+
- **src** — auto-diff (planner) or file (file-based migration)
|
|
2208
|
+
- **op count** — how many ops were in that plan
|
|
2209
|
+
- **duration** — milliseconds the apply took
|
|
2210
|
+
- **applied at** — ISO timestamp
|
|
2211
|
+
- **applied by** — CLI user / `boot:dev` / service principal
|
|
2212
|
+
- **notes** — the freeform `--note` string if provided, dim italic
|
|
2213
|
+
|
|
2214
|
+
Click a row to expand → shows the operations JSON (the full `PlannedOperation[]` that ran). Useful for "why is `users.legacy` gone?" — find the plan that dropped it, see exactly what executed.
|
|
2215
|
+
|
|
2216
|
+
### No per-row Rollback button
|
|
2217
|
+
|
|
2218
|
+
History rows are read-only — there is NO Rollback button on a plan row.
|
|
2219
|
+
That matches the runtime: planner-applied plans have no auto-rollback
|
|
2220
|
+
(see [Rollback](./rollback.md)). The page DOES surface a separate
|
|
2221
|
+
restore control for soft-dropped columns (when a plan was applied with
|
|
2222
|
+
`VOLTRO_SOFT_DROP=1`, the sidecar columns get a per-plan "Restore"
|
|
2223
|
+
button driving `voltro db restore-snapshot`). Reversing a file-based
|
|
2224
|
+
migration's `down` body is a CLI-only action (`voltro db rollback-file
|
|
2225
|
+
<id>`).
|
|
2226
|
+
|
|
2227
|
+
## Loading + error states
|
|
2228
|
+
|
|
2229
|
+
The page wraps the data fetch in a `DataSource<MigrationsStatus>`:
|
|
2230
|
+
|
|
2231
|
+
- Pending: shows a loading card while the first fetch is in flight
|
|
2232
|
+
- Error: renders the error message in rose
|
|
2233
|
+
- Data + error both undefined → page is in initial state, no flicker
|
|
2234
|
+
|
|
2235
|
+
A common error: the framework's inspect endpoint isn't reachable. Verify with `curl -sS http://localhost:4000/_voltro/inspect/migrations` — should return JSON. If not, check that the app is running + `VOLTRO_INSPECT` isn't `off`.
|
|
2236
|
+
|
|
2237
|
+
## Multi-app
|
|
2238
|
+
|
|
2239
|
+
The dashboard's app picker shows every running voltro process (from `~/.voltro/runtime-registry.json`). Each app gets its own Migrations tab; the data is per-app + the URL includes the app id.
|
|
2240
|
+
|
|
2241
|
+
If multiple apps target the same database, they're showing the same `_voltro_migration_plans` rows — applied plans are global. Drift detection runs against the live DB per-app inspect, so if one app is talking to a different db (env var override etc.) you might see drift on one but not the other.
|
|
2242
|
+
|
|
2243
|
+
## Source code
|
|
2244
|
+
|
|
2245
|
+
The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.tsx`. Both repos are open to extension.
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
---
|
|
2250
|
+
|
|
2251
|
+
<!-- source: en/database/migrations/cloud-ui.md -->
|
|
2252
|
+
## Cloud UI
|
|
2253
|
+
|
|
2254
|
+
_Cloud dashboard's Migrations tab — what it shows, the submit→review→approve workflow with HMAC-signed review URLs, why it never applies (and never will), how it differs from the local devtools UI, and the multi-tenant boundary._
|
|
2255
|
+
|
|
2256
|
+
The cloud dashboard's per-app Migrations tab mounts the same `MigrationsPage` component the local devtools uses — the page itself is portable. The difference is transport + capabilities.
|
|
2257
|
+
|
|
2258
|
+
## Transport
|
|
2259
|
+
|
|
2260
|
+
Local devtools fetches `/_voltro/inspect/migrations` directly over HTTP. The cloud dashboard can't — it doesn't have direct network access to the customer's app, and the inspect endpoint isn't internet-exposed in a sane deploy.
|
|
2261
|
+
|
|
2262
|
+
Instead, the cloud dashboard subscribes via cloud-RPC:
|
|
2263
|
+
|
|
2264
|
+
```ts
|
|
2265
|
+
useAppInspectMigrationsStatus(appId)
|
|
2266
|
+
// → calls the cloud-api's `apps.inspectMigrationsStatus({appId})` query
|
|
2267
|
+
// → cloud-api looks up the app row (tenant-scoped to the caller)
|
|
2268
|
+
// → cloud-api calls `<app.url>/_voltro/inspect/migrations` with the app's stored inspectToken
|
|
2269
|
+
// → cloud-api returns the response to the dashboard
|
|
2270
|
+
```
|
|
2271
|
+
|
|
2272
|
+
Three indirection levels: dashboard → cloud-api → customer-app → DB. Each hop is auth-scoped — the dashboard only sees apps the calling user has access to; the cloud-api enforces caller-owns-tenant on every request; the customer-app verifies the inspectToken.
|
|
2273
|
+
|
|
2274
|
+
Live data: the subscription stays open; deltas push when the underlying `_voltro_migration_plans` row set changes (the customer-app's reactive engine emits a change event on insert, which propagates through the cloud-api's RPC proxy back to the dashboard subscription). Same UX as the local devtools' 10-second polling, but push-driven.
|
|
2275
|
+
|
|
2276
|
+
## The Apply button is gone — permanently
|
|
2277
|
+
|
|
2278
|
+
Apply locally with `voltro db apply` (the dev CLI). The cloud dashboard never applies — by design; it shows the plan, the approval workflow gates a human-triggered apply, and the apply itself runs in your own pipeline.
|
|
2279
|
+
|
|
2280
|
+
Reasoning:
|
|
2281
|
+
|
|
2282
|
+
- Cloud-applied plans would need to hold migration-grade credentials in the cloud-api. Today the inspectToken authorises READ-only access to inspect endpoints; an apply endpoint would require an entirely separate trust boundary.
|
|
2283
|
+
- Cloud-applied plans would mean the cloud platform owns the operational responsibility for the customer's schema. We don't want to.
|
|
2284
|
+
- The plan model is designed for human review BEFORE apply. A button click that says "apply now" without forcing the operator through `voltro db plan` first → review → deploy isn't the safety story the docs promise.
|
|
2285
|
+
- For prod specifically: the rule is `voltro db apply` runs as an explicit deploy step (and refuses on `NODE_ENV=production`). No browser button bypasses that.
|
|
2286
|
+
|
|
2287
|
+
What the cloud dashboard DOES instead: surfaces the pending plan
|
|
2288
|
+
(ops + classifications + fingerprints) read-only, and points the
|
|
2289
|
+
operator at the CLI command to run in their own pipeline:
|
|
2290
|
+
|
|
2291
|
+
```
|
|
2292
|
+
Pending plan — 3 ops · 0 blocked
|
|
2293
|
+
|
|
2294
|
+
Apply via your deploy pipeline:
|
|
2295
|
+
voltro db apply # re-diffs live + applies; run as a deploy step
|
|
2296
|
+
```
|
|
2297
|
+
|
|
2298
|
+
The apply re-diffs the live DB against the deployed code, so the
|
|
2299
|
+
dashboard doesn't hand out a serialised plan to feed back in — the
|
|
2300
|
+
plan it shows is the PREVIEW, and `voltro db apply` recomputes the same
|
|
2301
|
+
diff at deploy time. This keeps the dashboard the source of truth for
|
|
2302
|
+
what's pending, while never executing.
|
|
2303
|
+
|
|
2304
|
+
## Multi-environment view
|
|
2305
|
+
|
|
2306
|
+
A per-app cloud dashboard has multiple environments. The Migrations tab surfaces all of them with sub-tabs:
|
|
2307
|
+
|
|
2308
|
+
```
|
|
2309
|
+
┌────────────────────────────────────────────────────────────┐
|
|
2310
|
+
│ Migrations │
|
|
2311
|
+
│ App: myApi · project: acme · org: acme-inc │
|
|
2312
|
+
├────────────────────────────────────────────────────────────┤
|
|
2313
|
+
│ [ dev ] [ staging ] [ prod ] │ ← env tabs
|
|
2314
|
+
├────────────────────────────────────────────────────────────┤
|
|
2315
|
+
│ <selected env's MigrationsPage> │
|
|
2316
|
+
└────────────────────────────────────────────────────────────┘
|
|
2317
|
+
```
|
|
2318
|
+
|
|
2319
|
+
Each tab shows its own `MigrationsStatus`. Switching tabs is instant + the data is per-env subscription.
|
|
2320
|
+
|
|
2321
|
+
Common workflow:
|
|
2322
|
+
|
|
2323
|
+
1. Reviewer opens the PR's preview env (`dev`) tab → confirms the plan ran cleanly there
|
|
2324
|
+
2. Reviewer switches to `staging` → sees the SAME fingerprint applied → fingerprint chain looks healthy
|
|
2325
|
+
3. Reviewer switches to `prod` → fingerprint is still old → operator hasn't run apply yet, that's expected, OK to merge
|
|
2326
|
+
|
|
2327
|
+
When prod drifts (the production fingerprint is older than staging), the prod tab shows the drift banner. Out-of-band alerting (Slack ping, email, PagerDuty) is intentionally NOT the framework's job — your deploy pipeline already has hooks for it (GitHub/GitLab Actions, k8s controllers, ArgoCD, etc.). The dashboard is the source of truth; whatever notification system your team already runs subscribes to the relevant pipeline events.
|
|
2328
|
+
|
|
2329
|
+
## Pending-plans queue + approval workflow
|
|
2330
|
+
|
|
2331
|
+
The cloud-api ships a human-gated approval flow on top of the read-only
|
|
2332
|
+
view above: an operator's deploy pipeline **submits** a plan, reviewers
|
|
2333
|
+
**approve** (or reject) it, and only then does a human run
|
|
2334
|
+
`voltro db apply` from the pipeline. The cloud still NEVER applies the
|
|
2335
|
+
schema itself — approval gates the human apply, it does not perform it.
|
|
2336
|
+
|
|
2337
|
+
### Submit a plan
|
|
2338
|
+
|
|
2339
|
+
The pipeline computes the plan with `voltro db plan --json` and posts it
|
|
2340
|
+
to the `migrations.submitPlan` mutation. The plan lands as `pending` in
|
|
2341
|
+
the `migration_plans` table (tenant-scoped) and the mutation returns the
|
|
2342
|
+
plan id plus an **HMAC-signed shareable review URL**:
|
|
2343
|
+
|
|
2344
|
+
```ts
|
|
2345
|
+
const { id, status, reviewUrl } = await submitPlan.mutate({
|
|
2346
|
+
orgId, // = the active tenant id
|
|
2347
|
+
appId, // which deployed app the plan targets
|
|
2348
|
+
env: 'staging', // free-form env tag the pipeline submits
|
|
2349
|
+
fingerprint: livePlan.toFingerprint,
|
|
2350
|
+
operations: livePlan.operations, // [{ kind, table, classification, … }]
|
|
2351
|
+
summary: livePlan.summary, // per-classification counts
|
|
2352
|
+
})
|
|
2353
|
+
// status === 'pending'
|
|
2354
|
+
// reviewUrl === 'https://dashboard…/migrations/review?t=<signed-token>'
|
|
2355
|
+
```
|
|
2356
|
+
|
|
2357
|
+
The review URL carries a token whose body is `{ planId, exp }`,
|
|
2358
|
+
base64url-encoded, with an HMAC-SHA256 signature appended. The token is
|
|
2359
|
+
signed with `VOLTRO_CLOUD_SECRET` (falling back to
|
|
2360
|
+
`VOLTRO_SESSION_SECRET`), so it can't be forged for a plan the reviewer
|
|
2361
|
+
was never sent, and it expires after 7 days. A reviewer opening the link
|
|
2362
|
+
hands the token to `migrations.reviewByToken`, which verifies the
|
|
2363
|
+
signature + expiry server-side (constant-time compare) before returning
|
|
2364
|
+
the plan — no interactive session required.
|
|
2365
|
+
|
|
2366
|
+
### Review + decide
|
|
2367
|
+
|
|
2368
|
+
The dashboard's per-app Migrations tab renders a **Pending Approvals**
|
|
2369
|
+
section above the read-only history. It subscribes to
|
|
2370
|
+
`migrations.pendingApprovals` (a reactive, tenant-scoped query over
|
|
2371
|
+
`migration_plans`) — each plan expands to its operation list +
|
|
2372
|
+
per-classification summary badges, with **Sign / Approve** and
|
|
2373
|
+
**Reject** buttons plus an optional note. Signing a decision calls the
|
|
2374
|
+
`migrations.approve` mutation, which — in one transaction — appends an
|
|
2375
|
+
immutable row to `migration_approvals` and flips the plan's `status` to
|
|
2376
|
+
`approved` / `rejected`. The subscription drops the plan from the queue
|
|
2377
|
+
on the status flip.
|
|
2378
|
+
|
|
2379
|
+
Approval is **role-gated**: only org `owner` / `admin` can sign a
|
|
2380
|
+
decision (the same role guard the member-invite flow uses). A reviewer
|
|
2381
|
+
without the role sees a typed `MigrationApprovalForbidden` rejection.
|
|
2382
|
+
|
|
2383
|
+
### Still no apply from the browser
|
|
2384
|
+
|
|
2385
|
+
Approval is distinct from apply, by design. An approved plan tells the
|
|
2386
|
+
operator "a reviewer signed off"; the operator still runs
|
|
2387
|
+
`voltro db apply` as an explicit deploy step (which re-diffs live and
|
|
2388
|
+
refuses on `NODE_ENV=production` without a pre-reviewed plan). When the
|
|
2389
|
+
apply lands, the pipeline reports back and the plan's `status` moves to
|
|
2390
|
+
`applied`. No browser button ever executes DDL.
|
|
2391
|
+
|
|
2392
|
+
## Audit log
|
|
2393
|
+
|
|
2394
|
+
The cloud-api's audit log records the events it observes — who read the
|
|
2395
|
+
migrations tab, who submitted / approved / rejected each plan (the
|
|
2396
|
+
`migration_approvals` table is the immutable decision trail, stamped
|
|
2397
|
+
with `signedBy` + `decidedAt`), and (via the customer app's own history)
|
|
2398
|
+
which plans were applied and when.
|
|
2399
|
+
|
|
2400
|
+
## What's NOT in the cloud UI
|
|
2401
|
+
|
|
2402
|
+
- **Apply button** — see above
|
|
2403
|
+
- **Rollback button** — there is no per-plan rollback anywhere (the
|
|
2404
|
+
runtime has no auto-rollback for planner plans — see
|
|
2405
|
+
[Rollback](./rollback.md)), so the dashboard surfaces none
|
|
2406
|
+
- **Inline plan editing** — plans are immutable; correct via a new PR
|
|
2407
|
+
- **Cross-app comparison** — each app's tab is independent
|
|
2408
|
+
|
|
2409
|
+
## Permissions
|
|
2410
|
+
|
|
2411
|
+
Reading the Migrations tab requires the `canViewMigrationDetails`
|
|
2412
|
+
capability + caller-owns-tenant on the app. The framework's `Subject`
|
|
2413
|
+
model + per-org role mapping carries this; the dashboard hides the tab
|
|
2414
|
+
from users who lack the cap. Signing an approval decision is gated
|
|
2415
|
+
further: only org `owner` / `admin` can call `migrations.approve` — the
|
|
2416
|
+
server enforces this regardless of which buttons the UI renders.
|
|
2417
|
+
|
|
2418
|
+
## Comparison with local devtools
|
|
2419
|
+
|
|
2420
|
+
| | Local devtools | Cloud dashboard |
|
|
2421
|
+
|---|---|---|
|
|
2422
|
+
| Transport | HTTP fetch (direct) | Cloud-RPC subscribe (proxied) |
|
|
2423
|
+
| Auth | none (local-only) | session + tenant scope + per-app cap |
|
|
2424
|
+
| Multi-env | no (per app at a time) | yes (tabs per env) |
|
|
2425
|
+
| Apply | cap-gated (single-user) | NEVER |
|
|
2426
|
+
| Approval workflow | no | submit → review → approve (owner/admin) |
|
|
2427
|
+
| Audit log | local log buffer | persisted in cloud-api |
|
|
2428
|
+
| Drift alerts | banner only | banner only (in-app) |
|
|
2429
|
+
| Live updates | 10s polling | push via subscription |
|
|
2430
|
+
|
|
2431
|
+
## Source code
|
|
2432
|
+
|
|
2433
|
+
- Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx` — shared
|
|
2434
|
+
- Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.tsx`
|
|
2435
|
+
- Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
|
|
2436
|
+
- Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
|
|
2437
|
+
- Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
|
|
2438
|
+
- Review-URL signing: `voltro-cloud/apps/voltro-cloud/api/lib/migrationReviewUrl.ts` (HMAC-SHA256 over `{planId, exp}`, `VOLTRO_CLOUD_SECRET` / `VOLTRO_SESSION_SECRET`)
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
---
|
|
2443
|
+
|
|
2444
|
+
<!-- source: en/database/migrations/troubleshooting.md -->
|
|
2445
|
+
## Troubleshooting
|
|
2446
|
+
|
|
2447
|
+
_The most common "why is my voltro dev refusing to boot?" cases with copy-paste fixes. Each pattern maps a planner error message to the schema annotation that resolves it._
|
|
2448
|
+
|
|
2449
|
+
Every refuse-to-boot from the planner includes a structured fix hint. This page is the comprehensive catalog of those hints + the schema edit each one wants.
|
|
2450
|
+
|
|
2451
|
+
## "NOT NULL column on a table whose row count is unknown"
|
|
2452
|
+
|
|
2453
|
+
Full error:
|
|
2454
|
+
|
|
2455
|
+
```
|
|
2456
|
+
auto-migrate: REFUSED — 1 blocked operation(s):
|
|
2457
|
+
- add-column [users]: NOT NULL column on a table whose row count is unknown
|
|
2458
|
+
fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive the migration
|
|
2459
|
+
```
|
|
2460
|
+
|
|
2461
|
+
What happened: you added a non-nullable column to a populated table without telling the planner how to populate it for existing rows.
|
|
2462
|
+
|
|
2463
|
+
Fix — pick ONE:
|
|
2464
|
+
|
|
2465
|
+
```ts
|
|
2466
|
+
// Option A — constant default (planner emits ADD COLUMN ... NOT NULL DEFAULT value)
|
|
2467
|
+
plan: text().default('free'),
|
|
2468
|
+
|
|
2469
|
+
// Option B — SQL expression (planner emits ADD nullable → UPDATE → SET NOT NULL)
|
|
2470
|
+
email: text().backfill(sql`'unknown-' || id || '@local'`),
|
|
2471
|
+
|
|
2472
|
+
// Option C — JS function (slower, only when SQL can't express what you need)
|
|
2473
|
+
embedding: text().backfill(async (row) => embed(row.title)),
|
|
2474
|
+
|
|
2475
|
+
// Option D — make it nullable
|
|
2476
|
+
bio: text().nullable(),
|
|
2477
|
+
```
|
|
2478
|
+
|
|
2479
|
+
Decision rubric in [backfill.md](./backfill.md).
|
|
2480
|
+
|
|
2481
|
+
## "column missing from declared schema" (DROP COLUMN refused)
|
|
2482
|
+
|
|
2483
|
+
Full error:
|
|
2484
|
+
|
|
2485
|
+
```
|
|
2486
|
+
✗ ALTER TABLE users DROP COLUMN legacyField
|
|
2487
|
+
! fix: if intentional, add `legacyField: dropped()` to the schema. If a typo, restore the field
|
|
2488
|
+
```
|
|
2489
|
+
|
|
2490
|
+
What happened: the live DB has a column the declared schema doesn't reference. Could be deliberate (you want to drop it) or accidental (someone deleted the field from the schema by mistake).
|
|
2491
|
+
|
|
2492
|
+
Fix — pick ONE:
|
|
2493
|
+
|
|
2494
|
+
```ts
|
|
2495
|
+
// Option A — declare intent. Column goes away on next apply.
|
|
2496
|
+
export const users = table('users', {
|
|
2497
|
+
id: id(),
|
|
2498
|
+
legacyField: dropped(), // ← explicit. Planner allows the drop.
|
|
2499
|
+
})
|
|
2500
|
+
|
|
2501
|
+
// Option B — typo, restore the field.
|
|
2502
|
+
export const users = table('users', {
|
|
2503
|
+
id: id(),
|
|
2504
|
+
legacyField: text(),
|
|
2505
|
+
})
|
|
2506
|
+
|
|
2507
|
+
// Option C — use VOLTRO_DESTRUCTIVE_OK for one-off applies (loud warning):
|
|
2508
|
+
// VOLTRO_DESTRUCTIVE_OK=1 voltro db apply
|
|
2509
|
+
```
|
|
2510
|
+
|
|
2511
|
+
## "table missing from declared schema" (DROP TABLE refused)
|
|
2512
|
+
|
|
2513
|
+
```
|
|
2514
|
+
✗ DROP TABLE oldUsersTable
|
|
2515
|
+
! fix: if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration. If a typo, restore the table declaration
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
Fix:
|
|
2519
|
+
|
|
2520
|
+
```ts
|
|
2521
|
+
// Option A — restore the table declaration (probably the right answer if surprised)
|
|
2522
|
+
export const oldUsersTable = table('oldUsersTable', { id: id(), ... })
|
|
2523
|
+
|
|
2524
|
+
// Option B — file-based migration that moves data out + then drops.
|
|
2525
|
+
// The file context is { sql, log, appliedAt } — no store; use sql:
|
|
2526
|
+
// migrations/20260415_retire_old_users.ts
|
|
2527
|
+
export default migration({
|
|
2528
|
+
id: '20260415_retire_old_users',
|
|
2529
|
+
description: 'Move oldUsersTable rows into users, then drop it.',
|
|
2530
|
+
up: async ({ sql }) => {
|
|
2531
|
+
await sql.unsafe(`INSERT INTO users (id, ...) SELECT id, ... FROM "oldUsersTable"`)
|
|
2532
|
+
await sql.unsafe(`DROP TABLE "oldUsersTable"`)
|
|
2533
|
+
},
|
|
2534
|
+
down: async ({ sql }) => { /* recreate + restore as far as possible */ },
|
|
2535
|
+
})
|
|
2536
|
+
|
|
2537
|
+
// Option C — one-off destructive apply:
|
|
2538
|
+
// VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring oldUsersTable per ticket #...'
|
|
2539
|
+
```
|
|
2540
|
+
|
|
2541
|
+
**Two tables this never proposes dropping:**
|
|
2542
|
+
|
|
2543
|
+
- **`actors`** — the framework-provided audit subject. You don't declare an
|
|
2544
|
+
`actors.entity.ts`; `db plan` / `db apply` auto-include the built-in `actors`
|
|
2545
|
+
in the declared set, so it's never a DROP candidate. (Declare your own
|
|
2546
|
+
`actors` with extra columns and that takes precedence.)
|
|
2547
|
+
- **Your own UNMANAGED infra tables** — a table you keep outside the Voltro
|
|
2548
|
+
schema (a migration id-map, a legacy audit table). List them in
|
|
2549
|
+
`VOLTRO_DB_IGNORE_TABLES` (comma-separated) and the diff leaves them alone
|
|
2550
|
+
instead of planning a DROP:
|
|
2551
|
+
|
|
2552
|
+
```bash
|
|
2553
|
+
VOLTRO_DB_IGNORE_TABLES=_strapi_id_map,_legacy_audit voltro db apply
|
|
2554
|
+
```
|
|
2555
|
+
|
|
2556
|
+
The **same env var is honoured by the `voltro dev` boot auto-migrate**, not
|
|
2557
|
+
just the `db plan` / `db apply` CLI — set it in the app's environment and the
|
|
2558
|
+
boot diff leaves the listed tables alone too, so a fresh clone with a
|
|
2559
|
+
Strapi→Voltro `_strapi_id_map` sitting in the DB won't refuse-to-boot on a
|
|
2560
|
+
`drop-table`. (The framework already self-excludes its own `_voltro_*` /
|
|
2561
|
+
`cluster_*` runtime tables; this is the user list on top of that.)
|
|
2562
|
+
|
|
2563
|
+
## A migrate / apply DDL statement failed — find which one
|
|
2564
|
+
|
|
2565
|
+
When `voltro migrate` / `db apply` hits a DDL error, the CLI names the **failing
|
|
2566
|
+
statement** plus the driver's fields, not just a stack:
|
|
2567
|
+
|
|
2568
|
+
```
|
|
2569
|
+
═══ migrate: statement failed ═══
|
|
2570
|
+
statement: CREATE INDEX "users_orgId_idx" ON "users" ("orgId")
|
|
2571
|
+
db.message: column "orgId" does not exist
|
|
2572
|
+
db.code: 42703
|
|
2573
|
+
```
|
|
2574
|
+
|
|
2575
|
+
A `column … does not exist` on a `CREATE INDEX` usually means the column was
|
|
2576
|
+
never added to an EXISTING table: `voltro migrate` (auto-migrate) is `CREATE
|
|
2577
|
+
TABLE IF NOT EXISTS` — it does NOT `ADD COLUMN` to a table that already exists.
|
|
2578
|
+
To evolve an existing table's columns, use the declarative path (`voltro db
|
|
2579
|
+
plan` → `db apply`), which orders `ADD COLUMN` before the index. Set
|
|
2580
|
+
`VOLTRO_MIGRATE_DEBUG=1` to trace every statement as it executes.
|
|
2581
|
+
|
|
2582
|
+
## "users.givenName looks like a new required column on a populated table"
|
|
2583
|
+
|
|
2584
|
+
Full error:
|
|
2585
|
+
|
|
2586
|
+
```
|
|
2587
|
+
✗ ALTER TABLE users ADD COLUMN givenName text # NOT NULL column on a table whose row count is unknown
|
|
2588
|
+
```
|
|
2589
|
+
|
|
2590
|
+
What probably happened: you renamed `firstName` → `givenName` in the schema, but didn't tell the planner it's a rename. The planner sees `firstName` gone + `givenName` new + classifies each separately.
|
|
2591
|
+
|
|
2592
|
+
Fix:
|
|
2593
|
+
|
|
2594
|
+
```ts
|
|
2595
|
+
export const users = table('users', {
|
|
2596
|
+
id: id(),
|
|
2597
|
+
givenName: text().renamedFrom('firstName'),
|
|
2598
|
+
})
|
|
2599
|
+
```
|
|
2600
|
+
|
|
2601
|
+
The planner folds the diff into one `ALTER TABLE users RENAME COLUMN firstName TO givenName`, classified `safe`. After the rename is applied in every env, the marker can be removed (covered in [rename-and-drop.md](./rename-and-drop.md)).
|
|
2602
|
+
|
|
2603
|
+
## "Schema fingerprint mismatch" (prod refuse)
|
|
2604
|
+
|
|
2605
|
+
Full error:
|
|
2606
|
+
|
|
2607
|
+
```
|
|
2608
|
+
[voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
|
|
2609
|
+
declared = a8f2c9d10b3f4e62
|
|
2610
|
+
live = 8f507ba1e1aadad5
|
|
2611
|
+
Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
|
|
2612
|
+
exit 1
|
|
2613
|
+
```
|
|
2614
|
+
|
|
2615
|
+
What happened: the production runtime checked its declared schema's fingerprint against the latest `_voltro_migration_plans.fingerprint` and they don't match. The framework refuses to start serving because it doesn't know what to do — auto-apply on prod isn't allowed (see [prod-pipeline.md](./prod-pipeline.md)).
|
|
2616
|
+
|
|
2617
|
+
Fix:
|
|
2618
|
+
|
|
2619
|
+
1. Preview the plan against the prod DB shape (NOT prod credentials —
|
|
2620
|
+
use a staging-replica snapshot):
|
|
2621
|
+
|
|
2622
|
+
```sh
|
|
2623
|
+
DB_URL=<staging-snapshot-url> voltro db plan
|
|
2624
|
+
```
|
|
2625
|
+
|
|
2626
|
+
2. Review the printed plan in the PR.
|
|
2627
|
+
|
|
2628
|
+
3. Run apply from the CI/CD step (a one-shot job with migration
|
|
2629
|
+
credentials and `NODE_ENV` unset / `staging` — `voltro db apply`
|
|
2630
|
+
re-diffs live and refuses on `NODE_ENV=production`):
|
|
2631
|
+
|
|
2632
|
+
```sh
|
|
2633
|
+
voltro db apply --note 'PR #1234'
|
|
2634
|
+
```
|
|
2635
|
+
|
|
2636
|
+
4. Re-deploy. The new boot's fingerprint check passes.
|
|
2637
|
+
|
|
2638
|
+
Note: the boot-mismatch message itself prints
|
|
2639
|
+
`voltro db apply --plan plan.json`, but `--plan` is not a real flag —
|
|
2640
|
+
the prod apply is a plain `voltro db apply` that recomputes the diff.
|
|
2641
|
+
|
|
2642
|
+
If the cause is drift (someone DDL'd prod manually), see [drift.md](./drift.md) for reconciliation paths.
|
|
2643
|
+
|
|
2644
|
+
## "duplicate column name in plan" (logically invalid plan)
|
|
2645
|
+
|
|
2646
|
+
```
|
|
2647
|
+
db apply: refusing — plan contains 2 ops targeting users.email (CREATE + DROP)
|
|
2648
|
+
This usually means the planner couldn't determine the correct order.
|
|
2649
|
+
Hand-edit the plan JSON or use a file-based migration to express the intent explicitly.
|
|
2650
|
+
```
|
|
2651
|
+
|
|
2652
|
+
What happened: the diff produced both an ADD and DROP for the same column → ambiguous intent.
|
|
2653
|
+
|
|
2654
|
+
Fix: it's almost always a schema edit ordering problem. Either:
|
|
2655
|
+
|
|
2656
|
+
- The schema was edited twice + both edits are in the diff (rebase the PR; squash the two commits)
|
|
2657
|
+
- A column was renamed + another column was added with the same name (use `.renamedFrom()` on the second)
|
|
2658
|
+
- A file-based migration is racing the planner (sequence them differently — file before planner)
|
|
2659
|
+
|
|
2660
|
+
## "VOLTRO_DESTRUCTIVE_OK relaxes lossy ops only; plan also contains rename-without-marker"
|
|
2661
|
+
|
|
2662
|
+
```
|
|
2663
|
+
auto-migrate: REFUSED — 2 blocked operation(s):
|
|
2664
|
+
- drop-column [users.legacy]: lossy
|
|
2665
|
+
fix: if intentional, add `legacy: dropped()` to the schema
|
|
2666
|
+
- rename-column [users.givenName from firstName]: rename without marker
|
|
2667
|
+
fix: declare `.renamedFrom('firstName')` on the new column
|
|
2668
|
+
|
|
2669
|
+
VOLTRO_DESTRUCTIVE_OK=1 was set but at least one blocked op is NOT lossy.
|
|
2670
|
+
The flag only relaxes lossy ops; other refuse cases (rename, NOT-NULL-no-backfill, multi-step) stay firm.
|
|
2671
|
+
```
|
|
2672
|
+
|
|
2673
|
+
What happened: you reached for `VOLTRO_DESTRUCTIVE_OK=1` to bypass a refuse, but the plan has a non-lossy refuse too. The flag is intentionally narrow.
|
|
2674
|
+
|
|
2675
|
+
Fix: address the non-lossy refuse first (add the rename annotation in the example above). Then the flag relaxes the remaining lossy op.
|
|
2676
|
+
|
|
2677
|
+
## "live introspection failed; cannot diff"
|
|
2678
|
+
|
|
2679
|
+
```
|
|
2680
|
+
db plan: live introspection failed
|
|
2681
|
+
cause: Connection refused at localhost:5432
|
|
2682
|
+
```
|
|
2683
|
+
|
|
2684
|
+
What happened: the framework can't reach the DB. The planner needs a live introspection to compute the diff.
|
|
2685
|
+
|
|
2686
|
+
Fix:
|
|
2687
|
+
|
|
2688
|
+
- Check the DB is running (`docker ps`, `systemctl status postgres`)
|
|
2689
|
+
- Check the connection URL: `echo $DB_URL` matches what the DB expects
|
|
2690
|
+
- Check credentials: `psql $DB_URL -c 'SELECT 1'` should succeed
|
|
2691
|
+
- If using cloud, check the inspectToken: `curl -H "Authorization: Bearer $TOKEN" "$APP_URL/_voltro/inspect/app"` should return JSON
|
|
2692
|
+
|
|
2693
|
+
## "advisory lock held; refusing to wait"
|
|
2694
|
+
|
|
2695
|
+
```
|
|
2696
|
+
db apply: refusing — advisory lock 8732891 is held by another process (pid 4892)
|
|
2697
|
+
This usually means another `voltro db apply` is running. Wait for it to finish or kill the holder.
|
|
2698
|
+
```
|
|
2699
|
+
|
|
2700
|
+
Fix:
|
|
2701
|
+
|
|
2702
|
+
- If a real apply is running elsewhere, wait
|
|
2703
|
+
- If the holder is stuck (`pid 4892` died without releasing):
|
|
2704
|
+
- Postgres: `SELECT pg_advisory_unlock(8732891);` (run as the same user that acquired)
|
|
2705
|
+
- Or kill the postgres backend: `SELECT pg_terminate_backend(<pid>)`
|
|
2706
|
+
- For MySQL: `SELECT RELEASE_LOCK('voltro_migration')` from the same connection (different connection won't release)
|
|
2707
|
+
|
|
2708
|
+
The lock is per-database-cluster, not per-deploy. Two prod regions hitting the same DB cluster race; the second blocks until the first releases.
|
|
2709
|
+
|
|
2710
|
+
## `voltro dev` boot hangs at "auto-migrate: planning schema" (0/1, no error)
|
|
2711
|
+
|
|
2712
|
+
The boot-time auto-migrate takes the **same** migration advisory lock as `db apply`. If a prior boot crashed while holding it (its DB connection still open) or a sibling pod holds it, the boot would otherwise wait on the lock — the pod sits at `auto-migrate: planning schema`, readiness never flips, and no error line prints.
|
|
2713
|
+
|
|
2714
|
+
The boot now **fails fast** instead of hanging: it polls the lock to a deadline (default **30s**) and then aborts with a clear message rather than blocking forever.
|
|
2715
|
+
|
|
2716
|
+
```
|
|
2717
|
+
could not acquire the postgres migration advisory lock within 30s.
|
|
2718
|
+
Another migration is in progress, or a prior boot crashed while holding it.
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
Fix:
|
|
2722
|
+
|
|
2723
|
+
- A crashed process's **session-level** advisory lock is released the moment its DB connection closes — so a truly dead holder frees the lock on its own; just restart.
|
|
2724
|
+
- If a live-but-stuck backend holds it, find + terminate it: `SELECT pid, query FROM pg_stat_activity WHERE query LIKE '%advisory%'` → `SELECT pg_terminate_backend(<pid>)`.
|
|
2725
|
+
- Long, legitimate migrations on a big schema can outlast 30s — raise the ceiling with **`VOLTRO_MIGRATION_LOCK_TIMEOUT_MS`** (milliseconds).
|
|
2726
|
+
|
|
2727
|
+
Related: if the boot instead REFUSES with a `drop-table` blocker for a table you want to keep (a `_strapi_id_map`-style leftover), that's the ["table missing from declared schema"](#table-missing-from-declared-schema-drop-table-refused) case — `VOLTRO_DB_IGNORE_TABLES` unfreezes it.
|
|
2728
|
+
|
|
2729
|
+
### Variant: it hangs even with the lock free (large / FK-dense schema)
|
|
2730
|
+
|
|
2731
|
+
Same symptom, different cause. If nothing else holds the lock and the boot **still** sits at `auto-migrate: planning schema`, the **schema introspection** is the bottleneck — the step that reads the live database shape before diffing. It only runs on a real diff (a no-diff boot skips it via the schema fingerprint), which is why adding a single column can trigger it while an unchanged restart boots fine.
|
|
2732
|
+
|
|
2733
|
+
The cause is almost always a **large, foreign-key-dense schema** (hundreds of tables, thousands of FKs). Introspection reads foreign keys and primary keys directly from `pg_catalog` (index-backed, filter pushed down) rather than the `information_schema` constraint views — those can't push the per-batch table filter down, so each batch re-scans the whole catalog. On a 500-table / 2600-FK schema that is the difference between **> 2 minutes (hangs)** and **well under a second**.
|
|
2734
|
+
|
|
2735
|
+
If introspection ever degenerates again it **fails fast** instead of hanging: every introspection statement runs under a `statement_timeout` (default **30s**), so a runaway query aborts with an actionable error rather than freezing the pod at 0/1.
|
|
2736
|
+
|
|
2737
|
+
```text
|
|
2738
|
+
schema introspection exceeded VOLTRO_INTROSPECT_TIMEOUT_MS (30000ms) — the schema is
|
|
2739
|
+
very large / FK-dense or the database is slow.
|
|
2740
|
+
```
|
|
2741
|
+
|
|
2742
|
+
Fix:
|
|
2743
|
+
|
|
2744
|
+
- Raise the ceiling for a legitimately huge schema with **`VOLTRO_INTROSPECT_TIMEOUT_MS`** (milliseconds; `0` disables it entirely).
|
|
2745
|
+
- Prefer a **direct (non-pooler) connection** for migrations via **`DB_DIRECT_URL`** — so a large introspection response isn't mis-framed by a transaction-mode pooler.
|
|
2746
|
+
- **`VOLTRO_DB_IGNORE_TABLES` does not help here** — it filters the *diff*, which runs **after** introspection; the introspection cost is independent of it.
|
|
2747
|
+
|
|
2748
|
+
## A MySQL/MariaDB apply failed midway
|
|
2749
|
+
|
|
2750
|
+
`voltro db apply` on **postgres / mssql** is ATOMIC: every op runs in ONE
|
|
2751
|
+
transaction, so a failure on op N rolls the WHOLE plan back — nothing is
|
|
2752
|
+
committed, no half-applied schema. (`online-required` `CREATE INDEX
|
|
2753
|
+
CONCURRENTLY` ops run after the commit — they can't be in a transaction —
|
|
2754
|
+
so a failure THERE can leave the index half-built; re-apply finishes it.)
|
|
2755
|
+
|
|
2756
|
+
> **Run migrations through a SESSION connection, not a transaction-mode
|
|
2757
|
+
> pooler.** Because the whole plan is one transaction, a large apply (many
|
|
2758
|
+
> ops + big backfill `UPDATE`s + index builds) is ONE long-lived
|
|
2759
|
+
> transaction. A transaction-mode pooler (Supabase Supavisor on `:6543`,
|
|
2760
|
+
> PgBouncer in `transaction` mode) can't hold a multi-statement transaction
|
|
2761
|
+
> reliably and will abort it — surfacing as an opaque `Failed to execute
|
|
2762
|
+
> statement (at sql.transaction)`. Point `DB_URL` at the **direct / session
|
|
2763
|
+
> connection** (`:5432`, or a session-mode pooler) for `db apply`; raise
|
|
2764
|
+
> `statement_timeout` for that session if a single index build is slow. This
|
|
2765
|
+
> is the same constraint every migration tool has (Prisma/Drizzle/etc.) —
|
|
2766
|
+
> the transaction pooler is for app traffic, the direct connection is for
|
|
2767
|
+
> migrations. The failing statement itself is now logged with its SQL +
|
|
2768
|
+
> `db.code` (e.g. `57014` statement timeout) so you can see which op stalled.
|
|
2769
|
+
|
|
2770
|
+
**MySQL and MariaDB** (and sqlite / turso) implicit-commit every DDL
|
|
2771
|
+
statement, so THERE a plan that fails on op N leaves ops 1..N-1 committed.
|
|
2772
|
+
There is no `--resume` / `--abort` flag and no per-op partial-status
|
|
2773
|
+
tracking — the apply records a row only on full success.
|
|
2774
|
+
|
|
2775
|
+
Recovery is just to re-run the apply: `voltro db apply` re-diffs the
|
|
2776
|
+
declared schema against the current (half-applied) live shape and emits
|
|
2777
|
+
only the ops that are still missing. Fix the cause of the failed op
|
|
2778
|
+
first (e.g. the `ER_DUP_KEYNAME` that stopped op N), then:
|
|
2779
|
+
|
|
2780
|
+
```sh
|
|
2781
|
+
voltro db apply --note 'completing partial apply after fixing op N'
|
|
2782
|
+
```
|
|
2783
|
+
|
|
2784
|
+
If a deploy reverted the code that referenced the half-applied schema,
|
|
2785
|
+
the re-diff naturally reflects the new declared shape — no separate
|
|
2786
|
+
abort step is needed; the next `voltro db plan` already shows the
|
|
2787
|
+
correct remaining work. See [multi-dialect](./multi-dialect.md).
|
|
2788
|
+
|
|
2789
|
+
## `relation "..._uq" already exists` (42P07) on re-apply
|
|
2790
|
+
|
|
2791
|
+
A composite (multi-column) `.unique([a, b])` constraint that ALREADY
|
|
2792
|
+
exists in the DB is now introspected (postgres reads it back from
|
|
2793
|
+
`pg_constraint`), so `db apply` matches it against the declared schema
|
|
2794
|
+
and emits nothing. On older builds it wasn't read back, so the planner
|
|
2795
|
+
re-emitted `ADD CONSTRAINT … UNIQUE` for the existing one → `42P07
|
|
2796
|
+
relation "<name>_uq" already exists`, and the plan never reached "up to
|
|
2797
|
+
date". If you see this, update the framework. (Single-column `.unique()`
|
|
2798
|
+
was never affected — it round-trips via the column's `unique` flag.)
|
|
2799
|
+
|
|
2800
|
+
## `db plan` keeps showing `CREATE INDEX` for indexes that already exist
|
|
2801
|
+
|
|
2802
|
+
If `db plan` always lists `add-index` for `expressionIndex(...)` /
|
|
2803
|
+
`jsonIndex(...)` indexes that demonstrably exist in the DB — and you
|
|
2804
|
+
never see a matching `drop-index` — that's an introspection gap (now
|
|
2805
|
+
fixed). On postgres an expression key carries a `0` in `pg_index.indkey`
|
|
2806
|
+
(it has no backing column), and the old introspect query INNER-joined
|
|
2807
|
+
`pg_attribute` on the column → the whole index disappeared from the live
|
|
2808
|
+
snapshot. The declared index then had nothing to match → re-emitted every
|
|
2809
|
+
run, but never converged to "up to date". (`CREATE INDEX … IF NOT EXISTS`
|
|
2810
|
+
made each re-emit a silent no-op, so it wasn't data-destructive — just a
|
|
2811
|
+
plan that never went empty.) The fix introspects expression indexes (with
|
|
2812
|
+
a NULL column + an `expression` flag) and matches them by NAME +
|
|
2813
|
+
uniqueness, since the DB normalises the expression text
|
|
2814
|
+
(`(lower("email"))` → `lower(email)`) and it can't round-trip
|
|
2815
|
+
byte-for-byte. Plain-column indexes were never affected. If you see this,
|
|
2816
|
+
update the framework.
|
|
2817
|
+
|
|
2818
|
+
**Second cause — the same table name in two schemas.** If the phantom
|
|
2819
|
+
`add-index` is for PLAIN-column indexes (often camelCase like
|
|
2820
|
+
`"<table>_tenantId_idx"`) and your DB has the SAME table names in more than
|
|
2821
|
+
one schema — classically a `public` legacy/migration copy alongside the
|
|
2822
|
+
app's own schema (e.g. `voltro`) — that was a separate introspection bug
|
|
2823
|
+
(now fixed). The table-list query joined `pg_class` by NAME, so a name
|
|
2824
|
+
present in both schemas fanned out to two rows → the table was listed twice
|
|
2825
|
+
→ its columns and index-columns were accumulated twice in the BUILT snapshot
|
|
2826
|
+
(`["tenantId"]` became `["tenantId","tenantId"]`) → the planner diffed
|
|
2827
|
+
`["tenantId"] != ["tenantId","tenantId"]` and re-emitted forever. The raw
|
|
2828
|
+
`pg_*` catalog looks correct (the duplication is in introspect's built
|
|
2829
|
+
output, not the SQL) — to confirm it's THIS, call the inspect endpoint and
|
|
2830
|
+
look for doubled columns: `curl "$API/_voltro/inspect/migrations" | jq
|
|
2831
|
+
'.drift.liveSnapshot.tables[] | select(.name=="<table>") | .indexes'`. The
|
|
2832
|
+
fix scopes the table list to `current_schema()` by OID (+ defensive dedup),
|
|
2833
|
+
so each table is read once. Point `DB_SCHEMA` / the connection's
|
|
2834
|
+
`search_path` at your app schema and update the framework.
|
|
2835
|
+
|
|
2836
|
+
## `duplicate index name '<name>' across tables '<a>' and '<b>'`
|
|
2837
|
+
|
|
2838
|
+
Index names are unique **per schema**, not per table, in every dialect. If
|
|
2839
|
+
you gave the SAME explicit name to indexes on two different tables
|
|
2840
|
+
(`.index('byStatusStart', …)` on both `ab_tests` and `tournaments`), boot /
|
|
2841
|
+
`db plan` now fails loud with this error instead of silently creating only
|
|
2842
|
+
one and re-emitting the rest forever. Fix: rename the collisions to
|
|
2843
|
+
distinct, table-scoped names (`abTestsByStatusStart`,
|
|
2844
|
+
`tournamentsByStatusStart`). Auto-named indexes (`.index([col])` →
|
|
2845
|
+
`<table>_<col>_idx`) are table-prefixed and never collide — only hand-picked
|
|
2846
|
+
names can. (If you're updating from an older build that let these through,
|
|
2847
|
+
expect this error on first boot for every pre-existing collision — rename
|
|
2848
|
+
each one it names.)
|
|
2849
|
+
|
|
2850
|
+
## `auto-named index '<table>_<col>_idx' … exceeds the 63-byte … limit`
|
|
2851
|
+
|
|
2852
|
+
The framework derives an FK auto-index name from the table + column name
|
|
2853
|
+
(`<table>_<col>_idx`). On a long junction table that can exceed 63 bytes —
|
|
2854
|
+
and the DB **silently truncates** index names (postgres → 63 bytes, dropping
|
|
2855
|
+
the `_idx` suffix), so the declared name (`…_idx`) never matches the live
|
|
2856
|
+
(truncated) one and `db plan` re-emits it forever. The explicit-index path
|
|
2857
|
+
was always length-validated; this closes the gap for the **auto** path —
|
|
2858
|
+
it now hard-fails at boot (same policy as every other identifier: no silent
|
|
2859
|
+
truncation). Two fixes, your choice:
|
|
2860
|
+
|
|
2861
|
+
- Add an explicit short name for that FK column — `.index('<short>',
|
|
2862
|
+
['<col>'])` — which replaces the auto-index, OR
|
|
2863
|
+
- Shorten the table / column name.
|
|
2864
|
+
|
|
2865
|
+
(Updating from an older build that truncated these? Expect the error on
|
|
2866
|
+
first boot for each one — apply one of the two fixes per index it names.)
|
|
2867
|
+
|
|
2868
|
+
## `db plan` re-emits `alter-column-default` for a `json().default({…})` column
|
|
2869
|
+
|
|
2870
|
+
A `json()` column with an OBJECT default (`json<T>().default({ a: 1 })`) had
|
|
2871
|
+
two problems on postgres (both now fixed): (1) the default was **silently
|
|
2872
|
+
dropped** — the DDL emitter only handled scalar defaults, so the column got
|
|
2873
|
+
no default at all (an omitted field inserted `NULL`, not the object); and
|
|
2874
|
+
(2) even once present, the comparison didn't match — postgres stores a jsonb
|
|
2875
|
+
default in canonical text (`'{"a": 1}'::jsonb`: spaces after `:`/`,` and keys
|
|
2876
|
+
reordered by length/bytes), which never equals the declared JS object's
|
|
2877
|
+
`JSON.stringify`, so `db plan` re-emitted `alter-column-default` every run.
|
|
2878
|
+
The fix emits object defaults as `'<json>'::jsonb` AND canonicalises both
|
|
2879
|
+
sides (key-sorted, space-free) before comparing. Update the framework.
|
|
2880
|
+
|
|
2881
|
+
Cross-dialect: object literal defaults are now emitted to DDL on **every**
|
|
2882
|
+
dialect, in each one's json idiom — postgres `'<json>'::jsonb`, mysql/mariadb
|
|
2883
|
+
`(CAST('<json>' AS JSON))` (a literal default is rejected on a JSON column),
|
|
2884
|
+
mssql/sqlite a `'<json>'` string literal — and the comparison canonicalises
|
|
2885
|
+
each engine's introspected form (postgres reorders + spaces; mysql wraps in
|
|
2886
|
+
`cast(…)`; mssql wraps in `('…')`). Arrays are unaffected (an array default
|
|
2887
|
+
stays as-is — its `json[]` vs native `array()` column is ambiguous). If you
|
|
2888
|
+
need a per-insert dynamic value instead of a fixed literal, use a factory
|
|
2889
|
+
`.default(() => ({ … }))` (the store applies it at insert).
|
|
2890
|
+
|
|
2891
|
+
## MySQL `db plan` / `db apply` crashes: `Cannot read properties of undefined (reading 'toLowerCase')`
|
|
2892
|
+
|
|
2893
|
+
MySQL 8 returns `information_schema` result columns in UPPERCASE
|
|
2894
|
+
(`DATA_TYPE`, `COLUMN_NAME`, …) where MariaDB returns lowercase. The
|
|
2895
|
+
introspector read the lowercase fields, so on MySQL the type mapper got an
|
|
2896
|
+
`undefined` data type and the whole introspect (every `db plan` / `db apply`)
|
|
2897
|
+
crashed. Fixed — the introspector now lowercases each `information_schema`
|
|
2898
|
+
row's keys (no-op on MariaDB). If you hit this on MySQL, update the framework.
|
|
2899
|
+
(MariaDB was never affected, which is why it went unnoticed — the
|
|
2900
|
+
introspect tests run on MariaDB.)
|
|
2901
|
+
|
|
2902
|
+
## "no schema files found"
|
|
2903
|
+
|
|
2904
|
+
```
|
|
2905
|
+
db plan: no schema files found
|
|
2906
|
+
hint: looked for *.entity.ts / *.schema.ts / schema.ts
|
|
2907
|
+
root: /home/me/myproject/apps/api
|
|
2908
|
+
```
|
|
2909
|
+
|
|
2910
|
+
What happened: the discovery walker didn't find any schema files under the project root.
|
|
2911
|
+
|
|
2912
|
+
Fix:
|
|
2913
|
+
|
|
2914
|
+
- Check you're running the command from the right directory (`pwd`)
|
|
2915
|
+
- Check your entity files match the convention (`apps/api/database/*.entity.ts`)
|
|
2916
|
+
- Run from the project dir, or pass the path as a POSITIONAL arg
|
|
2917
|
+
(`voltro db plan ./apps/api`) — there is no `--root` flag; the CLI
|
|
2918
|
+
resolves the root from the first non-flag argument, defaulting to
|
|
2919
|
+
the current working directory
|
|
2920
|
+
|
|
2921
|
+
## When the fix hint doesn't match reality
|
|
2922
|
+
|
|
2923
|
+
The fix hints come from the planner's classification logic — they should always be actionable. If you see one that doesn't make sense given your code:
|
|
2924
|
+
|
|
2925
|
+
1. Check git: were there uncommitted schema changes you forgot about? `git status`
|
|
2926
|
+
2. Check the introspect output: `curl <app-url>/_voltro/inspect/migrations | jq '.pending'` → see the raw plan
|
|
2927
|
+
3. File an issue with the schema + the inspect JSON + the message
|
|
2928
|
+
|
|
2929
|
+
Hint mismatches are bugs in the planner's classification — they're rare but always worth reporting because they're typically reproducible.
|
|
2930
|
+
|
|
2931
|
+
## Where to learn more
|
|
2932
|
+
|
|
2933
|
+
- [Operation classes](./operation-classes.md) — the seven classes + per-class examples
|
|
2934
|
+
- [Backfill](./backfill.md) — SQL vs JS + the dry-run pattern
|
|
2935
|
+
- [Rename and drop](./rename-and-drop.md) — the `.renamedFrom()` + `dropped()` lifecycle
|
|
2936
|
+
- [Drift](./drift.md) — when the live DB diverged
|
|
2937
|
+
- [Multi-dialect strategy](./multi-dialect.md) — why MySQL + forward-roll
|
|
2938
|
+
- [Prod pipeline](./prod-pipeline.md) — the deploy-step apply pattern
|