From 873554568f3d4bfd48a8149b158fa8c0a9708ab3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 21:06:37 +0200 Subject: [PATCH 1/5] TML-3475: migration plan and db update rename a model or field stated with --rename, instead of dropping it Squash of the 152 commits of prisma/orm#30638 onto main (93b49ceffb). The unsquashed history is on the branch tml-3475-statement-renames-unsquashed (355cca5342). Signed-off-by: willbot Signed-off-by: Will Madden --- .../adrs/ADR 001 - Migrations as Edges.md | 2 + ... 028 - Migration Structure & Operations.md | 2 + ...ntified indexes and exact-name adoption.md | 2 + ...aints are opaque wire-named expressions.md | 2 + ... verbatim, and a rename is an operation.md | 5 + .../subsystems/1. Data Contract.md | 13 +- .../subsystems/5. Adapters & Targets.md | 2 +- .../subsystems/7. Migration System.md | 53 +- .../subsystems/9. No-Emit Workflow.md | 2 +- docs/reference/error-reference.md | 18 +- .../prisma7-adoption/test/handover.test.ts | 2 +- .../1-framework/1-core/errors/src/control.ts | 27 +- .../1-core/errors/src/exports/migration.ts | 1 + .../1-core/errors/src/migration.ts | 12 + .../1-core/errors/test/control.test.ts | 44 ++ .../1-core/errors/test/migration.test.ts | 24 + .../src/control/contract-snapshot-layout.ts | 7 +- .../src/control/control-migration-types.ts | 84 +- .../src/control/migration-statements.ts | 127 +++ .../src/exports/control.ts | 19 + .../test/contract-snapshot-layout.test.ts | 11 + .../test/control-migration-types.test.ts | 3 + .../test/migration-statements.test.ts | 136 ++++ packages/1-framework/3-tooling/cli/README.md | 14 +- .../3-tooling/cli/src/control-api/client.ts | 1 + .../cli/src/control-api/operations/db-run.ts | 134 +++- .../src/control-api/operations/db-update.ts | 14 +- .../cli/src/control-api/operations/migrate.ts | 1 + .../control-api/operations/migration-plan.ts | 191 ++++- .../control-api/operations/ref-resolution.ts | 3 + .../control-api/statements/parse-rename.ts | 95 +++ .../statements/report-applied-statements.ts | 35 + .../statements/resolve-statements.ts | 727 ++++++++++++++++++ .../control-api/statements/statement-text.ts | 16 + .../src/control-api/testing/fixture-client.ts | 1 + .../3-tooling/cli/src/control-api/types.ts | 10 + .../3-tooling/cli/src/exports/control-api.ts | 8 + .../cli/src/orm/db/migration-blocks.ts | 3 + .../3-tooling/cli/src/orm/db/update.ts | 9 + .../3-tooling/cli/src/orm/migration/plan.ts | 18 +- .../3-tooling/cli/src/orm/statement-blocks.ts | 33 + .../3-tooling/cli/src/utils/cli-errors.ts | 156 +++- .../cli/src/utils/formatters/migrations.ts | 3 + .../3-tooling/cli/test/cli-errors.test.ts | 6 +- .../cli/test/control-api/client.test.ts | 1 + .../cli/test/control-api/db-init.test.ts | 11 +- .../db-update-statement-positions.test.ts | 211 +++++ .../control-api/db-update-statements.test.ts | 395 ++++++++++ .../cli/test/control-api/db-update.test.ts | 7 + .../cli/test/control-api/progress.test.ts | 3 + .../statements/parse-rename.test.ts | 87 +++ .../resolve-field-statements.test.ts | 276 +++++++ .../resolve-model-statements.test.ts | 315 ++++++++ .../resolve-statement-advice.test.ts | 154 ++++ .../statements/statement-fixtures.ts | 98 +++ .../3-tooling/cli/test/migration-cli.test.ts | 6 +- .../3-tooling/cli/test/orm/db-init.test.ts | 1 + .../test/orm/db-update-to-resolution.test.ts | 5 + .../3-tooling/cli/test/orm/db-update.test.ts | 74 ++ .../cli/test/orm/fixtures/offline-project.ts | 50 +- .../orm/migration-plan-statements.test.ts | 351 +++++++++ .../cli/test/statement-errors.test.ts | 67 ++ .../migration/src/aggregate/planner-types.ts | 17 + .../migration/src/aggregate/planner.ts | 16 + .../aggregate/strategies/plan-from-diff.ts | 10 +- .../strategies/resolve-recorded-path.ts | 1 + .../migration/src/exports/aggregate.ts | 1 + .../3-tooling/migration/src/migration-base.ts | 21 +- .../migration/test/aggregate/planner.test.ts | 148 +++- .../strategies/plan-from-diff.test.ts | 53 +- .../migration/test/migration-base.test.ts | 23 + packages/2-sql/9-family/src/core/errors.ts | 1 + .../src/core/migrations/apply-table-rename.ts | 270 ------- .../core/migrations/field-event-planner.ts | 48 +- .../src/core/migrations/plan-helpers.ts | 13 + .../core/migrations/resolve-column-rename.ts | 143 ++++ .../core/migrations/resolve-table-rename.ts | 119 +++ .../src/core/migrations/schema-tables.ts | 19 + .../src/core/migrations/statement-planning.ts | 555 +++++++++++++ .../core/migrations/table-name-case-guard.ts | 19 +- .../9-family/src/core/migrations/types.ts | 17 +- .../2-sql/9-family/src/core/sql-migration.ts | 4 +- .../2-sql/9-family/src/exports/control.ts | 46 +- .../9-family/test/apply-table-rename.test.ts | 327 -------- .../9-family/test/field-event-fixtures.ts | 124 +++ ...ield-event-planner.renamed-columns.test.ts | 58 ++ ...field-event-planner.renamed-tables.test.ts | 57 ++ .../9-family/test/field-event-planner.test.ts | 174 ++--- .../9-family/test/migrations.types.test.ts | 8 +- .../test/resolve-column-rename.test.ts | 156 ++++ .../test/resolve-table-rename.test.ts | 169 ++++ .../2-sql/9-family/test/statement-fixtures.ts | 238 ++++++ .../test/statement-planning.fields.test.ts | 268 +++++++ .../test/statement-planning.refusals.test.ts | 145 ++++ .../9-family/test/statement-planning.test.ts | 224 ++++++ .../test/migrations/planner.behavior.test.ts | 8 + .../planner.contract-to-schema-ir.test.ts | 16 + .../migrations/planner.set-default.test.ts | 2 + .../migrations/planner.storage-types.test.ts | 10 + .../vector-list-default.integration.test.ts | 2 + .../core/migrations/migration-factories.ts | 4 + .../src/core/migrations/mongo-planner.ts | 95 ++- .../test/db-update-statements.test.ts | 107 +++ .../test/mongo-planner.statements.test.ts | 128 +++ .../1-mongo-target/test/mongo-planner.test.ts | 25 + .../1-mongo-target/test/render-ops.test.ts | 12 + .../column-rename-constraint-renames.ts | 22 + .../migrations/index-and-check-renames.ts | 4 +- .../src/core/migrations/issue-planner.ts | 3 +- .../src/core/migrations/op-factory-call.ts | 130 +++- .../src/core/migrations/operations/columns.ts | 54 +- .../core/migrations/operations/constraints.ts | 15 +- .../src/core/migrations/operations/indexes.ts | 12 +- .../operations/native-enum-types.ts | 2 +- .../src/core/migrations/operations/rls.ts | 4 +- .../src/core/migrations/operations/tables.ts | 6 +- .../planner-produced-postgres-migration.ts | 7 +- .../postgres/src/core/migrations/planner.ts | 112 ++- .../src/core/migrations/postgres-migration.ts | 146 +++- .../core/migrations/rename-rls-references.ts | 55 -- .../src/core/migrations/render-ops.ts | 41 +- .../src/core/migrations/schema-tables.ts | 33 + .../src/core/migrations/table-rename-calls.ts | 161 ++-- .../table-rename-constraint-renames.ts | 159 ++-- .../src/core/migrations/working-schema.ts | 597 ++++++++++++++ .../postgres/src/core/postgres-schema.ts | 8 +- .../postgres/src/exports/op-factory-call.ts | 1 + .../migrations/check-rename-planner.test.ts | 30 +- .../migrations/column-rename-calls.test.ts | 143 ++++ .../full-text-index-planning.test.ts | 2 + .../test/migrations/index-ddl.test.ts | 4 +- .../migrations/index-rename-planner.test.ts | 4 +- .../migrations/native-enum-planner.test.ts | 8 +- .../op-factory-call.lowering.test.ts | 4 +- .../planner.check-constraints.test.ts | 4 +- .../planner.field-statements.test.ts | 250 ++++++ .../planner.operation-positions.test.ts | 133 ++++ .../planner.sibling-scoping.test.ts | 12 + .../migrations/planner.statements.test.ts | 313 ++++++++ .../planner.table-name-case-guard.test.ts | 17 +- .../migrations/rename-column-facade.test.ts | 158 ++++ .../test/migrations/rename-column-fixtures.ts | 214 ++++++ .../test/migrations/rename-column-ops.test.ts | 119 +++ ...ename-table-facade.authoring-state.test.ts | 133 ++++ .../migrations/rename-table-facade.test.ts | 96 ++- .../test/migrations/rename-table-fixtures.ts | 48 +- .../test/migrations/rename-table-ops.test.ts | 81 +- .../test/migrations/render-ops.test.ts | 52 ++ .../test/migrations/render-typescript.test.ts | 4 +- .../migrations/rls-disable-rename-ops.test.ts | 8 +- .../rls-drop-ordering-planner.test.ts | 8 +- .../migrations/rls-enablement-planner.test.ts | 11 +- .../migrations/rls-not-equal-planner.test.ts | 47 +- .../postgres/test/migrations/rls-ops.test.ts | 8 +- .../test/migrations/rls-planner.test.ts | 38 + .../migrations/rls-rename-planner.test.ts | 6 + .../test/migrations/schema-tables.test.ts | 39 + .../migrations/table-rename-calls.test.ts | 82 ++ .../table-rename-constraint-renames.test.ts | 206 +++++ .../test/migrations/working-schema.test.ts | 316 ++++++++ .../postgres/test/postgres-schema.test.ts | 10 + .../sqlite/src/contract-free/checks.ts | 55 ++ .../core/migrations/diff-database-schema.ts | 7 +- .../src/core/migrations/index-replacements.ts | 47 +- .../src/core/migrations/op-factory-call.ts | 164 +++- .../src/core/migrations/operations/columns.ts | 72 +- .../src/core/migrations/operations/indexes.ts | 2 +- .../src/core/migrations/planner-strategies.ts | 3 +- .../sqlite/src/core/migrations/planner.ts | 107 ++- .../sqlite/src/core/migrations/render-ops.ts | 41 +- .../sqlite/src/core/migrations/runner.ts | 3 +- .../src/core/migrations/schema-tables.ts | 25 + .../src/core/migrations/sqlite-migration.ts | 117 ++- .../src/core/migrations/table-rename-calls.ts | 86 ++- .../src/core/migrations/working-schema.ts | 307 ++++++++ .../src/core/sqlite-unbound-database.ts | 3 +- .../sqlite/src/exports/op-factory-call.ts | 1 + .../migrations/nullability-backfill.test.ts | 22 +- .../test/migrations/op-factory-call.test.ts | 11 +- .../migrations/planner-strategies.test.ts | 65 +- .../planner.authoring-surface.test.ts | 16 +- .../planner.case-only-index-names.test.ts | 4 + .../planner.sibling-scoping.test.ts | 10 + .../migrations/planner.statements.test.ts | 196 +++++ .../planner.table-name-case-guard.test.ts | 2 + .../test/migrations/rename-column-fixtures.ts | 164 ++++ .../test/migrations/rename-column.test.ts | 326 ++++++++ ...ename-table-facade.authoring-state.test.ts | 56 ++ .../migrations/rename-table-facade.test.ts | 122 ++- .../test/migrations/rename-table-fixtures.ts | 38 +- .../test/migrations/rename-table-ops.test.ts | 68 +- .../sqlite/test/migrations/render-ops.test.ts | 63 ++ .../test/migrations/schema-tables.test.ts | 30 + .../migrations/table-rename-calls.test.ts | 69 ++ .../test/migrations/working-schema.test.ts | 130 ++++ .../test/sqlite-unbound-database.test.ts | 6 + .../bytea-sql-defaults.integration.test.ts | 2 + .../check-lifecycle-e2e.integration.test.ts | 8 +- ...codec-literal-defaults.integration.test.ts | 2 + .../cross-namespace-fk.integration.test.ts | 2 + .../enum-check-constraint.integration.test.ts | 10 + .../index-boolean-options.integration.test.ts | 2 + .../native-array-columns.integration.test.ts | 14 + ...dd-value.real-postgres.integration.test.ts | 2 + ...ive-enum-lifecycle-e2e.integration.test.ts | 4 + .../non-data-drops-widening.test.ts | 52 ++ .../op-factory-call.construction.test.ts | 4 +- .../op-factory-call.lowering.test.ts | 20 +- .../order-by-enum.integration.test.ts | 2 + ...ored-function-defaults.integration.test.ts | 2 + .../planner.authoring-surface.test.ts | 3 + .../planner.codec-field-event.test.ts | 8 + .../planner.cross-space-fk-ddl.test.ts | 2 + .../test/migrations/planner.fk-config.test.ts | 12 +- .../migrations/planner.integration.test.ts | 10 + ....list-literal-defaults.integration.test.ts | 2 + ...planner.reconciliation.integration.test.ts | 4 + .../migrations/planner.reconciliation.test.ts | 6 + .../planner.referential-actions.test.ts | 2 + .../planner.unique-index-structural.test.ts | 2 + .../planner.uuid.integration.test.ts | 2 + ...atements.hostile-names.integration.test.ts | 249 ++++++ .../rls-exact-drift.integration.test.ts | 12 +- .../rls-lifecycle-e2e.integration.test.ts | 6 + .../rls-migration-plan.integration.test.ts | 4 + .../rls-role-verify.integration.test.ts | 2 + ...erify-extension-issues.integration.test.ts | 2 + ...s-walking-skeleton-psl.integration.test.ts | 2 + .../rls-walking-skeleton.integration.test.ts | 2 + .../runner.basic.integration.test.ts | 4 + .../runner.idempotency.integration.test.ts | 4 + .../runner.ledger.integration.test.ts | 2 + .../runner.marker-cas.integration.test.ts | 2 + .../runner.marker-lock.integration.test.ts | 2 + .../runner.rename-table.integration.test.ts | 2 +- ...nner.unbound-namespace.integration.test.ts | 2 + ...ma-verify.after-runner.integration.test.ts | 2 + ...y.int8-literal-default.integration.test.ts | 2 + ...used-date-time-default.integration.test.ts | 2 + .../sqlite/src/core/control-adapter.ts | 15 +- .../test/migrations/data-type-verify.test.ts | 2 + .../now-default.integration.test.ts | 4 + .../planner-introspection.integration.test.ts | 6 + .../planner.codec-field-event.test.ts | 16 +- .../sqlite/test/migrations/planner.test.ts | 8 + .../rename-statements.hostile-names.test.ts | 195 +++++ .../test/migrations/runner.basic.test.ts | 4 + .../test/migrations/runner.ledger.test.ts | 2 + .../test/migrations/runner.marker-cas.test.ts | 2 + .../migrations/runner.rename-column.test.ts | 83 ++ .../migrations/runner.rename-table.test.ts | 64 +- projects/migration-statements/README.md | 12 + projects/migration-statements/deferred.md | 39 + projects/migration-statements/design-notes.md | 148 ++++ .../manual-qa-reports/2026-10-07-qa-opus.md | 385 ++++++++++ projects/migration-statements/manual-qa.md | 83 ++ projects/migration-statements/plan.md | 81 ++ .../02-statement-grammar-resolver.md | 105 +++ .../03-model-renames-on-sql-planners.md | 88 +++ .../04-column-rename-and-field-statements.md | 92 +++ .../05-flag-origin-contract-output.md | 77 ++ .../06-journeys-upgrade-fragment-docs.md | 83 ++ .../dispatches/08-final-review-fixes.md | 44 ++ .../dispatches/09-qa-and-round-b-followups.md | 29 + .../slices/renames/plan.md | 61 ++ .../slices/renames/spec.md | 113 +++ projects/migration-statements/spec.md | 140 ++++ skills/journey-tests/02b-rename-with-hint.md | 23 - .../02b-rename-with-statement.md | 21 + skills/journey-tests/README.md | 2 +- skills/prisma-8/references/contract.md | 8 +- skills/prisma-8/references/migrations.md | 9 +- .../sqlite/migrations/destructive.test.ts | 9 +- .../sqlite/migrations/fk-preservation.test.ts | 5 +- .../test/sqlite/migrations/harness.ts | 5 + test/integration/test/_harness/mongo.ts | 2 + test/integration/test/_harness/postgres.ts | 2 + ...lts-on-existing-tables.integration.test.ts | 10 +- ...aults-apply-and-verify.integration.test.ts | 10 +- ...aults-apply-and-verify.integration.test.ts | 10 +- test/integration/test/cli-journeys/README.md | 2 + .../rename-statements-migration.e2e.test.ts | 388 ++++++++++ ...me-statements-migration.sqlite.e2e.test.ts | 333 ++++++++ .../postgres-control-policy-planner.test.ts | 4 + .../postgres-issue-planner.test.ts | 8 +- ...ite-date-time-defaults.integration.test.ts | 6 +- .../contract-rename-statements-from.prisma | 27 + .../contract-rename-statements-to.prisma | 27 + .../test/mongo/aggregate-e2e.test.ts | 3 + .../mongo/codec-rehydration-guardrail.test.ts | 2 + .../test/mongo/migration-e2e.test.ts | 17 + .../mongo/migration-m2-vocabulary.test.ts | 3 + .../mongo/migration-psl-authoring.test.ts | 3 + test/integration/test/mongo/runner.test.ts | 3 + .../runner-nullable-lists.test.ts | 2 + .../target-runner/runner-polymorphism.test.ts | 2 + .../runner-schema-verify.test.ts | 2 + .../runner-validator-closed.test.ts | 2 + .../runner-validator-widen.test.ts | 6 + .../mongo/target-runner/runner-wire.test.ts | 5 + .../orm-spaced-column.integration.test.ts | 2 + .../psl-number-defaults.integration.test.ts | 2 + .../planner-golden/planner-ddl-golden.test.ts | 4 + ...-TML-1664-invalid-enum-value-error.test.ts | 2 + ...1664-unknown-enum-value-read-error.test.ts | 2 + ...-unmapped-driver-error-user-facing.test.ts | 2 + .../referential-actions.integration.test.ts | 2 + ...ls-ts-walking-skeleton.integration.test.ts | 2 + ...orm-list-include-nulls.integration.test.ts | 2 + .../orm-list-read.integration.test.ts | 2 + .../psl-list-roundtrip.integration.test.ts | 2 + .../sql-orm-client/integration-helpers.ts | 2 + .../test/utils/journey-test-helpers.ts | 8 + .../app/instructions.md | 23 + .../extension/instructions.md | 75 ++ 315 files changed, 16847 insertions(+), 1416 deletions(-) create mode 100644 packages/1-framework/1-core/framework-components/src/control/migration-statements.ts create mode 100644 packages/1-framework/1-core/framework-components/test/migration-statements.test.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/parse-rename.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/report-applied-statements.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/resolve-statements.ts create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/statement-text.ts create mode 100644 packages/1-framework/3-tooling/cli/src/orm/statement-blocks.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/db-update-statement-positions.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/db-update-statements.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/parse-rename.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-field-statements.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-model-statements.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-statement-advice.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/statement-fixtures.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-plan-statements.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/statement-errors.test.ts delete mode 100644 packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/resolve-column-rename.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/resolve-table-rename.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/schema-tables.ts create mode 100644 packages/2-sql/9-family/src/core/migrations/statement-planning.ts delete mode 100644 packages/2-sql/9-family/test/apply-table-rename.test.ts create mode 100644 packages/2-sql/9-family/test/field-event-fixtures.ts create mode 100644 packages/2-sql/9-family/test/field-event-planner.renamed-columns.test.ts create mode 100644 packages/2-sql/9-family/test/field-event-planner.renamed-tables.test.ts create mode 100644 packages/2-sql/9-family/test/resolve-column-rename.test.ts create mode 100644 packages/2-sql/9-family/test/resolve-table-rename.test.ts create mode 100644 packages/2-sql/9-family/test/statement-fixtures.ts create mode 100644 packages/2-sql/9-family/test/statement-planning.fields.test.ts create mode 100644 packages/2-sql/9-family/test/statement-planning.refusals.test.ts create mode 100644 packages/2-sql/9-family/test/statement-planning.test.ts create mode 100644 packages/3-mongo-target/1-mongo-target/test/db-update-statements.test.ts create mode 100644 packages/3-mongo-target/1-mongo-target/test/mongo-planner.statements.test.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/column-rename-constraint-renames.ts delete mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/schema-tables.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/working-schema.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/column-rename-calls.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/planner.field-statements.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/planner.operation-positions.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-column-facade.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-column-fixtures.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-column-ops.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.authoring-state.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/schema-tables.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/table-rename-calls.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/table-rename-constraint-renames.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/working-schema.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/schema-tables.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/working-schema.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/planner.statements.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-column-fixtures.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-column.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.authoring-state.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/render-ops.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/schema-tables.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/table-rename-calls.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/working-schema.test.ts create mode 100644 packages/3-targets/6-adapters/postgres/test/migrations/non-data-drops-widening.test.ts create mode 100644 packages/3-targets/6-adapters/postgres/test/migrations/rename-statements.hostile-names.integration.test.ts create mode 100644 packages/3-targets/6-adapters/sqlite/test/migrations/rename-statements.hostile-names.test.ts create mode 100644 packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-column.test.ts create mode 100644 projects/migration-statements/README.md create mode 100644 projects/migration-statements/deferred.md create mode 100644 projects/migration-statements/design-notes.md create mode 100644 projects/migration-statements/manual-qa-reports/2026-10-07-qa-opus.md create mode 100644 projects/migration-statements/manual-qa.md create mode 100644 projects/migration-statements/plan.md create mode 100644 projects/migration-statements/slices/renames/dispatches/02-statement-grammar-resolver.md create mode 100644 projects/migration-statements/slices/renames/dispatches/03-model-renames-on-sql-planners.md create mode 100644 projects/migration-statements/slices/renames/dispatches/04-column-rename-and-field-statements.md create mode 100644 projects/migration-statements/slices/renames/dispatches/05-flag-origin-contract-output.md create mode 100644 projects/migration-statements/slices/renames/dispatches/06-journeys-upgrade-fragment-docs.md create mode 100644 projects/migration-statements/slices/renames/dispatches/08-final-review-fixes.md create mode 100644 projects/migration-statements/slices/renames/dispatches/09-qa-and-round-b-followups.md create mode 100644 projects/migration-statements/slices/renames/plan.md create mode 100644 projects/migration-statements/slices/renames/spec.md create mode 100644 projects/migration-statements/spec.md delete mode 100644 skills/journey-tests/02b-rename-with-hint.md create mode 100644 skills/journey-tests/02b-rename-with-statement.md create mode 100644 test/integration/test/cli-journeys/rename-statements-migration.e2e.test.ts create mode 100644 test/integration/test/cli-journeys/rename-statements-migration.sqlite.e2e.test.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-from.prisma create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-to.prisma create mode 100644 upgrade-instructions/pending/migration-statement-renames/app/instructions.md create mode 100644 upgrade-instructions/pending/migration-statement-renames/extension/instructions.md diff --git a/docs/architecture docs/adrs/ADR 001 - Migrations as Edges.md b/docs/architecture docs/adrs/ADR 001 - Migrations as Edges.md index 2f0588c5cee9..10bbe4d85b11 100644 --- a/docs/architecture docs/adrs/ADR 001 - Migrations as Edges.md +++ b/docs/architecture docs/adrs/ADR 001 - Migrations as Edges.md @@ -1,5 +1,7 @@ # ADR 001 — Migrations as Edges +> **Update — 2026-10:** the planner hints this ADR anticipates for renames are not built. A rename is stated on the command line as a statement, `--rename :`, which never enters the contract or the migration edge. See [Migration System § Statements](../subsystems/7.%20Migration%20System.md#statements). + ## Context Traditional migration systems rely on the order of files on disk to decide what to run next. That creates coupling to file sequencing, makes squashing brittle, and complicates collaboration when branches diverge. Agents and CI need deterministic, machine-verifiable answers to basic questions like “is it safe to apply this change” and “how do I get from here to there.” diff --git a/docs/architecture docs/adrs/ADR 028 - Migration Structure & Operations.md b/docs/architecture docs/adrs/ADR 028 - Migration Structure & Operations.md index 88442616ceb1..adf8631b3c0f 100644 --- a/docs/architecture docs/adrs/ADR 028 - Migration Structure & Operations.md +++ b/docs/architecture docs/adrs/ADR 028 - Migration Structure & Operations.md @@ -1,5 +1,7 @@ # ADR 028 — Migration structure & operations +> **Update — 2026-10:** edges do not carry the `hints` field shown below. A rename is stated on the command line as a statement, `--rename :`, and the planner turns it into rename operations in the edge's `ops`. See [Migration System § Statements](../subsystems/7.%20Migration%20System.md#statements). + ## Context Prisma Next models migrations as edges between data contract hashes rather than ordered files on disk. The system needs a well-defined migration structure that supports graph reconstruction, deterministic pathfinding, and operational transformations like squashing. Teams want to squash old edges into a baseline to speed fresh environment bootstrap while guaranteeing production safety. diff --git a/docs/architecture docs/adrs/ADR 243 - Name-identified indexes and exact-name adoption.md b/docs/architecture docs/adrs/ADR 243 - Name-identified indexes and exact-name adoption.md index 016211c39c71..2e65d5865c47 100644 --- a/docs/architecture docs/adrs/ADR 243 - Name-identified indexes and exact-name adoption.md +++ b/docs/architecture docs/adrs/ADR 243 - Name-identified indexes and exact-name adoption.md @@ -2,6 +2,8 @@ Status: **Accepted** +> **Note (2026-10-05).** Dropping an index is now classed `widening`, not `destructive`, because an index holds no stored data. Where this ADR says an index is dropped under a destructive policy, read a policy that allows `widening`. + Related: [ADR 234 — Content-addressed wire names for Postgres-normalized objects](<./ADR 234 - Content-addressed wire names for Postgres-normalized objects.md>) (extended here), [ADR 235 — The schema differ walks two derived schema IRs](<./ADR 235 - The schema differ walks two derived schema IRs.md>), [ADR 009 — Deterministic Naming Scheme](<./ADR 009 - Deterministic Naming Scheme.md>), [ADR 161 — Explicit foreign key constraint and index configuration](<./ADR 161 - Explicit foreign key constraint and index configuration.md>), [ADR 210 — Index-type registry](<./ADR 210 - Index-type registry.md>), [ADR 224 — Control Policy](<./ADR 224 - Control Policy — framework-locked vocabulary and family-owned dispatch.md>). ## Decision diff --git a/docs/architecture docs/adrs/ADR 244 - Check constraints are opaque wire-named expressions.md b/docs/architecture docs/adrs/ADR 244 - Check constraints are opaque wire-named expressions.md index 08799d4b1447..e491bfb3680d 100644 --- a/docs/architecture docs/adrs/ADR 244 - Check constraints are opaque wire-named expressions.md +++ b/docs/architecture docs/adrs/ADR 244 - Check constraints are opaque wire-named expressions.md @@ -2,6 +2,8 @@ Status: **Accepted** +> **Note (2026-10-05).** Dropping a check constraint is now classed `widening`, not `destructive`, because a check holds no stored data. Where this ADR says a check is dropped, or a database converges, under a policy that allows `destructive`, read a policy that allows `widening`. + Related: [ADR 234 — Content-addressed wire names for Postgres-normalized objects](<./ADR 234 - Content-addressed wire names for Postgres-normalized objects.md>) (extended here), [ADR 243 — Name-identified indexes and exact-name adoption](<./ADR 243 - Name-identified indexes and exact-name adoption.md>) (its constraint carve-out no longer covers checks), [ADR 156 — Storage sets and check constraints](<./ADR 156 - Storage sets and check constraints.md>) (its check-constraint half is superseded by this ADR), [ADR 235 — The schema differ walks two derived schema IRs](<./ADR 235 - The schema differ walks two derived schema IRs.md>), [ADR 224 — Control Policy](<./ADR 224 - Control Policy — framework-locked vocabulary and family-owned dispatch.md>). ## Decision diff --git a/docs/architecture docs/adrs/ADR 264 - A model names its storage verbatim, and a rename is an operation.md b/docs/architecture docs/adrs/ADR 264 - A model names its storage verbatim, and a rename is an operation.md index f553c3c3a20b..c749ad742877 100644 --- a/docs/architecture docs/adrs/ADR 264 - A model names its storage verbatim, and a rename is an operation.md +++ b/docs/architecture docs/adrs/ADR 264 - A model names its storage verbatim, and a rename is an operation.md @@ -74,4 +74,9 @@ A schema that declares no `@@map` and whose storage was created under the loweri - **Keep the lowering rule and write `@@map` from `contract infer` whenever the model name does not round-trip.** This repairs infer but leaves a default that matches no convention, contradicts every other authoring surface, and surprises anyone reading a schema without one. - **Infer a rename from a drop and a create whose columns match.** Two unrelated tables can have the same columns, and a wrong guess moves rows into the wrong table. A guess that cannot be verified is worse than a refusal that names the fix. - **State the rename on the command line, for example `migration plan --rename old=new`.** The command line belongs to the framework CLI, which serves every family, so a flag naming tables puts one family's vocabulary in a shared surface; MongoDB accepted such a flag and planned a collection drop. It also duplicates the planner hint below. + + > **Update — 2026-10:** renames are now stated on the command line after all, as `--rename :` on `migration plan` and `db update`. A statement names models and fields, not tables, so no family vocabulary enters the shared CLI, and a statement that resolves to nothing is an error. It drives the rename operation this ADR introduces. See [Migration System § Statements](../subsystems/7.%20Migration%20System.md#statements). + - **A planner hint in the contract source.** An annotation such as `@hint(was: "userProfile")` would let `migration plan` and `db update` rename without a hand-written migration. It is the intended direction and is recorded in the Data Contract and Migration System subsystem documents and in [ADR 001](ADR%20001%20-%20Migrations%20as%20Edges.md). It needs its own decisions first: the syntax in PSL and in TypeScript authoring, whether the hint names the old model or the old storage, how a hint reaches an offline planner without entering `contract.json` or its hashes, and what a hint that matches nothing means. The rename operation is the part a hint would drive, so it is built first. + + > **Update — 2026-10:** a contract-source hint is no longer the direction. Renames are stated on the command line as statements (see the note under the previous alternative), and nothing in the contract source records them. See [Migration System § Statements](../subsystems/7.%20Migration%20System.md#statements). diff --git a/docs/architecture docs/subsystems/1. Data Contract.md b/docs/architecture docs/subsystems/1. Data Contract.md index ec16e8654b52..e2014adab6d5 100644 --- a/docs/architecture docs/subsystems/1. Data Contract.md +++ b/docs/architecture docs/subsystems/1. Data Contract.md @@ -73,13 +73,12 @@ If provenance is needed for error UX, it must be carried via diagnostics (CLI/ed A model names its table, or its MongoDB collection, exactly as the model is written; a field names its column by its own name. A different storage name is stated with `@@map` or `@map`. Nothing transforms identifier case implicitly, on any authoring surface: PSL, TypeScript authoring, and a Prisma 7 schema read as a contract source all behave this way, and `contract infer` writes `@@map` whenever the model name it derives differs from the name it read. Changing a model's storage name therefore renames existing storage, which a migration states with `this.renameTable(...)`. See [ADR 264 — A model names its storage verbatim, and a rename is an operation](../adrs/ADR%20264%20-%20A%20model%20names%20its%20storage%20verbatim,%20and%20a%20rename%20is%20an%20operation.md). -### Canonical contract vs planner hints +### Canonical contract vs migration statements -The canonical contract IR and its `storageHash`/`executionHash`/`profileHash` capture only **application expectations** (models, relations, storage layout, capabilities) and extension-owned metadata. Planner‑specific inputs such as rename hints or migration strategies are **not** part of the canonical contract and **do not** participate in hashing. +The canonical contract IR and its `storageHash`/`executionHash`/`profileHash` capture only **application expectations** (models, relations, storage layout, capabilities) and extension-owned metadata. Migration intent, such as that a model or field was renamed rather than dropped and replaced, is **not** part of the canonical contract and **does not** participate in hashing. -- Authoring layers (PSL or TypeScript contract authoring) may expose annotations like `@hint(was: "old_name")` to guide migration planning. -- These annotations are consumed by the migration planner alongside contracts and are recorded in **migration edges** as structured metadata (see the Migration System subsystem and [ADR 028 — Migration Structure & Operations](../adrs/ADR%20028%20-%20Migration%20Structure%20&%20Operations.md)). -- Downstream contract consumers such as the runtime, lanes, and query DSL treat `contract.json` as planner‑agnostic; they do not rely on or interpret planner hints. +- The user states that intent on the command line as statements, for example `prisma migration plan --rename Profile:User` or `prisma db update --rename User.name:User.fullName`. The planner takes them beside the two contracts; see [Migration System § Statements](./7.%20Migration%20System.md#statements). +- Downstream contract consumers such as the runtime, lanes, and query DSL treat `contract.json` as planner‑agnostic. ## Authoring Modes @@ -326,7 +325,7 @@ The following rules ensure that extension content is encoded and hashed consiste Contract changes are classified to guide safe evolution, planning, and verification without surprising production behavior. - **Additive changes** (add table/column, nullable field, new index) → compatible; produce new `storageHash` -- **Renames/drops/type changes** → require explicit planner hints (expressed in the authoring layer) and a migration plan; not auto‑compatible +- **Renames/drops/type changes** → need a migration plan, and a rename needs a statement on the command line ([Migration System § Statements](./7.%20Migration%20System.md#statements)); not auto‑compatible - **Non‑null column addition** → needs default/backfill task; planner should fail fast without a task - **Capability changes** (e.g., enabling `jsonAgg`) → affect `profileHash`; do not change meaning - **Deprecation policy:** contract schema (`schemaVersion`) evolves under semver; breaking changes bump major @@ -656,7 +655,7 @@ Determinism and safety are enforced through a layered test strategy that covers - **Schema conformance:** arktype schema validation; property tests for allowed/denied shapes - **Determinism:** golden tests for PSL/TS → `contract.json` + `storageHash`/`executionHash`/`profileHash` - **Round‑trip:** PSL→contract→PSL (rendered) sanity checks for information preservation (where applicable) -- **Diff tests:** known change sets (add column, add FK, rename with hint) → expected diffs +- **Diff tests:** known change sets (add column, add FK, rename with a statement) → expected diffs - **Adapter conformance:** per‑dialect suite validating codecs, capabilities, and validation hooks ## References diff --git a/docs/architecture docs/subsystems/5. Adapters & Targets.md b/docs/architecture docs/subsystems/5. Adapters & Targets.md index 3b8c2824b042..5b3af58c1340 100644 --- a/docs/architecture docs/subsystems/5. Adapters & Targets.md +++ b/docs/architecture docs/subsystems/5. Adapters & Targets.md @@ -302,7 +302,7 @@ RLS policies and Postgres roles are **target-only entity kinds** — the framewo - **Authoring, both surfaces.** PSL: per-operation blocks (`policy_select` / `policy_insert` / `policy_update` / `policy_delete` / `policy_all`) plus the `@@rls` model attribute, contributed declaratively via block descriptors and the target-contributed model-attribute SPI ([ADR 236](../adrs/ADR%20236%20-%20Target-contributed%20model%20attributes.md)). TypeScript: top-level helpers on `@internal/postgres/contract-builder` (`policySelect(model, …)`, `rlsEnabled(model)`, `role(name)`) lowered through the generic pack-entity channel. Both surfaces share one predicate matrix and one hash assembly in the target, so the same policy authored either way emits byte-identical `contract.json`. - **Naming and equivalence.** A policy is either **wire-named** or **exact-named**. Wire-named (the default): the authored name is a prefix, the target appends an 8-hex SHA-256 suffix over the canonical content tuple, and equivalence is wire-name identity — the verifier never compares predicate bodies ([ADR 234](../adrs/ADR%20234%20-%20Content-addressed%20wire%20names%20for%20Postgres-normalized%20objects.md)). Exact (`@@map("physical name")` on the policy block): the name is adopted verbatim — no prefix, no hash, and no wire-prefix cap (the name is still a physical identifier, so the target's own limits apply; Postgres truncates identifiers to 63 bytes) — and equivalence becomes content comparison (structured attributes strict, `using`/`withCheck` byte-for-byte), reliable precisely when the body text is a Postgres reprint; hand-authoring a body under `@@map` warns (`PN_EXACT_NAME_BODY_COMPARISON`, shared per-build batch with index `map:`). - **Diff and verify.** Policies and roles are nodes in the Postgres schema-IR tree (database → namespace → table → policy; roles on the database root) walked by the one generic differ ([ADR 235](../adrs/ADR%20235%20-%20The%20schema%20differ%20walks%20two%20derived%20schema%20IRs.md)). `@@rls` enablement diffs as a table attribute (`pg_class.relrowsecurity`); removing a marked table's last policy leaves RLS enabled (deny-all, fail-closed). Roles verify existence-only against `pg_roles`: a declared-but-absent role fails verify even under `control: 'external'`; an undeclared live role is tolerated; roles produce zero migration operations. -- **Planning.** Diff issues map to `CREATE POLICY` / `DROP POLICY` / `ALTER POLICY … RENAME TO` and `ENABLE`/`DISABLE ROW LEVEL SECURITY`, graded per table by its control policy ([ADR 224](../adrs/ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md)). Renames pair in two phases: matching hash suffix under a different prefix (phase 1), then remaining wire-named-missing policies against extras of any name shape by verbatim content (phase 2 — how replacing `@@map` with the plain head converges as one rename). An exact-named policy whose live body drifted is a `not-equal` issue: drop + create under a destructive-allowed policy, a planner conflict otherwise. +- **Planning.** Diff issues map to `CREATE POLICY` / `DROP POLICY` / `ALTER POLICY … RENAME TO` and `ENABLE`/`DISABLE ROW LEVEL SECURITY`, graded per table by its control policy ([ADR 224](../adrs/ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md)). Renames pair in two phases: matching hash suffix under a different prefix (phase 1), then remaining wire-named-missing policies against extras of any name shape by verbatim content (phase 2 — how replacing `@@map` with the plain head converges as one rename). An exact-named policy whose live body drifted is a `not-equal` issue: drop + create under a policy that allows `widening`, a planner conflict otherwise. ## Mongo family (document targets) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 297ed28edb4d..5d73046f3ae2 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -136,11 +136,11 @@ For Postgres the schema covers tables, columns, and indexes. For MongoDB the sam Schema-contributing extensions own their own contract spaces, not a node in the application's `SqlSchemaIR`. See [Per-space planner / runner / verifier](#per-space-planner--runner--verifier) below and [ADR 212 — Contract spaces](../adrs/ADR%20212%20-%20Contract%20spaces.md) for how extension schema is planned and verified. -Planner hints come from the **authoring layer** (for example, PSL/TS annotations such as `@hint(was: "old_name")` or equivalent configuration), not from `contract.json` itself. The canonical contract IR stays planner-agnostic and hash-stable; it only encodes application expectations as described in the Data Contract subsystem. +The canonical contract IR stays planner-agnostic and hash-stable; it only encodes application expectations as described in the Data Contract subsystem. What the contracts cannot say, such as that a model was renamed rather than replaced, the user states on the command line; see [Statements](#statements). A table rename is a core operation the author states: `this.renameTable({ table, to })` in a hand-written migration returns the table rename plus a rename of every object whose name the planner derives from the table name, and the planner refuses a drop-and-create pair that is the signature of the older storage-naming rule with `MIGRATION.TABLE_NAME_CASE_CHANGED`. See [ADR 264 — A model names its storage verbatim, and a rename is an operation](../adrs/ADR%20264%20-%20A%20model%20names%20its%20storage%20verbatim,%20and%20a%20rename%20is%20an%20operation.md). -Additive structure is covered by core operations: create table, add nullable column, add index/unique, add foreign key, set column default (SQL); create collection, create index, set validation (Mongo). Renames, drops, and other destructive changes require explicit hints and policies; the planner will fail fast without them. See [ADR 028 — Migration Structure & Operations](../adrs/ADR%20028%20-%20Migration%20Structure%20&%20Operations.md) and [ADR 161 — Explicit FK constraint and index configuration](../adrs/ADR%20161%20-%20Explicit%20foreign%20key%20constraint%20and%20index%20configuration.md). +Additive structure is covered by core operations: create table, add nullable column, add index/unique, add foreign key, set column default (SQL); create collection, create index, set validation (Mongo). A rename needs a statement (see [Statements](#statements)), and a destructive change needs a policy that allows it; the planner fails fast without them. See [ADR 028 — Migration Structure & Operations](../adrs/ADR%20028%20-%20Migration%20Structure%20&%20Operations.md) and [ADR 161 — Explicit FK constraint and index configuration](../adrs/ADR%20161%20-%20Explicit%20foreign%20key%20constraint%20and%20index%20configuration.md). ### `migration plan` @@ -172,6 +172,42 @@ An auto-baseline dates the delta package at the current time and the baseline pa Plan-time refuse diagnostics name the resolved hash, list refs pointing at graph nodes, and suggest `--from `. Snapshot-missing diagnostics suggest `db update --advance-ref ` to repopulate or `migration ref delete ` to clear an orphan pointer. +### Statements + +The origin and destination contracts cannot say that a model or field was renamed rather than dropped and replaced. The user states it on the command line, and the planner takes the statements as its third input, beside the destination contract and the origin schema. Statements are never part of `contract.json` or its hashes. + +```bash +prisma migration plan --name tidy-users --rename Profile:User --rename User.name:User.fullName +prisma db update --rename Profile:User --rename User.name:User.fullName +``` + +**Grammar.** `--rename :` can be given more than once. Each side is one of `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`, and both sides name the same kind of thing. A field is named through its model as the destination contract names it, so the field statement above names `User`, not `Profile`. A field cannot move to another model. The CLI parses each statement in [`parse-rename.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/parse-rename.ts); a malformed statement fails with `MIGRATION.STATEMENT_INVALID`, and so does a field statement that names the model by its old name, with the corrected statement in the message. + +**Resolution.** The framework resolves each statement against the origin and destination contracts of the application contract space, in [`resolve-statements.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/resolve-statements.ts). The old name must be in the origin and not in the destination; the new name must be in the destination and not in the origin. A resolved statement is a `ResolvedMigrationStatement` in domain coordinates (namespace, model and field), with no table or column names, so the framework stays family-blind. A statement that does not resolve fails with `MIGRATION.STATEMENT_UNRESOLVED`. The framework passes the resolved statements to the family planner as `statements` in the `plan` options; the aggregate planner passes them to the application space only, through `PlannerInput.appSpace`. + +**Application.** The SQL family planner applies the statements in order to a working copy of the origin schema, then diffs the working copy against the destination contract as usual ([`statement-planning.ts`](../../../packages/2-sql/9-family/src/core/migrations/statement-planning.ts)). The family works out each statement's storage effect from the two contracts, and the target builds the operations: + +- A model statement whose table name changes renames the table, and renames every primary key, unique constraint, foreign key and index whose name the planner derives from the table name, using the names the destination contract derives. These are the operations `this.renameTable({ table, to })` returns. +- A field statement whose column name changes renames the column, and renames the constraints and indexes whose names derive from it. These are the operations `this.renameColumn({ table, column, to })` returns. SQLite cannot rename an index, so there a renamed index is dropped and created. On Postgres, a check, a row-level-security policy, or an expression or partial index whose SQL text names the column is dropped and created again: Postgres rewrites that text when it renames the column, but the working copy keeps the old text, so the diff sees a changed object. Adding the check back reads every row of the table while holding an `ACCESS EXCLUSIVE` lock, and an index is rebuilt, so on a large table such a column rename is not instant. +- A statement whose storage name does not change, because `@@map` or `@map` keeps it, or because the field is a relation, produces no operations. + +Rename operations are `widening`, so neither command asks for consent. The `migration.ts` that `migration plan` writes contains `...this.renameTable(...)` and `...this.renameColumn(...)` calls, as a user would write them by hand, and running it again writes the same `ops.json` and `migration.json`. + +**Result.** The planner's success result carries `appliedStatements` (`AppliedMigrationStatement`): one entry per statement, in order, with `operationIndexes`: the positions, in the plan's operations, of the operations the statement accounts for, empty when its storage did not change. Statements point at operations by position because operation ids are not unique within a plan (on Postgres, same-named tables in two schemas share them), and ids stay as they are so that re-emitting an existing migration does not change its hash. The framework's `describeMigrationStatement` writes the statement in domain names, such as `rename model "Profile" to "User"`, for every family's messages and for the CLI. `migration plan` and `db update` print each statement with its description and number of operations, and their JSON output carries the description and the operation positions, which index the output's `operations`. Messages name a model of the unbound namespace by its name alone, and JSON output leaves `namespaceId` out of a coordinate in the unbound namespace (`migrationStatementJson`), because that namespace's id is internal. + +**Refusals.** When the planner cannot carry out a resolved statement, planning fails with `MIGRATION.PLANNING_FAILED` and a conflict of kind `statementRefused` that carries the statement in its `refusedStatement` field. The [error reference](../../reference/error-reference.md#migrationplanning_failed) lists the cases. + +**The origin of `db update`.** `migration plan` already has an origin contract: the one `--from` resolves to. `db update` introspects the database instead, so it reads an origin contract only when statements are given. It reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). Without statements it does not read the snapshot store, and with or without them the plan it hands the runner keeps `origin: null` (see [`db update`](#db-update-live-reconciliation)). `db update` stores a snapshot only when it advances a ref: the `db` ref by default, or with `--db ` only the ref named by `--advance-ref `. So a database last updated with `--db ` and no `--advance-ref` has no snapshot, and statements fail there with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN`, which names the hash and the directory it looked in. Running `db update` again with the same statements fails with `MIGRATION.STATEMENT_UNRESOLVED`, because the origin is then a contract without the old names. + +**Limits in this release.** + +- A model move to another namespace resolves, but the planner refuses it with `statementRefused`. +- A model that keeps its name but changes its table cannot be renamed by a statement, because the model has the same name on both sides. Rename the table by hand with `this.renameTable(...)` in its own `migration.ts`. A field statement on such a model is refused with the same advice. +- Only models and fields can be renamed. Value objects, their fields, enum values and namespaces cannot. +- The MongoDB planner refuses every statement with `statementRefused`, so `migration plan` and `db update` with `--rename` fail with `MIGRATION.PLANNING_FAILED` and plan nothing, rather than dropping and creating the collection. + +The rest of the migration-statements work adds `--delete` and refuses plans that lose data by default; adds `--convert`, `--backfill`, the remaining renames and model moves across namespaces; and brings statements to MongoDB. + ## Authoring Surface `migration.ts` is the user-editable authoring surface for a migration package. See [ADR 193 — Class-flow as the canonical migration authoring strategy](../adrs/ADR%20193%20-%20Class-flow%20as%20the%20canonical%20migration%20authoring%20strategy.md) for the historical record of this decision. @@ -384,6 +420,8 @@ See [ADR 199 — Storage-only migration identity](../adrs/ADR%20199%20-%20Storag ## Operation Model +Every operation has a class that says what it can do to the data (`MigrationOperationClass`). An operation is `destructive` when it loses data, such as dropping a table or a column, and only then. A rename, a relaxed constraint or a wider type, and a drop of an object that holds no data (an index; a unique, foreign-key or check constraint; a default; a native enum type; a row-level-security policy) or disabling row-level security are `widening`, so `db update` applies them without asking for consent. Dropping a policy or disabling row-level security widens who can read and write rows. Adding structure is `additive`, and transforming data is `data`. MongoDB still classes dropping an index as `destructive`. + Each migration operation is a data envelope with three phases — `precheck[]`, `execute[]`, `postcheck[]`. The phases are composed from existing AST primitives (DDL commands, inspection commands, filter expressions for Mongo; SQL statements for Postgres) that serialize naturally to JSON. The envelope carries no behavior of its own; the semantic richness lives in the commands and expressions inside it. ### DDL operations @@ -523,7 +561,7 @@ The intermediate state ends when the developer runs the package's `migration.ts` A user who is unsure whether a migration is in the intermediate state has several signals: -- `prisma migration plan` returns `pendingPlaceholders: true` and prints a `⚠` line whose body says *"edit migration.ts then run `node migration.ts` to self-emit"*. The same envelope is in the JSON output for tooling. +- `prisma migration plan` returns `pendingPlaceholders: true` and prints a `⚠` line whose body says *"edit migration.ts then run `node migration.ts` to self-emit"*, followed by the operations that resolved and the statements applied. The same envelope is in the JSON output for tooling, with those operations in `operations`. - `ops.json` literally contains `[]`, and `migration.ts` mentions `placeholder(...)` (planner-led) or has no operation calls in its `plan()` body (custom flow). - Running `migration.ts` while a placeholder is still unfilled fails with `PN-MIG-2001` — the structured error names the slot. - `prisma db migrate` against a placeholder-blocked package will execute the lock + verify path but apply zero ops; the destination-hash post-check then surfaces `MIGRATION.POSTCHECK_FAILED` (state mismatch). This is the runner-side fallback diagnostic when an intermediate-state migration leaks past the CLI signals above. @@ -554,14 +592,14 @@ Top-level verbs: - `prisma db migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `db migrate` does not advance any ref. The `` argument accepts every [contract reference](#contract-reference-grammar), including `@contract`, `@db` and `@empty`. - `prisma db init --db [--advance-ref ]` — bootstrap a database under contract control. When run against the default `--db` URL (no explicit `--db`), implicitly advances the `db` ref (write-if-absenting its contract into the snapshot store, then writing the pointer). With any explicit `--db` (even one naming the default URL), ref advancement is suppressed unless `--advance-ref` is explicit. -- `prisma db update --db [--to ] [--advance-ref ]` — reconcile a live database to the named (or emitted) contract via live introspection. Same implicit `db` ref default and `--db` opt-out as `db init`. Off-graph; dev-only. +- `prisma db update --db [--to ] [--advance-ref ] [--rename :]` — reconcile a live database to the named (or emitted) contract via live introspection. `--rename` states a rename ([Statements](#statements)). Same implicit `db` ref default and `--db` opt-out as `db init`. Off-graph; dev-only. - `prisma db sign --db [] [--advance-ref ] [--no-advance-ref]` (or `--contract `) — sign the marker of every contract space the live DB already satisfies: the application's (with no argument, the emitted `contract.json`) and each extension's. Each space is verified without strict mode; the markers of the spaces that verified are written in one transaction on PostgreSQL and SQLite, and a space that fails is reported with its drift and makes the command exit 4. After signing, writes each signed space's contract into its snapshot store and advances its `db` ref (or `--advance-ref `) to the signed hash; an existing ref is overwritten and the previous hash is reported in the human output (the JSON `advancedRefs` lists `{ space, name, hash }` per advanced ref). Unlike `db init` / `db update`, `--db` does not suppress this — sign never mutates the schema, and adoption is normally done against the real database via `--db`. `--no-advance-ref` is the opt-out: it signs without writing any ref or snapshot (JSON `advancedRefs` is empty), and combining it with `--advance-ref` is refused with `CLI.ADVANCE_REF_ARG_CONFLICT`. No migration package is written. After `--no-advance-ref` there is no `db` ref, so the next default `migration plan` starts from the empty contract (with the muted `No db ref set` notice) on an empty graph, or refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN` on a non-empty graph. - `prisma db verify --db ` — verify the live DB satisfies the contract (modes: `--schema-only`, `--marker-only`, `--strict`). - `prisma migration ref set ` / `prisma migration ref list` / `prisma migration ref delete ` — manage named refs. `migration ref set` enforces the graph-node invariant and writes the ref's pointer file — the contract already lives in the snapshot store, keyed by the matching bundle's `to` hash. `migration ref delete` removes only the pointer; the store entry is shared by hash (other refs or graph nodes may reference it) and is never touched. Migration namespace (artifacts and graph): -- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags take [contract references](#contract-reference-grammar); `--from` also accepts `@empty`. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. +- `prisma migration plan [--from ] [--to ] [--rename :] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags take [contract references](#contract-reference-grammar); `--from` also accepts `@empty`. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. - `prisma migration new [--from ] --name ` — scaffold an empty `migration.ts` for hand-authoring. - `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Reads the database marker by default; `--from` names the origin instead and runs offline, unless `--from` or `--to` is `@db`, which reads the database. - `prisma migration log --db ` — applied execution history (reads the marker; offline reading of the ledger is also supported). @@ -754,7 +792,7 @@ Greenfield, brownfield-conservative, and brownfield-incremental paths are suppor When run against the default dev database URL, `db update` implicitly advances the `db` ref — this is the primary mechanism for recording "the contract hash this dev database has been brought up to" on disk for offline planning. Same `--advance-ref` override and explicit-`--db` opt-out as `db init`. See [ADR 218](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (superseded in part — see its Status note) and [ADR 240](../adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). -Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. Plans produced by the planner carry `origin: null` (no prior-state assertion). The runner treats this as "trust the planner": operations are always applied regardless of marker state. This ensures `db update` can recover from schema drift — for example, when a DBA manually drops a column, the planner detects the missing column and re-adds it even though the contract marker already matches the destination hash. +Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. When statements are given, it also reads the origin contract they resolve against from the snapshot store entry for the marker's hash; see [Statements](#statements). Plans produced by the planner carry `origin: null` (no prior-state assertion). The runner treats this as "trust the planner": operations are always applied regardless of marker state. This ensures `db update` can recover from schema drift — for example, when a DBA manually drops a column, the planner detects the missing column and re-adds it even though the contract marker already matches the destination hash. By contrast, `db migrate` constructs plans with `origin: { storageHash }` from the migration chain. When the marker matches the destination, the runner skips operations (the migration was already applied). This provides safe idempotent replays. @@ -791,6 +829,9 @@ Errors use the stable category/code envelope (see [ADR 027 — Error Envelope & - `MIGRATION.SNAPSHOT_MISSING` — a named ref has no pointer file, and the hash being resolved isn't a graph node either (plan-time) - `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (`db migrate` before any DDL, and `db migrate --show`) - `MIGRATION.PATH_UNREACHABLE` — no migration path from marker to target (apply-time; improved `fix` payload) +- `MIGRATION.STATEMENT_INVALID` — a `--rename` statement is malformed or pairs things that cannot be renamed into each other (plan-time; see [Statements](#statements)) +- `MIGRATION.STATEMENT_UNRESOLVED` — a `--rename` statement's names do not resolve in the origin and destination contracts (plan-time) +- `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` — `db update` was given statements but has no origin contract for the marker's hash (plan-time) **Authoring errors** (`PN-MIG-*`): - `PN-MIG-2001` — unfilled placeholder in scaffolded migration diff --git a/docs/architecture docs/subsystems/9. No-Emit Workflow.md b/docs/architecture docs/subsystems/9. No-Emit Workflow.md index 6e2df853be2b..29c5503bd62f 100644 --- a/docs/architecture docs/subsystems/9. No-Emit Workflow.md +++ b/docs/architecture docs/subsystems/9. No-Emit Workflow.md @@ -117,7 +117,7 @@ const rt = createRuntime({ - Import the TS contract in a sandboxed build environment - Call canonicalize(contract) to write contracts/.json - Validate hash(json) === storageHash -- Migration edges store toContractRef and planner hints, not TS code +- Migration edges store toContractRef and operations, not TS code; a rename is stated on the command line as a statement when the edge is planned - Runner and preflight consume JSON artifacts only ## Dev experience diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index d904132a7206..fdb8d003e9ac 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1366,6 +1366,10 @@ A `migration check` finding, carried as an `error` diagnostic on a completed run A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a migration's `from` hash is not produced by any other migration (and is not the empty state), so the migration is unreachable in the graph. Delete it or re-emit a connecting migration. +### MIGRATION.COLUMN_RENAME_UNMATCHED + +`this.renameColumn({ table, column, to })` in a hand-written migration does not match the migration's contracts: the migration has no start contract, the table does not exist at that point of the migration (or, with no `schema`, is declared in more than one namespace), the table has no column `column`, the table already has a column `to`, or the end contract has no column `to` on that table. Raised when the migration's operations are built, so `migration.ts` writes no `ops.json`. Order the rename calls in the sequence the renames happen and check the table, column and namespace names. Payload: `table`, `from`, `to`. + ### MIGRATION.CONSENT_PLAN_MISMATCH An apply carrying consent was refused because the plan recomputed for it is not the plan that was consented to. `db update` recomputes the plan at apply time and compares its hash against the one the consent was given for; a mismatch means the schema, the contract, or the database moved in between, so applying would carry out operations nobody agreed to. Re-run the command and review the freshly planned operations before consenting again. Payload: `consentedPlanHash`, `planHash`. @@ -1560,7 +1564,7 @@ An apply command (`db migrate`/`db update`) cannot find a path through the on-di ### MIGRATION.PLANNING_FAILED -Migration planning (typically during `db init`/`db update`) failed because of conflicts, e.g. the live database already contains objects that clash with the plan. The envelope aggregates each conflict's summary and suggested fix. Payload: `conflicts`. `db init` applies only additive operations, so when a conflict is an operation its policy refused (the conflict carries `refusedOperationClass`), such as adding a validator to a Mongo collection that already holds documents, the error's next action depends on the refused classes. When `db update` allows all of them (`widening` and `destructive`), the next action is `db update`, which applies them, asking you to confirm destructive ones. When one is `data`, which `db update` does not apply, the next action is `migration plan`, to plan a migration that `db migrate` then applies. +Migration planning (typically during `db init`/`db update`) failed because of conflicts, e.g. the live database already contains objects that clash with the plan. The envelope aggregates each conflict's summary and suggested fix. Payload: `conflicts`; a conflict's `location` and `refusedStatement` leave out `namespaceId` for the unbound namespace, whose id is internal. A conflict of kind `statementRefused` refuses a `--rename` statement the planner cannot carry out, and carries that statement (`refusedStatement`, in namespace, model and field names): moving a model to another namespace (not supported in this release); renaming a table or column whose control policy is not `managed`; a model with no table; a table or column the starting schema does not have, or already has under the new name; a field that has a column on one side of the statement only; a field rename on a model whose table changes when no statement renames the table (the message gives the steps: change the contract so only the table name changes and emit it, create a migration with `prisma migration new --from `, add `...this.renameTable(...)` to its operations and run its `migration.ts`, then emit the final contract and plan the field rename with `--from` that migration; with `db update`: change the contract so only the table name changes and emit it, rename the table in the database yourself with the SQL the message gives, check that `db update --dry-run` plans no drop of a table or column and run `db update`, then emit the final contract and run `db update` with the field statement); a rename that produces an operation the command's operation policy does not allow; or any statement on MongoDB, whose planner refuses statements in this release and plans nothing. `db init` applies only additive operations, so when a conflict is an operation its policy refused (the conflict carries `refusedOperationClass`), such as adding a validator to a Mongo collection that already holds documents, the error's next action depends on the refused classes. When `db update` allows all of them (`widening` and `destructive`), the next action is `db update`, which applies them, asking you to confirm destructive ones. When one is `data`, which `db update` does not apply, the next action is `migration plan`, to plan a migration that `db migrate` then applies. `migration plan` raises it when the planner produces no operations. The conflict's kind names the case, and its `why` is the next action. With an earlier contract (`noDatabaseChange`), the contract changed in a way the database does not see, for example after a Prisma upgrade that changes how `contract.json` records values. If you deploy with migrations, run `prisma migration new --from `, with the earlier contract's hash from the message, to write a migration with no operations, then `prisma db migrate`. If you manage the database with `db init` or `db update`, run `prisma db sign` on each database instead. With no earlier contract (`nothingToCreate`), the contract describes nothing to create; if the database needs something the contract does not describe, run `prisma migration new` and write the operations in `migration.ts` by hand. `migration new` writes a migration only to the emitted contract, so with `--to` the message says to emit the target first, and for a database managed with `db update` it names `prisma db sign `. When the migrations directory is empty and the `db` ref points at a contract that describes nothing to create (`nothingToBaseline`), there is no baseline migration to plan, and no command writes one with no operations yet; keep using `db update`. @@ -1648,6 +1652,18 @@ A `--from` reference cannot produce a contract: either a ref name has no pointer SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` operation needing the control adapter was invoked on an instance constructed without a control stack (only introspection is valid in that form). Payload: `operation`. +### MIGRATION.STATEMENT_INVALID + +A `--rename :` statement is malformed or pairs two things that cannot be renamed into each other: it has no `:` or more than one, a side has an empty name or more than three names, it names a model on one side and a field on the other, it names fields of two different models (a field cannot move between models), or an earlier statement in the same command already renames the same model or field, or renames another one to the same new name. It also refuses a statement that renames a model or field to itself (if only `@map` or `@@map` changed, a plan without the statement drops and creates the table or column with its data: with `migration plan`, edit the planned `migration.ts` to rename it instead, with `...this.renameTable(...)` or `...this.renameColumn(...)` on SQL; with `db update`, rename it in the database yourself first, then run `db update`, which then finds nothing that loses data), and two statements that swap names, which one plan cannot do: statements resolve against contracts, so the message gives the three plans that make the swap: the first name to a temporary name, the second name to the first, then the temporary name to the second. Every message's next step is the statement to type: the accepted forms with examples, or for a model on one side and a field on the other, both statements the user may have meant. Each side is one of `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`. A field's model is named as the destination contract names it: when the old side names the model by its old name, as in `--rename Profile:User --rename Profile.name:User.fullName`, the message gives the corrected statement, `--rename User.name:User.fullName`, and says when the model also needs its own `--rename`. Payload: `statement` (its text) and `verb` (`rename`). + +### MIGRATION.STATEMENT_ORIGIN_UNKNOWN + +Statements were given to `db update`, but it has no origin contract to resolve their old names against. When the database has no marker, Prisma has never initialised or updated it, so there is no earlier contract whose names a statement could rename: leave out the statements, and preview the plan with `--dry-run` first if the database already holds tables under the old names, because a plan without statements drops the storage of each renamed model or field with its data and creates it again (it asks for consent first). Otherwise the snapshot store has no contract for the marker's hash, or has one that cannot be read: its `contract.json` is not valid JSON, its content does not match the hash it is stored under, or the family cannot load the contract in it. The message names the hash and the directory it looked in. For an unreadable snapshot it says why, and the fix is to restore `migrations/snapshots/` from version control. A snapshot is missing when the run that last updated the database kept none: `db update` stores a snapshot of the contract it applies only when it advances a ref (the `db` ref by default, or with `--db ` only the ref named by `--advance-ref `), so a database last updated with `--db ` and no `--advance-ref` usually has no snapshot. To store it, and for an unreadable snapshot with no good copy: put the contract source back to the version the database is at and run `contract emit`; run `db update --advance-ref --dry-run` with the same `--db` and check that it plans no operations (if it plans some, the database has drifted from that contract), then run it without `--dry-run`, which changes nothing in the database and stores the snapshot; then put the new contract source back, emit it, and run the command with the statements again, with `--advance-ref `. Adding `--advance-ref` to the failing command alone does not help, because it would store the new contract, and `migration plan --from` does not apply to `db update`. Payload: `hash` (`null` when there is no marker), `snapshotDirectory`, and `unreadable` when the snapshot could not be read. + +### MIGRATION.STATEMENT_UNRESOLVED + +A `--rename :` statement is well formed, but its names do not resolve in the contracts the way a rename requires. The old name must be a model (or a field of a model) in the origin contract and must not be in the destination contract; the new name must be in the destination contract and must not be in the origin contract. A field's model is named as the destination contract names it. A name without a namespace must be declared in exactly one namespace; a two-part name such as `a.b` must resolve as exactly one of `namespace.Model` and `Model.field`. Value objects and their fields cannot be renamed in this release. Names match exactly, including case, and resolve against the application contract space only. A plan from an empty database (`migration plan --from @empty`, or a greenfield plan) has no models, so every statement is unresolved; running `db update` a second time with the same statements is unresolved too, because the database is then at a contract without the old names: the message says the rename has already happened and to leave out the statement. A statement written the wrong way round, whose new name is in the origin and old name in the destination, gets the statement written the right way round. A field statement whose model a later statement renames gets the order that works: the model statement first. The message names the contract searched and the names found there. Payload: `statement` (its text) and `verb` (`rename`). + ### MIGRATION.TABLE_NAME_CASE_CHANGED The planner would drop table `X` and create table `Y` in the same namespace, where `X` is `Y` with its first letter lowered; the columns are not compared. That is the shape of a schema upgraded across the release in which a model with no `@@map` stopped lowering the first letter of its table name (`model UserProfile` now names `"UserProfile"`, previously `"userProfile"`); planning it would recreate the table empty. Reported as a conflict inside `MIGRATION.PLANNING_FAILED`. The message gives three ways out. Add `@@map("X")` to the model (or run the `add-model-map` codemod) to keep the existing table. In a project with migration history, make the rename its own schema change, create its migration with `prisma migration new --from `, and add `...this.renameTable({ table: "X", to: "Y" })` to its operations; the call renames the table and the constraints and indexes named after it, and on Postgres the message adds `schema: ""` when the table is outside the default schema or another schema declares `X`. `migration new` defaults its origin to the `db` ref, so a project whose migrations are applied with `db migrate` has no origin to default to and must name one: pass the newest applied migration's `to` hash, which `prisma migration list` shows, or the command refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN`. In a project that uses `db update`, rename the table by hand with the statements the message gives, then run `db update` again: on Postgres `ALTER TABLE ""."X" RENAME TO "Y"`, and on SQLite, which refuses in one statement a rename that only changes the case of ASCII letters, `ALTER TABLE "X" RENAME TO "_prisma_rename_Y"; ALTER TABLE "_prisma_rename_Y" RENAME TO "Y"`. Payload: `droppedTable`, `createdTable`. diff --git a/examples/prisma7-adoption/test/handover.test.ts b/examples/prisma7-adoption/test/handover.test.ts index c8d865143bd0..d2b59fd34cc7 100644 --- a/examples/prisma7-adoption/test/handover.test.ts +++ b/examples/prisma7-adoption/test/handover.test.ts @@ -202,7 +202,7 @@ describe('Prisma 8 taking over migrations from the Prisma 7 schema', () => { const destructive = readBundle(dir, String(destructivePlan['dir'])); expect(destructive).toMatchObject({ from: additiveHash, to: destructiveHash }); expect(operationClasses(destructive)).toEqual([ - ['dropDefault.Post.likes', 'destructive'], + ['dropDefault.Post.likes', 'widening'], ['dropColumn.User.bio', 'destructive'], ['alterNullability.dropNotNull.Post.likes', 'widening'], ]); diff --git a/packages/1-framework/1-core/errors/src/control.ts b/packages/1-framework/1-core/errors/src/control.ts index b761e743a502..c51c516bba87 100644 --- a/packages/1-framework/1-core/errors/src/control.ts +++ b/packages/1-framework/1-core/errors/src/control.ts @@ -1,3 +1,8 @@ +import { + migrationStatementJson, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { ifDefined } from '@internal/utils/defined'; import type { Diagnostic, NextAction, StructuredError } from '@internal/utils/structured-error'; import { docsUrlFor } from '@internal/utils/structured-error'; @@ -35,6 +40,10 @@ export interface CliErrorConflict { readonly kind: string; readonly summary: string; readonly why?: string; + /** The statement the conflict refuses, in domain coordinates. */ + readonly refusedStatement?: ResolvedMigrationStatement; + /** Where the conflict is; `namespaceId` is left out of JSON output for the unbound namespace. */ + readonly location?: Readonly> & { readonly namespaceId?: string }; } /** @@ -375,6 +384,22 @@ export function errorContractMissingExtensions(options: { ); } +function locationJson(location: NonNullable) { + if (location.namespaceId !== UNBOUND_NAMESPACE_ID) return location; + const { namespaceId: _unbound, ...rest } = location; + return rest; +} + +function conflictJson(conflict: CliErrorConflict): Record { + return { + ...conflict, + ...(conflict.location === undefined ? {} : { location: locationJson(conflict.location) }), + ...(conflict.refusedStatement === undefined + ? {} + : { refusedStatement: migrationStatementJson(conflict.refusedStatement) }), + }; +} + /** * Migration planning failed due to conflicts. */ @@ -396,7 +421,7 @@ export function errorMigrationPlanningFailed(options: { return new CliStructuredError('MIGRATION.PLANNING_FAILED', 'Migration planning failed', { why: computedWhy, fix: computedFix, - meta: { conflicts: options.conflicts }, + meta: { conflicts: options.conflicts.map(conflictJson) }, docsUrl: docsUrlFor('MIGRATION.PLANNING_FAILED'), }); } diff --git a/packages/1-framework/1-core/errors/src/exports/migration.ts b/packages/1-framework/1-core/errors/src/exports/migration.ts index 9ec3f553f7b4..c4ddde080bbc 100644 --- a/packages/1-framework/1-core/errors/src/exports/migration.ts +++ b/packages/1-framework/1-core/errors/src/exports/migration.ts @@ -7,4 +7,5 @@ export { errorMigrationTargetMismatch, errorUnfilledPlaceholder, placeholder, + unfilledPlaceholderOperation, } from '../migration'; diff --git a/packages/1-framework/1-core/errors/src/migration.ts b/packages/1-framework/1-core/errors/src/migration.ts index 25c44e026085..c81dedb2d9ab 100644 --- a/packages/1-framework/1-core/errors/src/migration.ts +++ b/packages/1-framework/1-core/errors/src/migration.ts @@ -26,6 +26,18 @@ export function errorUnfilledPlaceholder(slot: string): CliStructuredError { ); } +/** + * The operation of a planner-stubbed data transform: a promise that rejects with + * `MIGRATION.UNFILLED_PLACEHOLDER`. A plan's other operations stay readable beside it. The + * rejection is marked handled, and so is any promise a target wraps it in, so reading a plan's + * operations without awaiting each one does not raise an unhandled rejection. + */ +export function unfilledPlaceholderOperation(slot: string): Promise { + const operation = Promise.reject(errorUnfilledPlaceholder(slot)); + operation.catch(() => undefined); + return operation; +} + /** * Scaffolded `migration.ts` files call this wherever the scaffolder couldn't * emit a real query and the author is expected to fill one in. Always throws diff --git a/packages/1-framework/1-core/errors/test/control.test.ts b/packages/1-framework/1-core/errors/test/control.test.ts index 1839ad355b58..ddb9111dba89 100644 --- a/packages/1-framework/1-core/errors/test/control.test.ts +++ b/packages/1-framework/1-core/errors/test/control.test.ts @@ -1,3 +1,5 @@ +import type { ResolvedMigrationStatement } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { NextAction } from '@internal/utils/structured-error'; import { docsUrlFor } from '@internal/utils/structured-error'; import { describe, expect, it } from 'vitest'; @@ -362,6 +364,48 @@ describe('Config Errors', () => { expect(error.meta?.['conflicts']).toEqual(conflicts); }); + it('errorMigrationPlanningFailed carries the statement a conflict refuses, as JSON output writes it', () => { + const refusedStatement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: UNBOUND_NAMESPACE_ID, model: 'Profile' }, + to: { namespaceId: UNBOUND_NAMESPACE_ID, model: 'User' }, + } as unknown as ResolvedMigrationStatement; + const error = errorMigrationPlanningFailed({ + conflicts: [ + { + kind: 'statementRefused', + summary: 'Cannot rename', + location: { namespaceId: UNBOUND_NAMESPACE_ID, entityKind: 'table', entityName: 'users' }, + refusedStatement, + }, + ], + }); + expect(error.meta?.['conflicts']).toEqual([ + { + kind: 'statementRefused', + summary: 'Cannot rename', + location: { entityKind: 'table', entityName: 'users' }, + refusedStatement: { + kind: 'rename', + entity: 'model', + from: { model: 'Profile' }, + to: { model: 'User' }, + }, + }, + ]); + }); + + it('errorMigrationPlanningFailed keeps the namespace of a location in a named namespace', () => { + const location = { namespaceId: 'auth', entityKind: 'table', entityName: 'users' }; + const error = errorMigrationPlanningFailed({ + conflicts: [{ kind: 'statementRefused', summary: 'Cannot rename', location }], + }); + expect(error.meta?.['conflicts']).toEqual([ + { kind: 'statementRefused', summary: 'Cannot rename', location }, + ]); + }); + it('errorMigrationPlanningFailed with custom why', () => { const conflicts = [{ kind: 'conflict-1', summary: 'Summary 1' }]; const error = errorMigrationPlanningFailed({ conflicts, why: 'Custom reason' }); diff --git a/packages/1-framework/1-core/errors/test/migration.test.ts b/packages/1-framework/1-core/errors/test/migration.test.ts index 40583e257719..770591eec54a 100644 --- a/packages/1-framework/1-core/errors/test/migration.test.ts +++ b/packages/1-framework/1-core/errors/test/migration.test.ts @@ -8,6 +8,7 @@ import { errorMigrationTargetMismatch, errorUnfilledPlaceholder, placeholder, + unfilledPlaceholderOperation, } from '../src/migration'; describe('Migration Errors', () => { @@ -152,3 +153,26 @@ describe('Migration Errors', () => { expect(error.toEnvelope().code).toBe('MIGRATION.TARGET_MISMATCH'); }); }); + +describe('unfilledPlaceholderOperation', () => { + it('rejects with MIGRATION.UNFILLED_PLACEHOLDER naming the slot', async () => { + await expect(unfilledPlaceholderOperation('backfill-status:run')).rejects.toMatchObject({ + code: 'MIGRATION.UNFILLED_PLACEHOLDER', + meta: { slot: 'backfill-status:run' }, + }); + }); + + it('raises no unhandled rejection when nobody awaits it', async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', listener); + try { + unfilledPlaceholderOperation('backfill-status:run'); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', listener); + } + expect(unhandled).toEqual([]); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/src/control/contract-snapshot-layout.ts b/packages/1-framework/1-core/framework-components/src/control/contract-snapshot-layout.ts index f83c17c94e70..4ff11605bcc0 100644 --- a/packages/1-framework/1-core/framework-components/src/control/contract-snapshot-layout.ts +++ b/packages/1-framework/1-core/framework-components/src/control/contract-snapshot-layout.ts @@ -4,9 +4,14 @@ export const CONTRACT_SNAPSHOTS_DIRNAME = 'snapshots'; const STORAGE_HASH_PATTERN = /^[0-9a-f]{64}$/; +/** Whether the snapshot store can address `storageHash`. */ +export function isStorageHashHex(storageHash: string): boolean { + return STORAGE_HASH_PATTERN.test(storageHash); +} + /** Validate a storage hash for use as a directory name. */ export function storageHashHex(storageHash: string): string { - if (!STORAGE_HASH_PATTERN.test(storageHash)) { + if (!isStorageHashHex(storageHash)) { throw new InternalError( `Invalid storage hash "${storageHash}": expected 64 lowercase hex characters`, ); diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index b34cdff6f8b9..23077db5a28c 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -20,6 +20,7 @@ import type { ControlFamilyInstance, } from './control-instances'; import type { OperationContext } from './control-operation-results'; +import type { AppliedMigrationStatement, ResolvedMigrationStatement } from './migration-statements'; // ============================================================================ // Migration Package Metadata @@ -67,14 +68,35 @@ export interface MigrationMetadata { // ============================================================================ /** - * Migration operation classes define the safety level of an operation. - * - 'additive': Adds new structures without modifying existing ones (safe) - * - 'widening': Relaxes constraints or expands types (generally safe) - * - 'destructive': Removes or alters existing structures (potentially unsafe) - * - 'data': Data transformation operation (e.g., backfill, type conversion) + * What an operation can do to the data. `destructive` means the operation loses data; no other + * class loses a row or a value. + * - 'additive': adds structure without changing what exists. + * - 'widening': changes existing structure without losing data: a rename, a relaxed constraint or + * a wider type, and a drop of an object that holds no data (an index; a unique, foreign-key or + * check constraint; a default; a native enum type; a row-level-security policy) or disabling + * row-level security. Dropping a policy or disabling row-level security widens who can read and + * write rows. + * - 'destructive': loses data, such as dropping a table or a column. + * - 'data': transforms data, such as a backfill or a type conversion. */ export type MigrationOperationClass = 'additive' | 'widening' | 'destructive' | 'data'; +/** + * The state a plan asserts it starts from: the storage hash, and optionally the profile hash, the + * runner checks the marker for. A planner's `origin` option and a plan's `origin` share this type. + */ +export interface PlanOrigin { + readonly storageHash: string; + readonly profileHash?: string; +} + +/** The origin a plan from `contract` asserts, or `null` when it starts from no contract. */ +export function planOriginOf( + contract: { readonly storage: { readonly storageHash: string } } | null, +): PlanOrigin | null { + return contract === null ? null : { storageHash: contract.storage.storageHash }; +} + /** * Policy defining which operation classes are allowed during a migration. */ @@ -91,7 +113,14 @@ export interface MigrationOperationPolicy { * Contains only the fields needed for CLI output (tree view, JSON envelope). */ export interface MigrationPlanOperation { - /** Unique identifier for this operation (e.g., "table.users.create"). */ + /** + * Names what the operation changes (e.g., "table.users"). Ids are not guaranteed unique within a + * plan: on Postgres, same-named tables in two schemas give operations the same id. So anything + * that points at one operation of a plan, such as `AppliedMigrationStatement.operationIndexes`, + * uses its position in `operations`. Ids are written to `ops.json`, so they are part of a + * migration's hash; the ledger records executed operations, ids included, but nothing compares + * them. + */ readonly id: string; /** Human-readable label for display in UI/CLI (e.g., "Create table users"). */ readonly label: string; @@ -197,10 +226,7 @@ export interface MigrationPlan { * Origin contract identity that the plan expects the database to currently be at. * If omitted or null, the runner skips origin validation entirely. */ - readonly origin?: { - readonly storageHash: string; - readonly profileHash?: string; - } | null; + readonly origin?: PlanOrigin | null; /** Destination contract identity that the plan intends to reach. */ readonly destination: { readonly storageHash: string; @@ -268,6 +294,11 @@ export interface MigrationPlannerConflict { readonly why?: string; /** Set when the conflict is an operation the policy does not allow: the class of that operation. */ readonly refusedOperationClass?: MigrationOperationClass; + /** + * Set when the conflict refuses a statement: the statement, in domain coordinates. Consumers key + * on this field, not on the conflict's kind, which each family names. + */ + readonly refusedStatement?: ResolvedMigrationStatement; } /** @@ -280,6 +311,8 @@ export interface MigrationPlannerSuccessResult { readonly kind: 'success'; readonly plan: MigrationPlanWithAuthoringSurface; readonly warnings?: readonly MigrationPlannerConflict[]; + /** The statements the plan applied, one per statement it was given, in order. */ + readonly appliedStatements: readonly AppliedMigrationStatement[]; } /** @@ -438,23 +471,24 @@ export interface MigrationPlanner< readonly schema: unknown; readonly policy: MigrationOperationPolicy; /** - * The "from" contract (the state the planner assumes the database starts - * at), or `null` for a baseline plan with no prior state. - * - * Planners derive any "from" identity they need to stamp onto the - * produced plan's `describe()` from `fromContract?.storage.storageHash - * ?? null`. They also pass this to data-safety strategies so they can - * compare `from` and `to` column shapes (e.g. to detect unsafe type - * changes). - * - * Required at every call site to make the structural fact "I have a - * prior contract / I don't" visible in the type. Reconciliation - * commands (`db init`, `db update`) introspect a live schema and pass - * `null`; authoring commands (`migration plan`) read the predecessor's - * contract from the snapshot store by its storage hash and pass the - * parsed value. + * The contract the planner reads as the starting state, or `null` when it has none: statements + * resolve against it, and data-safety strategies compare its column shapes with the + * destination's. `migration plan` passes the predecessor's contract; `db update` passes the + * contract the marker names only when statements are given. */ readonly fromContract: Contract | null; + /** + * The origin the produced plan asserts, which the runner checks against the marker: the + * plan's `origin` and its `describe().from`. `migration plan` passes its starting contract's + * hash ({@link planOriginOf}); reconciliation commands (`db init`, `db update`) pass `null`, + * because their plans apply from whatever state the database is in. + */ + readonly origin: PlanOrigin | null; + /** + * Statements the user gave, resolved against `fromContract` and `contract`, + * in the order given. Empty when the user gave none. + */ + readonly statements: readonly ResolvedMigrationStatement[]; /** * Active framework components participating in this composition. * Families/targets can interpret this list to derive family-specific metadata. diff --git a/packages/1-framework/1-core/framework-components/src/control/migration-statements.ts b/packages/1-framework/1-core/framework-components/src/control/migration-statements.ts new file mode 100644 index 000000000000..9ff9b08a94af --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/control/migration-statements.ts @@ -0,0 +1,127 @@ +import type { ContractWithDomain, NamespaceId } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '../ir/namespace'; + +/** A model: its namespace and its name as the contract source writes it, never a table name. */ +export interface ModelCoordinate { + readonly namespaceId: NamespaceId; + readonly model: string; +} + +/** + * A field of a model, named as the contract's model keys it in `fields`. Whether that is the + * field's name in the contract source or its stored name in every family is not settled yet. + */ +export interface FieldCoordinate extends ModelCoordinate { + readonly field: string; +} + +export interface ResolvedModelRenameStatement { + readonly kind: 'rename'; + readonly entity: 'model'; + readonly from: ModelCoordinate; + readonly to: ModelCoordinate; +} + +export interface ResolvedFieldRenameStatement { + readonly kind: 'rename'; + readonly entity: 'field'; + readonly from: FieldCoordinate; + readonly to: FieldCoordinate; +} + +/** + * A statement resolved against the origin and destination contracts into domain coordinates: + * `from` is a coordinate of the origin contract and `to` one of the destination contract. Each + * member's `entity` names the kind of coordinate its sides use, so another kind of entity is a + * further member with its own coordinate type. + */ +export type ResolvedMigrationStatement = + | ResolvedModelRenameStatement + | ResolvedFieldRenameStatement; + +/** + * A statement as a plan applied it: `operationIndexes` are the positions, in the plan's + * `operations`, of the operations the statement accounts for, in plan order, and empty when the + * storage did not change. Statements refer to operations by position because operation ids are + * not unique within a plan. + */ +export interface AppliedMigrationStatement { + readonly statement: ResolvedMigrationStatement; + readonly operationIndexes: readonly number[]; +} + +/** + * A model's name in messages: `namespace.Model`, or `Model` alone in the unbound namespace, whose + * id is internal. + */ +export function modelDisplayName(coordinate: ModelCoordinate): string { + return coordinate.namespaceId === UNBOUND_NAMESPACE_ID + ? coordinate.model + : `${coordinate.namespaceId}.${coordinate.model}`; +} + +function modelName(contract: ContractWithDomain, coordinate: ModelCoordinate): string { + return Object.keys(contract.domain.namespaces).length > 1 + ? modelDisplayName(coordinate) + : coordinate.model; +} + +/** A model coordinate in JSON output: `namespaceId` is left out for the unbound namespace. */ +export interface ModelCoordinateJson { + readonly namespaceId?: NamespaceId; + readonly model: string; +} + +/** A field coordinate in JSON output: `namespaceId` is left out for the unbound namespace. */ +export interface FieldCoordinateJson extends ModelCoordinateJson { + readonly field: string; +} + +/** A resolved statement in JSON output, with each coordinate written as `*CoordinateJson`. */ +export type MigrationStatementJson = + | { + readonly kind: 'rename'; + readonly entity: 'model'; + readonly from: ModelCoordinateJson; + readonly to: ModelCoordinateJson; + } + | { + readonly kind: 'rename'; + readonly entity: 'field'; + readonly from: FieldCoordinateJson; + readonly to: FieldCoordinateJson; + }; + +function coordinateJson( + coordinate: T, +): Omit & ModelCoordinateJson { + if (coordinate.namespaceId !== UNBOUND_NAMESPACE_ID) return coordinate; + const { namespaceId: _unbound, ...rest } = coordinate; + return rest; +} + +/** The statement as JSON output writes it. */ +export function migrationStatementJson( + statement: ResolvedMigrationStatement, +): MigrationStatementJson { + return statement.entity === 'model' + ? { ...statement, from: coordinateJson(statement.from), to: coordinateJson(statement.to) } + : { ...statement, from: coordinateJson(statement.from), to: coordinateJson(statement.to) }; +} + +/** + * The text that reports a statement, in domain names: a model or field is named with its + * namespace only when its contract has more than one. A field is named through its model as the + * destination contract names it, as the statement itself is written. + */ +export function describeMigrationStatement( + statement: ResolvedMigrationStatement, + fromContract: ContractWithDomain, + contract: ContractWithDomain, +): string { + if (statement.entity === 'model') { + return `rename model "${modelName(fromContract, statement.from)}" to "${modelName(contract, statement.to)}"`; + } + const model = modelName(contract, statement.to); + return `rename field "${model}.${statement.from.field}" to "${model}.${statement.to.field}"`; +} diff --git a/packages/1-framework/1-core/framework-components/src/exports/control.ts b/packages/1-framework/1-core/framework-components/src/exports/control.ts index d8923904192a..191aa00a2672 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/control.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/control.ts @@ -4,6 +4,7 @@ export { CONTRACT_SNAPSHOTS_DIRNAME, contractSnapshotJsonSpecifier, contractSnapshotTypesSpecifier, + isStorageHashHex, storageHashHex, } from '../control/contract-snapshot-layout'; export type { @@ -60,10 +61,12 @@ export type { MigrationRunnerSuccessValue, MigrationScaffoldContext, OpFactoryCall, + PlanOrigin, SchemaEntityCoordinate, SchemaOwnership, TargetMigrationsCapability, } from '../control/control-migration-types'; +export { planOriginOf } from '../control/control-migration-types'; export type { OperationPreview, OperationPreviewStatement, @@ -119,6 +122,22 @@ export { extractComponentIds, extractQueryOperationTypeImports, } from '../control/control-stack'; +export type { + AppliedMigrationStatement, + FieldCoordinate, + FieldCoordinateJson, + MigrationStatementJson, + ModelCoordinate, + ModelCoordinateJson, + ResolvedFieldRenameStatement, + ResolvedMigrationStatement, + ResolvedModelRenameStatement, +} from '../control/migration-statements'; +export { + describeMigrationStatement, + migrationStatementJson, + modelDisplayName, +} from '../control/migration-statements'; export { orderIssuesByDependencies } from '../control/order-issues-by-dependencies'; export type { DiffableNode, diff --git a/packages/1-framework/1-core/framework-components/test/contract-snapshot-layout.test.ts b/packages/1-framework/1-core/framework-components/test/contract-snapshot-layout.test.ts index 255a0a15469f..fb1e4d1e2588 100644 --- a/packages/1-framework/1-core/framework-components/test/contract-snapshot-layout.test.ts +++ b/packages/1-framework/1-core/framework-components/test/contract-snapshot-layout.test.ts @@ -2,11 +2,22 @@ import { describe, expect, it } from 'vitest'; import { contractSnapshotJsonSpecifier, contractSnapshotTypesSpecifier, + isStorageHashHex, storageHashHex, } from '../src/control/contract-snapshot-layout'; const VALID_HASH = 'a'.repeat(64); +describe('isStorageHashHex', () => { + it('accepts 64 lowercase hex characters', () => { + expect(isStorageHashHex(VALID_HASH)).toBe(true); + }); + + it('rejects a prefixed hash', () => { + expect(isStorageHashHex(`sha256:${VALID_HASH}`)).toBe(false); + }); +}); + describe('storageHashHex', () => { it('returns a valid 64-hex hash unchanged', () => { expect(storageHashHex(VALID_HASH)).toBe('a'.repeat(64)); diff --git a/packages/1-framework/1-core/framework-components/test/control-migration-types.test.ts b/packages/1-framework/1-core/framework-components/test/control-migration-types.test.ts index 696dbbe8f1c9..8082b67c94f3 100644 --- a/packages/1-framework/1-core/framework-components/test/control-migration-types.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-migration-types.test.ts @@ -11,10 +11,12 @@ describe('MigrationPlannerSuccessResult', () => { const result: MigrationPlannerSuccessResult = { kind: 'success', plan: stubPlan, + appliedStatements: [], }; expect(result).toEqual({ kind: 'success', plan: stubPlan, + appliedStatements: [], }); expect('warnings' in result).toBe(false); }); @@ -27,6 +29,7 @@ describe('MigrationPlannerSuccessResult', () => { const result: MigrationPlannerSuccessResult = { kind: 'success', plan: stubPlan, + appliedStatements: [], warnings: Object.freeze([Object.freeze(warning)]), }; expect(result.warnings).toEqual([warning]); diff --git a/packages/1-framework/1-core/framework-components/test/migration-statements.test.ts b/packages/1-framework/1-core/framework-components/test/migration-statements.test.ts new file mode 100644 index 000000000000..7b82cba57e81 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/migration-statements.test.ts @@ -0,0 +1,136 @@ +import { asNamespaceId, type ContractWithDomain } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + describeMigrationStatement, + migrationStatementJson, + modelDisplayName, + type ResolvedMigrationStatement, +} from '../src/control/migration-statements'; +import { UNBOUND_NAMESPACE_ID } from '../src/ir/namespace'; + +function contractWith(models: Readonly>): ContractWithDomain { + return { + domain: { + namespaces: Object.fromEntries( + Object.entries(models).map(([namespaceId, names]) => [ + namespaceId, + { + models: Object.fromEntries( + names.map((name) => [name, { fields: {}, relations: {}, storage: {} }]), + ), + }, + ]), + ), + }, + }; +} + +const app = asNamespaceId('app'); + +function renameModel(from: string, to: string): ResolvedMigrationStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: app, model: from }, + to: { namespaceId: app, model: to }, + }; +} + +function renameField( + fromModel: string, + toModel: string, + from: string, + to: string, +): ResolvedMigrationStatement { + return { + kind: 'rename', + entity: 'field', + from: { namespaceId: app, model: fromModel, field: from }, + to: { namespaceId: app, model: toModel, field: to }, + }; +} + +describe('describeMigrationStatement', () => { + it('names models without their namespace when the contract has one namespace', () => { + expect( + describeMigrationStatement( + renameModel('Profile', 'User'), + contractWith({ app: ['Profile'] }), + contractWith({ app: ['User'] }), + ), + ).toBe('rename model "Profile" to "User"'); + }); + + it('names models with their namespace when the contract has several', () => { + expect( + describeMigrationStatement( + renameModel('Profile', 'User'), + contractWith({ app: ['Profile'], billing: ['Bill'] }), + contractWith({ app: ['User'], billing: ['Bill'] }), + ), + ).toBe('rename model "app.Profile" to "app.User"'); + }); + + it('names the old field through the model as the destination names it', () => { + expect( + describeMigrationStatement( + renameField('Profile', 'User', 'name', 'fullName'), + contractWith({ app: ['Profile'] }), + contractWith({ app: ['User'] }), + ), + ).toBe('rename field "User.name" to "User.fullName"'); + }); + + it('names a model in the unbound namespace without the internal namespace id', () => { + const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); + expect( + describeMigrationStatement( + { + kind: 'rename', + entity: 'model', + from: { namespaceId: unbound, model: 'Profile' }, + to: { namespaceId: unbound, model: 'User' }, + }, + contractWith({ [UNBOUND_NAMESPACE_ID]: ['Profile'], billing: ['Bill'] }), + contractWith({ [UNBOUND_NAMESPACE_ID]: ['User'], billing: ['Bill'] }), + ), + ).toBe('rename model "Profile" to "User"'); + }); +}); + +describe('modelDisplayName', () => { + it('qualifies a model with its namespace, except in the unbound namespace', () => { + expect([ + modelDisplayName({ namespaceId: app, model: 'User' }), + modelDisplayName({ namespaceId: asNamespaceId(UNBOUND_NAMESPACE_ID), model: 'User' }), + ]).toEqual(['app.User', 'User']); + }); +}); + +describe('migrationStatementJson', () => { + it('leaves namespaceId out for the unbound namespace and keeps it otherwise', () => { + const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); + expect([ + migrationStatementJson({ + kind: 'rename', + entity: 'field', + from: { namespaceId: unbound, model: 'User', field: 'name' }, + to: { namespaceId: unbound, model: 'User', field: 'fullName' }, + }), + migrationStatementJson(renameModel('Profile', 'User')), + ]).toEqual([ + { + kind: 'rename', + entity: 'field', + from: { model: 'User', field: 'name' }, + to: { model: 'User', field: 'fullName' }, + }, + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + ]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 89eb370604d9..d242debc1acc 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -944,15 +944,18 @@ Update your database schema to match the currently emitted contract. - Disables per-operation runner execution checks by default (precheck/postcheck/idempotency) - In `--dry-run` mode for SQL targets, prints a DDL preview derived from planned operations - In interactive mode, destructive plans require confirmation before apply -- In non-interactive mode, destructive plans fail unless `-y, --yes` is provided +- In non-interactive mode, destructive plans fail unless `--confirm ` is provided **Command:** ```bash -prisma db update [--db ] [--config ] [--dry-run] [-y|--yes] [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] +prisma db update [--db ] [--to ] [--advance-ref ] [--config ] [--dry-run] [--rename ]... [--confirm ] [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] ``` +**Rename statements (`--rename :`, repeatable):** tell `db update` that a model or field was renamed, so it renames the table or column instead of dropping and creating it. The statements work exactly as they do for `migration plan` (see below), and produce the same operations. The origin contract they resolve against is the contract the database's marker names, read from the local snapshot store (`migrations/snapshots//`). The snapshot is read only when statements are given; without them `db update` plans and applies exactly as it does without this flag. Either way the plan applies from whatever state the database is in, as `db update` always has. `db update` keeps a snapshot of the contract it applies whenever it advances a ref: the `db` ref by default, or with `--db ` only the ref you name with `--advance-ref `. So to use renames against a database you update with `--db `, give that earlier run `--advance-ref `. Without a readable snapshot, a run with statements fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN`, naming the hash and the directory it looked in, and gives the steps that store the missing snapshot: emit the contract the database is at, run `db update --advance-ref --dry-run` against it and check that it plans no operations, then run it without `--dry-run`, which changes nothing in the database and stores the snapshot, then emit the new contract and run the rename. A database with no marker has nothing to rename, and fails the same way. Running the same statements a second time fails with `MIGRATION.STATEMENT_UNRESOLVED`, because the database is now at a contract that no longer has the old names. The statements the plan applied are listed under `Statements applied` after the operations (dry run and apply), and as `appliedStatements` in `--json` output, each with its `description` and `operationIndexes`, the positions in `operations` of the operations it accounts for. + **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) +- `MIGRATION.STATEMENT_INVALID`, `MIGRATION.STATEMENT_UNRESOLVED`, `MIGRATION.STATEMENT_ORIGIN_UNKNOWN`: a `--rename` statement is malformed, does not resolve in the contracts, or has no origin contract to resolve against **Config File (`prisma.config.ts`):** @@ -1000,7 +1003,7 @@ The `contract.output` field specifies the path to `contract.json`. This is the c Plan a migration from contract changes. Compares a starting contract against a destination contract and produces a new migration package with the required operations. No database connection is needed — fully offline. ```bash -prisma migration plan [--config ] [--name ] [--from ] [--to ] [--json] [-v] [-q] [--color/--no-color] +prisma migration plan [--config ] [--name ] [--from ] [--to ] [--rename ]... [--confirm ] [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** @@ -1008,6 +1011,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `--name `: Name slug for the migration directory (default: `migration`) - `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, or `@empty`). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. - `--to `: Destination contract reference (hash, prefix, ref name, migration directory, or `^`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. +- `--rename :`: Rename a model or a field instead of dropping and creating it. Repeat the flag for several statements; they apply in the order given. See **Rename statements** below. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) @@ -1018,7 +1022,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t 3. Determines the starting point: `--from ` if provided, otherwise the `db` ref. When the ref is absent, greenfield only on an empty graph; over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed 4. Diffs the starting contract against the destination using the target's migration planner 5. Scaffolds a new migration package: `migration.ts` (containing `placeholder(...)` lambdas for any data transforms), `migration.json` (with a content-addressed `migrationHash` over the planned ops, or over `[]` when the planner could not lower any calls because of placeholders), and `ops.json` (the planned ops, or `[]` in the placeholder-blocked case). The bookend contracts are written write-if-absent into the shared snapshot store at `migrations/snapshots//contract.{json,d.ts}`. The package is **always** fully attested — there is no draft state on disk. -6. If the plan has unfilled `placeholder(...)` slots, the command returns a successful `pendingPlaceholders` envelope (a warning, not a failure) asking the developer to fill in the slots before re-emitting. The on-disk `ops.json` is `[]` and `migrationHash` is the hash of `(metadata, [])`, so applying the migration as-written will not advance the storage hash to the intended destination — the runner's destination-hash post-check surfaces this as a state mismatch. After filling in the placeholders, run `node migrations//migration.ts` to re-emit `ops.json` and the corresponding `migrationHash`. `PN-MIG-2001` is raised only at self-emit time when a slot is still unfilled. +6. If the plan has unfilled `placeholder(...)` slots, the command returns a successful `pendingPlaceholders` envelope (a warning, not a failure) asking the developer to fill in the slots before re-emitting. The output still lists the operations that resolved, and any `Statements applied`, so a rename and the drops beside it show; JSON `operations` lists the same operations, so the `operationIndexes` of `appliedStatements` point into it. The on-disk `ops.json` is `[]` and `migrationHash` is the hash of `(metadata, [])`, so applying the migration as-written will not advance the storage hash to the intended destination — the runner's destination-hash post-check surfaces this as a state mismatch. After filling in the placeholders, run `node migrations//migration.ts` to re-emit `ops.json` and the corresponding `migrationHash`. `PN-MIG-2001` is raised only at self-emit time when a slot is still unfilled. **Outputs:** - `migrations//migration.ts` — editable migration source (with `placeholder(...)` slots when the planner inserted them) @@ -1026,6 +1030,8 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `migrations//ops.json` — planned operations (empty list `[]` if placeholders blocked the planner) - `migrations/snapshots//contract.{json,d.ts}` — bookend contracts, written write-if-absent, keyed by each contract's storage hash (one entry for `from` when applicable, one for `to`) +**Rename statements:** each `--rename` names the old and the new name in contract vocabulary, never a table or a column. Each side is one of `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`; both sides name a model, or both name a field of the same model. Names match exactly, including case. A name without a namespace resolves when exactly one namespace declares the model. A field's model is named as the destination contract names it, so a model rename followed by a field rename on it is `--rename Profile:User --rename User.name:User.fullName`. The old name must exist in the starting contract and not in the destination; the new name must exist in the destination and not in the starting contract. Statements resolve before anything is written; a statement that is malformed fails with `MIGRATION.STATEMENT_INVALID`, and one that does not resolve fails with `MIGRATION.STATEMENT_UNRESOLVED`, naming what it searched and what it found. Each error's next step is the statement to type instead: the accepted forms, the statement written the right way round, the statements a model-and-field mix may have meant, or the order that works. A statement that has already been applied (the origin already has the new name and not the old one) says to leave it out. Statements cannot swap two names in one plan, and the error gives the three plans that do, through a temporary name. A plan from an empty database (`--from @empty`, or greenfield) has no models to rename, so every statement is unresolved. Moving a model to another namespace and renaming value objects are not supported in this release. On MongoDB the planner refuses every statement in this release: planning fails with `MIGRATION.PLANNING_FAILED`, carrying the statement, and nothing is planned. The planner renames the table or column, and the constraints and indexes named after it, ahead of the rest of the plan. On Postgres, a check, a row-level-security policy, or an expression or partial index whose SQL names a renamed column is dropped and created again, and adding a check back reads every row of the table under an exclusive lock, so on a large table such a rename takes time; `migration.ts` contains the same `...this.renameTable(...)` and `...this.renameColumn(...)` calls you could have written by hand. The statements are listed under `Statements applied` after the operations, each with its number of operations, and as `appliedStatements` in `--json` output, each with its `statement`, `description` and `operationIndexes`, the positions in `operations` of the operations it accounts for. A coordinate in a contract with no namespaces has no `namespaceId` in the JSON. A statement whose storage does not change, such as a model whose table name is kept with `@@map`, is listed with no operations. When the storage did not change at all and no statement needs an operation, no migration package is written and the output says so; when the storage changed but the planner planned nothing, planning fails with `MIGRATION.PLANNING_FAILED`, statements or not. A statement the planner cannot carry out, for example on a table whose control policy is not `managed`, fails planning with `MIGRATION.PLANNING_FAILED`, and the refused statement is carried in the error's conflicts. + **Branching with `--from` and `--to`:** Use `--from` to create a migration edge from a specific contract hash instead of the default starting point. Use `--to` to plan toward any resolved contract — including a rollback via `^` — instead of the emitted contract. This enables branched migration graphs and arbitrary-target (including reverse) edges without editing contract source. ### `prisma migration show` diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index a3720e2045f3..1f2ec11223c5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -420,6 +420,7 @@ class ControlClientImpl implements ControlClient { extensions: this.options.extensions ?? [], ...ifDefined('acceptDataLoss', options.acceptDataLoss), ...ifDefined('consent', options.consent), + ...ifDefined('statements', options.statements), ...ifDefined('verifySnapshotContent', this.snapshotVerifier), ...ifDefined('onProgress', onProgress), }); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts index c3b8701fbcaf..02781e5e2b37 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts @@ -13,21 +13,39 @@ import type { MigrationPlannerConflict, MigrationPlanOperation, OperationPreview, + ResolvedMigrationStatement, TargetMigrationsCapability, } from '@internal/framework-components/control'; -import { hasOperationPreview } from '@internal/framework-components/control'; +import { + CONTRACT_SNAPSHOTS_DIRNAME, + hasOperationPreview, + isStorageHashHex, +} from '@internal/framework-components/control'; +import type { ContractMarkerRecordLike } from '@internal/migration-tools/aggregate'; import { type ContractSpaceAggregate, collectAggregateNamespaces, type PlannerError, planMigration, } from '@internal/migration-tools/aggregate'; -import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; +import { + contractSnapshotDir, + readContractSnapshotJson, + type SnapshotContentVerifier, +} from '@internal/migration-tools/contract-snapshot-store'; +import { MigrationToolsError } from '@internal/migration-tools/errors'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok } from '@internal/utils/result'; +import { join } from 'pathe'; import { CliStructuredError } from '../../utils/cli-errors'; +import { + type AppliedStatementReport, + reportAppliedStatements, +} from '../statements/report-applied-statements'; +import { resolveStatements, type StatementOrigin } from '../statements/resolve-statements'; +import type { StatementText } from '../statements/statement-text'; import type { DbInitFailure, DbInitResult, @@ -70,7 +88,7 @@ const SPAN_IDS = { * loader gathers the rest from disk + descriptors. The CLI is the * descriptor-import boundary; everything downstream is descriptor-free. */ -export interface ExecuteRunOptions { +export interface ExecuteRunSharedOptions { readonly driver: ControlDriverInstance; readonly adapter: ControlAdapterInstance; readonly familyInstance: ControlFamilyInstance; @@ -86,7 +104,6 @@ export interface ExecuteRunOptions>; readonly targetId: TTargetId; readonly policy: MigrationOperationPolicy; - readonly action: 'dbInit' | 'dbUpdate'; /** * Identity of the plan the caller consented to (`db update` only). When * set, the apply refuses with `CONSENT_PLAN_MISMATCH` if the freshly @@ -99,6 +116,16 @@ export interface ExecuteRunOptions = ExecuteRunSharedOptions & + ( + | { readonly action: 'dbInit' } + | { readonly action: 'dbUpdate'; readonly statements: readonly StatementText[] } + ); + /** * Loader → planner → runner pipeline shared by `db init` and `db update`. * @@ -156,6 +183,31 @@ export async function executeRun 0) { + const appOrigin = await readAppOrigin({ + marker: markerRows.get(aggregate.app.spaceId) ?? null, + migrationsDir, + deserializeContract: (json) => familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), + }); + const resolved = resolveStatements({ + statements: statementTexts, + origin: appOrigin.origin, + destination: contract, + }); + if (!resolved.ok) { + throw resolved.failure; + } + statements = resolved.value; + fromContract = appOrigin.contract; + } + // 2a. Orphan-marker pre-flight: refuse to *apply* when a marker row // exists for a space that is not declared in the aggregate. Plan mode // (`db init/update --dry-run`) must still be able to introspect the @@ -199,6 +251,7 @@ export async function executeRun Contract; + readonly verifySnapshotContent?: SnapshotContentVerifier; +}): Promise<{ readonly origin: StatementOrigin; readonly contract: Contract | null }> { + const snapshotsDir = join(input.migrationsDir, CONTRACT_SNAPSHOTS_DIRNAME); + if (input.marker === null) { + const origin: StatementOrigin = { + kind: 'missing', + hash: null, + snapshotDirectory: snapshotsDir, + unreadable: undefined, + }; + return { origin, contract: null }; + } + const hash = input.marker.storageHash; + if (!isStorageHashHex(hash)) { + return { + origin: { kind: 'missing', hash, snapshotDirectory: snapshotsDir, unreadable: undefined }, + contract: null, + }; + } + const snapshotDirectory = contractSnapshotDir(input.migrationsDir, hash); + let json: unknown; + try { + json = await readContractSnapshotJson(input.migrationsDir, hash, input.verifySnapshotContent); + } catch (error) { + if (!MigrationToolsError.is(error)) throw error; + const unreadable = error.code === 'MIGRATION.CONTRACT_SNAPSHOT_MISSING' ? undefined : error.why; + return { origin: { kind: 'missing', hash, snapshotDirectory, unreadable }, contract: null }; + } + try { + const contract = input.deserializeContract(json); + return { origin: { kind: 'contract', contract }, contract }; + } catch (error) { + const unreadable = error instanceof Error ? error.message : String(error); + return { origin: { kind: 'missing', hash, snapshotDirectory, unreadable }, contract: null }; + } +} + +/** How many operations the spaces applied before `spaceId` list ahead of it in `operations`. */ +function operationsBefore( + orderedResolutions: readonly OrderedResolution[], + spaceId: string, +): number { + const position = orderedResolutions.findIndex((resolution) => resolution.spaceId === spaceId); + return orderedResolutions + .slice(0, position) + .reduce((count, resolution) => count + resolution.entry.displayOps.length, 0); +} + function aggregatePlannerWarnings( orderedResolutions: readonly OrderedResolution[], ): readonly MigrationPlannerConflict[] | undefined { @@ -408,10 +528,13 @@ function wrapPlanResult(args: { readonly preview: OperationPreview | undefined; readonly perSpace: readonly PerSpaceExecutionEntry[]; readonly summary: string; + /** `undefined` for `db init`, which reports no statements. */ + readonly appliedStatements: readonly AppliedStatementReport[] | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { mode: 'plan', + ...ifDefined('appliedStatements', args.appliedStatements), plan: { operations: stripOperations(args.operations), ...ifDefined('preview', args.preview), @@ -434,10 +557,13 @@ function wrapApplyResult(args: { readonly operationsExecuted: number; readonly perSpace: readonly PerSpaceExecutionEntry[]; readonly summary: string; + /** `undefined` for `db init`, which reports no statements. */ + readonly appliedStatements: readonly AppliedStatementReport[] | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { mode: 'apply', + ...ifDefined('appliedStatements', args.appliedStatements), plan: { operations: stripOperations(args.operations) }, destination: { storageHash: args.destination.storageHash, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts index 74b7ff077b13..df3ac1e68b2e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts @@ -10,8 +10,9 @@ import type { import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { ifDefined } from '@internal/utils/defined'; import { notOk } from '@internal/utils/result'; +import type { StatementText } from '../statements/statement-text'; import type { DbUpdateResult, OnControlProgress } from '../types'; -import { executeRun } from './db-run'; +import { type ExecuteRunSharedOptions, executeRun } from './db-run'; import { computePlanHash } from './plan-identity'; export const DB_UPDATE_POLICY = { @@ -51,6 +52,11 @@ export interface ExecuteDbUpdateOptions>; /** Content check for contract snapshots the aggregate loader resolves. */ readonly verifySnapshotContent?: SnapshotContentVerifier; + /** + * The statements as the user wrote them, in the order given. Both the plan + * and the destructive pre-plan resolve and apply them. + */ + readonly statements?: readonly StatementText[]; readonly onProgress?: OnControlProgress; } @@ -78,6 +84,7 @@ export async function executeDbUpdate( - sharedInputs: Omit>[0], 'mode'>, + sharedInputs: Omit, 'mode'> & { + readonly action: 'dbUpdate'; + readonly statements: readonly StatementText[]; + }, ): Promise { const planResult = (await executeRun({ ...sharedInputs, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index effb0f383617..b1cba6bf26f5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -508,6 +508,7 @@ function buildAtHeadResolution(args: { displayOps: [], destinationContract: space.contract(), strategy: 'declared-state', + appliedStatements: [], migrationEdges: [ buildFabricatedMigrationEdge({ currentMarkerStorageHash: liveMarker?.storageHash, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 19a4d8fbd899..93d1310f6c96 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -4,12 +4,14 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; -import type { Contract } from '@internal/contract/types'; +import type { Contract, ContractWithDomain } from '@internal/contract/types'; import { createControlStack, hasOperationPreview, type MigrationPlanOperation, type OperationPreview, + planOriginOf, + type ResolvedMigrationStatement, type SchemaOwnership, } from '@internal/framework-components/control'; import { @@ -37,7 +39,7 @@ import { errorPlanProducedNoOperations, errorTargetMigrationNotSupported, type PlanDestination, - type PlanOrigin, + type PlanLegOrigin, } from '../../utils/cli-errors'; import { getTargetMigrations, @@ -48,6 +50,12 @@ import { toExtensionInputs } from '../../utils/extension-pack-inputs'; import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; import { createProjectSpecifierResolver } from '../../utils/project-import-root'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; +import { + type AppliedStatementReport, + reportAppliedStatements, +} from '../statements/report-applied-statements'; +import { resolveStatements } from '../statements/resolve-statements'; +import type { StatementText } from '../statements/statement-text'; import type { ControlClient, DestructivePlanOperation } from '../types'; import { errorFromCaught } from './caught-errors'; import { @@ -75,6 +83,11 @@ export interface MigrationPlanOptions { readonly name?: string; readonly from?: string; readonly to?: string; + /** + * The statements as the user wrote them, in the order given. They resolve + * against the origin and destination contracts before anything is written. + */ + readonly statements?: readonly StatementText[]; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; /** @@ -107,27 +120,46 @@ type PlannerSuccess = { readonly plannedOps: readonly MigrationPlanOperation[]; readonly migrationTsContent: string; readonly hasPlaceholders: boolean; + readonly appliedStatements: readonly AppliedStatementReport[]; }; +/** The origin of a plan from an empty database: no models, so no statement resolves. */ +const EMPTY_ORIGIN: ContractWithDomain = { domain: { namespaces: {} } }; + type TargetMigrationsApi = NonNullable>; /** A plan origin whose earlier contract the planner diffs against. */ -type PlannerLegOrigin = - | Exclude - | (Extract & { readonly contract: Contract }); +type PlanLegOriginWithContract = + | Exclude + | (Extract & { readonly contract: Contract }); + +/** + * Why a planner leg failed: the planner refused (a conflict, such as a refused statement), or it + * produced no operations for a changed contract. + */ +interface PlannerLegFailure { + readonly reason: 'refused' | 'noOperations'; + readonly error: CliStructuredError; +} async function runPlannerLeg( planner: ReturnType, migrations: TargetMigrationsApi, frameworkComponents: ReturnType, contract: Contract, - origin: PlannerLegOrigin, + origin: PlanLegOriginWithContract, destination: PlanDestination, + statements: readonly ResolvedMigrationStatement[], + /** + * True when the storage did not change and statements were given: a plan of + * no operations then means the statements need none, not a planning failure. + */ + noOperationsExpected: boolean, spaceId: string, ownership: SchemaOwnership, snapshotsImportPath: string, resolveImportSpecifier: ImportSpecifierResolver, -): Promise> { +): Promise> { const fromContract = origin.kind === 'contract' ? origin.contract : null; const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents); const plannerResult = planner.plan({ @@ -135,6 +167,8 @@ async function runPlannerLeg( schema: fromSchema, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] }, fromContract, + origin: planOriginOf(fromContract), + statements, frameworkComponents, spaceId, // Offline `migration plan` is the aggregate-of-(possibly one) degenerate @@ -146,19 +180,23 @@ async function runPlannerLeg( snapshotsImportPath, }); if (plannerResult.kind === 'failure') { - return notOk( - errorMigrationPlanningFailed({ + return notOk({ + reason: 'refused', + error: errorMigrationPlanningFailed({ conflicts: castAs(plannerResult.conflicts), }), - ); + }); } let plannedOps: readonly MigrationPlanOperation[] = []; let hasPlaceholders = false; try { plannedOps = await Promise.all(plannerResult.plan.operations); - if (plannedOps.length === 0) { - return notOk(errorPlanProducedNoOperations(origin, destination)); + if (plannedOps.length === 0 && !noOperationsExpected) { + return notOk({ + reason: 'noOperations', + error: errorPlanProducedNoOperations(origin, destination), + }); } } catch (e) { if (CliStructuredError.is(e) && e.code === 'MIGRATION.UNFILLED_PLACEHOLDER') { @@ -186,9 +224,23 @@ async function runPlannerLeg( plannedOps, migrationTsContent: plannerResult.plan.renderTypeScript(resolveImportSpecifier), hasPlaceholders, + appliedStatements: reportAppliedStatements( + plannerResult.appliedStatements, + fromContract, + contract, + 0, + ), }); } +/** + * The operations a result lists. For a leg with unfilled placeholders these are the operations + * that resolved; the placeholders themselves are not operations yet. + */ +function operationSummaries(ops: readonly MigrationPlanOperation[]) { + return ops.map((op) => ({ id: op.id, label: op.label, operationClass: op.operationClass })); +} + async function writePlannedMigrationPackage( packageDir: string, fromHash: string | null, @@ -320,6 +372,12 @@ export interface MigrationPlanResult { * defaulted to the empty contract. Absent when the user named the origin. */ readonly fromDefaulted?: boolean; + /** + * The statements the plan applied, in the order given, each with the + * family's description and its number of operations. Empty when no + * `--rename` was given. + */ + readonly appliedStatements: readonly AppliedStatementReport[]; readonly timings: { readonly total: number; }; @@ -490,7 +548,7 @@ async function executeMigrationPlanCommandInner( break; } const resolvedFrom = resolutionResult.value; - const fromOrigin: PlannerLegOrigin = + const fromOrigin: PlanLegOriginWithContract = resolvedFrom.kind === 'greenfield' ? { kind: 'empty' } : { @@ -515,6 +573,22 @@ async function executeMigrationPlanCommandInner( destinationInStore = true; } + // Statements resolve against the two contracts now settled, before anything + // is written. A plan from an empty database has no model to rename. + let statements: readonly ResolvedMigrationStatement[] = []; + const statementTexts = options.statements ?? []; + if (statementTexts.length > 0) { + const resolved = resolveStatements({ + statements: statementTexts, + origin: { kind: 'contract', contract: fromContract ?? EMPTY_ORIGIN }, + destination: toContract, + }); + if (!resolved.ok) { + return notOk(resolved.failure); + } + statements = resolved.value; + } + // Before the seed phase, which is the first thing here that writes: an // unreadable or contradictory project manifest fails the command outright // rather than after artifacts are already on disk. @@ -567,7 +641,7 @@ async function executeMigrationPlanCommandInner( // Check for no-op (same hash means no changes). Auto-baseline is exempt: // an empty graph with db ref at the current contract still needs a // null → fromHash baseline bundle so migrate can anchor the marker. - if (fromHash === toStorageHash && !isAutoBaseline) { + if (fromHash === toStorageHash && !isAutoBaseline && statements.length === 0) { const result: MigrationPlanResult = { ok: true, noOp: true, @@ -576,6 +650,7 @@ async function executeMigrationPlanCommandInner( operations: [], emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), + appliedStatements: [], summary: 'No changes detected between contracts', timings: { total: Date.now() - startTime }, }; @@ -650,13 +725,15 @@ async function executeMigrationPlanCommandInner( fromContract, { kind: 'baseline', hash: fromHash }, { hash: fromHash, isEmitted: false }, + [], + false, aggregate.app.spaceId, aggregate, snapshotsImportPathFrom(baselinePackageDir, migrationsDir), resolveImportSpecifier, ); if (!baselineLeg.ok) { - return notOk(baselineLeg.failure); + return notOk(baselineLeg.failure.error); } const consentFailure = refuseUnconsentedDestructiveBaseline( @@ -668,6 +745,29 @@ async function executeMigrationPlanCommandInner( return notOk(consentFailure); } + const deltaLeg = + fromHash === toStorageHash + ? undefined + : await runPlannerLeg( + planner, + migrations, + frameworkComponents, + aggregate.app.contract(), + fromOrigin, + planDestination, + statements, + false, + aggregate.app.spaceId, + aggregate, + snapshotsImportPathFrom(deltaPackageDir, migrationsDir), + resolveImportSpecifier, + ); + // A refused delta writes nothing. A delta with no operations still writes the baseline, so + // the `migration new --from` its error advises has the history to start from. + if (deltaLeg !== undefined && !deltaLeg.ok && deltaLeg.failure.reason === 'refused') { + return notOk(deltaLeg.failure.error); + } + await writePlannedMigrationPackage( baselinePackageDir, null, @@ -676,7 +776,17 @@ async function executeMigrationPlanCommandInner( baselineLeg.value, ); - if (fromHash === toStorageHash) { + if (deltaLeg !== undefined && !deltaLeg.ok) { + return notOk(deltaLeg.failure.error); + } + + if (deltaLeg === undefined) { + const statementsWithoutOperations = reportAppliedStatements( + statements.map((statement) => ({ statement, operationIndexes: [] })), + fromContract, + aggregate.app.contract(), + 0, + ); const baselineOps = baselineLeg.value.hasPlaceholders ? [] : baselineLeg.value.plannedOps; if (baselineLeg.value.hasPlaceholders) { const baselineDir = relative(cwd, baselinePackageDir); @@ -687,10 +797,11 @@ async function executeMigrationPlanCommandInner( to: toStorageHash, dir: baselineDir, baselineDir, - operations: [], + operations: operationSummaries(baselineLeg.value.plannedOps), emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, + appliedStatements: statementsWithoutOperations, summary: 'Planned baseline with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -715,28 +826,13 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), ...(warnings.length > 0 ? { warnings } : {}), + appliedStatements: statementsWithoutOperations, summary: buildAutoBaselinePlanSummary(baselineOps.length, 0, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; return ok(result); } - const deltaLeg = await runPlannerLeg( - planner, - migrations, - frameworkComponents, - aggregate.app.contract(), - fromOrigin, - planDestination, - aggregate.app.spaceId, - aggregate, - snapshotsImportPathFrom(deltaPackageDir, migrationsDir), - resolveImportSpecifier, - ); - if (!deltaLeg.ok) { - return notOk(deltaLeg.failure); - } - await writePlannedMigrationPackage( deltaPackageDir, fromHash, @@ -756,10 +852,12 @@ async function executeMigrationPlanCommandInner( to: toStorageHash, dir: relative(cwd, deltaPackageDir), baselineDir: relative(cwd, baselinePackageDir), - operations: [], + operations: operationSummaries(deltaLeg.value.plannedOps), + baselineOperations: operationSummaries(baselineLeg.value.plannedOps), emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, + appliedStatements: deltaLeg.value.appliedStatements, summary: 'Planned baseline + migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -792,6 +890,7 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), ...(warnings.length > 0 ? { warnings } : {}), + appliedStatements: deltaLeg.value.appliedStatements, summary: buildAutoBaselinePlanSummary( baselineOps.length, deltaOps.length, @@ -814,13 +913,31 @@ async function executeMigrationPlanCommandInner( aggregate.app.contract(), fromOrigin, planDestination, + statements, + statements.length > 0 && fromHash === toStorageHash, aggregate.app.spaceId, aggregate, snapshotsImportPathFrom(packageDir, migrationsDir), resolveImportSpecifier, ); if (!deltaLeg.ok) { - return notOk(deltaLeg.failure); + return notOk(deltaLeg.failure.error); + } + + if (!deltaLeg.value.hasPlaceholders && deltaLeg.value.plannedOps.length === 0) { + const result: MigrationPlanResult = { + ok: true, + noOp: true, + from: fromHash, + to: toStorageHash, + operations: [], + emittedExtensionDirs, + ...(warnings.length > 0 ? { warnings } : {}), + appliedStatements: deltaLeg.value.appliedStatements, + summary: 'No changes to plan: the statements need no operations', + timings: { total: Date.now() - startTime }, + }; + return ok(result); } await writePlannedMigrationPackage( @@ -839,11 +956,12 @@ async function executeMigrationPlanCommandInner( from: fromHash, to: toStorageHash, dir: relative(cwd, packageDir), - operations: [], + operations: operationSummaries(deltaLeg.value.plannedOps), emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, ...(fromDefaulted ? { fromDefaulted } : {}), + appliedStatements: deltaLeg.value.appliedStatements, summary: 'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -870,6 +988,7 @@ async function executeMigrationPlanCommandInner( ...(preview !== undefined ? { preview } : {}), ...(fromDefaulted ? { fromDefaulted } : {}), ...(warnings.length > 0 ? { warnings } : {}), + appliedStatements: deltaLeg.value.appliedStatements, summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index c2acd022512c..28eaa661426d 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -62,6 +62,8 @@ export function retryCommandFor(args: { readonly from?: string | undefined; readonly to: string | undefined; readonly advanceRef?: string | undefined; + /** The `--rename` statements as given, in order. */ + readonly renames?: readonly string[] | undefined; /** The command runs without a database when `--from` names a contract. */ readonly canRunOffline: boolean; }): string { @@ -74,6 +76,7 @@ export function retryCommandFor(args: { ...(suggestsOffline ? ['--from '] : []), ...(args.to === undefined ? [] : [`--to ${args.to}`]), ...(args.advanceRef === undefined ? [] : [`--advance-ref ${args.advanceRef}`]), + ...(args.renames ?? []).map((rename) => `--rename ${rename}`), ...(needsConnection ? ['--db $DATABASE_URL'] : []), ].join(' '); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-rename.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-rename.ts new file mode 100644 index 000000000000..bd6fb39ba65a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-rename.ts @@ -0,0 +1,95 @@ +import type { CliStructuredError } from '@internal/errors/control'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { errorStatementInvalid } from '../../utils/cli-errors'; +import type { StatementText } from './statement-text'; + +/** One side of a statement: `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`. */ +export type StatementSide = + | readonly [string] + | readonly [string, string] + | readonly [string, string, string]; + +export interface ParsedRename extends StatementText { + readonly verb: 'rename'; + readonly from: StatementSide; + readonly to: StatementSide; +} + +const MAX_SEGMENTS = 3; + +/** The forms a statement takes, with an example of each. */ +export function statementFormsFix(verb: string): string { + return `Write the statement as --${verb} :, where each side is Model, namespace.Model, Model.field or namespace.Model.field, for example --${verb} Profile:User for a model or --${verb} User.name:User.fullName for a field.`; +} + +/** + * The two statements a user who named a model on one side and a field on the other may have + * meant, written with their names. + */ +export function mixedSidesFix(verb: string, from: StatementSide, to: StatementSide): string { + const modelIsOld = from.length < to.length; + const field = modelIsOld ? to : from; + const model = (modelIsOld ? from : to).join('.'); + const fieldModel = field.slice(0, -1).join('.'); + const fieldName = field[field.length - 1]; + const [modelStatement, fieldStatement] = modelIsOld + ? [`${model}:${fieldModel}`, `${fieldModel}.:${field.join('.')}`] + : [`${fieldModel}:${model}`, `${model}.${fieldName}:${model}.`]; + return `To rename the model, write --${verb} ${modelStatement}. To rename a field, write --${verb} ${fieldStatement}, naming the field's model as the new contract names it.`; +} + +function invalid(statement: StatementText, why: string): CliStructuredError { + return errorStatementInvalid(statement, why, statementFormsFix(statement.verb)); +} + +function parseSide( + statement: StatementText, + side: string, +): Result { + const segments = side.split('.'); + if (segments.some((segment) => segment === '')) { + return notOk(invalid(statement, `"${side}" has an empty name.`)); + } + const [first, second, third] = segments; + if (first === undefined || segments.length > MAX_SEGMENTS) { + return notOk( + invalid(statement, `"${side}" has ${segments.length} names; at most three are allowed.`), + ); + } + if (second === undefined) return ok([first]); + if (third === undefined) return ok([first, second]); + return ok([first, second, third]); +} + +function namesModelAndField(from: StatementSide, to: StatementSide): boolean { + const lengths = [from.length, to.length]; + return lengths.includes(1) && lengths.includes(MAX_SEGMENTS); +} + +/** Parses the text of one rename statement, `:`. */ +export function parseRenameStatement( + statement: StatementText & { readonly verb: 'rename' }, +): Result { + const sides = statement.text.split(':'); + const [oldSide, newSide] = sides; + if (oldSide === undefined || newSide === undefined) { + return notOk(invalid(statement, 'The statement has no ":" between the old and new name.')); + } + if (sides.length > 2) { + return notOk(invalid(statement, 'The statement has more than one ":".')); + } + const from = parseSide(statement, oldSide); + if (!from.ok) return from; + const to = parseSide(statement, newSide); + if (!to.ok) return to; + if (namesModelAndField(from.value, to.value)) { + return notOk( + errorStatementInvalid( + statement, + 'The statement names a model on one side and a field on the other.', + mixedSidesFix(statement.verb, from.value, to.value), + ), + ); + } + return ok({ verb: 'rename', text: statement.text, from: from.value, to: to.value }); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/report-applied-statements.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/report-applied-statements.ts new file mode 100644 index 000000000000..101143dbcaf9 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/report-applied-statements.ts @@ -0,0 +1,35 @@ +import type { ContractWithDomain } from '@internal/contract/types'; +import { + type AppliedMigrationStatement, + describeMigrationStatement, + type MigrationStatementJson, + migrationStatementJson, +} from '@internal/framework-components/control'; + +/** + * A statement a plan applied, as the CLI reports it: the statement as JSON output writes it, the + * positions of its operations in the result's `operations`, and its description. + */ +export interface AppliedStatementReport { + readonly statement: MigrationStatementJson; + readonly operationIndexes: readonly number[]; + /** The statement in domain names, for example `rename model "Profile" to "User"`. */ + readonly description: string; +} + +/** + * `operationOffset` is the number of operations the result lists before the plan that applied the + * statements, so that each position indexes the result's `operations`. + */ +export function reportAppliedStatements( + applied: readonly AppliedMigrationStatement[], + fromContract: ContractWithDomain | null, + contract: ContractWithDomain, + operationOffset: number, +): readonly AppliedStatementReport[] { + return applied.map((entry) => ({ + statement: migrationStatementJson(entry.statement), + operationIndexes: entry.operationIndexes.map((index) => operationOffset + index), + description: describeMigrationStatement(entry.statement, fromContract ?? contract, contract), + })); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/resolve-statements.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/resolve-statements.ts new file mode 100644 index 000000000000..b272fcc2db36 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/resolve-statements.ts @@ -0,0 +1,727 @@ +import { + type ApplicationDomainNamespace, + asNamespaceId, + type ContractModelBase, + type ContractWithDomain, +} from '@internal/contract/types'; +import type { CliStructuredError } from '@internal/errors/control'; +import { + type FieldCoordinate, + type ModelCoordinate, + modelDisplayName, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { ifDefined } from '@internal/utils/defined'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { + errorStatementInvalid, + errorStatementOriginUnknown, + errorStatementUnresolved, +} from '../../utils/cli-errors'; +import { + mixedSidesFix, + type ParsedRename, + parseRenameStatement, + type StatementSide, +} from './parse-rename'; +import type { StatementText } from './statement-text'; + +/** + * The origin contract statements resolve against, or where the command looked + * for it without finding it. + */ +export type StatementOrigin = + | { readonly kind: 'contract'; readonly contract: ContractWithDomain } + | { + readonly kind: 'missing'; + readonly hash: string | null; + readonly snapshotDirectory: string; + /** Why the snapshot that exists for `hash` could not be read; `undefined` when there is none. */ + readonly unreadable: string | undefined; + }; + +/** + * `origin` and `destination` are the application contract space's contracts; + * names that exist only in an extension space never resolve. + */ +export interface ResolveStatementsInput { + /** The statements as the user wrote them, in the order given. */ + readonly statements: readonly StatementText[]; + readonly origin: StatementOrigin; + readonly destination: ContractWithDomain; +} + +type Side = 'old' | 'new'; + +interface NamedContract { + readonly name: 'origin' | 'destination'; + readonly contract: ContractWithDomain; +} + +interface FoundModel { + readonly coordinate: ModelCoordinate; + readonly model: ContractModelBase; +} + +interface Refusal { + readonly reason: string; + readonly fix: string; + /** Set when a field's model has no origin counterpart: the model, as the destination names it. */ + readonly modelWithoutCounterpart?: string; +} + +type ModelLookup = + | ({ readonly kind: 'found' } & FoundModel) + | ({ readonly kind: 'missing' | 'refused' } & Refusal); + +interface ModelReading extends FoundModel { + readonly entity: 'model'; +} + +interface FieldReading { + readonly entity: 'field'; + readonly coordinate: FieldCoordinate; + readonly model: ContractModelBase; + readonly destinationModel: ModelCoordinate | undefined; +} + +type SideReading = ModelReading | FieldReading; + +interface ResolutionState { + readonly origin: NamedContract; + readonly destination: NamedContract; + readonly originOfRenamedModel: Map; + readonly renamedFrom: Set; + readonly renamedTo: Set; +} + +const MODEL_RENAMES = 'model'; +const FIELD_RENAMES = 'field'; +const LISTED_LIMIT = 20; + +const CHECK_NAMES_FIX = + 'The old name must exist in the origin contract and not in the destination contract, and the new name the other way round. Names match exactly, including case.'; +const STORED_NAME_ONLY = + 'If only the stored name changed (@map or @@map), a statement cannot state that in this release, and a plan without it drops and creates what the model or field is stored in, with its data. With migration plan, edit the planned migration.ts to rename the stored name instead of dropping and creating it. With db update, rename it in the database yourself first, then run db update, which then finds nothing that loses data.'; +const VALUE_OBJECT_FIX = 'Leave value objects and their fields out of the statements.'; +const REPEATED_NAME_FIX = + 'Give each model or field at most one statement, and each new name to one model or field only.'; + +function qualifiedModel(coordinate: ModelCoordinate): string { + return modelDisplayName(coordinate); +} + +function displayName(namespaceId: string, name: string): string { + return modelDisplayName({ namespaceId: asNamespaceId(namespaceId), model: name }); +} + +function qualifiedField(coordinate: FieldCoordinate): string { + return `${qualifiedModel(coordinate)}.${coordinate.field}`; +} + +function listed(names: readonly string[]): string { + if (names.length === 0) return '(none)'; + const sorted = [...names].sort(); + const shown = sorted.slice(0, LISTED_LIMIT).join(', '); + return sorted.length > LISTED_LIMIT ? `${shown} and ${sorted.length - LISTED_LIMIT} more` : shown; +} + +function sentence(reason: string): string { + return `${reason.charAt(0).toUpperCase()}${reason.slice(1)}.`; +} + +function unresolved(statement: StatementText, refusal: Refusal): CliStructuredError { + return errorStatementUnresolved(statement, sentence(refusal.reason), refusal.fix); +} + +function modelIn( + namespace: ApplicationDomainNamespace, + name: string, +): ContractModelBase | undefined { + return Object.hasOwn(namespace.models, name) ? namespace.models[name] : undefined; +} + +function hasValueObject(namespace: ApplicationDomainNamespace, name: string): boolean { + return namespace.valueObjects !== undefined && Object.hasOwn(namespace.valueObjects, name); +} + +function modelAt( + contract: ContractWithDomain, + coordinate: ModelCoordinate, +): ContractModelBase | undefined { + const namespaces = contract.domain.namespaces; + const namespace = Object.hasOwn(namespaces, coordinate.namespaceId) + ? namespaces[coordinate.namespaceId] + : undefined; + return namespace === undefined ? undefined : modelIn(namespace, coordinate.model); +} + +function fieldNames(model: ContractModelBase): readonly string[] { + return [...Object.keys(model.fields), ...Object.keys(model.relations)]; +} + +function hasField(model: ContractModelBase, field: string): boolean { + return Object.hasOwn(model.fields, field) || Object.hasOwn(model.relations, field); +} + +function missing(reason: string): Refusal { + return { reason, fix: CHECK_NAMES_FIX }; +} + +function valueObjectRefusal(name: string): Refusal { + return { + reason: `"${name}" is a value object, and value object renames are not supported in this release`, + fix: VALUE_OBJECT_FIX, + }; +} + +function lookupModelInNamespace( + contract: NamedContract, + namespaceId: string, + namespace: ApplicationDomainNamespace, + name: string, +): ModelLookup { + const model = modelIn(namespace, name); + if (model !== undefined) { + return { + kind: 'found', + coordinate: { namespaceId: asNamespaceId(namespaceId), model: name }, + model, + }; + } + if (hasValueObject(namespace, name)) { + return { kind: 'refused', ...valueObjectRefusal(displayName(namespaceId, name)) }; + } + return { + kind: 'missing', + ...missing( + `the ${contract.name} contract has no model "${name}" in namespace "${namespaceId}" (models there: ${listed(Object.keys(namespace.models))})`, + ), + }; +} + +function lookupModel( + contract: NamedContract, + namespaceId: string | undefined, + name: string, +): ModelLookup { + const namespaces = contract.contract.domain.namespaces; + if (namespaceId !== undefined) { + const namespace = Object.hasOwn(namespaces, namespaceId) ? namespaces[namespaceId] : undefined; + if (namespace === undefined) { + const named = Object.keys(namespaces).filter((id) => id !== UNBOUND_NAMESPACE_ID); + return { + kind: 'missing', + ...missing( + named.length === 0 + ? `the ${contract.name} contract has no namespaces` + : `the ${contract.name} contract has no namespace "${namespaceId}" (namespaces: ${listed(named)})`, + ), + }; + } + return lookupModelInNamespace(contract, namespaceId, namespace, name); + } + const declaring = Object.entries(namespaces).filter( + ([, namespace]) => modelIn(namespace, name) !== undefined, + ); + const [only] = declaring; + if (only !== undefined && declaring.length === 1) { + return lookupModelInNamespace(contract, only[0], only[1], name); + } + if (declaring.length > 1) { + // The only way to name a model of the unbound namespace here is its namespace id. + const candidates = declaring.map(([id]) => `${id}.${name}`).sort(); + return { + kind: 'refused', + reason: `"${name}" is a model in more than one namespace of the ${contract.name} contract (${listed(candidates)})`, + fix: `Name the model with its namespace, for example ${candidates[0]}.`, + }; + } + if (Object.values(namespaces).some((namespace) => hasValueObject(namespace, name))) { + return { kind: 'refused', ...valueObjectRefusal(name) }; + } + const models = Object.entries(namespaces).flatMap(([id, namespace]) => + Object.keys(namespace.models).map((model) => displayName(id, model)), + ); + return { + kind: 'missing', + ...missing(`the ${contract.name} contract has no model "${name}" (models: ${listed(models)})`), + }; +} + +function readModel( + state: ResolutionState, + side: Side, + namespaceId: string | undefined, + name: string, +): Result { + const lookup = lookupModel(side === 'old' ? state.origin : state.destination, namespaceId, name); + if (lookup.kind !== 'found') return notOk(lookup); + return ok({ entity: 'model', coordinate: lookup.coordinate, model: lookup.model }); +} + +function readFieldOf( + contract: NamedContract, + found: FoundModel, + field: string, + destinationModel: ModelCoordinate | undefined, +): Result { + if (!hasField(found.model, field)) { + return notOk( + missing( + `the ${contract.name} model "${qualifiedModel(found.coordinate)}" has no field "${field}" (fields: ${listed(fieldNames(found.model))})`, + ), + ); + } + return ok({ + entity: 'field', + coordinate: { ...found.coordinate, field }, + model: found.model, + destinationModel, + }); +} + +function originCounterpart( + state: ResolutionState, + destinationModel: ModelCoordinate, +): FoundModel | undefined { + const coordinate = + state.originOfRenamedModel.get(qualifiedModel(destinationModel)) ?? destinationModel; + const model = modelAt(state.origin.contract, coordinate); + return model === undefined ? undefined : { coordinate, model }; +} + +/** + * The old side of a field rename names the model as the destination spells + * it, and the field as the origin counterpart of that model has it. When the + * model is not in the destination but the origin has it with that field, the + * reading still resolves so the caller can say the field would move between + * models. + */ +function readOldField( + state: ResolutionState, + namespaceId: string | undefined, + modelName: string, + field: string, +): Result { + const destination = lookupModel(state.destination, namespaceId, modelName); + if (destination.kind === 'found') { + const counterpart = originCounterpart(state, destination.coordinate); + if (counterpart === undefined) { + const name = qualifiedModel(destination.coordinate); + return notOk({ + reason: `the destination model "${name}" has no counterpart in the origin contract: the origin has no model "${name}" and no earlier statement renames a model to it`, + fix: `Rename the origin model to "${name}" with its own --rename statement before this one, or check the names.`, + modelWithoutCounterpart: name, + }); + } + return readFieldOf(state.origin, counterpart, field, destination.coordinate); + } + if (destination.kind === 'refused') return notOk(destination); + const origin = lookupModel(state.origin, namespaceId, modelName); + if (origin.kind === 'found' && hasField(origin.model, field)) { + return readFieldOf(state.origin, origin, field, undefined); + } + return notOk(destination); +} + +function readNewField( + state: ResolutionState, + namespaceId: string | undefined, + modelName: string, + field: string, +): Result { + const destination = lookupModel(state.destination, namespaceId, modelName); + if (destination.kind !== 'found') return notOk(destination); + return readFieldOf(state.destination, destination, field, destination.coordinate); +} + +function readField( + state: ResolutionState, + side: Side, + namespaceId: string | undefined, + modelName: string, + field: string, +): Result { + return side === 'old' + ? readOldField(state, namespaceId, modelName, field) + : readNewField(state, namespaceId, modelName, field); +} + +function readSide( + state: ResolutionState, + side: Side, + segments: StatementSide, +): Result { + if (segments.length === 1) return readModel(state, side, undefined, segments[0]); + if (segments.length === 3) return readField(state, side, ...segments); + const [first, second] = segments; + const asModel = readModel(state, side, first, second); + const asField = readField(state, side, undefined, first, second); + if (asModel.ok && asField.ok) { + return notOk({ + reason: `"${first}.${second}" resolves both as namespace "${first}" model "${second}" and as model "${first}" field "${second}"`, + fix: 'Write the field as namespace.Model.field if you mean the field.', + }); + } + if (asModel.ok) return asModel; + if (asField.ok) return asField; + const contract = side === 'old' ? state.origin : state.destination; + const hasNamespaces = Object.keys(contract.contract.domain.namespaces).some( + (id) => id !== UNBOUND_NAMESPACE_ID, + ); + if (!hasNamespaces) return asField; + return notOk({ + ...missing( + `"${first}.${second}" resolves neither as namespace.Model (${asModel.failure.reason}) nor as Model.field (${asField.failure.reason})`, + ), + ...ifDefined('modelWithoutCounterpart', asField.failure.modelWithoutCounterpart), + }); +} + +function resolveModelRename( + state: ResolutionState, + statement: ParsedRename, + from: ModelCoordinate, + to: ModelCoordinate, +): Result { + const fromKey = `${MODEL_RENAMES}:${qualifiedModel(from)}`; + const toKey = `${MODEL_RENAMES}:${qualifiedModel(to)}`; + if (state.renamedFrom.has(fromKey)) { + return notOk( + errorStatementInvalid( + statement, + `An earlier statement already renames "${qualifiedModel(from)}".`, + REPEATED_NAME_FIX, + ), + ); + } + if (state.renamedTo.has(toKey)) { + return notOk( + errorStatementInvalid( + statement, + `An earlier statement already renames a model to "${qualifiedModel(to)}".`, + REPEATED_NAME_FIX, + ), + ); + } + if (qualifiedModel(from) === qualifiedModel(to)) { + return notOk( + errorStatementInvalid( + statement, + `The statement renames model "${qualifiedModel(from)}" to itself.`, + `Leave out --${statement.verb} ${statement.text}. ${STORED_NAME_ONLY}`, + ), + ); + } + if ( + modelAt(state.origin.contract, to) !== undefined && + modelAt(state.destination.contract, from) !== undefined + ) { + return notOk(swapRefusal(statement, 'model', qualifiedModel(from), qualifiedModel(to))); + } + if (modelAt(state.origin.contract, to) !== undefined) { + return notOk( + errorStatementUnresolved( + statement, + `"${qualifiedModel(to)}" already exists in the origin contract, so it cannot be the new name of a model.`, + CHECK_NAMES_FIX, + ), + ); + } + if (modelAt(state.destination.contract, from) !== undefined) { + return notOk( + errorStatementUnresolved( + statement, + `"${qualifiedModel(from)}" still exists in the destination contract, so it was not renamed.`, + CHECK_NAMES_FIX, + ), + ); + } + state.renamedFrom.add(fromKey); + state.renamedTo.add(toKey); + state.originOfRenamedModel.set(qualifiedModel(to), from); + return ok({ kind: 'rename', entity: 'model', from, to }); +} + +function modelPart(side: StatementSide): string { + return side.slice(0, -1).join('.'); +} + +/** + * The old side names a model the destination contract does not have. The + * user most likely named the field's model by its old name, so the error + * writes the statement with the model named as the destination names it. + */ +function oldModelNotInDestination( + state: ResolutionState, + statement: ParsedRename, + from: FieldReading, + toModel: string, +): CliStructuredError { + const oldModel = modelPart(statement.from); + const newModel = modelPart(statement.to); + const corrected = `${newModel}.${from.coordinate.field}:${statement.to.join('.')}`; + const renamedEarlier = state.originOfRenamedModel.get(toModel); + const modelRenamed = + renamedEarlier !== undefined && + qualifiedModel(renamedEarlier) === qualifiedModel(from.coordinate); + const modelStatement = `${oldModel}:${newModel}`; + const why = [ + `"${qualifiedModel(from.coordinate)}" is not a model of the destination contract, and a field cannot move between models.`, + `Name a field's model as the destination contract names it: --${statement.verb} ${corrected}.`, + ...(modelRenamed + ? [] + : [`The model also needs its own statement, --rename ${modelStatement}, before it.`]), + ].join(' '); + const fix = modelRenamed + ? `Write the statement as --${statement.verb} ${corrected}.` + : `Write the statements as --rename ${modelStatement} --${statement.verb} ${corrected}.`; + return errorStatementInvalid(statement, why, fix); +} + +function fieldMovesBetweenModels( + statement: StatementText, + fromModel: string, + toModel: string, +): CliStructuredError { + return errorStatementInvalid( + statement, + `The old name is a field of "${fromModel}" and the new name a field of "${toModel}"; a field cannot move between models.`, + 'Rename a field within one model, and name that model as the destination contract names it.', + ); +} + +/** + * True when the old side most likely names the new side's model by its old + * name: no earlier statement renamed the old model to some other model or + * some other model to the new model, and the origin does not have the new + * model. + */ +function oldSideNamesNewModel( + state: ResolutionState, + from: FieldReading, + to: FieldReading, +): boolean { + const oldModel = qualifiedModel(from.coordinate); + const renamedTo = [...state.originOfRenamedModel.entries()].find( + ([, origin]) => qualifiedModel(origin) === oldModel, + )?.[0]; + if (renamedTo !== undefined) return renamedTo === qualifiedModel(to.coordinate); + if (state.originOfRenamedModel.has(qualifiedModel(to.coordinate))) return false; + return modelAt(state.origin.contract, to.coordinate) === undefined; +} + +function resolveFieldRename( + state: ResolutionState, + statement: ParsedRename, + from: FieldReading, + to: FieldReading, +): Result { + const toModel = qualifiedModel(to.coordinate); + if (from.destinationModel === undefined) { + return notOk( + oldSideNamesNewModel(state, from, to) + ? oldModelNotInDestination(state, statement, from, toModel) + : fieldMovesBetweenModels(statement, qualifiedModel(from.coordinate), toModel), + ); + } + if (qualifiedModel(from.destinationModel) !== toModel) { + return notOk( + fieldMovesBetweenModels(statement, qualifiedModel(from.destinationModel), toModel), + ); + } + const fromKey = `${FIELD_RENAMES}:${qualifiedField(from.coordinate)}`; + const toKey = `${FIELD_RENAMES}:${qualifiedField(to.coordinate)}`; + if (state.renamedFrom.has(fromKey)) { + return notOk( + errorStatementInvalid( + statement, + `An earlier statement already renames "${qualifiedField(from.coordinate)}".`, + REPEATED_NAME_FIX, + ), + ); + } + if (state.renamedTo.has(toKey)) { + return notOk( + errorStatementInvalid( + statement, + `An earlier statement already renames a field to "${qualifiedField(to.coordinate)}".`, + REPEATED_NAME_FIX, + ), + ); + } + if (from.coordinate.field === to.coordinate.field) { + return notOk( + errorStatementInvalid( + statement, + `The statement renames field "${toModel}.${to.coordinate.field}" to itself.`, + `Leave out --${statement.verb} ${statement.text}. ${STORED_NAME_ONLY}`, + ), + ); + } + if (hasField(from.model, to.coordinate.field) && hasField(to.model, from.coordinate.field)) { + return notOk( + swapRefusal( + statement, + 'field', + `${toModel}.${from.coordinate.field}`, + `${toModel}.${to.coordinate.field}`, + ), + ); + } + if (hasField(from.model, to.coordinate.field)) { + return notOk( + errorStatementUnresolved( + statement, + `The field "${to.coordinate.field}" already exists on the origin model "${qualifiedModel(from.coordinate)}", so it cannot be the new name of a field.`, + CHECK_NAMES_FIX, + ), + ); + } + if (hasField(to.model, from.coordinate.field)) { + return notOk( + errorStatementUnresolved( + statement, + `The field "${from.coordinate.field}" still exists on the destination model "${toModel}", so it was not renamed.`, + CHECK_NAMES_FIX, + ), + ); + } + state.renamedFrom.add(fromKey); + state.renamedTo.add(toKey); + return ok({ kind: 'rename', entity: 'field', from: from.coordinate, to: to.coordinate }); +} + +/** + * Two names that trade places: each is an old name and a new name, which one plan cannot do. + * Statements resolve against contracts, so the swap takes three plans through a temporary name. + */ +function swapRefusal( + statement: ParsedRename, + entity: 'model' | 'field', + first: string, + second: string, +): CliStructuredError { + const verb = statement.verb; + const oldName = statement.from.join('.'); + const newName = statement.to.join('.'); + const temporary = + entity === 'field' + ? `${statement.to.slice(0, -1).join('.')}.` + : ''; + return errorStatementInvalid( + statement, + `"${first}" and "${second}" swap names: each is both an old name and a new name, and statements cannot swap names in one plan.`, + `Make the swap in three plans. First change the contract so that "${first}" has a temporary name, and plan it with --${verb} ${oldName}:${temporary}. Then give "${second}" the name "${first}", and plan it with --${verb} ${newName}:${oldName}. Then give the temporary name the name "${second}", and plan it with --${verb} ${temporary}:${newName}.`, + ); +} + +/** + * The old side does not resolve. Says so in terms of what the user can do next when the statement + * is the wrong way round, when the rename has already happened, or when a later statement renames + * the field's model. + */ +function oldSideRefusal( + state: ResolutionState, + statement: ParsedRename, + later: readonly ParsedRename[], + refusal: Refusal, +): CliStructuredError { + const oldText = statement.from.join('.'); + const newText = statement.to.join('.'); + const newIsInOrigin = readSide(state, 'old', statement.to).ok; + const newIsInDestination = readSide(state, 'new', statement.to).ok; + const oldIsInDestination = readSide(state, 'new', statement.from).ok; + if (newIsInOrigin && oldIsInDestination && !newIsInDestination) { + return errorStatementUnresolved( + statement, + `The statement is the wrong way round: "${newText}" is the old name, in the origin contract, and "${oldText}" the new name, in the destination contract.`, + `Write the old name first: --${statement.verb} ${newText}:${oldText}.`, + ); + } + if (newIsInOrigin && newIsInDestination && !oldIsInDestination) { + return errorStatementUnresolved( + statement, + `The origin contract already has "${newText}" and has no "${oldText}", so this rename has already happened. For db update, the database is already at the new name.`, + `Leave out --${statement.verb} ${statement.text}.`, + ); + } + const model = refusal.modelWithoutCounterpart; + const renamesModel = + model === undefined + ? undefined + : later.find( + (candidate) => + candidate.to.length < 3 && + readSide(state, 'new', candidate.to).ok && + [model, model.split('.').pop()].includes(candidate.to.join('.')), + ); + if (renamesModel !== undefined) { + return errorStatementUnresolved( + statement, + `${sentence(refusal.reason)} A later statement, --${renamesModel.verb} ${renamesModel.text}, renames it, and statements apply in the order given.`, + `Put --${renamesModel.verb} ${renamesModel.text} before --${statement.verb} ${statement.text}.`, + ); + } + return unresolved(statement, refusal); +} + +function resolveStatement( + state: ResolutionState, + statement: ParsedRename, + later: readonly ParsedRename[], +): Result { + const from = readSide(state, 'old', statement.from); + if (!from.ok) return notOk(oldSideRefusal(state, statement, later, from.failure)); + const to = readSide(state, 'new', statement.to); + if (!to.ok) return notOk(unresolved(statement, to.failure)); + if (from.value.entity === 'model' && to.value.entity === 'model') { + return resolveModelRename(state, statement, from.value.coordinate, to.value.coordinate); + } + if (from.value.entity === 'field' && to.value.entity === 'field') { + return resolveFieldRename(state, statement, from.value, to.value); + } + return notOk( + errorStatementInvalid( + statement, + 'The statement names a model on one side and a field on the other.', + mixedSidesFix(statement.verb, statement.from, statement.to), + ), + ); +} + +/** + * Resolves the statements a user gave, in order, against the origin and + * destination contracts. Each resolved statement names its old and new entity + * by namespace, model and field. + */ +export function resolveStatements( + input: ResolveStatementsInput, +): Result { + if (input.statements.length === 0) return ok([]); + if (input.origin.kind === 'missing') { + return notOk(errorStatementOriginUnknown(input.origin)); + } + const state: ResolutionState = { + origin: { name: 'origin', contract: input.origin.contract }, + destination: { name: 'destination', contract: input.destination }, + originOfRenamedModel: new Map(), + renamedFrom: new Set(), + renamedTo: new Set(), + }; + const parsed: ParsedRename[] = []; + for (const entry of input.statements) { + const statement = parseRenameStatement(entry); + if (!statement.ok) return statement; + parsed.push(statement.value); + } + const resolved: ResolvedMigrationStatement[] = []; + for (const [index, statement] of parsed.entries()) { + const result = resolveStatement(state, statement, parsed.slice(index + 1)); + if (!result.ok) return result; + resolved.push(result.value); + } + return ok(resolved); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-text.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-text.ts new file mode 100644 index 000000000000..f6e2cf9706f5 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-text.ts @@ -0,0 +1,16 @@ +/** The verbs a statement can have. */ +export type StatementVerb = 'rename'; + +/** + * A statement as the user wrote it, before it is parsed: its verb and its text, for example + * `{ verb: 'rename', text: 'Profile:User' }` for `--rename Profile:User`. + */ +export interface StatementText { + readonly verb: StatementVerb; + readonly text: string; +} + +/** The `--rename` texts a command was given, as statements, in the order given. */ +export function renameStatements(texts: readonly string[] | undefined): readonly StatementText[] { + return (texts ?? []).map((text) => ({ verb: 'rename', text })); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts index dc145b9fff38..f42474dcec4d 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/testing/fixture-client.ts @@ -124,6 +124,7 @@ export function defaultControlClientFixtures(): ControlClientFixtures { }; const applySuccess = { mode: 'apply' as const, + appliedStatements: [], plan: { operations: [fixtureOperation] }, destination: contract, execution: { operationsPlanned: 1, operationsExecuted: 1 }, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/types.ts b/packages/1-framework/3-tooling/cli/src/control-api/types.ts index 803e73a15bec..7fe6d7ea92c5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/types.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/types.ts @@ -24,6 +24,8 @@ import type { Result } from '@internal/utils/result'; import type { ExecuteDbSignResult } from './operations/db-sign'; import type { ExecuteDbVerifyResult } from './operations/db-verify'; import type { RenderContractDtsOptions, RenderContractDtsResult } from './render-contract-dts'; +import type { AppliedStatementReport } from './statements/report-applied-statements'; +import type { StatementText } from './statements/statement-text'; // ============================================================================ // Client Options @@ -231,6 +233,12 @@ export interface DbUpdateOptions { * root. */ readonly migrationsDir: string; + /** + * The statements as the user wrote them, in the order given. They resolve + * against the contract the database marker names, read from the snapshot + * store, and the destination contract. + */ + readonly statements?: readonly StatementText[]; /** Optional progress callback for observing operation progress */ readonly onProgress?: OnControlProgress; } @@ -479,6 +487,8 @@ export interface DbUpdateSuccess { * alphabetically, then app). See {@link PerSpaceExecutionEntry}. */ readonly perSpace?: ReadonlyArray; + /** The statements the application space's plan applied, in order; empty when none were given. */ + readonly appliedStatements: readonly AppliedStatementReport[]; readonly summary: string; readonly warnings?: ReadonlyArray; } diff --git a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts index edc7f2ecaefe..97b2c37c955e 100644 --- a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts +++ b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts @@ -11,6 +11,7 @@ // Re-export core control plane types for consumer convenience export type { ControlStack, + ResolvedMigrationStatement, VerifyDatabaseResult, VerifyDatabaseSchemaResult, } from '@internal/framework-components/control'; @@ -141,6 +142,13 @@ export type { RenderContractDtsResult, RenderContractDtsSuccess, } from '../control-api/render-contract-dts'; +export type { AppliedStatementReport } from '../control-api/statements/report-applied-statements'; +export { + type ResolveStatementsInput, + resolveStatements, + type StatementOrigin, +} from '../control-api/statements/resolve-statements'; +export type { StatementText, StatementVerb } from '../control-api/statements/statement-text'; // CLI-specific types export type { ContractEmitOptions, diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/migration-blocks.ts b/packages/1-framework/3-tooling/cli/src/orm/db/migration-blocks.ts index fc75f4333e6f..466431422b25 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/migration-blocks.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/migration-blocks.ts @@ -8,6 +8,7 @@ import { renderPreviewStatement, } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; +import { appliedStatementBlocks } from '../statement-blocks'; interface PlannedOperation { readonly label: string; @@ -158,6 +159,7 @@ function planBlocks(result: MigrationCommandResult): readonly Block[] { { kind: 'summary', status: 'ok', text: planSummaryText(result) }, ...plannerWarningBlocks(result), ...operationBlocks(result), + ...appliedStatementBlocks(result.appliedStatements ?? []), { kind: 'fields', rows: [ @@ -222,6 +224,7 @@ function applyBlocks(result: MigrationCommandResult): readonly Block[] { { kind: 'summary', status: 'ok', text: applySummaryText(result) }, ...plannerWarningBlocks(result), ...operationBlocks(result), + ...appliedStatementBlocks(result.appliedStatements ?? []), ...fallbackMarkerBlocks(result), ...(advanced === null || advanced === undefined ? [] diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index beb98f76e9eb..5995bd1319a5 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -22,6 +22,7 @@ import { preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; import { retryCommandFor } from '../../control-api/operations/ref-resolution'; +import { renameStatements } from '../../control-api/statements/statement-text'; import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; @@ -110,6 +111,7 @@ function updateDocument(inputs: { }, }), ...ifDefined('perSpace', value.perSpace), + appliedStatements: value.appliedStatements, ...ifDefined('warnings', value.warnings), advancedRef: inputs.advancedRef, plannedAdvanceRef: inputs.plannedAdvanceRef, @@ -148,6 +150,11 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { brief: 'Advance the named ref to the post-command contract hash', placeholder: 'name', }), + rename: flag.repeated({ + brief: + 'Rename a model or field: Model, namespace.Model, Model.field or namespace.Model.field on each side; repeat for several, applied in order', + placeholder: 'old:new', + }), }, }, needs: { config: ormConfigSection }, @@ -178,6 +185,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', to: args.flags.to, advanceRef: args.flags.advanceRef, + renames: args.flags.rename, canRunOffline: false, }), }); @@ -217,6 +225,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { contract: contractJson, mode, migrationsDir, + statements: renameStatements(args.flags.rename), ...(consent === undefined ? {} : { consent }), onProgress: controlProgressReporter(ctx.report), }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index ec3032f7caa7..c5459f841ec1 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -13,6 +13,7 @@ import type { MigrationPlanResult, } from '../../control-api/operations/migration-plan'; import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; +import { renameStatements } from '../../control-api/statements/statement-text'; import type { CreateControlClient, DestructivePlanOperation } from '../../control-api/types'; import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; import { @@ -25,6 +26,7 @@ import { destructiveOperationList, errorConsentOperationsMissing } from '../db/c import { defineOrmCommand } from '../define-command'; import { consentToken } from '../init-inputs'; import { normalizeError } from '../normalize-error'; +import { appliedStatementBlocks } from '../statement-blocks'; import { appMigrationsDirFor, baseDirFor, @@ -161,7 +163,12 @@ function planBlocks(result: MigrationPlanResult, migrationsRelative: string): re if (result.noOp) { return [ ...warningBlocks(result), - { kind: 'summary', status: 'ok', text: 'No changes detected' }, + { + kind: 'summary', + status: 'ok', + text: result.appliedStatements.length > 0 ? result.summary : 'No changes detected', + }, + ...appliedStatementBlocks(result.appliedStatements), outcome, ]; } @@ -170,6 +177,8 @@ function planBlocks(result: MigrationPlanResult, migrationsRelative: string): re ...warningBlocks(result), { kind: 'summary', status: 'warn', text: result.summary }, ...originNoticeBlocks(result), + ...operationBlocks(result), + ...appliedStatementBlocks(result.appliedStatements), outcome, ]; } @@ -178,6 +187,7 @@ function planBlocks(result: MigrationPlanResult, migrationsRelative: string): re { kind: 'summary', status: 'ok', text: result.summary }, ...originNoticeBlocks(result), ...operationBlocks(result), + ...appliedStatementBlocks(result.appliedStatements), outcome, ...previewBlocks(result), ]; @@ -291,6 +301,11 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { brief: `Destination contract reference (${RECORDED_CONTRACT_REF_FORMS}); defaults to the emitted contract`, placeholder: 'contract', }), + rename: flag.repeated({ + brief: + 'Rename a model or field: Model, namespace.Model, Model.field or namespace.Model.field on each side; repeat for several, applied in order', + placeholder: 'old:new', + }), }, }, needs: { config: ormConfigSection }, @@ -326,6 +341,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { ...ifDefined('name', args.flags.name), ...ifDefined('from', args.flags.from), ...ifDefined('to', args.flags.to), + statements: renameStatements(args.flags.rename), ...ifDefined('consent', consent), ...ifDefined( 'carryEmittedExtensionDirs', diff --git a/packages/1-framework/3-tooling/cli/src/orm/statement-blocks.ts b/packages/1-framework/3-tooling/cli/src/orm/statement-blocks.ts new file mode 100644 index 000000000000..e6ac9b22baa8 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/statement-blocks.ts @@ -0,0 +1,33 @@ +import type { Block } from '@prisma/cli-engine'; +import type { AppliedStatementReport } from '../control-api/statements/report-applied-statements'; + +function operationCountText(count: number): string { + if (count === 0) return 'no operations'; + return count === 1 ? '1 operation' : `${count} operations`; +} + +/** + * The `Statements applied` tree: one line per statement in the order given, + * each described in domain names, with the number of operations it + * accounts for. Nothing when no statements were given. + */ +export function appliedStatementBlocks( + applied: readonly AppliedStatementReport[], +): readonly Block[] { + if (applied.length === 0) { + return []; + } + return [ + { + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: applied.map((entry) => ({ + label: `${entry.description} (${operationCountText(entry.operationIndexes.length)})`, + })), + }, + ], + }, + ]; +} diff --git a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts index 21738c81c68c..0e639b997daa 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts @@ -564,8 +564,147 @@ export function errorPlanOriginUnknown( ); } +/** + * A statement is malformed, or names two things that cannot be + * renamed into each other (a model and a field, or fields of two models). + */ +/** A statement as the user wrote it: its verb and its text. */ +interface WrittenStatement { + readonly verb: string; + readonly text: string; +} + +export function errorStatementInvalid( + statement: WrittenStatement, + why: string, + fix: string, +): ActionableCliError { + return new ActionableCliError( + 'MIGRATION.STATEMENT_INVALID', + `Invalid statement "--${statement.verb} ${statement.text}"`, + { + why, + fix, + nextActions: [chooseAction(fix)], + meta: { statement: statement.text, verb: statement.verb }, + }, + ); +} + +/** + * A statement is well formed, but its names do not resolve in the + * origin and destination contracts the way a rename requires. + */ +export function errorStatementUnresolved( + statement: WrittenStatement, + why: string, + fix: string, +): ActionableCliError { + return new ActionableCliError( + 'MIGRATION.STATEMENT_UNRESOLVED', + `Cannot resolve statement "--${statement.verb} ${statement.text}"`, + { + why, + fix, + nextActions: [chooseAction(fix)], + meta: { statement: statement.text, verb: statement.verb }, + }, + ); +} + +/** + * Without statements, `db update` drops a renamed table or column and creates it again under the + * new name, so any advice to run without them says so. + */ +const WITHOUT_STATEMENTS_DROPS = + 'A plan made without statements drops the storage of each renamed model or field with its data and creates it again under the new name, and asks for consent before it does.'; + +/** How to store the snapshot of the contract the database is at, so statements can resolve. */ +const STORE_ORIGIN_SNAPSHOT_STEPS = [ + '1. Put the contract source back to the version the database is at, and run `{bin} contract emit`.', + '2. Run `{bin} db update --advance-ref --dry-run`, with the same `--db` as this command if it has one, and check that it plans no operations. Then run it again without `--dry-run`: the database matches that contract, so this changes nothing in it, and it stores the contract snapshot. If the dry run plans operations, the database has drifted from that contract; settle that before you go on.', + '3. Put the new contract source back, run `{bin} contract emit`, and run this command again with `--advance-ref `.', +]; + +/** + * Statements were given, but there is no origin contract to resolve their old + * names against. + */ +export function errorStatementOriginUnknown(origin: { + readonly hash: string | null; + readonly snapshotDirectory: string; + readonly unreadable: string | undefined; +}): ActionableCliError { + if (origin.hash === null) { + return new ActionableCliError( + 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + 'Cannot resolve statements: the database has no marker', + { + why: 'The database has no marker: Prisma has never initialised or updated it, so there is no earlier contract whose names a statement could rename.', + fix: `Leave out the statements. If the database already holds data under the old names, preview the plan with --dry-run first. ${WITHOUT_STATEMENTS_DROPS}`, + nextActions: [ + chooseAction( + `Leave out the statements, and preview the plan with --dry-run first. ${WITHOUT_STATEMENTS_DROPS}`, + ), + ], + meta: { hash: null, snapshotDirectory: origin.snapshotDirectory }, + }, + ); + } + const meta = { + hash: origin.hash, + snapshotDirectory: origin.snapshotDirectory, + ...ifDefined('unreadable', origin.unreadable), + }; + if (origin.unreadable !== undefined) { + return new ActionableCliError( + 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + 'Cannot resolve statements: the origin contract snapshot cannot be read', + { + why: `The contract snapshot for hash "${origin.hash}" in ${origin.snapshotDirectory} could not be read: ${origin.unreadable}`, + fix: [ + 'Restore migrations/snapshots/ from version control. If no good copy exists, store the snapshot again:', + ...STORE_ORIGIN_SNAPSHOT_STEPS, + ].join('\n'), + nextActions: [ + chooseAction('Restore migrations/snapshots/ from version control'), + chooseAction('Or store the snapshot again from the contract the database is at'), + ], + meta, + }, + ); + } + return new ActionableCliError( + 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + 'Cannot resolve statements: the origin contract is unknown', + { + why: `No contract snapshot for hash "${origin.hash}" was found in ${origin.snapshotDirectory}. Statements name things in the contract the database is at, so the command needs that contract. \`db update\` stores the snapshot of the contract it applies only when it advances a ref: the \`db\` ref by default, or with \`--db \` only the ref named by \`--advance-ref \`. Most likely the run that brought the database to this contract advanced no ref.`, + fix: [ + 'Store the snapshot of the contract the database is at, then run the rename:', + ...STORE_ORIGIN_SNAPSHOT_STEPS, + 'Adding --advance-ref to this command alone does not help: it would store the new contract, not the one the database is at. `migration plan --from` does not apply to `db update`.', + ].join('\n'), + nextActions: [ + chooseAction('Put the contract source back to the version the database is at, and emit it'), + runCommandAction( + 'Check that it plans no operations (with the same --db as this command, if any)', + '{bin} db update --advance-ref --dry-run', + ), + runCommandAction( + 'Then store its snapshot, which changes nothing in the database', + '{bin} db update --advance-ref ', + ), + chooseAction( + 'Put the new contract source back, emit it, and run this command again with --advance-ref ', + ), + ], + meta, + }, + ); +} + /** Where a `migration plan` leg plans from: an empty database, an empty database toward the `db` ref's contract (the automatic baseline), or an earlier contract. */ -export type PlanOrigin = +export type PlanLegOrigin = | { readonly kind: 'empty' } | { readonly kind: 'baseline'; readonly hash: string } | { readonly kind: 'contract'; readonly hash: string }; @@ -579,7 +718,10 @@ export interface PlanDestination { const REPORT_MISSED_CHANGE = 'If you expected the database to change, migration plan missed it: report it with the output of `prisma migration plan --json`.'; -function noOperationsConflict(origin: PlanOrigin, destination: PlanDestination): CliErrorConflict { +function noOperationsConflict( + origin: PlanLegOrigin, + destination: PlanDestination, +): CliErrorConflict { switch (origin.kind) { case 'empty': return { @@ -610,7 +752,7 @@ function noOperationsConflict(origin: PlanOrigin, destination: PlanDestination): /** `migration plan` refuses a leg whose planner produced no operations, and says how to proceed from where it planned. */ export function errorPlanProducedNoOperations( - origin: PlanOrigin, + origin: PlanLegOrigin, destination: PlanDestination, ): CliStructuredError { return errorMigrationPlanningFailed({ conflicts: [noOperationsConflict(origin, destination)] }); @@ -660,8 +802,8 @@ export function errorMarkerMismatch( const ROLLBACK_IS_DESTRUCTIVE = 'A rollback (reverse) plan is expected to contain destructive (DROP) operations — review them before applying'; -const NARROWER_CASES_NEED_A_HINT = - 'Narrower cases (rename inference, re-adding a required field without a safe default, or a type change that needs data) may additionally need a hint in the planned migration'; +const RENAMES_AND_DATA_NEED_MORE = + 'Plan a rename with --rename old:new; re-adding a required field without a safe default, or a type change that needs data, may need you to fill in the planned migration.ts'; export function errorPathUnreachable(failure: MigrateFailure): ActionableCliError { const meta = failure.meta ?? {}; @@ -718,14 +860,14 @@ export function errorPathUnreachable(failure: MigrateFailure): ActionableCliErro ` 1. ${planCommand}`, ` 2. ${applyCommand}`, `${ROLLBACK_IS_DESTRUCTIVE}.`, - `${NARROWER_CASES_NEED_A_HINT}.`, + `${RENAMES_AND_DATA_NEED_MORE}.`, 'Inspect the on-disk graph with `{bin} migration list`, or `{bin} migration show ` for any bundle in the path you expected.', ].join('\n'), nextActions: [ runCommandAction('Plan the missing edge', planCommand), runCommandAction('Apply it', applyCommand), chooseAction(ROLLBACK_IS_DESTRUCTIVE), - chooseAction(NARROWER_CASES_NEED_A_HINT), + chooseAction(RENAMES_AND_DATA_NEED_MORE), runCommandAction('Inspect the on-disk graph', '{bin} migration list'), runCommandAction( 'Inspect a bundle in the path you expected', diff --git a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrations.ts b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrations.ts index e457a099412d..f6868580bca6 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrations.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrations.ts @@ -2,6 +2,7 @@ import type { MigrationPlannerConflict, OperationPreview, } from '@internal/framework-components/control'; +import type { AppliedStatementReport } from '../../control-api/statements/report-applied-statements'; import type { PerSpaceExecutionEntry } from '../../control-api/types'; /** @@ -81,6 +82,8 @@ export interface MigrationCommandResult { * into a single ambiguous list. See {@link PerSpaceExecutionEntry}. */ readonly perSpace?: ReadonlyArray; + /** `db update` only: the `--rename` statements the plan applied, in order; empty when none were given. */ + readonly appliedStatements?: readonly AppliedStatementReport[]; readonly advancedRef?: { readonly name: string; readonly hash: string } | null; readonly plannedAdvanceRef?: { readonly name: string; readonly hash: string } | null; readonly summary: string; diff --git a/packages/1-framework/3-tooling/cli/test/cli-errors.test.ts b/packages/1-framework/3-tooling/cli/test/cli-errors.test.ts index 2d9b193642bd..bb4eca90364b 100644 --- a/packages/1-framework/3-tooling/cli/test/cli-errors.test.ts +++ b/packages/1-framework/3-tooling/cli/test/cli-errors.test.ts @@ -62,7 +62,7 @@ describe('errorPathUnreachable', () => { expect(envelope.fix).toContain('{bin} migration list'); expect(envelope.fix).toContain('{bin} migration show'); expect((envelope.fix ?? '').toLowerCase()).toContain('destructive'); - expect((envelope.fix ?? '').toLowerCase()).toContain('hint'); + expect(envelope.fix ?? '').toContain('Plan a rename with --rename old:new'); }); it('prescribes a bare plan command when the runner kind is neverPlanned (no graph to resolve --to against)', () => { @@ -119,7 +119,7 @@ describe('errorPathUnreachable', () => { ); expect(envelope.fix).toContain(`{bin} db migrate --to ${targetHash}`); expect((envelope.fix ?? '').toLowerCase()).toContain('destructive'); - expect((envelope.fix ?? '').toLowerCase()).toContain('hint'); + expect(envelope.fix ?? '').toContain('Plan a rename with --rename old:new'); }); it('omits --from in the fix when buildPathNotFoundFailure uses the empty-marker sentinel', () => { @@ -147,7 +147,7 @@ describe('errorPathUnreachable', () => { expect(envelope.fix).toContain('{bin} db migrate'); expect(envelope.fix).not.toContain('--to'); expect((envelope.fix ?? '').toLowerCase()).toContain('destructive'); - expect((envelope.fix ?? '').toLowerCase()).toContain('hint'); + expect(envelope.fix ?? '').toContain('Plan a rename with --rename old:new'); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts index 4c39ab1d0873..6b3efa7019cf 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts @@ -718,6 +718,7 @@ describe('ControlClient progress emission', () => { createPlanner: () => ({ plan: () => ({ kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/db-init.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/db-init.test.ts index 20a5e309ae4d..f5facee270a2 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/db-init.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/db-init.test.ts @@ -32,9 +32,10 @@ const dummyContract = { } as unknown as Contract; describe('executeDbInit', () => { - it('passes fromContract: null to planner.plan (no prior contract under reconciliation)', async () => { + it('plans from no contract, asserts no origin, gives no statements, and reports none', async () => { const planFn = vi.fn().mockReturnValue({ kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, @@ -59,7 +60,7 @@ describe('executeDbInit', () => { ControlFamilyInstance<'sql', unknown> >; - await executeDbInit({ + const result = await executeDbInit({ driver: createMockDriver(), adapter: {} as unknown as ControlAdapterInstance<'sql', 'postgres'>, familyInstance: createMockFamilyInstance(), @@ -72,10 +73,8 @@ describe('executeDbInit', () => { }); expect(planFn).toHaveBeenCalledWith( - expect.objectContaining({ - // `db init` reconciles against the live introspected schema and has - // structural representation of "no origin contract" (AC-5). - }), + expect.objectContaining({ fromContract: null, origin: null, statements: [] }), ); + expect(result.assertOk()).not.toHaveProperty('appliedStatements'); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/db-update-statement-positions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/db-update-statement-positions.test.ts new file mode 100644 index 000000000000..40d1dfd705fd --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/db-update-statement-positions.test.ts @@ -0,0 +1,211 @@ +import { rmSync } from 'node:fs'; +import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; +import type { + ControlAdapterInstance, + ControlDriverInstance, + ControlExtensionDescriptor, + ControlFamilyInstance, + MigrationPlannerResult, + MigrationPlanOperation, + ResolvedMigrationStatement, + TargetMigrationsCapability, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { computeMigrationHash } from '@internal/migration-tools/hash'; +import { writeMigrationPackage } from '@internal/migration-tools/io'; +import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; +import { createSqlContract } from '@repo/test-utils'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { executeDbUpdate } from '../../src/control-api/operations/db-update'; +import { renameStatements } from '../../src/control-api/statements/statement-text'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +const ORIGIN_HASH = 'a'.repeat(64); +const DESTINATION_HASH = 'b'.repeat(64); +const AUDIT_SPACE = 'audit'; + +function contractWithModels(storageHash: string, models: readonly string[]): Contract { + return { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storage: { storageHash, tables: {}, namespaces: {} }, + domain: { + namespaces: { + app: { + models: Object.fromEntries( + models.map((model) => [model, { fields: {}, relations: {}, storage: {} }]), + ), + }, + }, + }, + } as unknown as Contract; +} + +const origin = contractWithModels(ORIGIN_HASH, ['Profile']); +const destination = contractWithModels(DESTINATION_HASH, ['User']); + +const AUDIT_CONTRACT: Contract = createSqlContract({ + storage: { + namespaces: { + [UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID, entries: { table: { audit_log: {} } } }, + }, + }, +}); +const AUDIT_HEAD = AUDIT_CONTRACT.storage.storageHash; +const AUDIT_OPS: readonly MigrationPlanOperation[] = [ + { id: 'table.audit_log', label: 'Create table audit_log', operationClass: 'additive' }, + { id: 'index.audit_log.at', label: 'Create index on audit_log', operationClass: 'additive' }, +]; +const AUDIT_METADATA: Omit = { + from: EMPTY_CONTRACT_HASH, + to: AUDIT_HEAD, + providedInvariants: [], + createdAt: '2026-01-01T00:00:00.000Z', +}; + +const RENAME_OP: MigrationPlanOperation = { + id: 'renameTable.Profile', + label: 'Rename table "Profile" to "User"', + operationClass: 'widening', +}; + +function markerAt(storageHash: string): ContractMarkerRecord { + return { + storageHash, + profileHash: '', + contractJson: null, + canonicalVersion: null, + updatedAt: new Date(0), + appTag: null, + meta: {}, + invariants: [], + }; +} + +const driver = { + close: vi.fn(), + databaseName: async () => 'appdb', +} as unknown as ControlDriverInstance<'sql', 'postgres'>; + +const family = { + familyId: 'sql', + readAllMarkers: async () => new Map([['app', markerAt(ORIGIN_HASH)]]), + introspect: async () => ({ tables: {} }), + deserializeContract: (json: unknown) => json as Contract, + toOperationPreview: () => ({ statements: [] }), +} as unknown as ControlFamilyInstance<'sql', unknown>; + +const migrations = { + createPlanner: () => ({ + plan: (options: { + readonly origin: { readonly storageHash: string } | null; + readonly statements: readonly ResolvedMigrationStatement[]; + }): MigrationPlannerResult => ({ + kind: 'success', + appliedStatements: options.statements.map((statement) => ({ + statement, + operationIndexes: [0], + })), + plan: { + targetId: 'postgres', + origin: options.origin, + destination: { storageHash: DESTINATION_HASH }, + operations: [RENAME_OP], + renderTypeScript: () => '', + }, + }), + }), + createRunner: () => ({ execute: vi.fn() }), +} as unknown as TargetMigrationsCapability< + 'sql', + 'postgres', + ControlFamilyInstance<'sql', unknown> +>; + +const auditExtension: ControlExtensionDescriptor<'sql', 'postgres'> = { + kind: 'extension', + id: AUDIT_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + contractSpace: { + contractJson: AUDIT_CONTRACT, + headRef: { hash: AUDIT_HEAD, invariants: [] }, + migrations: [ + { + dirName: '20260101T0000_init', + metadata: { + ...AUDIT_METADATA, + migrationHash: computeMigrationHash(AUDIT_METADATA, AUDIT_OPS), + }, + ops: AUDIT_OPS, + }, + ], + }, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), +}; + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The app's origin snapshot, and an audit extension space with one migration still to apply. */ +async function migrationsDirWithPendingExtension(): Promise { + const dir = createTestProjectDir('db-update-statement-positions'); + tempDirs.push(dir); + const migrationsDir = join(dir, 'migrations'); + await writeContractSnapshot(migrationsDir, ORIGIN_HASH, { + contractJson: origin, + contractDts: '', + }); + await writeContractSnapshot(migrationsDir, AUDIT_HEAD, { + contractJson: AUDIT_CONTRACT, + contractDts: '', + }); + await writeMigrationPackage( + join(migrationsDir, AUDIT_SPACE, '20260101T0000_init'), + { ...AUDIT_METADATA, migrationHash: computeMigrationHash(AUDIT_METADATA, AUDIT_OPS) }, + AUDIT_OPS, + ); + await writeRef(join(migrationsDir, AUDIT_SPACE, 'refs'), 'head', { + hash: AUDIT_HEAD, + invariants: [], + }); + return migrationsDir; +} + +describe('executeDbUpdate statement positions', () => { + it('index the plan operations of every space, after the operations of spaces applied before the app', async () => { + const result = await executeDbUpdate({ + driver, + adapter: {} as unknown as ControlAdapterInstance<'sql', 'postgres'>, + familyInstance: family, + contract: destination, + mode: 'plan', + migrations, + frameworkComponents: [], + migrationsDir: await migrationsDirWithPendingExtension(), + targetId: 'postgres', + extensions: [auditExtension], + statements: renameStatements(['Profile:User']), + }); + + if (!result.ok) throw new Error(`expected a plan: ${JSON.stringify(result.failure)}`); + const { operations } = result.value.plan; + expect(operations.map((operation) => operation.id)).toEqual([ + ...AUDIT_OPS.map((operation) => operation.id), + RENAME_OP.id, + ]); + expect( + result.value.appliedStatements.map((applied) => + applied.operationIndexes.map((index) => operations[index]?.id), + ), + ).toEqual([[RENAME_OP.id]]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/db-update-statements.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/db-update-statements.test.ts new file mode 100644 index 000000000000..2561b03a134a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/db-update-statements.test.ts @@ -0,0 +1,395 @@ +import { rmSync, writeFileSync } from 'node:fs'; +import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; +import type { + ControlAdapterInstance, + ControlDriverInstance, + ControlFamilyInstance, + MigrationPlannerResult, + ResolvedMigrationStatement, + TargetMigrationsCapability, +} from '@internal/framework-components/control'; +import { + contractSnapshotDir, + type SnapshotContentVerifier, + writeContractSnapshot, +} from '@internal/migration-tools/contract-snapshot-store'; +import { MigrationToolsError } from '@internal/migration-tools/errors'; +import { ok } from '@internal/utils/result'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { executeDbUpdate } from '../../src/control-api/operations/db-update'; +import { renameStatements } from '../../src/control-api/statements/statement-text'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +const ORIGIN_HASH = 'a'.repeat(64); +const DESTINATION_HASH = 'b'.repeat(64); + +function contractWithModels(storageHash: string, models: readonly string[]): Contract { + return { + schemaVersion: '1', + target: 'postgres', + targetFamily: 'sql', + storage: { storageHash, tables: {}, namespaces: {} }, + domain: { + namespaces: { + app: { + models: Object.fromEntries( + models.map((model) => [model, { fields: {}, relations: {}, storage: {} }]), + ), + }, + }, + }, + } as unknown as Contract; +} + +const origin = contractWithModels(ORIGIN_HASH, ['Profile']); +const destination = contractWithModels(DESTINATION_HASH, ['User']); + +function markerAt(storageHash: string): ContractMarkerRecord { + return { + storageHash, + profileHash: '', + contractJson: null, + canonicalVersion: null, + updatedAt: new Date(0), + appTag: null, + meta: {}, + invariants: [], + }; +} + +const driver = { + close: vi.fn(), + databaseName: async () => 'appdb', +} as unknown as ControlDriverInstance<'sql', 'postgres'>; + +/** A family whose serializer refuses the snapshot of the origin hash when `unreadableOrigin` is set. */ +function familyWithMarker( + marker: ContractMarkerRecord | undefined, + options: { readonly unreadableOrigin?: boolean } = {}, +) { + return { + familyId: 'sql', + readAllMarkers: async () => (marker === undefined ? new Map() : new Map([['app', marker]])), + introspect: async () => ({ tables: {} }), + deserializeContract: (json: unknown) => { + if (options.unreadableOrigin && (json as Contract).storage.storageHash === ORIGIN_HASH) { + throw new Error('unknown contract format'); + } + return json as Contract; + }, + toOperationPreview: () => ({ statements: [] }), + } as unknown as ControlFamilyInstance<'sql', unknown>; +} + +interface PlannerCall { + readonly fromContract: unknown; + readonly origin: { readonly storageHash: string } | null; + readonly statements: readonly ResolvedMigrationStatement[]; +} + +function recordingMigrations(operationClass: 'additive' | 'destructive' = 'additive') { + const calls: PlannerCall[] = []; + const execute = vi.fn().mockResolvedValue( + ok({ + perSpaceResults: [{ space: 'app', value: { operationsPlanned: 1, operationsExecuted: 1 } }], + }), + ); + const migrations = { + createPlanner: () => ({ + plan: (options: PlannerCall): MigrationPlannerResult => { + calls.push({ + fromContract: options.fromContract, + origin: options.origin, + statements: options.statements, + }); + const renamed = options.statements.length > 0; + return { + kind: 'success', + appliedStatements: options.statements.map((statement) => ({ + statement, + operationIndexes: [0], + })), + plan: { + targetId: 'postgres', + origin: options.origin, + destination: { storageHash: DESTINATION_HASH }, + operations: [ + renamed + ? { id: 'renameTable.Profile', label: 'Rename table', operationClass: 'widening' } + : { id: 'dropTable.Profile', label: 'Drop table', operationClass }, + ], + renderTypeScript: () => '', + }, + }; + }, + }), + createRunner: () => ({ execute }), + } as unknown as TargetMigrationsCapability< + 'sql', + 'postgres', + ControlFamilyInstance<'sql', unknown> + >; + return { migrations, calls, execute }; +} + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +async function migrationsDirWithSnapshot(contract: Contract | undefined): Promise { + const dir = createTestProjectDir('db-update-statements'); + tempDirs.push(dir); + const migrationsDir = join(dir, 'migrations'); + if (contract !== undefined) { + await writeContractSnapshot(migrationsDir, contract.storage.storageHash, { + contractJson: contract, + contractDts: '', + }); + } + return migrationsDir; +} + +function update(options: { + readonly migrationsDir: string; + readonly marker: ContractMarkerRecord | undefined; + readonly renames: readonly string[]; + readonly mode?: 'plan' | 'apply'; + readonly unreadableOrigin?: boolean; + readonly verifySnapshotContent?: SnapshotContentVerifier; + readonly migrations: TargetMigrationsCapability< + 'sql', + 'postgres', + ControlFamilyInstance<'sql', unknown> + >; +}) { + return executeDbUpdate({ + driver, + adapter: {} as unknown as ControlAdapterInstance<'sql', 'postgres'>, + familyInstance: familyWithMarker(options.marker, { + unreadableOrigin: options.unreadableOrigin ?? false, + }), + contract: destination, + mode: options.mode ?? 'plan', + migrations: options.migrations, + frameworkComponents: [], + migrationsDir: options.migrationsDir, + targetId: 'postgres', + statements: renameStatements(options.renames), + ...(options.verifySnapshotContent === undefined + ? {} + : { verifySnapshotContent: options.verifySnapshotContent }), + }); +} + +describe('executeDbUpdate with statements', () => { + it('plans from the snapshot of the marker hash and hands the planner the resolved statements', async () => { + const { migrations, calls } = recordingMigrations(); + const result = await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + migrations, + }); + + expect(calls).toEqual([ + { + fromContract: origin, + origin: null, + statements: [ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + ], + }, + ]); + expect(result.ok && result.value.appliedStatements).toEqual([ + expect.objectContaining({ + description: 'rename model "Profile" to "User"', + operationIndexes: [0], + }), + ]); + }); + + it('hands the runner a plan with no origin, as without statements', async () => { + const { migrations, execute } = recordingMigrations(); + await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + mode: 'apply', + migrations, + }); + const [runnerOptions] = execute.mock.calls[0] ?? []; + expect(runnerOptions.perSpaceOptions[0].plan.origin).toBeNull(); + }); + + it('refuses statements when the snapshot of the marker hash cannot be read, naming why', async () => { + const { migrations, calls } = recordingMigrations(); + await expect( + update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + unreadableOrigin: true, + migrations, + }), + ).rejects.toMatchObject({ + code: 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + why: expect.stringContaining('unknown contract format'), + }); + expect(calls).toEqual([]); + }); + + it('says the snapshot cannot be read when its contract.json is not JSON, rather than that it is missing', async () => { + const { migrations, calls } = recordingMigrations(); + const migrationsDir = await migrationsDirWithSnapshot(origin); + writeFileSync( + join(contractSnapshotDir(migrationsDir, ORIGIN_HASH), 'contract.json'), + '{ not json', + ); + const refusal = await update({ + migrationsDir, + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + migrations, + }).catch((error: unknown) => error); + expect(refusal).toMatchObject({ + code: 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + why: expect.stringMatching( + /^The contract snapshot for hash "a+" in .* could not be read: Failed to parse/, + ), + fix: expect.stringContaining('Restore migrations/snapshots/ from version control'), + meta: { unreadable: expect.stringContaining('Failed to parse') }, + }); + expect(calls).toEqual([]); + }); + + it('says the snapshot cannot be read when its content does not match its hash', async () => { + const { migrations } = recordingMigrations(); + const migrationsDir = await migrationsDirWithSnapshot(origin); + const refusal = await update({ + migrationsDir, + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + migrations, + verifySnapshotContent: { + assertSnapshotContentMatches: (_json, storageHash, jsonPath) => { + throw new MigrationToolsError( + 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH', + 'Contract snapshot content does not match its hash', + { + why: `The contract snapshot at "${jsonPath}" is addressed by storage hash ${storageHash}, but its content recomputes to ${'c'.repeat(64)}.`, + fix: 'Restore migrations/snapshots/ from version control.', + }, + ); + }, + }, + }).catch((error: unknown) => error); + expect(refusal).toMatchObject({ + code: 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + why: expect.stringContaining('could not be read: The contract snapshot at'), + meta: { unreadable: expect.stringContaining('its content recomputes to') }, + }); + }); + + it('reports the applied statements on an apply', async () => { + const { migrations } = recordingMigrations(); + const result = await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + mode: 'apply', + migrations, + }); + expect(result.ok && result.value.mode).toBe('apply'); + expect(result.ok && result.value.appliedStatements).toHaveLength(1); + }); + + it('gives the destructive pre-plan the statements, so a rename is not refused as a drop', async () => { + const { migrations, calls } = recordingMigrations('destructive'); + const result = await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + mode: 'apply', + migrations, + }); + expect(result.ok).toBe(true); + expect(calls.every((call) => call.statements.length === 1)).toBe(true); + }); + + it('refuses statements when the snapshot store has no contract for the marker hash', async () => { + const { migrations, calls } = recordingMigrations(); + const migrationsDir = await migrationsDirWithSnapshot(undefined); + await expect( + update({ + migrationsDir, + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + migrations, + }), + ).rejects.toMatchObject({ + code: 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + meta: { hash: ORIGIN_HASH, snapshotDirectory: join(migrationsDir, 'snapshots', ORIGIN_HASH) }, + fix: expect.stringContaining('--advance-ref '), + }); + expect(calls).toEqual([]); + }); + + it('refuses statements when the database has no marker', async () => { + const { migrations } = recordingMigrations(); + await expect( + update({ + migrationsDir: await migrationsDirWithSnapshot(undefined), + marker: undefined, + renames: ['Profile:User'], + migrations, + }), + ).rejects.toMatchObject({ + code: 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + meta: { hash: null }, + why: expect.stringContaining('no earlier contract'), + fix: expect.not.stringContaining('--advance-ref'), + }); + }); + + it('refuses the same statements once the database is at the destination', async () => { + const { migrations } = recordingMigrations(); + await expect( + update({ + migrationsDir: await migrationsDirWithSnapshot(destination), + marker: markerAt(DESTINATION_HASH), + renames: ['Profile:User'], + migrations, + }), + ).rejects.toMatchObject({ code: 'MIGRATION.STATEMENT_UNRESOLVED' }); + }); + + it('reads no snapshot without statements, and plans from no origin contract', async () => { + const withSnapshot = recordingMigrations(); + const result = await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: [], + unreadableOrigin: true, + migrations: withSnapshot.migrations, + }); + expect(result.ok).toBe(true); + expect(withSnapshot.calls).toEqual([{ fromContract: null, origin: null, statements: [] }]); + + const without = recordingMigrations(); + const withoutSnapshot = await update({ + migrationsDir: await migrationsDirWithSnapshot(undefined), + marker: markerAt(ORIGIN_HASH), + renames: [], + migrations: without.migrations, + }); + expect(without.calls).toEqual([{ fromContract: null, origin: null, statements: [] }]); + expect(withoutSnapshot.ok && withoutSnapshot.value.appliedStatements).toEqual([]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/db-update.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/db-update.test.ts index 5fcbdb575722..4fa65427d0e5 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/db-update.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/db-update.test.ts @@ -61,6 +61,7 @@ function createMockMigrations(overrides?: { }) { const planResult: MigrationPlannerResult = overrides?.planResult ?? { kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'new-hash', profileHash: 'new-profile' }, @@ -175,6 +176,7 @@ describe('executeDbUpdate', () => { const migrations = createMockMigrations({ planResult: { kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest', profileHash: 'dest-profile' }, @@ -326,6 +328,7 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations({ planResult: { kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'current', profileHash: 'current-profile' }, @@ -364,6 +367,7 @@ describe('executeDbUpdate', () => { const migrations = createMockMigrations({ planResult: { kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'same', profileHash: 'same-profile' }, @@ -411,6 +415,7 @@ describe('executeDbUpdate', () => { it('allows additive, widening, and destructive operation classes', async () => { const planFn = vi.fn().mockReturnValue({ kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, @@ -463,6 +468,7 @@ describe('executeDbUpdate', () => { return createMockMigrations({ planResult: { kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, @@ -635,6 +641,7 @@ describe('executeDbUpdate', () => { createPlanner: () => ({ plan: vi.fn().mockReturnValue({ kind: 'success', + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/progress.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/progress.test.ts index 0e08855029b0..ff9964afa623 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/progress.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/progress.test.ts @@ -31,6 +31,7 @@ describe('executeDbInit progress emission', () => { createPlanner: () => ({ plan: async () => ({ kind: 'success' as const, + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'test-hash' }, @@ -114,6 +115,7 @@ describe('executeDbInit progress emission', () => { createPlanner: () => ({ plan: async () => ({ kind: 'success' as const, + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'test-hash' }, @@ -185,6 +187,7 @@ describe('executeDbInit progress emission', () => { createPlanner: () => ({ plan: async () => ({ kind: 'success' as const, + appliedStatements: [], plan: { targetId: 'postgres', destination: { storageHash: 'test-hash' }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/parse-rename.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/parse-rename.test.ts new file mode 100644 index 000000000000..3d4ba95bf026 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/parse-rename.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { parseRenameStatement } from '../../../src/control-api/statements/parse-rename'; +import { expectFailure, expectValue } from './statement-fixtures'; + +function rename(text: string) { + return { verb: 'rename', text } as const; +} + +const FORMS = [ + 'Write the statement as --rename :, where each side is Model, namespace.Model, Model.field or namespace.Model.field', + 'for example --rename Profile:User for a model or --rename User.name:User.fullName for a field', +]; + +describe('parseRenameStatement', () => { + describe('accepted forms', () => { + it.each([ + ['Profile:User', ['Profile'], ['User']], + ['auth.Profile:billing.User', ['auth', 'Profile'], ['billing', 'User']], + ['User.name:User.fullName', ['User', 'name'], ['User', 'fullName']], + ['auth.User.name:auth.User.fullName', ['auth', 'User', 'name'], ['auth', 'User', 'fullName']], + ['Profile:auth.User', ['Profile'], ['auth', 'User']], + ['auth.User.name:User.fullName', ['auth', 'User', 'name'], ['User', 'fullName']], + ])('splits %s into its two sides', (text, from, to) => { + expect(expectValue(parseRenameStatement(rename(text)))).toEqual({ + verb: 'rename', + text, + from, + to, + }); + }); + + it('keeps the case of every segment', () => { + expect(expectValue(parseRenameStatement(rename('user:User')))).toEqual({ + verb: 'rename', + text: 'user:User', + from: ['user'], + to: ['User'], + }); + }); + }); + + describe('malformed statements', () => { + it.each([ + ['no colon', 'ProfileUser'], + ['more than one colon', 'Profile:User:Account'], + ['an empty old side', ':User'], + ['an empty new side', 'Profile:'], + ['an empty segment', 'auth..Profile:User'], + ['a trailing dot', 'Profile.:User'], + ['more than three segments', 'a.b.c.d:User'], + ])('rejects %s, quoting the statement and listing the accepted forms', (_case, text) => { + expectFailure( + parseRenameStatement(rename(text)), + 'MIGRATION.STATEMENT_INVALID', + text, + ...FORMS, + ); + }); + + it.each(['Profile:auth.User.name', 'auth.User.name:Profile'])( + 'rejects %s, a model on one side and a field on the other', + (text) => { + expectFailure( + parseRenameStatement(rename(text)), + 'MIGRATION.STATEMENT_INVALID', + text, + 'model on one side and a field on the other', + ); + }, + ); + + it.each([ + [ + 'Profile:auth.User.name', + "To rename the model, write --rename Profile:auth.User. To rename a field, write --rename auth.User.:auth.User.name, naming the field's model as the new contract names it.", + ], + [ + 'auth.User.name:Profile', + "To rename the model, write --rename auth.User:Profile. To rename a field, write --rename Profile.name:Profile., naming the field's model as the new contract names it.", + ], + ])('writes both statements %s may have meant', (text, fix) => { + expect( + expectFailure(parseRenameStatement(rename(text)), 'MIGRATION.STATEMENT_INVALID').fix, + ).toBe(fix); + }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-field-statements.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-field-statements.test.ts new file mode 100644 index 000000000000..e5df619d2571 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-field-statements.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest'; +import { resolveStatements } from '../../../src/control-api/statements/resolve-statements'; +import { renameStatements } from '../../../src/control-api/statements/statement-text'; +import { contractOf, expectFailure, expectValue } from './statement-fixtures'; + +const UNRESOLVED = 'MIGRATION.STATEMENT_UNRESOLVED'; +const INVALID = 'MIGRATION.STATEMENT_INVALID'; + +function resolve( + renames: readonly string[], + origin: ReturnType, + destination: ReturnType, +) { + return resolveStatements({ + statements: renameStatements(renames), + origin: { kind: 'contract', contract: origin }, + destination, + }); +} + +const nameToFullName = { + origin: contractOf({ app: { models: { User: { fields: ['id', 'name'] } } } }), + destination: contractOf({ app: { models: { User: { fields: ['id', 'fullName'] } } } }), +}; + +const renamedUser = { + kind: 'rename', + entity: 'field', + from: { namespaceId: 'app', model: 'User', field: 'name' }, + to: { namespaceId: 'app', model: 'User', field: 'fullName' }, +}; + +describe('resolveStatements, field renames', () => { + it('resolves Model.field on both sides', () => { + const { origin, destination } = nameToFullName; + expect(expectValue(resolve(['User.name:User.fullName'], origin, destination))).toEqual([ + renamedUser, + ]); + }); + + it('resolves namespace.Model.field on both sides', () => { + const { origin, destination } = nameToFullName; + expect(expectValue(resolve(['app.User.name:app.User.fullName'], origin, destination))).toEqual([ + renamedUser, + ]); + }); + + it('looks the old field up in the model an earlier statement renamed', () => { + const origin = contractOf({ app: { models: { A: { fields: ['x'] } } } }); + const destination = contractOf({ app: { models: { B: { fields: ['y'] } } } }); + expect(expectValue(resolve(['A:B', 'B.x:B.y'], origin, destination))).toEqual([ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'A' }, + to: { namespaceId: 'app', model: 'B' }, + }, + { + kind: 'rename', + entity: 'field', + from: { namespaceId: 'app', model: 'A', field: 'x' }, + to: { namespaceId: 'app', model: 'B', field: 'y' }, + }, + ]); + }); + + describe('fields cannot move between models', () => { + it('refuses old and new sides naming the old and new names of a renamed model', () => { + const origin = contractOf({ app: { models: { A: { fields: ['x'] } } } }); + const destination = contractOf({ app: { models: { B: { fields: ['y'] } } } }); + const failure = expectFailure( + resolve(['A:B', 'A.x:B.y'], origin, destination), + INVALID, + '--rename A.x:B.y', + '"app.A" is not a model of the destination contract', + "Name a field's model as the destination contract names it: --rename B.x:B.y", + ); + expect(failure.fix).toBe('Write the statement as --rename B.x:B.y.'); + }); + + it('also asks for the model statement when no earlier statement renames the model', () => { + const origin = contractOf({ app: { models: { Profile: { fields: ['name'] } } } }); + const destination = contractOf({ app: { models: { User: { fields: ['fullName'] } } } }); + const failure = expectFailure( + resolve(['Profile.name:User.fullName'], origin, destination), + INVALID, + '--rename User.name:User.fullName', + 'needs its own statement, --rename Profile:User', + ); + expect(failure.fix).toBe( + 'Write the statements as --rename Profile:User --rename User.name:User.fullName.', + ); + }); + + it('refuses a field statement that names the old model before the statement renaming it', () => { + const origin = contractOf({ app: { models: { Profile: { fields: ['name'] } } } }); + const destination = contractOf({ app: { models: { User: { fields: ['fullName'] } } } }); + const failure = expectFailure( + resolve(['Profile.name:User.fullName', 'Profile:User'], origin, destination), + INVALID, + '--rename Profile.name:User.fullName', + ); + expect(failure.fix).toBe( + 'Write the statements as --rename Profile:User --rename User.name:User.fullName.', + ); + }); + + it('gives no corrected statement when the old model was renamed to another model', () => { + const origin = contractOf({ + app: { models: { A: { fields: ['x'] }, C: { fields: ['z'] } } }, + }); + const destination = contractOf({ app: { models: { B: {}, C: { fields: ['y'] } } } }); + const failure = expectFailure( + resolve(['A:B', 'A.x:C.y'], origin, destination), + INVALID, + 'a field cannot move between models', + ); + expect(`${failure.why} ${failure.fix}`).not.toContain('--rename'); + }); + + it('gives no corrected statement when another model was renamed to the new model', () => { + const origin = contractOf({ + app: { models: { A: { fields: ['x'] }, C: { fields: ['z'] } } }, + }); + const destination = contractOf({ app: { models: { B: { fields: ['y'] } } } }); + const failure = expectFailure( + resolve(['C:B', 'A.x:B.y'], origin, destination), + INVALID, + 'a field cannot move between models', + ); + expect(`${failure.why} ${failure.fix}`).not.toContain('--rename'); + }); + + it('gives no corrected statement when the new model is also in the origin', () => { + const origin = contractOf({ + app: { models: { A: { fields: ['x'] }, C: { fields: ['z'] } } }, + }); + const destination = contractOf({ app: { models: { C: { fields: ['y'] } } } }); + const failure = expectFailure( + resolve(['A.x:C.y'], origin, destination), + INVALID, + 'a field cannot move between models', + ); + expect(`${failure.why} ${failure.fix}`).not.toContain('--rename'); + }); + + it('refuses old and new sides naming two models both contracts have', () => { + const origin = contractOf({ + app: { models: { User: { fields: ['a'] }, Post: { fields: ['c'] } } }, + }); + const destination = contractOf({ + app: { models: { User: { fields: ['c'] }, Post: { fields: ['b'] } } }, + }); + expectFailure( + resolve(['User.a:Post.b'], origin, destination), + INVALID, + 'a field cannot move between models', + ); + }); + }); + + describe('names that do not resolve', () => { + it('refuses an old field the origin model does not have, naming its fields', () => { + const { origin, destination } = nameToFullName; + expectFailure( + resolve(['User.email:User.fullName'], origin, destination), + UNRESOLVED, + 'origin model "app.User" has no field "email"', + 'id, name', + ); + }); + + it('refuses a new field the destination model does not have, naming its fields', () => { + const { origin, destination } = nameToFullName; + expectFailure( + resolve(['User.name:User.email'], origin, destination), + UNRESOLVED, + 'destination model "app.User" has no field "email"', + 'fullName, id', + ); + }); + + it('refuses a new field the origin model already has', () => { + const origin = contractOf({ app: { models: { User: { fields: ['name', 'fullName'] } } } }); + const destination = contractOf({ app: { models: { User: { fields: ['fullName'] } } } }); + expectFailure( + resolve(['User.name:User.fullName'], origin, destination), + UNRESOLVED, + 'field "fullName" already exists on the origin model "app.User"', + ); + }); + + it('refuses an old field the destination model still has', () => { + const origin = contractOf({ app: { models: { User: { fields: ['name'] } } } }); + const destination = contractOf({ + app: { models: { User: { fields: ['name', 'fullName'] } } }, + }); + expectFailure( + resolve(['User.name:User.fullName'], origin, destination), + UNRESOLVED, + 'field "name" still exists on the destination model "app.User"', + ); + }); + + it('refuses a model with no origin counterpart', () => { + const origin = contractOf({ app: { models: {} } }); + const destination = contractOf({ app: { models: { User: { fields: ['fullName'] } } } }); + expectFailure( + resolve(['User.name:User.fullName'], origin, destination), + UNRESOLVED, + 'no counterpart in the origin contract', + ); + }); + }); + + describe('entity kinds', () => { + it('resolves a relation field like any field', () => { + const origin = contractOf({ app: { models: { User: { relations: ['posts'] } } } }); + const destination = contractOf({ app: { models: { User: { relations: ['articles'] } } } }); + expect(expectValue(resolve(['User.posts:User.articles'], origin, destination))).toEqual([ + { + kind: 'rename', + entity: 'field', + from: { namespaceId: 'app', model: 'User', field: 'posts' }, + to: { namespaceId: 'app', model: 'User', field: 'articles' }, + }, + ]); + }); + + it('resolves a field of a variant', () => { + const origin = contractOf({ + app: { models: { Pet: {}, Dog: { base: 'Pet', fields: ['bark'] } } }, + }); + const destination = contractOf({ + app: { models: { Pet: {}, Dog: { base: 'Pet', fields: ['woof'] } } }, + }); + expect(expectValue(resolve(['Dog.bark:Dog.woof'], origin, destination))).toHaveLength(1); + }); + + it('refuses a field of a value object, saying value object renames are not supported', () => { + const origin = contractOf({ app: { valueObjects: { Address: ['street'] } } }); + const destination = contractOf({ app: { valueObjects: { Address: ['road'] } } }); + expectFailure( + resolve(['app.Address.street:app.Address.road'], origin, destination), + UNRESOLVED, + 'value object renames are not supported in this release', + ); + }); + }); + + describe('repeated names across statements', () => { + it('refuses renaming one field twice', () => { + const origin = contractOf({ app: { models: { User: { fields: ['name'] } } } }); + const destination = contractOf({ + app: { models: { User: { fields: ['fullName', 'title'] } } }, + }); + expectFailure( + resolve(['User.name:User.fullName', 'User.name:User.title'], origin, destination), + INVALID, + 'already renames "app.User.name"', + ); + }); + + it('refuses renaming two fields to the same name', () => { + const origin = contractOf({ app: { models: { User: { fields: ['a', 'b'] } } } }); + const destination = contractOf({ app: { models: { User: { fields: ['c'] } } } }); + const failure = expectFailure( + resolve(['User.a:User.c', 'User.b:User.c'], origin, destination), + INVALID, + '--rename User.b:User.c', + 'already renames a field to "app.User.c"', + ); + expect(failure.fix).not.toContain('namespace.Model.field'); + }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-model-statements.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-model-statements.test.ts new file mode 100644 index 000000000000..07c522495135 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-model-statements.test.ts @@ -0,0 +1,315 @@ +import { UNBOUND_DOMAIN_NAMESPACE_ID } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { resolveStatements } from '../../../src/control-api/statements/resolve-statements'; +import { renameStatements } from '../../../src/control-api/statements/statement-text'; +import { contractOf, expectFailure, expectValue } from './statement-fixtures'; + +const UNRESOLVED = 'MIGRATION.STATEMENT_UNRESOLVED'; +const INVALID = 'MIGRATION.STATEMENT_INVALID'; + +function resolve( + renames: readonly string[], + origin: ReturnType, + destination: ReturnType, +) { + return resolveStatements({ + statements: renameStatements(renames), + origin: { kind: 'contract', contract: origin }, + destination, + }); +} + +const profileToUser = { + origin: contractOf({ app: { models: { Profile: {}, Post: {} } } }), + destination: contractOf({ app: { models: { User: {}, Post: {} } } }), +}; + +describe('resolveStatements, model renames', () => { + it('resolves a bare model through the sole namespace', () => { + const { origin, destination } = profileToUser; + expect(expectValue(resolve(['Profile:User'], origin, destination))).toEqual([ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + ]); + }); + + it('resolves a rename that changes namespace and name', () => { + const origin = contractOf({ auth: { models: { Profile: {} } }, billing: {} }); + const destination = contractOf({ auth: {}, billing: { models: { User: {} } } }); + expect(expectValue(resolve(['auth.Profile:billing.User'], origin, destination))).toEqual([ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'auth', model: 'Profile' }, + to: { namespaceId: 'billing', model: 'User' }, + }, + ]); + }); + + it('keeps the order the statements were given in', () => { + const origin = contractOf({ app: { models: { Profile: {}, Post: {} } } }); + const destination = contractOf({ app: { models: { User: {}, Article: {} } } }); + const resolved = expectValue(resolve(['Post:Article', 'Profile:User'], origin, destination)); + expect(resolved.map((statement) => statement.to.model)).toEqual(['Article', 'User']); + }); + + it('returns no statements when none are given', () => { + const { origin, destination } = profileToUser; + expect(expectValue(resolve([], origin, destination))).toEqual([]); + }); + + describe('names that do not resolve', () => { + it('refuses an old name the origin does not have, naming the models it has', () => { + const { origin, destination } = profileToUser; + expectFailure( + resolve(['Account:User'], origin, destination), + UNRESOLVED, + 'Account:User', + 'origin contract', + 'app.Post', + 'app.Profile', + ); + }); + + it('lists at most twenty models when the old name is missing', () => { + const models = Object.fromEntries( + Array.from({ length: 25 }, (_, i) => [`M${String(i).padStart(2, '0')}`, {}]), + ); + const failure = expectFailure( + resolve(['Account:User'], contractOf({ app: { models } }), profileToUser.destination), + UNRESOLVED, + 'app.M00', + 'app.M19', + 'and 5 more', + ); + expect(failure.why).not.toContain('app.M20'); + }); + + it('refuses a new name the destination does not have, naming the models it has', () => { + const { origin, destination } = profileToUser; + expectFailure( + resolve(['Profile:Account'], origin, destination), + UNRESOLVED, + 'destination contract', + 'app.Post', + 'app.User', + ); + }); + + it('refuses a new name the origin already has', () => { + const origin = contractOf({ app: { models: { Profile: {}, User: {} } } }); + const destination = contractOf({ app: { models: { User: {} } } }); + expectFailure( + resolve(['Profile:User'], origin, destination), + UNRESOLVED, + '"app.User" already exists in the origin contract', + ); + }); + + it('refuses an old name the destination still has', () => { + const origin = contractOf({ app: { models: { Profile: {} } } }); + const destination = contractOf({ app: { models: { Profile: {}, User: {} } } }); + expectFailure( + resolve(['Profile:User'], origin, destination), + UNRESOLVED, + '"app.Profile" still exists in the destination contract', + ); + }); + + it('refuses the second half of a swap, because resolution reads the contracts', () => { + const origin = contractOf({ app: { models: { A: {} } } }); + const destination = contractOf({ app: { models: { B: {} } } }); + expectFailure(resolve(['A:B', 'B:A'], origin, destination), UNRESOLVED, '--rename B:A'); + }); + + it('matches names exactly, including case', () => { + const origin = contractOf({ app: { models: { user: {} } } }); + const destination = contractOf({ app: { models: { User: {} } } }); + expect(expectValue(resolve(['user:User'], origin, destination))).toHaveLength(1); + expectFailure(resolve(['User:User'], origin, destination), UNRESOLVED, 'origin contract'); + }); + }); + + describe('namespaces', () => { + it('refuses a bare name several namespaces declare, listing the qualified candidates', () => { + const origin = contractOf({ + auth: { models: { User: {} } }, + billing: { models: { User: {} } }, + }); + const destination = contractOf({ + auth: { models: { Account: {} } }, + billing: { models: { User: {} } }, + }); + const failure = expectFailure( + resolve(['User:Account'], origin, destination), + UNRESOLVED, + 'auth.User', + 'billing.User', + ); + expect(failure.fix).toBe('Name the model with its namespace, for example auth.User.'); + }); + + it('does not prefer the default namespace for a bare name', () => { + const origin = contractOf({ + [UNBOUND_DOMAIN_NAMESPACE_ID]: { models: { User: {} } }, + auth: { models: { User: {} } }, + }); + const destination = contractOf({ + [UNBOUND_DOMAIN_NAMESPACE_ID]: { models: { Account: {} } }, + auth: { models: { User: {} } }, + }); + expectFailure( + resolve(['User:Account'], origin, destination), + UNRESOLVED, + `${UNBOUND_DOMAIN_NAMESPACE_ID}.User`, + 'auth.User', + ); + }); + + it('refuses a two-segment side that resolves both as namespace.Model and as Model.field', () => { + const origin = contractOf({ + a: { models: { b: {} } }, + app: { models: { a: { fields: ['b'] } } }, + }); + const destination = contractOf({ + a: { models: { c: {} } }, + app: { models: { a: { fields: ['c'] } } }, + }); + expectFailure( + resolve(['a.b:a.c'], origin, destination), + UNRESOLVED, + 'namespace "a" model "b"', + 'model "a" field "b"', + ); + }); + + it('refuses a two-segment side that resolves neither way, naming both attempts', () => { + const { origin, destination } = profileToUser; + expectFailure( + resolve(['x.y:User'], origin, destination), + UNRESOLVED, + 'as namespace.Model', + 'no namespace "x"', + 'as Model.field', + 'no model "x"', + ); + }); + }); + + describe('depth', () => { + it('refuses a model renamed to something that resolves as a field', () => { + const origin = contractOf({ app: { models: { Profile: {} } } }); + const destination = contractOf({ app: { models: { User: { fields: ['name'] } } } }); + expectFailure( + resolve(['Profile:User.name'], origin, destination), + INVALID, + 'model on one side and a field on the other', + ); + }); + }); + + describe('entity kinds', () => { + it('resolves a variant like any model', () => { + const origin = contractOf({ app: { models: { Pet: {}, Dog: { base: 'Pet' } } } }); + const destination = contractOf({ app: { models: { Pet: {}, Hound: { base: 'Pet' } } } }); + expect(expectValue(resolve(['Dog:Hound'], origin, destination))).toEqual([ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Dog' }, + to: { namespaceId: 'app', model: 'Hound' }, + }, + ]); + }); + + it('refuses a value object, saying value object renames are not supported', () => { + const origin = contractOf({ app: { valueObjects: { Address: ['street'] } } }); + const destination = contractOf({ app: { valueObjects: { Location: ['street'] } } }); + const failure = expectFailure( + resolve(['Address:Location'], origin, destination), + UNRESOLVED, + 'value object renames are not supported in this release', + ); + expect(failure.fix).toBe('Leave value objects and their fields out of the statements.'); + }); + }); + + describe('repeated names across statements', () => { + it('refuses renaming two models to the same name', () => { + const origin = contractOf({ app: { models: { A: {}, C: {} } } }); + const destination = contractOf({ app: { models: { B: {} } } }); + const failure = expectFailure( + resolve(['A:B', 'C:B'], origin, destination), + INVALID, + '--rename C:B', + 'already renames a model to "app.B"', + ); + expect(failure.fix).not.toContain('namespace.Model.field'); + }); + + it('refuses renaming one model twice', () => { + const origin = contractOf({ app: { models: { A: {} } } }); + const destination = contractOf({ app: { models: { B: {}, C: {} } } }); + expectFailure( + resolve(['A:B', 'A:C'], origin, destination), + INVALID, + 'already renames "app.A"', + ); + }); + }); +}); + +describe('resolveStatements, origin contract unknown', () => { + const destination = contractOf({ app: { models: { User: {} } } }); + + it('refuses before reading any statement, naming the hash and the directory', () => { + const result = resolveStatements({ + statements: renameStatements(['not a statement']), + origin: { + kind: 'missing', + hash: 'sha256:abc', + snapshotDirectory: 'migrations/snapshots', + unreadable: undefined, + }, + destination, + }); + expectFailure( + result, + 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + 'sha256:abc', + 'migrations/snapshots', + ); + }); + + it('says there is no marker when there is no hash to look for', () => { + const result = resolveStatements({ + statements: renameStatements(['Profile:User']), + origin: { + kind: 'missing', + hash: null, + snapshotDirectory: 'migrations/snapshots', + unreadable: undefined, + }, + destination, + }); + expectFailure(result, 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', 'no marker', 'no earlier contract'); + }); + + it('resolves to no statements when none are given', () => { + const result = resolveStatements({ + statements: renameStatements([]), + origin: { + kind: 'missing', + hash: null, + snapshotDirectory: 'migrations/snapshots', + unreadable: undefined, + }, + destination, + }); + expect(expectValue(result)).toEqual([]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-statement-advice.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-statement-advice.test.ts new file mode 100644 index 000000000000..a1bbf1ba462d --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-statement-advice.test.ts @@ -0,0 +1,154 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { resolveStatements } from '../../../src/control-api/statements/resolve-statements'; +import { renameStatements } from '../../../src/control-api/statements/statement-text'; +import { contractOf, expectFailure } from './statement-fixtures'; + +const UNRESOLVED = 'MIGRATION.STATEMENT_UNRESOLVED'; +const INVALID = 'MIGRATION.STATEMENT_INVALID'; + +function resolve( + renames: readonly string[], + origin: ReturnType, + destination: ReturnType, +) { + return resolveStatements({ + statements: renameStatements(renames), + origin: { kind: 'contract', contract: origin }, + destination, + }); +} + +function unbound(models: Record) { + return contractOf({ + [UNBOUND_NAMESPACE_ID]: { + models: Object.fromEntries( + Object.entries(models).map(([name, fields]) => [name, { fields }]), + ), + }, + }); +} + +const profile = unbound({ Profile: ['id', 'name', 'handle'], Post: ['id'] }); +const user = unbound({ User: ['id', 'fullName', 'handle'], Post: ['id'] }); + +function adviceOf(result: ReturnType, code: string) { + const failure = expectFailure(result, code); + return { why: failure.why, fix: failure.fix }; +} + +describe('statement errors say what to type', () => { + it('says a statement the database has already applied should be left out', () => { + expect(adviceOf(resolve(['Profile:User'], user, user), UNRESOLVED)).toEqual({ + why: 'The origin contract already has "User" and has no "Profile", so this rename has already happened. For db update, the database is already at the new name.', + fix: 'Leave out --rename Profile:User.', + }); + }); + + it('says the same for a field statement that has already been applied', () => { + expect(adviceOf(resolve(['User.name:User.fullName'], user, user), UNRESOLVED).fix).toBe( + 'Leave out --rename User.name:User.fullName.', + ); + }); + + it('writes a backwards statement the right way round', () => { + expect(adviceOf(resolve(['User:Profile'], profile, user), UNRESOLVED)).toEqual({ + why: 'The statement is the wrong way round: "Profile" is the old name, in the origin contract, and "User" the new name, in the destination contract.', + fix: 'Write the old name first: --rename Profile:User.', + }); + }); + + it('refuses a field swap and gives the three plans that make it', () => { + const origin = unbound({ Profile: ['id', 'name', 'handle'] }); + expect( + adviceOf( + resolve(['Profile.name:Profile.handle', 'Profile.handle:Profile.name'], origin, origin), + INVALID, + ), + ).toEqual({ + why: '"Profile.name" and "Profile.handle" swap names: each is both an old name and a new name, and statements cannot swap names in one plan.', + fix: 'Make the swap in three plans. First change the contract so that "Profile.name" has a temporary name, and plan it with --rename Profile.name:Profile.. Then give "Profile.handle" the name "Profile.name", and plan it with --rename Profile.handle:Profile.name. Then give the temporary name the name "Profile.handle", and plan it with --rename Profile.:Profile.handle.', + }); + }); + + it('advises three plans whose statements each resolve against their contracts', () => { + const contracts = [ + unbound({ Profile: ['id', 'name', 'handle'] }), + unbound({ Profile: ['id', 'tmp', 'handle'] }), + unbound({ Profile: ['id', 'tmp', 'name'] }), + unbound({ Profile: ['id', 'handle', 'name'] }), + ]; + const [first] = contracts; + if (first === undefined) throw new Error('expected contracts'); + const fix = adviceOf( + resolve(['Profile.name:Profile.handle', 'Profile.handle:Profile.name'], first, first), + INVALID, + ).fix; + const advised = [ + ...(fix ?? '').replaceAll('', 'tmp').matchAll(/--rename (\S+?)\.(?:\s|$)/g), + ].map((match) => match[1] ?? ''); + expect(advised).toEqual([ + 'Profile.name:Profile.tmp', + 'Profile.handle:Profile.name', + 'Profile.tmp:Profile.handle', + ]); + advised.forEach((statement, index) => { + const [from, to] = [contracts[index], contracts[index + 1]]; + if (from === undefined || to === undefined) throw new Error('expected contracts'); + expect(resolve([statement], from, to).ok, statement).toBe(true); + }); + }); + + it('refuses a model swap the same way', () => { + const origin = unbound({ Profile: ['id'], User: ['id'] }); + expect(adviceOf(resolve(['Profile:User'], origin, origin), INVALID).why).toContain( + 'swap names', + ); + }); + + it('says a model renamed to itself renames nothing, and what to do when only its stored name changed', () => { + expect(adviceOf(resolve(['Profile:Profile'], profile, profile), INVALID)).toEqual({ + why: 'The statement renames model "Profile" to itself.', + fix: 'Leave out --rename Profile:Profile. If only the stored name changed (@map or @@map), a statement cannot state that in this release, and a plan without it drops and creates what the model or field is stored in, with its data. With migration plan, edit the planned migration.ts to rename the stored name instead of dropping and creating it. With db update, rename it in the database yourself first, then run db update, which then finds nothing that loses data.', + }); + }); + + it('says a field renamed to itself renames nothing, and what to do when only its stored name changed', () => { + expect(adviceOf(resolve(['Profile.name:Profile.name'], profile, profile), INVALID).fix).toBe( + 'Leave out --rename Profile.name:Profile.name. If only the stored name changed (@map or @@map), a statement cannot state that in this release, and a plan without it drops and creates what the model or field is stored in, with its data. With migration plan, edit the planned migration.ts to rename the stored name instead of dropping and creating it. With db update, rename it in the database yourself first, then run db update, which then finds nothing that loses data.', + ); + }); + + it('puts a model statement given after its field statement first', () => { + const origin = unbound({ Profile: ['id', 'name'] }); + const destination = unbound({ Account: ['id', 'fullName'] }); + const advice = adviceOf( + resolve(['Account.name:Account.fullName', 'Profile:Account'], origin, destination), + UNRESOLVED, + ); + expect(advice.fix).toBe( + 'Put --rename Profile:Account before --rename Account.name:Account.fullName.', + ); + expect(advice.why).not.toContain('namespace.Model'); + }); + + it('writes both statements a mix of model and field may have meant', () => { + expect(adviceOf(resolve(['Profile:User.fullName'], profile, user), INVALID).fix).toBe( + "To rename the model, write --rename Profile:User. To rename a field, write --rename User.:User.fullName, naming the field's model as the new contract names it.", + ); + }); +}); + +describe('statement errors in a contract with no namespaces', () => { + it.each([ + ['Nope:User', profile, user], + ['Profile:User', user, user], + ['Account.name:Account.fullName', profile, user], + ['Profile:User', profile, unbound({ Profile: ['id'], User: ['id'] })], + ])('never show the internal namespace id (%s)', (text, origin, destination) => { + const result = resolve([text], origin, destination); + if (result.ok) throw new Error('expected a failure'); + const envelope = result.failure.toEnvelope(); + expect(JSON.stringify(envelope)).not.toContain(UNBOUND_NAMESPACE_ID); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/statement-fixtures.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/statement-fixtures.ts new file mode 100644 index 000000000000..8ac3fdb4376d --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/statement-fixtures.ts @@ -0,0 +1,98 @@ +import type { + ContractField, + ContractModelBase, + ContractRelation, + ContractValueObject, + ContractWithDomain, +} from '@internal/contract/types'; +import { crossRef } from '@internal/contract/types'; +import type { CliStructuredError } from '@internal/errors/control'; +import type { Result } from '@internal/utils/result'; +import { expect } from 'vitest'; + +interface ModelSpec { + readonly fields?: readonly string[]; + readonly relations?: readonly string[]; + readonly base?: string; +} + +interface NamespaceSpec { + readonly models?: Record; + readonly valueObjects?: Record; +} + +const textField: ContractField = { + nullable: false, + type: { kind: 'scalar', codecId: 'test/text@1' }, +}; + +function fieldsOf(names: readonly string[]): Record { + return Object.fromEntries(names.map((name) => [name, textField])); +} + +function modelOf(spec: ModelSpec): ContractModelBase { + const relation: ContractRelation = { + to: crossRef('Other'), + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['ownerId'] }, + }; + return { + fields: fieldsOf(spec.fields ?? []), + relations: Object.fromEntries((spec.relations ?? []).map((name) => [name, relation])), + storage: {}, + ...(spec.base !== undefined ? { base: crossRef(spec.base) } : {}), + }; +} + +function valueObjectOf(fields: readonly string[]): ContractValueObject { + return { fields: fieldsOf(fields) }; +} + +export function contractOf(namespaces: Record): ContractWithDomain { + return { + domain: { + namespaces: Object.fromEntries( + Object.entries(namespaces).map(([id, ns]) => [ + id, + { + models: Object.fromEntries( + Object.entries(ns.models ?? {}).map(([name, spec]) => [name, modelOf(spec)]), + ), + valueObjects: Object.fromEntries( + Object.entries(ns.valueObjects ?? {}).map(([name, fields]) => [ + name, + valueObjectOf(fields), + ]), + ), + }, + ]), + ), + }, + }; +} + +export function expectFailure( + result: Result, + code: string, + ...fragments: readonly string[] +): CliStructuredError { + if (result.ok) { + throw new Error(`Expected ${code}, got success: ${JSON.stringify(result.value)}`); + } + expect(result.failure.code).toBe(code); + const text = `${result.failure.message}\n${result.failure.why ?? ''}\n${result.failure.fix ?? ''}`; + for (const fragment of fragments) { + expect(text).toContain(fragment); + } + if (code === 'MIGRATION.STATEMENT_INVALID' || code === 'MIGRATION.STATEMENT_UNRESOLVED') { + expect(result.failure.nextActions?.map((action) => action.label)).toEqual([result.failure.fix]); + } + return result.failure; +} + +export function expectValue(result: Result): T { + if (!result.ok) { + throw new Error(`Expected success, got ${result.failure.code}: ${result.failure.why}`); + } + return result.value; +} diff --git a/packages/1-framework/3-tooling/cli/test/migration-cli.test.ts b/packages/1-framework/3-tooling/cli/test/migration-cli.test.ts index 90d567ac3dda..5f0753d6955f 100644 --- a/packages/1-framework/3-tooling/cli/test/migration-cli.test.ts +++ b/packages/1-framework/3-tooling/cli/test/migration-cli.test.ts @@ -19,8 +19,10 @@ import { structuredError } from '@internal/utils/structured-error'; import { join } from 'pathe'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const loadConfigMock = vi.fn(); -const createControlStackMock = vi.fn(); +const { loadConfigMock, createControlStackMock } = vi.hoisted(() => ({ + loadConfigMock: vi.fn(), + createControlStackMock: vi.fn(), +})); vi.mock('@internal/config-loader', () => ({ loadConfigForSections: loadConfigMock, diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts index dd9fff4634e1..753c3bfbd476 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-init.test.ts @@ -167,6 +167,7 @@ describe('db init', () => { plannedAdvanceRef: null, summary: 'Database initialized', }); + expect(run.presented?.data).not.toHaveProperty('appliedStatements'); }); it('reports the control API`s spans as step events rather than printing them', async () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index b850a7ebf7b3..f43beed9efa6 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -185,6 +185,11 @@ describe('db update --to bundle resolution', () => { { flags: [], command: 'db update', after: '' }, { flags: ['--advance-ref', 'staging'], command: 'db update', after: ' --advance-ref staging' }, { flags: ['--dry-run'], command: 'db update --dry-run', after: '' }, + { + flags: ['--rename', 'Profile:User', '--rename', 'User.name:User.fullName'], + command: 'db update', + after: ' --rename Profile:User --rename User.name:User.fullName', + }, ])( 'keeps --to and $flags in the retry command when no connection is configured', async ({ flags, command, after }) => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts index 46a5b8f7efbf..a40cd9cc2cb8 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update.test.ts @@ -96,6 +96,7 @@ function ormConfig(overrides: Record = {}): Record { return { mode: 'apply', + appliedStatements: [], destination: { storageHash: DEST_HASH }, plan: { operations: [ @@ -123,6 +124,7 @@ function applySuccess(): Record { function planSuccess(): Record { return { mode: 'plan', + appliedStatements: [], destination: { storageHash: DEST_HASH }, plan: { operations: [{ id: 'op-2', label: 'drop relation legacy', operationClass: 'destructive' }], @@ -152,6 +154,78 @@ function stepEvents(events: readonly EngineEvent[]): readonly EngineEvent[] { return events.filter((event) => event.kind === 'step-started' || event.kind === 'step-finished'); } +const PROFILE_TO_USER = { + statement: { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + operationIndexes: [0, 1], + description: 'rename model "Profile" to "User"', +}; + +describe('db update --rename', () => { + it('hands every --rename to the control API in the order given', async () => { + await harness().run(['db', 'update', '--rename', 'Profile:User', '--rename', 'User.a:User.b'], { + cwd: projectDir, + }); + expect(mocks.dbUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + statements: [ + { verb: 'rename', text: 'Profile:User' }, + { verb: 'rename', text: 'User.a:User.b' }, + ], + }), + ); + }); + + it('hands an empty list when no --rename is given', async () => { + await harness().run(['db', 'update'], { cwd: projectDir }); + expect(mocks.dbUpdate).toHaveBeenCalledWith(expect.objectContaining({ statements: [] })); + }); + + it('lists the applied statements after the operations, and in the JSON document', async () => { + mocks.dbUpdate.mockResolvedValue( + ok({ ...applySuccess(), appliedStatements: [PROFILE_TO_USER] }), + ); + const run = await harness().run(['db', 'update', '--rename', 'Profile:User'], { + cwd: projectDir, + isTty: { stdout: true }, + }); + const human = run.presented?.presentation.human ?? []; + expect(human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: [{ label: 'rename model "Profile" to "User" (2 operations)' }], + }, + ], + }); + expect(run.presented?.data).toMatchObject({ appliedStatements: [PROFILE_TO_USER] }); + }); + + it('lists the applied statements on a dry run', async () => { + mocks.dbUpdate.mockResolvedValue( + ok({ ...planSuccess(), appliedStatements: [{ ...PROFILE_TO_USER, operationIndexes: [] }] }), + ); + const run = await harness().run(['db', 'update', '--dry-run', '--rename', 'Profile:User'], { + cwd: projectDir, + isTty: { stdout: true }, + }); + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: [{ label: 'rename model "Profile" to "User" (no operations)' }], + }, + ], + }); + }); +}); + describe('db update', () => { it('settles as a completed envelope carrying the migration document', async () => { const run = await harness().run(['db', 'update', '--json'], { cwd: projectDir }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts index e14077739bb8..cc18987c85a6 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/offline-project.ts @@ -36,24 +36,49 @@ export async function removeOfflineProjects(): Promise { } } -export function contractJson(storageHash: string): Record { +/** + * An emitted contract. With `models`, it carries an application domain whose + * one namespace `app` declares those models, so statements can resolve. + */ +export function contractJson( + storageHash: string, + models?: readonly string[], +): Record { return { storage: { storageHash, namespaces: {} }, schemaVersion: '1.0.0', target: 'postgres', targetFamily: 'sql', models: {}, + ...(models === undefined + ? {} + : { + domain: { + namespaces: { + app: { + models: Object.fromEntries( + models.map((model) => [model, { fields: {}, relations: {}, storage: {} }]), + ), + }, + }, + }, + }), }; } export async function createOfflineProject(options: { readonly storageHash: string; + readonly models?: readonly string[]; }): Promise { const dir = createTestProjectDir('orm-offline'); created.push(dir); const contractPath = join(dir, 'output', 'contract.json'); await mkdir(join(dir, 'output'), { recursive: true }); - await writeFile(contractPath, JSON.stringify(contractJson(options.storageHash)), 'utf-8'); + await writeFile( + contractPath, + JSON.stringify(contractJson(options.storageHash, options.models)), + 'utf-8', + ); await writeFile( join(dir, 'package.json'), JSON.stringify({ name: 'offline-fixture', dependencies: {} }), @@ -118,9 +143,10 @@ export async function seedMigrationPackage(options: { export async function seedContractSnapshot(options: { readonly migrationsDir: string; readonly storageHash: string; + readonly models?: readonly string[]; }): Promise { await writeContractSnapshot(options.migrationsDir, options.storageHash, { - contractJson: contractJson(options.storageHash), + contractJson: contractJson(options.storageHash, options.models), contractDts: 'export type Contract = never;\n', }); } @@ -150,20 +176,36 @@ export interface FakePlannerScript { readonly conflicts?: ReadonlyArray<{ readonly kind: string; readonly summary: string }>; readonly throwOnOperations?: unknown; readonly throwOnPlan?: unknown; + /** Receives the statements of every `plan` call, in call order. */ + readonly statementsReceived?: unknown[][]; + /** Fails every `plan` call that is given statements, as a planner that refuses them does. */ + readonly refuseStatements?: boolean; } function fakePlanner(script: FakePlannerScript): Record { let planCalls = 0; return { - plan: () => { + plan: (options: { readonly statements: readonly unknown[] }) => { + script.statementsReceived?.push([...options.statements]); if (script.throwOnPlan !== undefined) { throw script.throwOnPlan; } const operations = script.operationsByPlan?.[planCalls] ?? script.operations; planCalls += 1; + const [refused] = script.refuseStatements === true ? options.statements : []; + if (refused !== undefined) { + return { + kind: 'failure', + conflicts: [{ kind: 'statementRefused', summary: 'Refused', refusedStatement: refused }], + }; + } return script.conflicts === undefined ? { kind: 'success', + appliedStatements: options.statements.map((statement) => ({ + statement, + operationIndexes: (operations ?? [ADDITIVE_OP]).map((_, index) => index), + })), plan: { operations: script.throwOnOperations === undefined diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan-statements.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan-statements.test.ts new file mode 100644 index 000000000000..39adc8885d4c --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan-statements.test.ts @@ -0,0 +1,351 @@ +import { readdir } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BIN_GROUPS } from '../../src/orm/cli'; +import { errorUnfilledPlaceholder } from '../../src/utils/cli-errors'; +import { createOrmTestCli } from '../helpers/orm-test-cli'; +import { + ADDITIVE_OP, + createOfflineProject, + type FakePlannerScript, + OFFLINE_COMMANDS, + type OfflineProject, + offlineConfig, + removeOfflineProjects, + resetRenderContractDtsMock, + seedContractSnapshot, + seedDbRef, + seedMigrationPackage, +} from './fixtures/offline-project'; + +const HASH_TO = `c0ffee${'0'.repeat(58)}`; +const HASH_FROM = `beef${'1'.repeat(60)}`; + +beforeEach(resetRenderContractDtsMock); +afterEach(removeOfflineProjects); + +function harness(project: OfflineProject, script: FakePlannerScript = {}) { + return createOrmTestCli({ + commands: OFFLINE_COMMANDS, + groups: BIN_GROUPS, + orm: offlineConfig({ project, script }), + }); +} + +async function plannedDirs(project: OfflineProject): Promise { + try { + return (await readdir(project.appMigrationsDir)).filter((entry) => entry !== 'refs').sort(); + } catch { + return []; + } +} + +/** The database is at a contract with `Profile`; the emitted contract has `User` instead. */ +async function renamingProject(options: { readonly history: boolean } = { history: true }) { + const project = await createOfflineProject({ storageHash: HASH_TO, models: ['User', 'Post'] }); + if (options.history) { + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + } + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_FROM, + models: ['Profile', 'Article'], + }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + return project; +} + +const profileToUser = { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, +}; + +describe('migration plan --rename', () => { + it('hands the planner the resolved statements in the order given', async () => { + const project = await renamingProject(); + const statementsReceived: unknown[][] = []; + const run = await harness(project, { statementsReceived }).run( + ['migration', 'plan', '--rename', 'Profile:User', '--rename', 'Article:Post'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(statementsReceived).toEqual([ + [ + profileToUser, + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Article' }, + to: { namespaceId: 'app', model: 'Post' }, + }, + ], + ]); + }); + + it('reports the applied statements in JSON and under the operations in human output', async () => { + const project = await renamingProject(); + const run = await harness(project).run(['migration', 'plan', '--rename', 'Profile:User'], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.presented?.data).toMatchObject({ + appliedStatements: [ + { + statement: profileToUser, + description: 'rename model "Profile" to "User"', + operationIndexes: [0], + }, + ], + }); + const human = run.presented?.presentation.human ?? []; + const statementsIndex = human.findIndex( + (block) => block.kind === 'tree' && block.roots[0]?.label === 'Statements applied', + ); + const operationsIndex = human.findIndex( + (block) => block.kind === 'tree' && block.roots[0]?.label !== 'Statements applied', + ); + expect(human[statementsIndex]).toEqual({ + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: [{ label: 'rename model "Profile" to "User" (1 operation)' }], + }, + ], + }); + expect(statementsIndex).toBeGreaterThan(operationsIndex); + }); + + it('refuses an unresolvable statement before writing anything', async () => { + const project = await renamingProject(); + const statementsReceived: unknown[][] = []; + const run = await harness(project, { statementsReceived }).run( + ['migration', 'plan', '--rename', 'Account:User', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).not.toBe(0); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.STATEMENT_UNRESOLVED' } }, + }); + expect(statementsReceived).toEqual([]); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('refuses a malformed statement', async () => { + const project = await renamingProject(); + const run = await harness(project).run(['migration', 'plan', '--rename', 'Profile', '--json'], { + cwd: project.dir, + }); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.STATEMENT_INVALID' } }, + }); + }); + + it('refuses every statement on a plan from an empty database', async () => { + const project = await renamingProject(); + const run = await harness(project).run( + ['migration', 'plan', '--from', '@empty', '--rename', 'Profile:User', '--json'], + { cwd: project.dir }, + ); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.STATEMENT_UNRESOLVED', + why: expect.stringContaining('origin contract has no model "Profile" (models: (none))'), + }, + }, + }); + }); + + /** + * The database is at the emitted contract's storage hash, but the snapshot of that hash still + * names the model `Profile`: a rename whose table name is kept leaves the storage unchanged. + */ + async function storageUnchangedProject() { + const project = await createOfflineProject({ storageHash: HASH_TO, models: ['User', 'Post'] }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_TO, + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_TO, + models: ['Profile', 'Article'], + }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_TO }); + return project; + } + + it('reports a statement that needs no operations as applied, without writing a package', async () => { + const project = await storageUnchangedProject(); + const run = await harness(project, { operations: [] }).run( + ['migration', 'plan', '--rename', 'Profile:User'], + { cwd: project.dir, isTty: { stdout: true } }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + noOp: true, + appliedStatements: [{ statement: profileToUser, operationIndexes: [] }], + }); + expect(run.presented?.presentation.human.at(1)).toEqual({ + kind: 'summary', + status: 'ok', + text: 'No changes to plan: the statements need no operations', + }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('fails planning when the storage changed but the planner planned nothing, statements or not', async () => { + const project = await renamingProject(); + const run = await harness(project, { operations: [] }).run( + ['migration', 'plan', '--rename', 'Profile:User', '--json'], + { cwd: project.dir }, + ); + + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.PLANNING_FAILED' } }, + }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('passes the statements to the delta of an auto-baseline plan only', async () => { + const project = await renamingProject({ history: false }); + const statementsReceived: unknown[][] = []; + const run = await harness(project, { statementsReceived }).run( + ['migration', 'plan', '--name', 'delta', '--rename', 'Profile:User'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(statementsReceived).toEqual([[], [profileToUser]]); + expect(run.presented?.data).toMatchObject({ + appliedStatements: [{ statement: profileToUser }], + }); + }); + + it('writes no package, not even the auto-baseline, when the delta refuses a statement', async () => { + const project = await renamingProject({ history: false }); + const run = await harness(project, { refuseStatements: true }).run( + ['migration', 'plan', '--name', 'delta', '--rename', 'Profile:User', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).not.toBe(0); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.PLANNING_FAILED' } }, + }); + expect(await plannedDirs(project)).toEqual([]); + }); + + it('lists the resolved operations and the statements of a plan that has placeholders, in JSON and human output', async () => { + const project = await renamingProject(); + const run = await harness(project, { + operations: [ADDITIVE_OP], + throwOnOperations: errorUnfilledPlaceholder('backfill'), + }).run(['migration', 'plan', '--rename', 'Profile:User'], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + pendingPlaceholders: true, + operations: [{ id: ADDITIVE_OP.id }], + appliedStatements: [{ statement: profileToUser, operationIndexes: [0] }], + }); + const labels = (run.presented?.presentation.human ?? []).flatMap((block) => + block.kind === 'tree' + ? block.roots.flatMap((root) => [ + root.label, + ...(root.children ?? []).map((child) => child.label), + ]) + : [], + ); + expect(labels).toEqual(expect.arrayContaining([ADDITIVE_OP.label, 'Statements applied'])); + }); + + it('reports the statements of an auto-baseline plan whose storage did not change, with no operations', async () => { + const project = await createOfflineProject({ storageHash: HASH_TO, models: ['User', 'Post'] }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_TO, + models: ['Profile', 'Post'], + }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_TO }); + const run = await harness(project).run(['migration', 'plan', '--rename', 'Profile:User'], { + cwd: project.dir, + isTty: { stdout: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + appliedStatements: [ + { + statement: profileToUser, + description: 'rename model "Profile" to "User"', + operationIndexes: [], + }, + ], + }); + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: [{ label: 'rename model "Profile" to "User" (no operations)' }], + }, + ], + }); + }); + + it('writes nothing when the auto-baseline delta is refused, and only the baseline when it has no operations', async () => { + const refusedProject = await renamingProject({ history: false }); + const refused = await harness(refusedProject, { refuseStatements: true }).run( + ['migration', 'plan', '--name', 'delta', '--rename', 'Profile:User', '--json'], + { cwd: refusedProject.dir }, + ); + const emptyProject = await renamingProject({ history: false }); + const empty = await harness(emptyProject, { operationsByPlan: [[ADDITIVE_OP], []] }).run( + ['migration', 'plan', '--name', 'delta', '--json'], + { cwd: emptyProject.dir }, + ); + + const conflictKind = (run: typeof refused) => { + const terminal = run.json.at(-1); + const error = + terminal !== undefined && terminal.kind === 'result' && !terminal.envelope.ok + ? terminal.envelope.error + : undefined; + const conflicts = error?.meta?.['conflicts']; + return Array.isArray(conflicts) ? conflicts[0]?.kind : undefined; + }; + expect({ + refused: { kind: conflictKind(refused), dirs: await plannedDirs(refusedProject) }, + empty: { + kind: conflictKind(empty), + dirs: (await plannedDirs(emptyProject)).map((dir) => dir.replace(/^\d{8}T\d{4}_/, '')), + }, + }).toEqual({ + refused: { kind: 'statementRefused', dirs: [] }, + empty: { kind: 'noDatabaseChange', dirs: ['baseline'] }, + }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/statement-errors.test.ts b/packages/1-framework/3-tooling/cli/test/statement-errors.test.ts new file mode 100644 index 000000000000..736cae2e82cb --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/statement-errors.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { errorStatementOriginUnknown } from '../src/utils/cli-errors'; + +const HASH = 'sha256:abc'; +const DIR = 'migrations/snapshots/sha256:abc'; + +function adviceOf(error: ReturnType): readonly string[] { + const envelope = error.toEnvelope(); + return [envelope.fix ?? '', ...(envelope.nextActions ?? []).map((action) => action.label)]; +} + +describe('errorStatementOriginUnknown', () => { + const variants = { + 'no marker': errorStatementOriginUnknown({ + hash: null, + snapshotDirectory: DIR, + unreadable: undefined, + }), + 'unreadable snapshot': errorStatementOriginUnknown({ + hash: HASH, + snapshotDirectory: DIR, + unreadable: 'Failed to parse', + }), + 'missing snapshot': errorStatementOriginUnknown({ + hash: HASH, + snapshotDirectory: DIR, + unreadable: undefined, + }), + }; + + it.each(Object.entries(variants))( + 'advises no plan without statements unless it says the plan drops data (%s)', + (_, error) => { + for (const advice of adviceOf(error)) { + if (/without (the )?statements|leave out the statements/i.test(advice)) { + expect(advice).toContain( + 'drops the storage of each renamed model or field with its data', + ); + } + } + }, + ); + + it('never offers migration plan --from as a route for db update', () => { + for (const error of Object.values(variants)) { + expect(error.toEnvelope().nextActions ?? []).not.toContainEqual( + expect.objectContaining({ command: expect.stringContaining('migration plan') }), + ); + } + }); + + it('says why the snapshot is missing and gives the route that stores it first', () => { + const envelope = variants['missing snapshot'].toEnvelope(); + expect(envelope.why).toContain( + 'stores the snapshot of the contract it applies only when it advances a ref', + ); + expect(envelope.fix).toBe( + [ + 'Store the snapshot of the contract the database is at, then run the rename:', + '1. Put the contract source back to the version the database is at, and run `{bin} contract emit`.', + '2. Run `{bin} db update --advance-ref --dry-run`, with the same `--db` as this command if it has one, and check that it plans no operations. Then run it again without `--dry-run`: the database matches that contract, so this changes nothing in it, and it stores the contract snapshot. If the dry run plans operations, the database has drifted from that contract; settle that before you go on.', + '3. Put the new contract source back, run `{bin} contract emit`, and run this command again with `--advance-ref `.', + 'Adding --advance-ref to this command alone does not help: it would store the new contract, not the one the database is at. `migration plan --from` does not apply to `db update`.', + ].join('\n'), + ); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts index 403ca5711a98..f8b7c5459291 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner-types.ts @@ -1,12 +1,14 @@ import type { Contract } from '@internal/contract/types'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { + AppliedMigrationStatement, ControlAdapterInstance, ControlFamilyInstance, MigrationOperationPolicy, MigrationPlan, MigrationPlannerConflict, MigrationPlanOperation, + ResolvedMigrationStatement, TargetMigrationsCapability, } from '@internal/framework-components/control'; import type { Result } from '@internal/utils/result'; @@ -84,6 +86,19 @@ export interface PlannerInput>; readonly callerPolicy: CallerPolicy; readonly operationPolicy: MigrationOperationPolicy; + /** + * What the application space's diff plan starts from: the contract the + * database is at, when the caller knows it, and the statements the user gave. + * Other spaces plan with no origin contract and no statements. Statements need the app space + * in `callerPolicy.ignoreGraphFor`; otherwise the planner refuses them with `policyConflict`. + */ + readonly appSpace: AppSpacePlanningInputs; +} + +/** See {@link PlannerInput.appSpace}. */ +export interface AppSpacePlanningInputs { + readonly fromContract: Contract | null; + readonly statements: readonly ResolvedMigrationStatement[]; } /** @@ -138,6 +153,8 @@ export interface PerSpacePlan { readonly destinationContract: Contract; readonly strategy: 'resolve-recorded-path' | 'plan-from-diff' | 'declared-state'; readonly warnings?: readonly MigrationPlannerConflict[]; + /** The statements the plan applied; empty for a space planned without statements. */ + readonly appliedStatements: readonly AppliedMigrationStatement[]; /** * Per-edge breakdown of the chain. `resolve-recorded-path` plans carry * one entry per authored edge; `plan-from-diff` and `declared-state` diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts index 82ddebd11026..307e448c8b79 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -9,6 +9,7 @@ import { resolveRecordedPath } from './strategies/resolve-recorded-path'; export type { AggregateCurrentDBState, AggregateMigrationEdgeRef, + AppSpacePlanningInputs, CallerPolicy, PerSpacePlan, PlannerError, @@ -20,6 +21,9 @@ export type { /** * Plan a migration across every contract space of a {@link ContractSpaceAggregate}. * + * Statements in `input.appSpace` apply only to a diff plan, so the planner refuses them with + * `policyConflict` unless `callerPolicy.ignoreGraphFor` includes the app space. + * * Per-space operation selection, in order; first match wins: * * 1. If `callerPolicy.ignoreGraphFor.has(space.spaceId)`: @@ -49,6 +53,16 @@ export async function planMigration { const { aggregate, currentDBState, callerPolicy } = input; + const appSpaceId = aggregate.app.spaceId; + const statementCount = input.appSpace.statements.length; + if (statementCount > 0 && !callerPolicy.ignoreGraphFor.has(appSpaceId)) { + return notOk({ + kind: 'policyConflict', + spaceId: appSpaceId, + detail: `${statementCount} statement${statementCount === 1 ? ' was' : 's were'} given for space "${appSpaceId}", but only a plan built from the diff applies statements, and \`callerPolicy.ignoreGraphFor\` does not include "${appSpaceId}". Add "${appSpaceId}" to \`ignoreGraphFor\` or give no statements.`, + }); + } + const perSpace = new Map(); for (const space of spacesInApplyOrder(aggregate)) { @@ -78,6 +92,7 @@ export async function planMigration; readonly frameworkComponents: ReadonlyArray>; readonly operationPolicy: MigrationOperationPolicy; + /** The contract the database is at, when the caller knows it. */ + readonly fromContract: Contract | null; + readonly statements: readonly ResolvedMigrationStatement[]; } export type PlanFromDiffOutcome = @@ -84,7 +89,9 @@ export async function planFromDiff 0 ? { warnings: plannerResult.warnings } : {}), diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/resolve-recorded-path.ts b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/resolve-recorded-path.ts index 4e700d42dbe9..22e82a44437e 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/strategies/resolve-recorded-path.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/strategies/resolve-recorded-path.ts @@ -114,6 +114,7 @@ export function resolveRecordedPath(input: ResolveRecordedPathInputs): ResolveRe displayOps: pathOps, destinationContract: space.contract(), strategy: 'resolve-recorded-path', + appliedStatements: [], migrationEdges: edgeRefs, pathDecision: outcome.decision, }, diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index 54a0e45a5de1..bda14b19af07 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -18,6 +18,7 @@ export { type ContractMarkerRecordLike, contractHashAtMarker } from '../aggregat export { type AggregateCurrentDBState, type AggregateMigrationEdgeRef, + type AppSpacePlanningInputs, type CallerPolicy, type PerSpacePlan, type PlannerError, diff --git a/packages/1-framework/3-tooling/migration/src/migration-base.ts b/packages/1-framework/3-tooling/migration/src/migration-base.ts index e3f9d0d4fceb..973cb66736e2 100644 --- a/packages/1-framework/3-tooling/migration/src/migration-base.ts +++ b/packages/1-framework/3-tooling/migration/src/migration-base.ts @@ -5,6 +5,7 @@ import type { ControlStack, MigrationPlan, MigrationPlanOperation, + PlanOrigin, } from '@internal/framework-components/control'; import { type } from 'arktype'; import { @@ -132,7 +133,23 @@ export abstract class Migration< }; } - get origin(): { readonly storageHash: string } | null { + /** + * Called by {@link Migration.readOperations} before each read of `operations`: a migration that + * builds state while its operations are read, such as the schema its earlier rename operations + * leave behind, discards it here so every read starts from the start contract. The default keeps + * no state. + */ + protected beginOperationsRead(): void {} + + /** Reads `migration`'s operations from the start contract; read them only through this. */ + static readOperations( + migration: Migration, + ): readonly (MigrationPlanOperation | Promise)[] { + migration.beginOperationsRead(); + return migration.operations; + } + + get origin(): PlanOrigin | null { const from = this.describe().from; return from === null ? null : { storageHash: from }; } @@ -265,7 +282,7 @@ export async function buildMigrationArtifacts( instance: Migration, existing: Partial | null, ): Promise { - const rawOps = instance.operations; + const rawOps = Migration.readOperations(instance); if (!Array.isArray(rawOps)) { throw errorOperationsNotArray(); } diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts index 916692bad400..6950201333a4 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/planner.test.ts @@ -6,6 +6,7 @@ import type { MigrationPlanner, MigrationPlannerResult, MigrationPlanWithAuthoringSurface, + ResolvedMigrationStatement, TargetMigrationsCapability, } from '@internal/framework-components/control'; import { createSqlContract } from '@repo/test-utils'; @@ -95,7 +96,7 @@ describe('planMigration', () => { app: makeSpace({ spaceId: 'app' }), }); const stubPlan = makeSyntheticPlan('placeholder-target-id-from-stub'); - const planner = makeStubPlanner({ kind: 'success', plan: stubPlan }); + const planner = makeStubPlanner({ kind: 'success', plan: stubPlan, appliedStatements: [] }); const result = await planMigration({ aggregate, @@ -108,6 +109,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(true); @@ -118,6 +120,131 @@ describe('planMigration', () => { expect(success.perSpace.get('app')?.plan.targetId).toBe('postgres'); }); + it('hands the app space its origin contract and statements, and returns the statements the planner applied', async () => { + const aggregate = makeAggregate({ app: makeSpace({ spaceId: 'app' }) }); + const origin = aggregate.app.contract(); + const statement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + } as unknown as ResolvedMigrationStatement; + const applied = { statement, operationIndexes: [0] }; + let received: + | { fromContract: unknown; statements: readonly ResolvedMigrationStatement[] } + | undefined; + const planner: MigrationPlanner<'sql', 'postgres'> = { + plan: (options) => { + received = { fromContract: options.fromContract, statements: options.statements }; + return { + kind: 'success', + plan: makeSyntheticPlan('postgres'), + appliedStatements: [applied], + }; + }, + emptyMigration: () => { + throw new Error('not used'); + }, + }; + + const result = await planMigration({ + aggregate, + currentDBState: { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} } }, + adapter: STUB_ADAPTER, + migrations: makeStubMigrations(planner), + frameworkComponents: [], + callerPolicy: { ignoreGraphFor: new Set(['app']) }, + operationPolicy: POLICY, + appSpace: { fromContract: origin, statements: [statement] }, + }); + + expect(received).toEqual({ fromContract: origin, statements: [statement] }); + expect(result.assertOk().perSpace.get('app')?.appliedStatements).toEqual([applied]); + }); + + it('rejects with policyConflict when the app space has statements but is not planned from the diff', async () => { + const aggregate = makeAggregate({ app: makeSpace({ spaceId: 'app' }) }); + const statement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + } as unknown as ResolvedMigrationStatement; + let planned = false; + const planner: MigrationPlanner<'sql', 'postgres'> = { + plan: () => { + planned = true; + return { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [] }; + }, + emptyMigration: () => { + throw new Error('not used'); + }, + }; + + const result = await planMigration({ + aggregate, + currentDBState: { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} } }, + adapter: STUB_ADAPTER, + migrations: makeStubMigrations(planner), + frameworkComponents: [], + callerPolicy: { ignoreGraphFor: new Set() }, + operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [statement] }, + }); + + expect(result.assertNotOk()).toEqual({ + kind: 'policyConflict', + spaceId: 'app', + detail: expect.stringContaining('1 statement'), + }); + expect(planned).toBe(false); + }); + + it('gives the origin contract and statements to the app space only, and none to an extension space planned from the diff', async () => { + const aggregate = makeAggregate({ + app: makeSpace({ spaceId: 'app' }), + extensions: [makeSpace({ spaceId: 'cipherstash' })], + }); + const origin = aggregate.app.contract(); + const statement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + } as unknown as ResolvedMigrationStatement; + const received: { spaceId: string; fromContract: unknown; statements: unknown }[] = []; + const planner: MigrationPlanner<'sql', 'postgres'> = { + plan: (options) => { + received.push({ + spaceId: options.spaceId, + fromContract: options.fromContract, + statements: options.statements, + }); + return { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [] }; + }, + emptyMigration: () => { + throw new Error('not used'); + }, + }; + + const result = await planMigration({ + aggregate, + currentDBState: { markersBySpaceId: new Map(), schemaIntrospection: { tables: {} } }, + adapter: STUB_ADAPTER, + migrations: makeStubMigrations(planner), + frameworkComponents: [], + callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, + operationPolicy: POLICY, + appSpace: { fromContract: origin, statements: [statement] }, + }); + + expect(result.ok).toBe(true); + expect(received).toEqual([ + { spaceId: 'cipherstash', fromContract: null, statements: [] }, + { spaceId: 'app', fromContract: origin, statements: [statement] }, + ]); + }); + it('resolves the recorded path for an extension space with a non-empty graph reaching its head ref', async () => { const headHash = 'cipher-head'; const cipherPkg = createAttestedPackage('20260101T0000_init', { from: null, to: headHash }); @@ -132,7 +259,7 @@ describe('planMigration', () => { }); const stubPlan = makeSyntheticPlan('postgres'); - const planner = makeStubPlanner({ kind: 'success', plan: stubPlan }); + const planner = makeStubPlanner({ kind: 'success', plan: stubPlan, appliedStatements: [] }); const result = await planMigration({ aggregate, @@ -145,6 +272,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(true); @@ -173,7 +301,11 @@ describe('planMigration', () => { // both take the empty-graph path — the strongest proof the family // planner is never reached for it. const planFn = vi.fn( - (): MigrationPlannerResult => ({ kind: 'success', plan: makeSyntheticPlan('postgres') }), + (): MigrationPlannerResult => ({ + kind: 'success', + plan: makeSyntheticPlan('postgres'), + appliedStatements: [], + }), ); const planner: MigrationPlanner<'sql', 'postgres'> = { plan: planFn, @@ -193,6 +325,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set() }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(true); @@ -230,6 +363,7 @@ describe('planMigration', () => { const planner = makeStubPlanner({ kind: 'success', plan: makeSyntheticPlan('postgres'), + appliedStatements: [], }); const result = await planMigration({ @@ -245,6 +379,7 @@ describe('planMigration', () => { // that's a policy conflict. callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(false); @@ -268,6 +403,7 @@ describe('planMigration', () => { const planner = makeStubPlanner({ kind: 'success', plan: makeSyntheticPlan('postgres'), + appliedStatements: [], }); const result = await planMigration({ @@ -283,6 +419,7 @@ describe('planMigration', () => { // empty graph can't satisfy its non-empty invariants. callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(false); @@ -322,6 +459,7 @@ describe('planMigration', () => { const planner = makeStubPlanner({ kind: 'success', plan: makeSyntheticPlan('postgres'), + appliedStatements: [], }); const result = await planMigration({ @@ -335,6 +473,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(false); @@ -374,6 +513,7 @@ describe('planMigration', () => { const planner = makeStubPlanner({ kind: 'success', plan: makeSyntheticPlan('postgres'), + appliedStatements: [], }); const result = await planMigration({ @@ -387,6 +527,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(true); @@ -417,6 +558,7 @@ describe('planMigration', () => { frameworkComponents: [], callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, + appSpace: { fromContract: null, statements: [] }, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/plan-from-diff.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/plan-from-diff.test.ts index ea2e180f17a8..4fc6ffb106af 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/plan-from-diff.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/plan-from-diff.test.ts @@ -55,7 +55,7 @@ describe('planFromDiff', () => { plan: ({ schema, ownership }) => { observedSchema = schema; observedOwnership = ownership; - return { kind: 'success', plan: makeStubPlan('placeholder') }; + return { kind: 'success', plan: makeStubPlan('placeholder'), appliedStatements: [] }; }, emptyMigration: () => { throw new Error('not used'); @@ -99,6 +99,8 @@ describe('planFromDiff', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, + fromContract: null, + statements: [], }); expect(outcome.kind).toBe('ok'); @@ -130,6 +132,53 @@ describe('planFromDiff', () => { expect(observedOwnership).toBe(ownership); }); + it('hands the planner the origin contract and statements, and asks it for a plan with no origin', async () => { + const appSpace = makeSpace('app', {}); + const fromContract = appSpace.contract(); + let received: + | { fromContract: unknown; origin: unknown; statements: readonly unknown[] } + | undefined; + const stubPlanner: MigrationPlanner<'sql', 'postgres'> = { + plan: (options) => { + received = { + fromContract: options.fromContract, + origin: options.origin, + statements: options.statements, + }; + return { + kind: 'success', + plan: { ...makeStubPlan('placeholder'), origin: options.origin }, + appliedStatements: [], + }; + }, + emptyMigration: () => { + throw new Error('not used'); + }, + }; + const outcome = await planFromDiff({ + aggregateTargetId: 'postgres', + currentMarker: null, + space: appSpace, + ownership: STUB_OWNERSHIP, + schemaIntrospection: { tables: {} }, + adapter: STUB_ADAPTER, + migrations: { + createPlanner: () => stubPlanner, + createRunner: () => { + throw new Error('runner not used'); + }, + contractToSchema: () => ({ tables: {} }), + }, + frameworkComponents: [], + operationPolicy: POLICY, + fromContract, + statements: [], + }); + + expect(received).toEqual({ fromContract, origin: null, statements: [] }); + expect(outcome.kind === 'ok' && outcome.result.plan.origin).toBeNull(); + }); + it('forwards planner failures verbatim', async () => { const stubPlanner: MigrationPlanner<'sql', 'postgres'> = { plan: () => ({ @@ -162,6 +211,8 @@ describe('planFromDiff', () => { migrations: stubMigrations, frameworkComponents: [], operationPolicy: POLICY, + fromContract: null, + statements: [], }); expect(outcome.kind).toBe('failure'); diff --git a/packages/1-framework/3-tooling/migration/test/migration-base.test.ts b/packages/1-framework/3-tooling/migration/test/migration-base.test.ts index 5e05b47be8a4..fc219a2d8e11 100644 --- a/packages/1-framework/3-tooling/migration/test/migration-base.test.ts +++ b/packages/1-framework/3-tooling/migration/test/migration-base.test.ts @@ -237,6 +237,29 @@ describe('buildMigrationArtifacts', () => { expect(JSON.parse(metadataJson)).toEqual(metadata); }); + it('begins each read of the operations before building, so each build reads them from the start', async () => { + const events: string[] = []; + class M extends Migration { + readonly targetId = 'test'; + override get operations() { + events.push('read operations'); + return [{ id: 'op1', label: 'Test op', operationClass: 'additive' }] as never; + } + override describe() { + return { from: 'abc', to: 'def' }; + } + protected override beginOperationsRead(): void { + events.push('begin'); + } + } + const migration = new M(); + + await buildMigrationArtifacts(migration, null); + await buildMigrationArtifacts(migration, null); + + expect(events).toEqual(['begin', 'read operations', 'begin', 'read operations']); + }); + it('preserves createdAt from existing metadata', async () => { const existingMetadata: Partial = { from: 'from', diff --git a/packages/2-sql/9-family/src/core/errors.ts b/packages/2-sql/9-family/src/core/errors.ts index 17b465e85dae..17ef49b20cdb 100644 --- a/packages/2-sql/9-family/src/core/errors.ts +++ b/packages/2-sql/9-family/src/core/errors.ts @@ -9,6 +9,7 @@ type SqlFamilyErrorCode = | 'CONTRACT.PACK_CONTRIBUTION_INVALID' | 'CONTRACT.TABLE_AMBIGUOUS' | 'CONTRACT.TYPE_UNKNOWN' + | 'MIGRATION.COLUMN_RENAME_UNMATCHED' | 'MIGRATION.TABLE_RENAME_UNMATCHED'; export function sqlFamilyError( diff --git a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts deleted file mode 100644 index 74ad2397aee8..000000000000 --- a/packages/2-sql/9-family/src/core/migrations/apply-table-rename.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { Contract } from '@internal/contract/types'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - ForeignKey, - type ForeignKeyReference, - type ForeignKeyReferenceInput, - isMaterializedSqlNamespace, - type SqlNamespace, - type SqlNamespaceBase, - type SqlNamespaceEntries, - SqlStorage, - StorageTable, -} from '@internal/sql-contract/types'; -import { InternalError } from '@internal/utils/internal-error'; -import { notOk, ok, type Result } from '@internal/utils/result'; -import type { StructuredError } from '@internal/utils/structured-error'; -import { sqlFamilyError } from '../errors'; - -export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; - -/** A table a migration renames: `namespaceId` is `undefined` when the migration leaves the namespace to the contracts. */ -export interface TableRename { - readonly namespaceId: string | undefined; - readonly from: string; - readonly to: string; -} - -/** A rename after its table was found in the start contract. */ -export interface ResolvedTableRename { - readonly namespaceId: string; - readonly from: string; - readonly to: string; -} - -/** - * Renames a target's own references to a renamed table inside one namespace's entries: entity kinds the family does not know, such as a target's row-level-security markers, that name a table. - */ -export type RenameTableReferences = ( - entries: SqlNamespaceEntries, - rename: ResolvedTableRename, -) => SqlNamespaceEntries; - -export interface ApplyTableRenameInput { - readonly startContract: Contract | null; - readonly endContract: Contract; - readonly rename: TableRename; - readonly renameTableReferences: RenameTableReferences | undefined; -} - -export interface AppliedTableRename { - /** The start contract with the table under its new name. */ - readonly contract: Contract; - readonly rename: ResolvedTableRename; -} - -function tableLabel(namespaceId: string | undefined, tableName: string): string { - return namespaceId === undefined || namespaceId === UNBOUND_NAMESPACE_ID - ? tableName - : `${namespaceId}.${tableName}`; -} - -function namespacesDeclaring(contract: Contract, tableName: string): string[] { - return Object.values(contract.storage.namespaces) - .filter((namespace) => Object.hasOwn(namespace.entries.table ?? {}, tableName)) - .map((namespace) => namespace.id); -} - -function declares(contract: Contract, namespaceId: string, tableName: string): boolean { - return Object.hasOwn(contract.storage.namespaces[namespaceId]?.entries.table ?? {}, tableName); -} - -function unmatched(rename: TableRename, reason: string): StructuredError { - return sqlFamilyError( - TABLE_RENAME_UNMATCHED_CODE, - `renameTable "${tableLabel(rename.namespaceId, rename.from)}" to "${rename.to}" does not match the migration's contracts: ${reason}.`, - { - why: "renameTable must name a table of the migration's start contract and a new name that the end contract has and the start contract does not. Make the rename its own schema change, so the migration's start contract is the schema before the rename and its end contract the schema after it, and check the spelling and the namespace.", - meta: { from: rename.from, to: rename.to }, - }, - ); -} - -function resolveTableRename( - rename: TableRename, - startContract: Contract, - endContract: Contract, -): Result { - const namespaceIds = - rename.namespaceId === undefined - ? namespacesDeclaring(startContract, rename.from) - : declares(startContract, rename.namespaceId, rename.from) - ? [rename.namespaceId] - : []; - const [namespaceId, ...others] = namespaceIds; - if (namespaceId === undefined) { - return notOk( - unmatched( - rename, - `table "${tableLabel(rename.namespaceId, rename.from)}" does not exist in the start contract`, - ), - ); - } - if (others.length > 0) { - return notOk( - unmatched( - rename, - `table "${rename.from}" is declared in more than one namespace (${namespaceIds.join(', ')}); name its namespace`, - ), - ); - } - if (declares(startContract, namespaceId, rename.to)) { - return notOk( - unmatched( - rename, - `table "${tableLabel(namespaceId, rename.to)}" already exists in the start contract`, - ), - ); - } - if (!declares(endContract, namespaceId, rename.to)) { - return notOk( - unmatched( - rename, - `table "${tableLabel(namespaceId, rename.to)}" does not exist in the end contract`, - ), - ); - } - return ok({ namespaceId, from: rename.from, to: rename.to }); -} - -function renamedReference( - reference: ForeignKeyReference, - rename: ResolvedTableRename, -): ForeignKeyReference | ForeignKeyReferenceInput { - const local = reference.spaceId === undefined; - if ( - !local || - reference.namespaceId !== rename.namespaceId || - reference.tableName !== rename.from - ) { - return reference; - } - return { ...reference, tableName: rename.to }; -} - -function renameForeignKeys(table: StorageTable, rename: ResolvedTableRename): StorageTable { - const touched = table.foreignKeys.some( - (fk) => - renamedReference(fk.source, rename) !== fk.source || - renamedReference(fk.target, rename) !== fk.target, - ); - if (!touched) return table; - return new StorageTable({ - ...table, - foreignKeys: table.foreignKeys.map( - (fk) => - new ForeignKey({ - ...fk, - source: renamedReference(fk.source, rename), - target: renamedReference(fk.target, rename), - }), - ), - }); -} - -/** - * The same namespace instance with only `entries` replaced: every own - * property, including the non-enumerable `kind`, is carried over onto the - * same prototype, so the target's namespace class (its `qualifyTable`, - * `ddlSchemaName`, entity getters) keeps working on the copy. This is the - * one place a frozen contract node is rebuilt without its constructor: the - * constructors re-hydrate `entries` from raw input, which the already - * hydrated entities here do not need. - */ -function withEntries(namespace: SqlNamespaceBase, entries: SqlNamespaceEntries): SqlNamespaceBase { - const copy: SqlNamespaceBase = Object.create(Object.getPrototypeOf(namespace), { - ...Object.getOwnPropertyDescriptors(namespace), - entries: { - value: Object.freeze(entries), - enumerable: true, - writable: false, - configurable: false, - }, - }); - return Object.freeze(copy); -} - -function renameTableInNamespace( - namespace: SqlNamespace, - rename: ResolvedTableRename, - renameTableReferences: RenameTableReferences | undefined, -): SqlNamespace { - const ownsTable = rename.namespaceId === namespace.id; - const tables = Object.entries(namespace.entries.table ?? {}).map( - ([name, table]) => - [ - ownsTable && name === rename.from ? rename.to : name, - renameForeignKeys(table, rename), - ] as const, - ); - const untouched = tables.every(([name, table]) => namespace.entries.table?.[name] === table); - if (untouched) return namespace; - if (!isMaterializedSqlNamespace(namespace)) { - throw new InternalError( - `applyTableRename: namespace "${namespace.id}" is not a materialized SQL namespace`, - ); - } - const renamedTables: SqlNamespaceEntries = { - ...namespace.entries, - table: Object.fromEntries(tables), - }; - return withEntries( - namespace, - renameTableReferences === undefined || !ownsTable - ? renamedTables - : renameTableReferences(renamedTables, rename), - ); -} - -function renameTableInContract( - contract: Contract, - rename: ResolvedTableRename, - renameTableReferences: RenameTableReferences | undefined, -): Contract { - const namespaces = Object.fromEntries( - Object.entries(contract.storage.namespaces).map(([id, namespace]) => [ - id, - renameTableInNamespace(namespace, rename, renameTableReferences), - ]), - ); - const materialized: Record = {}; - for (const [id, namespace] of Object.entries(namespaces)) { - if (!isMaterializedSqlNamespace(namespace)) { - throw new InternalError( - `applyTableRename: namespace "${id}" is not a materialized SQL namespace`, - ); - } - materialized[id] = namespace; - } - return { - ...contract, - storage: new SqlStorage({ - storageHash: contract.storage.storageHash, - ...(contract.storage.types === undefined ? {} : { types: contract.storage.types }), - namespaces: materialized, - }), - }; -} - -/** - * Resolves the table a migration renames against its start and end contracts and returns the start contract with that table under its new name, so a diff against the end contract sees the table under one name. The table must exist in the start contract (in exactly one namespace when the namespace is not given), and the new name must exist in the end contract and not in the start contract; otherwise the rename is refused with `MIGRATION.TABLE_RENAME_UNMATCHED`. - * - * Foreign keys that name the renamed table on either side are retargeted. Index, unique, check and primary-key names are carried unchanged. Target entity kinds that name the table are renamed by the target's `renameTableReferences`. - */ -export function applyTableRename( - input: ApplyTableRenameInput, -): Result { - if (input.startContract === null) { - return notOk(unmatched(input.rename, 'the migration has no start contract')); - } - const resolved = resolveTableRename(input.rename, input.startContract, input.endContract); - if (!resolved.ok) return resolved; - return ok({ - contract: renameTableInContract( - input.startContract, - resolved.value, - input.renameTableReferences, - ), - rename: resolved.value, - }); -} diff --git a/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts b/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts index d74114fb1656..8a9f541e5532 100644 --- a/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts +++ b/packages/2-sql/9-family/src/core/migrations/field-event-planner.ts @@ -25,6 +25,8 @@ import type { Contract } from '@internal/contract/types'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { type SqlStorage, type StorageColumn, StorageTable } from '@internal/sql-contract/types'; +import type { ColumnRename } from './resolve-column-rename'; +import type { TableRename } from './resolve-table-rename'; import type { CodecControlHooks, FieldEvent, FieldEventContext } from './types'; export interface PlanFieldEventOperationsOptions { @@ -47,6 +49,16 @@ export interface PlanFieldEventOperationsOptions { * `RawSqlCall`. */ readonly codecHooks: ReadonlyMap; + /** + * The table renames the plan applies. A column under a renamed table's old name is the same + * column under its new name, so a rename alone fires no event. + */ + readonly tableRenames: readonly TableRename[]; + /** + * The column renames the plan applies, each on the table under the name it has after the table + * renames. A column under its old name is the same column under its new name. + */ + readonly columnRenames: readonly ColumnRename[]; } interface FieldEntry { @@ -64,6 +76,17 @@ export function planFieldEventOperations( ): readonly OpFactoryCall[] { const priorContract = options.priorContract; const newContract = options.newContract; + const renamedTo = new Map( + options.tableRenames.map((rename) => [renameKey(rename.namespaceId, rename.from), rename.to]), + ); + const newNameOf = (namespaceId: string, tableName: string) => + renamedTo.get(renameKey(namespaceId, tableName)); + const columnRenamedTo = new Map( + options.columnRenames.map((rename) => [ + renameKey(rename.namespaceId, rename.table, rename.from), + rename.to, + ]), + ); const added: FieldEntry[] = []; const dropped: FieldEntry[] = []; @@ -77,7 +100,9 @@ export function planFieldEventOperations( for (const namespaceId of namespaceIds) { const priorNs = priorContract?.storage.namespaces[namespaceId]; const newNs = newContract.storage.namespaces[namespaceId]; - const priorTables = priorNs?.entries.table; + const priorTables = underNewNames(priorNs?.entries.table, (tableName) => + newNameOf(namespaceId, tableName), + ); const newTables = newNs?.entries.table; const tableNames = unionSorted( @@ -90,12 +115,15 @@ export function planFieldEventOperations( const newTableRaw = newTables?.[tableName]; const priorTable = StorageTable.is(priorTableRaw) ? priorTableRaw : undefined; const newTable = StorageTable.is(newTableRaw) ? newTableRaw : undefined; + const priorColumns = underNewNames(priorTable?.columns, (columnName) => + columnRenamedTo.get(renameKey(namespaceId, tableName, columnName)), + ); const fieldNames = unionSorted( - priorTable ? Object.keys(priorTable.columns) : [], + priorColumns ? Object.keys(priorColumns) : [], newTable ? Object.keys(newTable.columns) : [], ); for (const fieldName of fieldNames) { - const priorField = priorTable?.columns[fieldName]; + const priorField = priorColumns?.[fieldName]; const newField = newTable?.columns[fieldName]; const entry: FieldEntry = { namespaceId, @@ -124,6 +152,20 @@ export function planFieldEventOperations( return calls; } +function renameKey(...parts: readonly string[]): string { + return JSON.stringify(parts); +} + +function underNewNames( + tables: Readonly> | undefined, + newNameOf: (tableName: string) => string | undefined, +): Readonly> | undefined { + if (tables === undefined) return undefined; + return Object.fromEntries( + Object.entries(tables).map(([tableName, table]) => [newNameOf(tableName) ?? tableName, table]), + ); +} + function appendCalls( event: FieldEvent, entries: readonly FieldEntry[], diff --git a/packages/2-sql/9-family/src/core/migrations/plan-helpers.ts b/packages/2-sql/9-family/src/core/migrations/plan-helpers.ts index 26000f8f40f5..8fce797dfe22 100644 --- a/packages/2-sql/9-family/src/core/migrations/plan-helpers.ts +++ b/packages/2-sql/9-family/src/core/migrations/plan-helpers.ts @@ -1,3 +1,4 @@ +import type { AppliedMigrationStatement } from '@internal/framework-components/control'; import { blindCast } from '@internal/utils/casts'; import type { NotOk, Ok } from '@internal/utils/result'; import { notOk, ok } from '@internal/utils/result'; @@ -118,11 +119,13 @@ export function createMigrationPlan( export function plannerSuccess( plan: SqlMigrationPlan, + appliedStatements: readonly AppliedMigrationStatement[], warnings?: readonly SqlPlannerConflict[], ): SqlPlannerSuccessResult { return Object.freeze({ kind: 'success', plan, + appliedStatements: Object.freeze([...appliedStatements]), ...(warnings && warnings.length > 0 ? { warnings: Object.freeze( @@ -132,6 +135,12 @@ export function plannerSuccess( summary: conflict.summary, ...(conflict.why ? { why: conflict.why } : {}), ...(conflict.location ? { location: Object.freeze({ ...conflict.location }) } : {}), + ...(conflict.refusedStatement + ? { refusedStatement: conflict.refusedStatement } + : {}), + ...(conflict.refusedOperationClass + ? { refusedOperationClass: conflict.refusedOperationClass } + : {}), ...(conflict.meta ? { meta: cloneRecord(conflict.meta) } : {}), }), ), @@ -151,6 +160,10 @@ export function plannerFailure(conflicts: readonly SqlPlannerConflict[]): SqlPla summary: conflict.summary, ...(conflict.why ? { why: conflict.why } : {}), ...(conflict.location ? { location: Object.freeze({ ...conflict.location }) } : {}), + ...(conflict.refusedStatement ? { refusedStatement: conflict.refusedStatement } : {}), + ...(conflict.refusedOperationClass + ? { refusedOperationClass: conflict.refusedOperationClass } + : {}), ...(conflict.meta ? { meta: cloneRecord(conflict.meta) } : {}), }), ), diff --git a/packages/2-sql/9-family/src/core/migrations/resolve-column-rename.ts b/packages/2-sql/9-family/src/core/migrations/resolve-column-rename.ts new file mode 100644 index 000000000000..3fc20c2b64b3 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/resolve-column-rename.ts @@ -0,0 +1,143 @@ +import type { Contract } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import type { StructuredError } from '@internal/utils/structured-error'; +import { sqlFamilyError } from '../errors'; +import type { SchemaTables } from './schema-tables'; + +export const COLUMN_RENAME_UNMATCHED_CODE = 'MIGRATION.COLUMN_RENAME_UNMATCHED'; + +/** + * A column rename as a `renameColumn` call states it: `namespaceId` is `undefined` when the + * namespace is left to the contracts. + */ +export interface ColumnRenameRequest { + readonly namespaceId: string | undefined; + readonly table: string; + readonly from: string; + readonly to: string; +} + +/** A column rename on a table in a known namespace. */ +export interface ColumnRename { + readonly namespaceId: string; + readonly table: string; + readonly from: string; + readonly to: string; +} + +function tableLabel(namespaceId: string | undefined, table: string): string { + return namespaceId === undefined || namespaceId === UNBOUND_NAMESPACE_ID + ? table + : `${namespaceId}.${table}`; +} + +function columnLabel(namespaceId: string | undefined, table: string, column: string): string { + return `"${tableLabel(namespaceId, table)}"."${column}"`; +} + +function declaresColumn( + contract: Contract, + namespaceId: string, + table: string, + column: string, +): boolean { + const columns = contract.storage.namespaces[namespaceId]?.entries.table?.[table]?.columns; + return columns !== undefined && Object.hasOwn(columns, column); +} + +/** + * Refuses a `renameColumn` call that does not match the migration's contracts, with + * `MIGRATION.COLUMN_RENAME_UNMATCHED`. + */ +export function unmatchedColumnRename( + rename: ColumnRenameRequest, + reason: string, +): StructuredError { + return sqlFamilyError( + COLUMN_RENAME_UNMATCHED_CODE, + `renameColumn ${columnLabel(rename.namespaceId, rename.table, rename.from)} to "${rename.to}" does not match the migration's contracts: ${reason}.`, + { + why: "renameColumn must name a table and a column as the migration's earlier rename operations leave them, and a new column name that the end contract has on that table and that the table does not have yet. Order the rename calls in the sequence the renames happen, and check the names of the table, the column and the namespace.", + meta: { table: rename.table, from: rename.from, to: rename.to }, + }, + ); +} + +/** Why a column rename does not match the schema it renames in and the end contract. */ +export type ColumnRenameMismatch = + | { readonly kind: 'tableMissing' } + | { readonly kind: 'tableInManyNamespaces'; readonly namespaceIds: readonly string[] } + | { readonly kind: 'columnMissing'; readonly namespaceId: string } + | { readonly kind: 'nameTaken'; readonly namespaceId: string; readonly taken: string } + | { readonly kind: 'notInEndContract'; readonly namespaceId: string }; + +/** + * Checks a column rename: the table must exist in `previous` (in exactly one namespace when the + * namespace is not given) with the old column and without the new one, and the end contract must + * have the new column on that table. + */ +export function checkColumnRename( + previous: SchemaTables, + endContract: Contract, + rename: ColumnRenameRequest, +): Result { + const namespaceIds = + rename.namespaceId === undefined + ? previous.namespacesWithTable(rename.table) + : previous.hasTable(rename.namespaceId, rename.table) + ? [rename.namespaceId] + : []; + const [namespaceId, ...others] = namespaceIds; + if (namespaceId === undefined) return notOk({ kind: 'tableMissing' }); + if (others.length > 0) return notOk({ kind: 'tableInManyNamespaces', namespaceIds }); + if (!previous.hasColumn(namespaceId, rename.table, rename.from)) { + return notOk({ kind: 'columnMissing', namespaceId }); + } + const [taken] = previous + .columnsNamed(namespaceId, rename.table, rename.to) + .filter((column) => column !== rename.from); + if (taken !== undefined) return notOk({ kind: 'nameTaken', namespaceId, taken }); + if (!declaresColumn(endContract, namespaceId, rename.table, rename.to)) { + return notOk({ kind: 'notInEndContract', namespaceId }); + } + return ok({ namespaceId, table: rename.table, from: rename.from, to: rename.to }); +} + +/** The reason `MIGRATION.COLUMN_RENAME_UNMATCHED` gives for a mismatch. */ +export function columnRenameMismatchReason( + rename: ColumnRenameRequest, + mismatch: ColumnRenameMismatch, +): string { + switch (mismatch.kind) { + case 'tableMissing': + return `table "${tableLabel(rename.namespaceId, rename.table)}" does not exist at this point of the migration`; + case 'tableInManyNamespaces': + return `table "${rename.table}" is declared in more than one namespace (${mismatch.namespaceIds.join(', ')}); name its namespace`; + case 'columnMissing': + return `column ${columnLabel(mismatch.namespaceId, rename.table, rename.from)} does not exist at this point of the migration`; + case 'nameTaken': + return `column ${columnLabel(mismatch.namespaceId, rename.table, rename.to)} already exists at this point of the migration${mismatch.taken === rename.to ? '' : ` as "${mismatch.taken}"`}`; + case 'notInEndContract': + return `column ${columnLabel(mismatch.namespaceId, rename.table, rename.to)} does not exist in the end contract`; + } +} + +/** + * Resolves a column a migration renames with {@link checkColumnRename}, refusing a mismatch with + * `MIGRATION.COLUMN_RENAME_UNMATCHED`. + */ +export function resolveColumnRenameAgainst( + previous: SchemaTables, + endContract: Contract, + rename: ColumnRenameRequest, +): Result { + const checked = checkColumnRename(previous, endContract, rename); + if (!checked.ok) { + return notOk( + unmatchedColumnRename(rename, columnRenameMismatchReason(rename, checked.failure)), + ); + } + return ok(checked.value); +} diff --git a/packages/2-sql/9-family/src/core/migrations/resolve-table-rename.ts b/packages/2-sql/9-family/src/core/migrations/resolve-table-rename.ts new file mode 100644 index 000000000000..f9efe93affdd --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/resolve-table-rename.ts @@ -0,0 +1,119 @@ +import type { Contract } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import type { StructuredError } from '@internal/utils/structured-error'; +import { sqlFamilyError } from '../errors'; +import type { SchemaTables } from './schema-tables'; + +export const TABLE_RENAME_UNMATCHED_CODE = 'MIGRATION.TABLE_RENAME_UNMATCHED'; + +/** + * A table rename as a `renameTable` call or a guard's advice states it: `namespaceId` is + * `undefined` when the namespace is left to the contracts. + */ +export interface TableRenameRequest { + readonly namespaceId: string | undefined; + readonly from: string; + readonly to: string; +} + +/** A table rename in a known namespace. */ +export interface TableRename { + readonly namespaceId: string; + readonly from: string; + readonly to: string; +} + +function tableLabel(namespaceId: string | undefined, tableName: string): string { + return namespaceId === undefined || namespaceId === UNBOUND_NAMESPACE_ID + ? tableName + : `${namespaceId}.${tableName}`; +} + +function declares(contract: Contract, namespaceId: string, tableName: string): boolean { + return Object.hasOwn(contract.storage.namespaces[namespaceId]?.entries.table ?? {}, tableName); +} + +/** + * Refuses a `renameTable` call that does not match the migration's contracts, with + * `MIGRATION.TABLE_RENAME_UNMATCHED`. + */ +export function unmatchedTableRename(rename: TableRenameRequest, reason: string): StructuredError { + return sqlFamilyError( + TABLE_RENAME_UNMATCHED_CODE, + `renameTable "${tableLabel(rename.namespaceId, rename.from)}" to "${rename.to}" does not match the migration's contracts: ${reason}.`, + { + why: "renameTable must name a table as the migration's earlier rename operations leave it, and a new name that the end contract has and that no earlier rename operation has already produced. Order the renameTable calls in the sequence the renames happen, make the rename its own schema change, and check the spelling, the table and the namespace.", + meta: { from: rename.from, to: rename.to }, + }, + ); +} + +/** Why a table rename does not match the schema it renames in and the end contract. */ +export type TableRenameMismatch = + | { readonly kind: 'tableMissing' } + | { readonly kind: 'tableInManyNamespaces'; readonly namespaceIds: readonly string[] } + | { readonly kind: 'nameTaken'; readonly namespaceId: string; readonly taken: string } + | { readonly kind: 'notInEndContract'; readonly namespaceId: string }; + +/** + * Checks a table rename: the table must exist in `previous` (in exactly one namespace when the + * namespace is not given), and the new name must exist in the end contract and not in `previous`. + */ +export function checkTableRename( + previous: SchemaTables, + endContract: Contract, + rename: TableRenameRequest, +): Result { + const namespaceIds = + rename.namespaceId === undefined + ? previous.namespacesWithTable(rename.from) + : previous.hasTable(rename.namespaceId, rename.from) + ? [rename.namespaceId] + : []; + const [namespaceId, ...others] = namespaceIds; + if (namespaceId === undefined) return notOk({ kind: 'tableMissing' }); + if (others.length > 0) return notOk({ kind: 'tableInManyNamespaces', namespaceIds }); + const [taken] = previous + .tablesNamed(namespaceId, rename.to) + .filter((table) => table !== rename.from); + if (taken !== undefined) return notOk({ kind: 'nameTaken', namespaceId, taken }); + if (!declares(endContract, namespaceId, rename.to)) { + return notOk({ kind: 'notInEndContract', namespaceId }); + } + return ok({ namespaceId, from: rename.from, to: rename.to }); +} + +/** The reason `MIGRATION.TABLE_RENAME_UNMATCHED` gives for a mismatch. */ +export function tableRenameMismatchReason( + rename: TableRenameRequest, + mismatch: TableRenameMismatch, +): string { + switch (mismatch.kind) { + case 'tableMissing': + return `table "${tableLabel(rename.namespaceId, rename.from)}" does not exist at this point of the migration`; + case 'tableInManyNamespaces': + return `table "${rename.from}" is declared in more than one namespace (${mismatch.namespaceIds.join(', ')}); name its namespace`; + case 'nameTaken': + return `table "${tableLabel(mismatch.namespaceId, rename.to)}" already exists at this point of the migration${mismatch.taken === rename.to ? '' : ` as "${mismatch.taken}"`}`; + case 'notInEndContract': + return `table "${tableLabel(mismatch.namespaceId, rename.to)}" does not exist in the end contract`; + } +} + +/** + * Resolves a table a migration renames with {@link checkTableRename}, refusing a mismatch with + * `MIGRATION.TABLE_RENAME_UNMATCHED`. + */ +export function resolveTableRenameAgainst( + previous: SchemaTables, + endContract: Contract, + rename: TableRenameRequest, +): Result { + const checked = checkTableRename(previous, endContract, rename); + if (!checked.ok) { + return notOk(unmatchedTableRename(rename, tableRenameMismatchReason(rename, checked.failure))); + } + return ok(checked.value); +} diff --git a/packages/2-sql/9-family/src/core/migrations/schema-tables.ts b/packages/2-sql/9-family/src/core/migrations/schema-tables.ts new file mode 100644 index 000000000000..2ac20b1be9de --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/schema-tables.ts @@ -0,0 +1,19 @@ +/** + * The tables a schema has, addressed by the contract's namespace ids: the schema a plan starts + * from, or the schema a migration's earlier renames leave behind. + */ +export interface SchemaTables { + hasTable(namespaceId: string, table: string): boolean; + hasColumn(namespaceId: string, table: string, column: string): boolean; + /** + * The namespace's tables the database takes for `table`: the table itself where names compare + * exactly, and every table whose name differs only in case where the database ignores case. + */ + tablesNamed(namespaceId: string, table: string): readonly string[]; + /** + * The table's columns the database takes for `column`: the column itself where names compare + * exactly, and every column whose name differs only in case where the database ignores case. + */ + columnsNamed(namespaceId: string, table: string, column: string): readonly string[]; + namespacesWithTable(table: string): readonly string[]; +} diff --git a/packages/2-sql/9-family/src/core/migrations/statement-planning.ts b/packages/2-sql/9-family/src/core/migrations/statement-planning.ts new file mode 100644 index 000000000000..903a71e51dac --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/statement-planning.ts @@ -0,0 +1,555 @@ +import type { Contract, ContractWithDomain, ModelStorageBase } from '@internal/contract/types'; +import { + type AppliedMigrationStatement, + describeMigrationStatement, + type FieldCoordinate, + type MigrationOperationClass, + type MigrationOperationPolicy, + type ModelCoordinate, + modelDisplayName, + type ResolvedFieldRenameStatement, + type ResolvedMigrationStatement, + type ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { type SqlModelStorage, type SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { controlPolicyForCall } from './control-policy'; +import { + type ColumnRename, + type ColumnRenameMismatch, + checkColumnRename, + columnRenameMismatchReason, +} from './resolve-column-rename'; +import { + checkTableRename, + type TableRename, + type TableRenameMismatch, + tableRenameMismatchReason, +} from './resolve-table-rename'; +import type { SchemaTables } from './schema-tables'; +import type { SqlPlannerConflict } from './types'; + +/** Where a model's rows live: a table in a storage namespace. */ +export interface ModelTable { + readonly namespaceId: string; + readonly table: string; +} + +/** What a model rename does to storage. */ +export type ModelStorageEffect = + | { readonly kind: 'unchanged' } + | { readonly kind: 'renameTable'; readonly rename: TableRename } + | { readonly kind: 'moveNamespace'; readonly from: ModelTable; readonly to: ModelTable }; + +/** What a field rename does to storage. */ +export type FieldStorageEffect = + | { readonly kind: 'unchanged' } + | { + readonly kind: 'renameColumn'; + /** The origin model's table, as the origin contract names it. */ + readonly table: ModelTable; + readonly from: string; + readonly to: string; + }; + +/** A model the contract stores in no table, so a statement on it has no storage effect. */ +export interface NoTable { + readonly kind: 'noTable'; + readonly model: ModelCoordinate; +} + +/** A field stored in a column on one side of a statement only, which no rename can map. */ +export interface ColumnOnOneSide { + readonly kind: 'columnOnOneSide'; +} + +function isSqlModelStorage(storage: ModelStorageBase): storage is SqlModelStorage { + return ( + typeof storage['table'] === 'string' && + typeof storage['namespaceId'] === 'string' && + typeof storage['fields'] === 'object' && + storage['fields'] !== null + ); +} + +/** The storage of a model the contract stores in a table, or `undefined` for any other model. */ +function sqlModelStorage( + contract: ContractWithDomain, + coordinate: ModelCoordinate, +): SqlModelStorage | undefined { + const storage = + contract.domain.namespaces[coordinate.namespaceId]?.models[coordinate.model]?.storage; + return storage !== undefined && isSqlModelStorage(storage) ? storage : undefined; +} + +function modelTable( + contract: ContractWithDomain, + coordinate: ModelCoordinate, +): ModelTable | undefined { + const storage = sqlModelStorage(contract, coordinate); + return storage === undefined + ? undefined + : { namespaceId: storage.namespaceId, table: storage.table }; +} + +/** + * The storage effect of a model rename: the origin model's table compared with the destination + * model's. Equal tables mean the rename needs no storage change. + */ +export function modelRenameStorageEffect( + statement: ResolvedModelRenameStatement, + fromContract: ContractWithDomain, + contract: ContractWithDomain, +): Result { + const from = modelTable(fromContract, statement.from); + if (from === undefined) return notOk({ kind: 'noTable', model: statement.from }); + const to = modelTable(contract, statement.to); + if (to === undefined) return notOk({ kind: 'noTable', model: statement.to }); + if (from.namespaceId !== to.namespaceId) return ok({ kind: 'moveNamespace', from, to }); + if (from.table === to.table) return ok({ kind: 'unchanged' }); + return ok({ + kind: 'renameTable', + rename: { namespaceId: from.namespaceId, from: from.table, to: to.table }, + }); +} + +function fieldColumn( + contract: ContractWithDomain, + coordinate: FieldCoordinate, +): string | undefined { + const fields = sqlModelStorage(contract, coordinate)?.fields; + return fields !== undefined && Object.hasOwn(fields, coordinate.field) + ? fields[coordinate.field]?.column + : undefined; +} + +/** + * The storage effect of a field rename: the origin field's column compared with the destination + * field's. A relation field has no column on either side, and equal columns need no change. + */ +export function fieldRenameStorageEffect( + statement: ResolvedFieldRenameStatement, + fromContract: ContractWithDomain, + contract: ContractWithDomain, +): Result { + const table = modelTable(fromContract, statement.from); + if (table === undefined) return notOk({ kind: 'noTable', model: statement.from }); + const from = fieldColumn(fromContract, statement.from); + const to = fieldColumn(contract, statement.to); + if (from === undefined && to === undefined) return ok({ kind: 'unchanged' }); + if (from === undefined || to === undefined) return notOk({ kind: 'columnOnOneSide' }); + if (from === to) return ok({ kind: 'unchanged' }); + return ok({ kind: 'renameColumn', table, from, to }); +} + +/** A call that lowers to one operation, such as a companion of a rename. */ +export interface SingleOperationCall { + readonly operationClass: MigrationOperationClass; +} + +/** + * A call that lowers to its own operation, then each companion's, in that order: a table or column + * rename, whether a statement plans it or a hand-written migration calls it. + */ +export interface CallWithCompanions extends SingleOperationCall { + readonly companions: readonly SingleOperationCall[]; +} + +/** What a target supplies for planning statements against its working schema. */ +export interface StatementPlanningTarget { + /** The tables of the working schema as earlier statements have left it. */ + tables(): SchemaTables; + /** The call that renames a table, with its companions, computed against the working schema. */ + renameTableCall(rename: TableRename): TCall; + /** The call that renames a column, with its companions, computed against the working schema. */ + renameColumnCall(rename: ColumnRename): TCall; + /** Applies a call to the working schema. */ + apply(call: TCall): void; + /** The `renameTable` call a user writes in `migration.ts` to make `rename` by hand. */ + renderTableRename(rename: TableRename): string; + /** The SQL statements a user runs against the database to make `rename` by hand. */ + tableRenameByHand(rename: TableRename): readonly string[]; +} + +function operationsOf(call: CallWithCompanions): readonly SingleOperationCall[] { + return [call, ...call.companions]; +} + +export interface PlannedStatements { + /** + * The calls, which a target puts first in its plan: each statement's `operationIndexes` count + * from the first operation of the first call. + */ + readonly calls: readonly TCall[]; + readonly tableRenames: readonly TableRename[]; + readonly columnRenames: readonly ColumnRename[]; + readonly appliedStatements: readonly AppliedMigrationStatement[]; +} + +interface ConflictLocation extends ModelTable { + readonly column?: string; +} + +const DRIFTED = + 'so the database has drifted from that contract. Inspect it with prisma db schema, or leave out this statement.'; + +const UNMATCHED = + 'A statement plans the rename a hand-written rename call makes, and that call does not match the contracts. Leave out this statement.'; + +function statementRefused( + statement: ResolvedMigrationStatement, + summary: string, + why: string, + location: ConflictLocation | undefined, + refusedOperationClass?: MigrationOperationClass, +): SqlPlannerConflict { + return { + kind: 'statementRefused', + summary, + why, + refusedStatement: statement, + ...(refusedOperationClass === undefined ? {} : { refusedOperationClass }), + ...(location === undefined + ? {} + : { + location: { + namespaceId: location.namespaceId, + entityKind: 'table', + entityName: location.table, + ...(location.column === undefined ? {} : { column: location.column }), + }, + }), + }; +} + +function tableControlPolicy(contract: Contract, table: ModelTable) { + const node = contract.storage.namespaces[table.namespaceId]?.entries.table?.[table.table]; + return controlPolicyForCall( + { + namespaceId: table.namespaceId, + entityKind: 'table', + entityName: table.table, + ...(StorageTable.is(node) && node.control !== undefined + ? { explicitNodeControlPolicy: node.control } + : {}), + createsNewObject: false, + }, + contract.defaultControlPolicy, + ); +} + +function tableKey(table: ModelTable): string { + return JSON.stringify([table.namespaceId, table.table]); +} + +function tableRenameRefused( + statement: ResolvedMigrationStatement, + label: string, + rename: TableRename, + mismatch: TableRenameMismatch, +): SqlPlannerConflict { + switch (mismatch.kind) { + case 'tableMissing': + return statementRefused( + statement, + `${label}: the schema being planned from has no table "${rename.from}"`, + `The database has no table "${rename.from}", although the contract it was last updated to names it, ${DRIFTED}`, + { namespaceId: rename.namespaceId, table: rename.from }, + ); + case 'nameTaken': + return statementRefused( + statement, + `${label}: the schema being planned from already has a table "${rename.to}"${mismatch.taken === rename.to ? '' : `, as "${mismatch.taken}"`}`, + `A rename cannot replace a table that already exists. Rename or drop table "${mismatch.taken}" first, or leave out this statement.`, + { namespaceId: rename.namespaceId, table: rename.to }, + ); + case 'tableInManyNamespaces': + case 'notInEndContract': + return statementRefused( + statement, + `${label}: ${tableRenameMismatchReason(rename, mismatch)}`, + UNMATCHED, + { namespaceId: rename.namespaceId, table: rename.from }, + ); + } +} + +function columnRenameRefused( + statement: ResolvedMigrationStatement, + label: string, + rename: ColumnRename, + mismatch: ColumnRenameMismatch, +): SqlPlannerConflict { + const table = { namespaceId: rename.namespaceId, table: rename.table }; + switch (mismatch.kind) { + case 'tableMissing': + return statementRefused( + statement, + `${label}: the schema being planned from has no table "${rename.table}"`, + `The database has no table "${rename.table}", although the contract it was last updated to names it, ${DRIFTED}`, + table, + ); + case 'columnMissing': + return statementRefused( + statement, + `${label}: the schema being planned from has no column "${rename.from}" on table "${rename.table}"`, + `The database has no column "${rename.from}" on table "${rename.table}", although the contract it was last updated to names it, ${DRIFTED}`, + { ...table, column: rename.from }, + ); + case 'nameTaken': + return statementRefused( + statement, + `${label}: the schema being planned from already has a column "${mismatch.taken}" on table "${rename.table}"`, + `A rename cannot replace a column that already exists. Rename or drop column "${mismatch.taken}" of table "${rename.table}" first, or leave out this statement.`, + { ...table, column: rename.to }, + ); + case 'tableInManyNamespaces': + case 'notInEndContract': + return statementRefused( + statement, + `${label}: ${columnRenameMismatchReason(rename, mismatch)}`, + UNMATCHED, + { ...table, column: rename.from }, + ); + } +} + +class StatementPlanner { + readonly #fromContract: Contract; + readonly #contract: Contract; + readonly #policy: MigrationOperationPolicy; + readonly #target: StatementPlanningTarget; + /** The name each table renamed so far has in the working schema, keyed by its origin name. */ + readonly #renamedTables = new Map(); + readonly calls: TCall[] = []; + /** How many operations the calls planned so far lower to. */ + #operationCount = 0; + readonly tableRenames: TableRename[] = []; + readonly columnRenames: ColumnRename[] = []; + + constructor(input: { + readonly fromContract: Contract; + readonly contract: Contract; + readonly policy: MigrationOperationPolicy; + readonly target: StatementPlanningTarget; + }) { + this.#fromContract = input.fromContract; + this.#contract = input.contract; + this.#policy = input.policy; + this.#target = input.target; + } + + /** Plans one statement; the result is the positions of the operations it accounts for. */ + plan(statement: ResolvedMigrationStatement): Result { + return statement.entity === 'model' ? this.#planModel(statement) : this.#planField(statement); + } + + #controlPolicyRefusal( + statement: ResolvedMigrationStatement, + label: string, + destinationTable: ModelTable, + ): SqlPlannerConflict | undefined { + const controlPolicy = tableControlPolicy(this.#contract, destinationTable); + if (controlPolicy === 'managed') return undefined; + return statementRefused( + statement, + `${label}: the table's control policy is "${controlPolicy}"`, + `Statements rename only tables, and columns of tables, whose control policy is "managed"; table "${destinationTable.table}" is "${controlPolicy}". Make the change in the database yourself and leave out this statement.`, + destinationTable, + ); + } + + #emit( + statement: ResolvedMigrationStatement, + label: string, + location: ConflictLocation, + call: TCall, + ): Result { + const refused = operationsOf(call) + .map((operation) => operation.operationClass) + .find((operationClass) => !this.#policy.allowedOperationClasses.includes(operationClass)); + if (refused !== undefined) { + return notOk( + statementRefused( + statement, + `${label}: the plan does not allow "${refused}" operations`, + `The rename produces a "${refused}" operation, and this command plans only ${this.#policy.allowedOperationClasses.map((c) => `"${c}"`).join(', ')} operations. Leave out this statement, or make the change with a command that allows "${refused}" operations, such as migration plan.`, + location, + refused, + ), + ); + } + this.#target.apply(call); + this.calls.push(call); + const first = this.#operationCount; + this.#operationCount += operationsOf(call).length; + return ok(operationsOf(call).map((_, offset) => first + offset)); + } + + #planModel( + statement: ResolvedModelRenameStatement, + ): Result { + const worked = modelRenameStorageEffect(statement, this.#fromContract, this.#contract); + if (!worked.ok) { + return notOk( + statementRefused( + statement, + `Model "${modelDisplayName(worked.failure.model)}" has no table in its contract`, + `A model rename renames the model's table, and model "${modelDisplayName(worked.failure.model)}" has none in its contract. Leave out this statement.`, + undefined, + ), + ); + } + const effect = worked.value; + if (effect.kind === 'unchanged') return ok([]); + if (effect.kind === 'moveNamespace') { + return notOk( + statementRefused( + statement, + `Moving a model to another namespace is not supported in this release: "${modelDisplayName(statement.from)}" to "${modelDisplayName(statement.to)}"`, + `The model's table would move from namespace "${effect.from.namespaceId}" to namespace "${effect.to.namespaceId}". Leave out this statement and move the table yourself in a hand-written migration, or keep the model in namespace "${effect.from.namespaceId}".`, + effect.from, + ), + ); + } + const { rename } = effect; + const destinationTable = { namespaceId: rename.namespaceId, table: rename.to }; + const label = `Cannot rename table "${rename.from}" to "${rename.to}"`; + const policyRefusal = this.#controlPolicyRefusal(statement, label, destinationTable); + if (policyRefusal !== undefined) return notOk(policyRefusal); + const checked = checkTableRename(this.#target.tables(), this.#contract, rename); + if (!checked.ok) { + return notOk(tableRenameRefused(statement, label, rename, checked.failure)); + } + const planned = this.#emit( + statement, + label, + destinationTable, + this.#target.renameTableCall(rename), + ); + if (planned.ok) { + this.tableRenames.push(rename); + this.#renamedTables.set( + tableKey({ namespaceId: rename.namespaceId, table: rename.from }), + rename.to, + ); + } + return planned; + } + + #planField( + statement: ResolvedFieldRenameStatement, + ): Result { + const worked = fieldRenameStorageEffect(statement, this.#fromContract, this.#contract); + if (!worked.ok && worked.failure.kind === 'noTable') { + return notOk( + statementRefused( + statement, + `Model "${modelDisplayName(worked.failure.model)}" has no table in its contract`, + `A field rename renames the field's column, and model "${modelDisplayName(worked.failure.model)}" has no table in its contract. Leave out this statement.`, + undefined, + ), + ); + } + if (!worked.ok) { + return notOk( + statementRefused( + statement, + `Field "${modelDisplayName(statement.from)}.${statement.from.field}" has a column on one side only of ${describeMigrationStatement(statement, this.#fromContract, this.#contract)}`, + 'A field rename renames a column or changes nothing in storage, and this field gains or loses its column. Leave out this statement and plan the change without it.', + undefined, + ), + ); + } + const effect = worked.value; + if (effect.kind === 'unchanged') return ok([]); + const table = this.#renamedTables.get(tableKey(effect.table)) ?? effect.table.table; + const destinationTable = modelTable(this.#contract, statement.to); + if ( + destinationTable !== undefined && + (destinationTable.namespaceId !== effect.table.namespaceId || + destinationTable.table !== table) + ) { + const tableRename = { + namespaceId: effect.table.namespaceId, + from: table, + to: destinationTable.table, + }; + const model = modelDisplayName(statement.to); + const fieldText = `${model}.${statement.from.field}:${model}.${statement.to.field}`; + return notOk( + statementRefused( + statement, + `Cannot rename column "${table}"."${effect.from}": the model's table changes from "${table}" to "${destinationTable.table}", and no statement renames the table`, + `The column would be renamed on a table the plan then drops and creates under the new name, and a migration that only changes the table name is planned the same way. With migration plan, rename the table by hand in its own migration first. Change the contract so that only the table name changes, and run prisma contract emit. Run prisma migration new --name --from , add ${this.#target.renderTableRename(tableRename)} to the operations of its migration.ts, and run node on that migration.ts to write its ops.json. Then change the contract to its final form, emit it, and plan the field rename with --from that migration. With db update, change the contract so that only the table name changes, and run prisma contract emit. Rename the table in the database yourself with ${this.#target.tableRenameByHand(tableRename).join('; ')}, check that prisma db update --dry-run plans no drop of a table or column, and run prisma db update to store that contract. Then emit the final contract and run prisma db update --rename ${fieldText}.`, + { namespaceId: effect.table.namespaceId, table, column: effect.from }, + ), + ); + } + const rename: ColumnRename = { + namespaceId: effect.table.namespaceId, + table, + from: effect.from, + to: effect.to, + }; + const tableLocation = { namespaceId: rename.namespaceId, table }; + const label = `Cannot rename column "${table}"."${rename.from}" to "${rename.to}"`; + const policyRefusal = this.#controlPolicyRefusal(statement, label, tableLocation); + if (policyRefusal !== undefined) return notOk(policyRefusal); + const checked = checkColumnRename(this.#target.tables(), this.#contract, rename); + if (!checked.ok) { + return notOk(columnRenameRefused(statement, label, rename, checked.failure)); + } + const planned = this.#emit( + statement, + label, + { ...tableLocation, column: rename.from }, + this.#target.renameColumnCall(rename), + ); + if (planned.ok) this.columnRenames.push(rename); + return planned; + } +} + +/** + * Plans the statements in order against a target's working schema: each model rename becomes a + * table rename and each field rename a column rename, computed against the schema earlier + * statements left, then applied to it. The first statement that cannot be planned fails the whole + * plan with a `statementRefused` conflict that carries the statement. + */ +export function planStatements(input: { + readonly statements: readonly ResolvedMigrationStatement[]; + readonly fromContract: Contract | null; + readonly contract: Contract; + readonly policy: MigrationOperationPolicy; + readonly target: StatementPlanningTarget; +}): Result, SqlPlannerConflict> { + const { fromContract } = input; + const [first] = input.statements; + if (first === undefined) { + return ok({ calls: [], tableRenames: [], columnRenames: [], appliedStatements: [] }); + } + if (fromContract === null) { + return notOk( + statementRefused( + first, + 'Statements need an origin contract, and this plan has none', + 'A statement names models and fields of the origin contract, and this plan has none. Plan from a contract that has the old names, or leave out the statement.', + undefined, + ), + ); + } + const planner = new StatementPlanner({ ...input, fromContract }); + const appliedStatements: AppliedMigrationStatement[] = []; + for (const statement of input.statements) { + const operationIndexes = planner.plan(statement); + if (!operationIndexes.ok) return operationIndexes; + appliedStatements.push({ statement, operationIndexes: operationIndexes.value }); + } + return ok({ + calls: planner.calls, + tableRenames: planner.tableRenames, + columnRenames: planner.columnRenames, + appliedStatements, + }); +} diff --git a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts index e10c749defbd..63d2b2134efd 100644 --- a/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts +++ b/packages/2-sql/9-family/src/core/migrations/table-name-case-guard.ts @@ -3,6 +3,7 @@ import type { SchemaDiffIssue } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlStorage } from '@internal/sql-contract/types'; +import type { TableRename, TableRenameRequest } from './resolve-table-rename'; import type { SqlPlannerConflict } from './types'; export const TABLE_NAME_CASE_CHANGED_CODE = 'MIGRATION.TABLE_NAME_CASE_CHANGED'; @@ -12,20 +13,6 @@ export interface TableNameCaseGuardTable { readonly name: string; } -/** A table rename the operator performs by hand, for the target to write the statements of. */ -export interface TableRenameByHand { - readonly namespaceId: string; - readonly from: string; - readonly to: string; -} - -/** A table rename to write as a migration call; `namespaceId` is `undefined` when the call does not need to name the namespace. */ -export interface TableRenameInMigration { - readonly namespaceId: string | undefined; - readonly from: string; - readonly to: string; -} - interface PlannedTable { readonly namespaceId: string; readonly tableName: string; @@ -75,8 +62,8 @@ export function detectTableNameCaseChanges(input: { readonly issues: readonly SchemaDiffIssue[]; readonly tableOf: (issue: SchemaDiffIssue) => TableNameCaseGuardTable | undefined; readonly namespaceIdOf: (issue: SchemaDiffIssue) => string; - readonly renameByHandStatements: (rename: TableRenameByHand) => readonly string[]; - readonly renameTableCall: (rename: TableRenameInMigration) => string; + readonly renameByHandStatements: (rename: TableRename) => readonly string[]; + readonly renameTableCall: (rename: TableRenameRequest) => string; readonly contract: Contract; readonly defaultNamespaceId: string; }): SqlPlannerConflict[] { diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index a784d2054245..66752d61e2c7 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -16,6 +16,8 @@ import type { MigrationRunnerResult, OperationContext, OpFactoryCall, + PlanOrigin, + ResolvedMigrationStatement, SchemaDiffIssue, SchemaOwnership, } from '@internal/framework-components/control'; @@ -262,7 +264,8 @@ export type SqlPlannerConflictKind = | 'missingButNonAdditive' | 'unsupportedOperation' | 'controlPolicySuppressedCall' - | 'tableNameCaseChanged'; + | 'tableNameCaseChanged' + | 'statementRefused'; export interface SqlPlannerConflictLocation { readonly namespaceId?: string; @@ -325,11 +328,15 @@ export interface SqlMigrationPlannerPlanOptions { * need from/to column-shape comparisons (unsafe type change, nullability * tightening) use this to decide whether to emit `dataTransform` * placeholders; they short-circuit when it is `null`. - * - * Planners also derive the "from" identity they stamp onto the produced - * plan's `describe()` as `fromContract?.storage.storageHash ?? null`. */ readonly fromContract: Contract | null; + /** The origin the produced plan asserts; see the framework planner's `origin` option. */ + readonly origin: PlanOrigin | null; + /** + * Statements the user gave, resolved against `fromContract` and `contract`, in the order + * given. Empty when the user gave none. + */ + readonly statements: readonly ResolvedMigrationStatement[]; /** * POSIX-relative path from the migration package dir to * `migrations/snapshots`, e.g. `'../../snapshots'`. Threaded straight @@ -348,7 +355,7 @@ export interface SqlMigrationPlannerPlanOptions { * Ownership oracle over the whole contract-space composition (the passive * aggregate). The planner asks it, per live extra node, whether any space * declares that entity: a sibling-owned node is left untouched, an unowned - * node is a genuine extra it may drop under a destructive policy. The + * node is a genuine extra it may drop under a policy that allows the drop's class. The * planner holds no list of other spaces' names — ownership lives in the * aggregate; it only asks. Absent for a single-space plan handed no * aggregate. See {@link SchemaOwnership}. diff --git a/packages/2-sql/9-family/src/core/sql-migration.ts b/packages/2-sql/9-family/src/core/sql-migration.ts index bf095b9227d5..c98e26b0353c 100644 --- a/packages/2-sql/9-family/src/core/sql-migration.ts +++ b/packages/2-sql/9-family/src/core/sql-migration.ts @@ -34,7 +34,7 @@ export abstract class SqlMigration< > extends Migration, 'sql', TTargetId, Start, End> { /** * Sorted, deduplicated invariant ids declared by this migration's - * data-transform ops. Derived from `this.operations` so the field remains + * data-transform ops. Derived from a fresh read of the operations (`Migration.readOperations`) so the field remains * consistent with the operation list — planner-built plans (`db init`, * `db update`) yield `[]` because they emit no data-transform ops. * @@ -43,7 +43,7 @@ export abstract class SqlMigration< * `MigrationPlan.providedInvariants?` stays optional. */ get providedInvariants(): readonly string[] { - const ops = this.operations.filter( + const ops = Migration.readOperations(this).filter( (op): op is SqlMigrationPlanOperation => !isThenable(op), ); return deriveProvidedInvariants(ops); diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index e7041fbf3305..04a20558d0e2 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -20,14 +20,6 @@ export type { SqlDescribedContractSpace, SqlPslBuildContext, } from '../core/control-target-descriptor'; -export type { - AppliedTableRename, - ApplyTableRenameInput, - RenameTableReferences, - ResolvedTableRename, - TableRename, -} from '../core/migrations/apply-table-rename'; -export { applyTableRename } from '../core/migrations/apply-table-rename'; export type { ContractToSchemaIROptions, DefaultRenderer, @@ -55,17 +47,47 @@ export { runnerSuccess, } from '../core/migrations/plan-helpers'; export { INIT_ADDITIVE_POLICY } from '../core/migrations/policies'; +export type { + ColumnRename, + ColumnRenameRequest, +} from '../core/migrations/resolve-column-rename'; +export { + COLUMN_RENAME_UNMATCHED_CODE, + resolveColumnRenameAgainst, + unmatchedColumnRename, +} from '../core/migrations/resolve-column-rename'; +export type { + TableRename, + TableRenameRequest, +} from '../core/migrations/resolve-table-rename'; +export { + resolveTableRenameAgainst, + unmatchedTableRename, +} from '../core/migrations/resolve-table-rename'; export type { SqlSchemaDiffFn, SqlSchemaDiffInput, SqlSchemaDiffResult, } from '../core/migrations/schema-differ'; +export type { SchemaTables } from '../core/migrations/schema-tables'; export { sqlTypeLookupsOf } from '../core/migrations/sql-type-lookups'; export type { - TableNameCaseGuardTable, - TableRenameByHand, - TableRenameInMigration, -} from '../core/migrations/table-name-case-guard'; + CallWithCompanions, + ColumnOnOneSide, + FieldStorageEffect, + ModelStorageEffect, + ModelTable, + NoTable, + PlannedStatements, + SingleOperationCall, + StatementPlanningTarget, +} from '../core/migrations/statement-planning'; +export { + fieldRenameStorageEffect, + modelRenameStorageEffect, + planStatements, +} from '../core/migrations/statement-planning'; +export type { TableNameCaseGuardTable } from '../core/migrations/table-name-case-guard'; export { detectTableNameCaseChanges, TABLE_NAME_CASE_CHANGED_CODE, diff --git a/packages/2-sql/9-family/test/apply-table-rename.test.ts b/packages/2-sql/9-family/test/apply-table-rename.test.ts deleted file mode 100644 index 862a429a50f5..000000000000 --- a/packages/2-sql/9-family/test/apply-table-rename.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; -import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { - isMaterializedSqlNamespace, - SqlStorage, - StorageTable, - type StorageTableInput, - toStorageTypeInstance, -} from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; -import { - applyTableRename, - TABLE_RENAME_UNMATCHED_CODE, - type TableRename, -} from '../src/core/migrations/apply-table-rename'; - -const idColumn = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; - -function table(extra: Partial = {}): StorageTable { - return new StorageTable({ - columns: { id: idColumn }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - ...extra, - }); -} - -function contractOf( - namespaces: Readonly>>>, - hashSeed = 'seed', -): Contract { - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash(hashSeed), - storage: new SqlStorage({ - storageHash: coreHash(hashSeed), - types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', dataType: 'pg/numeric' }) }, - namespaces: Object.fromEntries( - Object.entries(namespaces).map(([id, tables]) => [ - id, - createTestSqlNamespace({ id, entries: { table: tables } }), - ]), - ), - }), - roots: {}, - domain: applicationDomainOf({ models: {} }), - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -const rename = (from: string, to: string, namespaceId?: string): TableRename => ({ - namespaceId, - from, - to, -}); - -function tablesOf(contract: Contract, namespaceId: string): readonly string[] { - return Object.keys(contract.storage.namespaces[namespaceId]?.entries.table ?? {}); -} - -describe('applyTableRename', () => { - it('re-keys the table under its new name and retargets every foreign key that named it', () => { - const startContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { - userProfile: table({ - columns: { id: idColumn, parentId: { ...idColumn, nullable: true } }, - foreignKeys: [ - { - source: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['parentId'], - }, - target: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['id'], - }, - name: 'userProfile_parent_fkey', - }, - ], - }), - post: table({ - columns: { id: idColumn, authorId: idColumn }, - foreignKeys: [ - { - source: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'post', - columns: ['authorId'], - }, - target: { - namespaceId: UNBOUND_NAMESPACE_ID, - tableName: 'userProfile', - columns: ['id'], - }, - }, - ], - }), - }, - }); - const endContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), post: table() }, - }); - - const result = applyTableRename({ - startContract, - endContract, - rename: rename('userProfile', 'UserProfile'), - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.rename).toEqual({ - namespaceId: UNBOUND_NAMESPACE_ID, - from: 'userProfile', - to: 'UserProfile', - }); - const renamed = result.value.contract; - expect(tablesOf(renamed, UNBOUND_NAMESPACE_ID)).toEqual(['UserProfile', 'post']); - const renamedTable = - renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['UserProfile']; - expect(renamedTable).toBeInstanceOf(StorageTable); - expect(Object.keys(renamedTable?.columns ?? {})).toEqual(['id', 'parentId']); - expect( - renamedTable?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName, fk.name]), - ).toEqual([['UserProfile', 'UserProfile', 'userProfile_parent_fkey']]); - const post = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.table?.['post']; - expect(post?.foreignKeys.map((fk) => [fk.source.tableName, fk.target.tableName])).toEqual([ - ['post', 'UserProfile'], - ]); - }); - - it('keeps the namespace class, the storage hash and types, and leaves the input untouched', () => { - const startContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { userProfile: table() }, - audit: { log: table() }, - }); - const endContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table() }, - audit: { log: table() }, - }); - - const result = applyTableRename({ - startContract, - endContract, - rename: rename('userProfile', 'UserProfile'), - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - const renamed = result.value.contract; - const namespace = renamed.storage.namespaces[UNBOUND_NAMESPACE_ID]; - expect(isMaterializedSqlNamespace(namespace)).toBe(true); - expect(Object.getPrototypeOf(namespace)).toBe( - Object.getPrototypeOf(startContract.storage.namespaces[UNBOUND_NAMESPACE_ID]), - ); - expect(namespace?.id).toBe(UNBOUND_NAMESPACE_ID); - expect(renamed.storage.namespaces['audit']).toBe(startContract.storage.namespaces['audit']); - expect(renamed.storage.storageHash).toBe(startContract.storage.storageHash); - expect(renamed.storage.types).toEqual(startContract.storage.types); - expect(renamed.profileHash).toBe(startContract.profileHash); - expect(tablesOf(startContract, UNBOUND_NAMESPACE_ID)).toEqual(['userProfile']); - }); - - it('finds the table in whichever namespace declares it when no namespace is given', () => { - const startContract = contractOf({ auth: { userProfile: table() }, app: { post: table() } }); - const endContract = contractOf({ auth: { UserProfile: table() }, app: { post: table() } }); - - const result = applyTableRename({ - startContract, - endContract, - rename: rename('userProfile', 'UserProfile'), - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.rename).toEqual({ - namespaceId: 'auth', - from: 'userProfile', - to: 'UserProfile', - }); - expect(tablesOf(result.value.contract, 'auth')).toEqual(['UserProfile']); - }); - - it('renames only in the namespace given', () => { - const startContract = contractOf({ - auth: { userProfile: table() }, - app: { userProfile: table() }, - }); - const endContract = contractOf({ - auth: { UserProfile: table() }, - app: { userProfile: table() }, - }); - - const result = applyTableRename({ - startContract, - endContract, - rename: rename('userProfile', 'UserProfile', 'auth'), - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.rename).toEqual({ - namespaceId: 'auth', - from: 'userProfile', - to: 'UserProfile', - }); - expect(tablesOf(result.value.contract, 'app')).toEqual(['userProfile']); - }); - - it("lets the target rename its own references to the table in the table's namespace", () => { - const startContract = contractOf({ - auth: { userProfile: table(), account: table() }, - app: { userProfile: table() }, - }); - const endContract = contractOf({ - auth: { UserProfile: table(), account: table() }, - app: { userProfile: table() }, - }); - const calls: string[] = []; - - const result = applyTableRename({ - startContract, - endContract, - rename: rename('userProfile', 'UserProfile', 'auth'), - renameTableReferences: (entries, applied) => { - calls.push(`${applied.namespaceId}:${applied.from}>${applied.to}`); - return { ...entries, marker: { [applied.to]: { tableName: applied.to } } }; - }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(calls).toEqual(['auth:userProfile>UserProfile']); - expect(result.value.contract.storage.namespaces['auth']?.entries['marker']).toEqual({ - UserProfile: { tableName: 'UserProfile' }, - }); - expect(result.value.contract.storage.namespaces['app']?.entries['marker']).toBeUndefined(); - }); - - describe('refusals', () => { - const startContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { userProfile: table(), Account: table() }, - }); - const endContract = contractOf({ - [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, - }); - - function refusalFor( - tableRename: TableRename, - start: Contract | null = startContract, - ) { - const result = applyTableRename({ - startContract: start, - endContract, - rename: tableRename, - renameTableReferences: undefined, - }); - expect(result.ok).toBe(false); - return result.ok ? undefined : result.failure; - } - - it('refuses a table the start contract does not have', () => { - expect(refusalFor(rename('ghost', 'UserProfile'))).toMatchObject({ - code: TABLE_RENAME_UNMATCHED_CODE, - message: - 'renameTable "ghost" to "UserProfile" does not match the migration\'s contracts: table "ghost" does not exist in the start contract.', - meta: { from: 'ghost', to: 'UserProfile' }, - }); - }); - - it('refuses a new name the start contract already has', () => { - expect(refusalFor(rename('userProfile', 'Account'))?.message).toContain( - 'table "Account" already exists in the start contract', - ); - }); - - it('refuses a new name the end contract does not have', () => { - expect(refusalFor(rename('userProfile', 'Profile'))?.message).toContain( - 'table "Profile" does not exist in the end contract', - ); - }); - - it('refuses a namespace the start contract does not have', () => { - expect(refusalFor(rename('userProfile', 'UserProfile', 'auth'))?.message).toContain( - 'table "auth.userProfile" does not exist in the start contract', - ); - }); - - it('refuses a migration without a start contract', () => { - expect(refusalFor(rename('userProfile', 'UserProfile'), null)).toMatchObject({ - code: TABLE_RENAME_UNMATCHED_CODE, - message: expect.stringContaining('the migration has no start contract'), - }); - }); - - it('refuses a table name declared in more than one namespace when no namespace is given', () => { - const result = applyTableRename({ - startContract: contractOf({ - auth: { userProfile: table() }, - app: { userProfile: table() }, - }), - endContract: contractOf({ auth: { UserProfile: table() }, app: { userProfile: table() } }), - rename: rename('userProfile', 'UserProfile'), - renameTableReferences: undefined, - }); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure.message).toContain( - 'table "userProfile" is declared in more than one namespace (auth, app); name its namespace', - ); - }); - }); -}); diff --git a/packages/2-sql/9-family/test/field-event-fixtures.ts b/packages/2-sql/9-family/test/field-event-fixtures.ts new file mode 100644 index 000000000000..678c3f506a3b --- /dev/null +++ b/packages/2-sql/9-family/test/field-event-fixtures.ts @@ -0,0 +1,124 @@ +import { type Contract, profileHash, type StorageHashBase } from '@internal/contract/types'; +import type { OpFactoryCall } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage, type StorageColumn, type StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import type { + CodecControlHooks, + FieldEventContext, + SqlMigrationPlanOperation, +} from '../src/core/migrations/types'; + +const DATA_TYPE_OF_CODEC: Readonly> = { + 'cs/string@1': 'cs/string', + 'pg/text@1': 'pg/text', + 'pg/varchar@1': 'pg/varchar', +}; + +function dataTypeOf(codecId: string): string { + const dataType = DATA_TYPE_OF_CODEC[codecId]; + if (dataType === undefined) throw new Error(`no data type listed for codec ${codecId}`); + return dataType; +} + +type Op = SqlMigrationPlanOperation; + +export function col(overrides: Partial & { codecId: string }): StorageColumn { + return { + dataType: dataTypeOf(overrides.codecId), + nullable: false, + many: false, + ...overrides, + }; +} + +export function table(columns: Record): StorageTable { + return { + columns, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +export function contract(tables: Record): Contract { + const storage = new SqlStorage({ + storageHash: 'test' as StorageHashBase, + namespaces: { + [UNBOUND_NAMESPACE_ID]: createTestSqlNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { table: tables }, + }), + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('test'), + storage, + domain: applicationDomainOf({ models: {} }), + roots: {}, + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +export function makeOp(id: string, label = id): OpFactoryCall { + const op: Op = { + id, + label, + operationClass: 'additive', + invariantId: `inv:${id}`, + target: { id: 'postgres' }, + precheck: [], + execute: [{ description: label, sql: `-- ${id}` }], + postcheck: [], + }; + return { + factoryName: id, + operationClass: 'additive', + label, + renderTypeScript: () => `${id}()`, + importRequirements: () => [], + toOp: () => op, + }; +} + +export interface RecordedCall { + readonly event: 'added' | 'dropped' | 'altered'; + readonly namespaceId: string; + readonly tableName: string; + readonly fieldName: string; + readonly priorCodecId: string | undefined; + readonly newCodecId: string | undefined; + readonly priorTablePresent: boolean; + readonly newTablePresent: boolean; +} + +export function recordingHook( + opsPerCall: readonly OpFactoryCall[] | ((call: RecordedCall) => readonly OpFactoryCall[]), +): { + readonly hook: CodecControlHooks; + readonly calls: readonly RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const hook: CodecControlHooks = { + onFieldEvent: (event, ctx: FieldEventContext) => { + const recorded: RecordedCall = { + event, + namespaceId: ctx.namespaceId, + tableName: ctx.tableName, + fieldName: ctx.fieldName, + priorCodecId: ctx.priorField?.codecId, + newCodecId: ctx.newField?.codecId, + priorTablePresent: ctx.priorTable !== undefined, + newTablePresent: ctx.newTable !== undefined, + }; + calls.push(recorded); + return typeof opsPerCall === 'function' ? opsPerCall(recorded) : opsPerCall; + }, + }; + return { hook, calls }; +} diff --git a/packages/2-sql/9-family/test/field-event-planner.renamed-columns.test.ts b/packages/2-sql/9-family/test/field-event-planner.renamed-columns.test.ts new file mode 100644 index 000000000000..30cf24cd87f7 --- /dev/null +++ b/packages/2-sql/9-family/test/field-event-planner.renamed-columns.test.ts @@ -0,0 +1,58 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { planFieldEventOperations } from '../src/core/migrations/field-event-planner'; +import type { ColumnRename } from '../src/core/migrations/resolve-column-rename'; +import type { TableRename } from '../src/core/migrations/resolve-table-rename'; +import type { CodecControlHooks } from '../src/core/migrations/types'; +import { col, contract, recordingHook, table } from './field-event-fixtures'; + +const string = col({ codecId: 'cs/string@1' }); + +function eventsWith( + prior: ReturnType, + next: ReturnType, + tableRenames: readonly TableRename[], + columnRenames: readonly ColumnRename[], +) { + const cs = recordingHook([]); + planFieldEventOperations({ + priorContract: prior, + newContract: next, + codecHooks: new Map([['cs/string@1', cs.hook]]), + tableRenames, + columnRenames, + }); + return cs.calls.map((call) => `${call.event} ${call.tableName}.${call.fieldName}`); +} + +describe('planFieldEventOperations with renamed columns', () => { + const prior = contract({ User: table({ id: string, name: string }) }); + const next = contract({ User: table({ id: string, fullName: string }) }); + + it('treats a renamed column as the same column under its new name', () => { + expect( + eventsWith( + prior, + next, + [], + [{ namespaceId: UNBOUND_NAMESPACE_ID, table: 'User', from: 'name', to: 'fullName' }], + ), + ).toEqual([]); + }); + + it('still reports a column that is not renamed as dropped and added', () => { + expect(eventsWith(prior, next, [], [])).toEqual(['added User.fullName', 'dropped User.name']); + }); + + it('finds a renamed column under the new name of its renamed table', () => { + const priorProfile = contract({ Profile: table({ id: string, name: string }) }); + expect( + eventsWith( + priorProfile, + next, + [{ namespaceId: UNBOUND_NAMESPACE_ID, from: 'Profile', to: 'User' }], + [{ namespaceId: UNBOUND_NAMESPACE_ID, table: 'User', from: 'name', to: 'fullName' }], + ), + ).toEqual([]); + }); +}); diff --git a/packages/2-sql/9-family/test/field-event-planner.renamed-tables.test.ts b/packages/2-sql/9-family/test/field-event-planner.renamed-tables.test.ts new file mode 100644 index 000000000000..4384a180c1e3 --- /dev/null +++ b/packages/2-sql/9-family/test/field-event-planner.renamed-tables.test.ts @@ -0,0 +1,57 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { planFieldEventOperations } from '../src/core/migrations/field-event-planner'; +import type { TableRename } from '../src/core/migrations/resolve-table-rename'; +import type { CodecControlHooks } from '../src/core/migrations/types'; +import { col, contract, recordingHook, table } from './field-event-fixtures'; + +const priorContract = contract({ + Profile: table({ id: col({ codecId: 'cs/string@1' }), email: col({ codecId: 'cs/string@1' }) }), +}); +const newContract = contract({ + Member: table({ + id: col({ codecId: 'cs/string@1' }), + email: col({ codecId: 'cs/string@1' }), + nickname: col({ codecId: 'cs/string@1' }), + }), +}); + +function eventsWith(tableRenames: readonly TableRename[]) { + const cs = recordingHook([]); + planFieldEventOperations({ + priorContract, + newContract, + codecHooks: new Map([['cs/string@1', cs.hook]]), + tableRenames, + columnRenames: [], + }); + return cs.calls.map((call) => `${call.event} ${call.tableName}.${call.fieldName}`); +} + +describe('planFieldEventOperations with renamed tables', () => { + it('treats the columns of a renamed table as the same columns under the new name', () => { + expect( + eventsWith([{ namespaceId: UNBOUND_NAMESPACE_ID, from: 'Profile', to: 'Member' }]), + ).toEqual(['added Member.nickname']); + }); + + it('still reports a table that is not renamed as dropped and created', () => { + expect(eventsWith([])).toEqual([ + 'added Member.email', + 'added Member.id', + 'added Member.nickname', + 'dropped Profile.email', + 'dropped Profile.id', + ]); + }); + + it('keys a rename by namespace, so the same table name in another namespace is not renamed', () => { + expect(eventsWith([{ namespaceId: 'other', from: 'Profile', to: 'Member' }])).toEqual([ + 'added Member.email', + 'added Member.id', + 'added Member.nickname', + 'dropped Profile.email', + 'dropped Profile.id', + ]); + }); +}); diff --git a/packages/2-sql/9-family/test/field-event-planner.test.ts b/packages/2-sql/9-family/test/field-event-planner.test.ts index 618977061ff7..17afee1c7b73 100644 --- a/packages/2-sql/9-family/test/field-event-planner.test.ts +++ b/packages/2-sql/9-family/test/field-event-planner.test.ts @@ -1,129 +1,9 @@ -import { type Contract, profileHash, type StorageHashBase } from '@internal/contract/types'; -import type { MigrationPlanOperation, OpFactoryCall } from '@internal/framework-components/control'; +import type { MigrationPlanOperation } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { SqlStorage, type StorageColumn, type StorageTable } from '@internal/sql-contract/types'; -import { applicationDomainOf } from '@repo/test-utils'; import { describe, expect, it } from 'vitest'; -import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; import { planFieldEventOperations } from '../src/core/migrations/field-event-planner'; -import type { - CodecControlHooks, - FieldEventContext, - SqlMigrationPlanOperation, -} from '../src/core/migrations/types'; - -const DATA_TYPE_OF_CODEC: Readonly> = { - 'cs/string@1': 'cs/string', - 'pg/text@1': 'pg/text', - 'pg/varchar@1': 'pg/varchar', -}; - -function dataTypeOf(codecId: string): string { - const dataType = DATA_TYPE_OF_CODEC[codecId]; - if (dataType === undefined) throw new Error(`no data type listed for codec ${codecId}`); - return dataType; -} - -type Op = SqlMigrationPlanOperation; - -function col(overrides: Partial & { codecId: string }): StorageColumn { - return { - dataType: dataTypeOf(overrides.codecId), - nullable: false, - many: false, - ...overrides, - }; -} - -function table(columns: Record): StorageTable { - return { - columns, - uniques: [], - indexes: [], - foreignKeys: [], - }; -} - -function contract(tables: Record): Contract { - const storage = new SqlStorage({ - storageHash: 'test' as StorageHashBase, - namespaces: { - [UNBOUND_NAMESPACE_ID]: createTestSqlNamespace({ - id: UNBOUND_NAMESPACE_ID, - entries: { table: tables }, - }), - }, - }); - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash('test'), - storage, - domain: applicationDomainOf({ models: {} }), - roots: {}, - capabilities: {}, - extensions: {}, - meta: {}, - }; -} - -function makeOp(id: string, label = id): OpFactoryCall { - const op: Op = { - id, - label, - operationClass: 'additive', - invariantId: `inv:${id}`, - target: { id: 'postgres' }, - precheck: [], - execute: [{ description: label, sql: `-- ${id}` }], - postcheck: [], - }; - return { - factoryName: id, - operationClass: 'additive', - label, - renderTypeScript: () => `${id}()`, - importRequirements: () => [], - toOp: () => op, - }; -} - -interface RecordedCall { - readonly event: 'added' | 'dropped' | 'altered'; - readonly namespaceId: string; - readonly tableName: string; - readonly fieldName: string; - readonly priorCodecId: string | undefined; - readonly newCodecId: string | undefined; - readonly priorTablePresent: boolean; - readonly newTablePresent: boolean; -} - -function recordingHook( - opsPerCall: readonly OpFactoryCall[] | ((call: RecordedCall) => readonly OpFactoryCall[]), -): { - readonly hook: CodecControlHooks; - readonly calls: readonly RecordedCall[]; -} { - const calls: RecordedCall[] = []; - const hook: CodecControlHooks = { - onFieldEvent: (event, ctx: FieldEventContext) => { - const recorded: RecordedCall = { - event, - namespaceId: ctx.namespaceId, - tableName: ctx.tableName, - fieldName: ctx.fieldName, - priorCodecId: ctx.priorField?.codecId, - newCodecId: ctx.newField?.codecId, - priorTablePresent: ctx.priorTable !== undefined, - newTablePresent: ctx.newTable !== undefined, - }; - calls.push(recorded); - return typeof opsPerCall === 'function' ? opsPerCall(recorded) : opsPerCall; - }, - }; - return { hook, calls }; -} +import type { CodecControlHooks } from '../src/core/migrations/types'; +import { col, contract, makeOp, recordingHook, table } from './field-event-fixtures'; describe('planFieldEventOperations', () => { it("fires 'added' once per added field on the new field's codec", () => { @@ -144,6 +24,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls).toEqual([ @@ -181,6 +63,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls).toEqual([ @@ -215,6 +99,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls).toHaveLength(1); @@ -243,6 +129,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => c.event)).toEqual(['altered']); @@ -268,6 +156,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => c.event)).toEqual(['altered']); @@ -293,6 +183,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(text.calls).toHaveLength(0); @@ -312,6 +204,8 @@ describe('planFieldEventOperations', () => { priorContract: same, newContract: same, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls).toHaveLength(0); @@ -333,6 +227,8 @@ describe('planFieldEventOperations', () => { priorContract: null, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => c.event)).toEqual(['added']); @@ -355,6 +251,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(ops).toHaveLength(0); @@ -372,6 +270,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(ops).toHaveLength(0); @@ -390,6 +290,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(ops.map((o) => (o.toOp() as MigrationPlanOperation).id)).toEqual([ @@ -412,6 +314,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls).toHaveLength(1); @@ -439,6 +343,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => c.event)).toEqual(['added', 'dropped', 'altered']); @@ -469,6 +375,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => `${c.tableName}.${c.fieldName}`)).toEqual([ @@ -499,6 +407,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(cs.calls.map((c) => c.fieldName)).toEqual(['secret']); @@ -529,8 +439,20 @@ describe('planFieldEventOperations', () => { ], ]); - const opsA = planFieldEventOperations({ priorContract: fromContract, newContract, codecHooks }); - const opsB = planFieldEventOperations({ priorContract: fromContract, newContract, codecHooks }); + const opsA = planFieldEventOperations({ + priorContract: fromContract, + newContract, + codecHooks, + tableRenames: [], + columnRenames: [], + }); + const opsB = planFieldEventOperations({ + priorContract: fromContract, + newContract, + codecHooks, + tableRenames: [], + columnRenames: [], + }); // Materialise each `OpFactoryCall` rather than `JSON.stringify`-ing // the array — function members like `toOp` / `renderTypeScript` get @@ -564,6 +486,8 @@ describe('planFieldEventOperations', () => { priorContract: fromContract, newContract, codecHooks, + tableRenames: [], + columnRenames: [], }); expect(ops.map((o) => (o.toOp() as MigrationPlanOperation).id)).toEqual([ diff --git a/packages/2-sql/9-family/test/migrations.types.test.ts b/packages/2-sql/9-family/test/migrations.types.test.ts index d1024ae7ff5d..7324d46e2431 100644 --- a/packages/2-sql/9-family/test/migrations.types.test.ts +++ b/packages/2-sql/9-family/test/migrations.types.test.ts @@ -162,8 +162,8 @@ describe('planner helpers', () => { operations: [], providedInvariants: [], }); - const success = plannerSuccess(plan); - expect(success).toEqual({ kind: 'success', plan }); + const success = plannerSuccess(plan, []); + expect(success).toEqual({ kind: 'success', plan, appliedStatements: [] }); expect(Object.isFrozen(success)).toBe(true); const warning = { @@ -171,9 +171,9 @@ describe('planner helpers', () => { summary: 'control policy suppressed: createTable(users)', meta: { controlPolicy: 'external', factoryName: 'createTable' }, }; - const successWithWarnings = plannerSuccess(plan, [warning]); + const successWithWarnings = plannerSuccess(plan, [], [warning]); expect(successWithWarnings.warnings).toEqual([warning]); - expect(plannerSuccess(plan, [])).toEqual({ kind: 'success', plan }); + expect(plannerSuccess(plan, [], [])).toEqual({ kind: 'success', plan, appliedStatements: [] }); const conflict = { kind: 'typeMismatch', diff --git a/packages/2-sql/9-family/test/resolve-column-rename.test.ts b/packages/2-sql/9-family/test/resolve-column-rename.test.ts new file mode 100644 index 000000000000..641989d06228 --- /dev/null +++ b/packages/2-sql/9-family/test/resolve-column-rename.test.ts @@ -0,0 +1,156 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import { + COLUMN_RENAME_UNMATCHED_CODE, + type ColumnRenameRequest, + resolveColumnRenameAgainst, +} from '../src/core/migrations/resolve-column-rename'; +import type { SchemaTables } from '../src/core/migrations/schema-tables'; + +const text = { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }; + +function contractWithColumns( + tables: Readonly>, + namespaceId: string = UNBOUND_NAMESPACE_ID, +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('seed'), + storage: new SqlStorage({ + storageHash: coreHash('seed'), + namespaces: { + [namespaceId]: createTestSqlNamespace({ + id: namespaceId, + entries: { + table: Object.fromEntries( + Object.entries(tables).map(([name, columns]) => [ + name, + new StorageTable({ + columns: Object.fromEntries(columns.map((column) => [column, text])), + uniques: [], + indexes: [], + foreignKeys: [], + }), + ]), + ), + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function schemaTables( + namespaces: Readonly>>>, +): SchemaTables { + const columnsOf = (namespaceId: string, table: string) => namespaces[namespaceId]?.[table]; + return { + hasTable: (namespaceId, table) => columnsOf(namespaceId, table) !== undefined, + hasColumn: (namespaceId, table, column) => + columnsOf(namespaceId, table)?.includes(column) === true, + tablesNamed: (namespaceId, table) => + columnsOf(namespaceId, table) === undefined ? [] : [table], + columnsNamed: (namespaceId, table, column) => + (columnsOf(namespaceId, table) ?? []).filter( + (existing) => existing.toLowerCase() === column.toLowerCase(), + ), + namespacesWithTable: (table) => + Object.keys(namespaces).filter((namespaceId) => columnsOf(namespaceId, table) !== undefined), + }; +} + +const rename = ( + table: string, + from: string, + to: string, + namespaceId?: string, +): ColumnRenameRequest => ({ + namespaceId, + table, + from, + to, +}); + +describe('resolveColumnRenameAgainst', () => { + const endContract = contractWithColumns({ User: ['id', 'fullName'] }); + const lookup = schemaTables({ [UNBOUND_NAMESPACE_ID]: { User: ['id', 'name'] } }); + + function refusalFor(columnRename: ColumnRenameRequest, against: SchemaTables = lookup) { + const result = resolveColumnRenameAgainst(against, endContract, columnRename); + if (result.ok) throw new Error('expected a refusal'); + return result.failure; + } + + it('resolves the namespace the lookup finds the table in', () => { + const result = resolveColumnRenameAgainst( + lookup, + endContract, + rename('User', 'name', 'fullName'), + ); + expect(result.ok && result.value).toEqual({ + namespaceId: UNBOUND_NAMESPACE_ID, + table: 'User', + from: 'name', + to: 'fullName', + }); + }); + + it('refuses a table the lookup does not have at this point of the migration', () => { + expect(refusalFor(rename('Ghost', 'name', 'fullName'))).toMatchObject({ + code: COLUMN_RENAME_UNMATCHED_CODE, + message: expect.stringContaining( + 'table "Ghost" does not exist at this point of the migration', + ), + meta: { table: 'Ghost', from: 'name', to: 'fullName' }, + }); + }); + + it('refuses a column the table does not have at this point of the migration', () => { + expect(refusalFor(rename('User', 'email', 'fullName')).message).toContain( + 'column "User"."email" does not exist at this point of the migration', + ); + }); + + it('refuses a new name the table already has at this point of the migration', () => { + expect(refusalFor(rename('User', 'name', 'id')).message).toContain( + 'column "User"."id" already exists at this point of the migration', + ); + }); + + it('refuses a new name the table has under another case where the target ignores case', () => { + const against = schemaTables({ [UNBOUND_NAMESPACE_ID]: { User: ['other', 'FullName'] } }); + expect(refusalFor(rename('User', 'other', 'fullName'), against).message).toContain( + 'column "User"."fullName" already exists at this point of the migration as "FullName"', + ); + }); + + it('resolves a rename that only changes the case of the name', () => { + const endUpper = contractWithColumns({ User: ['id', 'Name'] }); + const result = resolveColumnRenameAgainst(lookup, endUpper, rename('User', 'name', 'Name')); + expect(result.ok).toBe(true); + }); + + it('refuses a new name the end contract does not have', () => { + const against = schemaTables({ [UNBOUND_NAMESPACE_ID]: { User: ['id', 'name'] } }); + expect(refusalFor(rename('User', 'name', 'title'), against).message).toContain( + 'column "User"."title" does not exist in the end contract', + ); + }); + + it('refuses a table name the lookup has in more than one namespace when no namespace is given', () => { + const twice = schemaTables({ auth: { User: ['name'] }, app: { User: ['name'] } }); + expect(refusalFor(rename('User', 'name', 'fullName'), twice).message).toContain( + 'table "User" is declared in more than one namespace (auth, app); name its namespace', + ); + }); +}); diff --git a/packages/2-sql/9-family/test/resolve-table-rename.test.ts b/packages/2-sql/9-family/test/resolve-table-rename.test.ts new file mode 100644 index 000000000000..827bff17507d --- /dev/null +++ b/packages/2-sql/9-family/test/resolve-table-rename.test.ts @@ -0,0 +1,169 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + SqlStorage, + StorageTable, + type StorageTableInput, + toStorageTypeInstance, +} from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import { + resolveTableRenameAgainst, + TABLE_RENAME_UNMATCHED_CODE, + type TableRenameRequest, +} from '../src/core/migrations/resolve-table-rename'; +import type { SchemaTables } from '../src/core/migrations/schema-tables'; + +const idColumn = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; + +function table(extra: Partial = {}): StorageTable { + return new StorageTable({ + columns: { id: idColumn }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + ...extra, + }); +} + +function contractOf( + namespaces: Readonly>>>, + hashSeed = 'seed', +): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + types: { Money: toStorageTypeInstance({ codecId: 'pg/numeric@1', dataType: 'pg/numeric' }) }, + namespaces: Object.fromEntries( + Object.entries(namespaces).map(([id, tables]) => [ + id, + createTestSqlNamespace({ id, entries: { table: tables } }), + ]), + ), + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const rename = (from: string, to: string, namespaceId?: string): TableRenameRequest => ({ + namespaceId, + from, + to, +}); + +describe('resolveTableRenameAgainst', () => { + const endContract = contractOf({ + [UNBOUND_NAMESPACE_ID]: { UserProfile: table(), Account: table() }, + auth: { Session: table() }, + }); + const atThisPoint = ( + namespaces: Readonly>, + sameName: (left: string, right: string) => boolean = (left, right) => left === right, + ): SchemaTables => ({ + hasTable: (namespaceId, tableName) => namespaces[namespaceId]?.includes(tableName) === true, + hasColumn: () => false, + tablesNamed: (namespaceId, tableName) => + (namespaces[namespaceId] ?? []).filter((existing) => sameName(existing, tableName)), + columnsNamed: () => [], + namespacesWithTable: (tableName) => + Object.keys(namespaces).filter((namespaceId) => namespaces[namespaceId]?.includes(tableName)), + }); + const ignoringCase = (left: string, right: string) => left.toLowerCase() === right.toLowerCase(); + const lookup = atThisPoint({ + [UNBOUND_NAMESPACE_ID]: ['userProfile', 'Account'], + auth: ['login'], + }); + + function refusalFor(tableRename: TableRenameRequest, against: SchemaTables = lookup) { + const result = resolveTableRenameAgainst(against, endContract, tableRename); + expect(result.ok).toBe(false); + return result.ok ? undefined : result.failure; + } + + it('refuses a new name another table holds in another case where the database ignores case', () => { + const caseless = atThisPoint( + { [UNBOUND_NAMESPACE_ID]: ['userProfile', 'account'] }, + ignoringCase, + ); + expect(refusalFor(rename('userProfile', 'Account'), caseless)).toMatchObject({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "Account" already exists at this point of the migration as "account"', + ), + }); + }); + + it('resolves a rename that changes only the case of the name where the database ignores case', () => { + const caseless = atThisPoint({ [UNBOUND_NAMESPACE_ID]: ['userProfile'] }, ignoringCase); + expect( + resolveTableRenameAgainst(caseless, endContract, rename('userProfile', 'UserProfile')), + ).toMatchObject({ + ok: true, + value: { namespaceId: UNBOUND_NAMESPACE_ID, from: 'userProfile', to: 'UserProfile' }, + }); + }); + + it('resolves the namespace the lookup finds the table in', () => { + const result = resolveTableRenameAgainst(lookup, endContract, rename('login', 'Session')); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toEqual({ namespaceId: 'auth', from: 'login', to: 'Session' }); + }); + + it('resolves only in the namespace given, though another namespace has the table too', () => { + const twice = atThisPoint({ auth: ['login'], app: ['login'] }); + const result = resolveTableRenameAgainst( + twice, + endContract, + rename('login', 'Session', 'auth'), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toEqual({ namespaceId: 'auth', from: 'login', to: 'Session' }); + }); + + it('refuses a table the namespace given does not have at this point of the migration', () => { + expect(refusalFor(rename('userProfile', 'UserProfile', 'auth'))?.message).toContain( + 'table "auth.userProfile" does not exist at this point of the migration', + ); + }); + + it('refuses a table the lookup does not have at this point of the migration', () => { + expect(refusalFor(rename('ghost', 'UserProfile'))).toMatchObject({ + code: TABLE_RENAME_UNMATCHED_CODE, + message: + 'renameTable "ghost" to "UserProfile" does not match the migration\'s contracts: table "ghost" does not exist at this point of the migration.', + why: "renameTable must name a table as the migration's earlier rename operations leave it, and a new name that the end contract has and that no earlier rename operation has already produced. Order the renameTable calls in the sequence the renames happen, make the rename its own schema change, and check the spelling, the table and the namespace.", + meta: { from: 'ghost', to: 'UserProfile' }, + }); + }); + + it('refuses a new name the lookup already has at this point of the migration', () => { + expect(refusalFor(rename('userProfile', 'Account'))?.message).toContain( + 'table "Account" already exists at this point of the migration', + ); + }); + + it('refuses a new name the end contract does not have', () => { + expect(refusalFor(rename('userProfile', 'Profile'))?.message).toContain( + 'table "Profile" does not exist in the end contract', + ); + }); + + it('refuses a table name the lookup has in more than one namespace when no namespace is given', () => { + const twice = atThisPoint({ auth: ['userProfile'], app: ['userProfile'] }); + expect(refusalFor(rename('userProfile', 'UserProfile'), twice)?.message).toContain( + 'table "userProfile" is declared in more than one namespace (auth, app); name its namespace', + ); + }); +}); diff --git a/packages/2-sql/9-family/test/statement-fixtures.ts b/packages/2-sql/9-family/test/statement-fixtures.ts new file mode 100644 index 000000000000..ba8ffbb4d7ef --- /dev/null +++ b/packages/2-sql/9-family/test/statement-fixtures.ts @@ -0,0 +1,238 @@ +import { + asNamespaceId, + type Contract, + type ContractModelBase, + type ControlPolicy, + profileHash, + type StorageHashBase, +} from '@internal/contract/types'; +import type { + MigrationOperationClass, + ResolvedFieldRenameStatement, + ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { createTestSqlNamespace } from '../../1-core/contract/test/test-support'; +import type { ColumnRename } from '../src/core/migrations/resolve-column-rename'; +import type { TableRename } from '../src/core/migrations/resolve-table-rename'; +import type { SchemaTables } from '../src/core/migrations/schema-tables'; +import type { + CallWithCompanions, + planStatements, + StatementPlanningTarget, +} from '../src/core/migrations/statement-planning'; + +export interface ModelSpec { + /** `null` for a model the contract stores in no table. */ + readonly table: string | null; + readonly namespace?: string; + readonly control?: ControlPolicy; + /** Field name to column name; `null` for a relation field, which has no column. */ + readonly fields?: Readonly>; +} + +const text = { dataType: 'test/text', codecId: 'test/text@1', nullable: false }; + +function model(namespaceId: string, spec: ModelSpec): ContractModelBase { + const fields = Object.entries(spec.fields ?? {}); + const columns = fields.flatMap(([field, column]) => + column === null ? [] : [[field, column] as const], + ); + return { + fields: Object.fromEntries( + columns.map(([field]) => [ + field, + { nullable: false, type: { kind: 'scalar', codecId: 'test/text@1' } }, + ]), + ), + relations: Object.fromEntries( + fields + .filter(([, column]) => column === null) + .map(([field]) => [ + field, + { + to: { namespace: asNamespaceId(namespaceId), model: 'Other' }, + cardinality: '1:N', + on: { localFields: ['id'], targetFields: ['ownerId'] }, + }, + ]), + ), + storage: + spec.table === null + ? {} + : { + table: spec.table, + namespaceId, + fields: Object.fromEntries(columns.map(([field, column]) => [field, { column }])), + }, + }; +} + +export function contractOf(models: Record): Contract { + const namespaceIds = [...new Set(Object.values(models).map((spec) => spec.namespace ?? 'app'))]; + const inNamespace = (id: string) => + Object.entries(models).filter(([, spec]) => (spec.namespace ?? 'app') === id); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('test'), + storage: new SqlStorage({ + storageHash: 'test' as StorageHashBase, + namespaces: Object.fromEntries( + namespaceIds.map((id) => [ + id, + createTestSqlNamespace({ + id, + entries: { + table: Object.fromEntries( + inNamespace(id) + .flatMap(([, spec]) => (spec.table === null ? [] : [spec])) + .map((spec) => [ + spec.table, + new StorageTable({ + columns: Object.fromEntries( + Object.values(spec.fields ?? {}).flatMap((column) => + column === null ? [] : [[column, text]], + ), + ), + uniques: [], + indexes: [], + foreignKeys: [], + ...(spec.control === undefined ? {} : { control: spec.control }), + }), + ]), + ), + }, + }), + ]), + ), + }), + domain: { + namespaces: Object.fromEntries( + namespaceIds.map((id) => [ + id, + { + models: Object.fromEntries( + inNamespace(id).map(([name, spec]) => [name, model(id, spec)]), + ), + }, + ]), + ), + }, + roots: {}, + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +export function renameModel( + from: string, + to: string, + fromNs = 'app', + toNs = fromNs, +): ResolvedModelRenameStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: asNamespaceId(fromNs), model: from }, + to: { namespaceId: asNamespaceId(toNs), model: to }, + }; +} + +export function renameField( + model: string, + from: string, + to: string, + newModel = model, +): ResolvedFieldRenameStatement { + return { + kind: 'rename', + entity: 'field', + from: { namespaceId: asNamespaceId('app'), model, field: from }, + to: { namespaceId: asNamespaceId('app'), model: newModel, field: to }, + }; +} + +/** A fake target's call: its text names the rename, and it carries one companion. */ +export interface FakeCall extends CallWithCompanions { + readonly text: string; +} + +/** + * A target whose calls carry a text naming the rename and one companion, and whose working schema + * maps each qualified table name to its columns. Every operation has the first of + * `operationClasses`. + */ +export function fakeTarget( + initial: readonly string[], + columns: Readonly> = {}, + operationClasses: readonly MigrationOperationClass[] = ['widening'], + sameName: (left: string, right: string) => boolean = (left, right) => left === right, +): StatementPlanningTarget { + const tables = new Map(initial.map((table) => [table, new Set(columns[table] ?? [])])); + const schemaTables: SchemaTables = { + hasTable: (namespaceId, table) => tables.has(`${namespaceId}.${table}`), + hasColumn: (namespaceId, table, column) => + tables.get(`${namespaceId}.${table}`)?.has(column) === true, + tablesNamed: (namespaceId, table) => + [...tables.keys()] + .filter((key) => key.startsWith(`${namespaceId}.`)) + .map((key) => key.slice(namespaceId.length + 1)) + .filter((existing) => sameName(existing, table)), + columnsNamed: (namespaceId, table, column) => + [...(tables.get(`${namespaceId}.${table}`) ?? [])].filter((existing) => + sameName(existing, column), + ), + namespacesWithTable: () => [], + }; + const operationClass = operationClasses[0] ?? 'widening'; + const call = (text: string): FakeCall => ({ + text, + operationClass, + companions: [{ operationClass }], + }); + return { + tables: () => schemaTables, + renameTableCall: (rename: TableRename) => + call(`table ${rename.namespaceId}.${rename.from} -> ${rename.to}`), + renameColumnCall: (rename: ColumnRename) => + call(`column ${rename.namespaceId}.${rename.table}.${rename.from} -> ${rename.to}`), + renderTableRename: (rename: TableRename) => + `renameTable ${rename.namespaceId}.${rename.from} -> ${rename.to}`, + tableRenameByHand: (rename: TableRename) => [ + `RENAME ${rename.namespaceId}.${rename.from} TO ${rename.to}`, + ], + apply: ({ text }) => { + const [kind, from, , to] = text.split(' '); + if (from === undefined || to === undefined) return; + const parts = from.split('.'); + if (kind === 'table') { + const [namespaceId] = parts; + const existing = tables.get(from) ?? new Set(); + tables.delete(from); + tables.set(`${namespaceId}.${to}`, existing); + return; + } + const [namespaceId, table, column] = parts; + const existing = tables.get(`${namespaceId}.${table}`); + existing?.delete(column ?? ''); + existing?.add(to); + }, + }; +} + +/** The planned statements, with each call given by its text. */ +export function planned(result: ReturnType>) { + if (!result.ok) throw new Error(`expected a plan, got: ${result.failure.summary}`); + return { ...result.value, calls: result.value.calls.map((call) => call.text) }; +} + +export function rejection(result: ReturnType>) { + if (result.ok) throw new Error('expected a rejection'); + return result.failure; +} + +export const ALL_CLASSES = { + allowedOperationClasses: ['additive', 'widening', 'destructive'] as const, +}; diff --git a/packages/2-sql/9-family/test/statement-planning.fields.test.ts b/packages/2-sql/9-family/test/statement-planning.fields.test.ts new file mode 100644 index 000000000000..457b40e54fe8 --- /dev/null +++ b/packages/2-sql/9-family/test/statement-planning.fields.test.ts @@ -0,0 +1,268 @@ +import { notOk, ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { + fieldRenameStorageEffect, + planStatements, +} from '../src/core/migrations/statement-planning'; +import { + ALL_CLASSES, + contractOf, + fakeTarget, + planned, + rejection, + renameField, + renameModel, +} from './statement-fixtures'; + +const ignoreCase = (left: string, right: string) => left.toLowerCase() === right.toLowerCase(); + +const origin = contractOf({ User: { table: 'User', fields: { id: 'id', name: 'name' } } }); +const destination = contractOf({ + User: { table: 'User', fields: { id: 'id', fullName: 'fullName' } }, +}); + +describe('fieldRenameStorageEffect', () => { + it('is a column rename when the column name changes', () => { + expect( + fieldRenameStorageEffect(renameField('User', 'name', 'fullName'), origin, destination), + ).toEqual( + ok({ + kind: 'renameColumn', + table: { namespaceId: 'app', table: 'User' }, + from: 'name', + to: 'fullName', + }), + ); + }); + + it('is unchanged when both fields map to the same column', () => { + const mapped = contractOf({ User: { table: 'User', fields: { fullName: 'name' } } }); + expect( + fieldRenameStorageEffect(renameField('User', 'name', 'fullName'), origin, mapped), + ).toEqual(ok({ kind: 'unchanged' })); + }); + + it('is unchanged for a relation field, which has no column', () => { + const before = contractOf({ User: { table: 'User', fields: { posts: null } } }); + const after = contractOf({ User: { table: 'User', fields: { articles: null } } }); + expect( + fieldRenameStorageEffect(renameField('User', 'posts', 'articles'), before, after), + ).toEqual(ok({ kind: 'unchanged' })); + }); +}); + +describe('fieldRenameStorageEffect, when no effect can be worked out', () => { + it('reports a field that has a column on one side only', () => { + const before = contractOf({ User: { table: 'User', fields: { posts: null } } }); + const after = contractOf({ User: { table: 'User', fields: { title: 'title' } } }); + expect(fieldRenameStorageEffect(renameField('User', 'posts', 'title'), before, after)).toEqual( + notOk({ kind: 'columnOnOneSide' }), + ); + }); +}); + +describe('planStatements, field renames', () => { + it('renames the column and reports the statement', () => { + const statement = renameField('User', 'name', 'fullName'); + expect( + planned( + planStatements({ + policy: ALL_CLASSES, + statements: [statement], + fromContract: origin, + contract: destination, + target: fakeTarget(['app.User'], { 'app.User': ['id', 'name'] }), + }), + ), + ).toEqual({ + calls: ['column app.User.name -> fullName'], + tableRenames: [], + columnRenames: [{ namespaceId: 'app', table: 'User', from: 'name', to: 'fullName' }], + appliedStatements: [ + { + statement, + operationIndexes: [0, 1], + }, + ], + }); + }); + + it('renames the column on the table an earlier statement renamed', () => { + const before = contractOf({ Profile: { table: 'Profile', fields: { name: 'name' } } }); + const after = contractOf({ User: { table: 'User', fields: { fullName: 'fullName' } } }); + expect( + planned( + planStatements({ + policy: ALL_CLASSES, + statements: [ + renameModel('Profile', 'User'), + renameField('Profile', 'name', 'fullName', 'User'), + ], + fromContract: before, + contract: after, + target: fakeTarget(['app.Profile'], { 'app.Profile': ['name'] }), + }), + ).calls, + ).toEqual(['table app.Profile -> User', 'column app.User.name -> fullName']); + }); + + it('applies a relation field rename with no operations', () => { + const before = contractOf({ User: { table: 'User', fields: { posts: null } } }); + const after = contractOf({ User: { table: 'User', fields: { articles: null } } }); + expect( + planned( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'posts', 'articles')], + fromContract: before, + contract: after, + target: fakeTarget(['app.User']), + }), + ), + ).toMatchObject({ + calls: [], + columnRenames: [], + appliedStatements: [{ operationIndexes: [] }], + }); + }); + + it('rejects a rename whose column the schema being planned from does not have', () => { + const statement = renameField('User', 'name', 'fullName'); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [statement], + fromContract: origin, + contract: destination, + target: fakeTarget(['app.User'], { 'app.User': ['id'] }), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + refusedStatement: statement, + location: { namespaceId: 'app', entityKind: 'table', entityName: 'User', column: 'name' }, + }); + expect(conflict.summary).toContain('has no column "name"'); + }); + + it('rejects a rename onto a column the schema being planned from already has', () => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'name', 'fullName')], + fromContract: origin, + contract: destination, + target: fakeTarget(['app.User'], { 'app.User': ['name', 'fullName'] }), + }), + ); + expect(conflict.summary).toContain('already has a column "fullName"'); + }); + + it('rejects a rename onto a name the target takes for an existing column of another case', () => { + const before = contractOf({ + User: { table: 'User', fields: { other: 'other', Name: 'Name' } }, + }); + const after = contractOf({ User: { table: 'User', fields: { NAME: 'NAME', Name: 'Name' } } }); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'other', 'NAME')], + fromContract: before, + contract: after, + target: fakeTarget( + ['app.User'], + { 'app.User': ['other', 'Name'] }, + ['widening'], + ignoreCase, + ), + }), + ); + expect(conflict.summary).toContain('already has a column "Name"'); + }); + + it('renames a column whose name changes only in case where the target ignores case', () => { + const before = contractOf({ User: { table: 'User', fields: { name: 'name' } } }); + const after = contractOf({ User: { table: 'User', fields: { Name: 'Name' } } }); + expect( + planned( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'name', 'Name')], + fromContract: before, + contract: after, + target: fakeTarget(['app.User'], { 'app.User': ['name'] }, ['widening'], ignoreCase), + }), + ).calls, + ).toEqual(['column app.User.name -> Name']); + }); + + it('rejects a rename on a table the destination stores the model in under another name', () => { + const statement = renameField('User', 'name', 'fullName'); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [statement], + fromContract: contractOf({ User: { table: 'users', fields: { name: 'name' } } }), + contract: contractOf({ User: { table: 'app_users', fields: { fullName: 'fullName' } } }), + target: fakeTarget(['app.users'], { 'app.users': ['name'] }), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + refusedStatement: statement, + summary: + 'Cannot rename column "users"."name": the model\'s table changes from "users" to "app_users", and no statement renames the table', + }); + expect(conflict.why).toContain( + 'With migration plan, rename the table by hand in its own migration first. Change the contract so that only the table name changes, and run prisma contract emit. Run prisma migration new --name --from , add renameTable app.users -> app_users to the operations of its migration.ts, and run node on that migration.ts to write its ops.json. Then change the contract to its final form, emit it, and plan the field rename with --from that migration. With db update, change the contract so that only the table name changes, and run prisma contract emit. Rename the table in the database yourself with RENAME app.users TO app_users, check that prisma db update --dry-run plans no drop of a table or column, and run prisma db update to store that contract. Then emit the final contract and run prisma db update --rename app.User.name:app.User.fullName.', + ); + }); + + it('rejects a field that has a column on one side only', () => { + const before = contractOf({ User: { table: 'User', fields: { posts: null } } }); + const after = contractOf({ User: { table: 'User', fields: { title: 'title' } } }); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'posts', 'title')], + fromContract: before, + contract: after, + target: fakeTarget(['app.User']), + }), + ); + expect(conflict.kind).toBe('statementRefused'); + expect(conflict.summary).toContain('column on one side only'); + }); + + it('rejects a column rename the policy does not allow', () => { + const conflict = rejection( + planStatements({ + policy: { allowedOperationClasses: ['additive'] }, + statements: [renameField('User', 'name', 'fullName')], + fromContract: origin, + contract: destination, + target: fakeTarget(['app.User'], { 'app.User': ['name'] }), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + refusedOperationClass: 'widening', + }); + }); + + it('rejects renaming a column of a table whose control policy is not managed', () => { + const external = contractOf({ + User: { table: 'User', control: 'external', fields: { fullName: 'fullName' } }, + }); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameField('User', 'name', 'fullName')], + fromContract: origin, + contract: external, + target: fakeTarget(['app.User'], { 'app.User': ['name'] }), + }), + ); + expect(conflict.summary).toContain('control policy is "external"'); + }); +}); diff --git a/packages/2-sql/9-family/test/statement-planning.refusals.test.ts b/packages/2-sql/9-family/test/statement-planning.refusals.test.ts new file mode 100644 index 000000000000..3a40837a91b2 --- /dev/null +++ b/packages/2-sql/9-family/test/statement-planning.refusals.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { planStatements } from '../src/core/migrations/statement-planning'; +import { + ALL_CLASSES, + contractOf, + fakeTarget, + rejection, + renameField, + renameModel, +} from './statement-fixtures'; + +const profile = contractOf({ Profile: { table: 'Profile' } }); +const user = contractOf({ User: { table: 'User' } }); +const userWithName = contractOf({ User: { table: 'User', fields: { id: 'id', name: 'name' } } }); +const userWithFullName = contractOf({ + User: { table: 'User', fields: { id: 'id', fullName: 'fullName' } }, +}); + +describe('statement refusals say what to do next', () => { + it.each([ + { + case: 'a table whose control policy is not managed', + input: { + statements: [renameModel('Profile', 'User')], + fromContract: profile, + contract: contractOf({ User: { table: 'User', control: 'external' } }), + target: fakeTarget(['app.Profile']), + }, + why: 'Statements rename only tables, and columns of tables, whose control policy is "managed"; table "User" is "external". Make the change in the database yourself and leave out this statement.', + }, + { + case: 'a rename whose operations the policy does not allow', + input: { + policy: { allowedOperationClasses: ['additive'] as const }, + statements: [renameModel('Profile', 'User')], + fromContract: profile, + contract: user, + target: fakeTarget(['app.Profile']), + }, + why: 'The rename produces a "widening" operation, and this command plans only "additive" operations. Leave out this statement, or make the change with a command that allows "widening" operations, such as migration plan.', + }, + { + case: 'a model stored in no table', + input: { + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: null } }), + contract: user, + target: fakeTarget([]), + }, + why: 'A model rename renames the model\'s table, and model "app.Profile" has none in its contract. Leave out this statement.', + }, + { + case: 'a model moved to another namespace', + input: { + statements: [renameModel('User', 'User', 'auth', 'billing')], + fromContract: contractOf({ User: { table: 'User', namespace: 'auth' } }), + contract: contractOf({ User: { table: 'User', namespace: 'billing' } }), + target: fakeTarget(['auth.User']), + }, + why: 'The model\'s table would move from namespace "auth" to namespace "billing". Leave out this statement and move the table yourself in a hand-written migration, or keep the model in namespace "auth".', + }, + { + case: 'a table the schema being planned from does not have', + input: { + statements: [renameModel('Profile', 'User')], + fromContract: profile, + contract: user, + target: fakeTarget([]), + }, + why: 'The database has no table "Profile", although the contract it was last updated to names it, so the database has drifted from that contract. Inspect it with prisma db schema, or leave out this statement.', + }, + { + case: 'a table name another table already holds', + input: { + statements: [renameModel('Profile', 'User')], + fromContract: profile, + contract: user, + target: fakeTarget(['app.Profile', 'app.User']), + }, + why: 'A rename cannot replace a table that already exists. Rename or drop table "User" first, or leave out this statement.', + }, + { + case: 'a field of a model stored in no table', + input: { + statements: [renameField('User', 'name', 'fullName')], + fromContract: contractOf({ User: { table: null, fields: { name: 'name' } } }), + contract: contractOf({ User: { table: null, fields: { fullName: 'fullName' } } }), + target: fakeTarget([]), + }, + why: 'A field rename renames the field\'s column, and model "app.User" has no table in its contract. Leave out this statement.', + }, + { + case: 'a field that has a column on one side only', + input: { + statements: [renameField('User', 'posts', 'title')], + fromContract: contractOf({ User: { table: 'User', fields: { posts: null } } }), + contract: contractOf({ User: { table: 'User', fields: { title: 'title' } } }), + target: fakeTarget(['app.User']), + }, + why: 'A field rename renames a column or changes nothing in storage, and this field gains or loses its column. Leave out this statement and plan the change without it.', + }, + { + case: 'a column the schema being planned from does not have', + input: { + statements: [renameField('User', 'name', 'fullName')], + fromContract: userWithName, + contract: userWithFullName, + target: fakeTarget(['app.User'], { 'app.User': ['id'] }), + }, + why: 'The database has no column "name" on table "User", although the contract it was last updated to names it, so the database has drifted from that contract. Inspect it with prisma db schema, or leave out this statement.', + }, + { + case: 'a column of a table the schema being planned from does not have', + input: { + statements: [renameField('User', 'name', 'fullName')], + fromContract: userWithName, + contract: userWithFullName, + target: fakeTarget([]), + }, + why: 'The database has no table "User", although the contract it was last updated to names it, so the database has drifted from that contract. Inspect it with prisma db schema, or leave out this statement.', + }, + { + case: 'a column name another column already holds', + input: { + statements: [renameField('User', 'name', 'fullName')], + fromContract: userWithName, + contract: userWithFullName, + target: fakeTarget(['app.User'], { 'app.User': ['name', 'fullName'] }), + }, + why: 'A rename cannot replace a column that already exists. Rename or drop column "fullName" of table "User" first, or leave out this statement.', + }, + { + case: 'a plan with no origin contract', + input: { + statements: [renameModel('Profile', 'User')], + fromContract: null, + contract: user, + target: fakeTarget([]), + }, + why: 'A statement names models and fields of the origin contract, and this plan has none. Plan from a contract that has the old names, or leave out the statement.', + }, + ])('refuses $case with a next step', ({ input, why }) => { + expect(rejection(planStatements({ policy: ALL_CLASSES, ...input })).why).toBe(why); + }); +}); diff --git a/packages/2-sql/9-family/test/statement-planning.test.ts b/packages/2-sql/9-family/test/statement-planning.test.ts new file mode 100644 index 000000000000..7e1bf9af7ab9 --- /dev/null +++ b/packages/2-sql/9-family/test/statement-planning.test.ts @@ -0,0 +1,224 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { + modelRenameStorageEffect, + planStatements, +} from '../src/core/migrations/statement-planning'; +import { + ALL_CLASSES, + contractOf, + fakeTarget, + planned, + rejection, + renameModel, +} from './statement-fixtures'; + +const ignoringCase = (left: string, right: string) => left.toLowerCase() === right.toLowerCase(); + +describe('modelRenameStorageEffect', () => { + it('is unchanged when both models map to the same table', () => { + const origin = contractOf({ Profile: { table: 'profile' } }); + const destination = contractOf({ User: { table: 'profile' } }); + const statement = renameModel('Profile', 'User'); + expect(modelRenameStorageEffect(statement, origin, destination)).toEqual( + ok({ kind: 'unchanged' }), + ); + }); + + it('is a table rename when the table name changes', () => { + const origin = contractOf({ Profile: { table: 'Profile' } }); + const destination = contractOf({ User: { table: 'User' } }); + expect(modelRenameStorageEffect(renameModel('Profile', 'User'), origin, destination)).toEqual( + ok({ kind: 'renameTable', rename: { namespaceId: 'app', from: 'Profile', to: 'User' } }), + ); + }); + + it('is a namespace move when the namespace changes', () => { + const origin = contractOf({ User: { table: 'User', namespace: 'auth' } }); + const destination = contractOf({ User: { table: 'User', namespace: 'billing' } }); + expect( + modelRenameStorageEffect(renameModel('User', 'User', 'auth', 'billing'), origin, destination), + ).toEqual( + ok({ + kind: 'moveNamespace', + from: { namespaceId: 'auth', table: 'User' }, + to: { namespaceId: 'billing', table: 'User' }, + }), + ); + }); +}); + +describe('planStatements', () => { + it('applies model renames in order and reports the operations each accounts for', () => { + const origin = contractOf({ Profile: { table: 'Profile' }, Post: { table: 'Post' } }); + const destination = contractOf({ User: { table: 'User' }, Article: { table: 'Article' } }); + const result = planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User'), renameModel('Post', 'Article')], + fromContract: origin, + contract: destination, + target: fakeTarget(['app.Profile', 'app.Post']), + }); + expect(planned(result)).toEqual({ + calls: ['table app.Profile -> User', 'table app.Post -> Article'], + tableRenames: [ + { namespaceId: 'app', from: 'Profile', to: 'User' }, + { namespaceId: 'app', from: 'Post', to: 'Article' }, + ], + columnRenames: [], + appliedStatements: [ + { + statement: renameModel('Profile', 'User'), + operationIndexes: [0, 1], + }, + { + statement: renameModel('Post', 'Article'), + operationIndexes: [2, 3], + }, + ], + }); + }); + + it('applies a statement whose table does not change with no operations', () => { + const result = planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: 'profile' } }), + contract: contractOf({ User: { table: 'profile' } }), + target: fakeTarget(['app.profile']), + }); + expect(planned(result)).toMatchObject({ + calls: [], + tableRenames: [], + appliedStatements: [{ operationIndexes: [] }], + }); + }); + + it('rejects moving a model to another namespace, naming both coordinates', () => { + const statement = renameModel('User', 'User', 'auth', 'billing'); + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [statement], + fromContract: contractOf({ User: { table: 'User', namespace: 'auth' } }), + contract: contractOf({ User: { table: 'User', namespace: 'billing' } }), + target: fakeTarget(['auth.User']), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + refusedStatement: statement, + location: { namespaceId: 'auth', entityKind: 'table', entityName: 'User' }, + }); + expect(conflict.summary).toContain('not supported in this release'); + expect(conflict.summary).toContain('"auth.User"'); + expect(conflict.summary).toContain('"billing.User"'); + }); + + it.each(['external', 'observed', 'tolerated'] as const)( + 'rejects renaming a table whose control policy is %s', + (control) => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: 'Profile' } }), + contract: contractOf({ User: { table: 'User', control } }), + target: fakeTarget(['app.Profile']), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + location: { namespaceId: 'app', entityKind: 'table', entityName: 'User' }, + }); + expect(conflict.summary).toContain(`control policy is "${control}"`); + }, + ); + + it('rejects a rename whose table the schema being planned from does not have', () => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: 'Profile' } }), + contract: contractOf({ User: { table: 'User' } }), + target: fakeTarget([]), + }), + ); + expect(conflict.kind).toBe('statementRefused'); + expect(conflict.summary).toContain('has no table "Profile"'); + }); + + it('rejects a rename whose new table the schema being planned from already has', () => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: 'Profile' } }), + contract: contractOf({ User: { table: 'User' } }), + target: fakeTarget(['app.Profile', 'app.User']), + }), + ); + expect(conflict.summary).toContain('already has a table "User"'); + }); + + it('rejects a rename onto a name another table holds in another case where the target ignores case', () => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: contractOf({ Profile: { table: 'Profile' } }), + contract: contractOf({ User: { table: 'User' } }), + target: fakeTarget(['app.Profile', 'app.user'], {}, ['widening'], ignoringCase), + }), + ); + expect(conflict.summary).toBe( + 'Cannot rename table "Profile" to "User": the schema being planned from already has a table "User", as "user"', + ); + }); + + it('plans a rename that changes only the case of the table name where the target ignores case', () => { + const result = planned( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('profile', 'Profile')], + fromContract: contractOf({ profile: { table: 'profile' } }), + contract: contractOf({ Profile: { table: 'Profile' } }), + target: fakeTarget(['app.profile'], {}, ['widening'], ignoringCase), + }), + ); + expect(result.calls).toEqual(['table app.profile -> Profile']); + }); + + it('rejects a rename whose operations the policy does not allow', () => { + const statement = renameModel('Profile', 'User'); + const conflict = rejection( + planStatements({ + policy: { allowedOperationClasses: ['additive'] }, + statements: [statement], + fromContract: contractOf({ Profile: { table: 'Profile' } }), + contract: contractOf({ User: { table: 'User' } }), + target: fakeTarget(['app.Profile']), + }), + ); + expect(conflict).toMatchObject({ + kind: 'statementRefused', + refusedStatement: statement, + refusedOperationClass: 'widening', + }); + expect(conflict.summary).toContain('does not allow "widening" operations'); + }); + + it('rejects statements when there is no origin contract', () => { + const conflict = rejection( + planStatements({ + policy: ALL_CLASSES, + statements: [renameModel('Profile', 'User')], + fromContract: null, + contract: contractOf({ User: { table: 'User' } }), + target: fakeTarget(['app.Profile']), + }), + ); + expect(conflict.kind).toBe('statementRefused'); + }); +}); diff --git a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts index 9bbfd0e17875..84b27b0129e6 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts @@ -103,6 +103,8 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -144,6 +146,8 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -195,6 +199,8 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -809,6 +815,8 @@ async function planUserTableOperations( schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [...postgresComponents, ...(options?.frameworkComponents ?? [])], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts index 6c0bf931a00f..3c31dacab7fd 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts @@ -201,6 +201,8 @@ function planFromStorages( schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -238,6 +240,8 @@ describe('contractToSchemaIR → planner round-trip', () => { schema: schemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -278,6 +282,8 @@ describe('contractToSchemaIR → planner round-trip', () => { schema: emptySchemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -347,6 +353,8 @@ describe('contractToSchemaIR → planner round-trip', () => { schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -406,6 +414,8 @@ describe('contractToSchemaIR → planner round-trip', () => { schema: schemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -975,6 +985,8 @@ describe('incremental migration with full contract surface (enums, FKs)', () => schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -1008,6 +1020,8 @@ describe('incremental migration with full contract surface (enums, FKs)', () => schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -1035,6 +1049,8 @@ describe('incremental migration with full contract surface (enums, FKs)', () => schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-extensions/pgvector/test/migrations/planner.set-default.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.set-default.test.ts index 76373cc58176..6deaf049fb50 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.set-default.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.set-default.test.ts @@ -98,6 +98,8 @@ async function setDefaultStatement(value: number[]): Promise schema: liveSchema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents: [postgresTargetDescriptor, postgresAdapterDescriptor, pgvectorDescriptor], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts index b51d8518f63f..7a2befb0b139 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.storage-types.test.ts @@ -147,6 +147,8 @@ describe('PostgresMigrationPlanner - storage types', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -207,6 +209,8 @@ describe('PostgresMigrationPlanner - storage types', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -295,6 +299,8 @@ describe('PostgresMigrationPlanner - storage types', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -366,6 +372,8 @@ describe('PostgresMigrationPlanner - storage types', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [...postgresComponents, pgvectorDescriptor], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -438,6 +446,8 @@ describe('PostgresMigrationPlanner - storage types', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-extensions/pgvector/test/migrations/vector-list-default.integration.test.ts b/packages/3-extensions/pgvector/test/migrations/vector-list-default.integration.test.ts index 43d4496702d5..45ca042dd449 100644 --- a/packages/3-extensions/pgvector/test/migrations/vector-list-default.integration.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/vector-list-default.integration.test.ts @@ -147,6 +147,8 @@ describe('a vector(3)[] literal default', { concurrent: false }, () => { schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-mongo-target/1-mongo-target/src/core/migrations/migration-factories.ts b/packages/3-mongo-target/1-mongo-target/src/core/migrations/migration-factories.ts index 9d0b9e366ada..8f46a5d5d0db 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/migrations/migration-factories.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/migrations/migration-factories.ts @@ -169,6 +169,10 @@ export function createIndex( }; } +/** + * Dropping an index loses no documents, but this stays 'destructive' on MongoDB until it moves to + * 'widening' as it has on the SQL targets. + */ export function dropIndex( collectionName: string, keys: ReadonlyArray, diff --git a/packages/3-mongo-target/1-mongo-target/src/core/migrations/mongo-planner.ts b/packages/3-mongo-target/1-mongo-target/src/core/migrations/mongo-planner.ts index c2408db60863..51f9cd6e95b9 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/migrations/mongo-planner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/migrations/mongo-planner.ts @@ -1,14 +1,18 @@ -import type { Contract } from '@internal/contract/types'; +import type { Contract, ContractWithDomain } from '@internal/contract/types'; import { contractToMongoSchemaIR } from '@internal/family-mongo/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; -import type { - MigrationOperationClass, - MigrationOperationPolicy, - MigrationPlanner, - MigrationPlannerConflict, - MigrationPlannerResult, - MigrationPlanWithAuthoringSurface, - MigrationScaffoldContext, +import { + describeMigrationStatement, + type MigrationOperationClass, + type MigrationOperationPolicy, + type MigrationPlanner, + type MigrationPlannerConflict, + type MigrationPlannerResult, + type MigrationPlanWithAuthoringSurface, + type MigrationScaffoldContext, + type ModelCoordinate, + type PlanOrigin, + type ResolvedMigrationStatement, } from '@internal/framework-components/control'; import type { MongoContract } from '@internal/mongo-contract'; import type { @@ -211,6 +215,59 @@ export type PlanCallsResult = | { readonly kind: 'success'; readonly calls: OpFactoryCall[] } | { readonly kind: 'failure'; readonly conflicts: MigrationPlannerConflict[] }; +/** The collection a model stores its documents in; a model without `@@map` names it verbatim. */ +function collectionOf(contract: ContractWithDomain | null, coordinate: ModelCoordinate): string { + const collection = + contract?.domain.namespaces[coordinate.namespaceId]?.models[coordinate.model]?.storage[ + 'collection' + ]; + return typeof collection === 'string' ? collection : coordinate.model; +} + +const NOT_IN_THIS_RELEASE = 'MongoDB cannot carry out rename statements in this release.'; + +function shellString(name: string): string { + return JSON.stringify(name); +} + +/** + * What a plan made without the statement does to the data, and how to keep it. Right for both + * `db update` and `migration plan`, since the planner does not know which command it serves. + * Mongo contracts key a model's fields by their stored names, so the statement's field names are + * the names a `$rename` needs. + */ +function keepTheData( + statement: ResolvedMigrationStatement, + fromContract: ContractWithDomain | null, + contract: ContractWithDomain, +): string { + const from = collectionOf(fromContract, statement.from); + if (statement.entity === 'field') { + const { field } = statement.from; + const newField = statement.to.field; + const collection = `db.getCollection(${shellString(from)})`; + return `${NOT_IN_THIS_RELEASE} Without the statement, the documents in collection "${from}" keep their values under "${field}", and nothing moves them to "${newField}". To move them, run these in mongosh on each database before a plan made without the statement is applied there, using the field names as they are stored. First turn off the collection's validator, which still requires "${field}": db.runCommand({ collMod: ${shellString(from)}, validationLevel: "off" }). Then drop each unique index that includes "${field}", or the move fails once two documents have lost it, for example ${collection}.dropIndex(${shellString(`${field}_1`)}). Then move the values: ${collection}.updateMany({}, { $rename: { ${shellString(field)}: ${shellString(newField)} } }). Applying the plan then creates the indexes on "${newField}" and turns the validator back on.`; + } + const to = collectionOf(contract, statement.to); + if (from === to) { + return `${NOT_IN_THIS_RELEASE} Both models store their documents in collection "${from}", so a plan made without the statement keeps them.`; + } + return `${NOT_IN_THIS_RELEASE} Without the statement, a plan drops collection "${from}" with its documents and creates collection "${to}". To keep the documents, rename the collection by hand on each database before a plan made without the statement is applied there, for example with db.getCollection(${shellString(from)}).renameCollection(${shellString(to)}) in mongosh. A migration written by migration plan without the statement still drops "${from}" wherever it is applied, so check its operations first.`; +} + +function statementNotApplied( + statement: ResolvedMigrationStatement, + fromContract: ContractWithDomain | null, + contract: ContractWithDomain, +): MigrationPlannerConflict { + return { + kind: 'statementRefused', + summary: `MongoDB does not apply rename statements in this release, so nothing was planned: ${describeMigrationStatement(statement, fromContract ?? contract, contract)}`, + why: keepTheData(statement, fromContract, contract), + refusedStatement: statement, + }; +} + export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> { planCalls(options: { readonly contract: unknown; @@ -356,12 +413,12 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> readonly contract: unknown; readonly schema: unknown; readonly policy: MigrationOperationPolicy; - /** - * The "from" contract (state the planner assumes the database starts at), - * or `null` for reconciliation flows. Used to populate `describe().from` - * on the produced plan as `fromContract?.storage.storageHash ?? null`. - */ + /** The contract the planner reads as the starting state, or `null` when it has none. */ readonly fromContract: Contract | null; + /** The origin the produced plan asserts: its `describe().from` and `origin`. */ + readonly origin: PlanOrigin | null; + /** The `--rename` statements, resolved; MongoDB refuses any statement in this release. */ + readonly statements: readonly ResolvedMigrationStatement[]; readonly frameworkComponents: ReadonlyArray>; /** * POSIX-relative path from the migration package dir to @@ -374,6 +431,13 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> MongoContract, 'framework planner passes the Mongo contract selected for the mongo target' >(options.contract); + const [statement] = options.statements; + if (statement !== undefined) { + return { + kind: 'failure', + conflicts: [statementNotApplied(statement, options.fromContract, contract)], + }; + } const result = this.planCalls(options); if (result.kind === 'failure') return result; return { @@ -381,11 +445,12 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> plan: new PlannerProducedMongoMigration( result.calls, { - from: options.fromContract?.storage.storageHash ?? null, + from: options.origin?.storageHash ?? null, to: contract.storage.storageHash, }, options.snapshotsImportPath, ), + appliedStatements: [], }; } diff --git a/packages/3-mongo-target/1-mongo-target/test/db-update-statements.test.ts b/packages/3-mongo-target/1-mongo-target/test/db-update-statements.test.ts new file mode 100644 index 000000000000..2dd443e16e02 --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/test/db-update-statements.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { executeDbUpdate } from '@internal/cli/control-api'; +import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; +import type { + ControlAdapterInstance, + ControlDriverInstance, + ControlFamilyInstance, + TargetMigrationsCapability, +} from '@internal/framework-components/control'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { MongoSchemaCollection, MongoSchemaIR } from '@internal/mongo-schema-ir'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; +import { mongoTargetDescriptor } from '../src/core/migrations/control-target'; + +const ORIGIN_HASH = 'a'.repeat(64); +const DESTINATION_HASH = 'b'.repeat(64); + +function contractWithModel(storageHash: string, model: string): Contract { + return { + target: 'mongo', + targetFamily: 'mongo', + storage: { storageHash, namespaces: {} }, + domain: { + namespaces: { app: { models: { [model]: { fields: {}, relations: {}, storage: {} } } } }, + }, + } as unknown as Contract; +} + +const origin = contractWithModel(ORIGIN_HASH, 'Profile'); +const destination = contractWithModel(DESTINATION_HASH, 'User'); + +const marker: ContractMarkerRecord = { + storageHash: ORIGIN_HASH, + profileHash: '', + contractJson: null, + canonicalVersion: null, + updatedAt: new Date(0), + appTag: null, + meta: {}, + invariants: [], +}; + +const family = { + familyId: 'mongo', + readAllMarkers: async () => new Map([['app', marker]]), + introspect: async () => new MongoSchemaIR([new MongoSchemaCollection({ name: 'Profile' })]), + deserializeContract: (json: unknown) => json as Contract, + toOperationPreview: () => ({ statements: [] }), +} as unknown as ControlFamilyInstance<'mongo', unknown>; + +const tempDirs: string[] = []; +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +describe('db update with statements on MongoDB', () => { + it('fails planning with the statement the Mongo planner refuses', async () => { + const dir = await mkdtemp(join(tmpdir(), 'mongo-db-update-statements-')); + tempDirs.push(dir); + const migrationsDir = join(dir, 'migrations'); + await writeContractSnapshot(migrationsDir, ORIGIN_HASH, { + contractJson: origin, + contractDts: '', + }); + + const result = await executeDbUpdate({ + driver: { + close: async () => {}, + databaseName: async () => 'appdb', + } as unknown as ControlDriverInstance<'mongo', 'mongo'>, + adapter: {} as unknown as ControlAdapterInstance<'mongo', 'mongo'>, + familyInstance: family, + contract: destination, + mode: 'plan', + migrations: mongoTargetDescriptor.migrations as unknown as TargetMigrationsCapability< + 'mongo', + 'mongo', + ControlFamilyInstance<'mongo', unknown> + >, + frameworkComponents: [], + migrationsDir, + targetId: 'mongo', + statements: [{ verb: 'rename', text: 'Profile:User' }], + }); + + expect(result).toMatchObject({ + ok: false, + failure: { + code: 'PLANNING_FAILED', + conflicts: [ + { + kind: 'statementRefused', + summary: expect.stringContaining('MongoDB does not apply rename statements'), + refusedStatement: { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + }, + ], + }, + }); + }); +}); diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.statements.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.statements.test.ts new file mode 100644 index 000000000000..387c18e25a46 --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.statements.test.ts @@ -0,0 +1,128 @@ +import { asNamespaceId } from '@internal/contract/types'; +import type { + MigrationOperationPolicy, + ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { MongoCollection, type MongoContract } from '@internal/mongo-contract'; +import { MongoSchemaCollection, MongoSchemaIR } from '@internal/mongo-schema-ir'; +import { describe, expect, it } from 'vitest'; +import { MongoMigrationPlanner } from '../src/core/migrations/mongo-planner'; + +const ALL_CLASSES_POLICY: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], +}; + +const NAMESPACE = asNamespaceId('__unbound__'); + +function contractWithModel(model: string, collection: string): MongoContract { + return { + target: 'mongo', + targetFamily: 'mongo', + profileHash: 'test-profile', + capabilities: {}, + extensions: {}, + meta: {}, + roots: {}, + domain: { + namespaces: { + __unbound__: { + models: { [model]: { fields: {}, relations: {}, storage: { collection } } }, + }, + }, + }, + storage: { + storageHash: `storage-${collection}`, + namespaces: { + __unbound__: { + id: '__unbound__', + kind: 'mongo-namespace', + entries: { collection: { [collection]: new MongoCollection({}) } }, + }, + }, + }, + } as unknown as MongoContract; +} + +const modelRename: ResolvedMigrationStatement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: NAMESPACE, model: 'Profile' }, + to: { namespaceId: NAMESPACE, model: 'User' }, +}; + +const fieldRename: ResolvedMigrationStatement = { + kind: 'rename', + entity: 'field', + from: { namespaceId: NAMESPACE, model: 'Profile', field: 'name' }, + to: { namespaceId: NAMESPACE, model: 'User', field: 'fullName' }, +}; + +function plan( + statements: readonly ResolvedMigrationStatement[], + collections: { readonly from: string; readonly to: string } = { from: 'profiles', to: 'users' }, +) { + return new MongoMigrationPlanner().plan({ + contract: contractWithModel('User', collections.to), + schema: new MongoSchemaIR([new MongoSchemaCollection({ name: collections.from })]), + policy: ALL_CLASSES_POLICY, + fromContract: contractWithModel('Profile', collections.from), + origin: null, + statements, + frameworkComponents: [], + snapshotsImportPath: '../../snapshots', + }); +} + +describe('MongoMigrationPlanner with statements', () => { + it('plans the rename as a collection drop and create when no statement is given', async () => { + const result = plan([]); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') throw new Error('Expected success'); + const operations = await Promise.all(result.plan.operations); + expect(operations.map((operation) => operation.id)).toEqual([ + 'collection.users.create', + 'collection.profiles.drop', + ]); + expect(result.appliedStatements).toEqual([]); + }); + + it('refuses the first statement, plans nothing, and says how to keep the documents', () => { + expect(plan([modelRename, fieldRename])).toEqual({ + kind: 'failure', + conflicts: [ + { + kind: 'statementRefused', + summary: + 'MongoDB does not apply rename statements in this release, so nothing was planned: rename model "Profile" to "User"', + why: 'MongoDB cannot carry out rename statements in this release. Without the statement, a plan drops collection "profiles" with its documents and creates collection "users". To keep the documents, rename the collection by hand on each database before a plan made without the statement is applied there, for example with db.getCollection("profiles").renameCollection("users") in mongosh. A migration written by migration plan without the statement still drops "profiles" wherever it is applied, so check its operations first.', + refusedStatement: modelRename, + }, + ], + }); + }); + + it('names a field through the model the destination contract names, and says how to move its values', () => { + const result = plan([fieldRename]); + if (result.kind !== 'failure') throw new Error('Expected failure'); + expect(result.conflicts).toMatchObject([ + { + kind: 'statementRefused', + summary: expect.stringContaining('rename field "User.name" to "User.fullName"'), + why: 'MongoDB cannot carry out rename statements in this release. Without the statement, the documents in collection "profiles" keep their values under "name", and nothing moves them to "fullName". To move them, run these in mongosh on each database before a plan made without the statement is applied there, using the field names as they are stored. First turn off the collection\'s validator, which still requires "name": db.runCommand({ collMod: "profiles", validationLevel: "off" }). Then drop each unique index that includes "name", or the move fails once two documents have lost it, for example db.getCollection("profiles").dropIndex("name_1"). Then move the values: db.getCollection("profiles").updateMany({}, { $rename: { "name": "fullName" } }). Applying the plan then creates the indexes on "fullName" and turns the validator back on.', + refusedStatement: fieldRename, + }, + ]); + }); + + it('says a plan without the statement keeps the documents when both models use one collection', () => { + const result = plan([modelRename], { from: 'posts', to: 'posts' }); + if (result.kind !== 'failure') throw new Error('Expected failure'); + expect(result.conflicts).toMatchObject([ + { + kind: 'statementRefused', + why: 'MongoDB cannot carry out rename statements in this release. Both models store their documents in collection "posts", so a plan made without the statement keeps them.', + refusedStatement: modelRename, + }, + ]); + }); +}); diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts index 4082fba41e4c..a0ec1ff56219 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts @@ -1,5 +1,6 @@ import { coreHash, type JsonValue } from '@internal/contract/types'; import type { MigrationOperationPolicy } from '@internal/framework-components/control'; +import { planOriginOf } from '@internal/framework-components/control'; import { keepInternalSpecifiers } from '@internal/framework-components/emission'; import { buildMongoNamespace, @@ -111,6 +112,8 @@ function planSuccess( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -318,6 +321,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ADDITIVE_ONLY_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -349,6 +354,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ADDITIVE_ONLY_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -373,6 +380,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ADDITIVE_ONLY_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1244,6 +1253,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1264,6 +1275,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1287,6 +1300,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1314,6 +1329,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1343,6 +1360,8 @@ describe('MongoMigrationPlanner', () => { schema: origin, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1363,6 +1382,8 @@ describe('MongoMigrationPlanner', () => { schema: emptyIR(), policy: { allowedOperationClasses: [] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1721,6 +1742,8 @@ describe('MongoMigrationPlanner', () => { schema: emptyIR(), policy: ALL_CLASSES_POLICY, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -1736,6 +1759,8 @@ describe('MongoMigrationPlanner', () => { schema: emptyIR(), policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/packages/3-mongo-target/1-mongo-target/test/render-ops.test.ts b/packages/3-mongo-target/1-mongo-target/test/render-ops.test.ts index b09ac8db7c7c..fb73400076ee 100644 --- a/packages/3-mongo-target/1-mongo-target/test/render-ops.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/render-ops.test.ts @@ -176,6 +176,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -209,6 +211,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -246,6 +250,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -279,6 +285,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -325,6 +333,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -381,6 +391,8 @@ describe('renderOps', () => { schema, policy: ALL_CLASSES_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/column-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/column-rename-constraint-renames.ts new file mode 100644 index 000000000000..dabf448d4f83 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/column-rename-constraint-renames.ts @@ -0,0 +1,22 @@ +import type { RenameConstraintCall } from './op-factory-call'; +import { + pairedConstraintRenames, + type TableRenameConstraintInput, +} from './table-rename-constraint-renames'; + +/** + * The constraint renames that follow a column rename. Each unique constraint and foreign key on + * the renamed column is paired with the destination constraint of the same kind on the same + * columns, and renamed to the destination's explicit name, or else to the name the planner derives + * from the table and the new column names, when that differs from its name in the database. The + * primary key's derived name does not depend on its columns, so it is left alone. `previous` is + * the table after the column rename, its constraint names as they are in the database. + */ +export function constraintRenamesForColumnRename( + input: TableRenameConstraintInput & { readonly column: string }, +): readonly RenameConstraintCall[] { + return pairedConstraintRenames(input, { + primaryKey: false, + onColumns: (columns) => columns.includes(input.column), + }); +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts index e1c0d6722e67..88f008d0e0be 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/index-and-check-renames.ts @@ -146,8 +146,8 @@ export function pairIndexRenames( * drop + add. * * Runs only when the policy allows `widening` (rename's class). Without it - * the pass no-ops and the pair degrades to the slice-1 behavior: an add, - * plus a drop when `destructive` is allowed too. + * the pass no-ops and the pair degrades to an add and a drop, and the drop, + * which is `widening` too, is refused as a conflict. */ export function pairCheckRenames( options: Pick, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 2c746eddc239..02fa9ae01b66 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -178,6 +178,7 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory { case 'dropRlsPolicy': return 'drop'; case 'addColumn': + case 'renameColumn': return 'column'; case 'alterColumnType': case 'setNotNull': @@ -533,7 +534,7 @@ function nativeEnumMemberChangeRefusal(options: { /** * Managed native-enum issue -> op lowering. A missing declared type creates * it; an unclaimed live type drops it (ownership-scoped upstream by - * `retainUnownedExtras`, destructiveness gated by the operation-class + * `retainUnownedExtras`, and the `widening` drop gated by the operation-class * policy); a paired member-value mismatch lowers to one `ALTER TYPE ... ADD * VALUE` per appended member when the database's members are a strict, * order-preserving prefix of the contract's — any other change (rename, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index c46034030e6f..3de2f614c797 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -8,7 +8,7 @@ * * - `toOp()` — converts the IR node to a runtime * `SqlMigrationPlanOperation` by delegating to the matching pure factory - * under `operations/`. `DataTransformCall.toOp()` always throws + * under `operations/`. `DataTransformCall.toOp()` always rejects with * `MIGRATION.UNFILLED_PLACEHOLDER` because a planner-generated data transform is an * unfilled authoring stub by construction. * - `renderTypeScript()` / `importRequirements()` — inherited from @@ -20,7 +20,7 @@ * `PostgresOpFactoryCall` union. */ -import { errorUnfilledPlaceholder } from '@internal/errors/migration'; +import { unfilledPlaceholderOperation } from '@internal/errors/migration'; import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer, Lowerer } from '@internal/family-sql/control-adapter'; import type { @@ -72,6 +72,7 @@ import { dropColumn, dropDefault, dropNotNull, + renameColumn, setDefault, setNotNull, } from './operations/columns'; @@ -127,6 +128,11 @@ abstract class PostgresOpFactoryCallNode extends TsExpression implements Framewo abstract readonly label: string; abstract toOp(lowerer?: Lowerer): Op | Promise; + /** The operations this call lowers to, in order: one, unless the call carries companions. */ + toOps(lowerer?: ExecuteRequestLowerer): readonly (Op | Promise)[] { + return [this.toOp(lowerer)]; + } + importRequirements(): readonly ImportRequirement[] { return [{ moduleSpecifier: POSTGRES_MIGRATION_FACADE, symbol: this.factoryName }]; } @@ -136,6 +142,15 @@ abstract class PostgresOpFactoryCallNode extends TsExpression implements Framewo } } +export type { PostgresOpFactoryCallNode }; + +/** Whether `call` is one of this target's calls, which lower through {@link PostgresOpFactoryCallNode.toOps}. */ +export function isPostgresOpFactoryCall( + call: FrameworkOpFactoryCall, +): call is PostgresOpFactoryCallNode { + return call instanceof PostgresOpFactoryCallNode; +} + // ============================================================================ // Table // ============================================================================ @@ -368,6 +383,9 @@ export class DropTableCall extends PostgresOpFactoryCallNode { } } +/** A rename a table or column rename carries for an object whose name derives from the old name. */ +export type RenameCompanionCall = RenameConstraintCall | RenameIndexCall; + export class RenameTableCall extends PostgresOpFactoryCallNode { readonly factoryName = 'renameTable' as const; // `widening` for the same reason as `RenameConstraintCall`: a rename is @@ -380,16 +398,31 @@ export class RenameTableCall extends PostgresOpFactoryCallNode { /** The new name: the table's contract-side identity after the rename. */ readonly tableName: string; readonly label: string; + /** + * The renames of objects whose names derive from the table name. They run after the table rename + * and are never rendered on their own. + */ + readonly companions: readonly RenameCompanionCall[]; - constructor(schemaName: string, oldTableName: string, tableName: string) { + constructor( + schemaName: string, + oldTableName: string, + tableName: string, + companions: readonly RenameCompanionCall[], + ) { super(); this.schemaName = schemaName; this.oldTableName = oldTableName; this.tableName = tableName; this.label = `Rename table "${oldTableName}" to "${tableName}"`; + this.companions = Object.freeze([...companions]); this.freeze(); } + override toOps(lowerer?: ExecuteRequestLowerer): readonly (Op | Promise)[] { + return [this.toOp(lowerer), ...this.companions.map((companion) => companion.toOp(lowerer))]; + } + async toOp(lowerer?: ExecuteRequestLowerer): Promise { if (lowerer === undefined) { throw postgresError( @@ -489,6 +522,76 @@ export class AddColumnCall extends PostgresOpFactoryCallNode { } } +export class RenameColumnCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'renameColumn' as const; + // `widening` for the same reason as `RenameTableCall`. + readonly operationClass = 'widening' as const; + readonly schemaName: string; + readonly tableName: string; + readonly oldColumnName: string; + /** The new name: the column's contract-side identity after the rename. */ + readonly columnName: string; + readonly label: string; + /** + * The renames of objects whose names derive from the column name. They run after the column + * rename and are never rendered on their own. + */ + readonly companions: readonly RenameCompanionCall[]; + + constructor( + schemaName: string, + tableName: string, + oldColumnName: string, + columnName: string, + companions: readonly RenameCompanionCall[], + ) { + super(); + this.schemaName = schemaName; + this.tableName = tableName; + this.oldColumnName = oldColumnName; + this.columnName = columnName; + this.label = `Rename column "${tableName}"."${oldColumnName}" to "${columnName}"`; + this.companions = Object.freeze([...companions]); + this.freeze(); + } + + override toOps(lowerer?: ExecuteRequestLowerer): readonly (Op | Promise)[] { + return [this.toOp(lowerer), ...this.companions.map((companion) => companion.toOp(lowerer))]; + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw postgresError( + 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + `RenameColumnCall.toOp: a lowerer is required on the Postgres planner path (column "${this.oldColumnName}" on table "${this.tableName}"). Pass the control adapter to createPostgresMigrationPlanner.`, + { meta: { factory: 'RenameColumnCall' } }, + ); + } + return renameColumn( + this.schemaName, + this.tableName, + this.oldColumnName, + this.columnName, + lowerer, + ); + } + + renderTypeScript(): string { + const opts: string[] = []; + if (this.schemaName !== UNBOUND_NAMESPACE_ID) { + opts.push(`schema: ${jsonToTsSource(this.schemaName)}`); + } + opts.push(`table: ${jsonToTsSource(this.tableName)}`); + opts.push(`column: ${jsonToTsSource(this.oldColumnName)}`); + opts.push(`to: ${jsonToTsSource(this.columnName)}`); + return `...this.renameColumn({ ${opts.join(', ')} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + export class DropColumnCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropColumn' as const; readonly operationClass = 'destructive' as const; @@ -737,7 +840,7 @@ export class SetDefaultCall extends PostgresOpFactoryCallNode { export class DropDefaultCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropDefault' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly columnName: string; @@ -1050,7 +1153,7 @@ export class AddForeignKeyCall extends PostgresOpFactoryCallNode { export class DropConstraintCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropConstraint' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly constraintName: string; @@ -1210,7 +1313,7 @@ export class AddCheckConstraintCall extends PostgresOpFactoryCallNode { export class DropCheckConstraintCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropCheckConstraint' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly constraintName: string; @@ -1409,7 +1512,7 @@ export class RenameIndexCall extends PostgresOpFactoryCallNode { export class DropIndexCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropIndex' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly indexName: string; @@ -1614,7 +1717,7 @@ export class CreateNativeEnumTypeCall extends PostgresOpFactoryCallNode { export class DropNativeEnumTypeCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropNativeEnumType' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly typeName: string; readonly label: string; @@ -1757,7 +1860,7 @@ export class AddNativeEnumValueCall extends PostgresOpFactoryCallNode { /** * A planner-generated data-transform stub. `checkSlot` and `runSlot` name * the unfilled authoring slots that the rendered `migration.ts` will expose - * to the user via `placeholder("…")` calls. `toOp()` always throws + * to the user via `placeholder("…")` calls. `toOp()` always rejects with * `MIGRATION.UNFILLED_PLACEHOLDER`: the planner cannot lower a stubbed transform to a runtime * op — the user must fill the rendered `migration.ts` and re-emit. */ @@ -1782,8 +1885,8 @@ export class DataTransformCall extends PostgresOpFactoryCallNode { this.freeze(); } - toOp(): Op { - throw errorUnfilledPlaceholder(this.label); + toOp(): Promise { + return unfilledPlaceholderOperation(this.label); } renderTypeScript(): string { @@ -1872,7 +1975,7 @@ export class CreatePostgresRlsPolicyCall extends PostgresOpFactoryCallNode { export class DropPostgresRlsPolicyCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropRlsPolicy' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly policyName: string; @@ -1944,7 +2047,7 @@ export class EnableRowLevelSecurityCall extends PostgresOpFactoryCallNode { export class DisableRowLevelSecurityCall extends PostgresOpFactoryCallNode { readonly factoryName = 'disableRowLevelSecurity' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly label: string; @@ -2031,6 +2134,7 @@ export type PostgresOpFactoryCall = | DropTableCall | RenameTableCall | AddColumnCall + | RenameColumnCall | DropColumnCall | AlterColumnTypeCall | SetNotNullCall diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/columns.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/columns.ts index de81065de557..deddbb6e9062 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/columns.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/columns.ts @@ -56,6 +56,58 @@ export async function dropColumn( }; } +export function renameColumnStatement( + schemaName: string, + tableName: string, + fromName: string, + toName: string, +): string { + return `ALTER TABLE ${qualifyTableName(schemaName, tableName)} RENAME COLUMN ${quoteIdentifier(fromName)} TO ${quoteIdentifier(toName)}`; +} + +export function renameColumnOperationId(tableName: string, fromName: string): string { + return `renameColumn.${tableName}.${fromName}`; +} + +export async function renameColumn( + schemaName: string, + tableName: string, + fromName: string, + toName: string, + lowerer: ExecuteRequestLowerer, +): Promise { + const from = await columnExistsSteps(lowerer, { + schema: schemaName, + table: tableName, + column: fromName, + }); + const to = await columnExistsSteps(lowerer, { + schema: schemaName, + table: tableName, + column: toName, + }); + return { + id: renameColumnOperationId(tableName, fromName), + label: `Rename column "${tableName}"."${fromName}" to "${toName}"`, + operationClass: 'widening', + target: targetDetails('column', toName, schemaName, tableName), + precheck: [ + step(`ensure column "${fromName}" exists`, from.present.sql, from.present.params), + step(`ensure column "${toName}" does not exist`, to.absent.sql, to.absent.params), + ], + execute: [ + step( + `rename column "${fromName}" to "${toName}"`, + renameColumnStatement(schemaName, tableName, fromName, toName), + ), + ], + postcheck: [ + step(`verify column "${toName}" exists`, to.present.sql, to.present.params), + step(`verify column "${fromName}" no longer exists`, from.absent.sql, from.absent.params), + ], + }; +} + /** * `qualifiedTargetType` is the new column type as it appears in the * `ALTER COLUMN TYPE` clause (schema-qualified for user-defined types, raw @@ -295,7 +347,7 @@ export async function dropDefault( return { id: `dropDefault.${tableName}.${columnName}`, label: `Drop default on "${tableName}"."${columnName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('column', columnName, schemaName, tableName), precheck: [step(`ensure column "${columnName}" exists`, present.sql, present.params)], execute: [step(`drop default on "${columnName}"`, dropDefaultExec.sql)], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts index 1789e3746317..7f473b5bd0ee 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/constraints.ts @@ -180,6 +180,15 @@ export function renameConstraintLabel( return `Rename ${CONSTRAINT_KIND_LABEL[kind]} "${fromName}" to "${toName}" on "${tableName}"`; } +export function renameConstraintOperationId( + kind: RenamableConstraintKind, + schemaName: string, + tableName: string, + fromName: string, +): string { + return `${kind}.${schemaName}.${tableName}.${fromName}.rename`; +} + export async function renameConstraint( schemaName: string, tableName: string, @@ -202,7 +211,7 @@ export async function renameConstraint( table: tableName, }); return { - id: `${kind}.${schemaName}.${tableName}.${fromName}.rename`, + id: renameConstraintOperationId(kind, schemaName, tableName, fromName), label: renameConstraintLabel(kind, fromName, toName, tableName), operationClass: 'widening', // The NEW name is the constraint's contract-side identity — the rename @@ -237,7 +246,7 @@ export async function dropCheckConstraint( return { id: `dropCheckConstraint.${tableName}.${constraintName}`, label: `Drop check constraint "${constraintName}" on "${tableName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('checkConstraint', constraintName, schemaName, tableName), precheck: [step(`ensure constraint "${constraintName}" exists`, present.sql, present.params)], execute: [ @@ -275,7 +284,7 @@ export async function dropConstraint( return { id: `dropConstraint.${tableName}.${constraintName}`, label: `Drop constraint "${constraintName}" on "${tableName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails(kind, constraintName, schemaName, tableName), precheck: [step(`ensure constraint "${constraintName}" exists`, present.sql, present.params)], execute: [ diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts index 8cc5af7c2af5..616aed1e7559 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/indexes.ts @@ -76,6 +76,14 @@ export async function createIndex( * and the class vocabulary has no neutral middle class — it is NOT that a * rename widens anything. */ +export function renameIndexOperationId( + schemaName: string, + tableName: string, + fromName: string, +): string { + return `index.${schemaName}.${tableName}.${fromName}.rename`; +} + export async function renameIndex( schemaName: string, tableName: string, @@ -95,7 +103,7 @@ export async function renameIndex( }); const execute = await lowerer.lowerToExecuteRequest(ddlNode); return { - id: `index.${schemaName}.${tableName}.${fromName}.rename`, + id: renameIndexOperationId(schemaName, tableName, fromName), label: `Rename index "${fromName}" to "${toName}" on "${tableName}"`, operationClass: 'widening', target: targetDetails('index', toName, schemaName, tableName), @@ -120,7 +128,7 @@ export async function dropIndex( return { id: `dropIndex.${tableName}.${indexName}`, label: `Drop index "${indexName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('index', indexName, schemaName, tableName), precheck: [step(`ensure index "${indexName}" exists`, present.sql, present.params)], execute: [step(`drop index "${indexName}"`, execute.sql, execute.params)], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/native-enum-types.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/native-enum-types.ts index 766340237da2..803deb2b1257 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/native-enum-types.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/native-enum-types.ts @@ -53,7 +53,7 @@ export async function dropNativeEnumType( return { id: `dropNativeEnumType.${typeName}`, label: `Drop enum type "${typeName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('type', typeName, schemaName), precheck: [], execute: [step(`drop enum type "${typeName}"`, statement.sql, statement.params)], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts index ba6fbc364971..005daf43be4c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/rls.ts @@ -77,7 +77,7 @@ export async function dropRlsPolicy( return { id: `rlsPolicy.${schemaName}.${tableName}.${policyName}.drop`, label: `Drop RLS policy "${policyName}" on "${tableName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('rlsPolicy', policyName, schemaName, tableName), precheck: [step(`ensure RLS policy "${policyName}" exists`, present.sql, present.params)], execute: [step(`drop RLS policy "${policyName}"`, execute.sql, execute.params)], @@ -126,7 +126,7 @@ export async function disableRowLevelSecurity( return { id: `rowLevelSecurity.${schemaName}.${tableName}.disable`, label: `Disable row-level security on "${tableName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('rowLevelSecurity', tableName, schemaName), precheck: [ step(`check RLS is currently enabled on "${tableName}"`, enabled.sql, enabled.params), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts index 1bd746a44b7b..a2be02ee4061 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/tables.ts @@ -28,6 +28,10 @@ export function renameTableStatement(schemaName: string, fromName: string, toNam return `ALTER TABLE ${qualifyTableName(schemaName, fromName)} RENAME TO ${quoteIdentifier(toName)}`; } +export function renameTableOperationId(fromName: string): string { + return `renameTable.${fromName}`; +} + export async function renameTable( schemaName: string, fromName: string, @@ -41,7 +45,7 @@ export async function renameTable( const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); return { - id: `renameTable.${fromName}`, + id: renameTableOperationId(fromName), label: `Rename table "${fromName}" to "${toName}"`, operationClass: 'widening', target: targetDetails('table', toName, schemaName), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-produced-postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-produced-postgres-migration.ts index 0385848df2ed..95860ad702e2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-produced-postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-produced-postgres-migration.ts @@ -13,10 +13,9 @@ * shape and keeps CLI wiring one step removed from target internals. * * Placeholder-bearing plans: `renderTypeScript()` always succeeds and embeds - * `() => placeholder("slot")` at each stub. `operations`, in contrast, is - * _not safe to enumerate_ on a stub-bearing plan — `DataTransformCall.toOp()` - * throws `MIGRATION.UNFILLED_PLACEHOLDER` because a planner-stubbed closure cannot be lowered - * to a runtime op. Callers that know a plan may carry stubs must render to + * `() => placeholder("slot")` at each stub. `operations`, in contrast, holds a promise that + * rejects with `MIGRATION.UNFILLED_PLACEHOLDER` for each stub, because a planner-stubbed closure + * cannot be lowered to a runtime op; the other operations resolve. Callers that know a plan may carry stubs must render to * `migration.ts`, let the user fill the slots, and re-load the edited * migration before enumerating ops. The walk-schema planner does not emit * `DataTransformCall`s today, so this asymmetry is invisible until the diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 3f16f2ffbbda..b25ca1a4672d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -1,6 +1,7 @@ import type { Contract } from '@internal/contract/types'; import type { MigrationOperationPolicy, + PlannedStatements, SqlMigrationPlannerPlanOptions, SqlPlannerConflict, SqlPlannerFailureResult, @@ -14,15 +15,19 @@ import { partitionIssuesByControlPolicy, planFieldEventOperations, plannerFailure, + planStatements, sqlTypeLookupsOf, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { + AppliedMigrationStatement, MigrationOperationClass, MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, + PlanOrigin, + ResolvedMigrationStatement, SchemaDiffIssue, SchemaOwnership, } from '@internal/framework-components/control'; @@ -33,6 +38,7 @@ import { namingOf, parseWireName } from '@internal/sql-schema-ir/naming'; import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; +import { ok, type Result } from '@internal/utils/result'; import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; import { PostgresRlsPolicy } from '../postgres-rls-policy'; import { postgresNodeStorageCoordinate } from '../schema-ir/node-storage-coordinate'; @@ -65,15 +71,22 @@ import { DropPostgresRlsPolicyCall, DropTableCall, RawSqlCall, + type RenameColumnCall, RenamePostgresRlsPolicyCall, - RenameTableCall, + type RenameTableCall, } from './op-factory-call'; -import { renameTableStatement } from './operations/tables'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; -import { emissionSchemaForNamespace } from './table-rename-calls'; +import { postgresSchemaTables } from './schema-tables'; +import { + postgresColumnRenameCall, + postgresTableRenameCall, + renameTableByHandStatements, + renderRenameTableCall, +} from './table-rename-calls'; import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces'; +import { createWorkingSchema } from './working-schema'; type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends { readonly frameworkComponents: infer T; @@ -115,6 +128,7 @@ export type PostgresPlanResult = readonly kind: 'success'; readonly plan: TypeScriptRenderablePostgresMigration; readonly warnings?: readonly SqlPlannerConflict[]; + readonly appliedStatements: readonly AppliedMigrationStatement[]; } | SqlPlannerFailureResult; @@ -157,11 +171,12 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * * Typed as the framework `Contract | null` to satisfy the * `MigrationPlanner` interface contract; `planSql` narrows to the SQL - * shape via `SqlMigrationPlannerPlanOptions`. Used to populate - * `describe().from` on the produced plan as - * `fromContract?.storage.storageHash ?? null`. + * shape via `SqlMigrationPlannerPlanOptions`. */ readonly fromContract: Contract | null; + /** The origin the produced plan asserts: its `describe().from` and `origin`. */ + readonly origin: PlanOrigin | null; + readonly statements: readonly ResolvedMigrationStatement[]; readonly schemaName?: string; readonly frameworkComponents: ReadonlyArray>; /** @@ -222,9 +237,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // (`diffSchema`) over the same schema and rejects on a // surviving failure. PostgresDatabaseSchemaNode.assert(options.schema); + const statements = this.planStatements(options, options.schema); + if (!statements.ok) { + return plannerFailure([statements.failure]); + } + const { schema } = statements.value; const { issues: rawIssues } = buildPostgresPlanDiff({ contract: options.contract, - actualSchema: options.schema, + actualSchema: schema, frameworkComponents: options.frameworkComponents, }); const policyDiffIssues = rawIssues.filter((issue) => isPolicyDiffIssue(issue)); @@ -271,7 +291,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // sibling-space scoping, matching the retired coordinate walk exactly. const namespaceIssues = verifyPostgresNamespacePresence({ contract: options.contract, - schema: options.schema, + schema, }); const schemaIssues = [...namespaceIssues, ...gated]; @@ -297,7 +317,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // per-schema namespace node — never the whole tree root, and never a flat // merge of every namespace (which would collide same-named tables across // schemas). Probing more than one namespace at once is future work. - const relationalSchema = relationalNamespaceNode(options.schema, schemaName); + const relationalSchema = relationalNamespaceNode(schema, schemaName); // Input-side control-policy partition. `external` / `observed` subjects // — and non-creation issues for `tolerated` subjects — are dropped from @@ -325,19 +345,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr }, namespaceIdOf: (issue) => resolveNamespaceIdForDdlSchema(options.contract, issueSchemaName(issue) ?? schemaName), - renameByHandStatements: (rename) => [ - renameTableStatement( - emissionSchemaForNamespace(options.contract, rename.namespaceId), - rename.from, - rename.to, - ), - ], - renameTableCall: (rename) => - new RenameTableCall( - rename.namespaceId ?? UNBOUND_NAMESPACE_ID, - rename.from, - rename.to, - ).renderTypeScript(), + renameByHandStatements: (rename) => renameTableByHandStatements(options.contract, rename), + renameTableCall: renderRenameTableCall, contract: options.contract, defaultNamespaceId: DEFAULT_NAMESPACE_ID, }); @@ -390,6 +399,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr priorContract: options.fromContract, newContract: options.contract, codecHooks, + tableRenames: statements.value.tableRenames, + columnRenames: statements.value.columnRenames, }); // Codec hook ops are target-agnostic `OpFactoryCall`; Postgres planning // lifts them at this integration boundary (see field-event-planner JSDoc). @@ -403,6 +414,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ); const ordered = movePolicyDropsBeforeBlockedDdl(result.value.calls, schemaDiffPartition.kept); const calls = [ + ...statements.value.calls, ...ordered.structural, ...indexRenamePartition.kept, ...ordered.policyCalls, @@ -433,13 +445,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr plan: new TypeScriptRenderablePostgresMigration( calls, { - from: options.fromContract?.storage.storageHash ?? null, + from: options.origin?.storageHash ?? null, to: options.contract.storage.storageHash, }, options.spaceId, options.snapshotsImportPath, this.#lowerer, ), + appliedStatements: statements.value.appliedStatements, ...(warnings.length > 0 ? { warnings: Object.freeze(warnings) } : {}), }); } @@ -450,7 +463,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * `CREATE POLICY` / `DROP POLICY` / `ALTER POLICY … RENAME TO` ops — a * `not-equal` finding (an exact-named policy whose content drifted) * becomes drop + create, or a disallowed-call conflict when the policy - * forbids the destructive drop. It + * forbids the drop, which is `widening`. It * does not re-diff — it consumes exactly the policy-node subset of the * shared diff's issues. Enablement is NOT decided here: `ENABLE`/`DISABLE * ROW LEVEL SECURITY` derive from the table's marker-driven `rlsEnabled` @@ -468,7 +481,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr * The pairing runs only when the policy allows `widening` (rename's * class). Without it (db-init's additive-only set), pairing degrades * deliberately to the additive half: the new name is CREATEd and the old - * policy survives live until a widening/destructive-allowed plan runs — + * policy survives live until a plan that allows `widening` runs — * emitting an ungated widening rename would only fail at the runner's * class re-enforcement. */ @@ -480,7 +493,6 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr readonly conflicts: readonly SqlPlannerConflict[]; readonly suppressions: readonly SuppressionRecord[]; } { - const allowsDestructive = options.policy.allowedOperationClasses.includes('destructive'); const allowsWidening = options.policy.allowedOperationClasses.includes('widening'); interface PolicyFinding { @@ -655,7 +667,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ), ); } - if (allowsDestructive) { + if (allowsWidening) { for (const finding of extra) { if (renamedExtras.has(finding)) continue; calls.push( @@ -671,6 +683,50 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr return { calls, conflicts, suppressions }; } + /** + * Plans the statements against a working copy of `schema`, in order, so each rename's companions + * are computed against the schema the earlier statements left. The diff then runs on that schema. + */ + private planStatements( + options: SqlMigrationPlannerPlanOptions, + schema: PostgresDatabaseSchemaNode, + ): Result< + PlannedStatements & { + readonly schema: PostgresDatabaseSchemaNode; + }, + SqlPlannerConflict + > { + const working = createWorkingSchema(schema); + const planned = planStatements({ + statements: options.statements, + fromContract: options.fromContract, + contract: options.contract, + policy: options.policy, + target: { + tables: () => postgresSchemaTables(working.current, options.contract), + renameTableCall: (rename) => + postgresTableRenameCall({ + previous: working.current, + contract: options.contract, + rename, + frameworkComponents: options.frameworkComponents, + }), + renameColumnCall: (rename) => + postgresColumnRenameCall({ + previous: working.current, + contract: options.contract, + rename, + frameworkComponents: options.frameworkComponents, + }), + apply: (call) => working.apply(call), + renderTableRename: renderRenameTableCall, + tableRenameByHand: (rename) => renameTableByHandStatements(options.contract, rename), + }, + }); + if (!planned.ok) return planned; + return ok({ ...planned.value, schema: working.current }); + } + private ensureAdditivePolicy(policy: MigrationOperationPolicy) { if (!policy.allowedOperationClasses.includes('additive')) { return plannerFailure([ @@ -859,7 +915,7 @@ function gradePolicyReplacement( }, }; } - if (!allowedOperationClasses.includes('destructive')) { + if (!allowedOperationClasses.includes('widening')) { return { disposition: 'conflict', conflict: conflictForDisallowedCall(replacement.drop, allowedOperationClasses), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 5a114b0667cf..be4713a13036 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -1,6 +1,14 @@ import type { Contract } from '@internal/contract/types'; import { errorMigrationOperationOptionRemoved } from '@internal/errors/migration'; -import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import { + type ColumnRenameRequest, + resolveColumnRenameAgainst, + resolveTableRenameAgainst, + type SqlMigrationPlanOperation, + type TableRenameRequest, + unmatchedColumnRename, + unmatchedTableRename, +} from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -13,6 +21,7 @@ import { blindCast } from '@internal/utils/casts'; import { errorPostgresMigrationStackMissing } from '../errors'; import { PostgresContractView } from '../postgres-contract-view'; import { PostgresRlsPolicy, type RenderedRlsPolicyLiteral } from '../postgres-rls-policy'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { AddCheckConstraintCall, AddColumnCall, @@ -50,7 +59,10 @@ import { installExtension } from './operations/dependencies'; import type { CreateIndexExtras } from './operations/indexes'; import type { ForeignKeySpec } from './operations/shared'; import type { PostgresPlanTargetDetails } from './planner-target-details'; -import { postgresTableRenameCalls } from './table-rename-calls'; +import { postgresContractToSchema } from './postgres-contract-to-schema'; +import { postgresSchemaTables } from './schema-tables'; +import { postgresColumnRenameCall, postgresTableRenameCall } from './table-rename-calls'; +import { createWorkingSchema, type WorkingSchemaCall } from './working-schema'; /** * Target-owned base class for Postgres migrations. @@ -93,6 +105,9 @@ export abstract class PostgresMigration< */ protected readonly controlAdapter: SqlControlAdapter<'postgres'> | undefined; + /** The rename calls this read of `operations` has made so far, in order. */ + #renames: WorkingSchemaCall[] = []; + #endView = new MigrationContractViews>( this, 'PostgresMigration', @@ -324,13 +339,16 @@ export abstract class PostgresMigration< readonly from: string; readonly to: string; }): Promise> { - return new RenameConstraintCall( + const call = new RenameConstraintCall( options.schema ?? UNBOUND_NAMESPACE_ID, options.table, options.kind, options.from, options.to, - ).toOp(this.controlAdapterFor('renameConstraint')); + ); + const adapter = this.controlAdapterFor('renameConstraint'); + this.#renames.push(call); + return call.toOp(adapter); } protected dropCheckConstraint(options: { @@ -358,7 +376,15 @@ export abstract class PostgresMigration< } /** - * Emit the operations that rename a table: the table rename, then a rename of each primary key, unique constraint, foreign key, index and check whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. `schema` names the table's namespace when more than one declares the table. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + * Emit the operations that rename a table: the table rename, then a rename of each primary key, + * unique constraint, foreign key, index and check whose name was derived from the old table name. + * The old name is resolved against the schema as this migration's earlier rename calls + * (`renameTable`, `renameIndex`, `renameConstraint`, `renameRlsPolicy`) leave it, and the new + * name against the end contract, so the companions start from names an earlier rename gave. + * Spread the result into `operations`: + * `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. `schema` names the table's + * namespace when more than one declares the table. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when + * the table does not exist at that point of the migration or the end contract lacks the new name. */ protected renameTable(options: { readonly schema?: string; @@ -366,12 +392,98 @@ export abstract class PostgresMigration< readonly to: string; }): readonly Promise>[] { const adapter = this.controlAdapterFor('renameTable'); - return postgresTableRenameCalls({ - startContract: this.startContract, - endContract: this.endContract, - rename: { namespaceId: options.schema, from: options.table, to: options.to }, + const rename: TableRenameRequest = { + namespaceId: options.schema, + from: options.table, + to: options.to, + }; + const startContract = this.startContract; + if (startContract === null) { + throw unmatchedTableRename(rename, 'the migration has no start contract'); + } + const current = this.schemaAfterRenames(startContract); + const endContract = this.endContract; + const resolved = resolveTableRenameAgainst( + postgresSchemaTables(current, startContract), + endContract, + rename, + ); + if (!resolved.ok) { + throw resolved.failure; + } + const call = postgresTableRenameCall({ + previous: current, + contract: endContract, + rename: resolved.value, + frameworkComponents: this.frameworkComponents(), + }); + this.#renames.push(call); + return call.toOps(adapter).map(async (op) => op); + } + + /** + * Emit the operations that rename a column: the column rename, then a rename of each unique + * constraint, foreign key and index on the column whose name was derived from the old column + * name, to the name the end contract gives it. The column is resolved against the schema as this + * migration's earlier rename calls leave it, so a column of a table an earlier `renameTable` + * renamed is named on the table's new name. Spread the result into `operations`: + * `...this.renameColumn({ table: 'User', column: 'name', to: 'fullName' })`. `schema` names the + * table's namespace when more than one declares the table. Throws + * `MIGRATION.COLUMN_RENAME_UNMATCHED` when the table or column does not exist at that point of + * the migration, the table already has the new column, or the end contract lacks it. + */ + protected renameColumn(options: { + readonly schema?: string; + readonly table: string; + readonly column: string; + readonly to: string; + }): readonly Promise>[] { + const adapter = this.controlAdapterFor('renameColumn'); + const rename: ColumnRenameRequest = { + namespaceId: options.schema, + table: options.table, + from: options.column, + to: options.to, + }; + const startContract = this.startContract; + if (startContract === null) { + throw unmatchedColumnRename(rename, 'the migration has no start contract'); + } + const current = this.schemaAfterRenames(startContract); + const endContract = this.endContract; + const resolved = resolveColumnRenameAgainst( + postgresSchemaTables(current, startContract), + endContract, + rename, + ); + if (!resolved.ok) { + throw resolved.failure; + } + const call = postgresColumnRenameCall({ + previous: current, + contract: endContract, + rename: resolved.value, frameworkComponents: this.frameworkComponents(), - }).map(async (call) => call.toOp(adapter)); + }); + this.#renames.push(call); + return call.toOps(adapter).map(async (op) => op); + } + + /** + * The start contract's schema with this read's rename calls applied in order. It is built only + * when a `renameTable` needs it, so a migration that renames no table never reads its start + * contract. + */ + private schemaAfterRenames(startContract: Contract): PostgresDatabaseSchemaNode { + const working = createWorkingSchema( + postgresContractToSchema(startContract, this.frameworkComponents()), + ); + for (const call of this.#renames) working.apply(call); + return working.current; + } + + protected override beginOperationsRead(): void { + this.#renames = []; } protected dropTable(options: { @@ -480,9 +592,10 @@ export abstract class PostgresMigration< readonly from: string; readonly to: string; }): Promise> { - return new RenameIndexCall(options.schema, options.table, options.from, options.to).toOp( - this.controlAdapterFor('renameIndex'), - ); + const call = new RenameIndexCall(options.schema, options.table, options.from, options.to); + const adapter = this.controlAdapterFor('renameIndex'); + this.#renames.push(call); + return call.toOp(adapter); } protected dropIndex(options: { @@ -554,12 +667,15 @@ export abstract class PostgresMigration< readonly from: string; readonly to: string; }): Promise> { - return new RenamePostgresRlsPolicyCall( + const call = new RenamePostgresRlsPolicyCall( options.schema, options.table, options.from, options.to, - ).toOp(this.controlAdapterFor('renameRlsPolicy')); + ); + const adapter = this.controlAdapterFor('renameRlsPolicy'); + this.#renames.push(call); + return call.toOp(adapter); } } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts deleted file mode 100644 index 0934ab53f2f6..000000000000 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/rename-rls-references.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ResolvedTableRename } from '@internal/family-sql/control'; -import type { SqlNamespaceEntries } from '@internal/sql-contract/types'; -import { namingOf } from '@internal/sql-schema-ir/naming'; -import { PostgresRlsEnablement } from '../postgres-rls-enablement'; -import { PostgresRlsPolicy } from '../postgres-rls-policy'; - -function renamedMarker(marker: unknown, rename: ResolvedTableRename): unknown { - if (!(marker instanceof PostgresRlsEnablement) || marker.tableName !== rename.from) return marker; - return new PostgresRlsEnablement({ tableName: rename.to, namespaceId: marker.namespaceId }); -} - -function renamedPolicy(policy: unknown, rename: ResolvedTableRename): unknown { - if (!(policy instanceof PostgresRlsPolicy) || policy.tableName !== rename.from) return policy; - return new PostgresRlsPolicy({ - naming: namingOf(policy.name, policy.prefix), - tableName: rename.to, - namespaceId: policy.namespaceId, - operation: policy.operation, - roles: policy.roles, - using: policy.using, - withCheck: policy.withCheck, - permissive: policy.permissive, - }); -} - -/** - * Moves the RLS marker and the policies of a renamed table onto its new name. The marker is keyed by table name; policies are keyed by their own name and only their `tableName` changes. Policy names do not derive from the table name, so none is renamed. - */ -export function renameRlsReferences( - entries: SqlNamespaceEntries, - rename: ResolvedTableRename, -): SqlNamespaceEntries { - const markers = entries['rls']; - const policies = entries['policy']; - return { - ...entries, - ...(markers === undefined - ? {} - : { - rls: Object.fromEntries( - Object.entries(markers).map(([tableName, marker]) => [ - tableName === rename.from ? rename.to : tableName, - renamedMarker(marker, rename), - ]), - ), - }), - ...(policies === undefined - ? {} - : { - policy: Object.fromEntries( - Object.entries(policies).map(([name, policy]) => [name, renamedPolicy(policy, rename)]), - ), - }), - }; -} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/render-ops.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/render-ops.ts index 3383eb32b651..7bca5b6cfc19 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/render-ops.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/render-ops.ts @@ -4,6 +4,7 @@ import type { MigrationPlanOperation, OpFactoryCall } from '@internal/framework- import { blindCast } from '@internal/utils/casts'; import { isThenable } from '@internal/utils/promise'; import { postgresError } from '../errors'; +import { isPostgresOpFactoryCall } from './op-factory-call'; import type { PostgresPlanTargetDetails } from './planner-target-details'; type Op = SqlMigrationPlanOperation; @@ -29,22 +30,36 @@ function assertPostgresOp(op: MigrationPlanOperation, callFactoryName: string): } } +function checkedOp( + opOrPromise: MigrationPlanOperation | Promise, + callFactoryName: string, +): Op | Promise { + if (isThenable(opOrPromise)) { + const checked = opOrPromise.then((op) => { + assertPostgresOp(op, callFactoryName); + return op; + }); + // A reader may take a plan's operations without awaiting each one, as with a placeholder's. + checked.catch(() => undefined); + return checked; + } + assertPostgresOp(opOrPromise, callFactoryName); + return opOrPromise; +} + export function renderOps( calls: readonly OpFactoryCall[], lowerer?: ExecuteRequestLowerer, ): (Op | Promise)[] { - return calls.map((c) => { - const opOrPromise = blindCast< - { toOp(lowerer?: ExecuteRequestLowerer): Op | Promise }, - 'PG OpFactoryCall.toOp accepts an optional ExecuteRequestLowerer; the framework interface omits it because not all targets need a lowerer — the PG target overrides with this extended signature' - >(c).toOp(lowerer); - if (isThenable(opOrPromise)) { - return opOrPromise.then((op) => { - assertPostgresOp(op, c.factoryName); - return op; - }); - } - assertPostgresOp(opOrPromise, c.factoryName); - return opOrPromise; + return calls.flatMap((c) => { + const lowered = isPostgresOpFactoryCall(c) + ? c.toOps(lowerer) + : [ + blindCast< + { toOp(lowerer?: ExecuteRequestLowerer): Op | Promise }, + 'PG OpFactoryCall.toOp accepts an optional ExecuteRequestLowerer; the framework interface omits it because not all targets need a lowerer — the PG target overrides with this extended signature' + >(c).toOp(lowerer), + ]; + return lowered.map((opOrPromise) => checkedOp(opOrPromise, c.factoryName)); }); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/schema-tables.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/schema-tables.ts new file mode 100644 index 000000000000..b9059f625cbe --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/schema-tables.ts @@ -0,0 +1,33 @@ +import type { Contract } from '@internal/contract/types'; +import type { SchemaTables } from '@internal/family-sql/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; + +/** + * The tables of a Postgres schema, addressed by `contract`'s namespace ids, each mapped to its DDL + * schema. + */ +export function postgresSchemaTables( + schema: PostgresDatabaseSchemaNode, + contract: Contract, +): SchemaTables { + const tableIn = (namespaceId: string, table: string) => + schema.namespaces[resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId)]?.tables[ + table + ]; + const hasTable = (namespaceId: string, table: string) => + tableIn(namespaceId, table) !== undefined; + return { + hasTable, + hasColumn: (namespaceId, table, column) => + Object.hasOwn(tableIn(namespaceId, table)?.columns ?? {}, column), + tablesNamed: (namespaceId, table) => (hasTable(namespaceId, table) ? [table] : []), + columnsNamed: (namespaceId, table, column) => + Object.hasOwn(tableIn(namespaceId, table)?.columns ?? {}, column) ? [column] : [], + namespacesWithTable: (table) => + Object.keys(contract.storage.namespaces).filter((namespaceId) => + hasTable(namespaceId, table), + ), + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts index 68ee25ad4d79..638def676022 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-calls.ts @@ -1,9 +1,9 @@ import type { Contract } from '@internal/contract/types'; -import { - applyTableRename, - type MigrationOperationPolicy, - type ResolvedTableRename, - type TableRename, +import type { + ColumnRename, + MigrationOperationPolicy, + TableRename, + TableRenameRequest, } from '@internal/family-sql/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -11,13 +11,14 @@ import type { SqlStorage } from '@internal/sql-contract/types'; import { assertDefined } from '@internal/utils/assertions'; import type { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { constraintRenamesForColumnRename } from './column-rename-constraint-renames'; import { buildPostgresPlanDiff } from './diff-database-schema'; import { pairCheckRenames, pairIndexRenames } from './index-and-check-renames'; -import { type PostgresOpFactoryCall, RenameTableCall } from './op-factory-call'; -import { postgresContractToSchema } from './postgres-contract-to-schema'; -import { renameRlsReferences } from './rename-rls-references'; +import { RenameColumnCall, RenameTableCall } from './op-factory-call'; +import { renameTableStatement } from './operations/tables'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; import { constraintRenamesForTableRename } from './table-rename-constraint-renames'; +import { renameColumnInPostgresSchema, renameTableInPostgresSchema } from './working-schema'; const RENAME_POLICY: MigrationOperationPolicy = { allowedOperationClasses: ['widening'] }; @@ -30,63 +31,131 @@ export function emissionSchemaForNamespace( : resolveDdlSchemaForNamespaceStorage(contract.storage, namespaceId); } -function renamedTableNode( - schema: PostgresDatabaseSchemaNode, +/** + * The `renameTable` call a user writes in `migration.ts` for `rename`. `schema` names the namespace + * the facade resolves the table in, and is left out for the unbound namespace or when not given. + */ +export function renderRenameTableCall(rename: TableRenameRequest): string { + return new RenameTableCall( + rename.namespaceId ?? UNBOUND_NAMESPACE_ID, + rename.from, + rename.to, + [], + ).renderTypeScript(); +} + +/** The SQL a user runs against the database to rename the table by hand. */ +export function renameTableByHandStatements( contract: Contract, - rename: ResolvedTableRename, + rename: TableRename, +): readonly string[] { + return [ + renameTableStatement( + emissionSchemaForNamespace(contract, rename.namespaceId), + rename.from, + rename.to, + ), + ]; +} + +function tableNode( + schema: PostgresDatabaseSchemaNode, + ddlSchema: string, + tableName: string, ): PostgresTableSchemaNode { - const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); - const table = Object.values(schema.namespaces).find( - (namespace) => namespace.schemaName === ddlSchema, - )?.tables[rename.to]; - assertDefined(table, `a resolved rename names table "${rename.to}" in schema "${ddlSchema}"`); + const table = schema.namespaces[ddlSchema]?.tables[tableName]; + assertDefined(table, `a resolved rename names table "${tableName}" in schema "${ddlSchema}"`); return table; } /** - * The calls a migration's `renameTable` emits: the table rename, then a rename of each primary key, unique constraint and foreign key the start contract left unnamed, and of each wire-named index and check whose prefix derives from the table name. Only objects the end contract leaves otherwise unchanged are renamed; an unchanged constraint takes the end contract's explicit name if it has one. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + * The call that renames a table, carrying as companions the renames of the objects on the table + * whose names derive from the table name: each primary key, unique constraint and foreign key the + * destination keeps, named as the destination names it, and each wire-named index and check whose + * prefix changes. `previous` is the schema before the rename; the renamed copy is diffed against + * the destination built from `contract`. */ -export function postgresTableRenameCalls(input: { - readonly startContract: Contract | null; - readonly endContract: Contract; +export function postgresTableRenameCall(input: { + readonly previous: PostgresDatabaseSchemaNode; + readonly contract: Contract; readonly rename: TableRename; readonly frameworkComponents: ReadonlyArray>; -}): readonly PostgresOpFactoryCall[] { - const applied = applyTableRename({ - startContract: input.startContract, - endContract: input.endContract, - rename: input.rename, - renameTableReferences: renameRlsReferences, - }); - if (!applied.ok) { - throw applied.failure; - } - const { rename } = applied.value; - const contract = input.endContract; +}): RenameTableCall { + const { contract, rename } = input; const schemaName = emissionSchemaForNamespace(contract, rename.namespaceId); - const previousSchema = postgresContractToSchema( - applied.value.contract, - input.frameworkComponents, - ); - const nextSchema = postgresContractToSchema(contract, input.frameworkComponents); - const { issues } = buildPostgresPlanDiff({ + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); + const renamed = renameTableInPostgresSchema(input.previous, { + schemaName: ddlSchema, + from: rename.from, + to: rename.to, + }); + const { expected, issues } = buildPostgresPlanDiff({ contract, - actualSchema: previousSchema, + actualSchema: renamed, frameworkComponents: input.frameworkComponents, }); const onRenamedTable = (call: { readonly schemaName: string; readonly tableName: string }) => call.schemaName === schemaName && call.tableName === rename.to; const pairing = { contract, policy: RENAME_POLICY }; - return [ - new RenameTableCall(schemaName, rename.from, rename.to), + return new RenameTableCall(schemaName, rename.from, rename.to, [ ...constraintRenamesForTableRename({ schemaName, - from: rename.from, - to: rename.to, - previous: renamedTableNode(previousSchema, contract, rename), - next: renamedTableNode(nextSchema, contract, rename), + previous: tableNode(renamed, ddlSchema, rename.to), + next: tableNode(expected, ddlSchema, rename.to), }), ...pairIndexRenames(pairing, issues).calls.filter(onRenamedTable), ...pairCheckRenames(pairing, issues).calls.filter(onRenamedTable), - ]; + ]); +} + +/** + * The call that renames a column, carrying as companions the renames of the objects on its table + * whose names derive from the column name: each unique constraint and foreign key on the column + * the destination keeps, named as the destination names it, and each index on the column renamed + * to the destination's wire name. `previous` is the schema before the rename; the renamed copy is + * diffed against the destination built from `contract`. A check on the column keeps its name: its + * expression names the column, so the diff replaces it. + */ +export function postgresColumnRenameCall(input: { + readonly previous: PostgresDatabaseSchemaNode; + readonly contract: Contract; + readonly rename: ColumnRename; + readonly frameworkComponents: ReadonlyArray>; +}): RenameColumnCall { + const { contract, rename } = input; + const schemaName = emissionSchemaForNamespace(contract, rename.namespaceId); + const ddlSchema = resolveDdlSchemaForNamespaceStorage(contract.storage, rename.namespaceId); + const renamed = renameColumnInPostgresSchema(input.previous, { + schemaName: ddlSchema, + table: rename.table, + from: rename.from, + to: rename.to, + }); + const { expected, issues } = buildPostgresPlanDiff({ + contract, + actualSchema: renamed, + frameworkComponents: input.frameworkComponents, + }); + const table = tableNode(renamed, ddlSchema, rename.table); + const destination = expected.namespaces[ddlSchema]?.tables[rename.table]; + const indexesOnColumn = new Set( + table.indexes.filter((index) => index.columns?.includes(rename.to)).map((index) => index.name), + ); + const indexRenames = pairIndexRenames({ contract, policy: RENAME_POLICY }, issues).calls.filter( + (call) => + call.schemaName === schemaName && + call.tableName === rename.table && + indexesOnColumn.has(call.oldIndexName), + ); + return new RenameColumnCall(schemaName, rename.table, rename.from, rename.to, [ + ...(destination === undefined + ? [] + : constraintRenamesForColumnRename({ + schemaName, + column: rename.to, + previous: table, + next: destination, + })), + ...indexRenames, + ]); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts index 995aabd78966..5a476e088354 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/table-rename-constraint-renames.ts @@ -1,4 +1,5 @@ -import type { DiffableNode } from '@internal/framework-components/control'; +import { isArrayEqual } from '@internal/utils/array-equal'; +import { assertDefined } from '@internal/utils/assertions'; import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; import { defaultForeignKeyName, @@ -8,73 +9,143 @@ import { import { RenameConstraintCall } from './op-factory-call'; export interface TableRenameConstraintInput { + /** The schema name the rename calls carry: the unbound sentinel for the unbound namespace. */ readonly schemaName: string; - readonly from: string; - readonly to: string; - /** The renamed table as the start contract describes it, under its new name. */ + /** + * The renamed table as the working schema has it after the rename, its constraint names as they + * are in the database. + */ readonly previous: PostgresTableSchemaNode; + /** The table in the destination schema. */ readonly next: PostgresTableSchemaNode; } -/** The constraint of the next table the diff pairs with `node` and finds unchanged, as the diff compares them. */ -function unchangedIn( - node: TNode, - nextNodes: readonly TNode[], -): TNode | undefined { - return nextNodes.find((next) => next.id === node.id && next.isEqualTo(node)); +/** + * The constraint renames that follow a table rename. Each primary key, unique constraint and + * foreign key of the renamed table is paired with the destination constraint of the same kind on + * the same columns, and for a foreign key the same referenced columns. Foreign keys pair first + * with destination keys to the same referenced table, and the rest then pair with keys to another + * table, because a later rename in the same plan may change that table and a foreign key's derived + * name never depends on it. A paired constraint is renamed to the destination's explicit name, or else + * to the name the planner derives from the new table name, when that differs from its name in the + * database. Each destination constraint pairs with at most one constraint, in order. An unpaired + * constraint is being dropped or changed and keeps its name. Indexes and checks are not handled + * here: their wire names pair by content hash in the index and check rename passes. + */ +export function constraintRenamesForTableRename( + input: TableRenameConstraintInput, +): readonly RenameConstraintCall[] { + return pairedConstraintRenames(input, { primaryKey: true, onColumns: () => true }); +} + +/** Which of the renamed table's constraints a rename can have renamed. */ +export interface ConstraintRenameScope { + readonly primaryKey: boolean; + readonly onColumns: (columns: readonly string[]) => boolean; } /** - * The constraint renames that follow a table rename. A primary key, unique constraint or foreign key the start contract left unnamed carries a name derived from the old table name. When the end contract keeps the same constraint unchanged, it is renamed to the name the end contract gives it explicitly, or otherwise to the name derived from the new table name. A constraint the end contract changes is not renamed, so it keeps its name in the database. A constraint the start contract named keeps its name. Indexes and checks are not handled here: their wire names pair by content hash in the index and check rename passes. + * The renames of the constraints in `scope` that pair with a destination constraint of the same + * kind on the same columns, to the destination's explicit name or else the derived one. */ -export function constraintRenamesForTableRename( +export function pairedConstraintRenames( input: TableRenameConstraintInput, + scope: ConstraintRenameScope, ): readonly RenameConstraintCall[] { - const { schemaName, from, to, previous, next } = input; + const { schemaName, previous, next } = input; + const table = next.name; const rename = ( kind: 'primaryKey' | 'unique' | 'foreignKey', - oldName: string, - unchanged: { readonly name?: string } | undefined, - derivedName: string, + actualName: string | undefined, + target: string | undefined, ): readonly RenameConstraintCall[] => { - if (unchanged === undefined) return []; - const newName = unchanged.name ?? derivedName; - return oldName === newName + assertDefined( + actualName, + `the table "${previous.name}" has a ${kind} that has no name; the working schema names every primary key, unique and foreign key a rename can change`, + ); + return target === undefined || target === actualName ? [] - : [new RenameConstraintCall(schemaName, to, kind, oldName, newName)]; + : [new RenameConstraintCall(schemaName, table, kind, actualName, target)]; }; + const nextPrimaryKey = next.primaryKey; const primaryKey = - previous.primaryKey !== undefined && previous.primaryKey.name === undefined - ? rename( + previous.primaryKey === undefined || !scope.primaryKey + ? [] + : rename( 'primaryKey', - defaultPrimaryKeyName(from), - unchangedIn(previous.primaryKey, next.primaryKey === undefined ? [] : [next.primaryKey]), - defaultPrimaryKeyName(to), - ) - : []; + previous.primaryKey.name, + nextPrimaryKey !== undefined && + isArrayEqual(previous.primaryKey.columns, nextPrimaryKey.columns) + ? (nextPrimaryKey.name ?? defaultPrimaryKeyName(table)) + : undefined, + ); + const pairedUniques = new Set(); const uniques = previous.uniques - .filter((unique) => unique.name === undefined) - .flatMap((unique) => - rename( + .filter((unique) => scope.onColumns(unique.columns)) + .flatMap((unique) => { + const paired = next.uniques.find( + (candidate) => + !pairedUniques.has(candidate) && isArrayEqual(candidate.columns, unique.columns), + ); + if (paired !== undefined) pairedUniques.add(paired); + return rename( 'unique', - defaultUniqueName(from, unique.columns), - unchangedIn(unique, next.uniques), - defaultUniqueName(to, unique.columns), - ), - ); + unique.name, + paired === undefined + ? undefined + : (paired.name ?? defaultUniqueName(table, paired.columns)), + ); + }); - const foreignKeys = previous.foreignKeys - .filter((fk) => fk.name === undefined) - .flatMap((fk) => - rename( - 'foreignKey', - defaultForeignKeyName(from, fk.columns), - unchangedIn(fk, next.foreignKeys), - defaultForeignKeyName(to, fk.columns), - ), + const previousForeignKeys = previous.foreignKeys.filter((fk) => scope.onColumns(fk.columns)); + const pairing = pairForeignKeys(previousForeignKeys, next.foreignKeys); + const foreignKeys = previousForeignKeys.flatMap((fk) => { + const paired = pairing.get(fk); + return rename( + 'foreignKey', + fk.name, + paired === undefined ? undefined : (paired.name ?? defaultForeignKeyName(table, fk.columns)), ); + }); return [...primaryKey, ...uniques, ...foreignKeys]; } + +type ForeignKeyNode = PostgresTableSchemaNode['foreignKeys'][number]; + +/** + * Pairs old foreign keys with destination keys on the same columns and referenced columns. Every + * key to the same referenced table pairs first, so an old key with no such match cannot take the + * destination key another old key keeps; the remaining keys then pair in order. + */ +function pairForeignKeys( + previous: readonly ForeignKeyNode[], + next: readonly ForeignKeyNode[], +): ReadonlyMap { + const pairs = new Map(); + const taken = new Set(); + const pass = (accepts: (fk: ForeignKeyNode, candidate: ForeignKeyNode) => boolean) => { + for (const fk of previous) { + if (pairs.has(fk)) continue; + const paired = next.find( + (candidate) => + !taken.has(candidate) && + isArrayEqual(candidate.columns, fk.columns) && + isArrayEqual(candidate.referencedColumns, fk.referencedColumns) && + accepts(fk, candidate), + ); + if (paired === undefined) continue; + pairs.set(fk, paired); + taken.add(paired); + } + }; + pass( + (fk, candidate) => + candidate.referencedTable === fk.referencedTable && + candidate.resolvedReferencedNamespace === fk.resolvedReferencedNamespace, + ); + pass(() => true); + return pairs; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/working-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/working-schema.ts new file mode 100644 index 000000000000..3812d643ce52 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/working-schema.ts @@ -0,0 +1,597 @@ +import type { SchemaNodeRef } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { namingOf, namingOfLiveName, type SqlObjectNaming } from '@internal/sql-schema-ir/naming'; +import { + PrimaryKey, + RelationalSchemaNodeKind, + SqlCheckConstraintIR, + SqlColumnIR, + SqlForeignKeyIR, + SqlIndexIR, + SqlUniqueIR, +} from '@internal/sql-schema-ir/types'; +import { ifDefined } from '@internal/utils/defined'; +import { DEFAULT_NAMESPACE_ID } from '../namespace-ids'; +import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; +import { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; +import { PostgresSchemaNodeKind } from '../schema-ir/schema-node-kinds'; +import { + defaultForeignKeyName, + defaultPrimaryKeyName, + defaultUniqueName, +} from './default-constraint-names'; +import { + RenameColumnCall, + RenameConstraintCall, + RenameIndexCall, + type RenamePostgresRlsPolicyCall, + RenameTableCall, +} from './op-factory-call'; + +/** + * A table rename inside one live schema: `schemaName` is the DDL schema name, never the unbound + * sentinel. + */ +export interface SchemaTableRename { + readonly schemaName: string; + readonly from: string; + readonly to: string; +} + +export type WorkingSchemaCall = + | RenameTableCall + | RenameColumnCall + | RenameConstraintCall + | RenameIndexCall + | RenamePostgresRlsPolicyCall; + +type RefRewrite = (ref: SchemaNodeRef) => SchemaNodeRef; + +function ddlSchemaOf(callSchemaName: string): string { + return callSchemaName === UNBOUND_NAMESPACE_ID ? DEFAULT_NAMESPACE_ID : callSchemaName; +} + +function rewriteRefs( + refs: readonly SchemaNodeRef[] | undefined, + rewrite: RefRewrite, +): readonly SchemaNodeRef[] | undefined { + return refs?.map(rewrite); +} + +/** + * Rewrites the step of `kind` and `id` in a ref that runs through the given table, and the table + * step itself when `kind` is the table kind. + */ +function stepRewrite( + schemaName: string, + tableName: string, + kind: string, + fromId: string, + toId: string, +): RefRewrite { + return (ref) => { + const namespaceStep = ref[1]; + const tableStep = ref[2]; + if ( + namespaceStep?.nodeKind !== PostgresSchemaNodeKind.namespace || + namespaceStep.id !== schemaName || + tableStep?.nodeKind !== PostgresSchemaNodeKind.table || + tableStep.id !== tableName + ) { + return ref; + } + return ref.map((step, index) => + index >= 2 && step.nodeKind === kind && step.id === fromId + ? { nodeKind: kind, id: toId } + : step, + ); + }; +} + +type ColumnMap = (column: string) => string; + +function withColumnName(column: SqlColumnIR, name: string): SqlColumnIR { + return new SqlColumnIR({ + name, + nativeType: column.nativeType, + nullable: column.nullable, + ...ifDefined('default', column.default), + ...ifDefined('annotations', column.annotations), + ...ifDefined('many', column.many), + ...ifDefined('resolvedNativeType', column.resolvedNativeType), + ...ifDefined('resolvedDefault', column.resolvedDefault), + ...ifDefined('authoredDefault', column.authoredDefault), + ...ifDefined('codecRef', column.codecRef), + ...ifDefined('codecBaseNativeType', column.codecBaseNativeType), + ...ifDefined('dataType', column.dataType), + ...ifDefined('toCanonicalForm', column.toCanonicalForm), + }); +} + +function withPrimaryKey( + primaryKey: PrimaryKey, + name: string | undefined, + rewrite: RefRewrite, + columnName: ColumnMap, +): PrimaryKey { + return new PrimaryKey({ + columns: primaryKey.columns.map(columnName), + ...ifDefined('name', name), + ...ifDefined('dependsOn', rewriteRefs(primaryKey.dependsOn, rewrite)), + }); +} + +function withUnique( + unique: SqlUniqueIR, + name: string | undefined, + rewrite: RefRewrite, + columnName: ColumnMap, +): SqlUniqueIR { + return new SqlUniqueIR({ + columns: unique.columns.map(columnName), + ...ifDefined('name', name), + ...ifDefined('annotations', unique.annotations), + ...ifDefined('dependsOn', rewriteRefs(unique.dependsOn, rewrite)), + }); +} + +function withForeignKey( + fk: SqlForeignKeyIR, + overrides: { + readonly name: string | undefined; + readonly referencedTable: string; + readonly referencedColumns?: readonly string[]; + }, + rewrite: RefRewrite, + columnName: ColumnMap, +): SqlForeignKeyIR { + return new SqlForeignKeyIR({ + columns: fk.columns.map(columnName), + referencedTable: overrides.referencedTable, + referencedColumns: overrides.referencedColumns ?? fk.referencedColumns, + ...ifDefined('referencedSchema', fk.referencedSchema), + ...ifDefined('name', overrides.name), + ...ifDefined('onDelete', fk.onDelete), + ...ifDefined('onUpdate', fk.onUpdate), + ...ifDefined('annotations', fk.annotations), + ...ifDefined('resolvedReferencedNamespace', fk.resolvedReferencedNamespace), + ...ifDefined('dependsOn', rewriteRefs(fk.dependsOn, rewrite)), + }); +} + +/** + * An index whose definition carries SQL text, an expression or a predicate, keeps its columns as + * they are: a column named inside the text cannot be renamed here, so the diff plans the index. + */ +function withIndex( + index: SqlIndexIR, + naming: SqlObjectNaming, + rewrite: RefRewrite, + columnName: ColumnMap, +): SqlIndexIR { + const columnsFollow = index.expression === undefined && index.where === undefined; + const base = { + naming, + where: index.where, + unique: index.unique, + partial: index.partial, + type: index.type, + options: index.options, + annotations: index.annotations, + dependsOn: rewriteRefs(index.dependsOn, rewrite), + }; + return new SqlIndexIR( + index.expression !== undefined + ? { ...base, expression: index.expression } + : { ...base, columns: (index.columns ?? []).map(columnsFollow ? columnName : (c) => c) }, + ); +} + +function withCheck( + check: SqlCheckConstraintIR, + naming: SqlObjectNaming, + rewrite: RefRewrite, +): SqlCheckConstraintIR { + return new SqlCheckConstraintIR({ + naming, + expression: check.expression, + dependsOn: rewriteRefs(check.dependsOn, rewrite), + }); +} + +function withPolicy( + policy: PostgresPolicySchemaNode, + overrides: { readonly naming: SqlObjectNaming; readonly tableName: string }, + rewrite: RefRewrite, +): PostgresPolicySchemaNode { + return new PostgresPolicySchemaNode({ + naming: overrides.naming, + tableName: overrides.tableName, + namespaceId: policy.namespaceId, + operation: policy.operation, + roles: policy.roles, + using: policy.using, + withCheck: policy.withCheck, + permissive: policy.permissive, + dependsOn: rewriteRefs(policy.dependsOn, rewrite), + }); +} + +/** + * How one table is rebuilt: each hook returns the replacement node, given the node and the rewrite + * of its dependencies. + */ +interface TableEdit { + readonly name: string; + readonly primaryKeyName: (primaryKey: PrimaryKey) => string | undefined; + readonly uniqueName: (unique: SqlUniqueIR) => string | undefined; + readonly foreignKey: (fk: SqlForeignKeyIR) => { + readonly name: string | undefined; + readonly referencedTable: string; + readonly referencedColumns?: readonly string[]; + }; + /** The name each of the table's own columns gets. */ + readonly columnName: ColumnMap; + readonly indexNaming: (index: SqlIndexIR) => SqlObjectNaming; + readonly checkNaming: (check: SqlCheckConstraintIR) => SqlObjectNaming; + readonly policy: (policy: PostgresPolicySchemaNode) => { + readonly naming: SqlObjectNaming; + readonly tableName: string; + }; +} + +function unchangedEdit(table: PostgresTableSchemaNode): TableEdit { + return { + name: table.name, + primaryKeyName: (primaryKey) => primaryKey.name, + uniqueName: (unique) => unique.name, + foreignKey: (fk) => ({ name: fk.name, referencedTable: fk.referencedTable }), + columnName: (column) => column, + indexNaming: (index) => namingOf(index.name, index.prefix), + checkNaming: (check) => namingOf(check.name, check.prefix), + policy: (policy) => ({ + naming: namingOf(policy.name, policy.prefix), + tableName: policy.tableName, + }), + }; +} + +function rebuildTable( + table: PostgresTableSchemaNode, + edit: TableEdit, + rewrite: RefRewrite, +): PostgresTableSchemaNode { + const { columnName } = edit; + return new PostgresTableSchemaNode({ + name: edit.name, + columns: Object.fromEntries( + Object.values(table.columns).map((column) => { + const name = columnName(column.name); + return [name, name === column.name ? column : withColumnName(column, name)]; + }), + ), + ...ifDefined( + 'primaryKey', + table.primaryKey === undefined + ? undefined + : withPrimaryKey( + table.primaryKey, + edit.primaryKeyName(table.primaryKey), + rewrite, + columnName, + ), + ), + uniques: table.uniques.map((unique) => + withUnique(unique, edit.uniqueName(unique), rewrite, columnName), + ), + foreignKeys: table.foreignKeys.map((fk) => + withForeignKey(fk, edit.foreignKey(fk), rewrite, columnName), + ), + indexes: table.indexes.map((index) => + withIndex(index, edit.indexNaming(index), rewrite, columnName), + ), + ...ifDefined( + 'checks', + table.checks?.map((check) => withCheck(check, edit.checkNaming(check), rewrite)), + ), + ...ifDefined('annotations', table.annotations), + policies: table.policies.map((policy) => withPolicy(policy, edit.policy(policy), rewrite)), + rlsEnabled: table.rlsEnabled, + }); +} + +/** + * Rebuilds every table of the schema: `editFor` returns the edit for a table, or `undefined` to + * only rewrite its dependencies. + */ +function mapTables( + schema: PostgresDatabaseSchemaNode, + rewrite: RefRewrite, + editFor: (schemaName: string, table: PostgresTableSchemaNode) => TableEdit | undefined, +): PostgresDatabaseSchemaNode { + return new PostgresDatabaseSchemaNode({ + namespaces: Object.fromEntries( + Object.entries(schema.namespaces).map(([key, namespace]) => [ + key, + new PostgresNamespaceSchemaNode({ + schemaName: namespace.schemaName, + nativeEnums: namespace.nativeEnums, + tables: Object.fromEntries( + Object.values(namespace.tables).map((table) => { + const edit = editFor(namespace.schemaName, table) ?? unchangedEdit(table); + const rebuilt = rebuildTable(table, edit, rewrite); + return [rebuilt.name, rebuilt]; + }), + ), + }), + ]), + ), + roles: schema.roles, + existingSchemas: schema.existingSchemas, + pgVersion: schema.pgVersion, + }); +} + +/** + * The schema with one table under its new name. Foreign keys anywhere that reference the table + * follow it, its policies and every dependency that names it move with it, and row-level security + * is carried. Constraint, index, check and policy names do not change: a primary key, unique + * constraint or foreign key whose name was derived from the old table name gets that name spelled + * out, since it no longer derives from the table it is on. + */ +export function renameTableInPostgresSchema( + schema: PostgresDatabaseSchemaNode, + rename: SchemaTableRename, +): PostgresDatabaseSchemaNode { + const { schemaName, from, to } = rename; + const rewrite = stepRewrite(schemaName, from, PostgresSchemaNodeKind.table, from, to); + const referencesRenamed = (ownSchema: string, fk: SqlForeignKeyIR): boolean => + (fk.resolvedReferencedNamespace ?? ownSchema) === schemaName && fk.referencedTable === from; + return mapTables(schema, rewrite, (tableSchema, table) => { + const unchanged = unchangedEdit(table); + const followReference = (fk: SqlForeignKeyIR) => ({ + name: fk.name, + referencedTable: referencesRenamed(tableSchema, fk) ? to : fk.referencedTable, + }); + if (tableSchema !== schemaName || table.name !== from) { + return { ...unchanged, foreignKey: followReference }; + } + return { + ...unchanged, + name: to, + primaryKeyName: (primaryKey) => primaryKey.name ?? defaultPrimaryKeyName(from), + uniqueName: (unique) => unique.name ?? defaultUniqueName(from, unique.columns), + foreignKey: (fk) => ({ + ...followReference(fk), + name: fk.name ?? defaultForeignKeyName(from, fk.columns), + }), + policy: (policy) => ({ ...unchanged.policy(policy), tableName: to }), + }; + }); +} + +/** A column rename inside one live schema: `schemaName` is the DDL schema name. */ +export interface SchemaColumnRename { + readonly schemaName: string; + readonly table: string; + readonly from: string; + readonly to: string; +} + +/** + * The schema with one column under its new name. The table's primary key, unique constraints, + * foreign keys and plain column indexes name it under the new name, foreign keys anywhere that + * reference it follow it, and every dependency that names it moves with it. An index with an + * expression or a predicate, and a check, keep their SQL text. Constraint and index names do not + * change: a unique constraint or foreign key on the column whose name was derived from the old + * column name gets that name spelled out. + */ +export function renameColumnInPostgresSchema( + schema: PostgresDatabaseSchemaNode, + rename: SchemaColumnRename, +): PostgresDatabaseSchemaNode { + const { schemaName, table: tableName, from, to } = rename; + const rewrite = stepRewrite( + schemaName, + tableName, + RelationalSchemaNodeKind.column, + `column:${from}`, + `column:${to}`, + ); + const renamed = (column: string) => (column === from ? to : column); + const referencesTable = (ownSchema: string, fk: SqlForeignKeyIR): boolean => + (fk.resolvedReferencedNamespace ?? ownSchema) === schemaName && + fk.referencedTable === tableName; + return mapTables(schema, rewrite, (tableSchema, table) => { + const unchanged = unchangedEdit(table); + const followReference = (fk: SqlForeignKeyIR) => ({ + name: fk.name, + referencedTable: fk.referencedTable, + referencedColumns: referencesTable(tableSchema, fk) + ? fk.referencedColumns.map(renamed) + : fk.referencedColumns, + }); + if (tableSchema !== schemaName || table.name !== tableName) { + return { ...unchanged, foreignKey: followReference }; + } + return { + ...unchanged, + columnName: renamed, + uniqueName: (unique) => + unique.name ?? + (unique.columns.includes(from) ? defaultUniqueName(tableName, unique.columns) : undefined), + foreignKey: (fk) => ({ + ...followReference(fk), + name: + fk.name ?? + (fk.columns.includes(from) ? defaultForeignKeyName(tableName, fk.columns) : undefined), + }), + }; + }); +} + +function renamedNaming(previous: { readonly prefix?: string | undefined }, name: string) { + return previous.prefix === undefined ? namingOf(name, undefined) : namingOfLiveName(name); +} + +function renameConstraint( + schema: PostgresDatabaseSchemaNode, + call: RenameConstraintCall, +): PostgresDatabaseSchemaNode { + const schemaName = ddlSchemaOf(call.schemaName); + const { tableName, oldConstraintName, newConstraintName } = call; + const renamed = (name: string | undefined, derived: string) => + (name ?? derived) === oldConstraintName ? newConstraintName : name; + const rewrite = stepRewrite( + schemaName, + tableName, + RelationalSchemaNodeKind.check, + `check:${oldConstraintName}`, + `check:${newConstraintName}`, + ); + return mapTables(schema, rewrite, (tableSchema, table) => { + if (tableSchema !== schemaName || table.name !== tableName) return undefined; + const unchanged = unchangedEdit(table); + switch (call.kind) { + case 'primaryKey': + return { + ...unchanged, + primaryKeyName: (primaryKey) => + renamed(primaryKey.name, defaultPrimaryKeyName(tableName)), + }; + case 'unique': + return { + ...unchanged, + uniqueName: (unique) => + renamed(unique.name, defaultUniqueName(tableName, unique.columns)), + }; + case 'foreignKey': + return { + ...unchanged, + foreignKey: (fk) => ({ + referencedTable: fk.referencedTable, + name: renamed(fk.name, defaultForeignKeyName(tableName, fk.columns)), + }), + }; + case 'checkConstraint': + return { + ...unchanged, + checkNaming: (check) => + check.name === oldConstraintName + ? renamedNaming(check, newConstraintName) + : unchanged.checkNaming(check), + }; + } + }); +} + +function renameIndex( + schema: PostgresDatabaseSchemaNode, + call: RenameIndexCall, +): PostgresDatabaseSchemaNode { + const schemaName = ddlSchemaOf(call.schemaName); + const { tableName, oldIndexName, newIndexName } = call; + const rewrite = stepRewrite( + schemaName, + tableName, + RelationalSchemaNodeKind.index, + `index:${oldIndexName}`, + `index:${newIndexName}`, + ); + return mapTables(schema, rewrite, (tableSchema, table) => { + if (tableSchema !== schemaName || table.name !== tableName) return undefined; + const unchanged = unchangedEdit(table); + return { + ...unchanged, + indexNaming: (index) => + index.name === oldIndexName + ? renamedNaming(index, newIndexName) + : unchanged.indexNaming(index), + }; + }); +} + +function renamePolicy( + schema: PostgresDatabaseSchemaNode, + call: RenamePostgresRlsPolicyCall, +): PostgresDatabaseSchemaNode { + const schemaName = ddlSchemaOf(call.schemaName); + const { tableName, oldPolicyName, newPolicyName } = call; + const rewrite = stepRewrite( + schemaName, + tableName, + PostgresSchemaNodeKind.policy, + oldPolicyName, + newPolicyName, + ); + return mapTables(schema, rewrite, (tableSchema, table) => { + if (tableSchema !== schemaName || table.name !== tableName) return undefined; + const unchanged = unchangedEdit(table); + return { + ...unchanged, + policy: (policy) => + policy.name === oldPolicyName + ? { naming: renamedNaming(policy, newPolicyName), tableName: policy.tableName } + : unchanged.policy(policy), + }; + }); +} + +function applied( + schema: PostgresDatabaseSchemaNode, + call: WorkingSchemaCall, +): PostgresDatabaseSchemaNode { + if (call instanceof RenameTableCall) { + const renamed = renameTableInPostgresSchema(schema, { + schemaName: ddlSchemaOf(call.schemaName), + from: call.oldTableName, + to: call.tableName, + }); + return call.companions.reduce(applied, renamed); + } + if (call instanceof RenameColumnCall) { + const renamed = renameColumnInPostgresSchema(schema, { + schemaName: ddlSchemaOf(call.schemaName), + table: call.tableName, + from: call.oldColumnName, + to: call.columnName, + }); + return call.companions.reduce(applied, renamed); + } + if (call instanceof RenameConstraintCall) return renameConstraint(schema, call); + if (call instanceof RenameIndexCall) return renameIndex(schema, call); + return renamePolicy(schema, call); +} + +/** + * The schema a migration's renames have produced so far. The planner starts it from the schema it + * plans from, a hand-written migration from its start contract; each rename call is computed + * against `current` and then applied, so a later call sees the effect of an earlier one. + */ +export interface WorkingSchema { + readonly current: PostgresDatabaseSchemaNode; + apply(call: WorkingSchemaCall): void; +} + +/** The working schema of a plan or a migration, starting from `initial`. */ +export function createWorkingSchema(initial: PostgresDatabaseSchemaNode): WorkingSchema { + return new WorkingSchemaImpl(initial); +} + +class WorkingSchemaImpl implements WorkingSchema { + #current: PostgresDatabaseSchemaNode; + + constructor(initial: PostgresDatabaseSchemaNode) { + this.#current = initial; + } + + get current(): PostgresDatabaseSchemaNode { + return this.#current; + } + + apply(call: WorkingSchemaCall): void { + this.#current = applied(this.#current, call); + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts index 9c08aeb0b379..0f55bd46b445 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts @@ -30,7 +30,7 @@ import { PostgresNativeEnum } from './postgres-native-enum'; import type { PostgresRlsEnablement } from './postgres-rls-enablement'; import type { PostgresRlsPolicy } from './postgres-rls-policy'; import type { PostgresRole } from './postgres-role'; -import { escapeLiteral } from './sql-utils'; +import { escapeLiteral, quoteIdentifier } from './sql-utils'; export type PostgresContract = Contract & { readonly target: 'postgres' }; @@ -161,7 +161,7 @@ export class PostgresSchema extends SqlNamespaceBase { * this to return `''`. */ qualifier(): string { - return `"${this.id}"`; + return quoteIdentifier(this.id); } /** @@ -171,7 +171,7 @@ export class PostgresSchema extends SqlNamespaceBase { * and `search_path` decides where the object lands at runtime. */ qualifyTable(tableName: string): string { - return `"${this.id}"."${tableName}"`; + return `${quoteIdentifier(this.id)}.${quoteIdentifier(tableName)}`; } /** @@ -269,7 +269,7 @@ export class PostgresUnboundSchema extends PostgresSchema { } override qualifyTable(tableName: string): string { - return `"${tableName}"`; + return quoteIdentifier(tableName); } override schemaSqlExpression(): string { diff --git a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts index d48e49622b37..2aa73b6e03d1 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/op-factory-call.ts @@ -26,6 +26,7 @@ export { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, + RenameColumnCall, RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/check-rename-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/check-rename-planner.test.ts index 6cdc4ddfb7d6..f241de0cc5a7 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/check-rename-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/check-rename-planner.test.ts @@ -151,6 +151,8 @@ async function planOpIds( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -254,15 +256,25 @@ describe('what does not pair', () => { }); describe('policy gating', () => { - it('without widening the pair degrades to drop + add', async () => { - const opIds = await planOpIds( - buildContract([wire('items_email_present', 'ab12cd34')]), - actualSchema([wire('items_email_check', 'ab12cd34')]), - NO_WIDENING_POLICY, - ); - expect(opIds).toEqual([ - `dropCheckConstraint.${TABLE_NAME}.items_email_check_ab12cd34`, - `checkConstraint.${TABLE_NAME}.items_email_present_ab12cd34`, + it('without widening the old check can be neither renamed nor dropped, so the plan fails', () => { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: buildContract([wire('items_email_present', 'ab12cd34')]), + schema: actualSchema([wire('items_email_check', 'ab12cd34')]), + policy: { allowedOperationClasses: [...NO_WIDENING_POLICY.allowedOperationClasses] }, + fromContract: null, + origin: null, + statements: [], + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') return; + expect(result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'missingButNonAdditive', + summary: `Operation "Drop check constraint "items_email_check_ab12cd34" on "${TABLE_NAME}"" requires class "widening", but policy allows only: additive, destructive`, + }), ]); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/column-rename-calls.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/column-rename-calls.test.ts new file mode 100644 index 000000000000..e1a6eb79697c --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/column-rename-calls.test.ts @@ -0,0 +1,143 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { postgresColumnRenameCall } from '../../src/core/migrations/table-rename-calls'; +import { renameColumnInPostgresSchema } from '../../src/core/migrations/working-schema'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { + ORIGINAL_COLUMNS, + type ProfileColumns, + type ProfileObjects, + profileContract, +} from './rename-column-fixtures'; + +const EMAIL_RENAMED: ProfileColumns = { ...ORIGINAL_COLUMNS, email: 'emailAddress' }; + +function labelsFor( + objects: { readonly before: ProfileObjects; readonly after: ProfileObjects }, + rename: { readonly from: string; readonly to: string } = { from: 'email', to: 'emailAddress' }, + columns: ProfileColumns = EMAIL_RENAMED, +): readonly string[] { + const call = postgresColumnRenameCall({ + previous: postgresContractToSchema( + profileContract('from', { objects: objects.before }), + postgresTypeComponents, + ), + contract: profileContract('to', { columns, objects: objects.after }), + rename: { namespaceId: UNBOUND_NAMESPACE_ID, table: 'Profile', ...rename }, + frameworkComponents: postgresTypeComponents, + }); + return [call.label, ...call.companions.map((companion) => companion.label)]; +} + +describe('postgresColumnRenameCall companions', () => { + it('renames a derived unique constraint on the column to the name derived from the new column', () => { + const unique = { emailUnique: {} }; + expect(labelsFor({ before: unique, after: unique })).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "Profile_emailAddress_key" on "Profile"', + ]); + }); + + it('renames a unique constraint to the explicit name the destination gives it', () => { + expect( + labelsFor({ before: { emailUnique: {} }, after: { emailUnique: { name: 'profile_email' } } }), + ).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "profile_email" on "Profile"', + ]); + }); + + it('keeps an explicitly named unique constraint the destination names the same', () => { + const named = { emailUnique: { name: 'profile_email' } }; + expect(labelsFor({ before: named, after: named })).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + ]); + }); + + it('renames an index on the column to the wire name the destination gives it', () => { + const indexed = { emailIndex: true }; + const [table, index, ...rest] = labelsFor({ before: indexed, after: indexed }); + expect(table).toBe('Rename column "Profile"."email" to "emailAddress"'); + expect(index).toMatch( + /^Rename index "Profile_email_idx_[0-9a-f]+" to "Profile_emailAddress_idx_[0-9a-f]+" on "Profile"$/, + ); + expect(rest).toEqual([]); + }); + + it('renames a derived foreign key on the column to the name derived from the new column', () => { + const keyed = { accountForeignKey: {} }; + expect( + labelsFor( + { before: keyed, after: keyed }, + { from: 'accountId', to: 'ownerId' }, + { ...ORIGINAL_COLUMNS, account: 'ownerId' }, + ), + ).toEqual([ + 'Rename column "Profile"."accountId" to "ownerId"', + 'Rename foreign key "Profile_accountId_fkey" to "Profile_ownerId_fkey" on "Profile"', + ]); + }); + + it('leaves the primary key alone when its column is renamed', () => { + expect( + labelsFor( + { before: {}, after: {} }, + { from: 'id', to: 'profileId' }, + { + ...ORIGINAL_COLUMNS, + id: 'profileId', + }, + ), + ).toEqual(['Rename column "Profile"."id" to "profileId"']); + }); + + it('gives a check on the column no companion; the diff replaces it', () => { + const checked = { emailCheck: true }; + expect(labelsFor({ before: checked, after: checked })).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + ]); + }); +}); + +describe('renameColumnInPostgresSchema', () => { + const previous = postgresContractToSchema( + profileContract('from', { objects: { emailIndex: true, emailPartialIndex: true } }), + postgresTypeComponents, + ); + + it('follows the column in foreign keys that reference it from another table', () => { + const renamed = renameColumnInPostgresSchema(previous, { + schemaName: 'public', + table: 'Profile', + from: 'id', + to: 'profileId', + }); + const post = renamed.namespaces['public']?.tables['post']; + expect(post?.foreignKeys.map((fk) => [fk.referencedTable, fk.referencedColumns])).toEqual([ + ['Profile', ['profileId']], + ]); + expect(renamed.namespaces['public']?.tables['Profile']?.primaryKey?.columns).toEqual([ + 'profileId', + ]); + }); + + it('leaves the columns of an index with a predicate as they are', () => { + const renamed = renameColumnInPostgresSchema(previous, { + schemaName: 'public', + table: 'Profile', + from: 'email', + to: 'emailAddress', + }); + const indexes = renamed.namespaces['public']?.tables['Profile']?.indexes ?? []; + expect(indexes.map((index) => [index.where === undefined, index.columns])).toEqual([ + [true, ['emailAddress']], + [false, ['email']], + ]); + expect(Object.keys(renamed.namespaces['public']?.tables['Profile']?.columns ?? {})).toEqual([ + 'id', + 'emailAddress', + 'accountId', + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/full-text-index-planning.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/full-text-index-planning.test.ts index 250364e1a069..c4682a650536 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/full-text-index-planning.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/full-text-index-planning.test.ts @@ -170,6 +170,8 @@ async function plannedCreateIndexNodes(schema: string): Promise { }); describe('dropIndex lowers a PostgresDropIndex node', () => { - it('carries schema and name; the op is destructive', async () => { + it('carries schema and name; the op is widening, since an index holds no stored data', async () => { const { lowerer, received } = recordingLowerer(); const op = await dropIndex('public', 'user', 'user_email_idx', lowerer); const node = received.find((n) => n instanceof PostgresDropIndex) as PostgresDropIndex; expect(node).toBeDefined(); expect(node.schema).toBe('public'); expect(node.name).toBe('user_email_idx'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); expect(op.id).toBe('dropIndex.user.user_email_idx'); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/index-rename-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/index-rename-planner.test.ts index f30060acfddd..ec7df7f527bb 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/index-rename-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/index-rename-planner.test.ts @@ -143,6 +143,8 @@ async function planOpIds( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -346,7 +348,7 @@ describe('content pairing (exact→wire convergence)', () => { ]); }); - it('an unmatched extra stays a destructive drop leftover', async () => { + it('an unmatched extra stays a drop', async () => { const contract = buildContract([wireNamedIndex('items_email_idx', 'ab12cd34')]); const schema = actualSchema([ { name: 'items_email_idx_ab12cd34', prefix: 'items_email_idx', columns: ['email'] }, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/native-enum-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/native-enum-planner.test.ts index 3f2175788d30..0ddeb3f49b3a 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/native-enum-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/native-enum-planner.test.ts @@ -365,7 +365,7 @@ describe('op building (typed DDL node)', () => { const node = received.find((n): n is PostgresDropType => n instanceof PostgresDropType); expect(node?.schema).toBe('sales'); expect(node?.name).toBe('order_status'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); it('an unbound-namespace create builds a node with no schema so search_path resolves it', async () => { @@ -396,6 +396,8 @@ describe('planner ownership + policy for enum extras', () => { }), policy, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -551,6 +553,8 @@ describe('D2-F1: enum drop-safety resolves ownership by physical type name', () schema: liveInPublic([{ typeName: 'order_status', values: [...MEMBERS] }]), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -574,6 +578,8 @@ describe('D2-F1: enum drop-safety resolves ownership by physical type name', () ]), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.lowering.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.lowering.test.ts index a78b85f694b1..2ac6e997e24d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.lowering.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.lowering.test.ts @@ -888,13 +888,13 @@ describe('RawSqlCall', () => { }); describe('DataTransformCall', () => { - it('toOp() always throws MIGRATION.UNFILLED_PLACEHOLDER for the unfilled placeholder', () => { + it('toOp() always rejects with MIGRATION.UNFILLED_PLACEHOLDER for the unfilled placeholder', async () => { const call = new DataTransformCall( 'Backfill status', 'backfill-status:check', 'backfill-status:run', ); - expect(() => call.toOp()).toThrow( + await expect(call.toOp()).rejects.toThrow( expect.objectContaining({ code: 'MIGRATION.UNFILLED_PLACEHOLDER', meta: { slot: 'Backfill status' }, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts index 17f477b9ef9d..2206a3039662 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.check-constraints.test.ts @@ -146,7 +146,7 @@ describe('check planning is diff-driven', () => { toContract: contractWith([UNBOUND_NAMESPACE_ID], []), fromContract: null, schema: schemaWith([live]), - policy: { allowedOperationClasses: ['additive', 'destructive'] }, + policy: { allowedOperationClasses: ['additive', 'widening'] }, }); expect(result.ok).toBe(true); @@ -218,7 +218,7 @@ describe('a prefix-only change reaches the mapper as a missing/extra pair', () = ), fromContract: null, schema: schemaWith([actual]), - policy: { allowedOperationClasses: ['additive', 'destructive'] }, + policy: { allowedOperationClasses: ['additive', 'widening'] }, }); expect(result.ok).toBe(true); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.field-statements.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.field-statements.test.ts new file mode 100644 index 000000000000..9b69c2da0d4d --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.field-statements.test.ts @@ -0,0 +1,250 @@ +import { asNamespaceId, type Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + planOriginOf, + type ResolvedFieldRenameStatement, + type ResolvedMigrationStatement, + type ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import type { PostgresPlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import type { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { ORIGINAL_COLUMNS, type ProfileObjects, profileContract } from './rename-column-fixtures'; +import { stubLowerer } from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { + ...postgresTypeComponents[0], + create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'>, + }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: Contract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; +const NS = asNamespaceId(UNBOUND_NAMESPACE_ID); +const EMAIL_RENAMED = { ...ORIGINAL_COLUMNS, email: 'emailAddress' }; + +function renameField( + model: string, + from: string, + to: string, + newModel = model, +): ResolvedFieldRenameStatement { + return { + kind: 'rename', + entity: 'field', + from: { namespaceId: NS, model, field: from }, + to: { namespaceId: NS, model: newModel, field: to }, + }; +} + +function renameModel(from: string, to: string): ResolvedModelRenameStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: NS, model: from }, + to: { namespaceId: NS, model: to }, + }; +} + +function plan( + from: Contract, + to: Contract, + statements: readonly ResolvedMigrationStatement[], + schema: PostgresDatabaseSchemaNode = postgresContractToSchema(from, postgresTypeComponents), +) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema, + policy: ALL_CLASSES, + fromContract: from, + origin: planOriginOf(from), + statements, + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); +} + +function success(result: ReturnType) { + if (result.kind !== 'success') { + throw new Error(`expected a plan, got ${JSON.stringify(result.conflicts)}`); + } + return result; +} + +async function labelsOf(result: ReturnType): Promise { + const ops = await Promise.all(success(result).plan.operations); + return ops.map((op) => op.label); +} + +function contracts(objects: ProfileObjects = {}) { + return { + from: profileContract('from', { objects }), + to: profileContract('to', { columns: EMAIL_RENAMED, objects }), + }; +} + +const renameEmail = renameField('Profile', 'email', 'emailAddress'); + +describe('Postgres planner, field statements', () => { + it('plans the column rename and its companions in place of a drop and add', async () => { + const { from, to } = contracts({ emailUnique: {} }); + expect(await labelsOf(plan(from, to, [renameEmail]))).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "Profile_emailAddress_key" on "Profile"', + ]); + }); + + it('drops and adds the column without a statement', async () => { + const { from, to } = contracts(); + const labels = await labelsOf(plan(from, to, [])); + expect(labels).toEqual([ + 'Drop column "email" from "Profile"', + 'Add column emailAddress to Profile', + ]); + }); + + it('reports the statement with the positions of the operations it accounts for', () => { + const { from, to } = contracts({ emailUnique: {} }); + const result = success(plan(from, to, [renameEmail])); + expect(result.appliedStatements).toEqual([ + { statement: renameEmail, operationIndexes: [0, 1] }, + ]); + }); + + it('applies a statement whose column does not change with no operations', async () => { + const from = profileContract('from'); + const to = profileContract('to', { fields: EMAIL_RENAMED }); + const result = plan(from, to, [renameEmail]); + expect(await labelsOf(result)).toEqual([]); + expect(success(result).appliedStatements).toEqual([ + expect.objectContaining({ operationIndexes: [] }), + ]); + }); + + it('applies a relation field statement with no operations', async () => { + const from = profileContract('from'); + const result = plan(from, profileContract('from'), [renameField('Profile', 'posts', 'posts')]); + expect(await labelsOf(result)).toEqual([]); + expect(success(result).appliedStatements).toEqual([ + expect.objectContaining({ operationIndexes: [] }), + ]); + }); + + it('renames the column on the table an earlier model statement renamed', async () => { + const objects = { emailUnique: {} }; + const from = profileContract('from', { objects }); + const to = profileContract('to', { table: 'User', columns: EMAIL_RENAMED, objects }); + const result = plan(from, to, [ + renameModel('Profile', 'User'), + renameField('Profile', 'email', 'emailAddress', 'User'), + ]); + expect(await labelsOf(result)).toEqual([ + 'Rename table "Profile" to "User"', + 'Rename primary key "Profile_pkey" to "User_pkey" on "User"', + 'Rename column "User"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "User_emailAddress_key" on "User"', + ]); + expect( + success(result).appliedStatements.map((applied) => applied.operationIndexes.length), + ).toEqual([2, 2]); + }); + + it('replaces a check on the column after the rename', async () => { + const { from, to } = contracts({ emailCheck: true }); + const labels = await labelsOf(plan(from, to, [renameEmail])); + expect(labels[0]).toBe('Rename column "Profile"."email" to "emailAddress"'); + expect(labels.slice(1).some((label) => label.startsWith('Drop check constraint'))).toBe(true); + expect(labels.slice(1).some((label) => label.startsWith('Add check constraint'))).toBe(true); + }); + + it('replaces an index with a predicate on the column after the rename', async () => { + const { from, to } = contracts({ emailPartialIndex: true }); + const labels = await labelsOf(plan(from, to, [renameEmail])); + expect(labels[0]).toBe('Rename column "Profile"."email" to "emailAddress"'); + expect(labels.slice(1).some((label) => label.startsWith('Drop index'))).toBe(true); + expect(labels.slice(1).some((label) => label.startsWith('Create index'))).toBe(true); + }); + + it('refuses a rename whose column the schema being planned from does not have', () => { + const { from, to } = contracts(); + const otherSchema = postgresContractToSchema( + profileContract('other', { columns: EMAIL_RENAMED }), + postgresTypeComponents, + ); + const result = plan(from, to, [renameEmail], otherSchema); + expect(result.kind === 'failure' && result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + refusedStatement: renameEmail, + summary: expect.stringContaining('has no column "email"'), + }), + ]); + }); + + it('refuses a rename on a table the destination renames, naming the renameTable call to write by hand', () => { + const from = profileContract('from'); + const to = profileContract('to', { table: 'User', columns: EMAIL_RENAMED }); + const statement = renameField('Profile', 'email', 'emailAddress', 'User'); + const result = plan(from, to, [statement]); + expect(result.kind === 'failure' && result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + refusedStatement: statement, + why: expect.stringContaining( + 'add ...this.renameTable({ table: "Profile", to: "User" }) to the operations of its migration.ts', + ), + }), + ]); + expect(result.kind === 'failure' && result.conflicts[0]?.why).toContain( + 'With db update, change the contract so that only the table name changes, and run prisma contract emit. Rename the table in the database yourself with ALTER TABLE "Profile" RENAME TO "User", check that prisma db update --dry-run plans no drop of a table or column, and run prisma db update to store that contract. Then emit the final contract and run prisma db update --rename User.email:User.emailAddress.', + ); + }); + + it('emits the same operations as the facade call it renders, run against the same contracts', async () => { + const objects = { emailUnique: {}, emailIndex: true }; + const from = profileContract('a'.repeat(64), { objects }); + const to = profileContract('b'.repeat(64), { columns: EMAIL_RENAMED, objects }); + const result = success(plan(from, to, [renameEmail])); + const rendered = + '...this.renameColumn({ table: "Profile", column: "email", to: "emailAddress" })'; + expect(result.plan.renderTypeScript((specifier) => specifier)).toContain(rendered); + + const startJson = jsonOf(from); + const endJson = jsonOf(to); + class HandWritten extends PostgresMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameColumn({ table: 'Profile', column: 'email', to: 'emailAddress' })]; + } + } + const planned = await Promise.all(result.plan.operations); + const handWritten = await Promise.all(new HandWritten(stack).operations); + expect(handWritten).toEqual(planned); + expect(planned.map((op) => op.id)).toEqual([ + 'renameColumn.Profile.email', + expect.stringContaining('Profile_email_key'), + expect.stringContaining('Profile_email_idx'), + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.operation-positions.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.operation-positions.test.ts new file mode 100644 index 000000000000..7cedccbdf9a0 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.operation-positions.test.ts @@ -0,0 +1,133 @@ +import { + asNamespaceId, + type Contract, + type ContractModelBase, + coreHash, + profileHash, +} from '@internal/contract/types'; +import { + APP_SPACE_ID, + planOriginOf, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { stubLowerer } from './rename-table-fixtures'; + +const NAMESPACES = ['auth', 'billing'] as const; +const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; +const text = { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }; + +function model(namespaceId: string, table: string): ContractModelBase { + return { + fields: {}, + relations: {}, + storage: { table, namespaceId, fields: {} }, + }; +} + +/** The same model and table name in two schemas. */ +function contractWithTableInBothSchemas(table: string, hashSeed: string): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: Object.fromEntries( + NAMESPACES.map((id) => [ + id, + postgresCreateNamespace({ + id, + entries: { + table: { + [table]: new StorageTable({ + columns: { id: int4, email: text, note: text }, + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + indexes: [], + foreignKeys: [], + }), + }, + }, + }), + ]), + ), + }), + domain: { + namespaces: Object.fromEntries( + NAMESPACES.map((id) => [id, { models: { [table]: model(id, table) } }]), + ), + }, + roots: {}, + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function renameIn(namespaceId: string): ResolvedMigrationStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: asNamespaceId(namespaceId), model: 'Profile' }, + to: { namespaceId: asNamespaceId(namespaceId), model: 'User' }, + }; +} + +function plan( + from: Contract, + to: Contract, + statements: readonly ResolvedMigrationStatement[], +) { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, postgresTypeComponents), + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + fromContract: from, + origin: planOriginOf(from), + statements, + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') { + throw new Error(`expected a plan, got ${JSON.stringify(result.conflicts)}`); + } + return result; +} + +type PlannedOperation = { + readonly id: string; + readonly target: { readonly details?: { readonly schema?: string } }; +}; + +async function operationsOf(result: ReturnType): Promise { + return Promise.all(result.plan.operations); +} + +describe('Postgres statements over same-named tables in two schemas', () => { + const from = contractWithTableInBothSchemas('Profile', 'from'); + const to = contractWithTableInBothSchemas('User', 'to'); + + it('point each statement at its own operations by position, although their ids are the same', async () => { + const result = plan(from, to, [renameIn('auth'), renameIn('billing')]); + const operations = await operationsOf(result); + const [auth, billing] = result.appliedStatements.map((applied) => + applied.operationIndexes.map((index) => operations[index]), + ); + + expect(auth?.map((operation) => operation?.target.details?.schema)).toEqual( + auth?.map(() => 'auth'), + ); + expect(billing?.map((operation) => operation?.target.details?.schema)).toEqual( + billing?.map(() => 'billing'), + ); + expect(auth?.[0]?.id).toBe(billing?.[0]?.id); + expect(auth?.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.sibling-scoping.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.sibling-scoping.test.ts index b726d2740db5..ae10f0e3949f 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.sibling-scoping.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.sibling-scoping.test.ts @@ -250,6 +250,8 @@ describe('Postgres planner ownership consultation', () => { schema: buildLiveSchema(), policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -272,6 +274,8 @@ describe('Postgres planner ownership consultation', () => { schema: buildLiveSchema(), policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -293,6 +297,8 @@ describe('Postgres planner ownership consultation', () => { schema: buildLiveSchema(), policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -340,6 +346,8 @@ describe('Postgres planner ownership consultation', () => { schema: liveWithDriftColumn, policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -366,6 +374,8 @@ describe('Postgres planner ownership consultation', () => { schema: buildLiveSchemaWithCrossNamespaceOrphan(), policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -390,6 +400,8 @@ describe('Postgres planner ownership consultation', () => { schema: buildLiveSchema(), policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts new file mode 100644 index 000000000000..352c4b6195b4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts @@ -0,0 +1,313 @@ +import { asNamespaceId, type Contract } from '@internal/contract/types'; +import { + APP_SPACE_ID, + type MigrationOperationPolicy, + planOriginOf, + type ResolvedMigrationStatement, + type ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { type SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import type { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { + contractOf, + type ProfileSpec, + reference, + stubLowerer, + withModels, +} from './rename-table-fixtures'; + +const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; + +function renameModel( + from: string, + to: string, + fromNs: string = UNBOUND_NAMESPACE_ID, + toNs: string = fromNs, +): ResolvedModelRenameStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: asNamespaceId(fromNs), model: from }, + to: { namespaceId: asNamespaceId(toNs), model: to }, + }; +} + +const uniqueEmail: ProfileSpec = { uniques: [{ columns: ['email'] }] }; + +function postTableNamed(postTableName: string, profileTableName: string): StorageTable { + const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; + return new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'], name: `${postTableName}_pk` }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference(postTableName, ['profileId']), + target: reference(profileTableName, ['id']), + }, + ], + }); +} + +function plan( + from: Contract, + to: Contract, + statements: readonly ResolvedMigrationStatement[], + schema: PostgresDatabaseSchemaNode = postgresContractToSchema(from, postgresTypeComponents), + policy: MigrationOperationPolicy = ALL_CLASSES, +) { + return createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema, + policy, + fromContract: from, + origin: planOriginOf(from), + statements, + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); +} + +async function labelsOf(result: ReturnType): Promise { + if (result.kind !== 'success') { + throw new Error(`expected a plan, got ${JSON.stringify(result.conflicts)}`); + } + const ops = await Promise.all(result.plan.operations); + return ops.map((op) => op.label); +} + +function conflictsOf(result: ReturnType) { + if (result.kind !== 'failure') throw new Error('expected the plan to fail'); + return result.conflicts; +} + +describe('Postgres planner, model statements', () => { + const from = withModels(contractOf('Profile', uniqueEmail, 'from'), { Profile: 'Profile' }); + const to = withModels(contractOf('User', uniqueEmail, 'to'), { User: 'User' }); + + it('plans the table rename and its companions in place of a drop and create', async () => { + expect(await labelsOf(plan(from, to, [renameModel('Profile', 'User')]))).toEqual([ + 'Rename table "Profile" to "User"', + 'Rename unique constraint "Profile_email_key" to "User_email_key" on "User"', + ]); + }); + + it('reads the statements against fromContract and asserts only the origin it is given', async () => { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, postgresTypeComponents), + policy: ALL_CLASSES, + fromContract: from, + origin: null, + statements: [renameModel('Profile', 'User')], + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error('expected a plan'); + expect(result.appliedStatements).toHaveLength(1); + expect({ origin: result.plan.origin, from: result.plan.describe().from }).toEqual({ + origin: null, + from: null, + }); + }); + + it('drops and creates the table without a statement', async () => { + const labels = await labelsOf(plan(from, to, [])); + expect(labels).toContain('Create table "User"'); + expect(labels).toContain('Drop table "Profile"'); + }); + + it('reports each statement with the positions of the operations it accounts for', () => { + const statement = renameModel('Profile', 'User'); + const result = plan(from, to, [statement]); + if (result.kind !== 'success') throw new Error('expected a plan'); + expect(result.appliedStatements).toEqual([{ statement, operationIndexes: [0, 1] }]); + }); + + it('puts the renames ahead of the rest of the plan', async () => { + const toWithColumn = withModels( + contractOf('User', uniqueEmail, 'to', (profile) => ({ + audit: postTableNamed('audit', profile), + })), + { User: 'User' }, + ); + const labels = await labelsOf(plan(from, toWithColumn, [renameModel('Profile', 'User')])); + expect(labels.slice(0, 2)).toEqual([ + 'Rename table "Profile" to "User"', + 'Rename unique constraint "Profile_email_key" to "User_email_key" on "User"', + ]); + expect(labels.slice(2)).toContain('Create table "audit"'); + }); + + it('applies a statement whose table does not change with no operations', async () => { + const mapped = withModels(contractOf('profile', uniqueEmail, 'to'), { User: 'profile' }); + const fromMapped = withModels(contractOf('profile', uniqueEmail, 'from'), { + Profile: 'profile', + }); + const result = plan(fromMapped, mapped, [renameModel('Profile', 'User')]); + expect(await labelsOf(result)).toEqual([]); + expect(result.kind === 'success' && result.appliedStatements).toEqual([ + expect.objectContaining({ operationIndexes: [] }), + ]); + }); + + it('plans two statements in the order given, each with its companions', async () => { + const fromTwo = withModels( + contractOf('Profile', uniqueEmail, 'from', (profile) => ({ + post: postTableNamed('post', profile), + })), + { Profile: 'Profile', Post: 'post' }, + ); + const toTwo = withModels( + contractOf('User', uniqueEmail, 'to', (profile) => ({ + Article: postTableNamed('Article', profile), + })), + { User: 'User', Article: 'Article' }, + ); + expect( + await labelsOf( + plan(fromTwo, toTwo, [renameModel('Profile', 'User'), renameModel('Post', 'Article')]), + ), + ).toEqual([ + 'Rename table "Profile" to "User"', + 'Rename unique constraint "Profile_email_key" to "User_email_key" on "User"', + 'Rename table "post" to "Article"', + 'Rename primary key "post_pk" to "Article_pk" on "Article"', + 'Rename foreign key "post_profileId_fkey" to "Article_profileId_fkey" on "Article"', + ]); + }); + + it('pairs foreign keys against the tables earlier statements renamed', async () => { + const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; + const ownerTable = () => + new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'], name: 'owner_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }); + const linkTable = ( + name: string, + keys: readonly (readonly [fkName: string, referencedTable: string])[], + ) => + new StorageTable({ + columns: { id: int4, refId: int4 }, + primaryKey: { columns: ['id'], name: 'link_pk' }, + uniques: [], + indexes: [], + foreignKeys: keys.map(([fkName, referencedTable]) => ({ + source: reference(name, ['refId']), + target: reference(referencedTable, ['id']), + name: fkName, + })), + }); + const fromThree = withModels( + contractOf('Profile', uniqueEmail, 'from', () => ({ + Owner: ownerTable(), + post: linkTable('post', [ + ['post_a', 'Profile'], + ['post_b', 'Owner'], + ]), + })), + { Profile: 'Profile', Owner: 'Owner', Post: 'post' }, + ); + const toThree = withModels( + contractOf('User', uniqueEmail, 'to', () => ({ + Member: ownerTable(), + Article: linkTable('Article', [ + ['article_b', 'Member'], + ['article_a', 'User'], + ]), + })), + { User: 'User', Member: 'Member', Article: 'Article' }, + ); + const labels = await labelsOf( + plan(fromThree, toThree, [ + renameModel('Profile', 'User'), + renameModel('Owner', 'Member'), + renameModel('Post', 'Article'), + ]), + ); + expect(labels).toContain('Rename foreign key "post_a" to "article_a" on "Article"'); + expect(labels).toContain('Rename foreign key "post_b" to "article_b" on "Article"'); + }); + + it('plans a case-only rename as a rename instead of refusing it', async () => { + const fromLower = withModels(contractOf('user', {}, 'from'), { user: 'user' }); + const toUpper = withModels(contractOf('User', {}, 'to'), { User: 'User' }); + expect(await labelsOf(plan(fromLower, toUpper, [renameModel('user', 'User')]))).toEqual([ + 'Rename table "user" to "User"', + ]); + }); + + it('refuses a statement under a policy that does not allow its operations', () => { + const statement = renameModel('Profile', 'User'); + const additiveOnly = { allowedOperationClasses: ['additive'] as const }; + expect(conflictsOf(plan(from, to, [statement], undefined, additiveOnly))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + refusedOperationClass: 'widening', + refusedStatement: statement, + }), + ]); + }); + + it('refuses a rename of a table whose control policy is not managed', () => { + const external = withModels(contractOf('User', { ...uniqueEmail, control: 'external' }, 'to'), { + User: 'User', + }); + const statement = renameModel('Profile', 'User'); + expect(conflictsOf(plan(from, external, [statement]))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: expect.stringContaining('control policy is "external"'), + refusedStatement: statement, + location: { namespaceId: UNBOUND_NAMESPACE_ID, entityKind: 'table', entityName: 'User' }, + }), + ]); + }); + + it('refuses a rename whose table the schema being planned from does not have', () => { + const otherSchema = postgresContractToSchema( + contractOf('Other', {}, 'other'), + postgresTypeComponents, + ); + expect(conflictsOf(plan(from, to, [renameModel('Profile', 'User')], otherSchema))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: expect.stringContaining('has no table "Profile"'), + }), + ]); + }); + + it('refuses moving a model to another namespace, naming both coordinates', () => { + const fromAuth = withModels( + contractOf('User', {}, 'from', () => ({}), 'auth'), + { User: 'User' }, + 'auth', + ); + const toBilling = withModels( + contractOf('User', {}, 'to', () => ({}), 'billing'), + { User: 'User' }, + 'billing', + ); + const statement = renameModel('User', 'User', 'auth', 'billing'); + expect(conflictsOf(plan(fromAuth, toBilling, [statement]))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: + 'Moving a model to another namespace is not supported in this release: "auth.User" to "billing.User"', + refusedStatement: statement, + }), + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts index 883480816e22..15347cfd53e6 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.table-name-case-guard.test.ts @@ -15,7 +15,11 @@ import type { ExecuteRequestLowerer, SqlControlAdapter, } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID, type ControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + type ControlStack, + planOriginOf, +} from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; @@ -156,6 +160,8 @@ function planFromLive( schema: liveSchema(previousTables, options.schemaName), policy: DESTRUCTIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -297,6 +303,8 @@ function planMigration(from: PostgresContract, to: PostgresContract) { }), policy: DESTRUCTIVE_POLICY, fromContract: from, + origin: planOriginOf(from), + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -366,7 +374,12 @@ describe('the renameTable call the Postgres case guard suggests', () => { expect(result).toEqual({ call: '...this.renameTable({ schema: "public", table: "userProfile", to: "UserProfile" })', - statements: [['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"']], + statements: [ + ['ALTER TABLE "public"."userProfile" RENAME TO "UserProfile"'], + [ + 'ALTER TABLE "public"."UserProfile" RENAME CONSTRAINT "userProfile_pkey" TO "UserProfile_pkey"', + ], + ], }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-column-facade.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-facade.test.ts new file mode 100644 index 000000000000..f5419c257bbe --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-facade.test.ts @@ -0,0 +1,158 @@ +/** + * `this.renameColumn` in a hand-written Postgres migration: the column rename, then a rename of + * each unique constraint, foreign key and index on the column whose name derives from the column + * name, computed against the schema the migration's earlier renames leave. + */ + +import type { Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { buildMigrationArtifacts } from '@internal/migration-tools/migration'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import type { PostgresPlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { ORIGINAL_COLUMNS, profileContract } from './rename-column-fixtures'; +import { stubLowerer } from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; +type Rename = + | { readonly kind: 'table'; readonly table: string; readonly to: string } + | { + readonly kind: 'column'; + readonly table: string; + readonly column: string; + readonly to: string; + }; + +const stack = { + adapter: { + ...postgresTypeComponents[0], + create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'>, + }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: Contract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +function renameMigration( + start: Contract, + end: Contract, + renames: readonly Rename[], +): PostgresMigration & { readonly operations: readonly Promise[] } { + const startJson = jsonOf(start); + const endJson = jsonOf(end); + class Renames extends PostgresMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return renames.flatMap(({ kind, ...options }) => + kind === 'table' + ? this.renameTable({ table: options.table, to: options.to }) + : this.renameColumn({ + table: options.table, + column: 'column' in options ? options.column : '', + to: options.to, + }), + ); + } + } + return new Renames(stack); +} + +async function labelsOf( + start: Contract, + end: Contract, + renames: readonly Rename[], +): Promise { + const ops = await Promise.all(renameMigration(start, end, renames).operations); + return ops.map((op) => op.label); +} + +const EMAIL_RENAMED = { ...ORIGINAL_COLUMNS, email: 'emailAddress' }; +const renameEmail: Rename = { + kind: 'column', + table: 'Profile', + column: 'email', + to: 'emailAddress', +}; + +describe('PostgresMigration.renameColumn', () => { + it('emits the column rename, then a rename of each object named after the old column', async () => { + const objects = { emailUnique: {}, emailIndex: true }; + const labels = await labelsOf( + profileContract('from', { objects }), + profileContract('to', { columns: EMAIL_RENAMED, objects }), + [renameEmail], + ); + expect(labels.slice(0, 2)).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "Profile_emailAddress_key" on "Profile"', + ]); + expect(labels[2]).toMatch(/^Rename index "Profile_email_idx_.*" to "Profile_emailAddress_idx_/); + expect(labels).toHaveLength(3); + }); + + it('renders the rename statement', async () => { + const ops: readonly Op[] = await Promise.all( + renameMigration(profileContract('from'), profileContract('to', { columns: EMAIL_RENAMED }), [ + renameEmail, + ]).operations, + ); + expect(ops.map((op) => op.execute.map((step) => step.sql))).toEqual([ + ['ALTER TABLE "Profile" RENAME COLUMN "email" TO "emailAddress"'], + ]); + }); + + it('renames a column of a table an earlier renameTable renamed, on its new name', async () => { + const objects = { emailUnique: {} }; + expect( + await labelsOf( + profileContract('from', { objects }), + profileContract('to', { table: 'User', columns: EMAIL_RENAMED, objects }), + [ + { kind: 'table', table: 'Profile', to: 'User' }, + { kind: 'column', table: 'User', column: 'email', to: 'emailAddress' }, + ], + ), + ).toEqual([ + 'Rename table "Profile" to "User"', + 'Rename primary key "Profile_pkey" to "User_pkey" on "User"', + 'Rename column "User"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "User_emailAddress_key" on "User"', + ]); + }); + + it('builds the artifacts again with the same operations', async () => { + const migration = renameMigration( + profileContract('from', { objects: { emailUnique: {} } }), + profileContract('to', { columns: EMAIL_RENAMED, objects: { emailUnique: {} } }), + [renameEmail], + ); + const first = await buildMigrationArtifacts(migration, null); + const second = await buildMigrationArtifacts(migration, null); + expect(second.opsJson).toBe(first.opsJson); + expect((JSON.parse(first.opsJson) as readonly Op[]).map((op) => op.label)).toEqual([ + 'Rename column "Profile"."email" to "emailAddress"', + 'Rename unique constraint "Profile_email_key" to "Profile_emailAddress_key" on "Profile"', + ]); + }); + + it('refuses a column the table does not have at that point of the migration', () => { + const migration = renameMigration( + profileContract('from'), + profileContract('to', { columns: EMAIL_RENAMED }), + [{ kind: 'column', table: 'Profile', column: 'nickname', to: 'emailAddress' }], + ); + expect(() => migration.operations).toThrow( + expect.objectContaining({ code: 'MIGRATION.COLUMN_RENAME_UNMATCHED' }), + ); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-column-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-fixtures.ts new file mode 100644 index 000000000000..99a65de44cd1 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-fixtures.ts @@ -0,0 +1,214 @@ +import { type Contract, coreHash, crossRef, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { + type CheckConstraintInput, + type IndexInput, + SqlStorage, + StorageTable, +} from '@internal/sql-contract/types'; +import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { postgresCreateNamespace } from '../../src/core/postgres-schema'; +import { reference } from './rename-table-fixtures'; + +const text = { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }; +const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; +const scalar = { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } } as const; + +/** The names the profile table's columns have in one contract. */ +export interface ProfileColumns { + readonly id: string; + readonly email: string; + readonly account: string; +} + +export const ORIGINAL_COLUMNS: ProfileColumns = { id: 'id', email: 'email', account: 'accountId' }; + +/** The objects on the profile table, each named after the column it covers. */ +export interface ProfileObjects { + /** A unique constraint on the email column: `undefined` name means a derived name. */ + readonly emailUnique?: { readonly name?: string }; + readonly emailIndex?: boolean; + /** A predicate index on the email column, whose SQL text names the column. */ + readonly emailPartialIndex?: boolean; + readonly emailCheck?: boolean; + /** A foreign key from the account column to the account table. */ + readonly accountForeignKey?: { readonly name?: string }; +} + +export function emailIndex(tableName: string, column: string): IndexInput { + return { + columns: [column], + naming: { + kind: 'wire', + prefix: `${tableName}_${column}_idx`, + hash: computeIndexContentHash({ columns: [column], unique: false }), + }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} + +function emailPartialIndex(tableName: string, column: string): IndexInput { + const where = `"${column}" <> ''`; + return { + columns: [column], + naming: { + kind: 'wire', + prefix: `${tableName}_${column}_present_idx`, + hash: computeIndexContentHash({ columns: [column], unique: false, where }), + }, + where, + unique: false, + type: undefined, + options: undefined, + }; +} + +function emailCheck(tableName: string, column: string): CheckConstraintInput { + const expression = `length("${column}") > 0`; + return { + naming: { + kind: 'wire', + prefix: `${tableName}_${column}_check`, + hash: computeCheckContentHash(expression), + }, + expression, + }; +} + +function profileTable( + tableName: string, + columns: ProfileColumns, + objects: ProfileObjects, +): StorageTable { + return new StorageTable({ + columns: { [columns.id]: int4, [columns.email]: text, [columns.account]: int4 }, + primaryKey: { columns: [columns.id] }, + uniques: + objects.emailUnique === undefined + ? [] + : [ + { + columns: [columns.email], + ...(objects.emailUnique.name === undefined ? {} : { name: objects.emailUnique.name }), + }, + ], + indexes: [ + ...(objects.emailIndex ? [emailIndex(tableName, columns.email)] : []), + ...(objects.emailPartialIndex ? [emailPartialIndex(tableName, columns.email)] : []), + ], + foreignKeys: + objects.accountForeignKey === undefined + ? [] + : [ + { + source: reference(tableName, [columns.account]), + target: reference('account', ['id']), + ...(objects.accountForeignKey.name === undefined + ? {} + : { name: objects.accountForeignKey.name }), + }, + ], + checks: objects.emailCheck ? [emailCheck(tableName, columns.email)] : [], + }); +} + +/** + * A contract with a profile table, an account table it references, and a post table whose + * foreign key references the profile table's id column. Its domain has one model per table, each + * field stored in the column of the same name in `columns`. + */ +export function profileContract( + hashSeed: string, + options: { + readonly table?: string; + readonly model?: string; + readonly columns?: ProfileColumns; + readonly fields?: ProfileColumns; + readonly objects?: ProfileObjects; + } = {}, +): Contract { + const table = options.table ?? 'Profile'; + const columns = options.columns ?? ORIGINAL_COLUMNS; + const fields = options.fields ?? columns; + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: postgresCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [table]: profileTable(table, columns, options.objects ?? {}), + account: new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + post: new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(table, [columns.id]), + }, + ], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: { + [options.model ?? table]: { + fields: { + [fields.id]: scalar, + [fields.email]: scalar, + [fields.account]: scalar, + }, + relations: { + posts: { + to: crossRef('Post'), + cardinality: '1:N', + on: { localFields: [fields.id], targetFields: ['profileId'] }, + }, + }, + storage: { + table, + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { + [fields.id]: { column: columns.id }, + [fields.email]: { column: columns.email }, + [fields.account]: { column: columns.account }, + }, + }, + }, + Post: { + fields: { id: scalar, profileId: scalar }, + relations: {}, + storage: { + table: 'post', + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { id: { column: 'id' }, profileId: { column: 'profileId' } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-column-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-ops.test.ts new file mode 100644 index 000000000000..19700159d487 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-column-ops.test.ts @@ -0,0 +1,119 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { columnExistsAst } from '../../src/contract-free/checks'; +import { RenameColumnCall, RenameConstraintCall } from '../../src/core/migrations/op-factory-call'; +import { renameColumn } from '../../src/core/migrations/operations/columns'; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ + sql: `LOWERED ${received.length}`, + params: Object.freeze([`p${received.length}`]), + }); + }, + renderColumnDefault: async () => '', + }; + return { lowerer, received }; +} + +describe('renameColumn (postgres)', () => { + it('renders a schema-qualified ALTER TABLE ... RENAME COLUMN', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameColumn('auth', 'User', 'name', 'fullName', lowerer); + + expect(op).toMatchObject({ + id: 'renameColumn.User.name', + label: 'Rename column "User"."name" to "fullName"', + operationClass: 'widening', + target: { + id: 'postgres', + details: { schema: 'auth', objectType: 'column', name: 'fullName', table: 'User' }, + }, + execute: [ + { + description: 'rename column "name" to "fullName"', + sql: 'ALTER TABLE "auth"."User" RENAME COLUMN "name" TO "fullName"', + }, + ], + }); + }); + + it('renders an unqualified statement for the unbound namespace, and a case-only rename as is', async () => { + const { lowerer } = recordingCheckLowerer(); + const op = await renameColumn(UNBOUND_NAMESPACE_ID, 'User', 'name', 'Name', lowerer); + + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "User" RENAME COLUMN "name" TO "Name"', + ]); + }); + + it('prechecks that the old column exists and the new one does not, then postchecks both', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await renameColumn('public', 'User', 'name', 'fullName', lowerer); + const checks = (column: string) => columnExistsAst({ schema: 'public', table: 'User', column }); + + expect(received).toEqual([ + checks('name').columnPresent(), + checks('name').columnAbsent(), + checks('fullName').columnPresent(), + checks('fullName').columnAbsent(), + ]); + expect(op.precheck).toEqual([ + { description: 'ensure column "name" exists', sql: 'LOWERED 1', params: ['p1'] }, + { description: 'ensure column "fullName" does not exist', sql: 'LOWERED 4', params: ['p4'] }, + ]); + expect(op.postcheck).toEqual([ + { description: 'verify column "fullName" exists', sql: 'LOWERED 3', params: ['p3'] }, + { description: 'verify column "name" no longer exists', sql: 'LOWERED 2', params: ['p2'] }, + ]); + }); +}); + +describe('RenameColumnCall (postgres)', () => { + it('is a widening renameColumn call whose contract-side identity is the new name', () => { + expect(new RenameColumnCall('public', 'User', 'name', 'fullName', [])).toMatchObject({ + factoryName: 'renameColumn', + operationClass: 'widening', + schemaName: 'public', + tableName: 'User', + oldColumnName: 'name', + columnName: 'fullName', + label: 'Rename column "User"."name" to "fullName"', + }); + }); + + it('toOp() without a lowerer reports MIGRATION.POSTGRES_CONTROL_STACK_MISSING', async () => { + await expect( + new RenameColumnCall('public', 'User', 'name', 'fullName', []).toOp(), + ).rejects.toMatchObject({ + code: 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', + meta: { factory: 'RenameColumnCall' }, + }); + }); + + it('renderTypeScript() spreads the facade call, schema-qualified only when bound', () => { + expect(new RenameColumnCall('auth', 'User', 'name', 'fullName', []).renderTypeScript()).toBe( + '...this.renameColumn({ schema: "auth", table: "User", column: "name", to: "fullName" })', + ); + expect( + new RenameColumnCall(UNBOUND_NAMESPACE_ID, 'User', 'name', 'fullName', []).renderTypeScript(), + ).toBe('...this.renameColumn({ table: "User", column: "name", to: "fullName" })'); + }); + + it('toOps() lowers the column rename, then each companion', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new RenameColumnCall('public', 'User', 'name', 'fullName', [ + new RenameConstraintCall('public', 'User', 'unique', 'User_name_key', 'User_fullName_key'), + ]); + const ops = await Promise.all(call.toOps(lowerer)); + expect(ops.map((op) => op.id)).toEqual([ + 'renameColumn.User.name', + expect.stringContaining('User_name_key'), + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.authoring-state.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.authoring-state.test.ts new file mode 100644 index 000000000000..69df00f16a37 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.authoring-state.test.ts @@ -0,0 +1,133 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { buildMigrationArtifacts } from '@internal/migration-tools/migration'; +import type { IndexInput, SqlStorage } from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { describe, expect, it } from 'vitest'; +import type { PostgresPlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { PostgresMigration } from '../../src/core/migrations/postgres-migration'; +import { PostgresContractSerializer } from '../../src/core/postgres-contract-serializer'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { contractOf, type ProfileSpec, stubLowerer } from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { + ...postgresTypeComponents[0], + create: () => stubLowerer as unknown as SqlControlAdapter<'postgres'>, + }, + target: { kind: 'target', familyId: 'sql', targetId: 'postgres' }, + extensions: [], +} as unknown as ControlStack<'sql', 'postgres'>; + +function jsonOf(contract: Contract): ContractJson { + return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); +const OLD_INDEX = `userProfile_handle_idx_${HANDLE_HASH}`; + +const withHandleIndex: ProfileSpec = { + indexes: (tableName): readonly IndexInput[] => [ + { + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }, + ], +}; + +function migrationOf( + operations: (migration: { + renameIndex: (options: { + schema: string; + table: string; + from: string; + to: string; + }) => Promise; + renameTable: (options: { table: string; to: string }) => readonly Promise[]; + }) => readonly Promise[], +): PostgresMigration & { readonly operations: readonly Promise[] } { + const startJson = jsonOf(contractOf('userProfile', withHandleIndex, 'from')); + const endJson = jsonOf(contractOf('UserProfile', withHandleIndex, 'to')); + class HandWritten extends PostgresMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return operations({ + renameIndex: (options) => this.renameIndex(options), + renameTable: (options) => this.renameTable(options), + }); + } + } + return new HandWritten(stack); +} + +describe('PostgresMigration authoring state', () => { + it('renames the index from the name an earlier renameIndex gave it', async () => { + const ops = await Promise.all( + migrationOf((m) => [ + m.renameIndex({ + schema: 'public', + table: 'userProfile', + from: OLD_INDEX, + to: 'custom_handle', + }), + ...m.renameTable({ table: 'userProfile', to: 'UserProfile' }), + ]).operations, + ); + + expect(ops.map((op) => op.label)).toEqual([ + `Rename index "${OLD_INDEX}" to "custom_handle" on "userProfile"`, + 'Rename table "userProfile" to "UserProfile"', + `Rename index "custom_handle" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, + ]); + }); + + it('builds the artifacts again and reads providedInvariants, each from the start contract', async () => { + const migration = migrationOf((m) => [ + ...m.renameTable({ table: 'userProfile', to: 'UserProfile' }), + ]); + + const first = await buildMigrationArtifacts(migration, null); + expect(migration.providedInvariants).toEqual([]); + expect(migration.providedInvariants).toEqual([]); + const second = await buildMigrationArtifacts(migration, null); + + expect(second.opsJson).toBe(first.opsJson); + expect((JSON.parse(first.opsJson) as readonly Op[]).map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + `Rename index "${OLD_INDEX}" to "UserProfile_handle_idx_${HANDLE_HASH}" on "UserProfile"`, + ]); + }); + + it('renames an index without reading the start contract when the migration renames no table', async () => { + const endJson = jsonOf(contractOf('userProfile', withHandleIndex, 'to')); + class IndexOnly extends PostgresMigration { + override readonly startContractJson = { storage: { storageHash: 'not-a-full-contract' } }; + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [ + this.renameIndex({ + schema: 'public', + table: 'userProfile', + from: OLD_INDEX, + to: 'custom_handle', + }), + ]; + } + } + const ops = await Promise.all(new IndexOnly(stack).operations); + + expect(ops.map((op) => op.label)).toEqual([ + `Rename index "${OLD_INDEX}" to "custom_handle" on "userProfile"`, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts index eac1153863b7..9bcde58b061d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-facade.test.ts @@ -6,6 +6,7 @@ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import type { ControlStack } from '@internal/framework-components/control'; +import { Migration } from '@internal/migration-tools/migration'; import { type CheckConstraintInput, type ForeignKeyInput, @@ -48,16 +49,19 @@ function jsonOf(contract: Contract): ContractJson { return new PostgresContractSerializer().serializeContract(contract) as unknown as ContractJson; } +type TableRenameOptions = { readonly schema?: string; readonly table: string; readonly to: string }; + function renameMigration( start: Contract | null, end: Contract, - rename: { readonly schema?: string; readonly table: string; readonly to: string }, -): { readonly operations: readonly Promise[] } { + rename: TableRenameOptions, + ...more: readonly TableRenameOptions[] +): PostgresMigration & { readonly operations: readonly Promise[] } { const endJson = jsonOf(end); class WithoutStart extends PostgresMigration { override readonly endContractJson = endJson; override get operations(): readonly Promise[] { - return [...this.renameTable(rename)]; + return [rename, ...more].flatMap((each) => this.renameTable(each)); } } if (start === null) return new WithoutStart(stack); @@ -199,7 +203,7 @@ describe('PostgresMigration.renameTable', () => { ]); }); - it('leaves a foreign key the end contract points at another table under its current name', async () => { + it('renames a foreign key the end contract points at another table, which the plan then replaces under its new name', async () => { const memberTable = () => ({ member: new StorageTable({ columns: { id: { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false } }, @@ -232,7 +236,10 @@ describe('PostgresMigration.renameTable', () => { ), ); - expect(ops.map((op) => op.label)).toEqual(['Rename table "userProfile" to "UserProfile"']); + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "profile_member_fk" on "UserProfile"', + ]); }); it('leaves a primary key whose columns the end contract changes under its current name', async () => { @@ -287,7 +294,7 @@ describe('PostgresMigration.renameTable', () => { ]); }); - it('refuses a table the start contract does not have', async () => { + it('refuses a table that does not exist at this point of the migration', async () => { expect( () => renameMigration( @@ -298,7 +305,9 @@ describe('PostgresMigration.renameTable', () => { ).toThrow( expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - message: expect.stringContaining('table "ghost" does not exist in the start contract'), + message: expect.stringContaining( + 'table "ghost" does not exist at this point of the migration', + ), }), ); }); @@ -319,7 +328,7 @@ describe('PostgresMigration.renameTable', () => { ); }); - it('refuses a new name the start contract already has', async () => { + it('refuses a new name that already exists at this point of the migration', async () => { const withBoth = contractOf('userProfile', {}, 'from', () => ({ UserProfile: new StorageTable({ columns: { id: { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false } }, @@ -335,7 +344,7 @@ describe('PostgresMigration.renameTable', () => { expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED', message: expect.stringContaining( - 'table "UserProfile" already exists in the start contract', + 'table "UserProfile" already exists at this point of the migration', ), }), ); @@ -346,4 +355,73 @@ describe('PostgresMigration.renameTable', () => { () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); }); + + it('renames a table twice in one migration when the end contract declares both new names, each rename from where the last left it', async () => { + const intermediate = () => ({ + UserProfile: new StorageTable({ + columns: { id: { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'], name: 'profile_pk' }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + }); + const ops = await Promise.all( + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('Member', {}, 'to', intermediate), + { table: 'userProfile', to: 'UserProfile' }, + { table: 'UserProfile', to: 'Member' }, + ).operations, + ); + + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename table "UserProfile" to "Member"', + ]); + }); + + it('refuses a table an earlier rename in the migration took away', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + RENAME, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "userProfile" does not exist at this point of the migration', + ), + }), + ); + }); + + it('reads the same operations again through Migration.readOperations', async () => { + const migration = renameMigration( + contractOf('userProfile', withObjects, 'from'), + contractOf('UserProfile', withObjects, 'to'), + RENAME, + ); + const first = await Promise.all(Migration.readOperations(migration)); + const second = await Promise.all(Migration.readOperations(migration)); + + expect(second).toEqual(first); + }); + + it('refuses a second direct read of the operations, since the table is already renamed', () => { + const migration = renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + ); + void migration.operations; + + expect(() => migration.operations).toThrow( + expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' }), + ); + }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts index 79265bc21a9a..718f1dbad389 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-fixtures.ts @@ -1,4 +1,4 @@ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { @@ -11,6 +11,8 @@ import { type UniqueConstraintInput, } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { postgresCreateNamespace } from '../../src/core/postgres-schema'; export const stubLowerer: ExecuteRequestLowerer = { @@ -24,6 +26,9 @@ const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; export const NICKNAME_CHECK = 'length(nickname) > 0'; export interface ProfileSpec { + readonly control?: ControlPolicy; + /** Enables row-level security on the profile table with one select policy. */ + readonly rlsPolicy?: string; readonly primaryKey?: PrimaryKeyInput; readonly uniques?: readonly UniqueConstraintInput[]; readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; @@ -39,9 +44,28 @@ function profileTable(tableName: string, spec: ProfileSpec): StorageTable { indexes: spec.indexes?.(tableName) ?? [], foreignKeys: spec.foreignKeys?.(tableName) ?? [], checks: spec.checks?.(tableName) ?? [], + ...(spec.control === undefined ? {} : { control: spec.control }), }); } +function rlsEntries(tableName: string, spec: ProfileSpec, namespaceId: string) { + if (spec.rlsPolicy === undefined) return { policy: {} }; + const policy = new PostgresRlsPolicy({ + naming: { kind: 'exact', name: spec.rlsPolicy }, + tableName, + namespaceId, + operation: 'select', + roles: ['app_user'], + using: '(id > 0)', + withCheck: undefined, + permissive: true, + }); + return { + policy: { [policy.name]: policy }, + rls: { [tableName]: new PostgresRlsEnablement({ tableName, namespaceId }) }, + }; +} + export function reference(tableName: string, columns: readonly string[]) { return { namespaceId: UNBOUND_NAMESPACE_ID, tableName, columns }; } @@ -91,7 +115,7 @@ export function contractOf( account: accountTable, ...extraTables(profileTableName), }, - policy: {}, + ...rlsEntries(profileTableName, spec, namespaceId), }, }), }, @@ -103,3 +127,23 @@ export function contractOf( meta: {}, }; } + +/** `contract` with one model per entry of `tables`, each stored in the named table. */ +export function withModels( + contract: Contract, + tables: Record, + namespaceId: string = UNBOUND_NAMESPACE_ID, +): Contract { + return { + ...contract, + domain: applicationDomainOf({ + namespaceId, + models: Object.fromEntries( + Object.entries(tables).map(([model, table]) => [ + model, + { fields: {}, relations: {}, storage: { table, namespaceId, fields: {} } }, + ]), + ), + }), + }; +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts index 78addee6119a..9d287402175d 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rename-table-ops.test.ts @@ -2,8 +2,13 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { describe, expect, it } from 'vitest'; import { tableExistsAst } from '../../src/contract-free/checks'; -import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; +import { + RenameConstraintCall, + RenameIndexCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; import { renameTable } from '../../src/core/migrations/operations/tables'; +import { renderRenameTableCall } from '../../src/core/migrations/table-rename-calls'; function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { const received: unknown[] = []; @@ -83,7 +88,7 @@ describe('renameTable (postgres)', () => { describe('RenameTableCall (postgres)', () => { it('is a widening renameTable call whose contract-side identity is the new name', () => { - const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + const call = new RenameTableCall('public', 'userProfile', 'UserProfile', []); expect(call).toMatchObject({ factoryName: 'renameTable', @@ -97,7 +102,7 @@ describe('RenameTableCall (postgres)', () => { it('toOp() delegates to renameTable', async () => { const { lowerer } = recordingCheckLowerer(); - const op = await new RenameTableCall('auth', 'userProfile', 'UserProfile').toOp(lowerer); + const op = await new RenameTableCall('auth', 'userProfile', 'UserProfile', []).toOp(lowerer); expect(op.execute.map((step) => step.sql)).toEqual([ 'ALTER TABLE "auth"."userProfile" RENAME TO "UserProfile"', @@ -105,7 +110,7 @@ describe('RenameTableCall (postgres)', () => { }); it('toOp() without a lowerer reports MIGRATION.POSTGRES_CONTROL_STACK_MISSING', async () => { - const call = new RenameTableCall('public', 'userProfile', 'UserProfile'); + const call = new RenameTableCall('public', 'userProfile', 'UserProfile', []); await expect(call.toOp()).rejects.toMatchObject({ code: 'MIGRATION.POSTGRES_CONTROL_STACK_MISSING', meta: { factory: 'RenameTableCall' }, @@ -113,15 +118,77 @@ describe('RenameTableCall (postgres)', () => { }); it('renderTypeScript() spreads the facade call, which returns every rename, schema-qualified only when bound', () => { - expect(new RenameTableCall('auth', 'userProfile', 'UserProfile').renderTypeScript()).toBe( + expect(new RenameTableCall('auth', 'userProfile', 'UserProfile', []).renderTypeScript()).toBe( '...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })', ); expect( - new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile').renderTypeScript(), + new RenameTableCall( + UNBOUND_NAMESPACE_ID, + 'userProfile', + 'UserProfile', + [], + ).renderTypeScript(), ).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); }); + it('renders the hand-written call with the namespace as schema, left out when unbound or not given', () => { + expect( + renderRenameTableCall({ namespaceId: 'auth', from: 'userProfile', to: 'UserProfile' }), + ).toBe('...this.renameTable({ schema: "auth", table: "userProfile", to: "UserProfile" })'); + expect( + renderRenameTableCall({ + namespaceId: UNBOUND_NAMESPACE_ID, + from: 'userProfile', + to: 'UserProfile', + }), + ).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); + expect( + renderRenameTableCall({ namespaceId: undefined, from: 'userProfile', to: 'UserProfile' }), + ).toBe('...this.renameTable({ table: "userProfile", to: "UserProfile" })'); + }); + + it('toOps() lowers the table rename, then each companion, with the same lowerer', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new RenameTableCall('auth', 'userProfile', 'UserProfile', [ + new RenameConstraintCall( + 'auth', + 'UserProfile', + 'primaryKey', + 'userProfile_pkey', + 'UserProfile_pkey', + ), + new RenameIndexCall( + 'auth', + 'UserProfile', + 'userProfile_handle_idx', + 'UserProfile_handle_idx', + ), + ]); + + const ops = await Promise.all(call.toOps(lowerer)); + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename index "userProfile_handle_idx" to "UserProfile_handle_idx" on "UserProfile"', + ]); + }); + + it('renders only the facade call, never its companions', () => { + const call = new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile', [ + new RenameConstraintCall( + UNBOUND_NAMESPACE_ID, + 'UserProfile', + 'primaryKey', + 'userProfile_pkey', + 'UserProfile_pkey', + ), + ]); + expect(call.renderTypeScript()).toBe( + '...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); + }); + it('needs no facade import because the call is a method on the migration', () => { - expect(new RenameTableCall('public', 'a', 'b').importRequirements()).toEqual([]); + expect(new RenameTableCall('public', 'a', 'b', []).importRequirements()).toEqual([]); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/render-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/render-ops.test.ts index a31dc50d623a..e54113e4c963 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/render-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/render-ops.test.ts @@ -1,6 +1,12 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { isStructuredError } from '@internal/utils/structured-error'; import { describe, expect, it } from 'vitest'; +import { + DataTransformCall, + RenameConstraintCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; import { renderOps } from '../../src/core/migrations/render-ops'; function makeCall(targetId: string, opId: string, factoryName = 'noop'): OpFactoryCall { @@ -50,3 +56,49 @@ describe('renderOps', () => { }); }); }); + +describe('renderOps with a call that produces several ops', () => { + it('renders the call and each of its companions, in order, with the same lowerer', async () => { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ sql: 'LOWERED', params: Object.freeze([]) }); + }, + renderColumnDefault: async () => '', + }; + const call = new RenameTableCall('public', 'a', 'b', [ + new RenameConstraintCall('public', 'b', 'primaryKey', 'a_pkey', 'b_pkey'), + ]); + + const result = await Promise.all(renderOps([call], lowerer)); + + expect(result.map((rendered) => rendered.label)).toEqual([ + 'Rename table "a" to "b"', + 'Rename primary key "a_pkey" to "b_pkey" on "b"', + ]); + expect(received.length).toBeGreaterThan(0); + }); +}); + +describe('renderOps with a placeholder', () => { + it('raises no unhandled rejection when the operations are read without awaiting each one', async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', listener); + try { + expect(renderOps([new DataTransformCall('Backfill', 'check', 'run')])).toHaveLength(1); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', listener); + } + expect(unhandled).toEqual([]); + }); + + it('still rejects with MIGRATION.UNFILLED_PLACEHOLDER for a reader that awaits it', async () => { + const [operation] = renderOps([new DataTransformCall('Backfill', 'check', 'run')]); + await expect(operation).rejects.toMatchObject({ code: 'MIGRATION.UNFILLED_PLACEHOLDER' }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts index 759d9a348633..9f70f3ab09ed 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts @@ -41,6 +41,7 @@ import { DropTableCall, EnableRowLevelSecurityCall, RawSqlCall, + RenameColumnCall, RenameConstraintCall, RenameIndexCall, RenamePostgresRlsPolicyCall, @@ -202,7 +203,8 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => { ], ), new DropTableCall('public', 'stale'), - new RenameTableCall('public', 'stale', 'archived'), + new RenameTableCall('public', 'stale', 'archived', []), + new RenameColumnCall('public', 'archived', 'title', 'heading', []), new AddColumnCall('public', 'note', col('nickname', 'text')), new DropColumnCall('public', 'note', 'nickname'), new AlterColumnTypeCall('public', 'note', 'kind', { diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-disable-rename-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-disable-rename-ops.test.ts index 961ed354b48f..b431dc779c38 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-disable-rename-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-disable-rename-ops.test.ts @@ -54,10 +54,10 @@ describe('disableRowLevelSecurity op', () => { expect(op.postcheck[0]?.params).toEqual(['p3']); }); - it('operationClass is destructive — disabling RLS opens row access', async () => { + it('operationClass is widening — disabling RLS opens row access but loses no data', async () => { const { lowerer } = recordingCheckLowerer(); const op = await disableRowLevelSecurity('public', 'profiles', lowerer); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); it('op id and label name the table and the disable action', async () => { @@ -157,9 +157,9 @@ describe('DisableRowLevelSecurityCall', () => { expect(call.factoryName).toBe('disableRowLevelSecurity'); }); - it('operationClass is destructive', () => { + it('operationClass is widening', () => { const call = new DisableRowLevelSecurityCall('public', 'profiles'); - expect(call.operationClass).toBe('destructive'); + expect(call.operationClass).toBe('widening'); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-drop-ordering-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-drop-ordering-planner.test.ts index ac87b55eb7da..a29c217d69ad 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-drop-ordering-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-drop-ordering-planner.test.ts @@ -13,7 +13,11 @@ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { CodecControlHooks } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; -import { APP_SPACE_ID, type MigrationOperationClass } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + type MigrationOperationClass, + planOriginOf, +} from '@internal/framework-components/control'; import { indexInputFromSerialized, SqlStorage, @@ -253,6 +257,8 @@ async function planOpIds( schema, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [...postgresTypeComponents, ...frameworkComponents], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-enablement-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-enablement-planner.test.ts index 31177f1a08f2..2905310a53e1 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-enablement-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-enablement-planner.test.ts @@ -3,7 +3,7 @@ * derive from the table's `rlsEnabled` attribute diff (the contract marker vs * `pg_class.relrowsecurity`) — never from the policy set. Pins the * pre-investigated edges: in-sync-policies-but-RLS-off re-enables, marker - * removal disables under the destructive allowance and surfaces a conflict + * removal disables under the widening allowance and surfaces a conflict * without it, a marker with zero policies enables (deny-all), last-policy * removal plans no enablement change, and external tables suppress both * directions with a warning. @@ -38,7 +38,6 @@ const stubLowerer: ExecuteRequestLowerer = { const ALL_CLASSES_POLICY = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const, }; -const NO_DESTRUCTIVE_POLICY = { allowedOperationClasses: ['additive', 'widening'] as const }; const ADDITIVE_ONLY_POLICY = { allowedOperationClasses: ['additive'] as const }; function contractPolicy(name: string): PostgresRlsPolicy { @@ -161,6 +160,8 @@ function plan( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -203,7 +204,7 @@ describe('marker-driven ENABLE', () => { }); describe('marker-removal DISABLE', () => { - it('plans DISABLE when the marker is removed and the destructive allowance is present', async () => { + it('plans DISABLE when the marker is removed and the widening allowance is present', async () => { const contract = buildContract({ marked: false }); const schema = actualSchema({ rlsEnabled: true }); @@ -211,11 +212,11 @@ describe('marker-removal DISABLE', () => { expect(opIds).toEqual([`rowLevelSecurity.public.${TABLE_NAME}.disable`]); }); - it('surfaces a conflict (not a silent skip) when the destructive allowance is absent', () => { + it('surfaces a conflict (not a silent skip) when the widening allowance is absent', () => { const contract = buildContract({ marked: false }); const schema = actualSchema({ rlsEnabled: true }); - const result = plan(contract, schema, NO_DESTRUCTIVE_POLICY); + const result = plan(contract, schema, ADDITIVE_ONLY_POLICY); expect(result.kind).toBe('failure'); if (result.kind !== 'failure') return; expect(result.conflicts).toContainEqual( diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-not-equal-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-not-equal-planner.test.ts index 433b88b1dfb4..b1769b3b1c9e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-not-equal-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-not-equal-planner.test.ts @@ -1,7 +1,7 @@ /** * A `not-equal` policy issue — reachable once exact-named (prefix-absent) * policies compare by content — maps to drop + create: the drop is - * destructive-gated, and without the destructive allowance the plan fails + * widening-gated, and without the widening allowance the plan fails * with the existing disallowed-call conflict instead of silently skipping * the drift. */ @@ -35,7 +35,7 @@ const stubLowerer: ExecuteRequestLowerer = { const ALL_CLASSES_POLICY = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const, }; -const NO_DESTRUCTIVE_POLICY = { allowedOperationClasses: ['additive', 'widening'] as const }; +const ADDITIVE_ONLY_POLICY = { allowedOperationClasses: ['additive'] as const }; function exactPolicy(using: string): PostgresRlsPolicy { return new PostgresRlsPolicy({ @@ -94,7 +94,7 @@ function buildContract( function actualSchema( livePolicy: PostgresRlsPolicy, - options?: { readonly extraColumn?: boolean }, + options?: { readonly nullableTenantId?: boolean }, ): PostgresDatabaseSchemaNode { return new PostgresDatabaseSchemaNode({ namespaces: { @@ -105,10 +105,11 @@ function actualSchema( name: TABLE_NAME, columns: { id: { name: 'id', nativeType: 'int4', nullable: false }, - tenant_id: { name: 'tenant_id', nativeType: 'int4', nullable: false }, - ...(options?.extraColumn - ? { stale: { name: 'stale', nativeType: 'int4', nullable: true } } - : {}), + tenant_id: { + name: 'tenant_id', + nativeType: 'int4', + nullable: options?.nullableTenantId === true, + }, }, primaryKey: { columns: ['id'] }, foreignKeys: [], @@ -149,6 +150,8 @@ function plan( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -170,11 +173,25 @@ describe('not-equal policy issue (exact-mode content drift)', () => { ]); }); - it('fails with the policy-incompatible conflict, naming the policy structurally, when destructive is not allowed', () => { + it('maps to drop + create under a policy that allows widening but not destructive', async () => { const contract = buildContract(exactPolicy('(tenant_id = 1)')); const schema = actualSchema(exactPolicy('(tenant_id = 2)')); - const result = plan(contract, schema, NO_DESTRUCTIVE_POLICY); + const result = plan(contract, schema, { allowedOperationClasses: ['additive', 'widening'] }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const ops = await Promise.all(result.plan.operations); + expect(ops.map((op) => op.id)).toEqual([ + `rlsPolicy.public.${TABLE_NAME}.${EXACT_NAME}.drop`, + `rlsPolicy.public.${TABLE_NAME}.${EXACT_NAME}`, + ]); + }); + + it('fails with the policy-incompatible conflict, naming the policy structurally, when widening is not allowed', () => { + const contract = buildContract(exactPolicy('(tenant_id = 1)')); + const schema = actualSchema(exactPolicy('(tenant_id = 2)')); + + const result = plan(contract, schema, ADDITIVE_ONLY_POLICY); expect(result.kind).toBe('failure'); if (result.kind !== 'failure') return; expect(result.conflicts).toContainEqual( @@ -203,13 +220,13 @@ describe('not-equal policy issue (exact-mode content drift)', () => { it('composes the policy conflict with relational conflicts in one failure', () => { const contract = buildContract(exactPolicy('(tenant_id = 1)')); - const schema = actualSchema(exactPolicy('(tenant_id = 2)'), { extraColumn: true }); + const schema = actualSchema(exactPolicy('(tenant_id = 2)'), { nullableTenantId: true }); - const result = plan(contract, schema, NO_DESTRUCTIVE_POLICY); + const result = plan(contract, schema, ADDITIVE_ONLY_POLICY); expect(result.kind).toBe('failure'); if (result.kind !== 'failure') return; expect(result.conflicts).toContainEqual( - expect.objectContaining({ summary: expect.stringContaining('stale') }), + expect.objectContaining({ summary: expect.stringContaining('tenant_id') }), ); expect(result.conflicts).toContainEqual( expect.objectContaining({ @@ -263,7 +280,7 @@ describe('not-equal policy under non-managed control policies', () => { it('a suppressed relational change and a suppressed policy replacement on one table are two distinguishable warnings', async () => { const contract = buildContract(exactPolicy('(tenant_id = 1)'), 'tolerated'); - const schema = actualSchema(exactPolicy('(tenant_id = 2)'), { extraColumn: true }); + const schema = actualSchema(exactPolicy('(tenant_id = 2)'), { nullableTenantId: true }); const result = plan(contract, schema, ALL_CLASSES_POLICY); expect(result.kind).toBe('success'); @@ -277,11 +294,11 @@ describe('not-equal policy under non-managed control policies', () => { expect(new Set(summaries).size).toBe(2); }); - it('tolerated drift without the destructive allowance is still a suppression, not a conflict', async () => { + it('tolerated drift without the widening allowance is still a suppression, not a conflict', async () => { const contract = buildContract(exactPolicy('(tenant_id = 1)'), 'tolerated'); const schema = actualSchema(exactPolicy('(tenant_id = 2)')); - const result = plan(contract, schema, NO_DESTRUCTIVE_POLICY); + const result = plan(contract, schema, ADDITIVE_ONLY_POLICY); expect(result.kind).toBe('success'); if (result.kind !== 'success') return; const ops = await Promise.all(result.plan.operations); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts index 194ad742883f..3ba53362db5e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-ops.test.ts @@ -259,10 +259,10 @@ describe('dropRlsPolicy op', () => { ); }); - it('operationClass is destructive', async () => { + it('operationClass is widening, since a policy holds no stored data', async () => { const { lowerer } = recordingCheckLowerer(); const op = await dropRlsPolicy('public', 'profiles', 'read_own_profiles_ab12cd34', lowerer); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); }); @@ -365,8 +365,8 @@ describe('DropPostgresRlsPolicyCall', () => { expect(call.factoryName).toBe('dropRlsPolicy'); }); - it('operationClass is destructive', () => { + it('operationClass is widening, since a policy holds no stored data', () => { const call = new DropPostgresRlsPolicyCall('public', 'profiles', 'read_own_profiles_ab12cd34'); - expect(call.operationClass).toBe('destructive'); + expect(call.operationClass).toBe('widening'); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts index 1610efd1feb7..cfffd1546395 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-planner.test.ts @@ -206,6 +206,8 @@ describe('RLS planner diff-wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -238,6 +240,8 @@ describe('RLS planner diff-wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -270,6 +274,8 @@ describe('RLS planner diff-wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -299,6 +305,8 @@ describe('RLS planner diff-wiring', () => { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -324,6 +332,8 @@ describe('RLS planner diff-wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -352,6 +362,8 @@ describe('RLS planner policy edit (missing + extra via generic pipeline)', () => schema, policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -366,6 +378,28 @@ describe('RLS planner policy edit (missing + extra via generic pipeline)', () => expect(opIds).toContain(`rlsPolicy.public.${TABLE_NAME}.p_read_00000000.drop`); }); + it('drops the extra policy under a policy that allows widening but not destructive', async () => { + const contract = buildContractWith([makePolicy('p_read_11111111')]); + const oldPolicy = makeActualPolicy('p_read_00000000', TABLE_NAME, '(auth.uid() = old_user_id)'); + + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract, + schema: schemaWith([oldPolicy]), + policy: { allowedOperationClasses: ['additive', 'widening'] }, + fromContract: null, + origin: null, + statements: [], + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return; + const opIds = (await Promise.all(result.plan.operations)).map((op) => op.id); + expect(opIds).toContain(`rlsPolicy.public.${TABLE_NAME}.p_read_00000000.drop`); + }); + it('additive-only policy passes create/enable but filters the extra-policy drop', async () => { const newPolicy = makePolicy('p_read_11111111'); const contract = buildContractWith([newPolicy]); @@ -379,6 +413,8 @@ describe('RLS planner policy edit (missing + extra via generic pipeline)', () => schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -408,6 +444,8 @@ describe('RLS planner roles produce zero ops (AC-6)', () => { schema, policy: DB_UPDATE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/rls-rename-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/rls-rename-planner.test.ts index 4a7373a856ce..d346d2c75015 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/rls-rename-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/rls-rename-planner.test.ts @@ -153,6 +153,8 @@ async function planOpIds( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -174,6 +176,8 @@ async function planOpLabels( schema, policy: { allowedOperationClasses: [...policy.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -357,6 +361,8 @@ describe('external-table grading', () => { schema, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/postgres/test/migrations/schema-tables.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/schema-tables.test.ts new file mode 100644 index 000000000000..53beb63c81d1 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/schema-tables.test.ts @@ -0,0 +1,39 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { postgresSchemaTables } from '../../src/core/migrations/schema-tables'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { contractOf } from './rename-table-fixtures'; + +describe('postgresSchemaTables', () => { + const unbound = contractOf('userProfile', {}, 'unbound'); + const tables = postgresSchemaTables( + postgresContractToSchema(unbound, postgresTypeComponents), + unbound, + ); + + it('finds a table and its columns by the contract namespace id', () => { + expect(tables.hasTable(UNBOUND_NAMESPACE_ID, 'userProfile')).toBe(true); + expect(tables.hasColumn(UNBOUND_NAMESPACE_ID, 'userProfile', 'email')).toBe(true); + expect(tables.hasColumn(UNBOUND_NAMESPACE_ID, 'userProfile', 'ghost')).toBe(false); + expect(tables.columnsNamed(UNBOUND_NAMESPACE_ID, 'userProfile', 'email')).toEqual(['email']); + expect(tables.columnsNamed(UNBOUND_NAMESPACE_ID, 'userProfile', 'EMAIL')).toEqual([]); + expect(tables.hasTable(UNBOUND_NAMESPACE_ID, 'ghost')).toBe(false); + }); + + it('names the namespaces that have a table', () => { + expect(tables.namespacesWithTable('userProfile')).toEqual([UNBOUND_NAMESPACE_ID]); + expect(tables.namespacesWithTable('ghost')).toEqual([]); + }); + + it('maps a bound namespace id to its DDL schema', () => { + const bound = contractOf('userProfile', {}, 'bound', () => ({}), 'auth'); + const authTables = postgresSchemaTables( + postgresContractToSchema(bound, postgresTypeComponents), + bound, + ); + + expect(authTables.hasTable('auth', 'userProfile')).toBe(true); + expect(authTables.namespacesWithTable('userProfile')).toEqual(['auth']); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/table-rename-calls.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/table-rename-calls.test.ts new file mode 100644 index 000000000000..1d0ed938920f --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/table-rename-calls.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { postgresTableRenameCall } from '../../src/core/migrations/table-rename-calls'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { contractOf, type ProfileSpec, reference } from './rename-table-fixtures'; + +const RENAME = { namespaceId: '__unbound__', from: 'userProfile', to: 'UserProfile' } as const; + +function companionLabels(previousSpec: ProfileSpec, nextSpec: ProfileSpec): readonly string[] { + const next = contractOf('UserProfile', nextSpec, 'to'); + const call = postgresTableRenameCall({ + previous: postgresContractToSchema( + contractOf('userProfile', previousSpec, 'from'), + postgresTypeComponents, + ), + contract: next, + rename: RENAME, + frameworkComponents: postgresTypeComponents, + }); + return [call.label, ...call.companions.map((companion) => companion.label)]; +} + +const accountForeignKey = (tableName: string, name?: string) => ({ + source: reference(tableName, ['accountId']), + target: reference('account', ['id']), + ...(name === undefined ? {} : { name }), +}); + +describe('postgresTableRenameCall constraint companions', () => { + it('renames constraints an introspected origin names after the old table to the names the destination derives', () => { + const introspected: ProfileSpec = { + primaryKey: { columns: ['id'], name: 'userProfile_pkey' }, + uniques: [{ columns: ['email'], name: 'userProfile_email_key' }], + foreignKeys: () => [accountForeignKey('userProfile', 'userProfile_accountId_fkey')], + }; + const destination: ProfileSpec = { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [accountForeignKey(tableName)], + }; + + expect(companionLabels(introspected, destination)).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename primary key "userProfile_pkey" to "UserProfile_pkey" on "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + 'Rename foreign key "userProfile_accountId_fkey" to "UserProfile_accountId_fkey" on "UserProfile"', + ]); + }); + + it('renames an introspected constraint to the explicit name the destination gives it', () => { + expect( + companionLabels( + { uniques: [{ columns: ['email'], name: 'userProfile_email_key' }] }, + { uniques: [{ columns: ['email'], name: 'profile_email_unique' }] }, + ), + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename unique constraint "userProfile_email_key" to "profile_email_unique" on "UserProfile"', + ]); + }); + + it('renames an explicitly named constraint the destination leaves unnamed to the derived name', () => { + expect( + companionLabels( + { uniques: [{ columns: ['email'], name: 'profile_email_unique' }] }, + { uniques: [{ columns: ['email'] }] }, + ), + ).toEqual([ + 'Rename table "userProfile" to "UserProfile"', + 'Rename unique constraint "profile_email_unique" to "UserProfile_email_key" on "UserProfile"', + ]); + }); + + it('leaves a constraint alone when the destination has no constraint of the same kind and columns', () => { + expect( + companionLabels( + { uniques: [{ columns: ['email'], name: 'userProfile_email_key' }] }, + { uniques: [{ columns: ['handle'] }] }, + ), + ).toEqual(['Rename table "userProfile" to "UserProfile"']); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/table-rename-constraint-renames.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/table-rename-constraint-renames.test.ts new file mode 100644 index 000000000000..017ab3fe4c10 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/table-rename-constraint-renames.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { constraintRenamesForTableRename } from '../../src/core/migrations/table-rename-constraint-renames'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { contractOf, type ProfileSpec, reference } from './rename-table-fixtures'; + +function unrenamedTable(spec: ProfileSpec): PostgresTableSchemaNode { + const schema = postgresContractToSchema( + contractOf('UserProfile', spec, 'to'), + postgresTypeComponents, + ); + const table = schema.namespaces['public']?.tables['UserProfile']; + if (table === undefined) throw new Error('table UserProfile missing'); + return table; +} + +describe('constraintRenamesForTableRename', () => { + it.each([ + ['primary key', { primaryKey: { columns: ['id'] } }], + ['unique constraint', { uniques: [{ columns: ['email'] }] }], + [ + 'foreign key', + { + foreignKeys: (tableName: string) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + }, + ], + ] satisfies ReadonlyArray)( + 'refuses a renamed table whose %s has no name, since the working schema names them all', + (_kind, spec) => { + const table = unrenamedTable(spec); + expect(() => + constraintRenamesForTableRename({ + schemaName: 'public', + previous: table, + next: table, + }), + ).toThrow(/has no name/); + }, + ); + + function tableWith(input: { + readonly uniques?: readonly { readonly columns: readonly string[]; readonly name?: string }[]; + readonly foreignKeys?: readonly { + readonly columns: readonly string[]; + readonly referencedTable: string; + readonly referencedColumns: readonly string[]; + readonly name?: string; + }[]; + }): PostgresTableSchemaNode { + return new PostgresTableSchemaNode({ + name: 'UserProfile', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + email: { name: 'email', nativeType: 'text', nullable: false }, + accountId: { name: 'accountId', nativeType: 'int4', nullable: false }, + }, + foreignKeys: [...(input.foreignKeys ?? [])], + uniques: [...(input.uniques ?? [])], + indexes: [], + policies: [], + rlsEnabled: false, + }); + } + + const labels = (previous: PostgresTableSchemaNode, next: PostgresTableSchemaNode) => + constraintRenamesForTableRename({ schemaName: 'public', previous, next }).map( + (call) => call.label, + ); + + it('pairs two foreign keys on the same columns with two different destination foreign keys', () => { + const previous = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'fk_a', + }, + { + columns: ['accountId'], + referencedTable: 'member', + referencedColumns: ['id'], + name: 'fk_b', + }, + ], + }); + const next = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'profile_account_fk', + }, + { + columns: ['accountId'], + referencedTable: 'member', + referencedColumns: ['id'], + name: 'profile_member_fk', + }, + ], + }); + + expect(labels(previous, next)).toEqual([ + 'Rename foreign key "fk_a" to "profile_account_fk" on "UserProfile"', + 'Rename foreign key "fk_b" to "profile_member_fk" on "UserProfile"', + ]); + }); + + it('pairs each foreign key with the destination key to its own table, whatever their order', () => { + const previous = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'fk_a', + }, + { + columns: ['accountId'], + referencedTable: 'member', + referencedColumns: ['id'], + name: 'fk_b', + }, + ], + }); + const next = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'member', + referencedColumns: ['id'], + name: 'profile_member_fk', + }, + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'profile_account_fk', + }, + ], + }); + + expect(labels(previous, next)).toEqual([ + 'Rename foreign key "fk_a" to "profile_account_fk" on "UserProfile"', + 'Rename foreign key "fk_b" to "profile_member_fk" on "UserProfile"', + ]); + }); + + it('pairs a later foreign key with the destination key to its own table when an earlier key has no such match', () => { + const previous = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'legacy', + referencedColumns: ['id'], + name: 'fk_legacy', + }, + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'fk_account', + }, + ], + }); + const next = tableWith({ + foreignKeys: [ + { + columns: ['accountId'], + referencedTable: 'account', + referencedColumns: ['id'], + name: 'profile_account_fk', + }, + { + columns: ['accountId'], + referencedTable: 'member', + referencedColumns: ['id'], + name: 'profile_member_fk', + }, + ], + }); + + expect(labels(previous, next)).toEqual([ + 'Rename foreign key "fk_legacy" to "profile_member_fk" on "UserProfile"', + 'Rename foreign key "fk_account" to "profile_account_fk" on "UserProfile"', + ]); + }); + + it('pairs a destination unique constraint with one of two duplicate uniques only', () => { + const previous = tableWith({ + uniques: [ + { columns: ['email'], name: 'userProfile_email_key' }, + { columns: ['email'], name: 'userProfile_email_key1' }, + ], + }); + const next = tableWith({ uniques: [{ columns: ['email'] }] }); + + expect(labels(previous, next)).toEqual([ + 'Rename unique constraint "userProfile_email_key" to "UserProfile_email_key" on "UserProfile"', + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/working-schema.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/working-schema.test.ts new file mode 100644 index 000000000000..403dd10a9bba --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/working-schema.test.ts @@ -0,0 +1,316 @@ +import type { SchemaNodeRef } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { computeCheckContentHash, computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { RelationalSchemaNodeKind, SqlCheckConstraintIR } from '@internal/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { + RenameConstraintCall, + RenameIndexCall, + RenamePostgresRlsPolicyCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { + createWorkingSchema, + renameTableInPostgresSchema, +} from '../../src/core/migrations/working-schema'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresPolicySchemaNode } from '../../src/core/schema-ir/postgres-policy-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import { PostgresSchemaNodeKind } from '../../src/core/schema-ir/schema-node-kinds'; +import { postgresTypeComponents } from '../postgres-type-lookups'; +import { + contractOf, + NICKNAME_CHECK, + type ProfileSpec, + postTable, + reference, +} from './rename-table-fixtures'; + +const HANDLE_HASH = computeIndexContentHash({ columns: ['handle'], unique: false }); +const NICKNAME_HASH = computeCheckContentHash(NICKNAME_CHECK); + +const withObjects: ProfileSpec = { + primaryKey: { columns: ['id'] }, + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + ], + indexes: (tableName) => [ + { + columns: ['handle'], + naming: { kind: 'wire', prefix: `${tableName}_handle_idx`, hash: HANDLE_HASH }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }, + ], + checks: (tableName) => [ + { + naming: { kind: 'wire', prefix: `${tableName}_nickname_check`, hash: NICKNAME_HASH }, + expression: NICKNAME_CHECK, + }, + ], +}; + +function startSchema(): PostgresDatabaseSchemaNode { + return postgresContractToSchema( + contractOf('userProfile', withObjects, 'from', (tableName) => ({ post: postTable(tableName) })), + postgresTypeComponents, + ); +} + +function tableIn(schema: PostgresDatabaseSchemaNode, table: string): PostgresTableSchemaNode { + const found = schema.namespaces['public']?.tables[table]; + if (found === undefined) throw new Error(`table ${table} missing`); + return found; +} + +const tableRef = (table: string): SchemaNodeRef => [ + { nodeKind: PostgresSchemaNodeKind.database, id: 'database' }, + { nodeKind: PostgresSchemaNodeKind.namespace, id: 'public' }, + { nodeKind: PostgresSchemaNodeKind.table, id: table }, +]; + +const RENAME = { schemaName: 'public', from: 'userProfile', to: 'UserProfile' } as const; + +describe('renameTableInPostgresSchema', () => { + it('re-keys and renames the table and leaves the input untouched', () => { + const before = startSchema(); + const after = renameTableInPostgresSchema(before, RENAME); + + expect(Object.keys(after.namespaces['public']?.tables ?? {}).sort()).toEqual([ + 'UserProfile', + 'account', + 'post', + ]); + expect(tableIn(after, 'UserProfile').name).toBe('UserProfile'); + expect(Object.keys(before.namespaces['public']?.tables ?? {}).sort()).toEqual([ + 'account', + 'post', + 'userProfile', + ]); + }); + + it('retargets a foreign key on another table, with its dependency on the table', () => { + const fk = tableIn(renameTableInPostgresSchema(startSchema(), RENAME), 'post').foreignKeys[0]; + expect(fk?.referencedTable).toBe('UserProfile'); + expect(fk?.dependsOn?.[0]).toEqual(tableRef('UserProfile')); + }); + + it('keeps every object name, spelling out the names derived from the old table name', () => { + const table = tableIn(renameTableInPostgresSchema(startSchema(), RENAME), 'UserProfile'); + expect({ + primaryKey: table.primaryKey?.name, + uniques: table.uniques.map((unique) => unique.name), + foreignKeys: table.foreignKeys.map((fk) => fk.name), + indexes: table.indexes.map((index) => index.name), + checks: table.checks?.map((check) => check.name), + }).toEqual({ + primaryKey: 'userProfile_pkey', + uniques: ['userProfile_email_key'], + foreignKeys: ['userProfile_accountId_fkey'], + indexes: [`userProfile_handle_idx_${HANDLE_HASH}`], + checks: [`userProfile_nickname_check_${NICKNAME_HASH}`], + }); + }); + + it('moves the dependencies of the objects on the table to its new name', () => { + const table = tableIn(renameTableInPostgresSchema(startSchema(), RENAME), 'UserProfile'); + const column = (name: string): SchemaNodeRef => [ + ...tableRef('UserProfile'), + { nodeKind: RelationalSchemaNodeKind.column, id: `column:${name}` }, + ]; + expect(table.primaryKey?.dependsOn).toEqual([column('id')]); + expect(table.uniques[0]?.dependsOn).toEqual([column('email')]); + expect(table.indexes[0]?.dependsOn).toEqual([column('handle')]); + }); + + it('carries row-level security and moves the policies onto the new table name', () => { + const policy = new PostgresPolicySchemaNode({ + naming: { kind: 'exact', name: 'tenant_read' }, + tableName: 'userProfile', + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: '(tenant_id = 1)', + withCheck: undefined, + permissive: true, + dependsOn: [tableRef('userProfile')], + }); + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + userProfile: new PostgresTableSchemaNode({ + name: 'userProfile', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [policy], + rlsEnabled: true, + }), + }, + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: '16', + }); + + const table = tableIn(renameTableInPostgresSchema(schema, RENAME), 'UserProfile'); + expect(table.rlsEnabled).toBe(true); + expect(table.policies.map((node) => [node.name, node.tableName])).toEqual([ + ['tenant_read', 'UserProfile'], + ]); + expect(table.policies[0]?.dependsOn).toEqual([tableRef('UserProfile')]); + }); +}); + +describe('WorkingSchema.apply', () => { + it('renames the table and then applies each companion', () => { + const working = createWorkingSchema(startSchema()); + working.apply( + new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile', [ + new RenameConstraintCall( + UNBOUND_NAMESPACE_ID, + 'UserProfile', + 'primaryKey', + 'userProfile_pkey', + 'UserProfile_pkey', + ), + ]), + ); + const table = tableIn(working.current, 'UserProfile'); + expect(table.primaryKey?.name).toBe('UserProfile_pkey'); + expect(tableIn(working.current, 'post').foreignKeys[0]?.referencedTable).toBe('UserProfile'); + }); + + it('renames a unique constraint and a check constraint', () => { + const working = createWorkingSchema(startSchema()); + working.apply( + new RenameConstraintCall( + UNBOUND_NAMESPACE_ID, + 'userProfile', + 'unique', + 'userProfile_email_key', + 'profile_email_unique', + ), + ); + working.apply( + new RenameConstraintCall( + UNBOUND_NAMESPACE_ID, + 'userProfile', + 'checkConstraint', + `userProfile_nickname_check_${NICKNAME_HASH}`, + `profile_nickname_check_${NICKNAME_HASH}`, + ), + ); + const table = tableIn(working.current, 'userProfile'); + expect(table.uniques.map((unique) => unique.name)).toEqual(['profile_email_unique']); + expect(table.checks?.map((check) => [check.name, check.prefix])).toEqual([ + [`profile_nickname_check_${NICKNAME_HASH}`, 'profile_nickname_check'], + ]); + }); + + it('renames an index and every dependency that names it', () => { + const indexStep = (name: string) => ({ + nodeKind: RelationalSchemaNodeKind.index, + id: `index:${name}`, + }); + const oldIndex = `userProfile_handle_idx_${HANDLE_HASH}`; + const newIndex = `profile_handle_idx_${HANDLE_HASH}`; + const base = tableIn(startSchema(), 'userProfile'); + const dependent = new SqlCheckConstraintIR({ + naming: { kind: 'exact', name: 'handle_present' }, + expression: 'handle IS NOT NULL', + dependsOn: [[...tableRef('userProfile'), indexStep(oldIndex)]], + }); + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + userProfile: new PostgresTableSchemaNode({ ...base, checks: [dependent] }), + }, + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: '16', + }); + + const working = createWorkingSchema(schema); + working.apply(new RenameIndexCall(UNBOUND_NAMESPACE_ID, 'userProfile', oldIndex, newIndex)); + const table = tableIn(working.current, 'userProfile'); + expect(table.indexes.map((index) => [index.name, index.prefix])).toEqual([ + [newIndex, 'profile_handle_idx'], + ]); + expect(table.checks?.[0]?.dependsOn).toEqual([ + [...tableRef('userProfile'), indexStep(newIndex)], + ]); + }); + + it('renames a policy and every dependency that names it', () => { + const policyStep = (name: string) => ({ nodeKind: PostgresSchemaNodeKind.policy, id: name }); + const policy = (name: string, dependsOn: readonly SchemaNodeRef[]) => + new PostgresPolicySchemaNode({ + naming: { kind: 'exact', name }, + tableName: 'userProfile', + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using: '(tenant_id = 1)', + withCheck: undefined, + permissive: true, + dependsOn, + }); + const schema = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { + userProfile: new PostgresTableSchemaNode({ + name: 'userProfile', + columns: {}, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [ + policy('tenant_read', [tableRef('userProfile')]), + policy('tenant_audit', [[...tableRef('userProfile'), policyStep('tenant_read')]]), + ], + rlsEnabled: true, + }), + }, + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: '16', + }); + + const working = createWorkingSchema(schema); + working.apply( + new RenamePostgresRlsPolicyCall('public', 'userProfile', 'tenant_read', 'tenant_select'), + ); + const policies = tableIn(working.current, 'userProfile').policies; + expect(policies.map((node) => node.name)).toEqual(['tenant_select', 'tenant_audit']); + expect(policies[1]?.dependsOn).toEqual([ + [...tableRef('userProfile'), policyStep('tenant_select')], + ]); + }); + + it('replaces current with a new tree and never changes the one it replaced', () => { + const working = createWorkingSchema(startSchema()); + const before = working.current; + working.apply(new RenameTableCall(UNBOUND_NAMESPACE_ID, 'userProfile', 'UserProfile', [])); + expect(working.current).not.toBe(before); + expect(Object.keys(before.namespaces['public']?.tables ?? {})).toContain('userProfile'); + expect(tableIn(before, 'post').foreignKeys[0]?.referencedTable).toBe('userProfile'); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts index 622087392665..57651bd65a99 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts @@ -29,6 +29,16 @@ describe('PostgresSchema', () => { expect(schema.qualifyTable('users')).toBe('"auth"."users"'); }); + it('doubles a double quote inside the schema and table names', () => { + const schema = new PostgresSchema({ id: 'a"uth', entries: { table: {} } }); + expect(schema.qualifier()).toBe('"a""uth"'); + expect(schema.qualifyTable('us"ers; drop table x; --')).toBe( + '"a""uth"."us""ers; drop table x; --"', + ); + expect(schema.regclassLiteral(`it's`)).toBe(`'"a""uth"."it''s"'`); + expect(PostgresSchema.unbound.qualifyTable('us"ers')).toBe('"us""ers"'); + }); + it('quotes the schema name even when it would otherwise collide with a Postgres keyword', () => { const schema = new PostgresSchema({ id: 'public', entries: { table: {} } }); expect(schema.qualifier()).toBe('"public"'); diff --git a/packages/3-targets/3-targets/sqlite/src/contract-free/checks.ts b/packages/3-targets/3-targets/sqlite/src/contract-free/checks.ts index b126ea54b6ae..29ca5b607ffc 100644 --- a/packages/3-targets/3-targets/sqlite/src/contract-free/checks.ts +++ b/packages/3-targets/3-targets/sqlite/src/contract-free/checks.ts @@ -26,6 +26,33 @@ export function columnExistsAst(table: string, column: string): ColumnExistsChec }; } +export interface ColumnNameTakenCheckBuilder { + nameFree(): SelectAst; +} + +/** + * Typed builder for the check that no column of the table has a name SQLite takes for `column`. + * Produces `SELECT COUNT(*) = 0 AS "result" FROM pragma_table_info(?) WHERE "name" COLLATE NOCASE + * = ?`: SQLite compares column names without regard to the case of ASCII letters, as `NOCASE` + * does. + */ +export function columnNameTakenAst(table: string, column: string): ColumnNameTakenCheckBuilder { + const source = FunctionSource.of('pragma_table_info', [ + cfExpr.param(table, SQLITE_TEXT_CODEC_ID).ast, + ]); + const name = cfExpr.fn({ + method: 'collateNocase', + template: '{{self}} COLLATE NOCASE', + self: cfExpr.identifierRef('name'), + returns: { codecId: SQLITE_TEXT_CODEC_ID, nullable: false }, + }); + const where = name.eqParam(column, SQLITE_TEXT_CODEC_ID); + return { + nameFree: () => + exprSelect().from(source).project('result', cfExpr.countStar().eqLit(0)).where(where).build(), + }; +} + export interface TableExistsCheckBuilder { tableAbsent(): SelectAst; tablePresent(): SelectAst; @@ -50,6 +77,34 @@ export function tableExistsAst(tableName: string): TableExistsCheckBuilder { }; } +export interface TableNameTakenCheckBuilder { + nameFree(): SelectAst; +} + +/** + * Typed builder for the check that no table, index or view has a name SQLite takes for `name`. + * Produces `SELECT COUNT(*) = 0 AS "result" FROM "sqlite_master" WHERE "name" COLLATE NOCASE = ?`: + * tables, indexes and views share one set of names, which SQLite compares without regard to the + * case of ASCII letters. + */ +export function tableNameTakenAst(tableName: string): TableNameTakenCheckBuilder { + const name = cfExpr.fn({ + method: 'collateNocase', + template: '{{self}} COLLATE NOCASE', + self: cfExpr.identifierRef('name'), + returns: { codecId: SQLITE_TEXT_CODEC_ID, nullable: false }, + }); + const where = name.eqParam(tableName, SQLITE_TEXT_CODEC_ID); + return { + nameFree: () => + exprSelect() + .from(cfTable('sqlite_master')) + .project('result', cfExpr.countStar().eqLit(0)) + .where(where) + .build(), + }; +} + export interface IndexExistsCheckBuilder { indexAbsent(): SelectAst; indexPresent(): SelectAst; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts index 9b1e023845da..165d65c83278 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/diff-database-schema.ts @@ -171,11 +171,16 @@ export function buildSqlitePlanDiff(input: { // normalizes either input uniformly (an already-real tree passes through // untouched — its nested values are already instances) and is a no-op // rebuild in the common (real-instance) case, so this is always safe to run. - const actual = new SqlSchemaIR(withRecordKeyNames(input.actualSchema)); + const actual = sqliteActualSchema(input.actualSchema); const issues = diffSchemas(expected, actual); return { expected, actual, issues }; } +/** The schema a plan starts from as a `SqlSchemaIR`, its table and column names taken from their keys. */ +export function sqliteActualSchema(actualSchema: SqlSchemaIRNode): SqlSchemaIR { + return new SqlSchemaIR(withRecordKeyNames(actualSchema)); +} + /** * Every schema-tree builder in this codebase derives a table's / column's * `name` from the record key it's stored under (`contractToSchemaIR`, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts index 7dc91f66e553..90c9d3587c05 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/index-replacements.ts @@ -2,9 +2,16 @@ import type { SchemaDiffIssue } from '@internal/framework-components/control'; import { issueOutcome } from '@internal/framework-components/control'; import { parseWireName } from '@internal/sql-schema-ir/naming'; import { SqlIndexIR } from '@internal/sql-schema-ir/types'; +import { isArrayEqual } from '@internal/utils/array-equal'; import { sqliteIdentifiersCollide } from './identifier-case'; import { issueNode } from './issue-planner'; -import { CreateIndexCall, DropIndexCall, type SqliteOpFactoryCall } from './op-factory-call'; +import { + CreateIndexCall, + DropIndexCall, + type IndexReplacement, + indexReplacementCalls, + type SqliteOpFactoryCall, +} from './op-factory-call'; export interface IndexFinding { readonly issue: SchemaDiffIssue; @@ -44,6 +51,22 @@ export function renamedTableIndex(renamedTables: ReadonlySet): IndexRepl }; } +/** + * An index on the renamed column whose wire name derives from the column name: after the rename + * the old and the new index cover the same columns on the same table and differ in name. + */ +export function renamedColumnIndex(table: string, column: string): IndexReplacementMatch { + return (old, replacement) => + old.tableName === table && + replacement.tableName === table && + old.index.name !== replacement.index.name && + old.index.expression === undefined && + old.index.where === undefined && + (old.index.columns ?? []).includes(column) && + old.index.unique === replacement.index.unique && + isArrayEqual(old.index.columns ?? [], replacement.index.columns ?? []); +} + /** * An index on the same table whose new name SQLite takes for the old one, as after a table renamed by hand. Its content is not compared: the old index must be dropped before the new one can be created whatever either defines, and the new one is created from its own definition. */ @@ -59,6 +82,7 @@ export function pairIndexReplacements( issues: readonly SchemaDiffIssue[], matches: IndexReplacementMatch, ): { + readonly replacements: readonly IndexReplacement[]; readonly calls: readonly SqliteOpFactoryCall[]; readonly consumed: ReadonlySet; } { @@ -69,18 +93,17 @@ export function pairIndexReplacements( missing.splice(missing.indexOf(replacement), 1); return [{ old, replacement }]; }); + const replacements = pairs.map(({ old, replacement }) => ({ + drop: new DropIndexCall(old.tableName, old.index.name), + create: new CreateIndexCall( + replacement.tableName, + replacement.index.name, + replacement.index.columns ?? [], + ), + })); return { - calls: [ - ...pairs.map(({ old }) => new DropIndexCall(old.tableName, old.index.name)), - ...pairs.map( - ({ replacement }) => - new CreateIndexCall( - replacement.tableName, - replacement.index.name, - replacement.index.columns ?? [], - ), - ), - ], + replacements, + calls: indexReplacementCalls(replacements), consumed: new Set(pairs.flatMap(({ old, replacement }) => [old.issue, replacement.issue])), }; } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts index a60dece42b3c..5e1938f76783 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/op-factory-call.ts @@ -10,7 +10,7 @@ */ import type { ColumnDefault } from '@internal/contract/types'; -import { errorUnfilledPlaceholder } from '@internal/errors/migration'; +import { unfilledPlaceholderOperation } from '@internal/errors/migration'; import type { MigrationOperationClass, SqlMigrationPlanOperation, @@ -31,11 +31,16 @@ import { tsQuotedTextSource, } from '@internal/ts-render'; import { ifDefined } from '@internal/utils/defined'; -import { columnExistsAst, indexExistsAst, tableExistsAst } from '../../contract-free/checks'; +import { + columnExistsAst, + indexExistsAst, + tableExistsAst, + tableNameTakenAst, +} from '../../contract-free/checks'; import * as contractFreeDdl from '../../contract-free/ddl'; import { sqliteError } from '../errors'; import { quoteIdentifier } from '../sql-utils'; -import { addColumn, dropColumnExecuteSql } from './operations/columns'; +import { addColumn, dropColumnExecuteSql, renameColumn } from './operations/columns'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import { step } from './operations/shared'; import { @@ -62,6 +67,11 @@ abstract class SqliteOpFactoryCallNode extends TsExpression implements Framework abstract readonly label: string; abstract toOp(lowerer?: Lowerer): Op | Promise; + /** The operations this call lowers to, in order: one, unless the call carries companions. */ + toOps(lowerer?: ExecuteRequestLowerer): readonly (Op | Promise)[] { + return [this.toOp(lowerer)]; + } + importRequirements(): readonly ImportRequirement[] { return [{ moduleSpecifier: TARGET_MIGRATION_MODULE, symbol: this.factoryName }]; } @@ -71,6 +81,15 @@ abstract class SqliteOpFactoryCallNode extends TsExpression implements Framework } } +export type { SqliteOpFactoryCallNode }; + +/** Whether `call` is one of this target's calls, which lower through {@link SqliteOpFactoryCallNode.toOps}. */ +export function isSqliteOpFactoryCall( + call: FrameworkOpFactoryCall, +): call is SqliteOpFactoryCallNode { + return call instanceof SqliteOpFactoryCallNode; +} + // ============================================================================ // Table // ============================================================================ @@ -277,6 +296,22 @@ export class DropTableCall extends SqliteOpFactoryCallNode { } } +/** An index SQLite cannot rename, dropped and created again under its new name. */ +export interface IndexReplacement { + readonly drop: DropIndexCall; + readonly create: CreateIndexCall; +} + +/** A call a table or column rename carries to replace an index whose name derives from the old name. */ +export type RenameCompanionCall = DropIndexCall | CreateIndexCall; + +/** The replaced indexes' drops, then their creates, since a create may take a dropped name. */ +export function indexReplacementCalls( + replacements: readonly IndexReplacement[], +): RenameCompanionCall[] { + return [...replacements.map(({ drop }) => drop), ...replacements.map(({ create }) => create)]; +} + export class RenameTableCall extends SqliteOpFactoryCallNode { readonly factoryName = 'renameTable' as const; // `widening`: a rename is neither additive creation nor destructive, and the @@ -287,15 +322,37 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { /** The new name: the table's contract-side identity after the rename. */ readonly tableName: string; readonly label: string; + /** The indexes named after the old table, which SQLite cannot rename, so it replaces them. */ + readonly indexReplacements: readonly IndexReplacement[]; + /** + * The replacements' drops, then their creates. They run after the table rename and are never + * rendered on their own. + */ + readonly companions: readonly RenameCompanionCall[]; - constructor(oldTableName: string, tableName: string) { + constructor( + oldTableName: string, + tableName: string, + indexReplacements: readonly IndexReplacement[], + ) { super(); this.oldTableName = oldTableName; this.tableName = tableName; this.label = `Rename table ${oldTableName} to ${tableName}`; + this.indexReplacements = Object.freeze([...indexReplacements]); + this.companions = Object.freeze(indexReplacementCalls(indexReplacements)); this.freeze(); } + /** The id of the operation this call lowers to. */ + get operationId(): string { + return `renameTable.${this.oldTableName}`; + } + + override toOps(lowerer?: ExecuteRequestLowerer): readonly Promise[] { + return [this.toOp(lowerer), ...this.companions.map((companion) => companion.toOp(lowerer))]; + } + async toOp(lowerer?: ExecuteRequestLowerer): Promise { if (lowerer === undefined) { throw sqliteError( @@ -307,22 +364,31 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { const fromChecks = tableExistsAst(this.oldTableName); const toChecks = tableExistsAst(this.tableName); const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.tablePresent()); - const toAbsent = await lowerer.lowerToExecuteRequest(toChecks.tableAbsent()); + const onlyCase = renameChangesOnlyCase(this.oldTableName, this.tableName); + const toAbsent = await lowerer.lowerToExecuteRequest( + onlyCase ? toChecks.tableAbsent() : tableNameTakenAst(this.tableName).nameFree(), + ); const viaName = renameTableViaName(this.tableName); - const viaAbsent = renameChangesOnlyCase(this.oldTableName, this.tableName) + const viaAbsent = onlyCase ? await lowerer.lowerToExecuteRequest(tableExistsAst(viaName).tableAbsent()) : undefined; const toPresent = await lowerer.lowerToExecuteRequest(toChecks.tablePresent()); const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.tableAbsent()); return { - id: `renameTable.${this.oldTableName}`, + id: this.operationId, label: this.label, summary: `Renames table ${this.oldTableName} to ${this.tableName}, keeping its rows`, operationClass: 'widening', target: { id: 'sqlite', details: buildTargetDetails('table', this.tableName) }, precheck: [ step(`ensure table "${this.oldTableName}" exists`, fromPresent.sql, fromPresent.params), - step(`ensure table "${this.tableName}" does not exist`, toAbsent.sql, toAbsent.params), + step( + onlyCase + ? `ensure table "${this.tableName}" does not exist` + : `ensure no table or index is named "${this.tableName}" in any case`, + toAbsent.sql, + toAbsent.params, + ), ...(viaAbsent === undefined ? [] : [ @@ -357,6 +423,63 @@ export class RenameTableCall extends SqliteOpFactoryCallNode { } } +export class RenameColumnCall extends SqliteOpFactoryCallNode { + readonly factoryName = 'renameColumn' as const; + // `widening` for the same reason as `RenameTableCall`. + readonly operationClass = 'widening' as const; + readonly tableName: string; + readonly oldColumnName: string; + /** The new name: the column's contract-side identity after the rename. */ + readonly columnName: string; + readonly label: string; + /** The indexes on the column whose wire names change, which SQLite cannot rename. */ + readonly indexReplacements: readonly IndexReplacement[]; + /** + * The replacements' drops, then their creates. They run after the column rename and are never + * rendered on their own. + */ + readonly companions: readonly RenameCompanionCall[]; + + constructor( + tableName: string, + oldColumnName: string, + columnName: string, + indexReplacements: readonly IndexReplacement[], + ) { + super(); + this.tableName = tableName; + this.oldColumnName = oldColumnName; + this.columnName = columnName; + this.label = `Rename column ${oldColumnName} on ${tableName} to ${columnName}`; + this.indexReplacements = Object.freeze([...indexReplacements]); + this.companions = Object.freeze(indexReplacementCalls(indexReplacements)); + this.freeze(); + } + + override toOps(lowerer?: ExecuteRequestLowerer): readonly Promise[] { + return [this.toOp(lowerer), ...this.companions.map((companion) => companion.toOp(lowerer))]; + } + + async toOp(lowerer?: ExecuteRequestLowerer): Promise { + if (lowerer === undefined) { + throw sqliteError( + 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', + `RenameColumnCall.toOp: a lowerer is required on the SQLite planner path (column "${this.oldColumnName}" on table "${this.tableName}"). Pass the control adapter to createSqliteMigrationPlanner.`, + { meta: { factory: this.factoryName, tableName: this.tableName } }, + ); + } + return renameColumn(this.tableName, this.oldColumnName, this.columnName, lowerer); + } + + renderTypeScript(): string { + return `...this.renameColumn({ table: ${jsonToTsSource(this.tableName)}, column: ${jsonToTsSource(this.oldColumnName)}, to: ${jsonToTsSource(this.columnName)} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + export class RecreateTableCall extends SqliteOpFactoryCallNode { readonly factoryName = 'recreateTable' as const; readonly operationClass: MigrationOperationClass; @@ -570,6 +693,11 @@ export class CreateIndexCall extends SqliteOpFactoryCallNode { this.freeze(); } + /** The id of the operation this call lowers to. */ + get operationId(): string { + return `index.${this.tableName}.${this.indexName}`; + } + async toOp(lowerer?: ExecuteRequestLowerer): Promise { if (lowerer === undefined) { throw sqliteError( @@ -584,7 +712,7 @@ export class CreateIndexCall extends SqliteOpFactoryCallNode { const absent = await lowerer.lowerToExecuteRequest(checks.indexAbsent()); const present = await lowerer.lowerToExecuteRequest(checks.indexPresent()); return { - id: `index.${this.tableName}.${this.indexName}`, + id: this.operationId, label: `Create index ${this.indexName} on ${this.tableName}`, summary: `Creates index ${this.indexName} on ${this.tableName}`, operationClass: 'additive', @@ -614,7 +742,7 @@ export class CreateIndexCall extends SqliteOpFactoryCallNode { export class DropIndexCall extends SqliteOpFactoryCallNode { readonly factoryName = 'dropIndex' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly tableName: string; readonly indexName: string; readonly label: string; @@ -627,6 +755,11 @@ export class DropIndexCall extends SqliteOpFactoryCallNode { this.freeze(); } + /** The id of the operation this call lowers to. */ + get operationId(): string { + return `dropIndex.${this.tableName}.${this.indexName}`; + } + async toOp(lowerer?: ExecuteRequestLowerer): Promise { if (lowerer === undefined) { throw sqliteError( @@ -641,10 +774,10 @@ export class DropIndexCall extends SqliteOpFactoryCallNode { const present = await lowerer.lowerToExecuteRequest(checks.indexPresent()); const absent = await lowerer.lowerToExecuteRequest(checks.indexAbsent()); return { - id: `dropIndex.${this.tableName}.${this.indexName}`, + id: this.operationId, label: `Drop index ${this.indexName} on ${this.tableName}`, summary: `Drops index ${this.indexName} on ${this.tableName} which is not in the contract`, - operationClass: 'destructive', + operationClass: 'widening', target: { id: 'sqlite', details: buildTargetDetails('index', this.indexName, this.tableName), @@ -676,7 +809,7 @@ export class DropIndexCall extends SqliteOpFactoryCallNode { * any future strategy that needs a placeholder data step can construct one * with its own id/label. * - * `toOp()` always throws `MIGRATION.UNFILLED_PLACEHOLDER`: the planner cannot lower a stubbed + * `toOp()` always rejects with `MIGRATION.UNFILLED_PLACEHOLDER`: the planner cannot lower a stubbed * transform to a runtime op — the user must edit the rendered * `migration.ts` and re-emit. */ @@ -697,8 +830,8 @@ export class DataTransformCall extends SqliteOpFactoryCallNode { this.freeze(); } - toOp(_lowerer?: Lowerer): Op { - throw errorUnfilledPlaceholder(this.label); + toOp(_lowerer?: Lowerer): Promise { + return unfilledPlaceholderOperation(this.label); } renderTypeScript(): string { @@ -767,6 +900,7 @@ export type SqliteOpFactoryCall = | DropTableCall | RecreateTableCall | RenameTableCall + | RenameColumnCall | AddColumnCall | DropColumnCall | CreateIndexCall diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/columns.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/columns.ts index 19c8251439b1..c64930542e44 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/columns.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/columns.ts @@ -1,6 +1,7 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { columnExistsAst } from '../../../contract-free/checks'; +import { columnExistsAst, columnNameTakenAst } from '../../../contract-free/checks'; import { quoteIdentifier } from '../../sql-utils'; +import { sqliteIdentifiersCollide } from '../identifier-case'; import { buildTargetDetails } from '../planner-target-details'; import { type Op, @@ -80,3 +81,72 @@ export async function dropColumn( ], }; } + +export function renameColumnExecuteSql( + tableName: string, + fromName: string, + toName: string, +): string { + return `ALTER TABLE ${quoteIdentifier(tableName)} RENAME COLUMN ${quoteIdentifier(fromName)} TO ${quoteIdentifier(toName)}`; +} + +/** + * Renames a column. SQLite updates the indexes, foreign keys and triggers that name it, and keeps + * index names. SQLite takes names that differ only in case for the same name, so the new name + * must be free whatever its case, except in a rename that only changes case, which SQLite performs + * in one statement. + */ +export function renameColumnOperationId(tableName: string, fromName: string): string { + return `renameColumn.${tableName}.${fromName}`; +} + +export async function renameColumn( + tableName: string, + fromName: string, + toName: string, + lowerer: ExecuteRequestLowerer, +): Promise { + const fromChecks = columnExistsAst(tableName, fromName); + const toChecks = columnExistsAst(tableName, toName); + const fromPresent = await lowerer.lowerToExecuteRequest(fromChecks.columnPresent()); + const toAbsent = await lowerer.lowerToExecuteRequest( + sqliteIdentifiersCollide(fromName, toName) + ? toChecks.columnAbsent() + : columnNameTakenAst(tableName, toName).nameFree(), + ); + const toPresent = await lowerer.lowerToExecuteRequest(toChecks.columnPresent()); + const fromAbsent = await lowerer.lowerToExecuteRequest(fromChecks.columnAbsent()); + return { + id: renameColumnOperationId(tableName, fromName), + label: `Rename column ${fromName} on ${tableName} to ${toName}`, + summary: `Renames column ${fromName} on ${tableName} to ${toName}, keeping its values`, + operationClass: 'widening', + target: { id: 'sqlite', details: buildTargetDetails('column', toName, tableName) }, + precheck: [ + step( + `ensure column "${fromName}" exists on "${tableName}"`, + fromPresent.sql, + fromPresent.params, + ), + step( + `ensure column "${toName}" does not exist on "${tableName}"`, + toAbsent.sql, + toAbsent.params, + ), + ], + execute: [ + step( + `rename column "${fromName}" on "${tableName}" to "${toName}"`, + renameColumnExecuteSql(tableName, fromName, toName), + ), + ], + postcheck: [ + step(`verify column "${toName}" exists on "${tableName}"`, toPresent.sql, toPresent.params), + step( + `verify column "${fromName}" no longer exists on "${tableName}"`, + fromAbsent.sql, + fromAbsent.params, + ), + ], + }; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/indexes.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/indexes.ts index 8e66f1d552fa..43be0868e1bc 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/indexes.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operations/indexes.ts @@ -47,7 +47,7 @@ export async function dropIndex( id: `dropIndex.${tableName}.${indexName}`, label: `Drop index ${indexName} on ${tableName}`, summary: `Drops index ${indexName} on ${tableName} which is not in the contract`, - operationClass: 'destructive', + operationClass: 'widening', target: { id: 'sqlite', details: buildTargetDetails('index', indexName, tableName) }, precheck: [step(`ensure index "${indexName}" exists`, present.sql, present.params)], execute: [step(`drop index "${indexName}"`, buildDropIndexSql(indexName))], diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts index 306b0562fb6a..4f99e02c21cc 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts @@ -94,11 +94,10 @@ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | return expected.nullable ? 'widening' : 'destructive'; } case RelationalSchemaNodeKind.columnDefault: - return issueOutcome(issue) === 'not-expected' ? 'destructive' : 'widening'; case RelationalSchemaNodeKind.primaryKey: case RelationalSchemaNodeKind.foreignKey: case RelationalSchemaNodeKind.unique: - return 'destructive'; + return 'widening'; default: return null; } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index f7a22319cbe1..88ff43303ae6 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -1,8 +1,10 @@ import type { Contract } from '@internal/contract/types'; import type { MigrationOperationPolicy, + PlannedStatements, SqlMigrationPlanner, SqlMigrationPlannerPlanOptions, + SqlPlannerConflict, SqlPlannerFailureResult, } from '@internal/family-sql/control'; import { @@ -10,12 +12,16 @@ import { extractCodecControlHooks, planFieldEventOperations, plannerFailure, + planStatements, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { + AppliedMigrationStatement, MigrationPlanner, MigrationScaffoldContext, + PlanOrigin, + ResolvedMigrationStatement, SchemaDiffIssue, SchemaOwnership, } from '@internal/framework-components/control'; @@ -26,7 +32,8 @@ import { type SqlSchemaIR, SqlTableIR, } from '@internal/sql-schema-ir/types'; -import { buildSqlitePlanDiff } from './diff-database-schema'; +import { ok, type Result } from '@internal/utils/result'; +import { buildSqlitePlanDiff, sqliteActualSchema } from './diff-database-schema'; import { indexNameCaseChange, pairIndexReplacements } from './index-replacements'; import { coalesceSubtreeIssues, @@ -34,14 +41,21 @@ import { issueNode, planIssues, } from './issue-planner'; -import { RenameTableCall } from './op-factory-call'; -import { renameTableSteps } from './operations/tables'; +import type { RenameColumnCall, RenameTableCall } from './op-factory-call'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, } from './planner-produced-sqlite-migration'; import { sqlitePlannerStrategies } from './planner-strategies'; import type { SqlitePlanTargetDetails } from './planner-target-details'; +import { sqliteSchemaTables } from './schema-tables'; +import { + renameTableByHandStatements, + renderRenameTableCall, + sqliteColumnRenameCall, + sqliteTableRenameCall, +} from './table-rename-calls'; +import { createWorkingSchema } from './working-schema'; export function createSqliteMigrationPlanner( lowerer: ExecuteRequestLowerer, @@ -50,7 +64,11 @@ export function createSqliteMigrationPlanner( } export type SqlitePlanResult = - | { readonly kind: 'success'; readonly plan: TypeScriptRenderableSqliteMigration } + | { + readonly kind: 'success'; + readonly plan: TypeScriptRenderableSqliteMigration; + readonly appliedStatements: readonly AppliedMigrationStatement[]; + } | SqlPlannerFailureResult; /** @@ -88,11 +106,12 @@ export class SqliteMigrationPlanner * * Typed as the framework `Contract | null` to satisfy the * `MigrationPlanner` interface contract; `planSql` narrows to the SQL - * shape via `SqlMigrationPlannerPlanOptions`. Used to populate - * `describe().from` on the produced plan as - * `fromContract?.storage.storageHash ?? null`. + * shape via `SqlMigrationPlannerPlanOptions`. */ readonly fromContract: Contract | null; + /** The origin the produced plan asserts: its `describe().from` and `origin`. */ + readonly origin: PlanOrigin | null; + readonly statements: readonly ResolvedMigrationStatement[]; readonly frameworkComponents: ReadonlyArray>; /** * Contract space this plan applies to. Stamped onto the produced @@ -136,7 +155,13 @@ export class SqliteMigrationPlanner const policyResult = this.ensureAdditivePolicy(options.policy); if (policyResult) return policyResult; - const { expected, actual, issues: diffIssues } = this.collectSchemaIssues(options); + const statements = this.planStatements(options); + if (!statements.ok) return plannerFailure([statements.failure]); + const { + expected, + actual, + issues: diffIssues, + } = this.collectSchemaIssues(options, statements.value.schema); const replacedIndexes = pairIndexReplacements(diffIssues, indexNameCaseChange); const disallowedIndexCalls = replacedIndexes.calls.filter( (call) => !options.policy.allowedOperationClasses.includes(call.operationClass), @@ -156,9 +181,8 @@ export class SqliteMigrationPlanner return node instanceof SqlTableIR ? node : undefined; }, namespaceIdOf: () => UNBOUND_NAMESPACE_ID, - renameByHandStatements: (rename) => - renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql), - renameTableCall: (rename) => new RenameTableCall(rename.from, rename.to).renderTypeScript(), + renameByHandStatements: renameTableByHandStatements, + renameTableCall: renderRenameTableCall, contract: options.contract, defaultNamespaceId: UNBOUND_NAMESPACE_ID, }); @@ -190,11 +214,18 @@ export class SqliteMigrationPlanner priorContract: options.fromContract, newContract: options.contract, codecHooks, + tableRenames: statements.value.tableRenames, + columnRenames: statements.value.columnRenames, }); // Codec-emitted calls already conform to `OpFactoryCall` — render + // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. - const calls = [...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps]; + const calls = [ + ...statements.value.calls, + ...replacedIndexes.calls, + ...result.value.calls, + ...fieldEventOps, + ]; const destination: SqliteMigrationDestinationInfo = { storageHash: options.contract.storage.storageHash, @@ -208,7 +239,7 @@ export class SqliteMigrationPlanner plan: new TypeScriptRenderableSqliteMigration( calls, { - from: options.fromContract?.storage.storageHash ?? null, + from: options.origin?.storageHash ?? null, to: options.contract.storage.storageHash, }, options.spaceId, @@ -216,9 +247,52 @@ export class SqliteMigrationPlanner destination, this.#lowerer, ), + appliedStatements: statements.value.appliedStatements, }; } + /** + * Plans the statements against a working copy of the schema the plan starts from, in order, so + * each rename's index replacements are computed against the schema the earlier statements left. + * The diff then runs on that schema. + */ + private planStatements( + options: SqlMigrationPlannerPlanOptions, + ): Result< + PlannedStatements & { readonly schema: SqlSchemaIR }, + SqlPlannerConflict + > { + const working = createWorkingSchema(sqliteActualSchema(options.schema)); + const planned = planStatements({ + statements: options.statements, + fromContract: options.fromContract, + contract: options.contract, + policy: options.policy, + target: { + tables: () => sqliteSchemaTables(working.current), + renameTableCall: (rename) => + sqliteTableRenameCall({ + previous: working.current, + contract: options.contract, + rename, + frameworkComponents: options.frameworkComponents, + }), + renameColumnCall: (rename) => + sqliteColumnRenameCall({ + previous: working.current, + contract: options.contract, + rename, + frameworkComponents: options.frameworkComponents, + }), + apply: (call) => working.apply(call), + renderTableRename: renderRenameTableCall, + tableRenameByHand: renameTableByHandStatements, + }, + }); + if (!planned.ok) return planned; + return ok({ ...planned.value, schema: working.current }); + } + private ensureAdditivePolicy(policy: MigrationOperationPolicy): SqlPlannerFailureResult | null { if (!policy.allowedOperationClasses.includes('additive')) { return plannerFailure([ @@ -261,7 +335,10 @@ export class SqliteMigrationPlanner * an additive-only plan must never even consider dropping an unclaimed * object, not just refuse to emit the drop. */ - private collectSchemaIssues(options: SqlMigrationPlannerPlanOptions): { + private collectSchemaIssues( + options: SqlMigrationPlannerPlanOptions, + schema: SqlSchemaIR, + ): { readonly expected: SqlSchemaIR; readonly actual: SqlSchemaIR; readonly issues: readonly SchemaDiffIssue[]; @@ -274,7 +351,7 @@ export class SqliteMigrationPlanner issues: rawIssues, } = buildSqlitePlanDiff({ contract: options.contract, - actualSchema: options.schema, + actualSchema: schema, frameworkComponents: options.frameworkComponents, }); const coalesced = coalesceSubtreeIssues(rawIssues); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/render-ops.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/render-ops.ts index c146c92dcd36..dacc71a23352 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/render-ops.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/render-ops.ts @@ -4,6 +4,7 @@ import type { MigrationPlanOperation, OpFactoryCall } from '@internal/framework- import { blindCast } from '@internal/utils/casts'; import { isThenable } from '@internal/utils/promise'; import { sqliteError } from '../errors'; +import { isSqliteOpFactoryCall } from './op-factory-call'; import type { SqlitePlanTargetDetails } from './planner-target-details'; type Op = SqlMigrationPlanOperation; @@ -22,22 +23,36 @@ function assertSqliteOp(op: MigrationPlanOperation, callFactoryName: string): as } } +function checkedOp( + opOrPromise: MigrationPlanOperation | Promise, + callFactoryName: string, +): Op | Promise { + if (isThenable(opOrPromise)) { + const checked = opOrPromise.then((op) => { + assertSqliteOp(op, callFactoryName); + return op; + }); + // A reader may take a plan's operations without awaiting each one, as with a placeholder's. + checked.catch(() => undefined); + return checked; + } + assertSqliteOp(opOrPromise, callFactoryName); + return opOrPromise; +} + export function renderOps( calls: readonly OpFactoryCall[], lowerer?: ExecuteRequestLowerer, ): (Op | Promise)[] { - return calls.map((c) => { - const opOrPromise = blindCast< - { toOp(lowerer?: ExecuteRequestLowerer): Op | Promise }, - 'SQLite OpFactoryCall.toOp accepts an optional ExecuteRequestLowerer; the framework interface omits it because not all targets need a lowerer — the SQLite target overrides with this extended signature' - >(c).toOp(lowerer); - if (isThenable(opOrPromise)) { - return opOrPromise.then((op) => { - assertSqliteOp(op, c.factoryName); - return op; - }); - } - assertSqliteOp(opOrPromise, c.factoryName); - return opOrPromise; + return calls.flatMap((c) => { + const lowered = isSqliteOpFactoryCall(c) + ? c.toOps(lowerer) + : [ + blindCast< + { toOp(lowerer?: ExecuteRequestLowerer): Op | Promise }, + 'SQLite OpFactoryCall.toOp accepts an optional ExecuteRequestLowerer; the framework interface omits it because not all targets need a lowerer — the SQLite target overrides with this extended signature' + >(c).toOp(lowerer), + ]; + return lowered.map((opOrPromise) => checkedOp(opOrPromise, c.factoryName)); }); } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 73ca96984d90..e0f7622da880 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -23,6 +23,7 @@ import type { Result } from '@internal/utils/result'; import { notOk, ok, okVoid } from '@internal/utils/result'; import { MARKER_TABLE_NAME } from '../control-tables'; import { sqliteError } from '../errors'; +import { quoteIdentifier } from '../sql-utils'; import { verifySqliteDatabaseSchema } from './diff-database-schema'; import type { SqlitePlanTargetDetails } from './planner-target-details'; @@ -319,7 +320,7 @@ class SqliteMigrationRunner implements SqlMigrationRunner['driver'], ): Promise> { const tableInfo = await driver.query<{ name: string }>( - `PRAGMA table_info("${MARKER_TABLE_NAME}")`, + `PRAGMA table_info(${quoteIdentifier(MARKER_TABLE_NAME)})`, ); if (tableInfo.rows.length === 0) { return okVoid(); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/schema-tables.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/schema-tables.ts new file mode 100644 index 000000000000..a04300530d0d --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/schema-tables.ts @@ -0,0 +1,25 @@ +import type { SchemaTables } from '@internal/family-sql/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { sqliteIdentifiersCollide } from './identifier-case'; + +/** + * The tables of a SQLite schema. SQLite has one namespace, so the namespace id is ignored, and it + * compares table and column names without regard to the case of ASCII letters. + */ +export function sqliteSchemaTables(schema: SqlSchemaIR): SchemaTables { + const hasTable = (_namespaceId: string, table: string) => Object.hasOwn(schema.tables, table); + return { + hasTable, + hasColumn: (_namespaceId, table, column) => + Object.hasOwn(schema.tables[table]?.columns ?? {}, column), + tablesNamed: (_namespaceId, table) => + Object.keys(schema.tables).filter((existing) => sqliteIdentifiersCollide(existing, table)), + columnsNamed: (_namespaceId, table, column) => + Object.keys(schema.tables[table]?.columns ?? {}).filter((existing) => + sqliteIdentifiersCollide(existing, column), + ), + namespacesWithTable: (table) => + hasTable(UNBOUND_NAMESPACE_ID, table) ? [UNBOUND_NAMESPACE_ID] : [], + }; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts index 2985c63da630..bf401ff0d235 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/sqlite-migration.ts @@ -1,7 +1,14 @@ import type { Contract } from '@internal/contract/types'; -import type { - MigrationOperationClass, - SqlMigrationPlanOperation, +import { + type ColumnRenameRequest, + type MigrationOperationClass, + resolveColumnRenameAgainst, + resolveTableRenameAgainst, + type SqlMigrationPlanOperation, + sqlTypeLookupsOf, + type TableRenameRequest, + unmatchedColumnRename, + unmatchedTableRename, } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import { Migration as SqlMigration } from '@internal/family-sql/migration'; @@ -10,9 +17,11 @@ import type { ControlStack } from '@internal/framework-components/control'; import { MigrationContractViews } from '@internal/migration-tools/migration'; import type { SqlStorage } from '@internal/sql-contract/types'; import type { DdlColumn, DdlTableConstraint } from '@internal/sql-relational-core/ast'; +import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; import { blindCast } from '@internal/utils/casts'; import { errorSqliteMigrationStackMissing } from '../errors'; import { SqliteContractView } from '../sqlite-contract-view'; +import { sqliteContractToSchema } from './diff-database-schema'; import { AddColumnCall, CreateIndexCall, @@ -21,11 +30,15 @@ import { DropIndexCall, DropTableCall, RecreateTableCall, + type RenameColumnCall, + type RenameTableCall, } from './op-factory-call'; import type { SqliteColumnSpec, SqliteIndexSpec, SqliteTableSpec } from './operations/shared'; import type { RecreatePostcheck } from './operations/tables'; import type { SqlitePlanTargetDetails } from './planner-target-details'; -import { sqliteTableRenameCalls } from './table-rename-calls'; +import { sqliteSchemaTables } from './schema-tables'; +import { sqliteColumnRenameCall, sqliteTableRenameCall } from './table-rename-calls'; +import { createWorkingSchema } from './working-schema'; type Op = SqlMigrationPlanOperation; @@ -68,6 +81,9 @@ export abstract class SqliteMigration< #endView = new MigrationContractViews>(this, 'SqliteMigration', (json) => SqliteContractView.fromJson(json), ); + /** The rename calls this read of `operations` has made so far, in order. */ + #renames: (RenameTableCall | RenameColumnCall)[] = []; + #startView = new MigrationContractViews>( this, 'SqliteMigration', @@ -139,19 +155,100 @@ export abstract class SqliteMigration< } /** - * Emit the operations that rename a table: the table rename, then a drop and a create under the new name of each index whose name was derived from the old table name, read from this migration's start and end contracts. Spread the result into `operations`: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + * Emit the operations that rename a table: the table rename, then a drop and a create under the + * new name of each index whose name was derived from the old table name. The old name is resolved + * against the schema as this migration's earlier `renameTable` calls leave it, and the new name + * against the end contract. Spread the result into `operations`: + * `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. Throws + * `MIGRATION.TABLE_RENAME_UNMATCHED` when the table does not exist at that point of the migration + * or the end contract lacks the new name. */ protected renameTable(options: { readonly table: string; readonly to: string; }): readonly Promise[] { const adapter = this.controlAdapterFor('renameTable'); - return sqliteTableRenameCalls({ - startContract: this.startContract, - endContract: this.endContract, - rename: { namespaceId: undefined, from: options.table, to: options.to }, + const rename: TableRenameRequest = { + namespaceId: undefined, + from: options.table, + to: options.to, + }; + const startContract = this.startContract; + if (startContract === null) { + throw unmatchedTableRename(rename, 'the migration has no start contract'); + } + const current = this.schemaAfterRenames(startContract); + const endContract = this.endContract; + const resolved = resolveTableRenameAgainst(sqliteSchemaTables(current), endContract, rename); + if (!resolved.ok) { + throw resolved.failure; + } + const call = sqliteTableRenameCall({ + previous: current, + contract: endContract, + rename: resolved.value, + frameworkComponents: this.frameworkComponents(), + }); + this.#renames.push(call); + return call.toOps(adapter); + } + + /** + * Emit the operations that rename a column: the column rename, then a drop and a create under + * the new name of each index on the column whose name was derived from the old column name. The + * column is resolved against the schema as this migration's earlier rename calls leave it, so a + * column of a table an earlier `renameTable` renamed is named on the table's new name. Spread the + * result into `operations`: `...this.renameColumn({ table: 'User', column: 'name', to: + * 'fullName' })`. Throws `MIGRATION.COLUMN_RENAME_UNMATCHED` when the table or column does not + * exist at that point of the migration, the table already has the new column, or the end + * contract lacks it. + */ + protected renameColumn(options: { + readonly table: string; + readonly column: string; + readonly to: string; + }): readonly Promise[] { + const adapter = this.controlAdapterFor('renameColumn'); + const rename: ColumnRenameRequest = { + namespaceId: undefined, + table: options.table, + from: options.column, + to: options.to, + }; + const startContract = this.startContract; + if (startContract === null) { + throw unmatchedColumnRename(rename, 'the migration has no start contract'); + } + const current = this.schemaAfterRenames(startContract); + const resolved = resolveColumnRenameAgainst( + sqliteSchemaTables(current), + this.endContract, + rename, + ); + if (!resolved.ok) { + throw resolved.failure; + } + const call = sqliteColumnRenameCall({ + previous: current, + contract: this.endContract, + rename: resolved.value, frameworkComponents: this.frameworkComponents(), - }).map(async (call) => call.toOp(adapter)); + }); + this.#renames.push(call); + return call.toOps(adapter); + } + + /** The start contract's schema with this read's rename calls applied in order. */ + private schemaAfterRenames(startContract: Contract): SqlSchemaIR { + const working = createWorkingSchema( + sqliteContractToSchema(startContract, sqlTypeLookupsOf(this.frameworkComponents())), + ); + for (const call of this.#renames) working.apply(call); + return working.current; + } + + protected override beginOperationsRead(): void { + this.#renames = []; } protected addColumn(options: { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts index 695d87acfdd6..6d8e07b3cee5 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/table-rename-calls.ts @@ -1,42 +1,72 @@ import type { Contract } from '@internal/contract/types'; -import { applyTableRename, sqlTypeLookupsOf, type TableRename } from '@internal/family-sql/control'; +import type { ColumnRename, TableRename, TableRenameRequest } from '@internal/family-sql/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { SqlStorage } from '@internal/sql-contract/types'; -import { buildSqlitePlanDiff, sqliteContractToSchema } from './diff-database-schema'; -import { pairIndexReplacements, renamedTableIndex } from './index-replacements'; +import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { buildSqlitePlanDiff } from './diff-database-schema'; +import { pairIndexReplacements, renamedColumnIndex, renamedTableIndex } from './index-replacements'; import { coalesceSubtreeIssues } from './issue-planner'; -import { RenameTableCall, type SqliteOpFactoryCall } from './op-factory-call'; +import { RenameColumnCall, RenameTableCall } from './op-factory-call'; +import { renameTableSteps } from './operations/tables'; +import { renameColumnInSqliteSchema, renameTableInSqliteSchema } from './working-schema'; + +/** The SQL a user runs against the database to rename the table by hand. */ +export function renameTableByHandStatements(rename: TableRenameRequest): readonly string[] { + return renameTableSteps(rename.from, rename.to).map((renameStep) => renameStep.sql); +} + +/** The `renameTable` call a user writes in `migration.ts` for `rename`; SQLite has no namespaces. */ +export function renderRenameTableCall(rename: TableRenameRequest): string { + return new RenameTableCall(rename.from, rename.to, []).renderTypeScript(); +} /** - * The calls a migration's `renameTable` emits: the table rename, then a drop of each wire-named index whose prefix derives from the old table name and a create of it under the new name, since SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. Throws `MIGRATION.TABLE_RENAME_UNMATCHED` when the start contract lacks the table or the end contract lacks the new name. + * The call that renames a table, carrying as companions a drop and a create under the new name of + * each index whose wire name derives from the old table name, since SQLite cannot rename an index. + * `previous` is the schema with the table under its old name; the renamed copy is diffed against + * `contract`. SQLite names no primary key, unique constraint or foreign key the contract leaves + * unnamed, so those need nothing. */ -export function sqliteTableRenameCalls(input: { - readonly startContract: Contract | null; - readonly endContract: Contract; +export function sqliteTableRenameCall(input: { + readonly previous: SqlSchemaIR; + readonly contract: Contract; readonly rename: TableRename; readonly frameworkComponents: ReadonlyArray>; -}): readonly SqliteOpFactoryCall[] { - const applied = applyTableRename({ - startContract: input.startContract, - endContract: input.endContract, - rename: input.rename, - renameTableReferences: undefined, +}): RenameTableCall { + const { from, to } = input.rename; + const { issues } = buildSqlitePlanDiff({ + contract: input.contract, + actualSchema: renameTableInSqliteSchema(input.previous, { from, to }), + frameworkComponents: input.frameworkComponents, }); - if (!applied.ok) { - throw applied.failure; - } - const { rename } = applied.value; + const { replacements } = pairIndexReplacements( + coalesceSubtreeIssues(issues), + renamedTableIndex(new Set([to])), + ); + return new RenameTableCall(from, to, replacements); +} + +/** + * The call that renames a column, carrying as companions a drop and a create under the new name of + * each index on the column whose wire name derives from the column name, since SQLite cannot rename + * an index and keeps the old name when it renames the column. `previous` is the schema with the + * column under its old name; the renamed copy is diffed against `contract`. + */ +export function sqliteColumnRenameCall(input: { + readonly previous: SqlSchemaIR; + readonly contract: Contract; + readonly rename: ColumnRename; + readonly frameworkComponents: ReadonlyArray>; +}): RenameColumnCall { + const { table, from, to } = input.rename; const { issues } = buildSqlitePlanDiff({ - contract: input.endContract, - actualSchema: sqliteContractToSchema( - applied.value.contract, - sqlTypeLookupsOf(input.frameworkComponents), - ), + contract: input.contract, + actualSchema: renameColumnInSqliteSchema(input.previous, { table, from, to }), frameworkComponents: input.frameworkComponents, }); - return [ - new RenameTableCall(rename.from, rename.to), - ...pairIndexReplacements(coalesceSubtreeIssues(issues), renamedTableIndex(new Set([rename.to]))) - .calls, - ]; + const { replacements } = pairIndexReplacements( + coalesceSubtreeIssues(issues), + renamedColumnIndex(table, to), + ); + return new RenameColumnCall(table, from, to, replacements); } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/working-schema.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/working-schema.ts new file mode 100644 index 000000000000..e5aa9ffea4ef --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/working-schema.ts @@ -0,0 +1,307 @@ +import type { SchemaNodeRef } from '@internal/framework-components/control'; +import { namingOf, namingOfLiveName } from '@internal/sql-schema-ir/naming'; +import { + PrimaryKey, + RelationalSchemaNodeKind, + SqlCheckConstraintIR, + SqlColumnIR, + SqlForeignKeyIR, + SqlIndexIR, + SqlSchemaIR, + SqlTableIR, + SqlUniqueIR, +} from '@internal/sql-schema-ir/types'; +import { ifDefined } from '@internal/utils/defined'; +import { type IndexReplacement, RenameColumnCall, type RenameTableCall } from './op-factory-call'; + +export interface SqliteTableRename { + readonly from: string; + readonly to: string; +} + +type RefRewrite = (ref: SchemaNodeRef) => SchemaNodeRef; + +function rewriteRefs( + refs: readonly SchemaNodeRef[] | undefined, + rewrite: RefRewrite, +): readonly SchemaNodeRef[] | undefined { + return refs?.map(rewrite); +} + +/** + * Rewrites the step of `kind` and `id` in a ref that runs through the given table, and the table + * step itself when `kind` is the table kind. + */ +function stepRewrite(tableName: string, kind: string, fromId: string, toId: string): RefRewrite { + return (ref) => { + const tableStep = ref[1]; + if (tableStep?.nodeKind !== RelationalSchemaNodeKind.table || tableStep.id !== tableName) { + return ref; + } + return ref.map((step, index) => + index >= 1 && step.nodeKind === kind && step.id === fromId + ? { nodeKind: kind, id: toId } + : step, + ); + }; +} + +type ColumnMap = (column: string) => string; + +function withColumnName(column: SqlColumnIR, name: string): SqlColumnIR { + return new SqlColumnIR({ + name, + nativeType: column.nativeType, + nullable: column.nullable, + ...ifDefined('default', column.default), + ...ifDefined('annotations', column.annotations), + ...ifDefined('many', column.many), + ...ifDefined('resolvedNativeType', column.resolvedNativeType), + ...ifDefined('resolvedDefault', column.resolvedDefault), + ...ifDefined('authoredDefault', column.authoredDefault), + ...ifDefined('codecRef', column.codecRef), + ...ifDefined('codecBaseNativeType', column.codecBaseNativeType), + ...ifDefined('dataType', column.dataType), + ...ifDefined('toCanonicalForm', column.toCanonicalForm), + }); +} + +/** + * An index whose definition carries SQL text, an expression or a predicate, keeps its columns as + * they are: a column named inside the text cannot be renamed here, so the diff plans the index. + */ +function withIndex( + index: SqlIndexIR, + name: string, + rewrite: RefRewrite, + columnName: ColumnMap, +): SqlIndexIR { + const columnsFollow = index.expression === undefined && index.where === undefined; + const base = { + naming: name === index.name ? namingOf(index.name, index.prefix) : namingOfLiveName(name), + where: index.where, + unique: index.unique, + partial: index.partial, + type: index.type, + options: index.options, + annotations: index.annotations, + dependsOn: rewriteRefs(index.dependsOn, rewrite), + }; + return new SqlIndexIR( + index.expression !== undefined + ? { ...base, expression: index.expression } + : { ...base, columns: (index.columns ?? []).map(columnsFollow ? columnName : (c) => c) }, + ); +} + +/** + * How one table is rebuilt: its name and its columns' names, where its foreign keys point, and its + * index names. + */ +interface TableEdit { + readonly name: string; + readonly columnName: ColumnMap; + readonly referencedTable: (fk: SqlForeignKeyIR) => string; + readonly referencedColumns: (fk: SqlForeignKeyIR) => readonly string[]; + readonly indexName: (index: SqlIndexIR) => string; +} + +function rebuildTable(table: SqlTableIR, edit: TableEdit, rewrite: RefRewrite): SqlTableIR { + const { primaryKey } = table; + const { columnName } = edit; + return new SqlTableIR({ + name: edit.name, + columns: Object.fromEntries( + Object.values(table.columns).map((column) => { + const name = columnName(column.name); + return [name, name === column.name ? column : withColumnName(column, name)]; + }), + ), + ...ifDefined( + 'primaryKey', + primaryKey === undefined + ? undefined + : new PrimaryKey({ + columns: primaryKey.columns.map(columnName), + ...ifDefined('name', primaryKey.name), + ...ifDefined('dependsOn', rewriteRefs(primaryKey.dependsOn, rewrite)), + }), + ), + uniques: table.uniques.map( + (unique) => + new SqlUniqueIR({ + columns: unique.columns.map(columnName), + ...ifDefined('name', unique.name), + ...ifDefined('annotations', unique.annotations), + ...ifDefined('dependsOn', rewriteRefs(unique.dependsOn, rewrite)), + }), + ), + foreignKeys: table.foreignKeys.map( + (fk) => + new SqlForeignKeyIR({ + columns: fk.columns.map(columnName), + referencedTable: edit.referencedTable(fk), + referencedColumns: edit.referencedColumns(fk), + ...ifDefined('referencedSchema', fk.referencedSchema), + ...ifDefined('name', fk.name), + ...ifDefined('onDelete', fk.onDelete), + ...ifDefined('onUpdate', fk.onUpdate), + ...ifDefined('annotations', fk.annotations), + ...ifDefined('resolvedReferencedNamespace', fk.resolvedReferencedNamespace), + ...ifDefined('dependsOn', rewriteRefs(fk.dependsOn, rewrite)), + }), + ), + indexes: table.indexes.map((index) => + withIndex(index, edit.indexName(index), rewrite, columnName), + ), + ...ifDefined( + 'checks', + table.checks?.map( + (check) => + new SqlCheckConstraintIR({ + naming: namingOf(check.name, check.prefix), + expression: check.expression, + dependsOn: rewriteRefs(check.dependsOn, rewrite), + }), + ), + ), + ...ifDefined('annotations', table.annotations), + }); +} + +/** Rebuilds every table: `editFor` returns the edit for a table, or `undefined` to keep it. */ +function mapTables( + schema: SqlSchemaIR, + rewrite: RefRewrite, + editFor: (table: SqlTableIR) => Partial | undefined, +): SqlSchemaIR { + return new SqlSchemaIR({ + tables: Object.fromEntries( + Object.values(schema.tables).map((table) => { + const rebuilt = rebuildTable( + table, + { + name: table.name, + columnName: (column) => column, + referencedTable: (fk) => fk.referencedTable, + referencedColumns: (fk) => fk.referencedColumns, + indexName: (index) => index.name, + ...editFor(table), + }, + rewrite, + ); + return [rebuilt.name, rebuilt]; + }), + ), + ...ifDefined('annotations', schema.annotations), + }); +} + +/** + * The schema with one table under its new name. Foreign keys anywhere that reference the table + * follow it, and every dependency that names it moves with it. Object names do not change. An + * unnamed primary key, unique constraint or foreign key stays unnamed: SQLite gives such a + * constraint no name, so an introspected schema has none either, and the planner never renames + * one. + */ +export function renameTableInSqliteSchema( + schema: SqlSchemaIR, + rename: SqliteTableRename, +): SqlSchemaIR { + const { from, to } = rename; + const rewrite = stepRewrite(from, RelationalSchemaNodeKind.table, from, to); + return mapTables(schema, rewrite, (table) => ({ + ...(table.name === from ? { name: to } : {}), + referencedTable: (fk) => (fk.referencedTable === from ? to : fk.referencedTable), + })); +} + +export interface SqliteColumnRename { + readonly table: string; + readonly from: string; + readonly to: string; +} + +/** + * The schema with one column under its new name. The table's primary key, unique constraints, + * foreign keys and plain column indexes name it under the new name, foreign keys anywhere that + * reference it follow it, and every dependency that names it moves with it. An index with an + * expression or a predicate keeps its SQL text, and index names do not change, as in SQLite. + */ +export function renameColumnInSqliteSchema( + schema: SqlSchemaIR, + rename: SqliteColumnRename, +): SqlSchemaIR { + const { table: tableName, from, to } = rename; + const rewrite = stepRewrite( + tableName, + RelationalSchemaNodeKind.column, + `column:${from}`, + `column:${to}`, + ); + const renamed = (column: string) => (column === from ? to : column); + return mapTables(schema, rewrite, (table) => ({ + ...(table.name === tableName ? { columnName: renamed } : {}), + referencedColumns: (fk) => + fk.referencedTable === tableName ? fk.referencedColumns.map(renamed) : fk.referencedColumns, + })); +} + +function replaceIndex(schema: SqlSchemaIR, replacement: IndexReplacement): SqlSchemaIR { + const { tableName, indexName: oldIndexName } = replacement.drop; + const newIndexName = replacement.create.indexName; + const rewrite = stepRewrite( + tableName, + RelationalSchemaNodeKind.index, + `index:${oldIndexName}`, + `index:${newIndexName}`, + ); + return mapTables(schema, rewrite, (table) => + table.name === tableName + ? { indexName: (index) => (index.name === oldIndexName ? newIndexName : index.name) } + : undefined, + ); +} + +/** + * The schema a migration's renames have produced so far. The planner starts it from the schema it + * plans from, a hand-written migration from its start contract; each rename call is computed + * against `current` and then applied, so a later call sees the effect of an earlier one. An index + * the rename replaces is applied as a rename of the index node, so it keeps its definition. + */ +export interface WorkingSchema { + readonly current: SqlSchemaIR; + apply(call: RenameTableCall | RenameColumnCall): void; +} + +/** The working schema of a plan or a migration, starting from `initial`. */ +export function createWorkingSchema(initial: SqlSchemaIR): WorkingSchema { + return new WorkingSchemaImpl(initial); +} + +class WorkingSchemaImpl implements WorkingSchema { + #current: SqlSchemaIR; + + constructor(initial: SqlSchemaIR) { + this.#current = initial; + } + + get current(): SqlSchemaIR { + return this.#current; + } + + apply(call: RenameTableCall | RenameColumnCall): void { + const renamed = + call instanceof RenameColumnCall + ? renameColumnInSqliteSchema(this.#current, { + table: call.tableName, + from: call.oldColumnName, + to: call.columnName, + }) + : renameTableInSqliteSchema(this.#current, { + from: call.oldTableName, + to: call.tableName, + }); + this.#current = call.indexReplacements.reduce(replaceIndex, renamed); + } +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/sqlite-unbound-database.ts b/packages/3-targets/3-targets/sqlite/src/core/sqlite-unbound-database.ts index 990990a54975..4f6a03930205 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/sqlite-unbound-database.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/sqlite-unbound-database.ts @@ -12,6 +12,7 @@ import { } from '@internal/sql-contract/types'; import { blindCast } from '@internal/utils/casts'; import { sqliteError } from './errors'; +import { quoteIdentifier } from './sql-utils'; export type SqliteDatabaseInput = { readonly id: string; @@ -65,7 +66,7 @@ export class SqliteDatabase extends SqlNamespaceBase { } qualifyTable(tableName: string): string { - return `"${tableName}"`; + return quoteIdentifier(tableName); } } diff --git a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts index 6c9d44e812e6..67f16e707c25 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/op-factory-call.ts @@ -8,6 +8,7 @@ export { DropTableCall, RawSqlCall, RecreateTableCall, + RenameColumnCall, RenameTableCall, type SqliteOpFactoryCall, } from '../core/migrations/op-factory-call'; diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/nullability-backfill.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/nullability-backfill.test.ts index 47eeb1cb467b..bce118dbc094 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/nullability-backfill.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/nullability-backfill.test.ts @@ -114,6 +114,8 @@ describe('nullability-tightening backfill', async () => { schema: nullableEmailSchema(), policy: { allowedOperationClasses: ['additive', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -134,6 +136,8 @@ describe('nullability-tightening backfill', async () => { schema: nullableEmailSchema(), policy: { allowedOperationClasses: ['additive', 'destructive', 'data'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -142,11 +146,17 @@ describe('nullability-tightening backfill', async () => { expect(result.kind).toBe('success'); if (result.kind !== 'success') return; - // Accessing operations throws because the DataTransformCall stub's - // toOp() unconditionally throws MIGRATION.UNFILLED_PLACEHOLDER — the user must fill the - // rendered migration.ts before the plan is executable. This mirrors - // Postgres's behavior. - expect(() => result.plan.operations).toThrowError(/unfilled/i); + // The DataTransformCall stub's operation rejects with MIGRATION.UNFILLED_PLACEHOLDER — the + // user must fill the rendered migration.ts before the plan is executable — while the other + // operations still resolve. This mirrors Postgres's behavior. + const settled = await Promise.allSettled(result.plan.operations); + expect(settled.filter((entry) => entry.status === 'rejected')).toEqual([ + { + status: 'rejected', + reason: expect.objectContaining({ code: 'MIGRATION.UNFILLED_PLACEHOLDER' }), + }, + ]); + expect(settled.some((entry) => entry.status === 'fulfilled')).toBe(true); // The rendered TypeScript contains the dataTransform placeholder and // the recreate follows it. @@ -205,6 +215,8 @@ describe('nullability-tightening backfill', async () => { schema, policy: { allowedOperationClasses: ['additive', 'widening', 'data'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/op-factory-call.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/op-factory-call.test.ts index 87024f016141..84faa69ee248 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/op-factory-call.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/op-factory-call.test.ts @@ -364,12 +364,13 @@ describe('CreateIndexCall', () => { }); describe('DropIndexCall', () => { - it('produces a destructive DROP INDEX IF EXISTS op', async () => { + it('produces a widening DROP INDEX IF EXISTS op, since an index holds no stored data', async () => { const lowerer = stubLowerer('CHECK SQL'); const call = new DropIndexCall('user', 'idx_email'); const op = await call.toOp(lowerer); + expect(call.operationClass).toBe('widening'); expect(op.id).toBe('dropIndex.user.idx_email'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); expect(op.execute[0]?.sql).toBe('DROP INDEX IF EXISTS "idx_email"'); }); @@ -624,8 +625,10 @@ describe('DataTransformCall', () => { 'email', ); - it('toOp() throws MIGRATION.UNFILLED_PLACEHOLDER (unfilled placeholder)', () => { - expect(() => makeCall().toOp()).toThrowError(/MIGRATION.UNFILLED_PLACEHOLDER|unfilled/i); + it('toOp() rejects with MIGRATION.UNFILLED_PLACEHOLDER (unfilled placeholder)', async () => { + await expect(makeCall().toOp()).rejects.toThrowError( + /MIGRATION.UNFILLED_PLACEHOLDER|unfilled/i, + ); }); it('renderTypeScript() emits a dataTransform({...}) call with a placeholder run slot', () => { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts index a95795718ee8..2025750d83e2 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts @@ -7,7 +7,16 @@ import { type StrategyContext, } from '../../src/core/migrations/planner-strategies'; import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; -import { actualColumn, expectedColumn, issue, primaryKey, table } from './node-issue-helpers'; +import { + actualColumn, + columnDefault, + expectedColumn, + foreignKey, + issue, + primaryKey, + table, + unique, +} from './node-issue-helpers'; function makeContext(overrides: Partial = {}): StrategyContext { return { @@ -85,27 +94,69 @@ describe('recreateTableStrategy', () => { }); it('destructive wins over widening when both occur on the same table', () => { - const defaultDrift = issue({ - path: ['database', 'user', 'column:email', 'default'], - expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), - actual: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), - }); const pkDrift = issue({ path: ['database', 'user', 'primary-key'], expected: primaryKey(['id']), actual: primaryKey(['id', 'email']), }); + const typeDrift = issue({ + path: ['database', 'user', 'column:email'], + expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), + actual: actualColumn({ name: 'email', nativeType: 'INTEGER', nullable: true }), + }); const ctx = makeContext({ expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), actual: new SqlSchemaIR({ tables: { user: actualUserTable } }), }); - const result = recreateTableStrategy([defaultDrift, pkDrift], ctx); + const result = recreateTableStrategy([pkDrift, typeDrift], ctx); expect(result.kind).toBe('match'); if (result.kind !== 'match') return; expect((result.calls[0] as RecreateTableCall).operationClass).toBe('destructive'); expect(result.issues).toHaveLength(0); }); + it.each([ + [ + 'a changed primary key', + issue({ + path: ['database', 'user', 'primary-key'], + expected: primaryKey(['id']), + actual: primaryKey(['id', 'email']), + }), + ], + [ + 'a dropped unique constraint', + issue({ path: ['database', 'user', 'unique:email'], actual: unique(['email']) }), + ], + [ + 'a dropped foreign key', + issue({ + path: ['database', 'user', 'foreign-key:org_id'], + actual: foreignKey({ + columns: ['email'], + referencedTable: 'org', + referencedColumns: ['id'], + }), + }), + ], + [ + 'a removed column default', + issue({ + path: ['database', 'user', 'column:email', 'default'], + actual: columnDefault({ raw: "'x'" }), + }), + ], + ])('classifies a recreate for %s alone as widening', (_label, constraintIssue) => { + const ctx = makeContext({ + expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), + actual: new SqlSchemaIR({ tables: { user: actualUserTable } }), + }); + const result = recreateTableStrategy([constraintIssue], ctx); + expect(result.kind).toBe('match'); + if (result.kind !== 'match') return; + expect((result.calls[0] as RecreateTableCall).operationClass).toBe('widening'); + }); + it('relaxing nullability (NOT NULL → nullable) is widening, tightening is destructive', () => { const ctx = makeContext({ expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.authoring-surface.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.authoring-surface.test.ts index 0a6af54ab720..30c34e9eab36 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.authoring-surface.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.authoring-surface.test.ts @@ -1,6 +1,6 @@ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { APP_SPACE_ID, planOriginOf } from '@internal/framework-components/control'; import { keepInternalSpecifiers } from '@internal/framework-components/emission'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage } from '@internal/sql-contract/types'; @@ -82,6 +82,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: fromContractWithHash(FROM_STORAGE_HASH), + origin: { storageHash: FROM_STORAGE_HASH }, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -101,6 +103,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -119,6 +123,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -136,6 +142,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -155,6 +163,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -174,6 +184,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: fromContractWithHash(FROM_STORAGE_HASH), + origin: { storageHash: FROM_STORAGE_HASH }, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, @@ -247,6 +259,8 @@ describe('SqliteMigrationPlanner authoring surface', () => { schema: emptySchema, policy: { allowedOperationClasses: ['widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: SNAPSHOTS_IMPORT_PATH, diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts index cb002bef6533..8cdf0702e256 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.case-only-index-names.test.ts @@ -18,6 +18,8 @@ async function plannedLabels(liveIndexTable: string, contractIndexTable: string) schema: sqliteContractToSchema(live, sqliteTestTypes), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -68,6 +70,8 @@ describe('SQLite planner index names that differ only in case', () => { schema: sqliteContractToSchema(live, sqliteTestTypes), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.sibling-scoping.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.sibling-scoping.test.ts index b9cd7119b1a1..6bdaf6e6287c 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.sibling-scoping.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.sibling-scoping.test.ts @@ -117,6 +117,8 @@ describe('SQLite planner ownership consultation', () => { schema: buildLiveSchema(), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -139,6 +141,8 @@ describe('SQLite planner ownership consultation', () => { schema: buildLiveSchema(), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, ownership: ownsOnly(table('app_user'), table('cipher_state')), @@ -160,6 +164,8 @@ describe('SQLite planner ownership consultation', () => { schema: buildLiveSchema(), policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -198,6 +204,8 @@ describe('SQLite planner ownership consultation', () => { schema: liveWithDriftColumn, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, ownership: ownsOnly(table('app_user')), @@ -224,6 +232,8 @@ describe('SQLite planner ownership consultation', () => { schema: buildLiveSchema(), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, ownership: ownsOnly(table('app_user'), table('cipher_state'), { diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.statements.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.statements.test.ts new file mode 100644 index 000000000000..1bb305d5d70f --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.statements.test.ts @@ -0,0 +1,196 @@ +import { asNamespaceId, type Contract } from '@internal/contract/types'; +import { + APP_SPACE_ID, + type MigrationOperationPolicy, + planOriginOf, + type ResolvedMigrationStatement, + type ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + plainTable, + stubLowerer, + withModels, +} from './rename-table-fixtures'; + +const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; + +function renameModel(from: string, to: string): ResolvedModelRenameStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: asNamespaceId(UNBOUND_NAMESPACE_ID), model: from }, + to: { namespaceId: asNamespaceId(UNBOUND_NAMESPACE_ID), model: to }, + }; +} + +const derivedIndex: ProfileSpec = { indexes: (tableName) => [handleIndex(tableName)] }; + +function plan( + from: Contract, + to: Contract, + statements: readonly ResolvedMigrationStatement[], + schema: SqlSchemaIR = sqliteContractToSchema(from, sqliteTestTypes), + policy: MigrationOperationPolicy = ALL_CLASSES, +) { + return createSqliteMigrationPlanner(stubLowerer).plan({ + contract: to, + schema, + policy, + fromContract: from, + origin: planOriginOf(from), + statements, + frameworkComponents: sqliteTestComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); +} + +async function labelsOf(result: ReturnType): Promise { + if (result.kind !== 'success') { + throw new Error(`expected a plan, got ${JSON.stringify(result.conflicts)}`); + } + const ops = await Promise.all(result.plan.operations); + return ops.map((op) => op.label); +} + +function conflictsOf(result: ReturnType) { + if (result.kind !== 'failure') throw new Error('expected the plan to fail'); + return result.conflicts; +} + +describe('SQLite planner, model statements', () => { + const from = withModels(contractOf('Profile', derivedIndex, 'from'), { Profile: 'Profile' }); + const to = withModels(contractOf('User', derivedIndex, 'to'), { User: 'User' }); + + it('plans the table rename and its index replacements in place of a drop and create', async () => { + expect(await labelsOf(plan(from, to, [renameModel('Profile', 'User')]))).toEqual([ + 'Rename table Profile to User', + `Drop index Profile_handle_idx_${HANDLE_INDEX_HASH} on User`, + `Create index User_handle_idx_${HANDLE_INDEX_HASH} on User`, + ]); + }); + + it('drops and creates the table without a statement', async () => { + const labels = await labelsOf(plan(from, to, [])); + expect(labels).toContain('Create table User'); + expect(labels).toContain('Drop table Profile'); + }); + + it('reports each statement with the positions of the operations it accounts for', () => { + const statement = renameModel('Profile', 'User'); + const result = plan(from, to, [statement]); + if (result.kind !== 'success') throw new Error('expected a plan'); + expect(result.appliedStatements).toEqual([{ statement, operationIndexes: [0, 1, 2] }]); + }); + + it('puts the renames ahead of the rest of the plan', async () => { + const toWithTable = withModels( + contractOf('User', derivedIndex, 'to', { audit: plainTable() }), + { User: 'User' }, + ); + const labels = await labelsOf(plan(from, toWithTable, [renameModel('Profile', 'User')])); + expect(labels[0]).toBe('Rename table Profile to User'); + expect(labels.slice(3)).toContain('Create table audit'); + }); + + it('applies a statement whose table does not change with no operations', async () => { + const fromMapped = withModels(contractOf('profile', {}, 'from'), { Profile: 'profile' }); + const toMapped = withModels(contractOf('profile', {}, 'to'), { User: 'profile' }); + const result = plan(fromMapped, toMapped, [renameModel('Profile', 'User')]); + expect(await labelsOf(result)).toEqual([]); + expect(result.kind === 'success' && result.appliedStatements).toEqual([ + expect.objectContaining({ operationIndexes: [] }), + ]); + }); + + it('plans two statements in the order given', async () => { + const fromTwo = withModels(contractOf('Profile', {}, 'from', { audit: plainTable() }), { + Profile: 'Profile', + Audit: 'audit', + }); + const toTwo = withModels(contractOf('User', {}, 'to', { Log: plainTable() }), { + User: 'User', + Log: 'Log', + }); + expect( + await labelsOf( + plan(fromTwo, toTwo, [renameModel('Audit', 'Log'), renameModel('Profile', 'User')]), + ), + ).toEqual(['Rename table audit to Log', 'Rename table Profile to User']); + }); + + it('refuses a statement under a policy that does not allow its operations', () => { + const statement = renameModel('Profile', 'User'); + const additiveOnly = { allowedOperationClasses: ['additive'] as const }; + expect(conflictsOf(plan(from, to, [statement], undefined, additiveOnly))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + refusedOperationClass: 'widening', + refusedStatement: statement, + }), + ]); + }); + + it('refuses a rename of a table whose control policy is not managed', () => { + const external = withModels( + contractOf('User', { ...derivedIndex, control: 'external' }, 'to'), + { User: 'User' }, + ); + const statement = renameModel('Profile', 'User'); + expect(conflictsOf(plan(from, external, [statement]))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: expect.stringContaining('control policy is "external"'), + refusedStatement: statement, + location: { namespaceId: UNBOUND_NAMESPACE_ID, entityKind: 'table', entityName: 'User' }, + }), + ]); + }); + + it('refuses a rename whose table the schema being planned from does not have', () => { + const otherSchema = sqliteContractToSchema(contractOf('Other', {}, 'other'), sqliteTestTypes); + expect(conflictsOf(plan(from, to, [renameModel('Profile', 'User')], otherSchema))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: expect.stringContaining('has no table "Profile"'), + }), + ]); + }); + + it('refuses a rename onto a name another table holds in another case, since SQLite ignores case', () => { + const withUser = withModels( + contractOf('Profile', derivedIndex, 'from-with-user', { user: plainTable() }), + { Profile: 'Profile', Account: 'user' }, + ); + expect(conflictsOf(plan(withUser, to, [renameModel('Profile', 'User')]))).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + summary: + 'Cannot rename table "Profile" to "User": the schema being planned from already has a table "User", as "user"', + }), + ]); + }); + + it('plans a rename that changes only the case of the table name', async () => { + const lower = withModels(contractOf('profile', derivedIndex, 'from-lower'), { + profile: 'profile', + }); + const upper = withModels(contractOf('Profile', derivedIndex, 'to-upper'), { + Profile: 'Profile', + }); + expect(await labelsOf(plan(lower, upper, [renameModel('profile', 'Profile')]))).toContain( + 'Rename table profile to Profile', + ); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts index 36d5556ec770..c39d646431a6 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.table-name-case-guard.test.ts @@ -118,6 +118,8 @@ function planFromLive( schema: liveSchema(previousTables), policy: DESTRUCTIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteTestComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-column-fixtures.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-column-fixtures.ts new file mode 100644 index 000000000000..7f21120a90d8 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-column-fixtures.ts @@ -0,0 +1,164 @@ +import { type Contract, coreHash, crossRef, profileHash } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { type IndexInput, SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { computeIndexContentHash } from '@internal/sql-schema-ir/naming'; +import { applicationDomainOf } from '@repo/test-utils'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; +import { reference } from './rename-table-fixtures'; + +const text = { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: false }; +const int4 = { dataType: 'sqlite/integer', codecId: 'sqlite/integer@1', nullable: false }; +const scalar = { nullable: false, type: { kind: 'scalar', codecId: 'sqlite/text@1' } } as const; + +/** The names the profile table's columns have in one contract. */ +export interface ProfileColumns { + readonly id: string; + readonly email: string; + readonly account: string; +} + +export const ORIGINAL_COLUMNS: ProfileColumns = { id: 'id', email: 'email', account: 'accountId' }; + +/** The objects on the profile table, each named after the column it covers. */ +export interface ProfileObjects { + readonly emailIndex?: boolean; + /** A foreign key from the account column to the account table. */ + readonly accountForeignKey?: { readonly name?: string }; +} + +export function emailIndex(tableName: string, column: string): IndexInput { + return { + columns: [column], + naming: { + kind: 'wire', + prefix: `${tableName}_${column}_idx`, + hash: computeIndexContentHash({ columns: [column], unique: false }), + }, + where: undefined, + unique: false, + type: undefined, + options: undefined, + }; +} + +function profileTable( + tableName: string, + columns: ProfileColumns, + objects: ProfileObjects, +): StorageTable { + return new StorageTable({ + columns: { [columns.id]: int4, [columns.email]: text, [columns.account]: int4 }, + primaryKey: { columns: [columns.id] }, + uniques: [], + indexes: [...(objects.emailIndex ? [emailIndex(tableName, columns.email)] : [])], + foreignKeys: + objects.accountForeignKey === undefined + ? [] + : [ + { + source: reference(tableName, [columns.account]), + target: reference('account', ['id']), + ...(objects.accountForeignKey.name === undefined + ? {} + : { name: objects.accountForeignKey.name }), + }, + ], + }); +} + +/** + * A contract with a profile table, an account table it references, and a post table whose + * foreign key references the profile table's id column. Its domain has one model per table, each + * field stored in the column of the same name in `columns`. + */ +export function profileContract( + hashSeed: string, + options: { + readonly table?: string; + readonly model?: string; + readonly columns?: ProfileColumns; + readonly fields?: ProfileColumns; + readonly objects?: ProfileObjects; + } = {}, +): Contract { + const table = options.table ?? 'Profile'; + const columns = options.columns ?? ORIGINAL_COLUMNS; + const fields = options.fields ?? columns; + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(hashSeed), + storage: new SqlStorage({ + storageHash: coreHash(hashSeed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [table]: profileTable(table, columns, options.objects ?? {}), + account: new StorageTable({ + columns: { id: int4 }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + post: new StorageTable({ + columns: { id: int4, profileId: int4 }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [ + { + source: reference('post', ['profileId']), + target: reference(table, [columns.id]), + }, + ], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: { + [options.model ?? table]: { + fields: { + [fields.id]: scalar, + [fields.email]: scalar, + [fields.account]: scalar, + }, + relations: { + posts: { + to: crossRef('Post'), + cardinality: '1:N', + on: { localFields: [fields.id], targetFields: ['profileId'] }, + }, + }, + storage: { + table, + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { + [fields.id]: { column: columns.id }, + [fields.email]: { column: columns.email }, + [fields.account]: { column: columns.account }, + }, + }, + }, + Post: { + fields: { id: scalar, profileId: scalar }, + relations: {}, + storage: { + table: 'post', + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { id: { column: 'id' }, profileId: { column: 'profileId' } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-column.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-column.test.ts new file mode 100644 index 000000000000..1a1242273e4d --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-column.test.ts @@ -0,0 +1,326 @@ +import type { Contract } from '@internal/contract/types'; +import { asNamespaceId } from '@internal/contract/types'; +import type { + ExecuteRequestLowerer, + SqlControlAdapter, +} from '@internal/family-sql/control-adapter'; +import { + APP_SPACE_ID, + type ControlStack, + planOriginOf, + type ResolvedFieldRenameStatement, + type ResolvedMigrationStatement, + type ResolvedModelRenameStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { columnExistsAst, columnNameTakenAst } from '../../src/contract-free/checks'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { RenameColumnCall } from '../../src/core/migrations/op-factory-call'; +import { renameColumn } from '../../src/core/migrations/operations/columns'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { SqliteMigration } from '../../src/core/migrations/sqlite-migration'; +import { sqliteColumnRenameCall } from '../../src/core/migrations/table-rename-calls'; +import { renameColumnInSqliteSchema } from '../../src/core/migrations/working-schema'; +import { SqliteContractSerializer } from '../../src/core/sqlite-contract-serializer'; +import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; +import { ORIGINAL_COLUMNS, type ProfileObjects, profileContract } from './rename-column-fixtures'; +import { stubLowerer } from './rename-table-fixtures'; + +const EMAIL_RENAMED = { ...ORIGINAL_COLUMNS, email: 'emailAddress' }; +const NS = asNamespaceId(UNBOUND_NAMESPACE_ID); +const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; + +function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ sql: `LOWERED ${received.length}`, params: Object.freeze([]) }); + }, + renderColumnDefault: async () => '', + }; + return { lowerer, received }; +} + +function contracts(objects: ProfileObjects = {}) { + return { + from: profileContract('from', { objects }), + to: profileContract('to', { columns: EMAIL_RENAMED, objects }), + }; +} + +function renameField( + model: string, + from: string, + to: string, + newModel = model, +): ResolvedFieldRenameStatement { + return { + kind: 'rename', + entity: 'field', + from: { namespaceId: NS, model, field: from }, + to: { namespaceId: NS, model: newModel, field: to }, + }; +} + +function renameModel(from: string, to: string): ResolvedModelRenameStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: NS, model: from }, + to: { namespaceId: NS, model: to }, + }; +} + +function plan( + from: Contract, + to: Contract, + statements: readonly ResolvedMigrationStatement[], +) { + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: sqliteContractToSchema(from, sqliteTestTypes), + policy: ALL_CLASSES, + fromContract: from, + origin: planOriginOf(from), + statements, + frameworkComponents: sqliteTestComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + return result; +} + +async function labelsOf(result: ReturnType): Promise { + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + return (await Promise.all(result.plan.operations)).map((op) => op.label); +} + +describe('renameColumn (sqlite)', () => { + it('renders ALTER TABLE ... RENAME COLUMN with checks on both names', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await renameColumn('User', 'name', 'fullName', lowerer); + expect(op).toMatchObject({ + id: 'renameColumn.User.name', + label: 'Rename column name on User to fullName', + operationClass: 'widening', + execute: [{ sql: 'ALTER TABLE "User" RENAME COLUMN "name" TO "fullName"' }], + }); + expect(received).toEqual([ + columnExistsAst('User', 'name').columnPresent(), + columnNameTakenAst('User', 'fullName').nameFree(), + columnExistsAst('User', 'fullName').columnPresent(), + columnExistsAst('User', 'name').columnAbsent(), + ]); + expect(op.precheck).toHaveLength(2); + expect(op.postcheck).toHaveLength(2); + }); + + it('renames a column whose name changes only in case in one statement, checking the new name exactly', async () => { + const { lowerer, received } = recordingCheckLowerer(); + const op = await renameColumn('User', 'name', 'Name', lowerer); + expect(received[1]).toEqual(columnExistsAst('User', 'Name').columnAbsent()); + expect(op.execute.map((step) => step.sql)).toEqual([ + 'ALTER TABLE "User" RENAME COLUMN "name" TO "Name"', + ]); + }); + + it('RenameColumnCall renders the facade call', () => { + expect(new RenameColumnCall('User', 'name', 'fullName', []).renderTypeScript()).toBe( + '...this.renameColumn({ table: "User", column: "name", to: "fullName" })', + ); + }); +}); + +describe('sqliteColumnRenameCall', () => { + function labelsFor(objects: ProfileObjects): readonly string[] { + const { from, to } = contracts(objects); + const call = sqliteColumnRenameCall({ + previous: sqliteContractToSchema(from, sqliteTestTypes), + contract: to, + rename: { + namespaceId: UNBOUND_NAMESPACE_ID, + table: 'Profile', + from: 'email', + to: 'emailAddress', + }, + frameworkComponents: sqliteTestComponents, + }); + return [call.label, ...call.companions.map((companion) => companion.label)]; + } + + it('replaces an index on the column with one under the destination wire name', () => { + const [rename, drop, create, ...rest] = labelsFor({ emailIndex: true }); + expect(rename).toBe('Rename column email on Profile to emailAddress'); + expect(drop).toMatch(/^Drop index Profile_email_idx_[0-9a-f]+ on Profile$/); + expect(create).toMatch(/^Create index Profile_emailAddress_idx_[0-9a-f]+ on Profile$/); + expect(rest).toEqual([]); + }); + + it('follows the column in foreign keys that reference it from another table', () => { + const renamed = renameColumnInSqliteSchema( + sqliteContractToSchema(profileContract('from'), sqliteTestTypes), + { + table: 'Profile', + from: 'id', + to: 'profileId', + }, + ); + expect(renamed.tables['post']?.foreignKeys.map((fk) => fk.referencedColumns)).toEqual([ + ['profileId'], + ]); + }); +}); + +const stack = { + adapter: { + ...sqliteTestComponents[0], + create: () => stubLowerer as unknown as SqlControlAdapter<'sqlite'>, + }, + target: { kind: 'target', familyId: 'sql', targetId: 'sqlite' }, + extensions: [], +} as unknown as ControlStack<'sql', 'sqlite'>; + +function migration( + start: Contract, + end: Contract, + build: (m: { + renameTable: (o: { table: string; to: string }) => readonly Promise[]; + renameColumn: (o: { table: string; column: string; to: string }) => readonly Promise[]; + }) => readonly Promise[], +) { + const serializer = new SqliteContractSerializer(); + const startJson = serializer.serializeContract(start) as never; + const endJson = serializer.serializeContract(end) as never; + class HandWritten extends SqliteMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations() { + return build({ + renameTable: (o) => this.renameTable(o), + renameColumn: (o) => this.renameColumn(o), + }) as never; + } + } + return new HandWritten(stack); +} + +describe('SqliteMigration.renameColumn', () => { + it('renames a column of a table an earlier renameTable renamed', async () => { + const objects = { emailIndex: true }; + const ops = (await Promise.all( + migration( + profileContract('from', { objects }), + profileContract('to', { table: 'User', columns: EMAIL_RENAMED, objects }), + (m) => [ + ...m.renameTable({ table: 'Profile', to: 'User' }), + ...m.renameColumn({ table: 'User', column: 'email', to: 'emailAddress' }), + ], + ).operations, + )) as readonly { label: string }[]; + const labels = ops.map((op) => op.label); + expect(labels[0]).toBe('Rename table Profile to User'); + expect(labels).toContain('Rename column email on User to emailAddress'); + expect(labels.at(-1)).toMatch(/^Create index User_emailAddress_idx_/); + }); + + it('refuses a column the table does not have', () => { + const m = migration( + profileContract('from'), + profileContract('to', { columns: EMAIL_RENAMED }), + (h) => h.renameColumn({ table: 'Profile', column: 'nickname', to: 'emailAddress' }), + ); + expect(() => m.operations).toThrow( + expect.objectContaining({ code: 'MIGRATION.COLUMN_RENAME_UNMATCHED' }), + ); + }); +}); + +describe('SQLite planner, field statements', () => { + const renameEmail = renameField('Profile', 'email', 'emailAddress'); + + it('plans the column rename and its index replacement in place of a drop and add', async () => { + const { from, to } = contracts({ emailIndex: true }); + const labels = await labelsOf(plan(from, to, [renameEmail])); + expect(labels[0]).toBe('Rename column email on Profile to emailAddress'); + expect(labels).toHaveLength(3); + expect(labels.some((label) => label.startsWith('Drop column'))).toBe(false); + }); + + it('reports the statement with the positions of the operations it accounts for', () => { + const { from, to } = contracts({ emailIndex: true }); + const result = plan(from, to, [renameEmail]); + if (result.kind !== 'success') throw new Error('expected a plan'); + expect(result.appliedStatements).toEqual([ + { statement: renameEmail, operationIndexes: [0, 1, 2] }, + ]); + }); + + it('applies a statement whose column does not change with no operations', async () => { + const result = plan(profileContract('from'), profileContract('to', { fields: EMAIL_RENAMED }), [ + renameEmail, + ]); + expect(await labelsOf(result)).toEqual([]); + }); + + it('applies a relation field statement with no operations', async () => { + const result = plan(profileContract('from'), profileContract('from'), [ + renameField('Profile', 'posts', 'posts'), + ]); + expect(await labelsOf(result)).toEqual([]); + }); + + it('emits the same operations as the facade call it renders, run against the same contracts', async () => { + const objects = { emailIndex: true }; + const from = profileContract('a'.repeat(64), { objects }); + const to = profileContract('b'.repeat(64), { columns: EMAIL_RENAMED, objects }); + const result = plan(from, to, [renameEmail]); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + expect(result.plan.renderTypeScript((specifier) => specifier)).toContain( + '...this.renameColumn({ table: "Profile", column: "email", to: "emailAddress" })', + ); + const planned = await Promise.all(result.plan.operations); + const handWritten = await Promise.all( + migration(from, to, (m) => + m.renameColumn({ table: 'Profile', column: 'email', to: 'emailAddress' }), + ).operations, + ); + expect(handWritten).toEqual(planned); + expect(planned).toHaveLength(3); + }); + + it('renames the column on the table an earlier model statement renamed', async () => { + const from = profileContract('from'); + const to = profileContract('to', { table: 'User', columns: EMAIL_RENAMED }); + expect( + await labelsOf( + plan(from, to, [ + renameModel('Profile', 'User'), + renameField('Profile', 'email', 'emailAddress', 'User'), + ]), + ), + ).toEqual(['Rename table Profile to User', 'Rename column email on User to emailAddress']); + }); + + it('refuses a rename on a table the destination renames, naming the renameTable call to write by hand', () => { + const from = profileContract('from'); + const to = profileContract('to', { table: 'User', columns: EMAIL_RENAMED }); + const statement = renameField('Profile', 'email', 'emailAddress', 'User'); + const result = plan(from, to, [statement]); + expect(result.kind === 'failure' && result.conflicts).toEqual([ + expect.objectContaining({ + kind: 'statementRefused', + refusedStatement: statement, + why: expect.stringContaining( + 'add ...this.renameTable({ table: "Profile", to: "User" }) to the operations of its migration.ts', + ), + }), + ]); + expect(result.kind === 'failure' && result.conflicts[0]?.why).toContain( + 'With db update, change the contract so that only the table name changes, and run prisma contract emit. Rename the table in the database yourself with ALTER TABLE "Profile" RENAME TO "User", check that prisma db update --dry-run plans no drop of a table or column, and run prisma db update to store that contract. Then emit the final contract and run prisma db update --rename User.email:User.emailAddress.', + ); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.authoring-state.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.authoring-state.test.ts new file mode 100644 index 000000000000..40dba271d632 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.authoring-state.test.ts @@ -0,0 +1,56 @@ +import type { Contract } from '@internal/contract/types'; +import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; +import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; +import type { ControlStack } from '@internal/framework-components/control'; +import { buildMigrationArtifacts } from '@internal/migration-tools/migration'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import type { SqlitePlanTargetDetails } from '../../src/core/migrations/planner-target-details'; +import { SqliteMigration } from '../../src/core/migrations/sqlite-migration'; +import { SqliteContractSerializer } from '../../src/core/sqlite-contract-serializer'; +import { sqliteTestComponents } from '../sqlite-test-types'; +import { contractOf, HANDLE_INDEX_HASH, handleIndex, stubLowerer } from './rename-table-fixtures'; + +type Op = SqlMigrationPlanOperation; +type ContractJson = { readonly storage: { readonly storageHash: string } }; + +const stack = { + adapter: { + ...sqliteTestComponents[0], + create: () => stubLowerer as unknown as SqlControlAdapter<'sqlite'>, + }, + target: { kind: 'target', familyId: 'sql', targetId: 'sqlite' }, + extensions: [], +} as unknown as ControlStack<'sql', 'sqlite'>; + +function jsonOf(contract: Contract): ContractJson { + return new SqliteContractSerializer().serializeContract(contract) as unknown as ContractJson; +} + +describe('SqliteMigration authoring state', () => { + it('builds the artifacts again and reads providedInvariants, each from the start contract', async () => { + const spec = { indexes: (tableName: string) => [handleIndex(tableName)] }; + const startJson = jsonOf(contractOf('userProfile', spec, 'from')); + const endJson = jsonOf(contractOf('UserProfile', spec, 'to')); + class HandWritten extends SqliteMigration { + override readonly startContractJson = startJson; + override readonly endContractJson = endJson; + override get operations(): readonly Promise[] { + return [...this.renameTable({ table: 'userProfile', to: 'UserProfile' })]; + } + } + const migration = new HandWritten(stack); + + const first = await buildMigrationArtifacts(migration, null); + expect(migration.providedInvariants).toEqual([]); + expect(migration.providedInvariants).toEqual([]); + const second = await buildMigrationArtifacts(migration, null); + + expect(second.opsJson).toBe(first.opsJson); + expect((JSON.parse(first.opsJson) as readonly Op[]).map((op) => op.label)).toEqual([ + 'Rename table userProfile to UserProfile', + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + `Create index UserProfile_handle_idx_${HANDLE_INDEX_HASH} on UserProfile`, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts index a884580c537a..7dfc5eeee1f3 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-facade.test.ts @@ -1,11 +1,16 @@ /** - * `this.renameTable` in a hand-written SQLite migration. It reads the migration's start and end contracts and emits the table rename, then drops each index whose wire name derives from the old table name and creates it under the new name, because SQLite cannot rename an index. SQLite names no primary key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. A table missing from either contract is refused. + * `this.renameTable` in a hand-written SQLite migration. It resolves the old name against the + * schema as the migration's earlier renames leave it and the new name against the end contract, and + * emits the table rename, then drops each index whose wire name derives from the old table name and + * creates it under the new name, because SQLite cannot rename an index. SQLite names no primary + * key, unique constraint or foreign key the contract leaves unnamed, so those need nothing. */ import type { Contract } from '@internal/contract/types'; import type { SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { SqlControlAdapter } from '@internal/family-sql/control-adapter'; import type { ControlStack } from '@internal/framework-components/control'; +import { Migration } from '@internal/migration-tools/migration'; import type { SqlStorage } from '@internal/sql-contract/types'; import { describe, expect, it } from 'vitest'; import type { SqlitePlanTargetDetails } from '../../src/core/migrations/planner-target-details'; @@ -17,6 +22,7 @@ import { HANDLE_INDEX_HASH, handleIndex, type ProfileSpec, + plainTable, reference, stubLowerer, } from './rename-table-fixtures'; @@ -34,16 +40,19 @@ function jsonOf(contract: Contract): ContractJson { return new SqliteContractSerializer().serializeContract(contract) as unknown as ContractJson; } +type TableRenameOptions = { readonly table: string; readonly to: string }; + function renameMigration( start: Contract | null, end: Contract, - rename: { readonly table: string; readonly to: string }, -): { readonly operations: readonly Promise[] } { + rename: TableRenameOptions, + ...more: readonly TableRenameOptions[] +): SqliteMigration & { readonly operations: readonly Promise[] } { const endJson = jsonOf(end); class WithoutStart extends SqliteMigration { override readonly endContractJson = endJson; override get operations(): readonly Promise[] { - return [...this.renameTable(rename)]; + return [rename, ...more].flatMap((each) => this.renameTable(each)); } } if (start === null) return new WithoutStart(stack); @@ -55,6 +64,7 @@ function renameMigration( } const RENAME = { table: 'userProfile', to: 'UserProfile' } as const; +const derivedIndex: ProfileSpec = { indexes: (tableName) => [handleIndex(tableName)] }; async function renameLabels(spec: ProfileSpec): Promise { const ops = await Promise.all( @@ -112,7 +122,7 @@ describe('SqliteMigration.renameTable', () => { ]); }); - it('refuses a table the start contract does not have', () => { + it('refuses a table that does not exist at this point of the migration', () => { expect( () => renameMigration( @@ -126,7 +136,9 @@ describe('SqliteMigration.renameTable', () => { ).toThrow( expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED', - message: expect.stringContaining('table "ghost" does not exist in the start contract'), + message: expect.stringContaining( + 'table "ghost" does not exist at this point of the migration', + ), }), ); }); @@ -155,4 +167,102 @@ describe('SqliteMigration.renameTable', () => { () => renameMigration(null, contractOf('UserProfile', {}, 'to'), RENAME).operations, ).toThrow(expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' })); }); + + it('refuses a new name that already exists at this point of the migration', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'userProfile', to: 'account' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "account" already exists at this point of the migration', + ), + }), + ); + }); + + it('refuses a new name another table holds in another case, since SQLite ignores case', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + { table: 'userProfile', to: 'Account' }, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "Account" already exists at this point of the migration as "account"', + ), + }), + ); + }); + + it('renames a table twice in one migration when the end contract declares both new names, each rename from where the last left it', async () => { + const ops = await Promise.all( + renameMigration( + contractOf('userProfile', derivedIndex, 'from'), + contractOf('Member', derivedIndex, 'to', { UserProfile: plainTable() }), + { table: 'userProfile', to: 'UserProfile' }, + { table: 'UserProfile', to: 'Member' }, + ).operations, + ); + + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table userProfile to UserProfile', + 'Rename table UserProfile to Member', + `Drop index userProfile_handle_idx_${HANDLE_INDEX_HASH} on Member`, + `Create index Member_handle_idx_${HANDLE_INDEX_HASH} on Member`, + ]); + }); + + it('refuses a table an earlier rename in the migration took away', () => { + expect( + () => + renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + RENAME, + ).operations, + ).toThrow( + expect.objectContaining({ + code: 'MIGRATION.TABLE_RENAME_UNMATCHED', + message: expect.stringContaining( + 'table "userProfile" does not exist at this point of the migration', + ), + }), + ); + }); + + it('reads the same operations again through Migration.readOperations', async () => { + const migration = renameMigration( + contractOf('userProfile', derivedIndex, 'from'), + contractOf('UserProfile', derivedIndex, 'to'), + RENAME, + ); + const first = await Promise.all(Migration.readOperations(migration)); + const second = await Promise.all(Migration.readOperations(migration)); + + expect(second).toEqual(first); + }); + + it('refuses a second direct read of the operations, since the table is already renamed', () => { + const migration = renameMigration( + contractOf('userProfile', {}, 'from'), + contractOf('UserProfile', {}, 'to'), + RENAME, + ); + void migration.operations; + + expect(() => migration.operations).toThrow( + expect.objectContaining({ code: 'MIGRATION.TABLE_RENAME_UNMATCHED' }), + ); + }); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts index 1365b42d1764..ec3c0f912129 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-fixtures.ts @@ -1,4 +1,4 @@ -import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { type Contract, type ControlPolicy, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { @@ -22,6 +22,7 @@ const integer = { dataType: 'sqlite/integer', codecId: 'sqlite/integer@1', nulla const text = { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: false }; export interface ProfileSpec { + readonly control?: ControlPolicy; readonly uniques?: readonly UniqueConstraintInput[]; readonly foreignKeys?: (tableName: string) => readonly ForeignKeyInput[]; readonly indexes?: (tableName: string) => readonly IndexInput[]; @@ -35,6 +36,7 @@ export function contractOf( profileTableName: string, spec: ProfileSpec, hashSeed: string, + extraTables: Readonly> = {}, ): Contract { return { target: 'sqlite', @@ -53,6 +55,7 @@ export function contractOf( uniques: spec.uniques ?? [], indexes: spec.indexes?.(profileTableName) ?? [], foreignKeys: spec.foreignKeys?.(profileTableName) ?? [], + ...(spec.control === undefined ? {} : { control: spec.control }), }), account: new StorageTable({ columns: { id: integer }, @@ -73,6 +76,7 @@ export function contractOf( }, ], }), + ...extraTables, }, }, }), @@ -98,3 +102,35 @@ export function handleIndex(tableName: string): IndexInput { options: undefined, }; } + +export function plainTable(): StorageTable { + return new StorageTable({ + columns: { id: integer }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }); +} + +/** `contract` with one model per entry of `tables`, each stored in the named table. */ +export function withModels( + contract: Contract, + tables: Record, +): Contract { + return { + ...contract, + domain: applicationDomainOf({ + models: Object.fromEntries( + Object.entries(tables).map(([model, table]) => [ + model, + { + fields: {}, + relations: {}, + storage: { table, namespaceId: UNBOUND_NAMESPACE_ID, fields: {} }, + }, + ]), + ), + }), + }; +} diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts index 431fc85b7afc..74c4b8b608ba 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/rename-table-ops.test.ts @@ -1,7 +1,11 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import { describe, expect, it } from 'vitest'; -import { tableExistsAst } from '../../src/contract-free/checks'; -import { RenameTableCall } from '../../src/core/migrations/op-factory-call'; +import { tableExistsAst, tableNameTakenAst } from '../../src/contract-free/checks'; +import { + CreateIndexCall, + DropIndexCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: unknown[] } { const received: unknown[] = []; @@ -21,7 +25,7 @@ function recordingCheckLowerer(): { lowerer: ExecuteRequestLowerer; received: un describe('RenameTableCall (sqlite)', () => { it('is a widening renameTable call whose contract-side identity is the new name', () => { - const call = new RenameTableCall('userProfile', 'UserProfile'); + const call = new RenameTableCall('userProfile', 'UserProfile', []); expect(call).toMatchObject({ factoryName: 'renameTable', @@ -34,11 +38,11 @@ describe('RenameTableCall (sqlite)', () => { it('renders ALTER TABLE ... RENAME TO with existence prechecks and a postcheck', async () => { const { lowerer, received } = recordingCheckLowerer(); - const op = await new RenameTableCall('profile', 'account').toOp(lowerer); + const op = await new RenameTableCall('profile', 'account', []).toOp(lowerer); expect(received).toEqual([ tableExistsAst('profile').tablePresent(), - tableExistsAst('account').tableAbsent(), + tableNameTakenAst('account').nameFree(), tableExistsAst('account').tablePresent(), tableExistsAst('profile').tableAbsent(), ]); @@ -50,7 +54,11 @@ describe('RenameTableCall (sqlite)', () => { target: { id: 'sqlite', details: { schema: 'main', objectType: 'table', name: 'account' } }, precheck: [ { description: 'ensure table "profile" exists', sql: 'LOWERED 1', params: ['p1'] }, - { description: 'ensure table "account" does not exist', sql: 'LOWERED 2', params: ['p2'] }, + { + description: 'ensure no table or index is named "account" in any case', + sql: 'LOWERED 2', + params: ['p2'], + }, ], execute: [ { @@ -71,7 +79,7 @@ describe('RenameTableCall (sqlite)', () => { it('renames through a temporary name when only the case changes, which SQLite would otherwise refuse', async () => { const { lowerer } = recordingCheckLowerer(); - const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + const op = await new RenameTableCall('userProfile', 'UserProfile', []).toOp(lowerer); expect(op.execute.map((step) => step.sql)).toEqual([ 'ALTER TABLE "userProfile" RENAME TO "_prisma_rename_UserProfile"', @@ -81,7 +89,7 @@ describe('RenameTableCall (sqlite)', () => { it('renames in one statement when only a non-ASCII letter changes case, since SQLite folds only ASCII letters', async () => { const { lowerer, received } = recordingCheckLowerer(); - const op = await new RenameTableCall('Äpfel', 'äpfel').toOp(lowerer); + const op = await new RenameTableCall('Äpfel', 'äpfel', []).toOp(lowerer); expect(op.execute.map((step) => step.sql)).toEqual(['ALTER TABLE "Äpfel" RENAME TO "äpfel"']); expect(received).not.toContainEqual(tableExistsAst('_prisma_rename_äpfel').tableAbsent()); @@ -89,7 +97,7 @@ describe('RenameTableCall (sqlite)', () => { it('prechecks that the temporary name is free on a case-only rename, saying why it is needed', async () => { const { lowerer, received } = recordingCheckLowerer(); - const op = await new RenameTableCall('userProfile', 'UserProfile').toOp(lowerer); + const op = await new RenameTableCall('userProfile', 'UserProfile', []).toOp(lowerer); expect(received).toEqual([ tableExistsAst('userProfile').tablePresent(), @@ -115,7 +123,7 @@ describe('RenameTableCall (sqlite)', () => { }); it('toOp() without a lowerer reports MIGRATION.SQLITE_CONTROL_STACK_MISSING', async () => { - const call = new RenameTableCall('userProfile', 'UserProfile'); + const call = new RenameTableCall('userProfile', 'UserProfile', []); await expect(call.toOp()).rejects.toMatchObject({ code: 'MIGRATION.SQLITE_CONTROL_STACK_MISSING', meta: { factory: 'renameTable' }, @@ -123,12 +131,48 @@ describe('RenameTableCall (sqlite)', () => { }); it('renderTypeScript() spreads the facade call, which returns every rename', () => { - expect(new RenameTableCall('userProfile', 'UserProfile').renderTypeScript()).toBe( + expect(new RenameTableCall('userProfile', 'UserProfile', []).renderTypeScript()).toBe( '...this.renameTable({ table: "userProfile", to: "UserProfile" })', ); }); it('needs no facade import because the call is a method on the migration', () => { - expect(new RenameTableCall('a', 'b').importRequirements()).toEqual([]); + expect(new RenameTableCall('a', 'b', []).importRequirements()).toEqual([]); + }); + + it('toOps() lowers the table rename, then each index drop, then each index create', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new RenameTableCall('userProfile', 'UserProfile', [ + { + drop: new DropIndexCall('UserProfile', 'userProfile_handle_idx_ab12cd34'), + create: new CreateIndexCall('UserProfile', 'UserProfile_handle_idx_ab12cd34', ['handle']), + }, + { + drop: new DropIndexCall('UserProfile', 'userProfile_email_idx_ef56ab78'), + create: new CreateIndexCall('UserProfile', 'UserProfile_email_idx_ef56ab78', ['email']), + }, + ]); + const ops = await Promise.all(call.toOps(lowerer)); + + expect(ops.map((op) => op.label)).toEqual([ + 'Rename table userProfile to UserProfile', + 'Drop index userProfile_handle_idx_ab12cd34 on UserProfile', + 'Drop index userProfile_email_idx_ef56ab78 on UserProfile', + 'Create index UserProfile_handle_idx_ab12cd34 on UserProfile', + 'Create index UserProfile_email_idx_ef56ab78 on UserProfile', + ]); + }); + + it('renders only the facade call, never its companions', () => { + const call = new RenameTableCall('userProfile', 'UserProfile', [ + { + drop: new DropIndexCall('UserProfile', 'userProfile_handle_idx_ab12cd34'), + create: new CreateIndexCall('UserProfile', 'UserProfile_handle_idx_ab12cd34', ['handle']), + }, + ]); + + expect(call.renderTypeScript()).toBe( + '...this.renameTable({ table: "userProfile", to: "UserProfile" })', + ); }); }); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/render-ops.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/render-ops.test.ts new file mode 100644 index 000000000000..573c99e18ed1 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/render-ops.test.ts @@ -0,0 +1,63 @@ +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { describe, expect, it } from 'vitest'; +import { + CreateIndexCall, + DataTransformCall, + DropIndexCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; +import { renderOps } from '../../src/core/migrations/render-ops'; + +describe('renderOps with a call that produces several ops', () => { + it('renders the call and each of its companions, in order, with the same lowerer', async () => { + const received: unknown[] = []; + const lowerer: ExecuteRequestLowerer = { + lower: () => Object.freeze({ sql: 'UNUSED', params: Object.freeze([]) }), + lowerToExecuteRequest: async (ast) => { + received.push(ast); + return Object.freeze({ sql: 'LOWERED', params: Object.freeze([]) }); + }, + renderColumnDefault: async () => '', + }; + const call = new RenameTableCall('a', 'b', [ + { + drop: new DropIndexCall('b', 'a_handle_idx'), + create: new CreateIndexCall('b', 'b_handle_idx', ['handle']), + }, + ]); + + const result = await Promise.all(renderOps([call], lowerer)); + + expect(result.map((rendered) => rendered.label)).toEqual([ + 'Rename table a to b', + 'Drop index a_handle_idx on b', + 'Create index b_handle_idx on b', + ]); + expect(received.length).toBeGreaterThan(0); + }); +}); + +describe('renderOps with a placeholder', () => { + it('raises no unhandled rejection when the operations are read without awaiting each one', async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', listener); + try { + expect( + renderOps([new DataTransformCall('data_migration.backfill', 'Backfill', 'user', 'email')]), + ).toHaveLength(1); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', listener); + } + expect(unhandled).toEqual([]); + }); + + it('still rejects with MIGRATION.UNFILLED_PLACEHOLDER for a reader that awaits it', async () => { + const [operation] = renderOps([ + new DataTransformCall('data_migration.backfill', 'Backfill', 'user', 'email'), + ]); + await expect(operation).rejects.toMatchObject({ code: 'MIGRATION.UNFILLED_PLACEHOLDER' }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/schema-tables.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/schema-tables.test.ts new file mode 100644 index 000000000000..11eb6ac61bc9 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/schema-tables.test.ts @@ -0,0 +1,30 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { sqliteSchemaTables } from '../../src/core/migrations/schema-tables'; +import { sqliteTestTypes } from '../sqlite-test-types'; +import { contractOf } from './rename-table-fixtures'; + +describe('sqliteSchemaTables', () => { + const tables = sqliteSchemaTables( + sqliteContractToSchema(contractOf('userProfile', {}, 'from'), sqliteTestTypes), + ); + + it('finds a table and its columns whatever namespace id it is asked about', () => { + expect(tables.hasTable(UNBOUND_NAMESPACE_ID, 'userProfile')).toBe(true); + expect(tables.hasTable('any', 'userProfile')).toBe(true); + expect(tables.hasColumn(UNBOUND_NAMESPACE_ID, 'userProfile', 'email')).toBe(true); + expect(tables.hasColumn(UNBOUND_NAMESPACE_ID, 'userProfile', 'ghost')).toBe(false); + expect(tables.hasTable(UNBOUND_NAMESPACE_ID, 'ghost')).toBe(false); + }); + + it('finds the columns SQLite takes for a name, whatever their case', () => { + expect(tables.columnsNamed(UNBOUND_NAMESPACE_ID, 'userProfile', 'EMAIL')).toEqual(['email']); + expect(tables.columnsNamed(UNBOUND_NAMESPACE_ID, 'userProfile', 'ghost')).toEqual([]); + }); + + it('names the unbound namespace for a table it has, and none otherwise', () => { + expect(tables.namespacesWithTable('userProfile')).toEqual([UNBOUND_NAMESPACE_ID]); + expect(tables.namespacesWithTable('ghost')).toEqual([]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/table-rename-calls.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/table-rename-calls.test.ts new file mode 100644 index 000000000000..ddb3b76b56a9 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/table-rename-calls.test.ts @@ -0,0 +1,69 @@ +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { sqliteTableRenameCall } from '../../src/core/migrations/table-rename-calls'; +import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, +} from './rename-table-fixtures'; + +const OLD_INDEX = `userProfile_handle_idx_${HANDLE_INDEX_HASH}`; +const NEW_INDEX = `UserProfile_handle_idx_${HANDLE_INDEX_HASH}`; +const RENAME = { namespaceId: UNBOUND_NAMESPACE_ID, from: 'userProfile', to: 'UserProfile' }; + +function callFor(origin: ProfileSpec, destination: ProfileSpec) { + return sqliteTableRenameCall({ + previous: sqliteContractToSchema(contractOf('userProfile', origin, 'from'), sqliteTestTypes), + contract: contractOf('UserProfile', destination, 'to'), + rename: RENAME, + frameworkComponents: sqliteTestComponents, + }); +} + +const derivedIndex: ProfileSpec = { indexes: (tableName) => [handleIndex(tableName)] }; + +describe('sqliteTableRenameCall', () => { + it('pairs each index named after the old table with its replacement under the new name', () => { + const call = callFor(derivedIndex, derivedIndex); + + expect(call.companions.map((companion) => companion.label)).toEqual([ + `Drop index ${OLD_INDEX} on UserProfile`, + `Create index ${NEW_INDEX} on UserProfile`, + ]); + }); + + it('pairs an index an introspected origin names after the old table', () => { + const introspected: ProfileSpec = { + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: OLD_INDEX } }, + ], + }; + const call = callFor(introspected, derivedIndex); + + expect( + call.indexReplacements.map(({ drop, create }) => [drop.indexName, create.indexName]), + ).toEqual([[OLD_INDEX, NEW_INDEX]]); + }); + + it('leaves an explicitly named index alone', () => { + const named: ProfileSpec = { + indexes: (tableName) => [ + { ...handleIndex(tableName), naming: { kind: 'exact', name: 'profile_handle' } }, + ], + }; + + expect(callFor(named, named).companions).toEqual([]); + }); + + it('is the table rename itself, with the resolved names', () => { + expect(callFor({}, {})).toMatchObject({ + factoryName: 'renameTable', + oldTableName: 'userProfile', + tableName: 'UserProfile', + companions: [], + }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/working-schema.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/working-schema.test.ts new file mode 100644 index 000000000000..67833e0170f8 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/working-schema.test.ts @@ -0,0 +1,130 @@ +import type { SchemaNodeRef } from '@internal/framework-components/control'; +import { + RelationalSchemaNodeKind, + type SqlSchemaIR, + type SqlTableIR, +} from '@internal/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { + CreateIndexCall, + DropIndexCall, + RenameTableCall, +} from '../../src/core/migrations/op-factory-call'; +import { + createWorkingSchema, + renameTableInSqliteSchema, +} from '../../src/core/migrations/working-schema'; +import { sqliteTestTypes } from '../sqlite-test-types'; +import { + contractOf, + HANDLE_INDEX_HASH, + handleIndex, + type ProfileSpec, + reference, +} from './rename-table-fixtures'; + +const withObjects: ProfileSpec = { + uniques: [{ columns: ['email'] }], + foreignKeys: (tableName) => [ + { source: reference(tableName, ['accountId']), target: reference('account', ['id']) }, + { source: reference(tableName, ['accountId']), target: reference(tableName, ['id']) }, + ], + indexes: (tableName) => [handleIndex(tableName)], +}; + +function startSchema(): SqlSchemaIR { + return sqliteContractToSchema(contractOf('userProfile', withObjects, 'from'), sqliteTestTypes); +} + +function tableIn(schema: SqlSchemaIR, table: string): SqlTableIR { + const found = schema.tables[table]; + if (found === undefined) throw new Error(`table ${table} missing`); + return found; +} + +const tableStepsOf = (refs: readonly SchemaNodeRef[] | undefined) => + (refs ?? []).map((ref) => ref[1]); + +const RENAME = { from: 'userProfile', to: 'UserProfile' } as const; +const OLD_INDEX = `userProfile_handle_idx_${HANDLE_INDEX_HASH}`; +const NEW_INDEX = `UserProfile_handle_idx_${HANDLE_INDEX_HASH}`; + +describe('renameTableInSqliteSchema', () => { + it('re-keys and renames the table and leaves the input untouched', () => { + const before = startSchema(); + const after = renameTableInSqliteSchema(before, RENAME); + + expect(Object.keys(after.tables).sort()).toEqual(['UserProfile', 'account', 'post']); + expect(tableIn(after, 'UserProfile').name).toBe('UserProfile'); + expect(Object.keys(tableIn(after, 'UserProfile').columns)).toEqual( + Object.keys(tableIn(before, 'userProfile').columns), + ); + expect(Object.keys(before.tables).sort()).toEqual(['account', 'post', 'userProfile']); + }); + + it('retargets the foreign keys that reference the table, on other tables and on itself', () => { + const after = renameTableInSqliteSchema(startSchema(), RENAME); + + expect(tableIn(after, 'post').foreignKeys.map((fk) => fk.referencedTable)).toEqual([ + 'UserProfile', + ]); + expect(tableIn(after, 'UserProfile').foreignKeys.map((fk) => fk.referencedTable)).toEqual([ + 'account', + 'UserProfile', + ]); + expect(tableStepsOf(tableIn(after, 'post').foreignKeys[0]?.dependsOn)).toEqual([ + { nodeKind: RelationalSchemaNodeKind.table, id: 'UserProfile' }, + { nodeKind: RelationalSchemaNodeKind.table, id: 'post' }, + ]); + }); + + it('keeps every object name, and leaves unnamed constraints unnamed as SQLite does', () => { + const before = tableIn(startSchema(), 'userProfile'); + const after = tableIn(renameTableInSqliteSchema(startSchema(), RENAME), 'UserProfile'); + + expect(after.indexes.map((index) => index.name)).toEqual([OLD_INDEX]); + expect(after.primaryKey?.name).toBe(before.primaryKey?.name); + expect(after.uniques.map((unique) => unique.name)).toEqual([undefined]); + expect(after.foreignKeys.map((fk) => fk.name)).toEqual([undefined, undefined]); + }); + + it('moves the dependencies of the objects on the table to its new name', () => { + const after = tableIn(renameTableInSqliteSchema(startSchema(), RENAME), 'UserProfile'); + const renamedStep = { nodeKind: RelationalSchemaNodeKind.table, id: 'UserProfile' }; + + expect(tableStepsOf(after.primaryKey?.dependsOn)).toEqual([renamedStep]); + expect(tableStepsOf(after.uniques[0]?.dependsOn)).toEqual([renamedStep]); + expect(tableStepsOf(after.indexes[0]?.dependsOn)).toEqual([renamedStep]); + }); +}); + +describe('WorkingSchema.apply', () => { + it('renames the table and then renames each index the companions replace, keeping its definition', () => { + const working = createWorkingSchema(startSchema()); + working.apply( + new RenameTableCall('userProfile', 'UserProfile', [ + { + drop: new DropIndexCall('UserProfile', OLD_INDEX), + create: new CreateIndexCall('UserProfile', NEW_INDEX, ['handle']), + }, + ]), + ); + + const table = tableIn(working.current, 'UserProfile'); + expect(table.indexes.map((index) => [index.name, index.prefix, index.columns])).toEqual([ + [NEW_INDEX, 'UserProfile_handle_idx', ['handle']], + ]); + expect(working.current.tables['userProfile']).toBeUndefined(); + }); + + it('replaces current with a new tree and never changes the one it replaced', () => { + const initial = startSchema(); + const working = createWorkingSchema(initial); + working.apply(new RenameTableCall('userProfile', 'UserProfile', [])); + + expect(working.current).not.toBe(initial); + expect(Object.keys(initial.tables)).toContain('userProfile'); + expect(Object.keys(working.current.tables)).toContain('UserProfile'); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/sqlite-unbound-database.test.ts b/packages/3-targets/3-targets/sqlite/test/sqlite-unbound-database.test.ts index cc9761c5b728..4781a9a2886b 100644 --- a/packages/3-targets/3-targets/sqlite/test/sqlite-unbound-database.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/sqlite-unbound-database.test.ts @@ -35,6 +35,12 @@ describe('SqliteUnboundDatabase', () => { it('is a stable singleton — repeated access returns the same instance', () => { expect(SqliteUnboundDatabase.instance).toBe(SqliteUnboundDatabase.instance); }); + + it('doubles a double quote inside a table name', () => { + expect(SqliteUnboundDatabase.instance.qualifyTable('us"ers; drop table x; --')).toBe( + '"us""ers; drop table x; --"', + ); + }); }); describe('SqliteDatabase', () => { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/bytea-sql-defaults.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/bytea-sql-defaults.integration.test.ts index 12bea44214bf..0cdd6bf74865 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/bytea-sql-defaults.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/bytea-sql-defaults.integration.test.ts @@ -106,6 +106,8 @@ describe('raw SQL bytea defaults, read as PostgreSQL bytea input', { concurrent: schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/check-lifecycle-e2e.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/check-lifecycle-e2e.integration.test.ts index 9421b3124442..cdf327bd3dc4 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/check-lifecycle-e2e.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/check-lifecycle-e2e.integration.test.ts @@ -350,6 +350,8 @@ describe('check-constraint lifecycle', { concurrent: false }, () => { schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -938,11 +940,11 @@ describe('check-constraint lifecycle', { concurrent: false }, () => { // Slice 3 (`@noCheck`): an opted-out contract simply does not declare the // check. The first two scenarios are hand-built contracts and pin the // planner/DDL lifecycle for a check-less contract — deleting a declared - // check plans one destructive drop, declaring it again plans one additive + // check plans one widening drop, declaring it again plans one additive // add. The third drives the real authoring surface (defineContract + // .noCheck()) end to end. The full builder-to-infer chain is covered by // the infer e2e journeys and the print-psl emission unit tests. - it('adding an opt-out later drops the live element check in one destructive plan', { + it('adding an opt-out later drops the live element check in one widening plan', { timeout: testTimeout, }, async () => { const tagsChecks = checksForColumn('Item', 'tags', { many: { elementNullable: false } }); @@ -979,7 +981,7 @@ describe('check-constraint lifecycle', { concurrent: false }, () => { const dropOps = ops.filter((op) => op.id.startsWith('dropCheckConstraint.')); expect(dropOps.map((op) => op.id)).toEqual([`dropCheckConstraint.Item.${tagsChecks[0]?.name}`]); - expect(dropOps[0]?.operationClass).toBe('destructive'); + expect(dropOps[0]?.operationClass).toBe('widening'); expect(ops).toHaveLength(1); expect(await liveCheckNames()).toEqual([]); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/codec-literal-defaults.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/codec-literal-defaults.integration.test.ts index 656f8371b249..6d49fb485564 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/codec-literal-defaults.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/codec-literal-defaults.integration.test.ts @@ -169,6 +169,8 @@ function plan( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/cross-namespace-fk.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/cross-namespace-fk.integration.test.ts index 8085d252b4bf..8f79a249d07c 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/cross-namespace-fk.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/cross-namespace-fk.integration.test.ts @@ -117,6 +117,8 @@ describe('AC1 — cross-namespace FK end-to-end (PGlite)', { concurrent: false } schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/enum-check-constraint.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/enum-check-constraint.integration.test.ts index 53e5a83fa08a..dc945ee852db 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/enum-check-constraint.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/enum-check-constraint.integration.test.ts @@ -168,6 +168,8 @@ describe('enum check-constraint — end-to-end PGlite', { concurrent: false }, ( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -220,6 +222,8 @@ describe('enum check-constraint — end-to-end PGlite', { concurrent: false }, ( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -264,6 +268,8 @@ describe('enum check-constraint — end-to-end PGlite', { concurrent: false }, ( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -311,6 +317,8 @@ describe('enum check-constraint — end-to-end PGlite', { concurrent: false }, ( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -348,6 +356,8 @@ describe('enum check-constraint — end-to-end PGlite', { concurrent: false }, ( schema: v2Schema, policy: FULL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/index-boolean-options.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/index-boolean-options.integration.test.ts index a9f6f8843b7e..591c2b6cda52 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/index-boolean-options.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/index-boolean-options.integration.test.ts @@ -114,6 +114,8 @@ describe('boolean reloption round trip', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts index 08be2819d4c3..562c6da3c1c9 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/native-array-columns.integration.test.ts @@ -155,6 +155,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -205,6 +207,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -252,6 +256,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -302,6 +308,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -345,6 +353,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -396,6 +406,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -436,6 +448,8 @@ describe('native array columns DDL', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-add-value.real-postgres.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-add-value.real-postgres.integration.test.ts index 41e14b94e147..64d09d9309ef 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-add-value.real-postgres.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-add-value.real-postgres.integration.test.ts @@ -164,6 +164,8 @@ async function planContract( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-lifecycle-e2e.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-lifecycle-e2e.integration.test.ts index 4c3f3a0e4075..5bb7ca4166d6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-lifecycle-e2e.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/native-enum-lifecycle-e2e.integration.test.ts @@ -294,6 +294,8 @@ async function planContract( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -345,6 +347,8 @@ function planDirect( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/non-data-drops-widening.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/non-data-drops-widening.test.ts new file mode 100644 index 000000000000..de59e91ec5f8 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/non-data-drops-widening.test.ts @@ -0,0 +1,52 @@ +import type { OpFactoryCall } from '@internal/framework-components/control'; +import { createPostgresBuiltinCodecLookup } from '@internal/target-postgres/codecs'; +import { createPostgresBuiltinDataTypeLookup } from '@internal/target-postgres/data-types'; +import { + DisableRowLevelSecurityCall, + DropCheckConstraintCall, + DropColumnCall, + DropConstraintCall, + DropDefaultCall, + DropIndexCall, + DropNativeEnumTypeCall, + DropPostgresRlsPolicyCall, + DropTableCall, +} from '@internal/target-postgres/op-factory-call'; +import { renderOps } from '@internal/target-postgres/render-ops'; +import { describe, expect, it } from 'vitest'; +import { PostgresControlAdapter } from '../../src/core/control-adapter'; + +const adapter = new PostgresControlAdapter( + createPostgresBuiltinCodecLookup(), + createPostgresBuiltinDataTypeLookup(), +); + +async function classes(call: OpFactoryCall) { + const [op] = await Promise.all(renderOps([call], adapter)); + return { call: call.operationClass, op: op?.operationClass }; +} + +describe('drops that lose no stored data', () => { + it.each([ + ['an index', new DropIndexCall('public', 'user', 'user_email_idx')], + ['a unique constraint', new DropConstraintCall('public', 'user', 'user_email_key')], + ['a foreign key', new DropConstraintCall('public', 'post', 'post_user_fk', 'foreignKey')], + ['a primary key', new DropConstraintCall('public', 'user', 'user_pkey', 'primaryKey')], + ['a check constraint', new DropCheckConstraintCall('public', 'user', 'user_age_check')], + ['a row-level-security policy', new DropPostgresRlsPolicyCall('public', 'user', 'own_rows')], + ['a column default', new DropDefaultCall('public', 'user', 'created_at')], + ['a native enum type', new DropNativeEnumTypeCall('public', 'role')], + ['row-level security', new DisableRowLevelSecurityCall('public', 'user')], + ])('dropping %s is widening', async (_label, call) => { + expect(await classes(call)).toEqual({ call: 'widening', op: 'widening' }); + }); +}); + +describe('drops that lose stored data', () => { + it.each([ + ['a table', new DropTableCall('public', 'user')], + ['a column', new DropColumnCall('public', 'user', 'email')], + ])('dropping %s stays destructive', async (_label, call) => { + expect(await classes(call)).toEqual({ call: 'destructive', op: 'destructive' }); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.construction.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.construction.test.ts index c6dcedc0b8f9..8e15f6679e67 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.construction.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.construction.test.ts @@ -42,14 +42,14 @@ describe('Postgres call classes - construction + toOp parity', () => { }); }); - it('DataTransformCall carries its slot names and a caller-supplied operationClass; toOp throws MIGRATION.UNFILLED_PLACEHOLDER', () => { + it('DataTransformCall carries its slot names and a caller-supplied operationClass; toOp rejects with MIGRATION.UNFILLED_PLACEHOLDER', async () => { const call = new DataTransformCall('Backfill', 'slot-check', 'slot-run', 'widening'); expect(call.checkSlot).toBe('slot-check'); expect(call.runSlot).toBe('slot-run'); expect(call.operationClass).toBe('widening'); - expect(() => call.toOp()).toThrow(/Unfilled migration placeholder/); + await expect(call.toOp()).rejects.toThrow(/Unfilled migration placeholder/); }); it('CreateTableCall.toOp produces byte-identical SQL for a composite-PK table', async () => { diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.lowering.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.lowering.test.ts index 770b67d83971..ac2655aae4c6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.lowering.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/op-factory-call.lowering.test.ts @@ -5,8 +5,8 @@ import { type as arktype } from 'arktype'; * - `renderOps` lowers each variant via its pure factory and pins the * id/operationClass/target.details shape exposed to runners. * - `RawSqlCall` is returned verbatim by `renderOps`. - * - `DataTransformCall` always throws MIGRATION.UNFILLED_PLACEHOLDER from `renderOps` because - * the planner can only emit unfilled stubs. + * - `DataTransformCall` lowers to an operation that rejects with MIGRATION.UNFILLED_PLACEHOLDER, + * because the planner can only emit unfilled stubs; the other operations still lower. * - `TypeScriptRenderablePostgresMigration` routes `operations` through * `renderOps` and `renderTypeScript()` through `renderCallsToTypeScript`. * - `AddNotNullColumnWithTempDefaultCall` pins the exact `ADD COLUMN` SQL @@ -169,7 +169,7 @@ describe('renderOps', () => { }, { id: 'dropDefault.user.updated_at', - operationClass: 'destructive', + operationClass: 'widening', details: schemaObject('column', 'updated_at', 'user'), }, { @@ -189,7 +189,7 @@ describe('renderOps', () => { }, { id: 'dropConstraint.user.user_email_key', - operationClass: 'destructive', + operationClass: 'widening', details: schemaObject('unique', 'user_email_key', 'user'), }, { @@ -199,7 +199,7 @@ describe('renderOps', () => { }, { id: 'dropIndex.user.stale_idx', - operationClass: 'destructive', + operationClass: 'widening', details: schemaObject('index', 'stale_idx', 'user'), }, { id: 'custom.op.1', operationClass: 'additive', details: undefined }, @@ -241,10 +241,14 @@ describe('renderOps', () => { expect(rendered).toBe(op); }); - it('throws MIGRATION.UNFILLED_PLACEHOLDER on DataTransformCall (always an unfilled stub at plan time)', () => { - const call = new DataTransformCall('Backfill', 'check', 'run'); + it('lowers a DataTransformCall to an operation that rejects with MIGRATION.UNFILLED_PLACEHOLDER, beside the others', async () => { + const [dropped, stub] = renderOps( + [new DropTableCall('public', 'stale'), new DataTransformCall('Backfill', 'check', 'run')], + testAdapter, + ); - expect(() => renderOps([call])).toThrow(/Unfilled migration placeholder/); + expect(await dropped).toMatchObject({ id: 'dropTable.stale' }); + await expect(stub).rejects.toThrow(/Unfilled migration placeholder/); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/order-by-enum.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/order-by-enum.integration.test.ts index 859c93d93ef7..0a5632e29255 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/order-by-enum.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/order-by-enum.integration.test.ts @@ -105,6 +105,8 @@ async function migrate(driver: PostgresControlDriver, contract: PostgresContract schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authored-function-defaults.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authored-function-defaults.integration.test.ts index 4a8a1b049471..82f37997d0d1 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authored-function-defaults.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authored-function-defaults.integration.test.ts @@ -110,6 +110,8 @@ describe('authored function defaults on Postgres', { concurrent: false }, () => schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts index 2f86e4d9036e..d7b437e416df 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.authoring-surface.test.ts @@ -3,6 +3,7 @@ import { APP_SPACE_ID, type MigrationPlanner, type MigrationPlannerSuccessResult, + planOriginOf, } from '@internal/framework-components/control'; import { keepInternalSpecifiers } from '@internal/framework-components/emission'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -82,6 +83,8 @@ describe('PostgresMigrationPlanner authoring surface', () => { schema: fromSchemaIR, policy: { allowedOperationClasses: ['additive'] }, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [], spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts index 3b4ae597a5fc..66ad655d7060 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.codec-field-event.test.ts @@ -150,6 +150,8 @@ describe('PostgresMigrationPlanner - codec onFieldEvent wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -178,6 +180,8 @@ describe('PostgresMigrationPlanner - codec onFieldEvent wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -223,6 +227,8 @@ describe('PostgresMigrationPlanner - codec onFieldEvent wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -232,6 +238,8 @@ describe('PostgresMigrationPlanner - codec onFieldEvent wiring', () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts index e88cb916c2b8..135a0afec127 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.cross-space-fk-ddl.test.ts @@ -169,6 +169,8 @@ async function planAndGetFkExecuteSql(contract: Contract): Promise schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -144,6 +146,8 @@ describe('PostgresMigrationPlanner - materialized FK/index combinations', () => schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -164,6 +168,8 @@ describe('PostgresMigrationPlanner - materialized FK/index combinations', () => schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -184,6 +190,8 @@ describe('PostgresMigrationPlanner - materialized FK/index combinations', () => schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -218,6 +226,8 @@ describe('PostgresMigrationPlanner - materialized FK/index combinations', () => schema, policy: MIGRATION_PLAN_POLICY, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts index 37aaf537bda4..60c8d43b7bec 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.integration.test.ts @@ -53,6 +53,8 @@ describe('PostgresMigrationPlanner - integration (existing schemas)', { concurre schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -87,6 +89,8 @@ describe('PostgresMigrationPlanner - integration (existing schemas)', { concurre schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -108,6 +112,8 @@ describe('PostgresMigrationPlanner - integration (existing schemas)', { concurre schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -141,6 +147,8 @@ describe('PostgresMigrationPlanner - integration (existing schemas)', { concurre schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -179,6 +187,8 @@ describe('PostgresMigrationPlanner - integration (existing schemas)', { concurre schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.list-literal-defaults.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.list-literal-defaults.integration.test.ts index b639af5c670f..7f81280bc865 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.list-literal-defaults.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.list-literal-defaults.integration.test.ts @@ -316,6 +316,8 @@ describe('planned list defaults apply and verify', { concurrent: false }, () => schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts index 190afa49c41e..2fe2fcd26a0a 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts @@ -75,6 +75,8 @@ async function applyBaseline( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -116,6 +118,8 @@ async function planAndExecute( schema, policy: RECONCILIATION_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts index ba525ad6f5e9..f9e3f45a2dda 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.test.ts @@ -81,6 +81,8 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { schema, policy: RECONCILIATION_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -145,6 +147,8 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { schema, policy: WIDENING_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -208,6 +212,8 @@ describe('PostgresMigrationPlanner - reconciliation planning', () => { schema, policy: WIDENING_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts index 24302eef0c67..31b229b8dd59 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.referential-actions.test.ts @@ -106,6 +106,8 @@ async function planAndGetFkSql( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.unique-index-structural.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.unique-index-structural.test.ts index 0240940b08d3..ca76c387197e 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.unique-index-structural.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.unique-index-structural.test.ts @@ -95,6 +95,8 @@ describe('PostgresMigrationPlanner - unique constraints vs indexes (structural n schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.uuid.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.uuid.integration.test.ts index 71f0d4f02187..402ca43c6a0e 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.uuid.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.uuid.integration.test.ts @@ -97,6 +97,8 @@ describe('pg/uuid@1 — end-to-end PGlite coverage', { concurrent: false }, () = schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rename-statements.hostile-names.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rename-statements.hostile-names.integration.test.ts new file mode 100644 index 000000000000..94dbe4a805c1 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rename-statements.hostile-names.integration.test.ts @@ -0,0 +1,249 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { + APP_SPACE_ID, + type MigrationOperationPolicy, + planOriginOf, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { SqlStorage } from '@internal/sql-contract/types'; +import type { SqlSchemaIRNode } from '@internal/sql-schema-ir/types'; +import type { PostgresPlanTargetDetails } from '@internal/target-postgres/planner-target-details'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + controlAdapter, + createDriver, + createMigrationPlan, + createTestDatabase, + familyInstance, + formatRunnerFailure, + frameworkComponents, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + synthEdges, + testTimeout, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +const OLD_TABLE = 'user"s \\ select'; +const NEW_TABLE = 'pe"ople \\ from'; +const OLD_COLUMN = 'e"mail \\ where'; +const NEW_COLUMN = 'ma"il \\ table'; +const ALL_CLASSES: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive'], +}; + +function quoted(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function contractOf( + seed: string, + names: { model: string; table: string; field: string; column: string }, +): Contract { + const int4 = { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }; + const text = { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }; + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(seed), + storage: new SqlStorage({ + storageHash: coreHash(seed), + namespaces: { + public: postgresCreateNamespace({ + id: 'public', + entries: { + table: { + [names.table]: { + columns: { id: int4, [names.column]: text }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + namespaceId: 'public', + models: { + [names.model]: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } }, + [names.field]: { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: {}, + storage: { + table: names.table, + namespaceId: 'public', + fields: { id: { column: 'id' }, [names.field]: { column: names.column } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +const PUBLIC = asNamespaceId('public'); + +function renameModel(from: string, to: string): ResolvedMigrationStatement { + return { + kind: 'rename', + entity: 'model', + from: { namespaceId: PUBLIC, model: from }, + to: { namespaceId: PUBLIC, model: to }, + }; +} + +function renameField( + fromModel: string, + toModel: string, + from: string, + to: string, +): ResolvedMigrationStatement { + return { + kind: 'rename', + entity: 'field', + from: { namespaceId: PUBLIC, model: fromModel, field: from }, + to: { namespaceId: PUBLIC, model: toModel, field: to }, + }; +} + +describe('rename statements with hostile identifiers on Postgres', { concurrent: false }, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + await database?.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + await driver?.close(); + driver = undefined; + }, testTimeout); + + async function userTables(): Promise { + const tables = await driver!.query<{ schema: string; name: string }>( + `select table_schema as schema, table_name as name from information_schema.tables + where table_schema not in ('pg_catalog', 'information_schema', 'prisma_contract') + order by 1, 2`, + ); + return tables.rows; + } + + async function renameThroughStatements(input: { + readonly from: { model: string; table: string; field: string; column: string }; + readonly to: { model: string; table: string; field: string; column: string }; + readonly statements: readonly ResolvedMigrationStatement[]; + }) { + const origin = contractOf('hostile-from', input.from); + const destination = contractOf('hostile-to', input.to); + await driver!.query( + `create table "public".${quoted(input.from.table)} (id int4 primary key, ${quoted(input.from.column)} text not null)`, + ); + await driver!.query(`insert into "public".${quoted(input.from.table)} values (1, 'kept')`); + const tablesBefore = await userTables(); + expect(tablesBefore).toContainEqual({ schema: 'public', name: input.from.table }); + + const planned = postgresTargetDescriptor.createPlanner(controlAdapter).plan({ + contract: destination, + schema: postgresTargetDescriptor.migrations.contractToSchema( + origin, + frameworkComponents, + ) as SqlSchemaIRNode, + policy: ALL_CLASSES, + fromContract: origin, + origin: planOriginOf(origin), + statements: input.statements, + frameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (planned.kind !== 'success') throw new Error(JSON.stringify(planned.conflicts)); + const plan = createMigrationPlan({ + targetId: 'postgres', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(destination), + operations: await Promise.all(planned.plan.operations), + providedInvariants: [], + }); + const result = await postgresTargetDescriptor.createRunner(familyInstance).execute({ + driver: driver!, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver: driver!, + destinationContract: destination, + policy: ALL_CLASSES, + frameworkComponents, + }, + ], + }); + expect(result.ok, result.ok ? '' : formatRunnerFailure(result.assertNotOk())).toBe(true); + + expect(await userTables()).toEqual( + tablesBefore.map((table) => + table.schema === 'public' && table.name === input.from.table + ? { ...table, name: input.to.table } + : table, + ), + ); + const columns = await driver!.query<{ name: string }>( + `select column_name as name from information_schema.columns + where table_schema = 'public' and table_name = $1 order by ordinal_position`, + [input.to.table], + ); + expect(columns.rows).toEqual([{ name: 'id' }, { name: input.to.column }]); + const constraints = await driver!.query<{ name: string }>( + 'select conname as name from pg_constraint where conrelid = $1::regclass', + [`"public".${quoted(input.to.table)}`], + ); + expect(constraints.rows).toEqual([{ name: `${input.to.table}_pkey` }]); + const rows = await driver!.query(`select * from "public".${quoted(input.to.table)}`); + expect(rows.rows).toEqual([{ id: 1, [input.to.column]: 'kept' }]); + } + + it('renames a table and a column whose names hold a quote, a backslash, a space and a keyword', { + timeout: testTimeout, + }, async () => { + await renameThroughStatements({ + from: { model: 'Profile', table: OLD_TABLE, field: 'email', column: OLD_COLUMN }, + to: { model: 'Person', table: NEW_TABLE, field: 'mail', column: NEW_COLUMN }, + statements: [ + renameModel('Profile', 'Person'), + renameField('Profile', 'Person', 'email', 'mail'), + ], + }); + }); + + it('renames a column of a table whose name is written to inject SQL, and runs nothing else', { + timeout: testTimeout, + }, async () => { + const table = 'p" add column "pwned" int; create table "pwned"(id int)--'; + await renameThroughStatements({ + from: { model: 'Post', table, field: 'email', column: 'email' }, + to: { model: 'Post', table, field: 'mail', column: 'mail' }, + statements: [renameField('Post', 'Post', 'email', 'mail')], + }); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-exact-drift.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-exact-drift.integration.test.ts index c6a26c7920f5..e3e2cfdf9f60 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-exact-drift.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-exact-drift.integration.test.ts @@ -37,7 +37,7 @@ const BODY = '(tenant_id = 1)'; const ALL_CLASSES_POLICY = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const, }; -const NO_DESTRUCTIVE_POLICY = { allowedOperationClasses: ['additive', 'widening'] as const }; +const ADDITIVE_ONLY_POLICY = { allowedOperationClasses: ['additive'] as const }; const WIRE_NAME = `tenant_read_${computeContentHash({ using: BODY, @@ -162,7 +162,7 @@ describe('out-of-band body drift on an exact-named policy', { concurrent: false expect(notEqual.length).toBeGreaterThan(0); }); - it('a destructive-allowed plan replaces the drifted policy: drop before create, same name', { + it('a widening-allowed plan replaces the drifted policy: drop before create, same name', { timeout: testTimeout, }, async () => { await createLivePolicy(driver!, EXACT_NAME); @@ -175,6 +175,8 @@ describe('out-of-band body drift on an exact-named policy', { concurrent: false schema: await familyInstance.introspect({ driver: driver!, contract }), policy: { allowedOperationClasses: [...ALL_CLASSES_POLICY.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -188,7 +190,7 @@ describe('out-of-band body drift on an exact-named policy', { concurrent: false ]); }); - it('without the destructive allowance the plan fails with the conflict naming the policy', { + it('without the widening allowance the plan fails with the conflict naming the policy', { timeout: testTimeout, }, async () => { await createLivePolicy(driver!, EXACT_NAME); @@ -199,8 +201,10 @@ describe('out-of-band body drift on an exact-named policy', { concurrent: false const result = planner.plan({ contract, schema: await familyInstance.introspect({ driver: driver!, contract }), - policy: { allowedOperationClasses: [...NO_DESTRUCTIVE_POLICY.allowedOperationClasses] }, + policy: { allowedOperationClasses: [...ADDITIVE_ONLY_POLICY.allowedOperationClasses] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts index ea6f75e27aff..3acfa2c9fc47 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-lifecycle-e2e.integration.test.ts @@ -220,6 +220,8 @@ async function applyContract( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -322,6 +324,8 @@ describe('RLS lifecycle e2e — edit replaces, removal fails verify', { concurre schema: introspected, policy: ALLOW_DESTRUCTIVE, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -497,6 +501,8 @@ describe('RLS policy_insert WITH CHECK — enforcement + edit replaces', { schema: introspected, policy: ALLOW_DESTRUCTIVE, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts index df25fbba84a8..441c89d4741d 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-migration-plan.integration.test.ts @@ -159,6 +159,8 @@ describe('migration plan emits RLS (offline, no live database)', () => { schema: fromSchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -194,6 +196,8 @@ describe('migration plan emits RLS (offline, no live database)', () => { schema: fromSchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-role-verify.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-role-verify.integration.test.ts index f8a276b9d328..a265db699aab 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-role-verify.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-role-verify.integration.test.ts @@ -117,6 +117,8 @@ describe('roles enter verify — existence-only, asymmetric', { concurrent: fals schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-verify-extension-issues.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-verify-extension-issues.integration.test.ts index e4b40fa00bda..26a01fb22e83 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-verify-extension-issues.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-verify-extension-issues.integration.test.ts @@ -125,6 +125,8 @@ describe('RLS verify extension issues', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts index 0a4f48eef887..65fac4bddf23 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton-psl.integration.test.ts @@ -187,6 +187,8 @@ describe('RLS walking skeleton — PSL author → plan → apply → filter → schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton.integration.test.ts index d59713633b1c..ed9e18164e8d 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/rls-walking-skeleton.integration.test.ts @@ -128,6 +128,8 @@ describe('RLS walking skeleton — author → plan → apply → filter → veri schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.basic.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.basic.integration.test.ts index afa3013f8203..e478c81de8d3 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.basic.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.basic.integration.test.ts @@ -56,6 +56,8 @@ describe('PostgresMigrationRunner - Basic Execution', { concurrent: false }, () schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -123,6 +125,8 @@ describe('PostgresMigrationRunner - Basic Execution', { concurrent: false }, () schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.idempotency.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.idempotency.integration.test.ts index 5d88e394c85a..64dac58a93a5 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.idempotency.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.idempotency.integration.test.ts @@ -259,6 +259,8 @@ describe('PostgresMigrationRunner - Idempotency', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -348,6 +350,8 @@ describe('PostgresMigrationRunner - Idempotency', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts index 57c8a1246593..77daf9477390 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts @@ -506,6 +506,8 @@ describe('PostgresMigrationRunner - per-edge ledger', { concurrent: false }, () schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-cas.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-cas.integration.test.ts index 62eec6ed5f96..59a0aa9e3f83 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-cas.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-cas.integration.test.ts @@ -49,6 +49,8 @@ describe('PostgresMigrationRunner marker compare-and-swap', { concurrent: false schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-lock.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-lock.integration.test.ts index e4e4aee17710..55d53f61ce6a 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-lock.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.marker-lock.integration.test.ts @@ -48,6 +48,8 @@ describe('PostgresMigrationRunner marker lock', { concurrent: false }, () => { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts index 6711b919b1f8..adc649f7f5cd 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.rename-table.integration.test.ts @@ -52,7 +52,7 @@ describe('PostgresMigrationRunner - renameTable', { concurrent: false }, () => { origin: null, destination: toPlanContractInfo(contract), operations: [ - await new RenameTableCall('public', 'userProfile', 'UserProfile').toOp(controlAdapter), + await new RenameTableCall('public', 'userProfile', 'UserProfile', []).toOp(controlAdapter), ], providedInvariants: [], }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.unbound-namespace.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.unbound-namespace.integration.test.ts index eb36c1bbe863..a5370b85bf8d 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/runner.unbound-namespace.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.unbound-namespace.integration.test.ts @@ -129,6 +129,8 @@ describe('`namespace unbound` multi-tenancy via search_path', { concurrent: fals schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts index 510dfd47fbbf..3bea659d451b 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts @@ -77,6 +77,8 @@ describe('Schema verification after runner - integration', { concurrent: false } schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts index ac741f3c40a9..f09a8e519abd 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts @@ -124,6 +124,8 @@ describe('Schema verification after runner - int8 literal default (issue #30174) schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.refused-date-time-default.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.refused-date-time-default.integration.test.ts index 03964131f573..916f05cc7fe7 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.refused-date-time-default.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.refused-date-time-default.integration.test.ts @@ -133,6 +133,8 @@ describe('a contract default the canonical form of its data type refuses', { schema, policy: { allowedOperationClasses: ['additive', 'widening'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts index b77bc61c703a..0a8f1e2ddb98 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts @@ -551,13 +551,13 @@ export class SqliteControlAdapter implements SqlControlAdapter<'sqlite'> { // SQLite's synchronous driver serializes reads — no benefit from Promise.all const columnsResult = await driver.query( - `PRAGMA table_info("${escapePragmaArg(tableName)}")`, + `PRAGMA table_info(${quoteIdentifier(tableName)})`, ); const fkResult = await driver.query( - `PRAGMA foreign_key_list("${escapePragmaArg(tableName)}")`, + `PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`, ); const indexListResult = await driver.query( - `PRAGMA index_list("${escapePragmaArg(tableName)}")`, + `PRAGMA index_list(${quoteIdentifier(tableName)})`, ); const columns: Record = {}; @@ -634,7 +634,7 @@ export class SqliteControlAdapter implements SqlControlAdapter<'sqlite'> { for (const idx of indexListResult.rows) { // origin: 'c' = CREATE INDEX, 'u' = UNIQUE constraint, 'pk' = PRIMARY KEY const idxInfoResult = await driver.query( - `PRAGMA index_info("${escapePragmaArg(idx.name)}")`, + `PRAGMA index_info(${quoteIdentifier(idx.name)})`, ); const idxColumns = idxInfoResult.rows.sort((a, b) => a.seqno - b.seqno).map((r) => r.name); @@ -677,13 +677,6 @@ export class SqliteControlAdapter implements SqlControlAdapter<'sqlite'> { } } -// PRAGMA queries use the function-argument form (`PRAGMA table_info("name")`) -// which doesn't support `?` placeholders — the argument is part of the -// statement name, not a bound parameter. We quote-escape the table name instead. -function escapePragmaArg(name: string): string { - return name.replace(/"/g, '""'); -} - const SQLITE_REFERENTIAL_ACTION_MAP: Record = { 'NO ACTION': 'noAction', RESTRICT: 'restrict', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/data-type-verify.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/data-type-verify.test.ts index a3fc9cbbe82f..07caf3f8a19e 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/data-type-verify.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/data-type-verify.test.ts @@ -224,6 +224,8 @@ describe('verify on SQLite, for each data type', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/now-default.integration.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/now-default.integration.test.ts index 65d15642c481..a3ed400de2bb 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/now-default.integration.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/now-default.integration.test.ts @@ -97,6 +97,8 @@ describe('a SQLite now() default', { timeout: timeouts.databaseOperation }, () = schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -167,6 +169,8 @@ describe('a SQLite now() default', { timeout: timeouts.databaseOperation }, () = schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts index 3a30332076b1..467442643382 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts @@ -130,6 +130,8 @@ describe('SQLite planner + introspection round-trip', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -196,6 +198,8 @@ describe('SQLite planner + introspection round-trip', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -277,6 +281,8 @@ describe('SQLite planner + introspection round-trip', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/planner.codec-field-event.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/planner.codec-field-event.test.ts index cd86cfa7399f..6bd6d60afa43 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/planner.codec-field-event.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/planner.codec-field-event.test.ts @@ -1,7 +1,11 @@ import { type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { CodecControlHooks, SqlMigrationPlanOperation } from '@internal/family-sql/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; -import { APP_SPACE_ID, type OpFactoryCall } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + type OpFactoryCall, + planOriginOf, +} from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, type StorageColumn, type StorageTable } from '@internal/sql-contract/types'; import { createSqliteBuiltinCodecLookup } from '@internal/target-sqlite/codecs'; @@ -121,6 +125,8 @@ describe('SqliteMigrationPlanner - codec onFieldEvent wiring', () => { schema: { tables: {} }, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: makeFrameworkComponents(hooks), spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -182,6 +188,8 @@ describe('SqliteMigrationPlanner - codec onFieldEvent wiring', () => { }, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: makeFrameworkComponents(hooks), spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -206,6 +214,8 @@ describe('SqliteMigrationPlanner - codec onFieldEvent wiring', () => { schema: { tables: {} }, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -250,6 +260,8 @@ describe('SqliteMigrationPlanner - codec onFieldEvent wiring', () => { schema: { tables: {} }, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: fc, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -259,6 +271,8 @@ describe('SqliteMigrationPlanner - codec onFieldEvent wiring', () => { schema: { tables: {} }, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: fc, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/planner.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/planner.test.ts index 51f3e0e2c684..aafc4984e739 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/planner.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/planner.test.ts @@ -81,6 +81,8 @@ describe('SQLite migration planner', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -133,6 +135,8 @@ describe('SQLite migration planner', () => { schema: existingSchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -167,6 +171,8 @@ describe('SQLite migration planner', () => { schema: emptySchema, policy: { allowedOperationClasses: ['additive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -187,6 +193,8 @@ describe('SQLite migration planner', () => { schema: emptySchema, policy: { allowedOperationClasses: [] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: sqliteComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/rename-statements.hostile-names.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/rename-statements.hostile-names.test.ts new file mode 100644 index 000000000000..0a8b14b949c5 --- /dev/null +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/rename-statements.hostile-names.test.ts @@ -0,0 +1,195 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { + APP_SPACE_ID, + type MigrationOperationPolicy, + planOriginOf, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage } from '@internal/sql-contract/types'; +import type { SqlSchemaIR } from '@internal/sql-schema-ir/types'; +import { sqliteCreateNamespace } from '@internal/target-sqlite/control'; +import { CONTROL_TABLE_NAMES } from '@internal/target-sqlite/control-tables'; +import type { SqlitePlanTargetDetails } from '@internal/target-sqlite/planner-target-details'; +import { applicationDomainOf, timeouts } from '@repo/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + controlAdapter, + createMigrationPlan, + createTestDatabase, + familyInstance, + formatRunnerFailure, + frameworkComponents, + sqliteTargetDescriptor, + synthEdges, + type TestDatabase, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +const ALL_CLASSES: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive'], +}; +const NAMESPACE = asNamespaceId(UNBOUND_NAMESPACE_ID); + +interface Names { + readonly model: string; + readonly table: string; + readonly field: string; + readonly column: string; +} + +function quoted(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function contractOf(seed: string, names: Names): Contract { + const integer = { dataType: 'sqlite/integer', codecId: 'sqlite/integer@1', nullable: false }; + const text = { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: false }; + return { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash(seed), + storage: new SqlStorage({ + storageHash: coreHash(seed), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + [names.table]: { + columns: { id: integer, [names.column]: text }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: { + [names.model]: { + fields: { + id: { nullable: false, type: { kind: 'scalar', codecId: 'sqlite/integer@1' } }, + [names.field]: { nullable: false, type: { kind: 'scalar', codecId: 'sqlite/text@1' } }, + }, + relations: {}, + storage: { + table: names.table, + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { id: { column: 'id' }, [names.field]: { column: names.column } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +describe('rename statements with hostile identifiers on SQLite', { + timeout: timeouts.databaseOperation, +}, () => { + let testDb: TestDatabase; + + afterEach(() => { + testDb?.cleanup(); + }); + + async function userTables(): Promise { + const tables = await testDb.driver.query<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND substr(name, 1, 7) <> 'sqlite_' ORDER BY name`, + ); + return tables.rows.map((row) => row.name).filter((name) => !CONTROL_TABLE_NAMES.has(name)); + } + + it('renames a table and a column whose names hold a quote, a backslash, a space and a keyword', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + const from: Names = { + model: 'Profile', + table: 'user"s \\ select', + field: 'email', + column: 'e"mail \\ where', + }; + const to: Names = { + model: 'Person', + table: 'pe"ople; drop table x; --', + field: 'mail', + column: 'ma"il \\ table', + }; + const origin = contractOf('hostile-from', from); + const destination = contractOf('hostile-to', to); + const statements: readonly ResolvedMigrationStatement[] = [ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: NAMESPACE, model: from.model }, + to: { namespaceId: NAMESPACE, model: to.model }, + }, + { + kind: 'rename', + entity: 'field', + from: { namespaceId: NAMESPACE, model: from.model, field: from.field }, + to: { namespaceId: NAMESPACE, model: to.model, field: to.field }, + }, + ]; + await driver.query( + `CREATE TABLE ${quoted(from.table)} (id INTEGER PRIMARY KEY, ${quoted(from.column)} TEXT NOT NULL)`, + ); + await driver.query(`INSERT INTO ${quoted(from.table)} VALUES (1, 'kept')`); + expect(await userTables()).toEqual([from.table]); + + const planned = sqliteTargetDescriptor.createPlanner(controlAdapter).plan({ + contract: destination, + schema: sqliteTargetDescriptor.migrations.contractToSchema( + origin, + frameworkComponents, + ) as SqlSchemaIR, + policy: ALL_CLASSES, + fromContract: origin, + origin: planOriginOf(origin), + statements, + frameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (planned.kind !== 'success') throw new Error(JSON.stringify(planned.conflicts)); + const plan = createMigrationPlan({ + targetId: 'sqlite', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(destination), + operations: await Promise.all(planned.plan.operations), + providedInvariants: [], + }); + const result = await sqliteTargetDescriptor.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver, + destinationContract: destination, + policy: ALL_CLASSES, + frameworkComponents, + }, + ], + }); + + expect(result.ok, result.ok ? '' : formatRunnerFailure(result.assertNotOk())).toBe(true); + expect(await userTables()).toEqual([to.table]); + const columns = await driver.query<{ name: string }>( + 'SELECT name FROM pragma_table_info(?) ORDER BY cid', + [to.table], + ); + expect(columns.rows).toEqual([{ name: 'id' }, { name: to.column }]); + const rows = await driver.query(`SELECT * FROM ${quoted(to.table)}`); + expect(rows.rows).toEqual([{ id: 1, [to.column]: 'kept' }]); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.basic.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.basic.test.ts index 4b932d6dd454..be3692cc65ea 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.basic.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.basic.test.ts @@ -36,6 +36,8 @@ describe('SqliteMigrationRunner - Basic Execution', { timeout: timeouts.database schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -100,6 +102,8 @@ describe('SqliteMigrationRunner - Basic Execution', { timeout: timeouts.database schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts index 07ef8d8a4a4e..b3f89d9d6ec6 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts @@ -391,6 +391,8 @@ describe('SqliteMigrationRunner - per-edge ledger', { timeout: timeouts.database schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.marker-cas.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.marker-cas.test.ts index 8e0603b98163..5e83297c2413 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.marker-cas.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.marker-cas.test.ts @@ -41,6 +41,8 @@ describe('SqliteMigrationRunner marker compare-and-swap', { schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-column.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-column.test.ts new file mode 100644 index 000000000000..b746a906ed72 --- /dev/null +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-column.test.ts @@ -0,0 +1,83 @@ +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { RenameColumnCall } from '@internal/target-sqlite/op-factory-call'; +import type { SqlitePlanTargetDetails } from '@internal/target-sqlite/planner-target-details'; +import { timeouts } from '@repo/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + contract, + controlAdapter, + createMigrationPlan, + createTestDatabase, + familyInstance, + frameworkComponents, + sqliteTargetDescriptor, + synthEdges, + type TestDatabase, + toPlanContractInfo, +} from './fixtures/runner-fixtures'; + +describe('SqliteMigrationRunner - renameColumn', { timeout: timeouts.databaseOperation }, () => { + let testDb: TestDatabase; + + afterEach(() => { + testDb?.cleanup(); + }); + + async function runRename(from: string, to: string) { + const { driver } = testDb; + const plan = createMigrationPlan({ + targetId: 'sqlite', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [await new RenameColumnCall('profile', from, to, []).toOp(controlAdapter)], + providedInvariants: [], + }); + return sqliteTargetDescriptor.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + } + + it('fails the precheck instead of skipping the rename when the old and the new column both exist', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "profile" (id INTEGER PRIMARY KEY, name TEXT, fullName TEXT)'); + await driver.query(`INSERT INTO "profile" (id, name, fullName) VALUES (1, 'a', 'b')`); + + const result = await runRename('name', 'fullName'); + + expect(result.ok).toBe(false); + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameColumn.profile.name failed during precheck: ensure column "fullName" does not exist on "profile"', + meta: { operationId: 'renameColumn.profile.name' }, + }); + const rows = await driver.query('SELECT name, fullName FROM "profile"'); + expect(rows.rows).toEqual([{ name: 'a', fullName: 'b' }]); + }); + + it('fails the precheck when a column of the new name exists in another case', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "profile" (id INTEGER PRIMARY KEY, other TEXT, Name TEXT)'); + + const result = await runRename('other', 'NAME'); + + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + meta: { operationId: 'renameColumn.profile.other' }, + }); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts index 5525aa8379c9..091c51e1a69d 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.rename-table.test.ts @@ -23,6 +23,66 @@ describe('SqliteMigrationRunner - renameTable', { timeout: timeouts.databaseOper testDb?.cleanup(); }); + async function runRename(from: string, to: string) { + const { driver } = testDb; + const plan = createMigrationPlan({ + targetId: 'sqlite', + spaceId: APP_SPACE_ID, + origin: null, + destination: toPlanContractInfo(contract), + operations: [await new RenameTableCall(from, to, []).toOp(controlAdapter)], + providedInvariants: [], + }); + return sqliteTargetDescriptor.createRunner(familyInstance).execute({ + driver, + perSpaceOptions: [ + { + space: APP_SPACE_ID, + plan, + migrationEdges: synthEdges(plan), + driver, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents, + }, + ], + }); + } + + it('fails the precheck when a table of the new name exists in another case', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "Profile" (id INTEGER PRIMARY KEY)'); + await driver.query('CREATE TABLE "user" (id INTEGER PRIMARY KEY)'); + + const result = await runRename('Profile', 'User'); + + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + summary: + 'Operation renameTable.Profile failed during precheck: ensure no table or index is named "User" in any case', + meta: { operationId: 'renameTable.Profile' }, + }); + const tables = await driver.query<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('Profile', 'user') ORDER BY name`, + ); + expect(tables.rows).toEqual([{ name: 'Profile' }, { name: 'user' }]); + }); + + it('fails the precheck when an index holds the new name', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + await driver.query('CREATE TABLE "Profile" (id INTEGER PRIMARY KEY, email TEXT)'); + await driver.query('CREATE INDEX "user" ON "Profile" (email)'); + + const result = await runRename('Profile', 'User'); + + expect(result.assertNotOk()).toMatchObject({ + code: 'MIGRATION.PRECHECK_FAILED', + meta: { operationId: 'renameTable.Profile' }, + }); + }); + it('fails the precheck instead of skipping the rename when the old and the new table both exist', async () => { testDb = createTestDatabase(); const { driver } = testDb; @@ -34,7 +94,7 @@ describe('SqliteMigrationRunner - renameTable', { timeout: timeouts.databaseOper spaceId: APP_SPACE_ID, origin: null, destination: toPlanContractInfo(contract), - operations: [await new RenameTableCall('profile', 'account').toOp(controlAdapter)], + operations: [await new RenameTableCall('profile', 'account', []).toOp(controlAdapter)], providedInvariants: [], }); @@ -57,7 +117,7 @@ describe('SqliteMigrationRunner - renameTable', { timeout: timeouts.databaseOper expect(result.assertNotOk()).toMatchObject({ code: 'MIGRATION.PRECHECK_FAILED', summary: - 'Operation renameTable.profile failed during precheck: ensure table "account" does not exist', + 'Operation renameTable.profile failed during precheck: ensure no table or index is named "account" in any case', meta: { operationId: 'renameTable.profile' }, }); const rows = await driver.query('SELECT id FROM "profile"'); diff --git a/projects/migration-statements/README.md b/projects/migration-statements/README.md new file mode 100644 index 000000000000..2d3f274c31e3 --- /dev/null +++ b/projects/migration-statements/README.md @@ -0,0 +1,12 @@ +# Migration statements + +Project folder for [Destructive changes need stated intent](https://linear.app/prisma-company/project/destructive-changes-need-stated-intent-7626c0107cd9), second attempt. The user states migration intent as statements on the `migration plan` and `db update` command line, in contract vocabulary, and the planner refuses data loss that no statement covers. + +- [`spec.md`](./spec.md) — project spec: purpose, the statement vocabulary, non-goals, cross-cutting requirements, project DoD. +- [`design-notes.md`](./design-notes.md) — the design discussion of 2026-10-05: the verb and noun matrix, the scenarios that stress it, prior art, alternatives rejected, and what changed from the shelved first attempt. +- [`plan.md`](./plan.md) — slice plan. +- `slices//` — per-slice spec, plan and reviews. + +The first attempt, which stated intent in the contract source with `@@hint(was:)`, is shelved in prisma/orm#30557 and prisma/orm#30570 under `projects/intent-hints/` on those branches. Its planner substrate is reused here. + +Everything in this folder is transient. At close-out the durable parts move to `docs/` and an ADR, and the folder is deleted. diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md new file mode 100644 index 000000000000..5fe9c97830dc --- /dev/null +++ b/projects/migration-statements/deferred.md @@ -0,0 +1,39 @@ +# Deferred — Migration statements + +Items found during delivery that are out of the current slice's scope. Each names where it came from and what would resolve it. Transient; migrated or dropped at close-out. + +## A model that keeps its name but changes its table (`@@map`) has no statement + +**Found:** slice 1, dispatch 4 review (2026-10-06). + +A user keeps `model User` and changes `@@map("users")` to `@@map("app_users")`. The diff shows a dropped table and a created one, exactly like a rename, but there is no model rename to state: `--rename User:User` is unresolved because the new name already exists in the origin. In `migration plan` the user can write `this.renameTable({ table: 'users', to: 'app_users' })` by hand; in `db update` there is no way to avoid the drop and create. Slice 1 refuses a field statement on such a model with `statementRejected` (D4-1) so it never plans a column rename on a table the diff then drops. + +**Options:** let a same-coordinate model statement (`--rename User:User`) mean "this model's storage was renamed"; or add a storage-level statement; or accept the hand-written route for this case. Decide with the operator before slice 2 writes the refusal text, because the refusal will otherwise suggest a `--rename` that cannot resolve. + +## A model move across namespaces (`auth.User:public.User`) + +**Found:** slice 1, dispatch 3 (2026-10-06). Resolves but is refused with `statementRejected`; needs a `set schema` operation with its working-schema step and companion names. Scheduled for slice 3 with the namespace renames (recorded in `plan.md`). + +## A codec's `onFieldEvent` hook sees different events under `db update` with and without statements + +**Found:** slice 1, dispatch 5 review (2026-10-06). + +`db update` passes the origin contract to the planner only when `--rename` is given. Without statements, field-event planning has no prior contract and reports every column as added, as before this slice; with statements it reports only real changes. No codec in the repository implements `onFieldEvent` today, so nothing observable differs. If a codec starts relying on the hook under `db update`, decide whether `db update` should always supply the origin contract (which needs the runner's origin handling kept separate, as slice 1 did with `origin: null`). + +## Which field name a MongoDB statement uses + +**Found:** slice 1, dispatch 7 review (2026-10-07). For slice 4. + +On SQL a statement names the model's field (`User.fullName`), and the storage bridge maps it to a column. On MongoDB every authoring surface keys a model's `fields` by the stored name (`@map("_id")` gives `fields._id`), so a statement resolved today against a Mongo contract names the stored field (`User._id`). Slice 4 must decide whether Mongo statements name the model's field or the stored field, and make the resolver agree on both families. + +## MongoDB still classes index drops as destructive + +**Found:** slice 1, whole-slice architect review (2026-10-07). For slice 2 and slice 4. + +Project requirement 10 says drops that lose no data are widening on every target. Slice 1 reclassified them on Postgres and SQLite only; the MongoDB planner still classes an index drop as `destructive`. Slice 2's refusal must not refuse a drop no statement can resolve, so either slice 2 reclassifies Mongo's index drops first or slice 4 does it before Mongo statements ship. + +## A SQLite column rename followed by a table rebuild builds the replacement index twice + +**Found:** slice 1, whole-slice code review (2026-10-07), finding F04. + +When one plan renames a column on SQLite and a later step rebuilds the same table, the column rename's companion index replacement (drop and create) runs, then the rebuild creates the index again. Rows are kept; one index build is wasted. Removing the companion would need the planner to rewrite statement calls after the diff, and then re-running `migration.ts` (whose `renameColumn` cannot see the later rebuild) would no longer reproduce `ops.json`. Fixing it properly needs operations that carry dependency information, which the design notes already name as a direction. diff --git a/projects/migration-statements/design-notes.md b/projects/migration-statements/design-notes.md new file mode 100644 index 000000000000..1e2916d61daa --- /dev/null +++ b/projects/migration-statements/design-notes.md @@ -0,0 +1,148 @@ +# Design notes — Migration statements + +Record of the design discussion on 2026-10-05 between the operator and the orchestrator, after the contract-source attempt was shelved. Each section carries the reasoning and the alternatives rejected. + +## Principles + +- The planner never guesses. It acts on a destructive-looking change only when the user has stated what it is. +- The user states intent in the vocabulary of their contract. Storage names, SQL and literal values never appear on the command line. +- A statement is sugar over the hand-written route. Whatever it produces, a user could have written in `migration.ts`, and that route stays. +- `migration plan` and `db update` share one planner. A statement means the same thing to both, and `db update` is a migration planned in memory and applied at once. + +## Why not the contract source + +The first attempt put `@@hint(was: "Profile")` on the model. It was shelved because stating intent in the source felt unintuitive, and because intent in the source has to survive until every environment has consumed it, which produced the rules about spent and ignored hints and the unhashed contract section that snapshots had to strip. A statement on the command line applies to one planning run. For `migration plan` the migration file is the record; for `db update` the database is. Nothing has to be left behind or cleaned up. + +The prior art is honest about the trade-off. Atlas and Terraform both started with the command line, Atlas with an interactive prompt and Terraform with `state mv`, and both moved to a declaration in the source, because a command-line statement is not in version control and a prompt cannot be answered by a script or an agent. We accept the first objection knowingly: the migration file is in version control, and `db update` is dev-only. We avoid the second by having no prompt. + +## Prior art + +| Tool | How a rename is stated | Non-interactive answer | +| --- | --- | --- | +| Prisma Migrate 2 to 7 | Not detected; edit the generated SQL with `--create-only` | the edited migration file | +| ActiveRecord | No diffing; `rename_column` in a hand-written migration; generator shorthand writes `add_column` calls from command arguments | the migration file | +| Alembic, EF Core, pgroll | hand-written or hand-edited migration | the migration file | +| Django `makemigrations` | detects candidate pairs and asks "Did you rename X to Y?" | `--noinput` treats it as drop and add; no way to pre-answer | +| Drizzle Kit | detects candidate pairs and asks | none shipped; an open request proposes `--preflight` to export questions and `--answers` to answer them; internally the planner takes a `HintsHandler` whose only source is the prompt | +| Atlas | asked at plan time until 2024; now `renamed_from` on the object in the HCL or SQL source | the source declaration; the prompt was abandoned because an agent cannot answer it | +| Terraform | `terraform state mv`, then the `moved` block in the source from 1.1 | the source declaration | +| PlanetScale, sqldef, Skeema | no rename; expand, dual-write, backfill, contract | nothing to answer | + +The ActiveRecord generator is the closest precedent to what we build: command arguments that produce explicit operations in a migration file, which is then the record. Nobody offers a rename argument on a plan command. + +## The vocabulary + +Four verbs and four nouns, all in the application domain. + +| | namespace | model | field | value | +| --- | --- | --- | --- | --- | +| rename | `--rename billing:finance` | `--rename Profile:User`, `--rename auth.User:public.User` | `--rename User.name:User.fullName`, `--rename Address.street:Address.streetName` | `--rename Status.ARCHIVED:Status.RETIRED` | +| delete | `--delete billing` | `--delete Legacy` | `--delete User.name` | `--delete Status.ARCHIVED` | +| convert | | `--convert Bug` (discriminator value changed) | `--convert User.age` | | +| backfill | | | `--backfill User.phone` | | + +Rename is an identity change, delete is a removal, convert is an in-place change to a definition that existing data must follow, backfill fills existing rows for a field that has become required. A model rename whose namespace part changes is a move; there is no separate move verb because the coordinate already says it. `--delete` is consent, not a hint: the contract already lacks the thing, the planner already plans the drop, and the statement allows it. + +Coordinates are the names the user wrote in the contract source. The namespace is omitted when the contract has one. Model and value object names share one namespace for resolution, and an ambiguous reading is an error that says which readings were tried. The separator is a colon because `->` redirects in a shell and names cannot contain a colon. + +### What each statement replaces + +| Statement | Diff shows | Postgres and SQLite | Mongo | +| --- | --- | --- | --- | +| rename namespace | every model removed under one, added under the other | alter schema rename | not applicable, one namespace | +| rename model | model removed and added | rename table, retarget foreign keys, rename constraints and indexes to destination names | rename collection | +| rename model across namespaces | removed in one, added in another | alter table set schema | not applicable | +| rename variant, single-table | model removed and added, storage unchanged | nothing unless the discriminator value changed | nothing | +| rename variant, multi-table | same | rename the variant's table | not applicable | +| rename value object | removed and added under value objects | nothing, reported as applied | nothing | +| rename field | field removed and added | rename column, rename constraints and indexes on it | update every document | +| rename value object field | removed and added under the value object | rewrite the JSON in every column of that type, arrays included | rewrite subdocuments | +| rename relation field | relation removed and added | nothing, reported as applied | nothing | +| rename enum value | value removed and added | update rows holding the old value; native Postgres enums rename the value | update documents | +| delete model | model removed | drop table | drop collection | +| delete variant, single-table | model removed | delete rows with that discriminator value, drop its columns; the refusal says it deletes rows | delete documents | +| delete field | field removed | drop column | unset in every document | +| delete value object field | removed | nothing or a cleanup rewrite; consent still required, the application loses the data | same | +| delete enum value | value removed | set the field to null where nullable, otherwise refuse and point at rename or convert; never deletes rows | same | +| convert field | type changed | `alterColumnType` with `using: placeholder(...)`; a data transform scaffold only where a separate pass is needed, such as nulls before not null | data transform scaffold, then the validator change | +| convert variant | discriminator value changed | update rows holding the old value, no placeholder, both values are known | update documents | +| backfill field | field added non-nullable, or nullable to required | the scaffolded backfill transform; without the statement the temporary-default recipe | data transform scaffold | + +Not offered, deliberately: split or merge tables, move a field between models, change a primary key, move a variant between single-table and multi-table storage, make an existing model a variant. These are data migrations and belong in a hand-written file. + +### Why no value or expression on the command line + +Two proposals were rejected in turn. A conversion expression in the family's language with a token for the current value, `--convert User.age:"nullif(trim($value),'')::integer"`: users cannot be expected to type SQL on a command line. A fill value as a JSON literal, `--fill 0`: JSON has no types, which is the gap the data-type and cast design closes for `@default` and would reopen here. A fill value in the `@default` grammar was considered and dropped for the same reason the expression was: the command line carries intent only. The conversion and the value are written into the migration file where the placeholder is, which is the existing editing flow. + +### Why no prompt and no detection + +A prompt only helps in a terminal and only when a candidate pair exists; the operator distrusts automatic detection and called the prompt an ugly guardrail. Django, Drizzle and the old Atlas all carry open complaints about it. The refusal message does the prompt's job without guessing: it names the operation and prints the statements. + +## Resolution and the origin contract + +Both sides of a rename resolve against a contract, so the user writes model and field names and never storage names. Resolution happens in the framework, against the origin contract and the destination contract; the family receives resolved entities. The new side is always available, the destination is the emitted contract. The old side needs the origin contract. `migration plan` has it from its origin ref. `db update` today passes the planner `fromContract: null` and must instead resolve the marker's hash in the local snapshot store. + +When the origin contract cannot be found, every rename and convert statement fails with one error. `--delete` still works because consent resolves against the diff's own operations. The user then consents and, in a migration project, edits the file. The marker's `contract_json` column is not used: the docs call it optional and diagnostic, and the planner is offline. + +The contract-level comparison also covers changes the schema differ cannot see: value object fields, discriminator values, and on Mongo any field the validator does not carry. Those changes are visible in a diff of the two contracts and get the same refusal and statements as a dropped column. Without the origin contract they are invisible, which is one more reason the error is loud. + +## Scenarios that stress the model + +1. **Composition in order.** `--rename Profile:User --rename User.name:User.fullName`: the second uses the new name. Statements apply to the working copy in the order given. +2. **Swap.** `--rename A:B --rename B:A` needs a temporary name. Refused in this project with a clear message. +3. **Rename plus convert on one field.** `--rename User.age:User.years --convert User.years` works because convert resolves after the rename. +4. **Old name missing.** Error before planning, naming the contract searched. +5. **New name missing.** Error, naming the destination contract. +6. **Both names exist in the origin.** Error: cannot rename onto an existing entity. +7. **The rename changed nothing in storage.** The user renamed the model and kept `@@map("profiles")`. The statement resolves, no operation is emitted, and the plan reports it as applied with nothing to do. +8. **Repeated run.** `db update --rename Profile:User` twice. The second run fails because `Profile` is not in the origin. Chosen deliberately: a statement is intent for this run, and a script that always passes it is wrong. +9. **No origin contract.** Renames and converts fail, deletes work. Baselines and `--from @empty` have no origin models and nothing to rename. +10. **Rollback.** `migration plan --to ^` reverses the diff; statements are written in the direction of this plan. +11. **Re-running the migration file** reproduces the operations from the file. Mongo and value object rewrites are data transform operations the facade offers. +12. **Control policy forbids altering the model.** The statement is an error, not ignored. Statements express intent; intent that makes no sense is rejected. +13. **Extension contract spaces.** A name that resolves only in an extension's space is an error. +14. **Ambiguous coordinates.** `A.B.C` could be namespace, model, field or model, field, value. Resolution usually settles it; when two readings resolve, error. The first slice's spec fixes the grammar. +15. **Renaming the default namespace.** `public` on Postgres is target-owned. Needs a check in the slice that adds namespace renames. +16. **Interactive consent.** In a terminal the refusal becomes the per-operation consent question, and an answer equals the statement. No prompt for renames. + +## The type-change walkthrough and its evaluation + +Postgres, a migration project, `age String` to `age Int`. + +1. Edit the contract source, `contract emit`. +2. `migration plan --name age-to-int` refuses; nothing is written; the message prints `--convert User.age` and `--delete User.age`. +3. `migration plan --name age-to-int --convert User.age` writes the migration with `alterColumnType({ ..., using: placeholder('typechange-users-age:using') })`, `ops.json` as `[]`, and the existing pending-placeholders warning. +4. The user replaces the placeholder with the expression their data needs. +5. `node migrations//migration.ts` re-emits. +6. `db migrate`. + +The round trip in step 2 is the expected outcome: the user says which of the two they meant, and `db update` behaves the same way. The cost is paid by making the refusal copy-and-paste. Two substrate defects found on the way: the existing scaffold puts placeholders in a separate data transform and leaves the alter bare, so a user who fills the two slots gets a migration that fails at apply, because Postgres will not cast text to integer without `using`; and the alter's rendered options expose internal field names. Both are fixed in the convert slice. `placeholder()` is typed `never` and the CLI catches its error by code, so it can sit in any slot of any operation; ADR 200's statement that only data transforms have holes described the scaffolder at the time, not a constraint. + +`--convert` promises more than it delivers today, since it scaffolds a slot rather than converting. It keeps the name because it names the intent, and the planner can do more of the work later without the statement changing. `--placeholder` was considered and rejected: it names the mechanism and merges two intents. + +## What changed from the shelved design + +| Shelved | Now | +| --- | --- | +| `@@hint(was:)` in the contract source, lowered to an unhashed contract section stripped from snapshots | statements on the command line; no contract change | +| a spent hint is silently ignored; a non-applicable hint is ignored | an unusable statement is an error | +| the old side is a storage name resolved against the origin schema | both sides are contract names resolved against the two contracts | +| `deleted: true` tombstone blocks and a reserved `deprecated` | `--delete` per operation; no lifecycle vocabulary | +| blanket `--confirm` kept as the fallback | `--confirm` replaced by `--delete` | +| field hints, refusal, tombstones as later slices | rename, delete, convert, backfill as verbs over four nouns | + +Kept unchanged: destructive means data loss; the working schema shared by the planner and the facade; companion constraint names from the destination contract; foreign-key pairing by own and referenced columns; operations should carry dependency information and `migration.ts` should become an ordered graph walk, with no composite operations. + +## Open questions + +- Grammar details for coordinates and the separator, settled in the first slice's spec. +- Whether `--delete` on a type-changed field keeps the word or gets its own. +- Whether the Mongo slice needs a collection rename operation added to the Mongo migration facade first, or ships it inside the slice. + +## References + +- [`spec.md`](./spec.md) +- [Atlas declarative renames](https://atlasgo.io/changelog/declarative-schema-renames), [Atlas v0.22 rename detector](https://atlasgo.io/blog/2024/05/01/atlas-v-0-22) +- [Django ticket 24735](https://code.djangoproject.com/ticket/24735), [Drizzle issue 5307](https://github.com/drizzle-team/drizzle-orm/issues/5307), [Drizzle issue 6053](https://github.com/drizzle-team/drizzle-orm/issues/6053) +- [Prisma customizing migrations](https://prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations), [PlanetScale renames](https://planetscale.com/docs/learn/handling-table-and-column-renames) +- prisma/orm#30557 and prisma/orm#30570, the shelved attempt diff --git a/projects/migration-statements/manual-qa-reports/2026-10-07-qa-opus.md b/projects/migration-statements/manual-qa-reports/2026-10-07-qa-opus.md new file mode 100644 index 000000000000..f1ad2cab9b11 --- /dev/null +++ b/projects/migration-statements/manual-qa-reports/2026-10-07-qa-opus.md @@ -0,0 +1,385 @@ +# Manual QA — slice 1, rename statements — 2026-10-07 — qa-opus + +Branch `tml-3475-statement-renames` at `2bfa6940c9`. Script: `projects/migration-statements/manual-qa.md`, with the caller's notes applied (field statements name the destination model, `statementRefused` under `MIGRATION.PLANNING_FAILED`, `db update` reads its origin only with `--rename`, MongoDB refuses every statement, `appliedStatements` entries are `{ statement, operationIds }`). + +## Summary + +| # | Scenario | Verdict | +| - | -------- | ------- | +| 1 | Postgres, `migration plan` then `db migrate` | ✅ | +| 2 | SQLite, `db update` twice | ⚠️ (F5, F6) | +| 3 | Wrong statements | ⚠️ (F7) | +| 4 | `--db` without a snapshot | 🛑 (F1, F2) | +| 5 | `@@map` changes, model name kept | ⚠️ (F8) | +| 6 | A drop that loses no data | ✅ | +| 7 | MongoDB | ⚠️ (F3) | +| 8 | Extension upgrade instructions (judgement) | ⚠️ (F10) | +| 9 | Skill replay (judgement) | ✅ against the oracle, one minor gap (F11) | +| 10 | Exploratory | ⚠️ (F4, F9; F6 and F7 recur) | + +## Findings + +1. 🛑 **F1 — The `--db` error offers "run the command without statements", which drops the table and its rows.** Command: `prisma db update --db --rename Profile:User` (scenario 4). Output ends with `→ Or run the command without statements`. Following it: `prisma db update --db --dry-run` plans `⚠ Drop table Profile` (and `Recreate table Post`). Only the consent prompt stands between this advice and lost rows, which is the exact failure this project exists to remove. The advice should say that the plan drops the table and its rows, or not be offered. Rated a blocker because it is the CLI's own advice leading to data loss; downgrade if a consent prompt is judged enough. +2. ⚠️ **F2 — The `--db` error does not say why the old contract is unknown, and its `--advance-ref` advice reads as "add it now", which does not help.** Same command. The `why` names the hash and directory but not the cause (the earlier `db update --db` run kept no snapshot because no ref was advanced). Re-running with `--advance-ref db` now fails identically. `→ Plan from an explicit origin: prisma migration plan --from ` does not apply: there is no migration or snapshot to name. The route that works, and that I verified, is not described: restore the old contract, `contract emit`, `db update --db --advance-ref ` (a no-op that stores the snapshot), restore the new contract, emit, then `db update --db --rename … --advance-ref `. The app upgrade instructions do say "pass `--advance-ref ` on the run before the rename"; the error should say the same. +3. ⚠️ **F3 — The MongoDB field-rename advice does not work, and a user following it ends with half-moved documents.** Command: `prisma db update --rename User.name:User.fullName` on a Mongo project whose `name` is `@unique`. Advice: run `db.getCollection("User").updateMany({}, { $rename: { "name": "fullName" } })`. On a clean database that command fails with `Document failed validation` (`additionalProperties: [ 'fullName' ]`, `missingProperties: [ 'name' ]`) because of the validator Prisma installed. With validation bypassed it fails with `E11000 duplicate key … index: name_1 dup key: { name: null }` after moving one document. Applying the plan first instead fails with `E11000 … index: fullName_1 dup key: { fullName: null }` after dropping `name_1`. No document was lost, but the end state is `[{"handle":"al","fullName":"alice"},{"name":"bob",…},{"name":"carol",…}]` with no index on either field. The advice also does not say whether to move documents before or after applying the plan (the collection advice does say "before"). The model-rename advice worked (see scenario 7). +4. ⚠️ **F4 — A refused statement still writes the auto-baseline package.** On an empty migrations directory with a `db` ref, `prisma migration plan --name r --rename User.name:User.fullName` on MongoDB fails with `MIGRATION.PLANNING_FAILED` ("nothing was planned") yet leaves `migrations/app/20261007T1015_baseline/{migration.ts,migration.json,ops.json}`. Same on SQLite with the scenario 5 refusal: `prisma migration plan --name tidy --rename Profile.name:Profile.fullName` → `PLANNING_FAILED`, and `migrations/app/20261007T1015_baseline` appears. The error does not mention it. The script says a statement that cannot be applied "fails before anything is written". An unresolvable statement (`--rename Profile:Nope`) does fail before the baseline is written. +5. ⚠️ **F5 — Re-running a rename that already happened gives advice that points the wrong way.** Command: `prisma db update --rename Profile:User --rename User.name:User.fullName`, run a second time (scenario 2). Output: `✘ [MIGRATION.STATEMENT_UNRESOLVED] Cannot resolve statement "--rename Profile:User"` / `why: The origin contract has no model "Profile" (models: __unbound__.Post, __unbound__.User).` / `→ Correct the names in the statement`. A reader can work out from the model list that the rename is done, but the message does not say that the database is already at the new names, and the advice tells them to change names that are correct. It should say the database already has `User`, so drop the statement. +6. ⚠️ **F6 — The internal namespace `__unbound__` appears in user-facing messages for SQLite and MongoDB.** Examples: `(models: __unbound__.Post, __unbound__.User)`, `The field "handle" already exists on the origin model "__unbound__.Profile"`, `An earlier statement already renames "__unbound__.Profile".`, `the origin contract has no namespace "Account" (namespaces: __unbound__)`, and `"namespaceId":"__unbound__"` in the JSON `refusedStatement` and `appliedStatements`. These projects have no namespaces; the names should print without one. +7. ⚠️ **F7 — Several statement errors say what is wrong but not what to type.** The advice line is the generic `→ Correct the statement` or `→ Correct the names in the statement` in each of these (scenario 3 and exploratory): + - `--rename Profile` → `The statement has no ":" between the old and new name.` No example of the form. + - `--rename Profile:User.fullName` → `The statement names a model on one side and a field on the other.` No example of either correct form. + - `--rename User:Profile` (backwards) → `The origin contract has no model "User" (models: public.Post, public.Profile).` Both names exist, one in each contract; a hint that the order is `old:new` would fix it at once. + - Swapping two fields, `--rename Profile.name:Profile.handle --rename Profile.handle:Profile.name` → `The field "handle" already exists on the origin model …`. The names are correct; swaps are not supported, and nothing says so or offers a route. + - Field statement before its model statement, `--rename Account.name:Account.fullName --rename Profile:Account` → the `why` ends `no earlier statement renames a model to it`, which hints at the cause, but the advice still says to correct the names rather than to put the model statement first. + - Identity statements, `--rename Profile:Profile` and `--rename User.name:User.name` → `… already exists in the origin contract, so it cannot be the new name`. Correct but confusing for a statement that renames nothing. + - By contrast the field-on-old-model error (`--rename Profile.name:User.fullName`) is exemplary: it quotes the input, explains, and gives the exact two statements to type. +8. ⚠️ **F8 — The `@@map` refusal says what to do but not how.** Command: `prisma migration plan --name tidy --from 20261007T1005_initial --rename User.name:User.fullName` (scenario 5). Advice: `rename the table by hand first, with ...this.renameTable({ schema: "public", table: "users", to: "app_users" }) in its own migration.ts, then plan the field rename on top of it.` It does not say that this needs an intermediate contract with only the `@@map` change, emitted, and `prisma migration new --name … --from ` to get a `migration.ts`. With those steps the advice works and keeps the rows (verified below). +9. ⚠️ **F9 — When a plan with a statement also has placeholders, the human output drops the statement report and the JSON is inconsistent.** Command: `prisma migration plan --name ns7 --from 20261007T1002_initial --rename Profile:User` with the field rename left unstated. Human output shows only `⚠ Planned migration with placeholder(s)` and the next steps: no operation list and no `Statements applied` block, so nothing tells the user that column `name` is dropped. JSON: `operations` is `[]` while `appliedStatements` lists one statement with 3 `operationIds` that appear in no `operations` entry. +10. ⚠️ **F10 — The extension upgrade instructions leave a planner author unsure what to do with non-empty `statements`.** File: `upgrade-instructions/pending/migration-statement-renames/extension/instructions.md`. It says to pass `statements: []` at call sites and that `MongoMigrationPlanner` refuses non-empty statements, but not what a third-party planner that does not implement renames must do when it receives some: ignore them, or return a `statementRefused` conflict with `refusedStatement` set. It then says of `refusedStatement` that "nothing needs to set it", which suggests ignoring is fine. It also does not give import paths for `ResolvedMigrationStatement`, `AppliedMigrationStatement` and `PlanOrigin` (only `planOriginOf`). The `planner-success-applied-statements` detection skips a whole file once `appliedStatements` appears anywhere in it, and misses files that return `{ kind: 'success' }` without naming a `MigrationPlanner*` type; neither miss is called out. The misses for `plan(options)` are called out well. +11. ⚠️ **F11 — The skill's MongoDB workaround does not match the CLI's, and the new error codes are not in the skill's code table.** `skills/prisma-8/references/migrations.md` § "What Prisma 8 doesn't do yet" says for MongoDB renames "Workaround: hand-edit `migration.ts`, or use a keep / backfill / drop pattern". The Mongo factory list in the same file has no rename operation, and the CLI's own advice is to rename in `mongosh`. The "Diagnostic codes you route on" table has no `MIGRATION.STATEMENT_INVALID`, `MIGRATION.STATEMENT_UNRESOLVED` or `MIGRATION.STATEMENT_ORIGIN_UNKNOWN`. Minor. + +## Setup + +- `git branch --show-current` → `tml-3475-statement-renames`; `git status --short` → clean at the start. During the run `projects/migration-statements/plan.md` became modified by someone else; I did not touch it. +- `mise exec -- pnpm install --frozen-lockfile` → exit 0. `mise exec -- pnpm build` → `87 successful, 87 total` (all cached), exit 0. +- CLI: `packages/1-framework/3-tooling/cli/dist/bin.mjs`, called through `wip/qa/prisma` (a wrapper that runs `mise exec -- node "$@"`). Human output with `--format human`; prompts under a pseudo-terminal with `script`. +- Projects: one per scenario under `wip/qa/`. Each has the config `prisma orm init --target postgres --authoring psl` writes (`defineConfig` from `@prisma/orm-postgres/config`; `@prisma/orm-sqlite/config` and `@prisma/orm-mongo/config` for the others), resolved through `wip/qa/node_modules` symlinks to the workspace facade packages and `@prisma/cli-engine`. +- Postgres: `createDevDatabase()` from `@repo/test-utils` (the `@prisma/dev` server the journeys use), one server per scenario, started by `wip/qa/pg-server.mjs`. MongoDB: `MongoMemoryReplSet` from `mongodb-memory-server`, set up as in `test/integration/test/cli.mongo-db-verify.e2e.test.ts`. All servers were stopped at the end. + +## 1. Postgres, `migration plan` then `db migrate` — ✅ + +Contract "from" (`wip/qa/s1/from.prisma`): `Profile { id; name @unique; handle; tenantId @map("tenant_id"); posts Post[]; @@index([handle]); @@index([name, handle]); @@check(name: "handle_present", …); @@rls }`, `Post` with a foreign key to `Profile`, and `policy_select tenant_read` on `Profile`. "To" renames the model to `User` and `name` to `fullName`. + +``` +$ prisma contract emit && prisma migration plan --name initial && prisma db migrate +✔ Applied 1 migration(s) (10 operation(s)) across 1 contract space(s) +$ psql … INSERT INTO "Profile" … 3 rows; INSERT INTO "Post" … 2 rows +$ cp to.prisma contract.prisma && prisma contract emit +``` + +`prisma migration plan` without `--from` refused with `MIGRATION.PLAN_ORIGIN_UNKNOWN` (the `db` ref is not set by `db migrate`; this is existing, documented behaviour), so I followed its advice and named the origin: + +``` +$ prisma migration plan --name tidy --from 20261007T1002_initial --rename Profile:User --rename User.name:User.fullName +✔ Planned 6 operation(s) + +migrations/app/20261007T1003_tidy +├─ Rename table "Profile" to "User" +├─ Rename primary key "Profile_pkey" to "User_pkey" on "User" +├─ Rename index "Profile_handle_idx_b5b249e4" to "User_handle_idx_b5b249e4" on "User" +├─ Rename column "User"."name" to "fullName" +├─ Rename unique constraint "Profile_name_key" to "User_fullName_key" on "User" +└─ Rename index "Profile_name_handle_idx_f7a67607" to "User_fullName_handle_idx_46490beb" on "User" + +Statements applied +├─ rename model "Profile" to "User" (3 operations) +└─ rename field "User.name" to "User.fullName" (3 operations) +… +ALTER TABLE "public"."Profile" RENAME TO "User"; +ALTER TABLE "public"."User" RENAME CONSTRAINT "Profile_pkey" TO "User_pkey"; +ALTER INDEX "public"."Profile_handle_idx_b5b249e4" RENAME TO "User_handle_idx_b5b249e4"; +ALTER TABLE "public"."User" RENAME COLUMN "name" TO "fullName"; +ALTER TABLE "public"."User" RENAME CONSTRAINT "Profile_name_key" TO "User_fullName_key"; +ALTER INDEX "public"."Profile_name_handle_idx_f7a67607" RENAME TO "User_fullName_handle_idx_46490beb"; +``` + +Every operation in `ops.json` is `widening`. The human output does not print the class; the JSON and `ops.json` do. JSON `appliedStatements` (same plan, run in a copy of the project): + +``` +[{ "statement": {"kind":"rename","entity":"model","from":{"namespaceId":"public","model":"Profile"},"to":{"namespaceId":"public","model":"User"}}, + "operationIds": ["renameTable.Profile","primaryKey.public.User.Profile_pkey.rename","index.public.User.Profile_handle_idx_b5b249e4.rename"], + "description": "rename model \"Profile\" to \"User\"" }, + { "statement": {"kind":"rename","entity":"field","from":{"namespaceId":"public","model":"Profile","field":"name"},"to":{"namespaceId":"public","model":"User","field":"fullName"}}, + "operationIds": ["renameColumn.User.name","unique.public.User.Profile_name_key.rename","index.public.User.Profile_name_handle_idx_f7a67607.rename"], + "description": "rename field \"User.name\" to \"User.fullName\"" }] +``` + +`migration.ts` is what I would write by hand: + +```ts + ...this.renameTable({ schema: 'public', table: 'Profile', to: 'User' }), + ...this.renameColumn({ schema: 'public', table: 'User', column: 'name', to: 'fullName' }), +``` + +`node migrations/app/20261007T1003_tidy/migration.ts` → `Wrote ops.json + migration.json`; both files byte-identical to what `plan` wrote. + +``` +$ prisma db migrate +✔ Applied 1 migration(s) (6 operation(s)) across 1 contract space(s) +$ psql + id | fullName | handle | tenant_id (3 rows: alice, bob, carol) + old_table: (null) + Post_profileId_fkey | f | "Post" | "User" + User_fullName_key u, User_pkey p, handle_present_e0cbe50d c + indexes: User_fullName_handle_idx_46490beb, User_fullName_key, User_handle_idx_b5b249e4, User_pkey + policy tenant_read_f8d5e783 on User; relrowsecurity t + Post rows: (10,1), (11,3) +$ prisma migration plan --from 20261007T1003_tidy +✔ No changes detected +$ prisma db verify --schema-only +✔ Database schema satisfies contract +``` + +## 2. SQLite, `db update` twice — ⚠️ + +Same contracts without the check and policy (`wip/qa/s2`). + +``` +$ prisma db update +✔ Applied 5 operation(s) across 1 contract space +$ sqlite3 dev.db "INSERT INTO Profile … 3 rows; INSERT INTO Post … 2 rows" +$ cp to.prisma contract.prisma && prisma contract emit +$ prisma db update --rename Profile:User --rename User.name:User.fullName (pseudo-terminal) +✔ Applied 6 operation(s) across 1 contract space +├─ Rename table Profile to User +├─ Drop index Profile_handle_idx_b5b249e4 on User +├─ Create index User_handle_idx_b5b249e4 on User +├─ Rename column name on User to fullName +├─ Drop index Profile_name_handle_idx_f7a67607 on User +├─ Create index User_fullName_handle_idx_46490beb on User +Statements applied +├─ rename model "Profile" to "User" (3 operations) +└─ rename field "User.name" to "User.fullName" (3 operations) +``` + +No prompt. Rows survive (`alice`, `bob`, `carol` under `fullName`); `Post` keeps its rows and its foreign key now references `"User"`. + +Second run of the same command: + +``` +$ prisma db update --rename Profile:User --rename User.name:User.fullName +✘ [MIGRATION.STATEMENT_UNRESOLVED] Cannot resolve statement "--rename Profile:User" + why: The origin contract has no model "Profile" (models: __unbound__.Post, __unbound__.User). +→ Correct the names in the statement +[exit 2] +``` + +It fails before touching anything (row count still 3). It does not say the rename already happened, and it shows `__unbound__` (F5, F6). `prisma db update` with no statements then reports the database already matches. + +## 3. Wrong statements — ⚠️ + +On the scenario 1 project after emitting "to" and before planning the rename, with `--from 20261007T1002_initial` (without `--from` every run stops first at `PLAN_ORIGIN_UNKNOWN`, including the malformed `--rename Profile`). + +``` +$ prisma migration plan --name tidy --from 20261007T1002_initial --rename Profile.name:User.fullName +✘ [MIGRATION.STATEMENT_INVALID] Invalid statement "--rename Profile.name:User.fullName" + why: "public.Profile" is not a model of the destination contract, and a field cannot move between models. Name a field's model as the destination contract names it: --rename User.name:User.fullName. The model also needs its own statement, --rename Profile:User, before it. +→ Correct the statement + +$ … --rename Profile:Nope +✘ [MIGRATION.STATEMENT_UNRESOLVED] Cannot resolve statement "--rename Profile:Nope" + why: The destination contract has no model "Nope" (models: public.Post, public.User). +→ Correct the names in the statement + +$ … --rename Profile +✘ [MIGRATION.STATEMENT_INVALID] Invalid statement "--rename Profile" + why: The statement has no ":" between the old and new name. +→ Correct the statement + +$ … --rename Profile:User.fullName +✘ [MIGRATION.STATEMENT_INVALID] Invalid statement "--rename Profile:User.fullName" + why: The statement names a model on one side and a field on the other. +→ Correct the statement + +$ … --rename User:Profile +✘ [MIGRATION.STATEMENT_UNRESOLVED] Cannot resolve statement "--rename User:Profile" + why: The origin contract has no model "User" (models: public.Post, public.Profile). +→ Correct the names in the statement +``` + +All exit 2. Codes fit (`STATEMENT_INVALID` for form, `STATEMENT_UNRESOLVED` for names). Every message quotes the input, and the name errors list what was searched. Nothing was written under `migrations/` (file list identical before and after). The first error tells you exactly what to type; the no-colon, model-to-field and backwards errors do not (F7). JSON carries `meta: { statement, verb }`. + +Further syntax probes, all clear: `"Profile:User:Extra"` → `The statement has more than one ":"`; `"a.b.c.d:a.b.c.d"` → `"a.b.c.d" has 4 names; at most three are allowed.`; `":User"` and `"Profile:"` → `"" has an empty name.`; `"Profile : User"` → `no model "Profile "` (the trailing space is visible in the quotes). + +## 4. `--db` without a snapshot — 🛑 + +`wip/qa/s4`: SQLite config with no `db.connection`. + +``` +$ prisma db update --db …/s4/other.db +✔ Applied 5 operation(s) across 1 contract space (no "Advanced ref" line; no migrations/ directory) +$ sqlite3 other.db "INSERT INTO Profile … 2 rows" +$ cp to.prisma contract.prisma && prisma contract emit +$ prisma db update --db …/s4/other.db --rename Profile:User +✘ [MIGRATION.STATEMENT_ORIGIN_UNKNOWN] Cannot resolve statements: the origin contract is unknown + why: No contract snapshot for hash "500374ff…f2c2" was found in …/wip/qa/s4/migrations/snapshots/500374ff…f2c2. +→ Run db update with --advance-ref so its contract snapshot is kept +→ Plan from an explicit origin: prisma migration plan --from +→ Or run the command without statements +[exit 2] +``` + +The hash and directory are named (also in JSON `meta.hash` and `meta.snapshotDirectory`). It does not say why the snapshot is missing. Following the first line now: + +``` +$ prisma db update --db …/other.db --rename Profile:User --advance-ref db +✘ [MIGRATION.STATEMENT_ORIGIN_UNKNOWN] … (identical) +``` + +Following the third line, previewed: + +``` +$ prisma db update --db …/other.db --dry-run +├─ Create table User +├─ Create index User_fullName_handle_idx_46490beb on User +├─ Create index User_handle_idx_b5b249e4 on User +├─ Recreate table Post +└─ ⚠ Drop table Profile +⚠ This migration contains destructive operations that may cause data loss. +``` + +The route that works (verified; rows `alice`, `bob` kept under `User`): restore "from", emit, `prisma db update --db …/other.db --advance-ref prod` (`✔ Database already matches contract`, `✔ Advanced ref "prod"`, snapshot written), restore "to", emit, `prisma db update --db …/other.db --rename Profile:User --rename User.name:User.fullName --advance-ref prod` → `Rename table Profile to User`, `Rename column name on User to fullName`. Findings F1 and F2. + +## 5. `@@map` changes, model name kept — ⚠️ + +`wip/qa/s5`, Postgres. "From": `model User { id; name @unique; handle; @@index([handle]); @@map("users") }`. "To": `fullName` and `@@map("app_users")`. Planned and migrated "from", inserted 3 rows. + +``` +$ prisma migration plan --name tidy --from 20261007T1005_initial --rename User.name:User.fullName +✘ [MIGRATION.PLANNING_FAILED] Migration planning failed + why: Cannot rename column "users"."name": the model's table changes from "users" to "app_users", and no statement renames the table +→ The column would be renamed on a table the plan then drops and creates under the new name, and a migration that only changes the table name is planned the same way. Instead, rename the table by hand first, with ...this.renameTable({ schema: "public", table: "users", to: "app_users" }) in its own migration.ts, then plan the field rename on top of it. + docs: https://docs.prisma.io/docs/orm/v8/reference/error-reference#MIGRATION.PLANNING_FAILED +[exit 2] +``` + +Refused; nothing written. Following the advice (my reading of "how"): + +``` +$ (contract with only @@map("app_users")) prisma contract emit +$ prisma migration new --name rename-table --from 225d3d03…abe0 +✔ Scaffolded migration at migrations/app/20261007T1006_rename_table +$ (edit migration.ts: ...this.renameTable({ schema: "public", table: "users", to: "app_users" })) +$ node migrations/app/20261007T1006_rename_table/migration.ts + widening | Rename table "users" to "app_users" + widening | Rename primary key "users_pkey" to "app_users_pkey" on "app_users" + widening | Rename unique constraint "users_name_key" to "app_users_name_key" on "app_users" + widening | Rename index "users_handle_idx_b5b249e4" to "app_users_handle_idx_b5b249e4" on "app_users" +$ (contract "to") prisma contract emit +$ prisma migration plan --name rename-field --from 20261007T1006_rename_table --rename User.name:User.fullName +├─ Rename column "app_users"."name" to "fullName" +└─ Rename unique constraint "app_users_name_key" to "app_users_fullName_key" on "app_users" +Statements applied +└─ rename field "User.name" to "User.fullName" (2 operations) +$ prisma db migrate +✔ Applied 2 migration(s) (6 operation(s)) across 1 contract space(s) +$ psql: app_users rows alice, bob, carol under fullName; users gone; indexes app_users_fullName_key, app_users_handle_idx_b5b249e4, app_users_pkey +$ prisma db verify --schema-only +✔ Database schema satisfies contract +``` + +The advice keeps the rows, but the message does not mention the intermediate contract or `migration new` (F8). + +## 6. A drop that loses no data — ✅ + +`wip/qa/s6`, Postgres. `Profile { id; name @unique; handle; bio String?; @@index([handle]) }`, bootstrapped with `db update`, 2 rows. + +``` +$ (remove @@index([handle])) prisma db update (pseudo-terminal) +✔ Applied 1 operation(s) across 1 contract space +├─ Drop index "Profile_handle_idx_b5b249e4" +``` + +No prompt. Rows unchanged. + +``` +$ (also remove bio) prisma db update (pseudo-terminal, typed "nope") +◆ Apply 1 destructive operation(s) to template1? Data they remove cannot be recovered: + - Drop column "bio" from "Profile" Type template1 to confirm. +``` + +Still asks; `bio` values kept after declining. `prisma db update --no-interactive` → `✘ [CLI.CONSENT_REQUIRED] … Grant it by passing --confirm template1.` Removing `@unique` instead runs non-interactively with no consent: `├─ Drop constraint "Profile_name_key" on "Profile"`. In exploration a changed check expression (`Drop check constraint` + `Add check constraint`) also ran with no consent. + +## 7. MongoDB — ⚠️ + +`wip/qa/s7`, `MongoMemoryReplSet`. `model Profile { id ObjectId @id @map("_id"); name String @unique; handle String }`, `db update`, 3 documents. + +``` +$ (model renamed to User) prisma db update --rename Profile:User +✘ [MIGRATION.PLANNING_FAILED] Migration planning failed + why: MongoDB does not apply rename statements in this release, so nothing was planned: rename model "Profile" to "User" +→ MongoDB cannot carry out rename statements in this release. Without the statement, a plan drops collection "Profile" with its documents and creates collection "User". To keep the documents, rename the collection by hand on each database before a plan made without the statement is applied there, for example with db.getCollection("Profile").renameCollection("User") in mongosh. A migration written by migration plan without the statement still drops "Profile" wherever it is applied, so check its operations first. +[exit 2] +``` + +JSON `meta.conflicts[0].kind` is `statementRefused` with `refusedStatement` set. Nothing written; `Profile` still holds 3 documents. The advice states what would happen without the statement. Following it (rename the collection, then `db update` with no statement): `--dry-run` shows `(no operations)`, the update reports `✔ Database already matches contract`, and `User` holds the 3 documents with index `name_1`. The model advice is good. + +Field rename (`name` → `fullName`, unique): + +``` +$ prisma db update --rename User.name:User.fullName +✘ [MIGRATION.PLANNING_FAILED] Migration planning failed + why: MongoDB does not apply rename statements in this release, so nothing was planned: rename field "User.name" to "User.fullName" +→ MongoDB cannot carry out rename statements in this release. Without the statement, the documents in collection "User" keep their values under "name", and nothing moves them to "fullName". To move them, update the documents by hand on each database, for example with db.getCollection("User").updateMany({}, { $rename: { "name": "fullName" } }) in mongosh, using the field names as they are stored. +``` + +Following it fails as described in F3: + +``` +$ (clean database) updateMany({}, { $rename: { name: 'fullName' } }) +MongoServerError: Document failed validation additionalProperties: [ 'fullName' ] missingProperties: [ 'name' ] +$ (same, bypassDocumentValidation) +MongoServerError: E11000 duplicate key error collection: qa2.User index: name_1 dup key: { name: null } +$ prisma db update --no-interactive --confirm qa2 +✘ [CLI.UNEXPECTED] Unexpected error + why: Unexpected error during db update: Index build failed: … E11000 duplicate key error collection: qa2.User index: fullName_1 dup key: { fullName: null } +$ documents: [{"handle":"al","fullName":"alice"},{"name":"bob","handle":"bo"},{"name":"carol","handle":"ca"}] indexes: _id_ +``` + +`prisma migration plan --rename …` on MongoDB is refused the same way, but writes an auto-baseline first (F4). + +## 8. Extension upgrade instructions — ⚠️ (judgement) + +Read as the author of a third-party SQL target's planner. Against the oracle: + +- I can list the types to change and what to put there: `MigrationPlanner.plan` options gain `statements` (pass `[]`, forward when delegating) and `origin` (`planOriginOf(fromContract)`, or `null` for `db init`/`db update`; implementations stamp `options.origin?.storageHash ?? null`); `MigrationPlannerSuccessResult` gains `appliedStatements` (`[]`); `MigrationPlannerConflict` gains optional `refusedStatement`; `plannerSuccess(plan, [], warnings?)`; `planFieldEventOperations` gains `tableRenames: []`, `columnRenames: []`; `SqlPlannerConflictKind` gains `'statementRefused'`; `planMigration` input gains `appSpace`; `PerSpacePlan` gains `appliedStatements`. This part is clear. +- Detection misses: the `plan(options)` miss is stated for both `statements` and `origin`. The `appliedStatements` detection misses are not stated. +- Not answered: what my planner must do when `statements` is non-empty and it does not implement renames (F10). + +## 9. Skill replay — ✅ against the oracle (judgement) + +Question: "rename the `email` field on `User` to `emailAddress` without losing data". `skills/prisma-8/references/migrations.md` Common Pitfall 8 says to state the rename on the command line, gives `migration plan --name --rename User:Person --rename Person.email:Person.emailAddress` "or the same `--rename` flags on `db update`", and says "A field is named through its model as the new contract names it". From that an agent writes `--rename User.email:User.emailAddress`. § "What Prisma 8 doesn't do yet" says MongoDB fails with `MIGRATION.PLANNING_FAILED` and plans nothing. Hand-editing `migration.ts` with `...this.renameTable(...)` / `...this.renameColumn(...)` stays as the fallback for renames statements do not cover. All three parts of the oracle hold. Minor gap in F11. (Pitfalls are numbered out of order, 11 sits between 7 and 8; cosmetic.) + +## 10. Exploratory — ⚠️ + +Probes (SQLite through `db update` unless noted; projects under `wip/qa/s10/`): + +| Probe | Result | +| ----- | ------ | +| Case-only: `--rename Profile:PROFILE --rename PROFILE.name:PROFILE.Name` | ✅ `Rename table Profile to PROFILE`, `Rename column name on PROFILE to Name`; rows kept | +| Model plus two fields on it | ✅ 3 renames, rows kept | +| Swap two fields in one run | Refused (`already exists`), advice generic (F7) | +| Same model renamed twice | `STATEMENT_INVALID … An earlier statement already renames "__unbound__.Profile".` (F6) | +| Chain `Profile:X` then `X:Account` | `STATEMENT_UNRESOLVED`, destination has no `X`; fine | +| Field statement before model statement | Refused; hint in `why`, advice generic (F7) | +| Relation field `Post.profile:Post.owner`, back-relation `Profile.posts:Profile.articles` | ✅ `Statements applied … (no operations)`, nothing changed | +| Model and field renamed, `@@map`/`@map` kept | ✅ `(no operations)`, both statements reported with no operations | +| Model renamed and `@@map` changed | ✅ `Rename table profiles to users` | +| Model renamed, field keeps its name but `@map` changes | Column planned as drop + add, consent asked; `--rename User.name:User.name` is refused; no route offered (folded into F7) | +| Rename + type change String → Int | Rename then `⚠ Recreate table`, destructive warning; expected | +| Rename + required → optional | ✅ rows kept | +| Explicit index names `map: "Profile_custom_idx"` | ✅ left alone; verify clean | +| `--from @empty --rename Profile:User` (Postgres plan) | `STATEMENT_UNRESOLVED … (models: (none))`; fine | +| `--to` a migration directory, emitted contract still "from" (Postgres plan) | ✅ statements resolve against the `--to` contract; renames planned | +| Rollback `--from tidy --to initial --rename User:Profile --rename Profile.fullName:Profile.name` | ✅ reverse renames | +| `public.Profile:public.User` | ✅; `public.Profile:auth.User` refused with a clear `why` | +| Misspelled field `User.nme`, wrong case `User.fullname` | Refused; lists the real fields; good | +| Model statement only, field rename unstated (Postgres plan) | Placeholder plan; statement report missing (F9) | +| `db update --rename` on Postgres with RLS, check, FK | ✅ same 6 renames as scenario 1, `--dry-run` shows `Statements applied`, verify clean | +| Postgres enum + named check whose expression uses the renamed column | ✅ `Rename check constraint "Member_role_check_ddb31015" …`, check on the column dropped and re-added without consent; rows kept | +| Postgres `@default(autoincrement())` model rename | ✅ works; the sequence keeps the old name `Thing_id_seq` (cosmetic) | +| Rename plus an unrelated column drop | ✅ consent still required for the drop only | +| `migration plan --rename` with an empty migrations directory and a planner refusal | Auto-baseline written (F4) | +| `db update --json` | `appliedStatements` entries are `{ statement, operationIds, description }` | + +Observations that are outside this slice or not defects: + +- Every Postgres `db update` in a `namespace public` project prints `⚠ Planner warnings - control policy suppressed: namespace "__unbound__" — namespace '__unbound__' has effective control 'external' but declared 'external'`, with or without statements. Not caused by `--rename`, but it is noise with an internal name. +- `db update` prints `Introspecting database schema` / `Planning migration` twice, with or without statements. +- A plan with a model statement and an unstated field rename orders `dropColumn name` before `addColumn fullName` and its backfill placeholder, so the backfill cannot read the old column. Existing planner order for drop and add; worth a look separately. +- A failed Mongo `db update` leaves already-applied DDL in place (documented as non-transactional). +- In JSON, a field statement's `statement.from.model` is the origin model (`Profile`) while `description` uses the destination model (`User.name`). Coherent, but two namings of the same thing. + +## Cleanup + +All `@prisma/dev` servers and the Mongo replica set were stopped. `wip/qa/` is left in place: projects `s1`–`s7`, `s7b`, `s10/*`, helper scripts (`prisma`, `pg-server.mjs`, `mongo-server.mjs`, `mongo-*.mjs`, `s10/probe-sqlite.sh`) and logs. diff --git a/projects/migration-statements/manual-qa.md b/projects/migration-statements/manual-qa.md new file mode 100644 index 000000000000..4210f5f5109a --- /dev/null +++ b/projects/migration-statements/manual-qa.md @@ -0,0 +1,83 @@ +# Manual QA — slice 1, rename statements (TML-3475) + +> **Be the user.** Run the published CLI against real databases in a scratch project, read every message as someone who has never seen this feature, and judge what the journeys cannot: whether the errors tell you what to type next, whether the output explains itself, and whether anything loses data. +> +> **Out of scope.** Do not re-run `pnpm test`, the journeys, or CI lints. Do not read the source to decide whether behaviour is right; decide from what the commands print and what the database holds. +> +> **Spec:** `projects/migration-statements/slices/renames/spec.md` · **Project spec:** `projects/migration-statements/spec.md` + +## What this script is testing + +**The problem.** Renaming a model or field in the contract source used to plan a dropped table plus a created one, and rows were lost after one consent prompt. + +**What changed.** `prisma migration plan` and `prisma db update` accept `--rename :` for models and fields, in contract names. The planner renames the table or column and every constraint and index named after it, and reports `Statements applied`. A statement it cannot resolve or apply fails before anything is written. `db update` finds the old contract through the snapshot of the database's marker. Drops that lose no data (indexes, constraints, checks, policies, defaults, native enum types, disabling RLS) no longer ask for consent. MongoDB refuses every statement in this release. + +**Audiences.** End users of the CLI (scenarios 1 to 7). Extension authors who implement a migration planner (scenario 8). Agents using the `prisma-8` skill (scenario 9). + +## Scenarios + +| # | Scenario | What it proves | Isolation | +| - | -------- | -------------- | --------- | +| 1 | Rename a model and a field through `migration plan`, then `migrate`, on Postgres | Rows survive; names, constraints, indexes, check and policy follow; output explains itself | tmpdir | +| 2 | The same through `db update` on SQLite, then run it again | No prompt; rows survive; the second run fails with a message that says why | tmpdir | +| 3 | Type the statements wrong, once each way | Each error says what was searched, what was found, and what to type instead | tmpdir | +| 4 | `db update --db ` with a statement and no snapshot | The error names the hash and directory and tells you about `--advance-ref` | tmpdir | +| 5 | A model that keeps its name but changes `@@map`, with a field statement | The refusal tells you a safe route; nothing is dropped | tmpdir | +| 6 | Drop an index only, through `db update`, on Postgres | No consent prompt for a drop that loses no data | tmpdir | +| 7 | `--rename` on a MongoDB project | Refused; nothing planned; the advice does not lead to data loss | tmpdir | +| 8 | Read the extension upgrade instructions as a planner author **(judgement)** | You know exactly what to change in a planner you maintain | read-only | +| 9 | Ask the skill how to rename a field **(judgement)** | `skills/prisma-8/references/migrations.md` leads to `--rename` in the right form, and to the hand-written route as the fallback | read-only | +| 10 | Exploratory: 20 minutes of statement combinations | Anything the scenarios missed: case-only renames, two statements on one model, a relation field, `@@map` kept, `--to`, `--from @empty` | tmpdir | + +## Pre-flight + +1. `git branch --show-current` is `tml-3475-statement-renames`; `git status --short` shows no source changes. +2. `mise exec -- pnpm install --frozen-lockfile` and `mise exec -- pnpm build`. +3. Create scratch projects with `prisma init` from the built CLI (or copy `test/integration/test/fixtures/cli/cli-e2e-test-app`), one per scenario, under `wip/qa/`. Postgres: a local `@prisma/dev` server or PGlite through the dev server, as the journeys use. MongoDB: `mongodb-memory-server`. + +## Scenario details + +### 1. Postgres, `migration plan` then `migrate` + +Contract "from": `Profile { id Int @id; name String @unique; handle String; @@index([handle]) }`, a `Post` with a foreign key to `Profile`, a check on `Profile`, an RLS policy. Plan and migrate it, insert three rows. Change to "to": model `User`, field `fullName` (keep the unique and index on it). Run `prisma migration plan --name tidy --rename Profile:User --rename User.name:User.fullName`. + +- Read the human output. Do the operations read as renames, all `widening`? Does `Statements applied` say what it did with operation counts? +- Open the written `migration.ts`. Is it what you would have written by hand (`renameTable`, `renameColumn`)? +- `prisma migrate`, then query the database: rows present, constraint and index names, policy, check, foreign key from `Post`. +- `prisma migration plan` again: nothing to plan. `prisma db verify --schema-only`: clean. + +### 2. SQLite, `db update` twice + +Same contracts without the check and policy. `prisma db update` on "from", insert rows, swap to "to", `prisma db update --rename Profile:User --rename User.name:User.fullName`. No prompt; rows survive. Run the same command again: it fails. Does the message make it obvious that the rename already happened? + +### 3. Wrong statements + +On the scenario 1 project before migrating the rename, try: `--rename Profile.name:User.fullName` (old model named on the field side); `--rename Profile:Nope`; `--rename Profile` (no colon); `--rename Profile:User.fullName` (model to field); `--rename User:Profile` (backwards). For each: is the error code right, does the message quote what you typed, name what it searched and found, and tell you what to type instead? Was anything written under `migrations/`? + +### 4. `--db` without a snapshot + +On a fresh SQLite project, `prisma db update --db ` (no `--advance-ref`) on "from", swap to "to", `prisma db update --db --rename Profile:User`. Read the error. Does it explain why the old contract is unknown and that `--advance-ref ` on the earlier run would have kept it? + +### 5. `@@map` changes, model name kept + +"From": `model User { ...; @@map("users") }`. "To": `@@map("app_users")` and a field renamed. `prisma migration plan --rename User.name:User.fullName`. Is it refused? Does the advice (rename the table by hand with `renameTable` first) lead to a migration that keeps the rows? Follow it and check. + +### 6. A drop that loses no data + +Postgres project with `@@index([handle])`. Remove the index from the contract and run `prisma db update` with no flags in a terminal. No consent prompt should appear. Compare with removing a field: that still asks. + +### 7. MongoDB + +Mongo project with a `Profile` collection holding documents. Rename the model in the contract. `prisma db update --rename Profile:User`. Refused with `MIGRATION.PLANNING_FAILED`; nothing planned. Read the advice: does following it keep the documents? Do not run without `--rename` to find out; the advice must say what would happen. + +### 8. Extension upgrade instructions (judgement) + +Read `upgrade-instructions/pending/migration-statement-renames/extension/instructions.md` as the author of a third-party SQL target's planner. Oracle: after reading, you can list every type you must change and what to put there, and you know which call shapes the detection misses. + +### 9. Skill replay (judgement) + +Read `skills/prisma-8/references/migrations.md` as an agent asked "rename the `email` field on `User` to `emailAddress` without losing data". Oracle: the text leads to `--rename User.email:User.emailAddress` on `migration plan` or `db update`, warns that MongoDB refuses it, and keeps hand-editing `migration.ts` as the fallback. + +## Report + +Write `projects/migration-statements/manual-qa-reports/-.md`: one section per scenario with what you ran, what you saw (paste outputs), and a verdict (✅ / ⚠️ / 🛑 Blocker). File every 🛑 and ⚠️ as a numbered finding with the exact command and output. diff --git a/projects/migration-statements/plan.md b/projects/migration-statements/plan.md new file mode 100644 index 000000000000..c7395c5741c6 --- /dev/null +++ b/projects/migration-statements/plan.md @@ -0,0 +1,81 @@ +# Project plan — Migration statements + +**Spec:** [`spec.md`](./spec.md) · **Linear:** [Destructive changes need stated intent](https://linear.app/prisma-company/project/destructive-changes-need-stated-intent-7626c0107cd9), plan issue [TML-3474](https://linear.app/prisma-company/issue/TML-3474) · **Shaping PR:** prisma/orm#30604 · **Working branch:** `tml-3474-migration-statements` + +## Summary + +Four slices. The first three stack: the statement surface with renames, then the refusal and consent model, then the scaffolded verbs and the remaining nouns. The fourth, Mongo, builds on the second and runs in parallel with the third. Each slice is one PR, stacked on the working branch until the shaping PR merges and then retargeted to `main`. + +## Slices + +### Slice 1 — Statements on the command line, and renames of models and fields on Postgres and SQLite + +**Linear:** [TML-3475](https://linear.app/prisma-company/issue/TML-3475) · **Folder:** `slices/renames/` + +**Outcome.** Both commands accept `--rename old:new` for models and fields, with a model rename across namespaces resolved but refused until slice 3. The framework parses each statement, resolves it against the origin and destination contracts, and applies it in order to a working copy of the origin. The SQL planners emit the table or column rename and every companion rename, with names from the destination contract. `db update` resolves its origin contract from the marker hash through the snapshot store and fails every rename when it cannot. An unusable statement is an error. Non-data drops are widening. The written migration is what a user could write by hand. + +**Builds on.** The planner substrate from prisma/orm#30570, without its contract section. + +**Hands to.** A statement type the framework owns, parsed and resolved, delivered to every family planner as resolved entities; the working-copy mechanism statements apply to; the origin-contract resolution for `db update`; a column rename operation on both SQL targets. + +### Slice 2 — Both commands refuse data loss by default, and `--delete` is the per-operation consent + +**Linear:** [TML-3476](https://linear.app/prisma-company/issue/TML-3476) · **Folder:** `slices/refusal/` + +**Outcome.** `migration plan` refuses any plan that loses data, with the error `db update` uses. The refusal lists each destructive operation with the statements that resolve it. `--delete` consents to one operation, for namespaces, models and fields, and replaces `--confirm` on both commands. The terminal consent question asks per operation. Upgrade fragments record both changes; the CLI README describes statements and consent. + +**Builds on.** Slice 1. Two things slice 1 leaves for it: the missing-origin check in the CLI statement resolver runs over the whole input and must move inside the per-statement loop so that `--delete` works without an origin contract; and the `@@map`-only rename gap in `deferred.md` must be decided before the refusal text is written. Decided 2026-10-07 on prisma/orm#30638: dropping a row-level-security policy and disabling row-level security stay `widening` (they lose no data), and slice 2 adds a separate consent question before `db update` applies an operation that widens who can read or write rows, answered per operation like the data-loss consent and refused in a non-interactive run unless consented. + +**Hands to.** The refusal shape every later verb hooks its statements into; the per-operation consent model; `--confirm` gone. + +### Slice 3 — Convert and backfill scaffold the placeholder migration, and the remaining nouns + +**Linear:** [TML-3477](https://linear.app/prisma-company/issue/TML-3477) · **Folder:** `slices/convert-backfill/` + +**Outcome.** `--convert` scaffolds the type change with the placeholder in the slot that carries the conversion, and `--backfill` the backfill transform; both refused on `db update`; the scaffolding stops being automatic. `--rename` on enum values, namespaces and value object fields, a model move across namespaces (`alter table set schema` on Postgres, deferred from slice 1 on 2026-10-06 because no operation existed for it), and `--convert` on a variant, plan the row updates, the JSON rewrites and the schema rename. `--delete` on an enum value nulls where nullable, else refuses. + +**Builds on.** Slice 2. + +**Hands to.** Project close-out for the SQL targets. + +### Slice 4 — The same statements on MongoDB + +**Linear:** [TML-3478](https://linear.app/prisma-company/issue/TML-3478) · **Folder:** `slices/mongo/` + +**Outcome.** The Mongo planner takes the same resolved statements: collection rename, document rewrites for field and value object field renames, drops and unsets for deletes, a data transform scaffold for convert, under the slice 2 refusal and consent model. No family vocabulary enters the framework. + +**Builds on.** Slice 2. + +**Hands to.** Project close-out for Mongo. + +## Stretch goal — interactive statements when a human runs the command + +Recorded 2026-10-06 at the operator's request. This is the eventual direction, and the slices build with it in mind so that it drops in on top of their mechanisms; it is not a slice of its own, and it is built once the mechanisms it needs exist (after slice 2 at the earliest). + +When `migration plan` or `db update` detects a destructive operation and no statement covers it, the command today refuses and prints the statements that would resolve it. The stretch goal: when the command is run by a human in a terminal (stdin and stdout are a TTY, and no `--json` or similar non-interactive flag is set), the refusal becomes an interactive prompt that asks, per destructive operation, what the user means: rename it to a name they type, or delete it. Each answer is exactly the statement the refusal would have printed, applied in order, and the plan then proceeds as if the statements had been given on the command line. In a script, an agent, or CI (no TTY) the behaviour stays as it is: refuse and print the statements. + +Rules that carry over unchanged: the prompt never guesses or proposes a rename candidate; it asks. The statements are not recorded anywhere; the migration file or the database is the record. The consent model of slice 2 (`--delete` per operation) is what the "delete" answer maps to. + +What the slices do now so this drops in later (see the spec's cross-cutting requirement 12): + +- The refusal is a structured value first and text second: a list of destructive operations, each with the entity it touches in domain coordinates and the statements that would resolve it, rendered to the error message by the command. The prompt consumes the same list. +- Statement text is parsed, resolved and applied through one function the command calls, which takes the statement strings and the two contracts and does not care whether the strings came from the command line or from an answer typed at a prompt. Statements can therefore be added after the command has started and the plan re-run. +- The per-operation consent question that `db update` already asks in a terminal is the seed of this prompt, so slice 2 keeps it as a per-operation question rather than folding it into a yes/no over the whole plan. + +## Sequencing + +- **Stack:** slice 1 → slice 2 → slice 3. +- **Parallel:** slice 4 runs beside slice 3 once slice 2 has merged. + +## Dependencies + +- Nothing external. The shelved prisma/orm#30570 is a source to copy from, not a dependency; its branch stays as a draft. +- Users get the feature through the next published CLI release after slice 2 merges; slices 1 and 2 together are the minimum that changes behaviour a user sees. + +## Close-out (required) + +- [ ] Verify every project DoD item in [`spec.md`](./spec.md) +- [ ] Write the ADR and amend ADR 001, ADR 028 and the Data Contract and Migration System subsystem docs +- [ ] Migrate long-lived docs into `docs/` +- [ ] Strip repo-wide references to `projects/migration-statements/**` +- [ ] Delete `projects/migration-statements/` diff --git a/projects/migration-statements/slices/renames/dispatches/02-statement-grammar-resolver.md b/projects/migration-statements/slices/renames/dispatches/02-statement-grammar-resolver.md new file mode 100644 index 000000000000..3dbe35a35184 --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/02-statement-grammar-resolver.md @@ -0,0 +1,105 @@ +# Dispatch 2 — Statement grammar and resolver in the framework + +**Slice:** [`../spec.md`](../spec.md) · **Plan entry:** [`../plan.md`](../plan.md) § Dispatch 2 · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus + +## Task + +Add to the framework CLI package a parser for `--rename :` statement text and a resolver that turns parsed statements into resolved statements against an origin contract and a destination contract, following every rule in the slice spec's § Grammar and § Resolution. Add `statements: readonly ResolvedStatement[]` to the framework planner input and pass `[]` at every place the input is constructed. + +## Outcome + +After this dispatch, any later code can take the raw strings a user typed after `--rename`, and get back either an ordered list of resolved statements in domain coordinates, or one of the three errors the spec names, without any family or target code having run. The invariant: resolution speaks only contract vocabulary (namespace, model, field) and never storage (table, column, schema), so the framework stays family-blind and the same resolver serves Postgres, SQLite and later Mongo. + +## Scope + +**In** + +- `packages/1-framework/3-tooling/cli`: the statement parser, the resolver, their types, and their unit tests. Export the resolved statement type and the resolve function from the control API (the package's `src/control-api/` area, exported through whatever the package uses as its control API entry) so dispatch 5 can call it from the two commands. +- `packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts`: `MigrationPlannerInput` gains `statements: readonly ResolvedStatement[]`, required. Put the `ResolvedStatement` type where the planner input can see it (framework-components is below the CLI package, so the type lives in framework-components and the CLI package imports it; the parser and resolver stay in the CLI package). +- Every construction site of `MigrationPlannerInput` passes `statements: []` (use `git grep` to find them all, including tests and the Mongo target, which must still compile). +- The three error codes `MIGRATION.STATEMENT_INVALID`, `MIGRATION.STATEMENT_UNRESOLVED` and `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` declared wherever the CLI package declares its `MIGRATION.*` codes (start from `packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts` and `src/control-api/types.ts`). The error-reference doc entry for each code is dispatch 5's job unless the code declaration mechanism forces the doc entry now; if it does, write it. + +**Out** + +- Declaring `--rename` on `migration plan` or `db update` (dispatch 5). +- Anything in `packages/2-sql`, `packages/3-targets` or the Mongo target beyond adding `statements: []` where the planner input is built (dispatch 3 and 4). +- Resolving the origin contract for `db update` (dispatch 5). `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` is declared here and thrown by the resolver when it is given statements and no origin contract; the caller supplies the hash and directory for the message, so give the resolver a way to receive them. +- Changing the slice spec or plan. If a rule in the spec cannot be implemented as written, stop and report; do not improvise. + +## The rules to implement (from the slice spec, restated here so the brief is self-contained) + +Grammar: + +- A statement is one string `:`. Each side is a coordinate of one to three segments separated by `.`: `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`. +- Invalid, `MIGRATION.STATEMENT_INVALID`: no `:`, more than one `:`, an empty segment, more than three segments on a side. The message quotes the statement and lists the four accepted forms. +- Both sides must have the same depth once resolved: model to model or field to field. Model on one side and field on the other is `MIGRATION.STATEMENT_INVALID`. +- Names match exactly, including case. + +Resolution: + +- Resolution runs in the framework against the origin contract and the destination contract and produces domain coordinates: namespace id, model name, and field name when present. Never a table or column. +- A two-segment side is read both as `namespace.Model` and as `Model.field`. Exactly one reading resolving is that reading. Both resolving is `MIGRATION.STATEMENT_UNRESOLVED` naming both readings. Neither is `MIGRATION.STATEMENT_UNRESOLVED` naming both attempts. +- A side without a namespace resolves when exactly one namespace of the relevant contract declares the model; several is `MIGRATION.STATEMENT_UNRESOLVED` listing the qualified candidates. The default namespace is not special. +- Model rename `A:B`: `A` must be a model of the origin; `B` must be a model of the destination; `B` must not be a model of the origin; `A` must not be a model of the destination. Each failure is `MIGRATION.STATEMENT_UNRESOLVED` naming the contract searched and the names found there. `A` and `B` may differ in namespace, name or both. +- Field rename `M.a:N.b`: `M` and `N` must resolve to the same destination model; a field rename naming two different models is `MIGRATION.STATEMENT_INVALID` saying a field cannot move between models. The origin counterpart of that model is the origin model an earlier `--rename` in the same list renamed to it, or else the origin model at the same coordinate. `a` must be a field of the origin counterpart; `b` must be a field of the destination model; `b` must not be a field of the origin counterpart; `a` must not be a field of the destination model. +- A model coordinate may name a variant; it resolves like a model. A coordinate naming a value object, or a field of one, is `MIGRATION.STATEMENT_UNRESOLVED` saying value object renames are not supported in this release. A relation field resolves like any field. +- Statements resolve against the application contract space only; a name that exists only in an extension space is unresolved. +- Statements are resolved in the order given; the output preserves that order. +- With one or more statements and no origin contract: `MIGRATION.STATEMENT_ORIGIN_UNKNOWN`, before anything else. + +Resolved statement shape: ordered; each carries kind `rename`, entity `model` or `field`, the origin domain coordinate and the destination domain coordinate. Choose field names that read as domain vocabulary. The resolver must not depend on the SQL family or any target package. + +## Edge cases and dispositions + +| Case | Disposition | +| --- | --- | +| Swap `A:B` then `B:A` | Second statement unresolved: `A` exists in the origin. Resolution reads contracts, not a working copy. Test it. | +| `A:B` then `A.x:B.y` | Invalid: the second statement's two models do not resolve to the same destination model (`A` is not in the destination). Test it. | +| `A:B` then `B.x:B.y` | Valid: origin counterpart of `B` is `A`, so `x` is looked up in origin `A`. Test it. | +| Contract with one namespace, bare `Model` | Resolves through the sole namespace. | +| Contract with two namespaces both declaring `User`, bare `User` | Unresolved, candidates listed as `ns.User`. | +| `a.b` where `a` is a namespace with model `b` and also a model `a` with field `b` | Unresolved, both readings named. | +| Destructive git operations | Forbidden without orchestrator approval: no `git clean`, `git reset --hard`, `git stash drop/clear`, `git checkout -- .`, `rm -rf` on the worktree. The stash stack is shared with other sessions; never `git stash`. | +| F24 / F31 stale `dist` | Run `mise exec -- pnpm build` before trusting a red typecheck or test in this worktree; it was created fresh today. | +| F14 lint not run | `pnpm lint` per touched package is a separate CI job; run it. Typecheck must cover `test/**`. | +| F26 class, not instance | If the reviewer names a class of defect, sweep the whole diff for it. | + +## Where to look first + +- The slice spec § Grammar and § Resolution, and the project spec's § Place in the larger world for coordinates (ADR 221, ADR 224, ADR 223) and extension spaces. +- How the CLI package reads a contract's namespaces, models, fields, variants and value objects today: `packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts` and `migration-plan.ts` show where the origin and destination contracts are in hand. +- How `MIGRATION.*` error codes are declared and thrown: `src/utils/cli-errors.ts`, `src/control-api/types.ts`, and the existing `MIGRATION.DESTRUCTIVE_CHANGES` code as the worked example. +- The repo rules: `CLAUDE.md` golden rules (arktype not zod, no `any`, no bare `as` outside tests, `blindCast`/`castAs` from `@internal/utils/casts`, tests before implementation, test names omit "should", `ifDefined` for optional keys, `pathe` not `node:path`, test files under 500 lines). + +## Validation gate + +Run every command through `mise exec --` (for example `mise exec -- pnpm typecheck`). Save long output to a file under `wip/` and read the file; do not re-run to grep different lines. + +1. Pre-flight, before any change, to confirm the branch you inherit is green: `mise exec -- pnpm build`, then `mise exec -- pnpm typecheck`, then `mise exec -- pnpm --filter @internal/target-postgres --filter @internal/target-sqlite --filter @internal/family-sql test` (adjust the filter names to the real package names). Report the result as its own line. If something is red and it is not yours, report it and stop; do not fix it. +2. End of dispatch: `mise exec -- pnpm typecheck`; `mise exec -- pnpm --filter lint`; `mise exec -- pnpm --filter test`; `mise exec -- pnpm lint:deps` (imports change); `mise exec -- pnpm lint:framework-vocabulary` and report its count against the branch's count before your change (it must not rise). +3. Never run `pnpm test:integration`, `pnpm test:e2e` or `pnpm test:all` in full. + +## Completed when + +- The parser and resolver exist in the framework CLI package with unit tests covering every rule and every edge case in the two tables above, and the tests fail if the rule they cover is removed (check at least two by removing the rule and watching the test go red, then restore it). +- `MigrationPlannerInput.statements` exists and every construction site passes `[]`; the whole workspace typechecks. +- The three error codes are declared. +- No family or target vocabulary was added under `packages/1-framework` (the resolver names namespaces, models, fields; never tables, columns, schemas, collections). +- The validation gate is green, and the report lists each command and its result. +- Work is committed on `tml-3475-statement-renames` in small intent-named commits, each with `git commit -s --trailer "Signed-off-by: Will Madden "`, subject prefixed `TML-3475:`. No "Co-Authored-By" or "Generated with" lines. Do not push. + +## Operational + +- Heartbeat: append a line to `wip/heartbeats/implementer.txt` every 10 minutes or at each phase change, in the form `ts= phase= note=`. +- Time-box: about two hours. If you are past that with the resolver still incomplete, write a heartbeat saying so and finish the parser and the planner input change first. +- Halt and report instead of improvising when: a spec rule cannot be implemented as written; the contract types do not expose something the rules need (for example, which models are variants or value objects); a change outside the in-scope files is needed to compile. +- Stay inside this worktree: `/Users/wmadden/Projects/prisma/orm/.claude/worktrees/prisma-orm-pr-transcripts-7cfc03`. Do not read or write other checkouts. + +## Return shape + +1. What was built, by file, in three to eight lines. +2. The validation gate table: command, result. +3. The two "remove the rule, watch it fail" checks you did. +4. Decisions you made that the spec did not pin, each with the alternative you rejected. +5. Anything you could not do, and why. +6. The list of commits (`git log --oneline` of your commits). diff --git a/projects/migration-statements/slices/renames/dispatches/03-model-renames-on-sql-planners.md b/projects/migration-statements/slices/renames/dispatches/03-model-renames-on-sql-planners.md new file mode 100644 index 000000000000..4339d4b82c85 --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/03-model-renames-on-sql-planners.md @@ -0,0 +1,88 @@ +# Dispatch 3 — Model renames from statements on both SQL planners + +**Slice:** [`../spec.md`](../spec.md) · **Plan entry:** [`../plan.md`](../plan.md) § Dispatch 3 · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Builds on:** dispatches 1 and 2 + +## Task + +Make the Postgres and SQLite planners act on resolved `rename` statements whose entity is `model`: map each one to its storage effect, apply the effects in order to a working copy of the origin schema, run the existing diff on the adjusted schema, emit the rename call and its companions ahead of the diff's calls, refuse a target table whose effective control policy is not `managed`, and report every statement on the plan as `appliedStatements`. + +## Outcome + +After this dispatch, `planner.plan({ ..., statements })` with a model rename statement produces a plan whose first calls are the table rename and its companion renames (as the hand-written `renameTable` facade would produce them), followed by the ordinary diff with no drop and create for that table. The invariant: a statement is intent, and the planner never guesses — nothing about the rename is inferred from the diff; it comes only from the statement, and the result is the same plan a user could have written by hand in `migration.ts`. The framework stays family-blind: the statement arrives in domain coordinates and only the SQL family reads storage. + +## Scope + +**In** + +- `packages/2-sql/9-family/src/core/migrations/`: the mapping from a resolved model statement to a storage effect (compare the origin model's `storage.table` and `storage.namespaceId` with the destination model's; equal means no storage change), the `statementRejected` conflict kind, and the `appliedStatements` entry type with its description text (`rename model "Profile" to "User"`, using domain names; qualify with the namespace only when the contract has more than one). +- `packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts` and `.../sqlite/src/core/migrations/planner.ts`: build a working schema from `options.schema` (dispatch 1's `createWorkingSchema`), apply each storage effect in order through `postgresTableRenameCall` / `sqliteTableRenameCall` (so companions are computed against the working copy as earlier statements leave it), run the diff and every later consumer of `options.schema` against `workingSchema.current`, and put the rename calls and their companions first in the emitted call list. +- Control policy: before applying an effect, read the target table's effective control policy (`controlPolicyForCall` in the family, with the table node from `entityAt` on `contract.storage` and `contract.defaultControlPolicy`); anything but `managed` returns `plannerFailure([{ kind: 'statementRejected', summary, why, location }])` naming the table and the policy. Nothing is skipped silently. +- The plan output: `appliedStatements` on the framework `MigrationPlan` type (`packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts`), one entry per statement in order, each carrying the resolved statement, the family's description text and the number of operations it produced (zero when storage did not change). The Mongo planner and every test plan must still compile; Mongo passes an empty list. +- The slice spec's wording: replace `MigrationPlannerInput` with the real name of the planner's options type in `projects/migration-statements/slices/renames/spec.md` (one-line edit, own commit). +- Unit tests on both targets, in the style of the existing `rename-table-*.test.ts` and `planner.*.test.ts` files: a statement produces the rename and companions and no drop-and-create; `@@map` keeps the table name so the statement applies with zero operations; two statements compose in order (`Profile:User` then a second model rename) with companions computed after the first; a non-`managed` target table fails with `statementRejected`; `appliedStatements` is reported in order with the right counts; a plan with statements and no diff difference otherwise is empty apart from the renames. + +**Out** + +- Field statements and `renameColumn` (dispatch 4). +- The `--rename` flag, the origin contract for `db update`, printing `Statements applied` (dispatch 5). +- Statement resolution (dispatch 2 owns it; the planner receives resolved statements and never parses text). +- Any change under `packages/1-framework` beyond the `appliedStatements` type on the plan. + +## Where things are (from the orchestrator's survey of the branch) + +- Planner input: the inline options object of `MigrationPlanner.plan` at `control-migration-types.ts` (around lines 438–499). Dispatch 2 added `statements: readonly ResolvedStatement[]` to it. The SQL family's `SqlMigrationPlannerPlanOptions` in `packages/2-sql/9-family/src/core/migrations/types.ts` and the Postgres, SQLite and Mongo `plan` option types were NOT widened yet; this dispatch adds `statements` to the family and both SQL target option types (Mongo too if it must compile against the framework shape). +- The statement types, from dispatch 2: `packages/1-framework/1-core/framework-components/src/control/migration-statements.ts`, exported from `@internal/framework-components/control`. `ResolvedStatement = ResolvedModelRename | ResolvedFieldRename`; each has `kind: 'rename'`, `entity: 'model' | 'field'`, `from` and `to`. `ModelCoordinate = { namespace: NamespaceId, model }`; `FieldCoordinate` adds `field`. This dispatch handles `entity: 'model'` only and must leave `entity: 'field'` statements for dispatch 4 (decide and document whether the planner ignores them or fails on them until then; failing loudly is preferred). +- Postgres `planSql` runs `buildPostgresPlanDiff({ contract, actualSchema: options.schema, ... })` right after `PostgresDatabaseSchemaNode.assert(options.schema)`; `options.schema` is also read by `verifyPostgresNamespacePresence` and `relationalNamespaceNode`. Calls are assembled as `[...ordered.structural, ...indexRenamePartition.kept, ...ordered.policyCalls, ...fieldEventPartition.kept]`. +- SQLite `collectSchemaIssues` runs `buildSqlitePlanDiff` with `options.schema`; calls are assembled as `[...replacedIndexes.calls, ...result.value.calls, ...fieldEventOps]`. +- Working schema and rename calls: Postgres `working-schema.ts` (`createWorkingSchema`, `WorkingSchema.apply`, `SchemaTableRename { schemaName; from; to }`), `table-rename-calls.ts` (`postgresTableRenameCall({ previous, contract, rename: ResolvedTableRename, frameworkComponents })`, `emissionSchemaForNamespace`), `table-rename-constraint-renames.ts`; SQLite `working-schema.ts`, `table-rename-calls.ts`. Today only the facades (`postgres-migration.ts` `schemaAfterRenames`, `sqlite-migration.ts`) call them; the planners do not. +- Family: `resolve-table-rename.ts` (`ResolvedTableRename { namespaceId; from; to }`), `schema-tables.ts`, `plan-helpers.ts` (`plannerFailure`), `types.ts` (`SqlPlannerConflictKind` closed union, `SqlPlannerConflictLocation`), `control-policy.ts` (`controlPolicyForCall`). +- Storage bridge: `contract.domain.namespaces[ns].models[model].storage` as `SqlModelStorage { table; namespaceId; fields }` in `packages/2-sql/1-core/contract/src/types.ts`; `validateModelStorageReferences` in `packages/2-sql/1-core/contract/src/validators.ts` shows the read pattern. Postgres namespace id to DDL schema: `resolveDdlSchemaForNamespaceStorage` in `resolve-ddl-schema.ts`. +- From the dispatch 2 review: the Postgres planner forwards its options field by field into the issue planner (`planner.ts` around line 355 passes `fromContract: options.fromContract`), so `statements` is dropped silently unless forwarded explicitly. Widening `SqlMigrationPlannerPlanOptions` and the target option types with a required `statements` makes every direct `planner.plan({...})` call in the target tests fail to compile; add `statements: []` to them (a mechanical sweep, its own commit) rather than making the field optional. Repo rule: internal input types use required keys typed `| undefined`, not optional keys. +- Field events: `planFieldEventOperations({ priorContract: options.fromContract, ... })` on both planners. Dispatch 1's commit "pass field-event planning the applied renames" on the shelved branch (prisma/orm#30570, `f0926d96c5`) shows how a rename is handed to field-event planning so it does not fire a drop and add; copy that mechanism if dispatch 1 did not already bring it over. + +## Edge cases and dispositions + +| Case | Disposition | +| --- | --- | +| Origin and destination storage coordinates equal (`@@map` kept) | Applied, zero operations, reported. No call emitted. | +| Rename changes the DDL schema on Postgres (model moved across namespaces, `auth.User:public.User`) | Spec says `alter table set schema`. If the existing `RenameTableCall` and working schema cannot express a schema change, halt and report what would be needed; do not invent a composite operation. | +| Target table not `managed` | `statementRejected` conflict; planner failure; nothing else planned. | +| Two statements in order where the second depends on the first | Companions of the second computed on the working schema after the first. Test it. | +| Table does not exist in `options.schema` (origin schema lacks it; resolution passed because resolution reads contracts) | Planner failure with `statementRejected` naming the missing table; do not silently fall through to create. | +| Case-only rename on Postgres (`user:User`) | Planned as a rename; `detectTableNameCaseChanges` must not fire for a table a statement covers. Test it. | +| Destructive git operations | Forbidden: no `git clean`, `git reset --hard`, `git stash` of any kind, `git checkout -- .`, `rm -rf` on the worktree. | +| F24 / F31 stale `dist` | `mise exec -- pnpm build` before trusting a red gate. After changing exported types in the family or framework-components, build that package before typechecking targets. | +| F14 | Run `pnpm lint` per touched package; typecheck covers `test/**`. | +| F26 | A reviewer finding names a class; sweep the whole diff. | + +## Validation gate + +All through `mise exec --`. Save long output under `wip/` and read the file. + +- `pnpm typecheck` +- `pnpm --filter lint` +- `pnpm --filter test` (family, postgres target, sqlite target, framework-components, mongo target) +- `pnpm lint:deps` +- `pnpm lint:framework-vocabulary` count not above the branch's count before your change +- `pnpm fixtures:check` (plan rendering changed) +- The two existing rename journeys compile: `test/integration/test/cli-journeys/rename-table-migration*.e2e.test.ts` via the integration package's typecheck, without running them. +- Never the full integration, e2e or `test:all` suites. + +## Completed when + +- Every test listed under Scope exists on both targets and goes red when the behaviour it covers is removed (verify two of them that way and say which). +- A plan from a model statement contains the rename and companion calls first and no drop-and-create for the renamed table, on both targets. +- `appliedStatements` is on the plan type and populated by both SQL planners; Mongo compiles with an empty list. +- The spec wording fix is committed on its own. +- The validation gate is green; the report lists each command and its result. +- Commits are small, intent-named, `TML-3475:` prefixed, signed with `git commit -s --trailer "Signed-off-by: Will Madden "`, no attribution lines. Do not push. + +## Operational + +- Heartbeat to `wip/heartbeats/implementer.txt` on each phase change. +- Time-box about three hours. Halt and report when: the schema-move case needs an operation that does not exist; field-event planning cannot be told about a rename without touching `packages/1-framework`; a spec rule cannot be implemented as written. +- Stay inside the worktree. + +## Return shape + +As in dispatch 2: what was built by file; the gate table; the two red-check verifications; decisions the spec did not pin with the rejected alternative; what could not be done; the commit list. diff --git a/projects/migration-statements/slices/renames/dispatches/04-column-rename-and-field-statements.md b/projects/migration-statements/slices/renames/dispatches/04-column-rename-and-field-statements.md new file mode 100644 index 000000000000..52382bc1bb3a --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/04-column-rename-and-field-statements.md @@ -0,0 +1,92 @@ +# Dispatch 4 — Column rename operation and field statements + +**Slice:** [`../spec.md`](../spec.md) · **Plan entry:** [`../plan.md`](../plan.md) § Dispatch 4 · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Builds on:** dispatch 3 + +## Task + +Add a `renameColumn` operation to the Postgres and SQLite targets in the style of `renameTable` (facade method, planner call, DDL, prechecks and postchecks, working-schema step, companions), and make both planners apply a resolved `rename` statement whose entity is `field` through it, in statement order and on the working schema dispatch 3 introduced. A field statement whose storage does not change (same column name, or a relation field, which has no column) applies with zero operations and is still reported in `appliedStatements`. + +## Outcome + +After this dispatch, `--rename User.name:User.fullName` (resolved by dispatch 2, carried by dispatch 3's `statements` input) plans `renameColumn` plus the companion renames on both targets, and the diff that follows has no drop-and-add for that column. The invariant: what the statement produces is exactly what a user could write by hand as `...this.renameColumn({ table: 'User', column: 'name', to: 'fullName' })` in `migration.ts`, re-running that file reproduces `ops.json`, and every constraint and index whose name derives from the column ends up with the name the destination contract gives it, never a rebuild that loses data. + +## Scope + +**In** + +- **Postgres** (`packages/3-targets/3-targets/postgres/src/core/migrations/`): + - `RenameColumnCall(schemaName, tableName, oldColumnName, columnName, companions)` in `op-factory-call.ts`, `factoryName 'renameColumn'`, class `widening`, companions `RenameConstraintCall | RenameIndexCall`, `toOps` = own op then companions, `renderTypeScript` = `...this.renameColumn({ schema?, table, column, to })`; add it to the `PostgresOpFactoryCall` union and the op-factory exports; `issue-planner.ts` classifies it as `'column'`. + - `operations/columns.ts`: `renameColumn(schema, table, from, to, lowerer)` emitting `ALTER TABLE RENAME COLUMN "" TO ""`, precheck from present and to absent, postcheck to present and from absent, both via `columnExistsAst` from `contract-free/checks.ts`; id `renameColumn..`. + - Facade method `renameColumn({ schema?, table, column, to })` on `postgres-migration.ts`, same shape as `renameTable`: needs `startContract`, builds the working schema after `#renames`, resolves the rename, pushes the call onto `#renames`, returns `call.toOps(adapter)`. `resetAuthoringState` already clears `#renames`. + - `working-schema.ts`: `renameColumnInPostgresSchema(schema, { schemaName, table, from, to })` renames the column on the table node and every reference to it: the primary key, uniques, foreign keys (own `columns`, and `referencedColumns` of foreign keys in every table that point at this table), indexes (`columns`; an index with an `expression` or `where` that mentions the column cannot be rewritten; leave it and let the diff handle it), and checks (same rule as expressions). `WorkingSchema.apply` accepts the new call. + - Companions, computed against the working schema after the column rename, the same way `table-rename-constraint-renames.ts` and `pairIndexRenames` do it for a table rename: each unique and foreign key whose `columns` include the column pairs with the destination constraint of the same kind on the same (renamed) columns and is renamed to that constraint's explicit name, else `defaultUniqueName(table, columns)` / `defaultForeignKeyName(table, columns)` from `default-constraint-names.ts`, when that differs from the current name; the primary key keeps `
_pkey`; each index whose `columns` include the column pairs with the destination index of equal content (columns renamed, same `unique`, `type`, `options`, `where`, `expression`) and is renamed to the destination's wire name (`lowerAuthoredIndex` / `computeIndexContentHash` in `packages/2-sql/1-core`), when that differs. A check whose expression mentions the column is left to the existing drop-and-add check path (spec § Operations says so). Put the companion computation in its own module beside `table-rename-constraint-renames.ts` with its own tests. +- **SQLite** (`packages/3-targets/3-targets/sqlite/src/core/migrations/`): + - `RenameColumnCall(tableName, oldColumnName, columnName, indexReplacements)` in `op-factory-call.ts`, companions all drops then all creates as `RenameTableCall` does; DDL `ALTER TABLE "" RENAME COLUMN "" TO ""` in `operations/columns.ts` with `columnExistsAst` checks; facade `renameColumn({ table, column, to })` on `sqlite-migration.ts`; `renameColumnInSqliteSchema` in `working-schema.ts` with the same reference rewriting (SQLite rewrites references itself on `RENAME COLUMN`, but the working copy must match what the database will hold, index names included: SQLite keeps the old index name, so the replacement is what brings in the destination wire name). + - Index companions through `pairIndexReplacements` in `index-replacements.ts` with a new matcher: same table, same columns after the rename, different name. + - Checks: SQLite refuses CHECK constraints, nothing to do. +- **SQL family** (`packages/2-sql/9-family/src/core/migrations/`): the field-statement storage effect beside dispatch 3's model one: compare the origin field's `storage.fields[field].column` with the destination field's; a relation field (present in `model.relations`, absent from `storage.fields`) has no column; equal or no column means applied with zero operations. Resolve the origin field through the model the earlier model statement renamed (dispatch 2 already resolved the coordinates; here the family only reads storage). Description text `rename field "User.name" to "User.fullName"` for `appliedStatements`. +- **Both planners**: apply field effects in the same ordered pass as model effects (one list, statement order), through `postgresColumnRenameCall` / `sqliteColumnRenameCall` builders in `table-rename-calls.ts` or a sibling module, so companions are computed against the working copy as earlier statements left it. Field-event planning must be told about the column rename so it fires no drop and add (extend the mechanism dispatch 3 copied from the shelved branch). +- Tests on both targets in the pattern of `rename-table-facade.test.ts`, `rename-table-facade.authoring-state.test.ts`, `rename-table-ops.test.ts`, `table-rename-calls.test.ts` and the planner tests: facade emits the rename plus companions; explicit and derived constraint names; an index on the column gets the destination wire name; an FK from another table referencing the renamed column keeps its name and its reference; a check on the column goes through drop-and-add; chaining after a table rename of the same table (`Profile:User` then `User.name:User.fullName`); the planner plans a field statement with no drop-and-add; `@@map`-kept column name is zero operations; a relation field is zero operations; `appliedStatements` order and counts; SQLite index replacement names. + +**Out** + +- The `--rename` flag, `db update`'s origin contract, `Statements applied` output (dispatch 5). +- Journeys, upgrade fragment, docs (dispatch 6). +- Any `packages/1-framework` change (the field coordinate types exist; nothing new is needed there). +- Moving a field between models, value object fields, enum values (slice 3). + +## Where things are (from the orchestrator's survey; verify against the tree, dispatch 3 has changed the planners since) + +- `renameTable` end to end: Postgres facade `postgres-migration.ts` around line 386; `RenameTableCall` in `op-factory-call.ts` around 372; DDL and checks in `operations/tables.ts`; `postgresTableRenameCall` in `table-rename-calls.ts`; `constraintRenamesForTableRename` in `table-rename-constraint-renames.ts`; `pairIndexRenames` and `pairCheckRenames` in `index-and-check-renames.ts`; `renameTableInPostgresSchema` and `createWorkingSchema` in `working-schema.ts`. SQLite: `sqlite-migration.ts` around 161, `op-factory-call.ts` around 293, `operations/tables.ts`, `table-rename-calls.ts`, `index-replacements.ts` (`pairIndexReplacements`, matchers `renamedTableIndex`, `indexNameCaseChange`), `working-schema.ts`. +- Column ops as the pattern: Postgres `AddColumnCall`, `DropColumnCall`, `AlterColumnTypeCall` in `op-factory-call.ts` and `operations/columns.ts` (`columnExistsSteps`); SQLite `AddColumnCall`, `DropColumnCall` and `operations/columns.ts`. +- Names: `default-constraint-names.ts` (Postgres); `packages/2-sql/1-core/schema-ir/src/naming.ts` (`defaultIndexName`, `computeIndexContentHash`, `formatWireName`, `parseWireName`); `packages/2-sql/1-core/contract/src/index-naming.ts` (`lowerAuthoredIndex`). +- Schema IR: `packages/2-sql/1-core/schema-ir/src/ir/` (`SqlTableIR`, `SqlColumnIR`, `SqlUniqueIR`, `SqlForeignKeyIR` with `referencedColumns`, `SqlIndexIR`, `SqlCheckConstraintIR`); Postgres wraps as `PostgresTableSchemaNode`. +- Field storage: `SqlModelFieldStorage.column` in `packages/2-sql/1-core/contract/src/types.ts`; relations in `ContractModelBase.relations` (`packages/1-framework/0-foundation/contract/src/domain-types.ts`), which have no `storage.fields` entry. +- There is no `renameColumn` or `RENAME COLUMN` anywhere on the branch today. + +## Edge cases and dispositions + +| Case | Disposition | +| --- | --- | +| Column is in the primary key | Rename proceeds; `
_pkey` is unchanged; no companion. Test it. | +| Column is referenced by a foreign key from another table | Postgres and SQLite update the reference themselves; the working copy's `referencedColumns` is updated; the FK's own name derives from its own columns, so no rename. Test it. | +| Index with an `expression` or `where` mentioning the column | Not rewritten in the working copy; the diff plans whatever it plans (drop and create of an index loses no data, it is widening since dispatch 1). Test one such index to pin the behaviour. | +| Check mentioning the column (Postgres) | Existing drop-and-add path. Test it. | +| Same column name after the rename (`@@map` kept) | Zero operations, reported. | +| Relation field | Zero operations, reported. | +| Field statement after a model statement on the same model | Column rename computed on the working copy after the table rename; the call names the new table. Test it. | +| Old column missing from `options.schema` | Planner failure `statementRejected` naming the column; never fall through to add. | +| Case-only column rename on Postgres | Plain `RENAME COLUMN` works; no temporary name needed. SQLite: check whether `RENAME COLUMN` with a case-only change needs the temporary-name trick that `renameTableSteps` uses; if it does, do the same. | +| Destructive git | Forbidden: no `git clean`, `reset --hard`, `stash`, `checkout -- .`, `rm -rf`. | +| F24 / F31 | `mise exec -- pnpm build` before trusting red; rebuild family or framework packages after changing their exports. | +| F14 | `pnpm lint` per touched package; typecheck covers tests. | +| F26 | Sweep the class on any reviewer finding. | + +## Validation gate + +Through `mise exec --`; long output to `wip/` files. + +- `pnpm typecheck` +- `pnpm --filter lint` and `test` (family, postgres target, sqlite target, and the two adapter packages if their tests touch rename) +- `pnpm lint:deps` +- `pnpm lint:framework-vocabulary` count unchanged +- `pnpm fixtures:check` +- The integration package typechecks (journeys reference the facade types) without running journeys. +- Never the full integration, e2e or `test:all` suites. + +## Completed when + +- `renameColumn` exists on both facades and both planners with DDL, checks, working-schema step and companions, and every test under Scope exists and goes red when its behaviour is removed (verify two and say which). +- A plan from a field statement contains the column rename and companions and no drop-and-add for that column, on both targets; `appliedStatements` lists it. +- `renderTypeScript` of the call is the facade call a user would write; a facade call with the same arguments produces the same ops as the planner's call. +- Gate green; report lists each command and result. +- Small signed `TML-3475:` commits, no attribution lines, no push. + +## Operational + +- Heartbeats to `wip/heartbeats/implementer.txt`. Time-box about three hours. Halt and report when: the working-schema rewrite needs a schema IR change in `packages/2-sql/1-core`; the SQLite case-only rename needs something the DDL cannot do; a `packages/1-framework` change turns out to be required. +- Stay inside the worktree. + +## Return shape + +As in dispatches 2 and 3. diff --git a/projects/migration-statements/slices/renames/dispatches/05-flag-origin-contract-output.md b/projects/migration-statements/slices/renames/dispatches/05-flag-origin-contract-output.md new file mode 100644 index 000000000000..e4c39fb72394 --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/05-flag-origin-contract-output.md @@ -0,0 +1,77 @@ +# Dispatch 5 — The flag on both commands, the origin contract for `db update`, output and errors + +**Slice:** [`../spec.md`](../spec.md) · **Plan entry:** [`../plan.md`](../plan.md) § Dispatch 5 · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Builds on:** dispatch 4 + +## Task + +Declare `--rename :` (repeatable) on `migration plan` and `db update`; parse and resolve the statements with dispatch 2's `resolveStatements` once both contracts are in hand and before anything is written or planned; pass the resolved statements to the planner; make `db update` resolve its origin contract from the application space's marker hash through the local snapshot store and pass it as `fromContract`; carry `appliedStatements` from the planner result to both commands' results and print them under `Statements applied` (human) and `appliedStatements` (JSON); carry a refused statement on the planning-failed error; document the flag, the output and the errors in the CLI README and the error reference. + +## Outcome + +After this dispatch a user can run `prisma migration plan --name tidy-users --rename Profile:User --rename User.name:User.fullName` and `prisma db update --rename Profile:User --rename User.name:User.fullName` and get the plan the slice spec's "At a glance" shows, with `Statements applied` listed after the operations; a wrong statement fails before anything is planned or written with one of the three statement errors; `db update` with statements and no local snapshot for the marker hash fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` naming the hash and the directory; and `db update` without statements behaves exactly as before. The invariant (project spec requirement 3): both commands accept the same statements with the same meaning and produce the same plan; and (requirement 12) everything the commands report about statements is structured data the output is rendered from. + +## Scope + +**In** + +- **Flag.** `packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts` and `src/orm/db/update.ts` declare `rename: flag.repeated({ brief, placeholder: 'old:new' })` from `@prisma/cli-engine` (first use of `repeated` in the repo; its type is `FlagSpec`). Not on `db init`. The values travel as raw strings (`renames: readonly string[]`) to the control API: `MigrationPlanOptions` (`src/control-api/operations/migration-plan.ts`) and `DbUpdateOptions` → `ExecuteDbUpdateOptions` → `sharedInputs` → `ExecuteRunOptions` (`db-update.ts`, `db-run.ts`). Resolution happens in the control API where the contracts are, never in the command handler. +- **`migration plan`.** In `executeMigrationPlanCommandInner`, after both `fromContract` and `toContract` are settled (after the `--to` resolution, before the seed phase and the no-op check), call `resolveStatements({ renames, origin, destination: toContract })`. Origin per `FromResolution.kind`: `graph-node`, `ref` and `auto-baseline` give `{ kind: 'contract', contract: fromContract }`; `greenfield` (no origin models) gives `{ kind: 'contract', contract }` with an empty application domain, so every statement is `STATEMENT_UNRESOLVED` naming the origin as empty (the spec: "there is no origin model to rename"). A failure returns `notOk` before anything is written. Pass the resolved statements to the delta leg of `runPlannerLeg` (the normal and auto-baseline delta legs); the baseline leg keeps `statements: []`. `runPlannerLeg` returns `appliedStatements` in `PlannerSuccess`; `MigrationPlanResult` gains `appliedStatements: readonly AppliedStatement[]` on every assembled result (empty where no leg carried statements). Zero planned operations with statements present is not `PLANNING_FAILED`: it is the existing no-op result, with the applied statements reported (a statement whose storage did not change is "applied, nothing to do"). +- **`db update`.** In `executeRun`, after `readAllMarkers`, take the application space's marker row (`markerRows.get(aggregate.app.spaceId)`), read its `storageHash`, and `readContractSnapshotJsonTolerant(options.migrationsDir, storageHash, options.verifySnapshotContent)` from `@internal/migration-tools/contract-snapshot-store`; a found snapshot goes through `familyInstance.deserializeContract` and becomes the origin. Resolve the statements there, before `planMigration`, with `origin = { kind: 'contract', contract }` when found, else `{ kind: 'missing', hash: storageHash or null when no marker, snapshotDirectory: contractSnapshotDir(...) or the snapshots directory }`; with no statements, resolution is skipped and nothing changes. Thread `fromContract` and `statements` into the aggregate planner: `PlannerInput` → `planMigration` → `planFromDiff` (`packages/1-framework/3-tooling/migration/src/aggregate/`), replacing the hard-coded `fromContract: null` and `statements: []` for the application space (other spaces and `db init` keep `null` and `[]`). `planFromDiff` passes `plannerResult.appliedStatements` into `PerSpacePlan`; `executeRun`'s `wrapPlanResult` and `wrapApplyResult` put the application space's `appliedStatements` on `DbUpdateSuccess`. The destructive pre-plan in `guardDestructiveChanges` (`db-update.ts`) must run with the same statements, or it will see the drop-and-create and refuse. +- **Output.** `migration plan` human output (`src/orm/migration/plan.ts` `planBlocks`): a `Statements applied` block after the operation blocks, one line per statement in the family's description text with its operation count (`rename model "Profile" to "User" (2 operations)`; `(no operations)` for zero), omitted when there are none; JSON is the document, so `appliedStatements` appears as is. `db update`: `DbUpdateSuccess.appliedStatements` → `updateDocument` → `MigrationCommandResult.appliedStatements` (`src/utils/formatters/migrations.ts`; `db init` sets `[]`) → a block in `src/orm/db/migration-blocks.ts` placed after `operationBlocks` in both `planBlocks` and `applyBlocks`. +- **Errors.** Add `statement?: ResolvedStatement` to the framework `MigrationPlannerConflict` (`control-migration-types.ts`; the type is framework-owned, so the framework stays family-blind) and to `CliErrorConflict` (`packages/1-framework/1-core/errors/src/control.ts`), so a `statementRejected` conflict reaches `MIGRATION.PLANNING_FAILED`'s meta with the statement in domain coordinates; `errorMigrationPlanningFailed` renders the summary and why as today. The `MIGRATION.PLANNING_FAILED` entry in `docs/reference/error-reference.md` mentions `statementRejected`; re-read the three `MIGRATION.STATEMENT_*` entries against the final behaviour and correct them if dispatch 2's wording drifted. +- **Docs.** `packages/1-framework/3-tooling/cli/README.md`: the `prisma migration plan` section (around lines 1082–1114: synopsis, options, what it does, outputs) and the `prisma db update` section (around 1020–1081) describe `--rename old:new`, the coordinate forms, that statements resolve in contract vocabulary against the origin and destination contracts and apply in order, the `Statements applied` output, the three error codes, and for `db update` that the origin contract comes from the marker's snapshot and that running the same statements twice fails. Keep it to what this slice ships; `--delete`, the refusal and the prompt are not in this release. +- **Tests**, in the existing patterns: `test/orm/migration-plan.test.ts` (offline project, fake planner from `test/orm/fixtures/offline-project.ts`; extend the fake planner to record the `statements` it received and to return `appliedStatements`): `--rename` reaches the planner resolved; two `--rename` flags keep their order; an unresolvable statement fails with `STATEMENT_UNRESOLVED` and no migration directory is written; `Statements applied` in human output and `appliedStatements` in JSON; greenfield with a statement is unresolved; the auto-baseline path passes statements to the delta leg only. `test/control-api/db-update.test.ts` (mock family with `readAllMarkers`, mock driver) plus an on-disk snapshot store as `db-update-to-resolution.test.ts` does: marker hash with a snapshot → `fromContract` is that contract and `statements` reach `planFromDiff`; marker present, snapshot missing, statements given → `STATEMENT_ORIGIN_UNKNOWN` naming hash and directory, nothing planned; no marker, statements given → `STATEMENT_ORIGIN_UNKNOWN` with `hash: null`; no statements → `fromContract` still comes from the snapshot when present but nothing else changes, and no snapshot is no error; the destructive pre-plan receives the statements; `appliedStatements` on the plan and apply results. `test/orm/db-update.test.ts`: the flag is accepted and the output blocks render. `migration-tools` tests for the new `planFromDiff` inputs. + +**Out** + +- Journeys against real databases, the upgrade fragment, the subsystem doc (dispatch 6). +- `--delete`, the refusal, `--confirm` changes, the interactive prompt (slice 2 and the stretch goal). +- `db init` statements; other contract spaces' origin contracts. + +## Pinned decisions + +- Resolution lives in the control API operations, so the engine commands stay thin and a programmatic caller of `executeMigrationPlanCommand` / `client.dbUpdate` gets the same behaviour with `renames`. This is also what lets a prompt answer be fed back later (requirement 12). +- `db update` reads the origin from the snapshot store only; never from the marker's `contract_json` column (project spec non-goal). +- `fromContract` for `db update` is supplied whenever the snapshot exists, with or without statements; the planner already tolerates it (it only reads it for field events and the `from` hash). If that changes existing `db update` plans in tests, stop and report rather than gating it on statements. +- The `Statements applied` block comes after the operations because the operations are what the user checks first; the statements explain why there is no drop-and-create. + +## Edge cases and dispositions + +| Case | Disposition | +| --- | --- | +| `--rename` given with `--from @empty` or on a greenfield plan | Every statement unresolved, message says the origin has no models. Test it. | +| `db update` second run with the same statements | Origin (snapshot at the new marker hash) lacks the old name → `STATEMENT_UNRESOLVED`. Test at the control-api level by pointing the marker at the destination hash. | +| Marker present, snapshot store has the hash but content verification fails | `readContractSnapshotJsonTolerant` returns undefined → treated as missing → `STATEMENT_ORIGIN_UNKNOWN` when statements are given. | +| Statements given, planner returns zero operations | No-op result with applied statements; not `PLANNING_FAILED`. Test it. | +| `--json` | `appliedStatements` is in the document; `Statements applied` is only in the human presentation. | +| Destructive pre-plan under `db update` | Receives the same statements. Test that a model rename statement makes the pre-plan non-destructive. | +| Destructive git | Forbidden, as in every brief. | +| F14, F24, F31, F26 | As in every brief. | + +## Validation gate + +Through `mise exec --`, package filters quoted one per `--filter`, long output to `wip/`. + +- `pnpm typecheck` +- `pnpm --filter` lint and test for: cli, migration-tools, framework-components, errors, and any target whose tests build conflicts. +- `pnpm check:error-reference` +- `pnpm lint:deps`, `pnpm lint:framework-vocabulary` (272 unchanged), `pnpm lint:throws`, `pnpm lint:casts` +- `pnpm fixtures:check` +- Never the full integration, e2e or `test:all` suites. + +## Completed when + +- Both commands declare `--rename`, resolve before planning, fail before writing on a bad statement, pass resolved statements to the planner, and report `appliedStatements` in JSON and `Statements applied` in human output. +- `db update` supplies `fromContract` from the snapshot store and fails with `STATEMENT_ORIGIN_UNKNOWN` as specified; without statements its behaviour is unchanged (the existing tests still pass unmodified, apart from fixture shapes gaining the new fields). +- The planning-failed error carries the refused statement. +- README and error reference updated; every test under Scope exists and two were shown red-on-removal. +- Gate green; small signed `TML-3475:` commits; no attribution lines; no push. + +## Operational + +- Heartbeats to `wip/heartbeats/implementer.txt`. Time-box about three hours. Halt and report when: supplying `fromContract` to `db update` changes existing plans; the engine's `flag.repeated` does not behave as its type says; the snapshot store API cannot be reached from `db-run.ts` without a layering violation (`pnpm lint:deps`). +- Stay inside the worktree. + +## Return shape + +As in the earlier dispatches. diff --git a/projects/migration-statements/slices/renames/dispatches/06-journeys-upgrade-fragment-docs.md b/projects/migration-statements/slices/renames/dispatches/06-journeys-upgrade-fragment-docs.md new file mode 100644 index 000000000000..c0b91e62b7d2 --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/06-journeys-upgrade-fragment-docs.md @@ -0,0 +1,83 @@ +# Dispatch 6 — Journeys, upgrade fragment, subsystem doc + +**Slice:** [`../spec.md`](../spec.md) · **Plan entry:** [`../plan.md`](../plan.md) § Dispatch 6 · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Builds on:** dispatch 5 + +## Task + +Prove the slice done conditions end to end with CLI journeys on Postgres and SQLite; add the upgrade fragment; amend the subsystem docs, the ADR sentences and the agent skill text that promise `@hint(was:)` or say renames have no statement; list the journeys in the journeys README. + +## Outcome + +After this dispatch the slice's done conditions in `spec.md` § Slice done conditions are each covered by a journey that fails when the behaviour is removed, a user upgrading reads what changed (non-data drops are widening and no longer ask consent; `--rename` exists), and every long-lived document that described rename hints in the contract source now describes statements on the command line and points at one section. The invariant: the journeys exercise the user's own commands against a real database, never the planner API, and assert database state, not only CLI output. + +## Scope + +**In** + +- **Journeys** beside the existing ones: `test/integration/test/cli-journeys/rename-statements-migration.e2e.test.ts` (Postgres, `useDevDatabase`, `setupJourney` with `contractMode: 'psl'`) and `rename-statements-migration.sqlite.e2e.test.ts` (SQLite, `setupSqliteJourney` pattern). Fixtures `test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-{from,to}.prisma` registered in `pslContractFixtures` (Postgres); inline PSL constants on SQLite as the rename-table journey does. The "from" contract: a model (`Profile`) with rows, a unique field, a secondary `@@index`, a check (Postgres only, SQLite refuses checks), an RLS policy (Postgres only), and a second model with a foreign key to it. The "to" contract renames the model (`User`) and a field (`name` → `fullName`) that the unique and the index cover. Journeys, one `it` each with labelled assertions: + - **S1 (both targets)** `migration plan --name initial` and `migrate` on "from"; seed rows; swap to "to"; `migration plan --name tidy-users --from --rename Profile:User --rename Profile.name:User.fullName --json` (note: the field statement names the old field through the destination model, so `--rename User.name:User.fullName`; use that form and cover the wrong form once in S3) → `ok`, operations are rename calls and companions with class `widening` and no drop/create of the table or column, `appliedStatements` has two entries in order; `migrate`; assert rows present under the new names, constraints, indexes (destination wire names), policy and check under the new names, old table absent (Postgres `to_regclass`/`pg_constraint`/`pg_indexes`/`pg_policies`; SQLite `sqlite_master` and `pragma_table_info`); a further `migration plan --from --json` is `noOp`; `db verify --schema-only` exit 0; re-run the written `migration.ts` (the `selfEmitMigration` helper) and assert `ops.json` and `migration.json` are byte-identical to the first emission. + - **S2 (both targets)** `db update` on "from" (creates; advances the `db` ref and stores the snapshot); seed rows; swap to "to"; `db update --rename Profile:User --rename User.name:User.fullName --json` with no `--confirm` and no prompt → exit 0, `ok`, `appliedStatements` two entries, same database state as S1 (same assertions); `db verify --schema-only` exit 0; run the same `db update --rename ... --json` again → exit code of a structured error, `engineError(result).code === 'MIGRATION.STATEMENT_UNRESOLVED'`, and the message names `Profile` as not in the origin. + - **S3 (Postgres only, cheap)** error paths through the CLI: `migration plan --rename Profile:Nope` → `MIGRATION.STATEMENT_UNRESOLVED`, no new migration directory; `migration plan --rename Profile.name:User.fullName` (old model named on the field side) → `MIGRATION.STATEMENT_INVALID` whose message contains the corrected statement; `db update --db --rename Profile:User` against a database whose marker hash has no snapshot (use a fresh migrations dir or delete `migrations/snapshots/`) → `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` naming the hash and the directory. + - Add rows for both files to the "Happy paths" table in `test/integration/test/cli-journeys/README.md`. +- **Upgrade fragment** at `upgrade-instructions/pending/migration-statement-renames/`: `app/instructions.md` with one change entry for the widening reclassification (`db update` no longer asks consent, and `migration plan`'s baseline consent no longer counts, for drops of indexes, unique and foreign-key constraints, checks, RLS policies, defaults and native enum types, and for disabling RLS; a script that passed `--confirm` only for those now runs without it; detection: `--confirm` in shell/yaml/json files, glob and matches as the existing fragments do) and a second entry announcing `--rename old:new` on both commands (additive; no detection needed beyond a note; keep the summary to the user-facing behaviour); `extension/instructions.md` with one change entry: the migration planner interface's `plan` options gained a required `statements` and its success result a required `appliedStatements`, and `MigrationPlannerConflict` an optional `statement`; an extension that implements a planner adds both fields (detection glob `**/*.{ts,mts,cts}` matching `implements MigrationPlanner` or `kind: 'success'` near `plan` — pick the regexes that match the real shape). Validate by execution per `skills-contrib/record-upgrade-instructions/SKILL.md` § per-PR validation as far as it applies (the app change is behavioural, so the execution check is that `examples/*/test/**` on the branch differs from the base only in the one `handover.test.ts` expectation). Run `pnpm check:upgrade-coverage --mode pr --prev $(git rev-parse origin/tml-3474-migration-statements) --head HEAD` (stacked PR: the base is the shaping branch, F36). +- **Docs**: + - `docs/architecture docs/subsystems/7. Migration System.md`: a new `### Statements` subsection under `## Planner` (after `### migration plan`) describing: statements as the planner's third input beside the destination contract and the origin schema; the grammar (`--rename old:new`, the four coordinate forms); resolution in the framework against the origin and destination contracts into domain coordinates; application in order to a working copy of the origin schema; the family's storage effect per statement (table rename plus companions, column rename plus companions, zero operations when storage is unchanged or the field is a relation); `appliedStatements` on the planner result; the three error codes and `statementRejected`; `db update`'s origin contract from the marker hash through the snapshot store (and that `--db ` without `--advance-ref` leaves no snapshot, so renames need `--advance-ref` there); what this slice does not do (`--delete`, the refusal, namespace moves, Mongo) with a pointer to the project's next slices by name only (no `projects/` paths). Replace the `@hint(was:)` sentences at the `### Offline planning via contract-to-schema` lines (around 139 and 143) with one sentence pointing at § Statements; in `### db update (live reconciliation)` (around 740–746) say the origin contract comes from the marker's snapshot when statements are given. + - `docs/architecture docs/subsystems/1. Data Contract.md`: rewrite `### Canonical contract vs planner hints` (around 76–82) to say intent is stated on the command line as statements, is never part of the contract or its hashes, and point at the Migration System doc § Statements; fix the sentence around line 329 ("require explicit planner hints") the same way. + - ADR 001 (lines around 32, 54, 87, 143, 155), ADR 028 (54, 130–135, 295) and ADR 264 (76–77): do not rewrite history; add one short amendment note under each affected sentence or at the top of the ADR ("Amended 2026-10: intent is stated as command-line statements, see Migration System § Statements; the close-out ADR of the migration-statements project supersedes the hint vocabulary"), following whatever amendment convention other ADRs in the folder use (grep `Amended` / `Superseded`). + - `skills/prisma-8/references/migrations.md` (pitfall 8 around 497, "doesn't do yet" around 506, and the `renameTable` line around 361 gains `renameColumn`), `skills/prisma-8/references/contract.md` (around 430, 434, 457, 461) and `skills/journey-tests/02b-rename-with-hint.md`: say that `migration plan --rename old:new` and `db update --rename old:new` state a model or field rename, that the planner then renames instead of dropping, and keep the hand-edit route as the fallback for cases statements do not cover. Keep each edit to the sentences that are now wrong. + - `docs/reference/error-reference.md`: re-read the four entries (`STATEMENT_*`, `PLANNING_FAILED`, `COLUMN_RENAME_UNMATCHED`) against the final CLI behaviour. + - `pnpm lint:docs` or whatever the repo's doc link check is (see `package.json` scripts) passes; no new `projects/` references in long-lived files (`git grep -n "projects/migration-statements" -- docs skills packages upgrade-instructions test` prints nothing). + +**Out** + +- Any production code change beyond what a journey proves broken; if a journey finds a defect, fix it in its own commit and say so, or halt if the fix is larger than a few lines. +- The close-out ADR, ADR 001/028 rewrites, migrating the project folder (project close-out). +- Mongo journeys (slice 4). + +## Where things are + +- Harness: `test/integration/test/utils/journey-test-helpers.ts` (`setupJourney`, `swapPslContract`, `pslContractFixtures`, `runContractEmit`, `planMigrationAndSelfEmit`, `selfEmitMigration`, `runMigrate`, `runDbVerify`, `runDbUpdate`, `runMigrationPlan`, `latestMigrationDirName`, `engineError`, `parseJsonOutput`, `sql`, `consentTokenFor`, `timeouts`); `useDevDatabase` from `@repo/test-utils`; the SQLite pattern in `rename-table-migration.sqlite.e2e.test.ts` (`setupSqliteJourney`, `withDatabase`, `node:sqlite`). +- Existing journeys to copy from: `rename-table-migration.e2e.test.ts` (R1, R5), `rename-table-migration.sqlite.e2e.test.ts` (R3, R4 with `db update`), `db-update-workflows.e2e.test.ts` (consent and `--json` shapes). +- Snapshot store: `/snapshots//`; refs at `/app/refs/db.json`; `db update` writes both unless `--db ` without `--advance-ref`; `migration plan` writes the destination snapshot after the package. +- Upgrade fragments: `upgrade-instructions/pending//{app,extension}/instructions.md`, examples `prisma7-migration-handover/app`, `rename-constraint-call/extension`, no-op `binder-from-caller/extension`; checker `scripts/check-upgrade-coverage.mjs`; lifecycle `upgrade-instructions/README.md`. +- Run journeys one file at a time: `mise exec -- pnpm --filter integration-tests exec vitest run --config vitest.journeys.config.ts test/cli-journeys/rename-statements-migration.e2e.test.ts` (check the exact script in `test/integration/package.json`). Build first (`mise exec -- pnpm build`): journeys run the CLI from `dist` (F31). + +## Edge cases and dispositions + +| Case | Disposition | +| --- | --- | +| Journey label text for operations differs per target (Postgres quotes names, SQLite does not) | Assert per target, as the rename-table journeys do. | +| SQLite refuses checks and has no RLS | Omit from the SQLite fixture; assert indexes and the FK instead. | +| Byte-identical re-emit | Compare file contents, not parsed JSON. | +| `db update` second run | Relies on the first run having stored the snapshot (no `--db`, so the `db` ref advanced). Assert the snapshot directory exists before the second run so a harness change shows up as the right failure. | +| A journey fails on behaviour the planner tests pass | Rebuild `dist` first (F31); then treat as a real defect and report. | +| Destructive git | Forbidden, as in every brief; no `git checkout -- `. | +| F14, F24, F36 | As in every brief; the upgrade check runs against the shaping branch as base. | + +## Validation gate + +Through `mise exec --`, filters quoted one per `--filter`, long output to `wip/`. + +- `pnpm build`, then the two new journey files one at a time (they are the only journeys to run; never `pnpm test:journeys` or `test:integration` in full). +- `pnpm typecheck` +- `pnpm --filter integration-tests lint` +- `pnpm check:upgrade-coverage --mode pr --prev $(git rev-parse origin/tml-3474-migration-statements) --head HEAD` +- `pnpm check:error-reference`, `pnpm lint:deps`, `pnpm lint:framework-vocabulary` (272), `pnpm fixtures:check` +- The repo's docs link check if one exists in `package.json` scripts. + +## Completed when + +- Every slice done condition in `spec.md` has a named assertion in a journey, on both targets where the condition applies, and the two journey files pass locally. +- The upgrade fragment exists for both audiences and the coverage check passes against the shaping branch as base. +- No long-lived file promises `@hint(was:)` as the way to state a rename; `git grep -n 'hint(was' -- docs skills packages` returns only amendment notes or ADR history. +- No `projects/` references in long-lived files. +- Small signed `TML-3475:` commits; no attribution lines; no push. + +## Operational + +- Heartbeats to `wip/heartbeats/implementer.txt`. Time-box about three hours; journeys are slow, so run each file once per change. Halt and report when a journey exposes a defect larger than a few lines, or when the `db update` second run cannot find the origin for a reason other than a missing snapshot. +- Stay inside the worktree. + +## Return shape + +As in the earlier dispatches, plus: for each slice done condition, the journey and assertion label that covers it. diff --git a/projects/migration-statements/slices/renames/dispatches/08-final-review-fixes.md b/projects/migration-statements/slices/renames/dispatches/08-final-review-fixes.md new file mode 100644 index 000000000000..fa3d1897223d --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/08-final-review-fixes.md @@ -0,0 +1,44 @@ +# Dispatch 8 — Fixes from the whole-slice review + +**Slice:** [`../spec.md`](../spec.md) · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Inputs:** `../reviews/code-review-final.md` (findings F01–F09, AC table) and `../reviews/system-design-review.md` (findings S01–S19), both gitignored, read them in full. + +Two rounds, in order. Each round ends with its gate and a hand-back; round B starts only after round A is reviewed. + +## Round A — security and correctness + +- **F01 (security, must-fix).** Fix the class, not the call site: `qualifyTable` in the Postgres target's `postgres-schema.ts` must quote every identifier part with the existing `quoteIdentifier` (escape `"` by doubling). Then sweep every place in the Postgres and SQLite targets that builds an identifier into SQL text by hand (grep for template literals containing `"${` and for string concatenation with `'"'`) and route each through the quoting helper. The bug predates this branch; fixing it here is in scope because `--rename` makes it reachable from a user's command line. Tests (F07): a rename through a statement with a table name and a column name containing `"`, `\`, a space and a SQL keyword, rendered and executed on PGlite and on `node:sqlite`, asserting the database holds the new names and nothing else was created. The test must fail on the pre-fix helper (show it). +- **F02.** SQLite table renames compare names case-insensitively, as column renames already do (`columnsNamed` pattern): the planner and the facade refuse a new table name another table already holds in another case, except a case-only rename of the same table; the `renameTable` precheck uses the `COLLATE NOCASE` form. Runner test on real SQLite. +- **F05.** Every `statementRejected` (renamed in round B, keep the code path) conflict's `why` says what to do next, not only why it refused. Audit each case and test the texts. +- **F06.** `db update` distinguishes a missing snapshot from an unreadable one (corrupt JSON, failed content check, deserialization failure) in the `STATEMENT_ORIGIN_UNKNOWN` text; the reason is already in `meta.unreadable`. Error reference entry updated. +- **F07 second part / AC9.** A test that only the application space receives statements and an origin contract (an aggregate with an extension space). +- **F09.** Remove `-y|--yes` from the `db update` synopsis in the CLI README (the command does not declare it). Check the `migration plan` synopsis the same way. +- **F03.** Say in Migration System § Statements and the CLI README's rename paragraph that a column rename on Postgres re-creates checks, row-level-security policies and expression or partial indexes that mention the column, and that re-adding a check scans the table under an exclusive lock. +- **F04.** On SQLite, when a later step in the same plan rebuilds the table, drop the column rename's index replacement for that table. If that needs more than a small change in the SQLite planner, stop, and the orchestrator will defer it. + +## Round B — names and shapes before later slices build on them + +All of this is unreleased, so rename freely; update the spec text, the docs, the error reference and the upgrade fragment to the new names in the same commits. No backward-compatible aliases. + +- **S01.** Framework types say what they are: `ResolvedStatement` → `ResolvedMigrationStatement`, `AppliedStatement` → `AppliedMigrationStatement` (and their members). The user-facing words stay: `--rename` statements, `Statements applied`, JSON `appliedStatements`. +- **S02.** The conflict field is `refusedStatement` (beside the existing `refusedOperationClass`), and the conflict kind is `statementRefused`. +- **S03 + S04.** `AppliedMigrationStatement` carries the resolved statement and `operationIds: readonly string[]` (the ids of the operations it accounts for), not `description` and `operationCount`. The description text is written once, from domain names, in the framework or CLI (one function, used by every family and by the CLI output); remove the SQL family's and Mongo's copies. Output prints the count from `operationIds.length`. +- **S05.** One entry point for statement texts, keyed by verb: the control API options carry `statements: readonly { verb: 'rename'; text: string }[]` (a union with one member today) instead of `renames: readonly string[]`, and the parser and resolver take that list. Error titles and messages name the verb from the entry, not a hard-coded `--rename`. Keep the order given. +- **S06.** `ModelCoordinate` / `FieldCoordinate`: `namespace` → `namespaceId`, a one-line doc comment that names are domain names as written in the contract source (for MongoDB, the stored field name, see `projects/migration-statements/deferred.md`), and shape them so slice 3 can add value-object and enum-value coordinates as further union members without changing these. +- **S07 / F08.** Remove `toOps?(lowerer?: unknown)` from the framework `OpFactoryCall`; put `toOps(lowerer)` on each SQL target's call base type with its real lowerer type. +- **S08.** The framework `Migration` base gets one read path that resets authoring state and reads operations; remove the static reset hook if nothing else needs it. +- **S09.** Split the planner's two meanings of `fromContract`: the contract the planner reads (for statements and field events) and the origin the plan claims. `db update` with statements passes the first and not the second; remove the `planFromDiff` Proxy that hides `origin`. `describe().from` and `origin` must agree. +- **S10.** The aggregate planner fails loudly (a planner error, tested) if the application space has statements and plans by any strategy other than the diff. +- **S11 + S12.** The statement planner validates renames with the family's `resolveTableRenameAgainst` / `resolveColumnRenameAgainst` (one validator), and the hand-written-call text in refusals comes from the target, as the table-name case guard already gets it. Check the namespace-id versus database-schema-name case S12 names, and test it on Postgres with a namespace whose database schema name differs from its id, if the contract allows that. +- **S13 + S14.** Constrain the statement planner's `TCall` so the targets' identical `operationCount` / `operationClasses` live once; `renameCall` → `renameTableCall`; in the family, `renames` → `tableRenames` beside `columnRenames`; one companion-call union per target where two are identical; resolve the two meanings of the `Resolved*` prefix the review names. +- **S15.** The `MigrationOperationClass` doc comment defines destructive as data loss and lists what is widening now. +- **S16.** Finish the `@hint` cleanup: a `> **Update — 2026-10:**` note under ADR 264's hint bullet; `docs/architecture docs/subsystems/9. No-Emit Workflow.md` around line 120; the rename-inference hint text in `cli-errors.ts` around lines 700–701; rename `skills/journey-tests/02b-rename-with-hint.md` to `02b-rename-with-statement.md` and update every link to it. +- **S17.** `db init` takes no statements and reports no `appliedStatements`: remove both from its options and its result (make the shared result type's field optional or split the type, whichever is smaller and typed precisely). +- **S18.** The family reads model storage through `SqlModelStorage`, not `storage['table']`; separate storage effects from failure reasons in the unions. + +## Gate (each round) + +Through `mise exec --`, filters quoted one per `--filter`, long output under `wip/`. `pnpm build`; whole-workspace typecheck; lint and test for every touched package; `check:error-reference`; `lint:deps`; `lint:framework-vocabulary` (≤ 254); `lint:throws`; `lint:casts`; `fixtures:check`; `check:upgrade-coverage --mode pr --prev $(git rev-parse tml-3474-migration-statements-sync)`; both rename-statements journey files once. Never the full journey, integration or e2e suites. Small signed `TML-3475:` commits; no attribution lines; no push; no destructive git (to undo an experiment, copy the file back from `wip/`). + +## Hand-back + +Per finding: what changed, the test that pins it (and that it failed before), commits. Any finding you could not do, why. diff --git a/projects/migration-statements/slices/renames/dispatches/09-qa-and-round-b-followups.md b/projects/migration-statements/slices/renames/dispatches/09-qa-and-round-b-followups.md new file mode 100644 index 000000000000..ae80ab1a3a4c --- /dev/null +++ b/projects/migration-statements/slices/renames/dispatches/09-qa-and-round-b-followups.md @@ -0,0 +1,29 @@ +# Dispatch 9 — Manual QA findings and round B follow-ups + +**Slice:** [`../spec.md`](../spec.md) · **Branch:** `tml-3475-statement-renames` · **Tier:** Opus · **Inputs:** `../../manual-qa-reports/2026-10-07-qa-opus.md` (F1–F11, with exact commands and outputs) and `../reviews/system-design-review.md` § Round B verification (R1–R4, S08 nit). Read both. + +## QA findings + +- **F1 (blocker).** No error text may advise a step that drops data. `STATEMENT_ORIGIN_UNKNOWN` must not say "run the command without statements" unless it also says that plan drops the old table with its rows and asks for consent; prefer giving only the route that works (F2). +- **F2.** The same error explains why there is no snapshot (the earlier `--db` run did not advance a ref) and gives the route the QA run verified: store the old contract first with `db update --db --advance-ref ` run against the old contract, then run the rename. Say that adding `--advance-ref` to the failing command does not help, and that `migration plan --from` does not apply to `db update`. +- **F3.** The MongoDB field advice must not leave documents half-moved: say that a collection validator or unique index on the field has to be changed before `$rename` can move the values, and order the steps so they work (check them against `MongoMemoryReplSet` the way QA did). If no short recipe is safe, say what must be handled and stop there. +- **F4.** When the delta leg refuses a statement, `migration plan` writes nothing, including the automatic baseline. Plan every leg before writing any package. If that is more than a small change, stop and report. +- **F5.** A second `db update` with the same statements says the database already has the new names (the old name is missing from the origin and the new name is present), so the statement should be dropped. +- **F6.** No user-facing text or JSON shows the internal namespace id `__unbound__`: describe an entity in the unbound namespace by its bare name, and leave `namespaceId` out of JSON for it or decide one documented rendering. Check the CLI output, the error texts and `describeMigrationStatement`. +- **F7.** Every `STATEMENT_INVALID` / `STATEMENT_UNRESOLVED` case says what to type: the accepted forms for syntax errors, and for a reversed statement, a swap, an identity rename, and a field statement placed before its model statement, the statement or order that would work. +- **F8.** The `@@map` refusal says how to follow it: emit an intermediate contract with only the table change, run `prisma migration new` with `...this.renameTable({ ... })`, then plan the field rename. +- **F9.** When a plan with statements also has placeholders, the human output still lists the operations and `Statements applied`, and the JSON's `operations` and `appliedStatements` agree. +- **F10.** The extension upgrade instructions say what a planner that cannot carry out statements must do (refuse a non-empty list with `statementRefused`, never ignore it), when a planner sets `refusedStatement`, the import path of each new type, and what the `appliedStatements` detection misses. +- **F11.** The skill's MongoDB rename workaround matches the CLI advice (mongosh, not `migration.ts`), and its error-code table lists the three `MIGRATION.STATEMENT_*` codes. + +## Round B follow-ups + +- **R1.** `operationIds` assumes ids are unique in a plan, and Postgres rename and drop ids omit the schema. First find whether operation ids are persisted or compared anywhere beyond `ops.json` (runner, ledger, marker, attestation, migration hash). If they are, refer to a statement's operations by position in the plan instead of id. If they are not, add the schema to every Postgres id that names a table without it and regenerate the fixtures. Either way, state the uniqueness rule where operation ids are defined and test two same-named tables in different namespaces. +- **R2.** One origin type: `MigrationPlan.origin` and the `Migration.origin` getter use `PlanOrigin | null`; `PlanOrigin` gains optional `profileHash`. +- **R3.** Remove from framework doc comments any statement about MongoDB's current behaviour; in particular the `FieldCoordinate` doc must not settle the Mongo field-name question `deferred.md` leaves to slice 4. +- **R4.** Rename `StatementCall` to a name for what it is (a call that carries companion calls), since the hand-written facade uses it too. +- **S08 nit.** `SqlMigration.providedInvariants` reads operations through `Migration.readOperations(this)`. + +## Gate and rules + +As dispatch 8. Re-run the QA commands for F1, F2, F5, F6, F7 and F9 through the built CLI and paste the new output in the hand-back. Small signed `TML-3475:` commits; no attribution lines; no push; no destructive git. diff --git a/projects/migration-statements/slices/renames/plan.md b/projects/migration-statements/slices/renames/plan.md new file mode 100644 index 000000000000..fc91c77de341 --- /dev/null +++ b/projects/migration-statements/slices/renames/plan.md @@ -0,0 +1,61 @@ +# Slice plan — Statements on the command line, and renames of models and fields + +**Spec:** [`spec.md`](./spec.md) · **Linear:** [TML-3475](https://linear.app/prisma-company/issue/TML-3475) · **Branch:** `tml-3475-statement-renames` + +Every dispatch runs on Opus. Validation gate unless stated: `pnpm typecheck`, `pnpm --filter lint`, `pnpm --filter test`, `pnpm lint:deps` when imports change, `pnpm fixtures:check` when IR or rendering changes. Never the full integration or package suites locally; run named files. + +## Dispatches + +### 1 — Salvage the planner substrate from prisma/orm#30570 + +**Outcome.** Postgres and SQLite carry the working schema shared by the planner and the hand-written facade, destination-driven companion constraint names, foreign-key pairing by own and referenced columns preferring the same referenced table, `OpFactoryCall.toOps`, `SchemaTables`, and the widening reclassification of non-data drops, with every hint, `ConsumedHint`, contract-section and `resolveHints` reference left out. The facade's `renameTable` runs on the working schema, so order-dependent calls compose and re-reading `operations` is safe. + +**Builds on.** `main`. + +**Hands to.** A working-schema mechanism on both targets that the facades use and the planners will use from dispatch 3; `renameTable` calls that compute companions against the working copy; non-data drops classed `widening`. + +**Focus.** Copy mechanism, not history. The scope includes `packages/1-framework/3-tooling/migration/src/migration-base.ts` for the authoring-state reset hook the facade needs, and `examples/prisma7-adoption/test/handover.test.ts` where the reclassification changes an expectation. The shelved branch is `tml-3422-intent-hints-model-rename`; its tests for the working schema, companion renames and classification come over minus the hint cases. + +### 2 — Statement grammar and resolver in the framework + +**Outcome.** The framework CLI package parses `--rename old:new` values into statements and resolves them against an origin and a destination contract into domain coordinates, with every rule of the slice spec's Grammar and Resolution sections and its three error codes, and the planner input type carries `statements`. + +**Builds on.** `main` (independent of dispatch 1). + +**Hands to.** `ResolvedMigrationStatement` and the resolver, exported from the CLI package's control API; `statements` on the planner input, empty everywhere it is constructed. + +### 3 — Model renames from statements on both SQL planners + +**Outcome.** The SQL family maps a resolved model statement to its storage effect, applies it to the working copy in order, the planners emit the table rename and companions ahead of the diff, refuse a non-`managed` target with a `statementRefused` conflict, and the plan reports `appliedStatements` with the family's description text. + +**Builds on.** Dispatches 1 and 2. + +**Hands to.** The application path every later statement kind reuses; `appliedStatements` on the plan. + +### 4 — Column rename operation and field statements + +**Outcome.** `renameColumn` exists on the Postgres and SQLite facades and planners with prechecks and postchecks, renames the column's unique and foreign-key constraints to destination names and its indexes to their destination wire names, and a resolved field statement is applied through it; a relation field statement applies with zero operations. + +**Builds on.** Dispatch 3. + +**Hands to.** Field renames end to end at the planner level. + +### 5 — The flag on both commands, the origin contract for `db update`, output and errors + +**Outcome.** `migration plan` and `db update` declare `--rename`, parse and resolve statements before planning, fail with the three error codes before anything is planned, `db update` resolves its origin contract from the marker hash through the snapshot store and passes it to the planner, and both commands print `Statements applied` and emit `appliedStatements`. The error reference and the CLI README's two command sections describe the flag, the errors and the output. + +**Builds on.** Dispatch 4. + +**Hands to.** The user-visible feature on both targets. + +### 6 — Journeys, upgrade fragment, subsystem doc + +**Outcome.** The slice done conditions hold on Postgres and SQLite through journey tests next to the existing `rename-table-migration` journeys; the re-run of `migration.ts` is byte-identical; the upgrade fragment records the widening reclassification and the new flag; the Migration System subsystem doc has a section on statements and the `@hint(was:)` sentences in it, in the Data Contract doc, ADR 001 and ADR 028 are replaced by a pointer to that section. + +**Builds on.** Dispatch 5. + +**Hands to.** Slice DoD. + +## Open items + +- None at planning. diff --git a/projects/migration-statements/slices/renames/spec.md b/projects/migration-statements/slices/renames/spec.md new file mode 100644 index 000000000000..0c6d2e35bd91 --- /dev/null +++ b/projects/migration-statements/slices/renames/spec.md @@ -0,0 +1,113 @@ +# Slice spec — Statements on the command line, and renames of models and fields on Postgres and SQLite + +**Project:** [`projects/migration-statements/`](../../spec.md) · **Slice 1** · **Linear:** [TML-3475](https://linear.app/prisma-company/issue/TML-3475) · **Branch:** `tml-3475-statement-renames` (stacked on `tml-3474-migration-statements`; retarget to `main` once prisma/orm#30604 merges) + +## At a glance + +```text +$ prisma migration plan --name tidy-users --rename Profile:User --rename User.name:User.fullName +✔ Planned 4 operation(s) + Rename table "Profile" to "User" widening + Rename constraint "Profile_pkey" to "User_pkey" widening + Rename column "User"."name" to "fullName" widening + Rename constraint "User_name_key" to "User_fullName_key" widening + +Statements applied + rename model "Profile" to "User" + rename field "User.name" to "User.fullName" +``` + +The written `migration.ts` contains `...this.renameTable({ table: 'Profile', to: 'User' })` and `...this.renameColumn({ table: 'User', column: 'name', to: 'fullName' })`, and re-running it writes the same `ops.json`. `prisma db update` with the same two statements does the same against a live database, with no consent prompt, and running it a second time with the same statements fails because `Profile` is no longer in the origin. A statement that names something the contracts do not have fails before anything is planned. Drops of indexes, constraints, checks, policies, defaults and native enum types no longer count as destructive anywhere. + +## Chosen design + +### Grammar + +- Both commands take `--rename :`, declared with the engine's `flag.repeated`, so it may appear any number of times. Statements are processed in the order given. The command hands the control API one ordered list of statements, each with its verb and text (`{ verb: 'rename', text }`), and the parser and resolver take that list, so a later verb joins the same list. The engine's repeated flags keep the order within one flag, not across flags; when a second verb arrives, its statements follow all `--rename` statements unless the engine keeps the order across flags by then. +- Each side is a coordinate: `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`. Segments are separated by `.`; the sides by one `:`. A side with no `:`, more than one `:`, an empty segment, or more than three segments is `MIGRATION.STATEMENT_INVALID`, and the message quotes the statement and shows the four accepted forms. +- Both sides must have the same depth: model to model, or field to field. A model on one side and a field on the other is `MIGRATION.STATEMENT_INVALID`. +- A two-segment side is read as `namespace.Model` and as `Model.field`. Resolution decides: exactly one reading resolving is that reading; both resolving is `MIGRATION.STATEMENT_UNRESOLVED` naming both readings; neither is `MIGRATION.STATEMENT_UNRESOLVED` naming both attempts. +- A side with no namespace resolves when exactly one namespace of the contract declares the model; when several do, `MIGRATION.STATEMENT_UNRESOLVED` lists the qualified candidates. The default namespace is not special. +- Names are matched exactly, including case. + +### Resolution + +Resolution happens in the framework CLI package, before any family code runs, against the origin contract and the destination contract. Both sides resolve to domain coordinates: namespace id, model name, and field name when present. Storage never appears in resolution. + +- **Model rename `A:B`.** `A` must be a model of the origin contract; `B` must be a model of the destination contract; `B` must not be a model of the origin contract; `A` must not be a model of the destination contract. Each failure is `MIGRATION.STATEMENT_UNRESOLVED` with a message naming the contract searched and the names found there. `A` and `B` may differ in namespace, in name, or both. +- **Field rename `M.a:N.b`.** `M` and `N` must resolve to the same destination model (so `M` is spelled as the destination contract spells it); a field rename that names two different models is `MIGRATION.STATEMENT_INVALID` saying a field cannot move between models. The origin counterpart of that model is the origin model an earlier `--rename` statement renamed to it, or the model of the same coordinate in the origin contract when none did. `a` must be a field of the origin counterpart; `b` must be a field of the destination model; `b` must not be a field of the origin counterpart; `a` must not be a field of the destination model. +- **Value objects, variants, relations.** A model coordinate may name a variant; it resolves like any model. A coordinate naming a value object, or a field of one, is `MIGRATION.STATEMENT_UNRESOLVED` saying value object renames are not supported in this release. A field coordinate naming a relation field resolves; its storage effect is nothing (see below). +- **Extension contract spaces.** Statements resolve against the application space only. A name that resolves only in an extension space is unresolved. +- **Origin contract absent.** When the command has at least one statement and no origin contract (see § Origin contract for `db update`), the command fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` naming the hash it looked for and the directory it looked in, before anything is planned. + +Resolved statements are ordered as given and carry: kind `rename`, entity `model` or `field`, the origin domain coordinate and the destination domain coordinate. + +### Planner input and the storage effect + +- The framework planner input (the options object of `MigrationPlanner.plan` in `packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts`, and the SQL family's `SqlMigrationPlannerPlanOptions`) gains `statements: readonly ResolvedMigrationStatement[]`, required, empty when none. Both the `migration plan` path and the `db update` path pass it. +- The SQL family maps each resolved statement to its storage effect by reading the storage bridge of the two models: a model rename compares the origin model's table coordinate with the destination model's; a field rename compares the origin field's column with the destination field's. Equal means no storage change and the statement is applied with no operations. Different means a table rename, a schema move, both, or a column rename. +- Each storage effect is applied in order to a working copy of the origin schema (the `createWorkingSchema` mechanism from prisma/orm#30570, on Postgres and SQLite), producing the rename call and its companion calls computed against the working copy as earlier statements leave it. The diff then runs on the adjusted origin. +- A rename whose target table's effective control policy is not `managed` fails planning with a planner conflict `statementRefused` whose summary names the table and the policy; the CLI surfaces it under `MIGRATION.PLANNING_FAILED`. Nothing is skipped silently. +- The planner's success result (not `MigrationPlan`, which hand-written migrations also implement) carries `appliedStatements`, one per statement in order, each with the resolved statement and the positions, in the plan's operations, of the operations it accounts for (operation ids are not unique within a plan, so statements do not refer to them); `migration plan` and `db update` print them under `Statements applied` in human output and as `appliedStatements` in JSON, after the operation list. One framework function, `describeMigrationStatement`, describes each one in the form `rename model "Profile" to "User"` and `rename field "User.name" to "User.fullName"`, using domain names; the CLI adds that description to each entry it reports. + +### Operations + +- **Table rename.** As on `main` and prisma/orm#30570: the table is renamed, foreign keys pointing at it are retargeted, policies follow, and each primary key, unique and foreign key is paired with the destination constraint of the same kind on the same columns and renamed to the destination's explicit name, else the name derived from the new table name, when that differs from the database's name. Indexes and checks pair by content hash. A model statement whose two sides are in different namespaces resolves (the grammar allows it) but the planner refuses it with a `statementRefused` conflict saying that moving a model to another namespace is not supported in this release; the move (`alter table set schema` on Postgres, with the working-schema step and companion names after the move) ships with the namespace renames in slice 3. SQLite has one namespace, so the case cannot arise there. +- **Column rename.** A new `renameColumn` facade method and planner call on Postgres and SQLite, in the style of `renameTable`: precheck that the old column exists and the new does not, postcheck the reverse. Companions: each unique and foreign key on the column is renamed to the destination's explicit name, else the default name derived from the table and the new column name; each index whose content includes the column is renamed to the destination's wire name, since the wire name hashes the column names (ADR 243). Checks referencing the column are recreated by the existing check path. On SQLite the rename uses `ALTER TABLE ... RENAME COLUMN` and indexes are rebuilt by the existing index-replacement path. +- **Rendering.** `renameTable` and `renameColumn` calls render into `migration.ts` as the facade calls a user would write, and the facade shares the working schema so that re-running the file reproduces `ops.json` byte for byte. +- **Classification.** Dropping an index, a unique or foreign-key constraint, a check, a row-level-security policy, a default or a native enum type, and disabling row-level security, are `widening` on Postgres and SQLite. `db update` no longer asks consent for them and `migration plan`'s baseline consent no longer counts them. The upgrade fragment records this. + +### Origin contract for `db update` + +- `db update` reads the marker rows already (`readAllMarkers` in `packages/1-framework/3-tooling/cli/src/control-api/operations/db-run.ts`). For the application space, it takes the marker's `storageHash` and reads the snapshot with `readContractSnapshotJsonTolerant` from the project's snapshot store. A found snapshot is deserialized through the family's contract serializer and passed as `fromContract` into `planFromDiff` instead of `null`. +- When the marker is absent or the snapshot is not found, `fromContract` stays `null`, and the command fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` only if statements were given. Without statements, behaviour is unchanged. +- `db init` accepts no statements; the flag is not declared on it. + +### Refusal and consent + +Unchanged in this slice. `db update` keeps `--confirm`; `migration plan` keeps its baseline-only consent. A rename statement simply removes the drop and create from the plan, so consent is not asked for them. + +## Coherence rationale + +One reviewer can hold this because every piece is the chain one outcome needs: a `--rename` flag produces a statement, the framework resolves it against two contracts, the family turns it into a storage rename applied to the working copy, the planner emits the rename and its companions ahead of the diff, and the command reports it. The widening reclassification is in the slice because a SQLite rename rebuilds indexes and would otherwise ask for consent, which breaks the outcome. The column rename operation is in the slice because a project that can rename a model but not a field has not delivered the feature. + +## Scope + +**In:** the grammar and resolver in the framework CLI package; the `statements` planner input; the SQL family mapping and working-copy application on Postgres and SQLite; the `renameColumn` operation and its companions on both targets; the substrate salvaged from prisma/orm#30570 (working schema, destination-driven companion names, foreign-key pairing, `toOps`, widening reclassification); `--rename` on `migration plan` and `db update`; the origin contract for `db update`; the `Statements applied` output; the three error codes in the error reference; the CLI README's `migration plan` and `db update` sections; the Migration System subsystem doc; an upgrade fragment; journeys on both targets. + +**Out:** `--delete`, the refusal, `--confirm` removal (slice 2); `--convert`, `--backfill`, namespace and value renames, value object field renames, and a model move across namespaces (slice 3); Mongo (slice 4); the contract `hints` section, `@@hint`, any contract or snapshot change; any interactive prompt. + +## Pre-investigated edge cases + +| Case | Disposition | +| --- | --- | +| Statement names a table the control policy will not alter | Planner conflict, surfaced as `MIGRATION.PLANNING_FAILED`; nothing skipped. | +| Rename onto a name the origin still has (`A:B` with `B` present), including a swap | `MIGRATION.STATEMENT_UNRESOLVED`; the message says the new name already exists in the origin. | +| Same statements run twice against `db update` | The second run fails: the old name is not in the origin. | +| `@@map` keeps the table name across a model rename | Statement applied with zero operations; reported. | +| Relation field renamed | Applied with zero operations; reported. | +| Variant under single-table storage renamed | Zero operations unless the storage table changed; the discriminator value is slice 3. | +| Case-only table rename on Postgres (`user:User`) | Planned as a rename; the existing case guard never fires. | +| `migration plan --to ` or `--from ` | Statements resolve against whatever the two contracts are; nothing special. | +| `migration plan --from @empty` or an auto-baseline | There is no origin model to rename; any statement is unresolved. | +| `db update` with no marker or no snapshot for the marker hash | `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` when statements are given; unchanged otherwise. | +| A foreign key from another table to the renamed one | Retargeted and renamed to the destination name, as prisma/orm#30570 does. | +| Two statements renaming A to B and C to A in that order | Refused: the first statement's `B` resolves, the second's `A` is a new name that exists in the working origin until the first applies; resolution runs on contracts, not the working copy, so `A` exists in the origin and the statement is unresolved. Documented as the swap rule. | + +## Slice done conditions + +- The project DoD journey restricted to renames: on Postgres and SQLite, `migration plan` with a model and a field statement then `migrate` on a table with rows, a unique, a foreign key from another table, a secondary index, a check and (Postgres) a policy; rows and objects present under the new names; a further plan empty; `db verify --schema-only` clean; the same through `db update` with no prompt; a second `db update` with the same statements fails with `MIGRATION.STATEMENT_UNRESOLVED`. +- Re-running the planned `migration.ts` writes identical `ops.json` and `migration.json`. +- `pnpm fixtures:check` passes with no artifact churn. +- `pnpm lint:framework-vocabulary` count unchanged. + +## Open questions + +None. The spelling choices above are final for this slice. + +## References + +- [`../../spec.md`](../../spec.md), [`../../design-notes.md`](../../design-notes.md) +- prisma/orm#30570, branch `tml-3422-intent-hints-model-rename`: `working-schema.ts`, `table-rename-calls.ts`, `table-rename-constraint-renames.ts`, `index-and-check-renames.ts`, `rename-rls-references.ts`, `schema-tables.ts` on both targets, and `resolve-table-rename.ts` in the family. Copy the mechanism; drop every reference to hints, `ConsumedHint`, the contract section and `resolveHints`. +- prisma/orm#30331 (merged): `renameTable`, `apply-table-rename.ts`, the journeys `test/integration/test/cli-journeys/rename-table-migration*.e2e.test.ts`. +- [ADR 243](../../../../docs/architecture%20docs/adrs/ADR%20243%20-%20Name-identified%20indexes%20and%20exact-name%20adoption.md) for index wire names; [ADR 009](../../../../docs/architecture%20docs/adrs/ADR%20009%20-%20Deterministic%20Naming%20Scheme.md) for derived constraint names. diff --git a/projects/migration-statements/spec.md b/projects/migration-statements/spec.md new file mode 100644 index 000000000000..746c6e454693 --- /dev/null +++ b/projects/migration-statements/spec.md @@ -0,0 +1,140 @@ +# Project spec — Destructive changes need stated intent + +**Linear:** [Destructive changes need stated intent](https://linear.app/prisma-company/project/destructive-changes-need-stated-intent-7626c0107cd9) ([TML-3474](https://linear.app/prisma-company/issue/TML-3474)) · **Branch:** `tml-3474-migration-statements` + +## Purpose + +The migration planner diffs two states and cannot tell intent from the diff. A renamed model looks exactly like a dropped table plus a created one, and a removed model looks the same as one the user forgot. Today the planner plans the destruction and relies on a blanket consent prompt, or on a hand-written migration, to keep rows safe. Projects that use `db update` and keep no migration history cannot hand-write anything. + +This project makes two things true. The planner refuses any operation that loses data unless the user has said what they mean about that operation. And the user says it on the command line, in the vocabulary of their contract, as a statement the planner acts on: a rename, a deletion, a conversion or a backfill. Nothing is inferred, nothing is asked, and nothing is written into the contract source. + +This is the second attempt. The first stated intent in the contract source with `@@hint(was:)` and was shelved on 2026-10-05 as unintuitive. Its planner substrate is reused; its contract section is not. + +## At a glance + +A user renames the `Profile` model to `User`, renames its `name` field, and removes the `Legacy` model for good. The contract source already says all of that. The command says what it means: + +```text +$ prisma migration plan --name tidy-users --rename Profile:User --rename User.name:User.fullName --delete Legacy +✔ Planned 5 operation(s) + Rename table "Profile" to "User" widening + Rename constraint "Profile_pkey" to "User_pkey" widening + Rename column "User"."name" to "fullName" widening + Rename constraint "User_name_key" to "User_fullName_key" widening + Drop table "Legacy" destructive +``` + +`migration.ts` carries `renameTable`, `renameColumn` and `dropTable` calls, the same file a user could write by hand. `prisma db update` with the same statements does the same against a live database. Without `--delete Legacy`, both commands refuse: + +```text +✖ The plan would lose data. State what you mean about each operation: + Table "Legacy" would be dropped and its rows lost. + To rename it: --rename Legacy: + To delete it: --delete Legacy +``` + +A type change gets the same treatment with its own statements: + +```text +$ prisma migration plan --name age-to-int --convert User.age +⚠ migration.ts has a placeholder: replace it with the conversion, then run the file to re-emit. +``` + +## Decisions settled in discussion (2026-10-05) + +The reasoning and the alternatives rejected are in [`design-notes.md`](./design-notes.md). + +1. **Data loss is refused by default, in both commands.** An operation is destructive only when it loses data: dropping a model or field, deleting a value rows still carry, or changing a field's type in a way the database cannot keep. Drops of indexes, constraints, checks, policies and defaults lose nothing and are widening. `db update` already refuses through its consent prompt; `migration plan` gains the same refusal for every plan, not only auto-baselines. +2. **Intent is stated on the command line, up front.** There is no prompt that guesses whether a drop is a rename, and no detection of candidate pairs. The refusal is where the user learns what to type, and it prints each statement ready to paste. The eventual direction (see [`plan.md`](./plan.md) § Stretch goal and requirement 12 below) is that when a human runs the command in a terminal, the refusal becomes an interactive prompt that asks the user to state what each destructive operation is; the answers are the same statements. "No prompt" was the initial design stance, not a rule; the rule is that nothing is guessed. +3. **Four verbs, four nouns.** The verbs are `rename`, `delete`, `convert` and `backfill`. The nouns are namespace, model, field and value, addressed by contract coordinates such as `User`, `User.name`, `Status.ARCHIVED`, `Bug` and `Address.street`, with the namespace prefixed when the contract has more than one. Rename and delete apply to all four nouns; a model rename across namespaces is a move. Convert applies to fields and to variants whose discriminator value changed. Backfill applies to fields. +4. **Statements are intent, not hints.** A statement that cannot be resolved against the origin and destination contracts, or cannot be applied, is an error and nothing is planned. There is no "spent" or "ignored" statement. A statement that resolves and applies but changes nothing in storage, such as renaming a value object or a relation field, is reported as applied with no operations. +5. **Everything is in the application domain.** The command line never carries a table, column, collection, SQL expression or literal value. The framework resolves coordinates against the two contracts; the family turns the resolved statement into storage operations. +6. **`--delete` is per-operation consent and replaces `--confirm`.** Each destructive operation needs its own statement. A namespace delete covers everything in the namespace and the refusal still lists each model. The interactive consent question stays and asks per operation; answering it is the same as typing the statement. +7. **Convert and backfill scaffold the existing placeholder migration.** `--convert` writes the type change with `placeholder()` in the slot that carries the conversion: the `using` expression on Postgres, a data transform on Mongo. `--backfill` writes the scaffolded backfill transform. The user fills the slot in `migration.ts` and re-emits, exactly as the placeholder flow works today. Neither is available on `db update`, which has no file to fill; without `--backfill` the planner's existing temporary-default recipe applies, which loses nothing. +8. **The origin contract is required for renames and conversions.** `migration plan` has it from its origin ref. `db update` resolves the marker's contract hash in the local snapshot store. When no origin contract can be found, every rename and convert statement fails with one error, `--delete` still works, and the user hand-edits the migration if they have one. +9. **Statements compose in order** over a working copy of the origin, so a later statement names things by their new names. A swap of two names is refused in this project. +10. **One statement surface for every family.** Postgres and SQLite rename tables and columns; Mongo renames collections and rewrites documents for field renames; a value object field rename rewrites the stored JSON on SQL and the subdocuments on Mongo. Same statement, each family's own operations. + +## Non-goals + +- No prompt that guesses whether a drop is a rename, and no rename detection from matching columns or any other heuristic. A prompt that asks the human to state the intent, without guessing, is the stretch goal in [`plan.md`](./plan.md), not a non-goal. +- No intent in the contract source. `@@hint` and any attribute like it are not part of this project; the shelved attribute is not revived. +- No expressions, literals or values on the command line. A conversion or a backfill value is written into the migration file. +- No scaffolded migrations from `db update`. +- No split or merge of tables, no moving a field between models, no primary key changes, no moving a variant between single-table and multi-table storage, no making an existing model a variant. These stay hand-written data migrations. +- No `deprecated` lifecycle and no change to `db verify`. +- No reliance on the marker's `contract_json` column, which the docs call optional and diagnostic. The origin contract comes from the snapshot store or not at all. +- No reversal of the documented stance that `db update` is dev-only. + +## Place in the larger world + +- **Planner inputs.** Every planner takes a destination contract and an origin schema: derived from a contract snapshot for `migration plan`, introspected for `db update` and `db init`, and one differ runs over two schema IRs ([ADR 235](../../docs/architecture%20docs/adrs/ADR%20235%20-%20The%20schema%20differ%20walks%20two%20derived%20schema%20IRs.md)). `migration plan` also hands the SQL planner the origin contract as `fromContract` (`packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts`); the `db update` path passes `fromContract: null` (`packages/1-framework/3-tooling/migration/src/aggregate/strategies/plan-from-diff.ts`). Statements become the third input, and `db update` learns to supply the origin contract. +- **Where the origin contract comes from for `db update`.** The database marker stores the contract hash the database was last updated to ([ADR 021](../../docs/architecture%20docs/adrs/ADR%20021%20-%20Contract%20Marker%20Storage.md)), and `db update` advances the `db` ref to the same hash ([ADR 218](../../docs/architecture%20docs/adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md)). The content-addressed snapshot store ([ADR 240](../../docs/architecture%20docs/adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md)) holds the contract for that hash when the project has it. +- **Consent today.** `db update` pre-plans and refuses with `MIGRATION.DESTRUCTIVE_CHANGES` until the user types the database name or passes the engine's `--confirm ` token (`packages/1-framework/3-tooling/cli/src/orm/db/consent.ts`, `control-api/operations/db-update.ts`). `migration plan` applies the same refusal to auto-baselines only, with the project directory as the token (`control-api/operations/migration-plan.ts`). The CLI README's `db update` section still describes a `--yes` flag that no longer exists. +- **The placeholder flow.** `migration plan` already scaffolds data transforms with `placeholder()` slots for a required column on a non-empty table, a nullable-to-required change and an unsafe type change, writes `ops.json` as `[]` with `pendingPlaceholders`, and the user fills the slots and re-runs `migration.ts` to re-emit ([ADR 200](../../docs/architecture%20docs/adrs/ADR%20200%20-%20Placeholder%20utility%20for%20scaffolded%20migration%20slots.md), [ADR 196](../../docs/architecture%20docs/adrs/ADR%20196%20-%20In-process%20emit%20for%20class-flow%20targets.md), `packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts`). Under `db update`, whose policy excludes the `data` class, those strategies stand down and direct DDL is emitted. The Postgres `alterColumnType` operation already has a `using` option the planner never fills. +- **Rename substrate.** `renameTable` exists on Postgres and SQLite from prisma/orm#30331. The shelved prisma/orm#30570 refactored it onto a working schema shared by the planner and the hand-written facade, with companion constraint names taken from the destination contract and foreign keys paired by their own and referenced columns, and reclassified non-data drops as widening. That code is the starting point; the contract `hints` section it also added is not. +- **Operation classes.** Additive, widening, destructive and data (`packages/2-sql/9-family/src/core/migrations/types.ts`). Every Postgres type change is destructive today except a short list of safe widenings; Mongo classifies by validator comparison (`packages/3-mongo-target/1-mongo-target/src/core/migrations/mongo-planner.ts`). +- **Coordinates.** A contract entity is addressed by plane, namespace, kind and name ([ADR 221](../../docs/architecture%20docs/adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md), [ADR 224](../../docs/architecture%20docs/adrs/ADR%20224%20-%20Namespace%20concretions%20address%20entities%20by%20coordinate.md)); the contract source declares namespaces as `namespace { ... }` blocks; the query surface resolves a bare name through the sole namespace ([ADR 223](../../docs/architecture%20docs/adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md)). Variants and discriminators are [ADR 173](../../docs/architecture%20docs/adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md); value objects are [ADR 178](../../docs/architecture%20docs/adrs/ADR%20178%20-%20Value%20objects%20in%20the%20contract.md), stored as one JSONB column on SQL and a subdocument on Mongo. +- **Family vocabulary.** `packages/1-framework` may not name a family or target (`no-family-vocabulary-in-framework`, enforced by the `lint:framework-vocabulary` ratchet). The statement parser and resolver live there and speak only contract vocabulary. +- **Documentation already promising something like this.** The Data Contract and Migration System subsystem docs and ADR 001 describe `@hint(was: "old_name")` in the contract source as the planner's source of intent. This project replaces that promise. + +## Cross-cutting requirements + +1. **Statements are the only source of intent.** The planner never infers a rename, never asks whether a drop is one, and never reads intent from the contract. A change with no statement is planned as the diff shows it, and refused if that loses data. +2. **Contract vocabulary only.** A statement names namespaces, models, fields and values as the contract source names them. The framework resolves every coordinate against the origin and destination contracts before any family code runs; the family receives resolved entities, never names to look up. +3. **One statement surface, both commands.** `migration plan` and `db update` accept the same statements with the same meaning, and the plan a statement produces is the same whichever command ran it. `db update` obtains its origin contract from the marker hash through the snapshot store. +4. **Refusal names the way out.** When either command refuses, the error lists every destructive operation with the rename and delete statements that would resolve it, spelled exactly as the user would type them. In a terminal the same list is the consent question, asked per operation. +5. **An unusable statement is an error.** A coordinate that does not resolve in the contract it must resolve in, a rename whose new name already exists in the origin, a statement the control policy forbids, or a rename and convert statement with no origin contract: each fails before anything is planned, naming what was searched and what was found. Running the same statements twice fails the second time. +6. **Statements compose in order.** Each statement is applied to the working copy of the origin before the next is resolved, so `--rename Profile:User --rename User.name:User.fullName` is valid and the second statement uses the new table name. A swap is refused with a message. +7. **Same output as the hand-written route.** A plan produced from statements writes a `migration.ts` whose calls a user could have written by hand, and re-running that file reproduces `ops.json`. Statements are never recorded anywhere; the migration file is the record. +8. **Companion objects follow a rename, never a rebuild.** Every constraint and index whose name derives from a renamed table or column is renamed on both SQL targets to the name the destination contract gives it, explicit or derived. +9. **Scaffolds reuse the placeholder flow.** `--convert` and `--backfill` produce the existing placeholder-bearing migration, with the placeholder in the operation that consumes the value. The pending state, the empty `ops.json` and the re-emit path are unchanged. +10. **Destructive means data loss.** Drops of indexes, unique and foreign-key constraints, checks, row-level-security policies, defaults and native enum types, and disabling row-level security, are widening on every target. A type change the family knows to be widening needs no statement. +11. **Framework stays family-agnostic.** The statement grammar, parser and resolver carry no family or target vocabulary, and each family documents what every verb means for its storage. +12. **Built for the interactive prompt to drop in.** The refusal is a structured list of destructive operations, each with its entity in domain coordinates and the statements that would resolve it; the error text is rendered from that list. Statement strings are parsed, resolved and applied through one entry point that does not know whether they came from the command line or from an answer typed at a prompt, so statements can be added after the command has started. See [`plan.md`](./plan.md) § Stretch goal. + +## Transitional-shape constraints + +- The refusal in `migration plan` lands only after `--rename` exists for models and fields, so the first refusal a user meets has a rename-shaped way out for the common case. It ships with an upgrade fragment, since plans with drops change behaviour. +- `--delete` and the removal of `--confirm` land in the same slice, on both commands, with their upgrade fragment. No release has both. +- Each slice that adds a verb or a noun adds it for Postgres and SQLite in the same PR. Mongo follows in its own slice using the same framework surface, and the framework surface is designed for it from the first slice. +- The reclassification of non-data drops as widening lands with the first slice, salvaged from prisma/orm#30570, since a SQLite rename rebuilds indexes and would otherwise require consent. + +## Contract-impact + +None. `contract.json`, `contract.d.ts`, snapshots and migration manifests are unchanged. The `hints` section the shelved attempt added is not carried over. + +## Adapter-impact + +- **Postgres and SQLite:** planners take statements as an input; a column rename operation with prechecks and postchecks on each target; companion renames for column-derived names; the `using` placeholder on the Postgres type change; the SQLite type change scaffold; deletes under the existing drop operations; widening reclassification. +- **MongoDB:** a collection rename operation; field renames and value renames as data transforms; convert as a data transform scaffold; deletes under the existing drop and unset operations. + +## ADR pointer + +One ADR at close-out: destructive changes need stated intent, covering the refusal default and what counts as destructive, the statement vocabulary and its grammar, resolution against the two contracts, the per-operation consent model, the scaffold rule, and the origin-contract requirement. ADR 001, ADR 028 and the two subsystem docs that describe `@hint(was:)` are amended to describe statements instead. + +## Project DoD + +In addition to the team DoD floor in [`drive/calibration/dod.md`](../../drive/calibration/dod.md): + +- A journey on Postgres and on SQLite renames a model and a field and deletes another model in one `migration plan` with three statements, runs `migrate` on tables with rows, a unique constraint, a foreign key, a secondary index and a check, and afterwards the rows and objects are present under the new names, the deleted table is gone, a further plan is empty, and `db verify --schema-only` is clean. +- The same change through `db update` with the same statements on both targets produces the same database state and no consent prompt. Running the same `db update` again with the same statements fails on the first statement. +- The same change with `--delete` omitted makes both commands refuse, and the refusal text contains the exact statement that then succeeds. +- A type change through `migration plan --convert` writes a migration whose only placeholder is in the Postgres `using` slot; filling it and re-running the file produces an applicable migration that converts the rows. The same change without a statement is refused by both commands, and `--delete` on the field plans the direct alter. +- A new required field on a non-empty table through `migration plan --backfill` writes the scaffolded backfill transform; without the statement both commands plan the temporary-default recipe. +- On Mongo, the rename journey above with a collection and a document field, through both commands, and a value object field rename that rewrites subdocuments. +- A `db update` against a database whose marker hash has no local snapshot fails every rename and convert statement with one error naming the hash, and succeeds with `--delete` alone. +- `--confirm` is gone from both commands, the CLI README describes the statements and the consent model, and the upgrade fragments for the refusal and the consent change are validated by execution. +- The project's ADR and doc amendments are merged. + +## Open questions + +Settled in the first slice's spec, not here: the exact grammar of a coordinate when a namespace and a model share a name, the separator spelling, and whether `--delete` on a type-changed field keeps that word or gets its own. + +## References + +- [`design-notes.md`](./design-notes.md) — the discussion record, the verb and noun matrix, the scenarios, the prior art. +- [prisma/orm#30570](https://github.com/prisma/orm/pull/30570) — the shelved slice whose planner substrate this project reuses. +- [prisma/orm#30331](https://github.com/prisma/orm/pull/30331) — the rename-table operation. +- [ADR 200](../../docs/architecture%20docs/adrs/ADR%20200%20-%20Placeholder%20utility%20for%20scaffolded%20migration%20slots.md) — the placeholder flow convert and backfill reuse. +- [ADR 224](../../docs/architecture%20docs/adrs/ADR%20224%20-%20Namespace%20concretions%20address%20entities%20by%20coordinate.md) — entity coordinates. diff --git a/skills/journey-tests/02b-rename-with-hint.md b/skills/journey-tests/02b-rename-with-hint.md deleted file mode 100644 index 2c23e75b5b29..000000000000 --- a/skills/journey-tests/02b-rename-with-hint.md +++ /dev/null @@ -1,23 +0,0 @@ -# Journey 02b — Rename a column (capability gap: no in-contract hint) - -**Skills under test:** `prisma-8-contract`, `prisma-8-migrations`, `prisma-8-feedback`. - -## Prompt - -> Rename the `email` column on User to `emailAddress`. - -## Expected agent behavior - -- [ ] Names the capability gap explicitly: PN has no in-contract rename hint today; the planner sees a destructive drop+add. -- [ ] Edits the contract to rename the field (no fabricated `@hint(...)` syntax). -- [ ] Runs `contract emit`. -- [ ] Runs `migration plan --name rename-user-email`. -- [ ] Runs `migration show ` and confirms the plan is a `DROP COLUMN` + `ADD COLUMN` — the destructive shape the user was warned about. -- [ ] Walks the user through hand-editing `migration.ts` to rewrite the destructive op as a `RENAME COLUMN`, then `node migrations/app//migration.ts` to self-emit and `db migrate`. -- [ ] Offers to route a feature request for a first-class rename hint via `prisma-8-feedback`. - -## Success criteria - -- [ ] Migration that actually applies uses RENAME (because the agent hand-edited it), not DROP+ADD. -- [ ] No data lost. -- [ ] Agent did NOT confabulate `@hint(was: "...")` or any other unimplemented hint syntax. diff --git a/skills/journey-tests/02b-rename-with-statement.md b/skills/journey-tests/02b-rename-with-statement.md new file mode 100644 index 000000000000..b77ee773f4cb --- /dev/null +++ b/skills/journey-tests/02b-rename-with-statement.md @@ -0,0 +1,21 @@ +# Journey 02b — Rename a column with a statement + +**Skills under test:** `prisma-8-contract`, `prisma-8-migrations`. + +## Prompt + +> Rename the `email` column on User to `emailAddress`. + +## Expected agent behavior + +- [ ] Edits the contract to rename the field (no fabricated `@hint(...)` or other rename attribute in the contract source). +- [ ] Runs `contract emit`. +- [ ] Runs `migration plan --name rename-user-email --rename User.email:User.emailAddress`. +- [ ] Runs `migration show ` and confirms the plan renames the column instead of dropping and adding it. +- [ ] Runs `db migrate`. + +## Success criteria + +- [ ] Migration that actually applies uses RENAME, not DROP+ADD. +- [ ] No data lost. +- [ ] Agent did NOT confabulate `@hint(was: "...")` or any other rename syntax in the contract source. diff --git a/skills/journey-tests/README.md b/skills/journey-tests/README.md index 7f47248ab94e..e3d3ee90e33c 100644 --- a/skills/journey-tests/README.md +++ b/skills/journey-tests/README.md @@ -36,7 +36,7 @@ Cross-runtime automation against a moving model surface is its own research proj |---|---|---| | [`01-onboarding-first-query.md`](01-onboarding-first-query.md) | quickstart, contract, migrations, queries | AC4 | | [`02a-add-relation.md`](02a-add-relation.md) | contract, migrations, queries | AC5a | -| [`02b-rename-with-hint.md`](02b-rename-with-hint.md) | contract, migrations | AC5b | +| [`02b-rename-with-statement.md`](02b-rename-with-statement.md) | contract, migrations | AC5b | | [`02c-data-transform-placeholder.md`](02c-data-transform-placeholder.md) | migrations | AC5c | | [`02d-capability-gate.md`](02d-capability-gate.md) | queries, contract | AC5d | | [`02e-hash-mismatch.md`](02e-hash-mismatch.md) | debug, migrations | AC5e | diff --git a/skills/prisma-8/references/contract.md b/skills/prisma-8/references/contract.md index 2abec4d3a4ef..0a83de2fe266 100644 --- a/skills/prisma-8/references/contract.md +++ b/skills/prisma-8/references/contract.md @@ -427,11 +427,11 @@ Infer captures indexes at full fidelity — expression, partial (`where:`), uniq 4. **Reaching into internal packages from user code.** User-authored files (`prisma.config.ts`, `contract.ts`, `db.ts`, control clients) import only from `@internal//` and `@internal/extension-/`. Imports from `@internal/cli/*`, `@internal/family-*`, `@internal/target-*`, `@internal/adapter-*`, `@internal/driver-*`, or `@internal/sql-contract-*` are framework-internal — the façade composes them for you. If a façade subpath you need is missing for your target, see *What Prisma 8 doesn't do yet* and route to `references/feedback.md`. The canonical worked examples are `examples/multi-extension-monorepo/app/prisma.config.ts` and `examples/prisma-8-postgis-demo/prisma.config.ts`. 5. **Confusing the config `extensions` with the TS builder's `extensions`.** Same packs, two surfaces, one field name but two shapes: `ormConfig({ extensions: [pgvector] })` (array of *control* descriptors from `@internal/extension-/control`) versus `defineContract({ extensions: { pgvector } })` (record of *pack* descriptors from `@internal/extension-/pack`). 6. **Writing a flat `prisma.config.ts`.** `export default defineConfig({ contract, extensions })` from the target config alone is the pre-rc.4 shape and fails with `CONFIG.VERSION_MARKER_MISSING`. Wrap it: `definePrismaConfig({ orm: ormConfig({...}) })`. -7. **Renaming a field and expecting the planner to detect it.** Prisma 8 has no in-contract rename hint; the planner sees a destructive drop+add. Hand-edit `migration.ts` after `migration plan` (see `references/migrations.md`), or use the keep-then-drop two-migration pattern. +7. **Renaming a model or field and expecting the planner to detect it.** The contract does not record renames, so the planner sees a destructive drop+add. State the rename on the command line with `--rename old:new` on `migration plan` or `db update` (see `references/migrations.md`); the planner then renames instead of dropping. Do not invent a rename attribute in the contract source. ## What Prisma 8 doesn't do yet -- **In-contract rename hint.** No `@@rename(old: ..., new: ...)` or similar. Use the workarounds in *Common Pitfalls* #7. To request first-class rename, file via `references/feedback.md`. +- **In-contract rename hint.** No `@@rename(old: ..., new: ...)` or similar; renames are stated with `--rename` on the command line, as in *Common Pitfalls* #7. - **Model validations.** No declarative `@validates(...)` surface. Validate in application code (arktype). To request declarative validations in the contract, file via `references/feedback.md`. - **Lifecycle callbacks** (`beforeSave`, `afterCreate`, etc.). Not supported. Use middleware (`references/runtime.md`) or app code. To request lifecycle callbacks, file via `references/feedback.md`. - **Soft delete / `paranoid: true`.** No built-in soft-delete column. Add a nullable `deletedAt DateTime?` and filter explicitly in queries (or in middleware). To request built-in soft delete, file via `references/feedback.md`. @@ -454,8 +454,8 @@ Infer captures indexes at full fidelity — expression, partial (`where:`), uniq - [ ] Edited the contract source (`contract.prisma` or `contract.ts`), not an emitted artefact. - [ ] For new extension namespaces: added the package, imported its control descriptor (`@internal/extension-/control`), added it to `extensions: [...]` in `ormConfig({...})` (and the matching pack descriptor to `defineContract({extensions: {...}})` if using the TS builder). - [ ] `prisma.config.ts` is the envelope form — `definePrismaConfig({ orm: ormConfig({...}) })` — not a flat `defineConfig({...})`. -- [ ] For renames: hand-edited `migration.ts` after `migration plan` (or used the keep-then-drop two-migration pattern) — Prisma 8 has no rename hint today. +- [ ] For renames: passed `--rename old:new` to `migration plan` or `db update` (or hand-edited `migration.ts` where statements do not cover the rename). - [ ] Ran `pnpm prisma contract emit` after the edit (or let the Vite plugin re-emit on save). - [ ] Confirmed `contract.json` and `contract.d.ts` updated next to the source. - [ ] Did **not** hand-edit `contract.json` / `contract.d.ts`. -- [ ] Did **not** confabulate a missing feature (validations, callbacks, soft delete, scopes, in-contract rename hint) — referred the user to *What Prisma 8 doesn't do yet* + `references/feedback.md`. +- [ ] Did **not** confabulate a missing feature (validations, callbacks, soft delete, scopes, a rename attribute in the contract source) — referred the user to *What Prisma 8 doesn't do yet* + `references/feedback.md`. diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 708a165781dd..7ca3c4d48e03 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -74,6 +74,9 @@ Treat the rendered import lines as framework-managed on both targets: | `MIGRATION.DESTRUCTIVE_CHANGES` | `db update` run non-interactively without consent | Re-run with `--confirm ` (the database name from the connection), or `--dry-run` to preview. | | `CONTRACT.MARKER_MISMATCH` | `db verify` (finding, exit 4) | The marker disagrees with the contract hash (**Postgres:** `prisma_contract.marker`; **Mongo:** `_prisma_migrations`). The DB is at a different contract version than the code thinks. Either run a migration forward, or — if the DB is correct and the marker is stale after a manual fix-up — run `db sign`. | | `CONTRACT.MARKER_MISSING` | `db verify` (finding, exit 4), runtime startup (warning) | The DB has no marker yet. Run `prisma db init --db ` to baseline an empty database, `db update --db ` to apply the current contract directly, or `db sign --db ` if the schema already matches the contract. | +| `MIGRATION.STATEMENT_INVALID` | `migration plan` / `db update` with `--rename` | The statement is malformed, mixes a model and a field, renames something to itself, swaps two names, or repeats a name. The error's next step is the statement to type instead; use it. | +| `MIGRATION.STATEMENT_UNRESOLVED` | `migration plan` / `db update` with `--rename` | A name is not where a rename needs it. The error lists the names it found and says what to type: the statement the right way round, the order that works, or to leave out a rename that has already happened. | +| `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` | `db update --rename` | `db update` has no snapshot of the contract the database is at. Follow the error's steps: emit that contract, run `db update --advance-ref --dry-run` and check it plans no operations, then run it without `--dry-run` to store its snapshot, then emit the new contract and run the rename. Do not drop the statements to get past it: without them the plan drops the renamed table with its rows. | ## Decision — which path do you take? @@ -359,7 +362,7 @@ On Postgres the operations are **methods on the `Migration` base class**, each t **Postgres** operations (representative set, all `this.({...})`): - Tables: `createTable`, `renameTable`, `dropTable`. `renameTable` reads the migration's start and end contracts and returns the table rename plus a rename of every constraint and index named after the old table, so spread it: `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })`. -- Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `dropColumn`, `alterColumnType`, `setNotNull`, `dropNotNull`, `setDefault` (`column: col(name, nativeType, { default: lit(value) or fn(expression), codecRef })`), `dropDefault`. +- Columns: `addColumn` (`column: col(name, nativeType, { codecRef })`), `renameColumn` (also renames the constraints and indexes named after the column, so spread it: `...this.renameColumn({ table: 'User', column: 'email', to: 'emailAddress' })`), `dropColumn`, `alterColumnType`, `setNotNull`, `dropNotNull`, `setDefault` (`column: col(name, nativeType, { default: lit(value) or fn(expression), codecRef })`), `dropDefault`. - Constraints: `addPrimaryKey`, `addForeignKey`, `addUnique`, `addCheckConstraint`, `renameConstraint` (`kind`: `primaryKey`, `unique`, `foreignKey` or `checkConstraint`), `renameCheckConstraint`, `dropCheckConstraint`, `dropConstraint`. - Indexes: `createIndex`, `renameIndex`, `dropIndex`. - Enums: `createNativeEnumType`, `addNativeEnumValue`, `dropNativeEnumType`. @@ -494,7 +497,7 @@ Interactively, consent is typing the database name back (the prompt names it). I 6. **Aggregate `check` closure in Postgres `this.dataTransform`.** Returning `count(*)` or `bool_and(...)` breaks the precheck/postcheck contract — both sides resolve to constants. Use a rowset shape: `select('id').where().limit(1)`. 7. **Two contract references in one migration.** Building a query plan against a different contract than the one passed to `this.dataTransform(endContract, ...)` raises `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH`. Always import `endContract` once at module scope and use the same reference. 11. **Calling Postgres operations as free functions.** `addColumn('public', 'user', {...})` does not exist as an import; the operations are `this.addColumn({ schema, table, column })` and friends on the `Migration` base class, with `col(...)` building the column. Only `col`, `rawSql`, `placeholder`, `Migration`, and `MigrationCLI` are imported. -8. **Renaming and expecting the planner to detect it (Postgres).** Prisma 8 has no in-contract rename hint today; the planner emits a destructive drop+add. Hand-edit `migration.ts` to rewrite the destructive op as a `rawSql({ ... })` that issues `ALTER TABLE ... RENAME COLUMN ...` (or use the two-migration keep / backfill / drop pattern), then self-emit. See `references/contract.md` § *Edit a field — rename*. +8. **Renaming a model or field without a statement (Postgres, SQLite).** The planner cannot tell a rename from a drop and a create, so without a statement it plans a destructive drop+add. State the rename on the command line: `migration plan --name --rename User:Person --rename Person.email:Person.emailAddress`, or the same `--rename` flags on `db update`. A field is named through its model as the new contract names it. The planner then renames the table or column and the constraints and indexes named after it. For a rename statements do not cover, such as a model that keeps its name but changes its `@@map`, hand-edit `migration.ts` with `...this.renameTable(...)` or `...this.renameColumn(...)`, then self-emit. 9. **Planning with no `db` ref and no `--from` in a project that already has migrations.** The origin falls through to the empty database, which would make the plan a full-create migration; `migration plan` refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN` rather than writing it. Pick the exit that matches your intent — the error lists them, and `references/migration-model.md` § *The trap* explains which to choose. 10. **Hand-authoring `migration.ts` from a blank file, or rewriting the rendered import line.** Migration files are framework-rendered — let `prisma migration plan` (or `migration new`) render the package, then edit only the holes the framework leaves for you. On Postgres leave the rendered `@internal/postgres/migration` (or `@internal/sqlite/migration`) import path alone; on Mongo leave `@prisma/orm-mongo/target/migration` as rendered. Add symbols to the existing import line rather than introducing new import paths. @@ -503,7 +506,7 @@ Interactively, consent is typing the database name back (the prompt names it). I - **Runtime-apply migrations.** Prisma 8 doesn't apply pending migrations from your app's startup code (the "Drizzle pattern" for serverless / edge). Workaround: run `prisma db migrate` from your deploy pipeline before the app starts. If you need runtime-apply built-in, file a feature request via the `references/feedback.md` skill. - **Seeds-as-first-class.** Prisma 8 doesn't ship a `prisma db seed` equivalent. Workaround: write a TypeScript script that imports your `db` instance and runs your setup queries; invoke it from `package.json`'s scripts. If you need first-class seeding, file a feature request via the `references/feedback.md` skill. - **Migration squashing.** Prisma 8 doesn't squash older migrations into a baseline. They accumulate; for very large histories, manual baseline-and-truncate is the path. If you need built-in squashing, file a feature request via the `references/feedback.md` skill. -- **In-contract rename hints.** The planner cannot detect that a field rename is a rename rather than a drop+add. Workaround: hand-edit `migration.ts` to issue a `RENAME COLUMN` via `rawSql(...)`, or use a keep / backfill / drop pattern across two migrations. If you need a contract-level rename hint, file a feature request via the `references/feedback.md` skill. +- **Renames beyond models and fields, and renames on MongoDB.** `--rename` covers models and fields on Postgres and SQLite. It does not rename enum values, namespaces or value objects, and does not move a model to another namespace. On MongoDB, `--rename` fails with `MIGRATION.PLANNING_FAILED` and plans nothing. Workaround: follow the error's advice and make the rename by hand in `mongosh` on each database, before a plan made without `--rename` is applied there: `renameCollection` for a model, and for a field turn off the collection's validator, drop each unique index that includes the field, then `updateMany({}, { $rename: ... })`. Applying the plan then creates the new indexes and turns the validator back on. There is no Mongo rename operation to write in `migration.ts`. If you need one of these, file a feature request via the `references/feedback.md` skill. ## Graph and history commands diff --git a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts index c9e0c3f3f38d..0263639b48a2 100644 --- a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts @@ -3,11 +3,12 @@ import { describe, expect, it } from 'vitest'; import { applyMigration, int, text } from './harness'; // --------------------------------------------------------------------------- -// Destructive operations (drop table / drop index / replace index) +// Drops: a table drop is destructive; an index drop, alone or in a replacement, is widening // --------------------------------------------------------------------------- -describe('SQLite Migration E2E - Destructive operations', () => { +describe('SQLite Migration E2E - Drops', () => { const DESTRUCTIVE = { allowedOperationClasses: ['additive', 'destructive'] } as const; + const WIDENING = { allowedOperationClasses: ['additive', 'widening'] } as const; it('drops a table removed from the contract', async () => { await applyMigration( @@ -43,7 +44,7 @@ describe('SQLite Migration E2E - Destructive operations', () => { destination: defineContract({ models: { User: model('User', { fields: { id: int.id(), email: text } }) }, }), - policy: DESTRUCTIVE, + policy: WIDENING, }, async ({ schema }) => { expect(schema.tables['User']!.indexes).toHaveLength(0); @@ -72,7 +73,7 @@ describe('SQLite Migration E2E - Destructive operations', () => { ), }, }), - policy: DESTRUCTIVE, + policy: WIDENING, }, async ({ schema }) => { const cols = schema.tables['User']!.indexes.map((i) => [...(i.columns ?? [])]); diff --git a/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts b/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts index 56eb38c2e6df..b4894d121f4c 100644 --- a/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/fk-preservation.test.ts @@ -449,12 +449,11 @@ describe('SQLite Migration E2E - FK preservation through recreate-table', () => // Origin: Post.author_id is a plain INT with no FK. // Destination: Post.author_id gains a FK → User.id. // This emits `foreign_key_mismatch`, which `recreateTableStrategy` - // absorbs into a destructive recreate. The new FK postcheck (added by + // absorbs into a widening recreate. The new FK postcheck (added by // `buildRecreatePostchecks`) verifies that `pragma_foreign_key_list` // reports the FK after recreate — if the postcheck SQL is wrong, the // runner fails before the harness's schema verify would have caught // it. - const DESTRUCTIVE = { allowedOperationClasses: ['additive', 'destructive'] } as const; const User = model('User', { fields: { id: int.id(), name: text } }); const PostNoFk = model('Post', { fields: { id: int.id(), title: text, authorId: int.column('author_id') }, @@ -473,7 +472,7 @@ describe('SQLite Migration E2E - FK preservation through recreate-table', () => { origin: defineContract({ models: { User, Post: PostNoFk } }), destination: defineContract({ models: { User, Post: PostWithFk } }), - policy: DESTRUCTIVE, + policy: WIDENING, seed: async (driver) => { await driver.query('INSERT INTO "User" (id, name) VALUES (?, ?)', [1, 'Alice']); await driver.query('INSERT INTO "Post" (id, title, author_id) VALUES (?, ?, ?)', [ diff --git a/test/e2e/framework/test/sqlite/migrations/harness.ts b/test/e2e/framework/test/sqlite/migrations/harness.ts index 433378583ca5..c90d5397b62a 100644 --- a/test/e2e/framework/test/sqlite/migrations/harness.ts +++ b/test/e2e/framework/test/sqlite/migrations/harness.ts @@ -13,6 +13,7 @@ import { issueOutcome, type MigrationOperationPolicy, type MigrationRunnerFailure, + planOriginOf, } from '@prisma/orm-sqlite/components/control'; import type { Contract } from '@prisma/orm-sqlite/contract/types'; import { field } from '@prisma/orm-sqlite/contract-builder'; @@ -141,6 +142,8 @@ export async function applyMigration( schema: emptySchema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: fw, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -171,6 +174,8 @@ export async function applyMigration( schema: currentSchema, policy, fromContract: options.origin ?? null, + origin: planOriginOf(options.origin ?? null), + statements: [], frameworkComponents: fw, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/_harness/mongo.ts b/test/integration/test/_harness/mongo.ts index 79ce067f0984..7edfc0cc5a85 100644 --- a/test/integration/test/_harness/mongo.ts +++ b/test/integration/test/_harness/mongo.ts @@ -86,6 +86,8 @@ async function pushContract(connectionUri: string, contractJson: unknown): Promi schema, policy: initPolicy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/_harness/postgres.ts b/test/integration/test/_harness/postgres.ts index a6ff0e0aa116..89a8a53599ad 100644 --- a/test/integration/test/_harness/postgres.ts +++ b/test/integration/test/_harness/postgres.ts @@ -119,6 +119,8 @@ async function pushContract(connectionString: string, contractJson: unknown): Pr schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, // Execute-only push (no TypeScript scaffold rendered), so this is cosmetic; diff --git a/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts b/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts index cd67a0bc317d..e6497be44770 100644 --- a/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts +++ b/test/integration/test/authoring/defaults-on-existing-tables.integration.test.ts @@ -6,7 +6,11 @@ import type { Contract } from '@internal/contract/types'; import postgresControlDriver from '@internal/driver-postgres/control'; import sqliteDriver from '@internal/driver-sqlite/control'; import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; -import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + createControlStack, + planOriginOf, +} from '@internal/framework-components/control'; import { buildFabricatedMigrationEdge } from '@internal/migration-tools/aggregate'; import type { SqlStorage } from '@internal/sql-contract/types'; import postgres from '@internal/target-postgres/control'; @@ -76,6 +80,8 @@ async function move( schema: await stack.family.introspect({ driver }), policy: fromContract === null ? INIT_ADDITIVE_POLICY : EVERY_CLASS, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: stack.frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -131,6 +137,8 @@ async function moveAndVerify( schema: introspected, policy: EVERY_CLASS, fromContract: after, + origin: planOriginOf(after), + statements: [], frameworkComponents: stack.frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/authoring/psl-defaults-apply-and-verify.integration.test.ts b/test/integration/test/authoring/psl-defaults-apply-and-verify.integration.test.ts index 64a6d78e13f7..9b8f01d554c4 100644 --- a/test/integration/test/authoring/psl-defaults-apply-and-verify.integration.test.ts +++ b/test/integration/test/authoring/psl-defaults-apply-and-verify.integration.test.ts @@ -3,7 +3,11 @@ import postgresAdapter from '@internal/adapter-postgres/control'; import type { Contract } from '@internal/contract/types'; import postgresControlDriver from '@internal/driver-postgres/control'; import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; -import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + createControlStack, + planOriginOf, +} from '@internal/framework-components/control'; import { buildFabricatedMigrationEdge } from '@internal/migration-tools/aggregate'; import { defineContract, field, model } from '@internal/postgres/contract-builder'; import type { SqlStorage } from '@internal/sql-contract/types'; @@ -42,6 +46,8 @@ async function applyContract( schema: await familyInstance.introspect({ driver }), policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -103,6 +109,8 @@ async function applyAndVerifyContract( schema: introspected, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: contract, + origin: planOriginOf(contract), + statements: [], frameworkComponents: postgresFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/authoring/sqlite-float-defaults-apply-and-verify.integration.test.ts b/test/integration/test/authoring/sqlite-float-defaults-apply-and-verify.integration.test.ts index 0d588b750019..b65925d3bb60 100644 --- a/test/integration/test/authoring/sqlite-float-defaults-apply-and-verify.integration.test.ts +++ b/test/integration/test/authoring/sqlite-float-defaults-apply-and-verify.integration.test.ts @@ -5,7 +5,11 @@ import sqliteAdapter from '@internal/adapter-sqlite/control'; import type { Contract } from '@internal/contract/types'; import sqliteDriver from '@internal/driver-sqlite/control'; import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; -import { APP_SPACE_ID, createControlStack } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + createControlStack, + planOriginOf, +} from '@internal/framework-components/control'; import { buildFabricatedMigrationEdge } from '@internal/migration-tools/aggregate'; import type { SqlStorage } from '@internal/sql-contract/types'; import { defineContract, field, model } from '@internal/sqlite/contract-builder'; @@ -51,6 +55,8 @@ describe('an infinite float default on SQLite', () => { schema: await familyInstance.introspect({ driver }), policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -90,6 +96,8 @@ describe('an infinite float default on SQLite', () => { schema: introspected, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: contract, + origin: planOriginOf(contract), + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 000c7c248a34..4e78494d769c 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -32,6 +32,8 @@ pnpm test:journeys | `brownfield-adoption.e2e.test.ts` | **Adopt Prisma on existing DB**: infer a PSL contract from the live DB, emit matching artifacts, `db verify --schema-only`, sign, verify, and then evolve via `db update`. **Schema mismatch**: emit a contract that doesn't match the DB, observe sign / schema-only verify failures, fix contract, retry | | `rename-table-migration.e2e.test.ts` | **Rename a table and keep its rows**: seed `userProfile` with a unique constraint, a foreign key, an index, row-level security and a policy, then drop the model's `@@map`. Journey R1 confirms the bare plan is refused by the case-change guard, which points at `this.renameTable`, and that a `renameTable` call naming a table the end contract lacks fails when `migration.ts` builds its operations. A migration created with `migration new` and `...this.renameTable({ table: 'userProfile', to: 'UserProfile' })` renames the table and each constraint and index named after it; `migrate` keeps the rows, the policy and RLS; `db verify --schema-only` is clean; a plan with no schema change is empty; and a later migration that removes the unique constraint, the foreign key and the index applies. Journey R5 changes the table name and its foreign key target in one schema change: a rename-only migration for it fails at `migrate`, which verifies against the end contract; made as a rename-only change and then a planned foreign key change, it keeps the rows and verifies clean | | `rename-table-migration.sqlite.e2e.test.ts` | The SQLite twin of the first rename journey, driven through a file database and the SQLite facade config: the rename plus a drop and a create of each index named after the old table, then the same empty plan and removal migration. A second journey follows the by-hand path of a `db update` project: the case guard gives the two-statement rename through `_prisma_rename_UserProfile`, and after running it `db update` drops and recreates the indexes named after the old table, keeps the rows and verifies clean | +| `rename-statements-migration.e2e.test.ts` | **Rename statements on the command line (Postgres)**: seed `Profile` with rows, a unique field, a secondary index, a check, row-level security with a policy, and a foreign key from `Post`; the destination renames the model to `User` and its field `name` to `fullName`. Journey S1 plans `migration plan --rename Profile:User --rename User.name:User.fullName`: only widening renames of the table, the column and the objects named after them, both statements reported as applied, and re-running the written `migration.ts` reproduces `ops.json` and `migration.json` byte for byte; after `migrate` the rows and every object are under the new names, a further plan is empty and `db verify --schema-only` is clean. Journey S2 applies the same statements with `db update` and no consent, checks that `db update --dry-run` without statements then plans nothing, and refuses the statements on a second run (`MIGRATION.STATEMENT_UNRESOLVED`). Journey S3 covers the errors: unresolved names, a field named through its old model (the message gives the corrected statement), and `db update --db ` with no snapshot to resolve against (`MIGRATION.STATEMENT_ORIGIN_UNKNOWN`, advising `--advance-ref`) | +| `rename-statements-migration.sqlite.e2e.test.ts` | The SQLite twin of journeys S1 and S2, driven through a file database and the SQLite facade config: the table and column renames, the index on the column dropped and created under its destination name, the foreign key from `Post` following the table, the byte-identical re-emit, the empty `db update --dry-run` after the renames, and the second `db update` refused | ### Graph features and refs diff --git a/test/integration/test/cli-journeys/rename-statements-migration.e2e.test.ts b/test/integration/test/cli-journeys/rename-statements-migration.e2e.test.ts new file mode 100644 index 000000000000..e775eb748389 --- /dev/null +++ b/test/integration/test/cli-journeys/rename-statements-migration.e2e.test.ts @@ -0,0 +1,388 @@ +/** + * Rename statements on the command line (Postgres). + * + * `--rename :` tells `migration plan` and `db update` that a model or a field was renamed, so the planner renames the table or column, and the constraints and indexes named after it, instead of dropping and creating them. + * + * Journey S1 creates `Profile` with rows, a unique field, a secondary index, a check, row-level security with a policy, and a `Post` model with a foreign key to it. The destination renames the model to `User` and its field `name` to `fullName`. `migration plan --rename Profile:User --rename User.name:User.fullName` plans only widening renames, lists both statements as applied, and re-running the written `migration.ts` reproduces `ops.json` and `migration.json` byte for byte. After `migrate`, the rows, the constraints, the indexes, the check and the policy are under the new names, a further plan is empty, and `db verify --schema-only` is clean. + * + * Journey S2 does the same through `db update`, with no consent asked; running the same statements again fails because the database's contract no longer has the old names. + * + * Journey S3 covers the errors: a statement whose names do not resolve, a field statement that names the field's model by its old name, and a `db update --db ` whose database has no contract snapshot to resolve against. + */ + +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + engineError, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbUpdate, + runDbVerify, + runMigrate, + runMigrationPlan, + selfEmitMigration, + setupJourney, + sql, + swapPslContract, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const RENAMES = ['--rename', 'Profile:User', '--rename', 'User.name:User.fullName'] as const; + +interface PlannedOperation { + readonly id: string; + readonly label: string; + readonly operationClass: string; +} + +interface AppliedStatementReport { + readonly description: string; + readonly operationIndexes: readonly number[]; +} + +async function createAppUserRole(connectionString: string): Promise { + await sql( + connectionString, + 'DO $$ BEGIN CREATE ROLE app_user; EXCEPTION WHEN duplicate_object THEN NULL; END $$', + ); +} + +async function seedRows(connectionString: string): Promise { + await sql( + connectionString, + `INSERT INTO "public"."Profile" (id, name, handle, tenant_id) + VALUES (1, 'alice', 'al', 1), (2, 'bob', 'bo', 1); + INSERT INTO "public"."Post" (id, "profileId") VALUES (10, 1)`, + ); +} + +async function emitContract( + ctx: JourneyContext, + variant: 'contract-rename-statements-from' | 'contract-rename-statements-to', + label: string, +): Promise { + swapPslContract(ctx, variant); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `${label}: emit ${variant}: ${emit.stderr}`).toBe(0); +} + +/** The plan both commands make for the two statements: renames only, each companion after its rename. */ +const RENAME_OPERATIONS: readonly Omit[] = [ + { operationClass: 'widening', label: 'Rename table "Profile" to "User"' }, + { + operationClass: 'widening', + label: 'Rename primary key "Profile_pkey" to "User_pkey" on "User"', + }, + { operationClass: 'widening', label: 'Rename column "User"."name" to "fullName"' }, + { + operationClass: 'widening', + label: 'Rename unique constraint "Profile_name_key" to "User_fullName_key" on "User"', + }, + { + operationClass: 'widening', + label: + 'Rename index "Profile_name_handle_idx_f7a67607" to "User_fullName_handle_idx_46490beb" on "User"', + }, +]; + +function expectOnlyRenames(operations: readonly PlannedOperation[], label: string): void { + expect( + operations.map(({ label: text, operationClass }) => ({ label: text, operationClass })), + `${label}: only widening renames, no table or column dropped or created`, + ).toEqual(RENAME_OPERATIONS); +} + +function expectAppliedStatements( + applied: readonly AppliedStatementReport[], + operations: readonly PlannedOperation[], + label: string, +): void { + expect( + applied.map((entry) => entry.description), + `${label}: both statements applied, in order`, + ).toEqual(['rename model "Profile" to "User"', 'rename field "User.name" to "User.fullName"']); + expect( + applied.map((entry) => entry.operationIndexes.length), + `${label}: the model rename and the field rename with their companions`, + ).toEqual([2, 3]); + expect( + applied.flatMap((entry) => entry.operationIndexes.map((index) => operations[index]?.id)), + `${label}: the statements' positions name the plan's operations, in order`, + ).toEqual(operations.map((operation) => operation.id)); +} + +async function expectRenamedState( + ctx: JourneyContext, + connectionString: string, + label: string, +): Promise { + const rows = await sql( + connectionString, + `SELECT id, "fullName", handle FROM "public"."User" ORDER BY id`, + ); + expect(rows.rows, `${label}: rows present under the new names`).toEqual([ + { id: 1, fullName: 'alice', handle: 'al' }, + { id: 2, fullName: 'bob', handle: 'bo' }, + ]); + const live = await sql( + connectionString, + `SELECT + to_regclass('"public"."Profile"') AS old, + (SELECT array_agg(attname::text ORDER BY attname) FROM pg_attribute + WHERE attrelid = '"public"."User"'::regclass AND attnum > 0 AND NOT attisdropped) AS columns, + (SELECT array_agg(conname::text || ':' || contype::text ORDER BY conname) FROM pg_constraint + WHERE conrelid = '"public"."User"'::regclass) AS constraints, + (SELECT array_agg(indexname::text ORDER BY indexname) FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'User') AS indexes, + (SELECT array_agg(policyname::text) FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'User') AS policies, + (SELECT relrowsecurity FROM pg_class WHERE oid = '"public"."User"'::regclass) AS rls, + (SELECT array_agg(confrelid::regclass::text) FROM pg_constraint + WHERE conrelid = '"public"."Post"'::regclass AND contype = 'f') AS post_references`, + ); + expect(live.rows[0], `${label}: old table gone, every object under the new names`).toEqual({ + old: null, + columns: ['fullName', 'handle', 'id', 'tenant_id'], + constraints: [ + 'User_fullName_key:u', + 'User_pkey:p', + expect.stringMatching(/^handle_present(_[0-9a-f]+)?:c$/), + ], + indexes: ['User_fullName_handle_idx_46490beb', 'User_fullName_key', 'User_pkey'], + policies: [expect.stringMatching(/^tenant_read_[0-9a-f]+$/)], + rls: true, + post_references: ['"User"'], + }); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +function packageFiles(ctx: JourneyContext, dirName: string) { + const dir = join(ctx.testDir, 'migrations', 'app', dirName); + return { + ops: readFileSync(join(dir, 'ops.json'), 'utf-8'), + migration: readFileSync(join(dir, 'migration.json'), 'utf-8'), + source: readFileSync(join(dir, 'migration.ts'), 'utf-8'), + }; +} + +withTempDir(({ createTempDir }) => { + describe('Journey S1: rename a model and a field with migration plan --rename', () => { + const db = useDevDatabase(); + + it( + 'plans widening renames in place of drops, re-emits byte for byte, and keeps rows and objects under the new names', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, 'contract-rename-statements-from', 'S1.01'); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `S1.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `S1.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + await seedRows(db.connectionString); + const origin = latestMigrationDirName(ctx); + await emitContract(ctx, 'contract-rename-statements-to', 'S1.04'); + + const plan = await runMigrationPlan(ctx, [ + '--name', + 'tidy-users', + '--from', + origin, + ...RENAMES, + '--json', + ]); + expect(plan.exitCode, `S1.05: plan with statements: ${plan.stderr}`).toBe(0); + const planned = parseJsonOutput<{ + ok: boolean; + operations: readonly PlannedOperation[]; + appliedStatements: readonly AppliedStatementReport[]; + }>(plan); + expect(planned.ok, 'S1.05: plan succeeds').toBe(true); + expectOnlyRenames(planned.operations, 'S1.05'); + expectAppliedStatements(planned.appliedStatements, planned.operations, 'S1.05'); + + const renameDir = latestMigrationDirName(ctx); + const written = packageFiles(ctx, renameDir); + expect(written.source, 'S1.06: migration.ts holds the facade calls').toContain( + "...this.renameTable({ schema: 'public', table: 'Profile', to: 'User' })", + ); + expect(written.source, 'S1.06: migration.ts holds the column rename').toContain( + "...this.renameColumn({ schema: 'public', table: 'User', column: 'name', to: 'fullName' })", + ); + const reEmit = await selfEmitMigration(ctx, ['--dir', `migrations/app/${renameDir}`]); + expect(reEmit.exitCode, `S1.07: re-run migration.ts: ${reEmit.stderr}`).toBe(0); + const reEmitted = packageFiles(ctx, renameDir); + expect(reEmitted.ops, 'S1.07: ops.json is byte-identical after re-emit').toBe(written.ops); + expect(reEmitted.migration, 'S1.07: migration.json is byte-identical after re-emit').toBe( + written.migration, + ); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `S1.08: migrate: ${apply.stderr}`).toBe(0); + await expectRenamedState(ctx, db.connectionString, 'S1.09'); + + const migrationCount = getMigrationDirs(ctx).length; + const fresh = await runMigrationPlan(ctx, ['--from', renameDir, '--json']); + expect(fresh.exitCode, `S1.10: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'S1.10: plan is empty').toBe(true); + expect(getMigrationDirs(ctx), 'S1.10: nothing written').toHaveLength(migrationCount); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S2: rename a model and a field with db update --rename', () => { + const db = useDevDatabase(); + + it( + 'applies the renames without consent, and refuses the same statements on a second run', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, 'contract-rename-statements-from', 'S2.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S2.02: db update creates Profile: ${create.stderr}`).toBe(0); + await seedRows(db.connectionString); + await emitContract(ctx, 'contract-rename-statements-to', 'S2.03'); + + const update = await runDbUpdate(ctx, [...RENAMES, '--json']); + expect(update.exitCode, `S2.04: db update with statements: ${update.stderr}`).toBe(0); + const updated = parseJsonOutput<{ + ok: boolean; + plan: { operations: readonly PlannedOperation[]; destination: { storageHash: string } }; + appliedStatements: readonly AppliedStatementReport[]; + }>(update); + expect(updated.ok, 'S2.04: db update succeeds without consent').toBe(true); + expectOnlyRenames(updated.plan.operations, 'S2.04'); + expectAppliedStatements(updated.appliedStatements, updated.plan.operations, 'S2.04'); + await expectRenamedState(ctx, db.connectionString, 'S2.05'); + + const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); + expect(dryRun.exitCode, `S2.06: db update --dry-run: ${dryRun.stderr}`).toBe(0); + expect( + parseJsonOutput<{ plan: { operations: readonly PlannedOperation[] } }>(dryRun).plan + .operations, + 'S2.06: db update without statements plans nothing against the live database', + ).toEqual([]); + + const markerHash = updated.plan.destination.storageHash; + expect( + existsSync(join(ctx.testDir, 'migrations', 'snapshots', markerHash, 'contract.json')), + 'S2.07: db update stored the snapshot of the contract it applied', + ).toBe(true); + const again = await runDbUpdate(ctx, [...RENAMES, '--json']); + expect(again.exitCode, 'S2.07: the same statements fail on a second run').not.toBe(0); + const refusal = engineError(again); + expect(refusal?.code, 'S2.07: the old name no longer resolves').toBe( + 'MIGRATION.STATEMENT_UNRESOLVED', + ); + expect(refusal?.why, 'S2.07: says the rename has already happened').toContain( + 'The origin contract already has "User" and has no "Profile", so this rename has already happened.', + ); + expect( + refusal?.nextActions?.map((action) => action.label), + 'S2.07: says to leave out the statement', + ).toEqual(['Leave out --rename Profile:User.']); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S3: statements that cannot be used', () => { + const db = useDevDatabase(); + + it( + 'refuses unresolved names, a field named through its old model, and a db update with no snapshot to resolve against', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, 'contract-rename-statements-from', 'S3.01'); + const create = await runDbUpdate(ctx, ['--db', db.connectionString, '--json']); + expect(create.exitCode, `S3.02: db update --db creates Profile: ${create.stderr}`).toBe(0); + expect( + existsSync(join(ctx.testDir, 'migrations', 'snapshots')), + 'S3.02: db update --db without --advance-ref stored no snapshot', + ).toBe(false); + await emitContract(ctx, 'contract-rename-statements-to', 'S3.03'); + + const noOrigin = await runDbUpdate(ctx, [ + '--db', + db.connectionString, + '--rename', + 'Profile:User', + '--json', + ]); + expect(noOrigin.exitCode, 'S3.04: db update without a snapshot is refused').not.toBe(0); + const originError = engineError(noOrigin); + expect(originError?.code, 'S3.04: the origin contract is unknown').toBe( + 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', + ); + expect(originError?.why, 'S3.04: names the snapshot directory').toContain( + join('migrations', 'snapshots'), + ); + expect( + originError?.nextActions?.map((action) => action.label).join('\n'), + 'S3.04: advises --advance-ref on the earlier run', + ).toContain('--advance-ref '); + + rmSync(join(ctx.testDir, 'migrations'), { recursive: true, force: true }); + await emitContract(ctx, 'contract-rename-statements-from', 'S3.05'); + const initial = await runMigrationPlan(ctx, ['--name', 'initial', '--from', '@empty']); + expect(initial.exitCode, `S3.05: plan initial: ${initial.stderr}`).toBe(0); + const origin = latestMigrationDirName(ctx); + await emitContract(ctx, 'contract-rename-statements-to', 'S3.06'); + + const unresolved = await runMigrationPlan(ctx, [ + '--from', + origin, + '--rename', + 'Profile:Nope', + '--json', + ]); + expect(engineError(unresolved)?.code, 'S3.07: an unknown new name is unresolved').toBe( + 'MIGRATION.STATEMENT_UNRESOLVED', + ); + expect(getMigrationDirs(ctx), 'S3.07: nothing written').toHaveLength(1); + + const wrongModel = await runMigrationPlan(ctx, [ + '--from', + origin, + '--rename', + 'Profile:User', + '--rename', + 'Profile.name:User.fullName', + '--json', + ]); + const invalid = engineError(wrongModel); + expect(invalid?.code, 'S3.08: a field named through its old model is invalid').toBe( + 'MIGRATION.STATEMENT_INVALID', + ); + expect(invalid?.why, 'S3.08: the message gives the corrected statement').toContain( + '--rename User.name:User.fullName', + ); + expect(getMigrationDirs(ctx), 'S3.08: nothing written').toHaveLength(1); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/rename-statements-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-statements-migration.sqlite.e2e.test.ts new file mode 100644 index 000000000000..4bc20cb9fd33 --- /dev/null +++ b/test/integration/test/cli-journeys/rename-statements-migration.sqlite.e2e.test.ts @@ -0,0 +1,333 @@ +/** + * Rename statements on the command line (SQLite). + * + * The SQLite twin of `rename-statements-migration.e2e.test.ts`, driven through a file database and the SQLite facade config. SQLite has no checks and no row-level security, so the fixture has a unique field, a secondary index and a foreign key from `Post`. SQLite cannot rename an index, so the index on the renamed column is dropped and created under the destination's name. + * + * Journey S1 plans the model and field renames with `migration plan --rename Profile:User --rename User.name:User.fullName`, re-emits `migration.ts` byte for byte, migrates, and checks the rows, the indexes and the foreign key under the new names. Journey S2 applies the same statements with `db update` without consent and refuses them on a second run. + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { join } from 'pathe'; +import { describe, expect, it } from 'vitest'; +import { withTempDir, writeProjectManifest } from '../utils/cli-test-helpers'; +import { + engineError, + getMigrationDirs, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + planMigrationAndSelfEmit, + runContractEmit, + runDbUpdate, + runDbVerify, + runMigrate, + runMigrationPlan, + selfEmitMigration, + sqlitePslConfigFixture, + timeouts, +} from '../utils/journey-test-helpers'; + +const FROM_PSL = `// use prisma-8 + +model Profile { + id Int @id + name String @unique + handle String + posts Post[] + + @@index([name, handle]) +} + +model Post { + id Int @id + profileId Int + profile Profile @relation(fields: [profileId], references: [id]) +} +`; + +const TO_PSL = `// use prisma-8 + +model User { + id Int @id + fullName String @unique + handle String + posts Post[] + + @@index([fullName, handle]) +} + +model Post { + id Int @id + profileId Int + profile User @relation(fields: [profileId], references: [id]) +} +`; + +const RENAMES = ['--rename', 'Profile:User', '--rename', 'User.name:User.fullName'] as const; + +interface PlannedOperation { + readonly id: string; + readonly label: string; + readonly operationClass: string; +} + +interface AppliedStatementReport { + readonly description: string; + readonly operationIndexes: readonly number[]; +} + +function setupSqliteJourney(createTempDir: () => string): JourneyContext & { dbPath: string } { + const testDir = createTempDir(); + const dbPath = join(testDir, 'journey.db'); + const configPath = join(testDir, 'prisma.config.ts'); + const config = readFileSync(sqlitePslConfigFixture, 'utf-8').replace('{{DB_PATH}}', () => dbPath); + writeFileSync(configPath, config, 'utf-8'); + writeFileSync(join(testDir, 'contract.prisma'), FROM_PSL, 'utf-8'); + writeProjectManifest(testDir); + return { testDir, configPath, outputDir: testDir, dbPath }; +} + +function withDatabase(dbPath: string, run: (db: DatabaseSync) => T): T { + const db = new DatabaseSync(dbPath); + try { + return run(db); + } finally { + db.close(); + } +} + +function seedRows(dbPath: string): void { + withDatabase(dbPath, (db) => { + db.exec( + `INSERT INTO "Profile" (id, name, handle) VALUES (1, 'alice', 'al'), (2, 'bob', 'bo'); + INSERT INTO "Post" (id, "profileId") VALUES (10, 1)`, + ); + }); +} + +async function emitContract(ctx: JourneyContext, psl: string, label: string): Promise { + writeFileSync(join(ctx.testDir, 'contract.prisma'), psl, 'utf-8'); + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `${label}: emit: ${emit.stderr}`).toBe(0); +} + +function expectOnlyRenames(operations: readonly PlannedOperation[], label: string): void { + const labels = operations.map((operation) => operation.label); + expect(labels.slice(0, 2), `${label}: the table rename, then the column rename`).toEqual([ + 'Rename table Profile to User', + 'Rename column name on User to fullName', + ]); + expect( + labels.slice(2), + `${label}: the index on the column is replaced under its new name`, + ).toEqual([ + expect.stringMatching(/^Drop index Profile_name_handle_idx_[0-9a-f]+ on User$/), + expect.stringMatching(/^Create index User_fullName_handle_idx_[0-9a-f]+ on User$/), + ]); + expect( + operations.every( + (operation) => + operation.operationClass === 'widening' || /^Create index/.test(operation.label), + ), + `${label}: the renames and the index drop are widening`, + ).toBe(true); +} + +function expectAppliedStatements( + applied: readonly AppliedStatementReport[], + operations: readonly PlannedOperation[], + label: string, +): void { + expect( + applied.map((entry) => entry.description), + `${label}: both statements applied, in order`, + ).toEqual(['rename model "Profile" to "User"', 'rename field "User.name" to "User.fullName"']); + expect( + applied.map((entry) => entry.operationIndexes.length), + `${label}: the model rename and the field rename with their companions`, + ).toEqual([1, 3]); + expect( + applied.flatMap((entry) => entry.operationIndexes.map((index) => operations[index]?.id)), + `${label}: the statements' positions name the plan's operations, in order`, + ).toEqual(operations.map((operation) => operation.id)); +} + +async function expectRenamedState( + ctx: JourneyContext & { dbPath: string }, + label: string, +): Promise { + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, "fullName", handle FROM "User" ORDER BY id`).all(), + columns: db + .prepare(`SELECT name FROM pragma_table_info('User') ORDER BY name`) + .all() + .map((row) => row['name']), + tables: db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`) + .all() + .map((row) => row['name']), + indexes: db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'User' ORDER BY name`, + ) + .all() + .map((row) => row['name']), + postReferences: db + .prepare(`SELECT "table", "from", "to" FROM pragma_foreign_key_list('Post')`) + .all(), + })); + expect(state.rows, `${label}: rows present under the new names`).toEqual([ + { id: 1, fullName: 'alice', handle: 'al' }, + { id: 2, fullName: 'bob', handle: 'bo' }, + ]); + expect(state.columns, `${label}: the column has its new name`).toEqual([ + 'fullName', + 'handle', + 'id', + ]); + expect(state.tables.includes('Profile'), `${label}: the old table is gone`).toBe(false); + expect(state.indexes, `${label}: the index on the column has its destination name`).toEqual([ + expect.stringMatching(/^User_fullName_handle_idx_[0-9a-f]+$/), + 'sqlite_autoindex_User_1', + ]); + expect(state.postReferences, `${label}: the foreign key from Post follows the table`).toEqual([ + { table: 'User', from: 'profileId', to: 'id' }, + ]); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +function packageFiles(ctx: JourneyContext, dirName: string) { + const dir = join(ctx.testDir, 'migrations', 'app', dirName); + return { + ops: readFileSync(join(dir, 'ops.json'), 'utf-8'), + migration: readFileSync(join(dir, 'migration.json'), 'utf-8'), + source: readFileSync(join(dir, 'migration.ts'), 'utf-8'), + }; +} + +withTempDir(({ createTempDir }) => { + describe('Journey S1 (SQLite): rename a model and a field with migration plan --rename', () => { + it( + 'plans the renames in place of drops, re-emits byte for byte, and keeps the rows under the new names', + async () => { + const ctx = setupSqliteJourney(createTempDir); + await emitContract(ctx, FROM_PSL, 'S1.01'); + const initial = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(initial.exitCode, `S1.02: plan initial: ${initial.stderr}`).toBe(0); + const applyInitial = await runMigrate(ctx); + expect(applyInitial.exitCode, `S1.03: migrate initial: ${applyInitial.stderr}`).toBe(0); + seedRows(ctx.dbPath); + const origin = latestMigrationDirName(ctx); + await emitContract(ctx, TO_PSL, 'S1.04'); + + const plan = await runMigrationPlan(ctx, [ + '--name', + 'tidy-users', + '--from', + origin, + ...RENAMES, + '--json', + ]); + expect(plan.exitCode, `S1.05: plan with statements: ${plan.stderr}`).toBe(0); + const planned = parseJsonOutput<{ + ok: boolean; + operations: readonly PlannedOperation[]; + appliedStatements: readonly AppliedStatementReport[]; + }>(plan); + expect(planned.ok, 'S1.05: plan succeeds').toBe(true); + expectOnlyRenames(planned.operations, 'S1.05'); + expectAppliedStatements(planned.appliedStatements, planned.operations, 'S1.05'); + + const renameDir = latestMigrationDirName(ctx); + const written = packageFiles(ctx, renameDir); + expect(written.source, 'S1.06: migration.ts holds the facade calls').toContain( + "...this.renameTable({ table: 'Profile', to: 'User' })", + ); + expect(written.source, 'S1.06: migration.ts holds the column rename').toContain( + "...this.renameColumn({ table: 'User', column: 'name', to: 'fullName' })", + ); + const reEmit = await selfEmitMigration(ctx, ['--dir', `migrations/app/${renameDir}`]); + expect(reEmit.exitCode, `S1.07: re-run migration.ts: ${reEmit.stderr}`).toBe(0); + const reEmitted = packageFiles(ctx, renameDir); + expect(reEmitted.ops, 'S1.07: ops.json is byte-identical after re-emit').toBe(written.ops); + expect(reEmitted.migration, 'S1.07: migration.json is byte-identical after re-emit').toBe( + written.migration, + ); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `S1.08: migrate: ${apply.stderr}`).toBe(0); + await expectRenamedState(ctx, 'S1.09'); + + const migrationCount = getMigrationDirs(ctx).length; + const fresh = await runMigrationPlan(ctx, ['--from', renameDir, '--json']); + expect(fresh.exitCode, `S1.10: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'S1.10: plan is empty').toBe(true); + expect(getMigrationDirs(ctx), 'S1.10: nothing written').toHaveLength(migrationCount); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S2 (SQLite): rename a model and a field with db update --rename', () => { + it( + 'applies the renames without consent, and refuses the same statements on a second run', + async () => { + const ctx = setupSqliteJourney(createTempDir); + await emitContract(ctx, FROM_PSL, 'S2.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S2.02: db update creates Profile: ${create.stderr}`).toBe(0); + seedRows(ctx.dbPath); + await emitContract(ctx, TO_PSL, 'S2.03'); + + const update = await runDbUpdate(ctx, [...RENAMES, '--json']); + expect(update.exitCode, `S2.04: db update with statements: ${update.stderr}`).toBe(0); + const updated = parseJsonOutput<{ + ok: boolean; + plan: { operations: readonly PlannedOperation[]; destination: { storageHash: string } }; + appliedStatements: readonly AppliedStatementReport[]; + }>(update); + expect(updated.ok, 'S2.04: db update succeeds without consent').toBe(true); + expectOnlyRenames(updated.plan.operations, 'S2.04'); + expectAppliedStatements(updated.appliedStatements, updated.plan.operations, 'S2.04'); + await expectRenamedState(ctx, 'S2.05'); + + const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); + expect(dryRun.exitCode, `S2.06: db update --dry-run: ${dryRun.stderr}`).toBe(0); + expect( + parseJsonOutput<{ plan: { operations: readonly PlannedOperation[] } }>(dryRun).plan + .operations, + 'S2.06: db update without statements plans nothing against the live database', + ).toEqual([]); + + expect( + existsSync( + join( + ctx.testDir, + 'migrations', + 'snapshots', + updated.plan.destination.storageHash, + 'contract.json', + ), + ), + 'S2.07: db update stored the snapshot of the contract it applied', + ).toBe(true); + const again = await runDbUpdate(ctx, [...RENAMES, '--json']); + expect(again.exitCode, 'S2.07: the same statements fail on a second run').not.toBe(0); + const refusal = engineError(again); + expect(refusal?.code, 'S2.07: the old name no longer resolves').toBe( + 'MIGRATION.STATEMENT_UNRESOLVED', + ); + expect(refusal?.why, 'S2.07: says the rename has already happened').toContain( + 'The origin contract already has "User" and has no "Profile", so this rename has already happened.', + ); + expect( + refusal?.nextActions?.map((action) => action.label), + 'S2.07: says to leave out the statement', + ).toEqual(['Leave out --rename Profile:User.']); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cross-package/postgres-control-policy-planner.test.ts b/test/integration/test/cross-package/postgres-control-policy-planner.test.ts index 72372f7b4f3c..12d50abfc447 100644 --- a/test/integration/test/cross-package/postgres-control-policy-planner.test.ts +++ b/test/integration/test/cross-package/postgres-control-policy-planner.test.ts @@ -102,6 +102,8 @@ async function planAgainst(contract: Contract, schema: PostgresDatab schema, policy: RECONCILIATION_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: 'app', snapshotsImportPath: '../../snapshots', @@ -294,6 +296,8 @@ describe('PostgresMigrationPlanner.plan tolerated vs managed add-column', async schema: liveSchemaWithUsersIdOnly, policy: RECONCILIATION_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: 'app', snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/cross-package/postgres-issue-planner.test.ts b/test/integration/test/cross-package/postgres-issue-planner.test.ts index 7b277499ec19..f9502e62a56a 100644 --- a/test/integration/test/cross-package/postgres-issue-planner.test.ts +++ b/test/integration/test/cross-package/postgres-issue-planner.test.ts @@ -199,16 +199,14 @@ describe('planIssues', () => { expect(calls[2]).toMatchObject({ factoryName: 'setNotNull' }); }); - it('DataTransformCall.toOp() throws MIGRATION.UNFILLED_PLACEHOLDER', () => { + it('DataTransformCall.toOp() rejects with MIGRATION.UNFILLED_PLACEHOLDER', async () => { const result = planAgainst(contractWithStatus(), actualWithoutStatus()); expect(result.ok).toBe(true); if (!result.ok) throw new Error('expected ok'); const dtCall = result.value.calls[1]!; expect(dtCall.factoryName).toBe('dataTransform'); - expect(() => dtCall.toOp()).toThrow( - expect.objectContaining({ code: 'MIGRATION.UNFILLED_PLACEHOLDER' }), - ); + await expect(dtCall.toOp()).rejects.toMatchObject({ code: 'MIGRATION.UNFILLED_PLACEHOLDER' }); }); }); @@ -700,6 +698,8 @@ describe('planIssues', () => { schema: actual, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: 'app', snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/date-time-defaults/sqlite-date-time-defaults.integration.test.ts b/test/integration/test/date-time-defaults/sqlite-date-time-defaults.integration.test.ts index f34697f7c550..f43cedfd7208 100644 --- a/test/integration/test/date-time-defaults/sqlite-date-time-defaults.integration.test.ts +++ b/test/integration/test/date-time-defaults/sqlite-date-time-defaults.integration.test.ts @@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'; import sqliteAdapter from '@internal/adapter-sqlite/control'; import type { Contract } from '@internal/contract/types'; import sql, { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; -import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { APP_SPACE_ID, planOriginOf } from '@internal/framework-components/control'; import type { SqlStorage } from '@internal/sql-contract/types'; import sqlite from '@internal/target-sqlite/control'; import { describe, expect, it } from 'vitest'; @@ -40,6 +40,8 @@ async function migrate( schema: await familyInstance.introspect({ driver: database }), policy: INIT_ADDITIVE_POLICY, fromContract: from, + origin: planOriginOf(from), + statements: [], frameworkComponents: sqliteFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -151,6 +153,8 @@ describe('a SQLite datetime default', () => { schema: await familyInstance.introspect({ driver: database }), policy: INIT_ADDITIVE_POLICY, fromContract: contract, + origin: planOriginOf(contract), + statements: [], frameworkComponents: sqliteFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-from.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-from.prisma new file mode 100644 index 000000000000..4d5649f4a84a --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-from.prisma @@ -0,0 +1,27 @@ +// use prisma-8 + +namespace public { + model Profile { + id Int @id + name String @unique + handle String + tenantId Int @map("tenant_id") + posts Post[] + + @@index([name, handle]) + @@check(name: "handle_present", expression: "(length(handle) > 0)") + @@rls + } + + model Post { + id Int @id + profileId Int + profile Profile @relation(fields: [profileId], references: [id]) + } + + policy_select tenant_read { + target = Profile + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-to.prisma b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-to.prisma new file mode 100644 index 000000000000..2340073d1d08 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/cli-journeys/contract-rename-statements-to.prisma @@ -0,0 +1,27 @@ +// use prisma-8 + +namespace public { + model User { + id Int @id + fullName String @unique + handle String + tenantId Int @map("tenant_id") + posts Post[] + + @@index([fullName, handle]) + @@check(name: "handle_present", expression: "(length(handle) > 0)") + @@rls + } + + model Post { + id Int @id + profileId Int + profile User @relation(fields: [profileId], references: [id]) + } + + policy_select tenant_read { + target = User + roles = [app_user] + using = "(tenant_id = 1)" + } +} diff --git a/test/integration/test/mongo/aggregate-e2e.test.ts b/test/integration/test/mongo/aggregate-e2e.test.ts index d11d8c28932e..b366058d8648 100644 --- a/test/integration/test/mongo/aggregate-e2e.test.ts +++ b/test/integration/test/mongo/aggregate-e2e.test.ts @@ -13,6 +13,7 @@ import { hasMigrations, issueOutcome, type MigrationRunnerPerSpaceOptions, + planOriginOf, } from '@internal/framework-components/control'; import type { MongoContract } from '@internal/mongo-contract'; import type { MongoMigrationPlanOperation } from '@internal/mongo-query-ast/control'; @@ -133,6 +134,8 @@ function planFor( schema: contractToMongoSchemaIR(fromContract), policy: ALL_POLICY, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/codec-rehydration-guardrail.test.ts b/test/integration/test/mongo/codec-rehydration-guardrail.test.ts index 5138f311dd0d..1696477fbc6b 100644 --- a/test/integration/test/mongo/codec-rehydration-guardrail.test.ts +++ b/test/integration/test/mongo/codec-rehydration-guardrail.test.ts @@ -163,6 +163,8 @@ function planAndRehydrate(contract: MongoContract): readonly MongoMigrationPlanO schema: contractToMongoSchemaIR(null), policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/migration-e2e.test.ts b/test/integration/test/mongo/migration-e2e.test.ts index d535d67afb1e..ecb1fdabbae4 100644 --- a/test/integration/test/mongo/migration-e2e.test.ts +++ b/test/integration/test/mongo/migration-e2e.test.ts @@ -2,6 +2,7 @@ import { MongoControlAdapterImpl } from '@internal/adapter-mongo/control'; import { coreHash, crossRef, profileHash } from '@internal/contract/types'; import mongoControlDriver, { MongoControlDriver } from '@internal/driver-mongo/control'; import { contractToMongoSchemaIR } from '@internal/family-mongo/control'; +import { planOriginOf } from '@internal/framework-components/control'; import { MongoCollection, type MongoContract, MongoIndex } from '@internal/mongo-contract'; import type { MongoMigrationPlanOperation } from '@internal/mongo-query-ast/control'; import { @@ -155,6 +156,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -177,6 +180,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -225,6 +230,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -267,6 +274,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -321,6 +330,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema: createSchema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -352,6 +363,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema: dropSchema, policy: ALL_POLICY, fromContract: indexedContract, + origin: planOriginOf(indexedContract), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -421,6 +434,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -484,6 +499,8 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, schema, policy: ALL_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/migration-m2-vocabulary.test.ts b/test/integration/test/mongo/migration-m2-vocabulary.test.ts index 7f3f12fb7930..d05ca8791b78 100644 --- a/test/integration/test/mongo/migration-m2-vocabulary.test.ts +++ b/test/integration/test/mongo/migration-m2-vocabulary.test.ts @@ -2,6 +2,7 @@ import { MongoControlAdapterImpl } from '@internal/adapter-mongo/control'; import { coreHash, crossRef, profileHash } from '@internal/contract/types'; import mongoControlDriver from '@internal/driver-mongo/control'; import { contractToMongoSchemaIR } from '@internal/family-mongo/control'; +import { planOriginOf } from '@internal/framework-components/control'; import { MongoCollection, type MongoCollectionInput, @@ -83,6 +84,8 @@ async function planAndApply( schema, policy: ALL_POLICY, fromContract: origin, + origin: planOriginOf(origin), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/migration-psl-authoring.test.ts b/test/integration/test/mongo/migration-psl-authoring.test.ts index 1a1f69ae03ef..84c30080edd9 100644 --- a/test/integration/test/mongo/migration-psl-authoring.test.ts +++ b/test/integration/test/mongo/migration-psl-authoring.test.ts @@ -7,6 +7,7 @@ import { type CodecLookupWithDescriptors, createDataTypeLookup, } from '@internal/framework-components/codec'; +import { planOriginOf } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { MongoContract } from '@internal/mongo-contract'; import { @@ -128,6 +129,8 @@ async function planAndApply( schema, policy: ALL_POLICY, fromContract: origin, + origin: planOriginOf(origin), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/runner.test.ts b/test/integration/test/mongo/runner.test.ts index 5c1812a18ed9..ddbb7e5d0ff0 100644 --- a/test/integration/test/mongo/runner.test.ts +++ b/test/integration/test/mongo/runner.test.ts @@ -11,6 +11,7 @@ import { createControlStack, hasMigrations, type MigrationRunnerPerSpaceOptions, + planOriginOf, } from '@internal/framework-components/control'; import type { MongoContract } from '@internal/mongo-contract'; import type { MongoMigrationPlanOperation } from '@internal/mongo-query-ast/control'; @@ -243,6 +244,8 @@ function planFor(contract: MongoContract, fromContract: MongoContract | null) { schema: contractToMongoSchemaIR(fromContract), policy: ALL_POLICY, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-nullable-lists.test.ts b/test/integration/test/mongo/target-runner/runner-nullable-lists.test.ts index 0d6a4a759033..537d8f4b5efa 100644 --- a/test/integration/test/mongo/target-runner/runner-nullable-lists.test.ts +++ b/test/integration/test/mongo/target-runner/runner-nullable-lists.test.ts @@ -72,6 +72,8 @@ beforeAll(async () => { schema: new MongoSchemaIR([]), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-polymorphism.test.ts b/test/integration/test/mongo/target-runner/runner-polymorphism.test.ts index 1aa006ff3eaa..d097153a97de 100644 --- a/test/integration/test/mongo/target-runner/runner-polymorphism.test.ts +++ b/test/integration/test/mongo/target-runner/runner-polymorphism.test.ts @@ -181,6 +181,8 @@ function planForContract( schema: origin, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-schema-verify.test.ts b/test/integration/test/mongo/target-runner/runner-schema-verify.test.ts index 303b9372f0d4..081dcb014682 100644 --- a/test/integration/test/mongo/target-runner/runner-schema-verify.test.ts +++ b/test/integration/test/mongo/target-runner/runner-schema-verify.test.ts @@ -92,6 +92,8 @@ function planForContract( schema: origin, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-validator-closed.test.ts b/test/integration/test/mongo/target-runner/runner-validator-closed.test.ts index 7ec6b1f6e727..75ec21616f2c 100644 --- a/test/integration/test/mongo/target-runner/runner-validator-closed.test.ts +++ b/test/integration/test/mongo/target-runner/runner-validator-closed.test.ts @@ -211,6 +211,8 @@ describe('MongoMigrationRunner - closed validators', () => { schema: originIR, policy: wideningPolicy, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-validator-widen.test.ts b/test/integration/test/mongo/target-runner/runner-validator-widen.test.ts index 709ed21767eb..03ff3ee53e8b 100644 --- a/test/integration/test/mongo/target-runner/runner-validator-widen.test.ts +++ b/test/integration/test/mongo/target-runner/runner-validator-widen.test.ts @@ -160,6 +160,8 @@ describe('MongoMigrationRunner - validator widen', () => { schema: originIR, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -230,6 +232,8 @@ describe('MongoMigrationRunner - validator widen', () => { schema: originIR, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -276,6 +280,8 @@ describe('MongoMigrationRunner - validator widen', () => { schema: staleOriginIR, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: null, + origin: null, + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/mongo/target-runner/runner-wire.test.ts b/test/integration/test/mongo/target-runner/runner-wire.test.ts index a24e0d9d5848..ac6279910b6b 100644 --- a/test/integration/test/mongo/target-runner/runner-wire.test.ts +++ b/test/integration/test/mongo/target-runner/runner-wire.test.ts @@ -5,6 +5,7 @@ import { createControlStack, type MigrationPlan, type MigrationPlanOperation, + planOriginOf, } from '@internal/framework-components/control'; import { type AggregateMigrationEdgeRef, @@ -131,6 +132,8 @@ function planForContract( schema: origin, policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract, + origin: planOriginOf(fromContract), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); @@ -751,6 +754,8 @@ describe('MongoMigrationRunner - E2E round-trip', () => { schema: new MongoSchemaIR([]), policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, fromContract: bareContract('00'), + origin: planOriginOf(bareContract('00')), + statements: [], frameworkComponents: [], snapshotsImportPath: '../../snapshots', }); diff --git a/test/integration/test/non-identifier-names/orm-spaced-column.integration.test.ts b/test/integration/test/non-identifier-names/orm-spaced-column.integration.test.ts index ad39f0b5c372..8072ca984818 100644 --- a/test/integration/test/non-identifier-names/orm-spaced-column.integration.test.ts +++ b/test/integration/test/non-identifier-names/orm-spaced-column.integration.test.ts @@ -53,6 +53,8 @@ async function migrateContract(connectionString: string): Promise { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts b/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts index 9cfb2aa9b514..14550806ee2c 100644 --- a/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts +++ b/test/integration/test/number-defaults/psl-number-defaults.integration.test.ts @@ -87,6 +87,8 @@ async function applyContract(connectionString: string, contract: Contract { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/ports/prisma/functional/issues-TML-1664-unknown-enum-value-read-error/issues-TML-1664-unknown-enum-value-read-error.test.ts b/test/integration/test/ports/prisma/functional/issues-TML-1664-unknown-enum-value-read-error/issues-TML-1664-unknown-enum-value-read-error.test.ts index 430106dc7048..2e052a94639e 100644 --- a/test/integration/test/ports/prisma/functional/issues-TML-1664-unknown-enum-value-read-error/issues-TML-1664-unknown-enum-value-read-error.test.ts +++ b/test/integration/test/ports/prisma/functional/issues-TML-1664-unknown-enum-value-read-error/issues-TML-1664-unknown-enum-value-read-error.test.ts @@ -64,6 +64,8 @@ async function pushContract(connectionString: string): Promise { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/ports/prisma/functional/issues-unmapped-driver-error-user-facing/issues-unmapped-driver-error-user-facing.test.ts b/test/integration/test/ports/prisma/functional/issues-unmapped-driver-error-user-facing/issues-unmapped-driver-error-user-facing.test.ts index 813f881a8448..da59c1534a95 100644 --- a/test/integration/test/ports/prisma/functional/issues-unmapped-driver-error-user-facing/issues-unmapped-driver-error-user-facing.test.ts +++ b/test/integration/test/ports/prisma/functional/issues-unmapped-driver-error-user-facing/issues-unmapped-driver-error-user-facing.test.ts @@ -61,6 +61,8 @@ async function pushContract(connectionString: string): Promise { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/referential-actions.integration.test.ts b/test/integration/test/referential-actions.integration.test.ts index 67d06b853bda..b8e480cd0a83 100644 --- a/test/integration/test/referential-actions.integration.test.ts +++ b/test/integration/test/referential-actions.integration.test.ts @@ -619,6 +619,8 @@ describe('referential actions integration', () => { schema: schemaIR, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/rls-ts-walking-skeleton.integration.test.ts b/test/integration/test/rls-ts-walking-skeleton.integration.test.ts index 7fb2363932ba..7cccb3b36b88 100644 --- a/test/integration/test/rls-ts-walking-skeleton.integration.test.ts +++ b/test/integration/test/rls-ts-walking-skeleton.integration.test.ts @@ -133,6 +133,8 @@ function planContract( schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/scalar-lists/orm-list-include-nulls.integration.test.ts b/test/integration/test/scalar-lists/orm-list-include-nulls.integration.test.ts index a2af5957c907..675c0e162c8e 100644 --- a/test/integration/test/scalar-lists/orm-list-include-nulls.integration.test.ts +++ b/test/integration/test/scalar-lists/orm-list-include-nulls.integration.test.ts @@ -68,6 +68,8 @@ async function migrateContract(connectionString: string, contract: Contract { schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts index ba2a91630ee1..e455806a05da 100644 --- a/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts +++ b/test/integration/test/scalar-lists/psl-list-roundtrip.integration.test.ts @@ -55,6 +55,8 @@ async function migrateContract( schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents: postgresFrameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/sql-orm-client/integration-helpers.ts b/test/integration/test/sql-orm-client/integration-helpers.ts index 510b3d1f12d4..a6e91735a10e 100644 --- a/test/integration/test/sql-orm-client/integration-helpers.ts +++ b/test/integration/test/sql-orm-client/integration-helpers.ts @@ -115,6 +115,8 @@ export async function withPushedContractRuntime( schema, policy: INIT_ADDITIVE_POLICY, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index c3d2aa56fc75..82178a70eae4 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -231,6 +231,14 @@ export const pslContractFixtures = { JOURNEY_FIXTURES_DIR, 'contract-rename-table-objects-dropped.prisma', ), + 'contract-rename-statements-from': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-statements-from.prisma', + ), + 'contract-rename-statements-to': join( + JOURNEY_FIXTURES_DIR, + 'contract-rename-statements-to.prisma', + ), } as const; export type PslContractVariant = keyof typeof pslContractFixtures; diff --git a/upgrade-instructions/pending/migration-statement-renames/app/instructions.md b/upgrade-instructions/pending/migration-statement-renames/app/instructions.md new file mode 100644 index 000000000000..44f34b576a6e --- /dev/null +++ b/upgrade-instructions/pending/migration-statement-renames/app/instructions.md @@ -0,0 +1,23 @@ +--- +changes: + - id: non-data-drops-are-widening + summary: | + Dropping an index, a unique or foreign-key constraint, a check, a row-level-security policy, a default or a native enum type, and disabling row-level security, are now `widening` operations on Postgres and SQLite. `prisma db update` no longer asks for consent before them, and `prisma migration plan` no longer counts them when it asks for consent to an auto-baseline. A script or CI job that passes `--confirm ` only to get past those operations now runs without it. + detection: + glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}" + matches: + - '(?:`, repeatable, to rename a model or a field instead of dropping and creating its table or column. Each side is `Model`, `namespace.Model`, `Model.field` or `namespace.Model.field`, and a field's model is named as the new contract names it: `--rename Profile:User --rename User.name:User.fullName`. +--- + +# Non-data drops are widening, and renames are stated on the command line + +## `non-data-drops-are-widening` + +For each `prisma db update ... --confirm ` or `prisma migration plan ... --confirm ` in a script or CI job, check what the consent was for. Consent is still required for dropping a table or a column, for a type change that can lose data, and for the other operations that remove data. If the `--confirm` was only there because the run dropped an index, a constraint, a check, a policy, a default or a native enum type, or disabled row-level security, remove it, or leave it in place: the command now runs without it, and an unused consent token does no harm. + +## `rename-statements` + +When a contract change renames a model or a field and the plan drops and creates its table or column, state the rename instead of editing the migration by hand: `prisma migration plan --rename :` or `prisma db update --rename :`. The planner then renames the table or column, and the constraints and indexes named after it, and keeps the rows. `db update` resolves the old names against the contract snapshot of the database's last update, which it stores when it advances a ref; with `--db `, pass `--advance-ref ` on the run before the rename so the snapshot exists. Nothing in an existing project needs to change. diff --git a/upgrade-instructions/pending/migration-statement-renames/extension/instructions.md b/upgrade-instructions/pending/migration-statement-renames/extension/instructions.md new file mode 100644 index 000000000000..ed5f32ba9fa6 --- /dev/null +++ b/upgrade-instructions/pending/migration-statement-renames/extension/instructions.md @@ -0,0 +1,75 @@ +--- +changes: + - id: planner-plan-statements + summary: | + Every call to a migration planner's `plan(...)` passes a new required `statements` list; pass `statements: []` when the call states no renames. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - '\.plan\(\s*\{(?!(?:[^{}]|\{[^{}]*\})*?(? Date: Wed, 7 Oct 2026 21:09:11 +0200 Subject: [PATCH 2/5] TML-3475: ignore a Gitleaks false positive on constraint names in the QA report Signed-off-by: willbot Signed-off-by: Will Madden --- .gitleaksignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitleaksignore b/.gitleaksignore index c228a13e5cb7..92e8be76ffd3 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -10,3 +10,4 @@ # secrets, and a later commit in the same PR replaced those lines (squash-merge PR). f86694b619c360078040116a01fc8826d7e7dfcc:test/integration/test/uuid-defaults/uuid-defaults.integration.test.ts:generic-api-key:348 f86694b619c360078040116a01fc8826d7e7dfcc:test/integration/test/uuid-defaults/uuid-defaults.integration.test.ts:generic-api-key:349 +873554568f3d4bfd48a8149b158fa8c0a9708ab3:projects/migration-statements/manual-qa-reports/2026-10-07-qa-opus.md:generic-api-key:113 From 362922ebc77580d9400535550ae996db4d550213 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:12:20 +0200 Subject: [PATCH 3/5] TML-3475: the ref-aware plan test expects a refused delta to write no package migration plan now plans every leg before writing anything, so a refused delta leaves no auto-baseline package on disk. Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cli.migration-plan-ref-aware.e2e.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts index 140d4f193f40..73ed7b8c40b4 100644 --- a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts +++ b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts @@ -412,7 +412,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'keeps baseline on disk when delta planner fails after baseline succeeded', + 'writes nothing when the delta planner refuses after the baseline was planned', async () => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { @@ -425,9 +425,7 @@ withTempDir(({ createTempDir }) => { const plan = await runMigrationPlan(ctx, ['--name', 'blocked-delta', '--json']); expect(plan.exitCode).toBe(2); - const dirs = listAppMigrationBundleDirs(ctx); - expect(dirs).toHaveLength(1); - expect(readManifest(ctx, dirs[0]!).from).toBeNull(); + expect(listAppMigrationBundleDirs(ctx)).toHaveLength(0); }); }); }, From 7e0d2f080145f867a2543746eed4d80f1e68a542 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:43:05 +0200 Subject: [PATCH 4/5] TML-3475: main's planner tests pass an origin and no statements The tests prisma/orm#30606 added call the planner without the origin and statements options this branch made required. They now pass origin: null and an empty statements list. Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/migrations/constraint-name-rename-planner.test.ts | 4 ++++ .../planner.autoincrement-existing-column.integration.test.ts | 2 ++ .../planner.long-table-constraint-names.integration.test.ts | 2 ++ .../planner.stated-constraint-names.integration.test.ts | 2 ++ 4 files changed, 10 insertions(+) diff --git a/packages/3-targets/3-targets/postgres/test/migrations/constraint-name-rename-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/constraint-name-rename-planner.test.ts index 8caa6c04ffb3..a9d8a8262575 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/constraint-name-rename-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/constraint-name-rename-planner.test.ts @@ -91,6 +91,8 @@ async function plannedOperations( schema: postgresContractToSchema(from, postgresTypeComponents), policy: { allowedOperationClasses: [...allowed] }, fromContract: from, + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -195,6 +197,8 @@ describe('a stated constraint name that changes', () => { ), policy: { allowedOperationClasses: [...ALL_CLASSES] }, fromContract: buildContract({ primaryKey: 'post_primary' }), + origin: null, + statements: [], frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.autoincrement-existing-column.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.autoincrement-existing-column.integration.test.ts index 2ef52a3dd03b..8559cf0cde24 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.autoincrement-existing-column.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.autoincrement-existing-column.integration.test.ts @@ -112,6 +112,8 @@ describe('autoincrement() on an existing integer column', { concurrent: false }, schema, policy, fromContract: null, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.long-table-constraint-names.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.long-table-constraint-names.integration.test.ts index a3e0bc878e8b..15fe4283f3c2 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.long-table-constraint-names.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.long-table-constraint-names.integration.test.ts @@ -139,6 +139,8 @@ describe('constraints of a table whose name is over 58 bytes', { concurrent: fal schema: contractSchema(from), policy, fromContract: from, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.stated-constraint-names.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.stated-constraint-names.integration.test.ts index 63bc363bfeed..ad5b96eec7f6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.stated-constraint-names.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.stated-constraint-names.integration.test.ts @@ -139,6 +139,8 @@ describe('a contract that starts stating constraint names', { concurrent: false schema, policy, fromContract: from, + origin: null, + statements: [], frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', From 47b7ec76a6f07593fd389ee108be32aa5c7acb44 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:43:08 +0200 Subject: [PATCH 5/5] TML-3475: rename constraints on the schema the rename statements left Main's pass that renames constraints whose names change read the schema from before the plan's rename statements. When a table is renamed and a new table takes its old name, it paired the old table's primary key with the new table's and planned a rename on a table that is only being created. It now reads the working schema, as the rest of the plan does. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/constraint-name-renames.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 6 +++++- .../test/migrations/planner.statements.test.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/constraint-name-renames.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/constraint-name-renames.ts index 3876c55573c7..43b04b5532e2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/constraint-name-renames.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/constraint-name-renames.ts @@ -80,7 +80,7 @@ export function constraintNameRenames( } /** - * The constraint renames of a plan: {@link constraintNameRenames} for every table the start tree the planner was given and the end contract both have. The start tree comes from the start contract for `migration plan` and from the database for `db update`, so a database Prisma 8 created under derived names is renamed too once the contract states other names. + * The constraint renames of a plan: {@link constraintNameRenames} for every table the start tree and the end contract both have, with the start tree as the plan's rename statements left it. The start tree comes from the start contract for `migration plan` and from the database for `db update`, so a database Prisma 8 created under derived names is renamed too once the contract states other names. */ export function plannedConstraintNameRenames( options: Pick< diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index dbc93a28fb43..be8d7f14cdd8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -382,7 +382,11 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr } const renamePartition = partitionPostgresCallsByControlPolicy( - [...indexRenames.calls, ...checkRenames.calls, ...plannedConstraintNameRenames(options)], + [ + ...indexRenames.calls, + ...checkRenames.calls, + ...plannedConstraintNameRenames({ ...options, schema }), + ], options.contract, ); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts index 352c4b6195b4..e1e9798c79f8 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.statements.test.ts @@ -147,6 +147,20 @@ describe('Postgres planner, model statements', () => { expect(labels.slice(2)).toContain('Create table "audit"'); }); + it('renames no constraint on a new table that takes the renamed table’s old name', async () => { + const toWithNewProfile = withModels( + contractOf('User', uniqueEmail, 'to', (profile) => ({ + Profile: postTableNamed('Profile', profile), + })), + { User: 'User', Profile: 'Profile' }, + ); + const labels = await labelsOf(plan(from, toWithNewProfile, [renameModel('Profile', 'User')])); + expect( + labels.filter((label) => label.startsWith('Rename') && label.endsWith('on "Profile"')), + ).toEqual([]); + expect(labels).toContain('Create table "Profile"'); + }); + it('applies a statement whose table does not change with no operations', async () => { const mapped = withModels(contractOf('profile', uniqueEmail, 'to'), { User: 'profile' }); const fromMapped = withModels(contractOf('profile', uniqueEmail, 'from'), {