From 873554568f3d4bfd48a8149b158fa8c0a9708ab3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 21:06:37 +0200 Subject: [PATCH 001/109] 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 002/109] 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 4f84a0506f02fa909f914a338e523d1b4bd07dff Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:10:56 +0200 Subject: [PATCH 003/109] TML-3476: slice 2 spec and plan, the engine statement-prompt slice spec, and the @@map decision Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/deferred.md | 2 + projects/migration-statements/plan.md | 4 +- .../slices/engine-statement-prompt/spec.md | 101 ++++++++++++++++ .../slices/refusal/plan.md | 53 +++++++++ .../slices/refusal/spec.md | 108 ++++++++++++++++++ 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 projects/migration-statements/slices/engine-statement-prompt/spec.md create mode 100644 projects/migration-statements/slices/refusal/plan.md create mode 100644 projects/migration-statements/slices/refusal/spec.md diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 5fe9c97830dc..8aa4b78b9144 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -10,6 +10,8 @@ A user keeps `model User` and changes `@@map("users")` to `@@map("app_users")`. **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. +**Decided 2026-10-07 by Will:** no statement for this case in this project. Slice 2's refusal keeps slice 1's manual steps for both commands. A statement that can name storage objects rather than models and fields is wanted later; Will floated `--rename table/users:app_users` as an illustration only, and the syntax needs a critical design discussion and a survey of established prior art before anything is chosen. + ## 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`). diff --git a/projects/migration-statements/plan.md b/projects/migration-statements/plan.md index c7395c5741c6..53031a39e2ed 100644 --- a/projects/migration-statements/plan.md +++ b/projects/migration-statements/plan.md @@ -26,7 +26,9 @@ Four slices. The first three stack: the statement surface with renames, then the **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. +**Design settled 2026-10-07 (Will).** Consent is a statement the user answers with, not a token copied back: the CLI engine gains `ctx.prompt.statement`/`statements`, answered on the command line by verb flags (`--delete Legacy`, `--rename Legacy:Archive`, `--allow User`) and interactively by typing the statement when a human runs the command. The engine change is its own slice, built in prisma/prisma-cli first: [`slices/engine-statement-prompt/spec.md`](./slices/engine-statement-prompt/spec.md). Slice 2's spec is [`slices/refusal/spec.md`](./slices/refusal/spec.md). The `@@map`-only gap gets no statement (see `deferred.md`). Operations that lose no data (`SET NOT NULL`, safe type widenings, MongoDB validator and index changes) stop being destructive. + +**Hands to.** The refusal shape every later verb hooks its statements into; the per-operation consent model; `--confirm` no longer consents to data loss. ### Slice 3 — Convert and backfill scaffold the placeholder migration, and the remaining nouns diff --git a/projects/migration-statements/slices/engine-statement-prompt/spec.md b/projects/migration-statements/slices/engine-statement-prompt/spec.md new file mode 100644 index 000000000000..99e07c8c3db6 --- /dev/null +++ b/projects/migration-statements/slices/engine-statement-prompt/spec.md @@ -0,0 +1,101 @@ +# Slice spec — The CLI engine asks for a statement, and a verb flag answers it + +**Project:** [`projects/migration-statements/`](../../spec.md) · **Engine slice, built in prisma/prisma-cli** (`packages/cli-engine`) · **Linear:** TML-3476 (slice 2 depends on it) · **Branch (prisma-cli):** `engine-statement-prompts` + +## At a glance + +An ORM command finds that applying its plan would drop the `Legacy` table. It asks the engine: + +```ts +const answer = await ctx.prompt.statement( + 'Table "Legacy" would be dropped and its rows lost. What do you mean?', + { + subject: 'Legacy', + verbs: ['rename', 'delete'], + validate: (verb, text) => resolve(verb, text), // ok or an error message + }, +); +// answer: { verb: 'delete', text: 'Legacy' } or { verb: 'rename', text: 'Legacy:Archive' } +``` + +Run by a script or an agent with no answer on the command line, the command fails before it changes anything: + +```text +✖ [CLI.CONSENT_REQUIRED] "Legacy" needs a statement, and the session is not interactive. +→ Pass --delete Legacy, or --rename Legacy: +``` + +Run with `--delete Legacy`, the question is answered before anything renders and no prompt is shown. Run by a human in a terminal, the question is asked and the human types `delete` or `rename Legacy:Archive`; a wrong answer is explained and asked again. `--yes` never answers it. `--delete Legacy` given to a run that never asks about `Legacy` is an error, not silence. + +## Chosen design + +### The primitive + +`PromptSurface` gains `statement(question, { subject, verbs, validate })`, beside `consent`. It is a consent: structurally undefaultable, never satisfied by `--yes`, never satisfied by Enter. + +- `subject` is the string the answer is about, in the command's own vocabulary (for the ORM: a model or field coordinate such as `Legacy` or `User.name`). +- `verbs` lists the verb flags that may answer it, in the order the refusal should print them. +- `validate(verb, text)` returns `undefined` to accept, or a message string to reject. The engine never interprets the text; the command does. +- It returns `{ verb, text }`. + +### How it is answered + +1. **Command line first.** For each verb in `verbs`, the engine looks through the run's unconsumed values of that verb flag for one that *names the subject*. The subject check is `text === subject` or `text.startsWith(subject + ':')`. The first match is validated; if `validate` accepts it, it is consumed and returned without rendering anything. If `validate` rejects it, the run fails with `CLI.PROMPT_INVALID` carrying the message, because a wrong flag cannot be corrected by re-prompting. +2. **Non-interactive, or `--yes`:** throw `CLI.CONSENT_REQUIRED`. The message names the subject; `nextActions` carries one `user-choice` per verb, written as the flag to pass (`--delete Legacy`, `--rename Legacy:`); `meta` carries `{ subject, verbs }`. +3. **Interactive:** render the question and read a line. The answer is ` ` or just `` (then `text` is the subject). An unknown verb, or a `validate` rejection, re-prompts under clack with the message; under the line renderer it fails with `CLI.PROMPT_INVALID`, as `consent` does today. + +### The batch form + +A command that has several questions asks them together: `ctx.prompt.statements([q1, q2, ...])`, each entry the same shape as a `statement` call, returning the answers in order. It exists because a refusal must name every unanswered question at once (project requirement 4), and a command cannot tell whether the session is interactive (the engine owns that). + +- Every question is first answered from the command line as above. +- Non-interactively, or under `--yes`, one `CLI.CONSENT_REQUIRED` is thrown that lists every question still unanswered, each with its flag forms in `nextActions`, and `meta.unanswered: [{ subject, verbs }]`. +- Interactively, the unanswered questions are asked one after another, in order. +- `statement(q)` is `statements([q])[0]`. + +### Verb flags + +- Verb flags are shared flags, declared by the engine like `--confirm`: `kind: "parsed"`, variadic, optional, reserved names. A command cannot declare a flag with a verb's name, and handlers never see the values. +- The set of verbs is registered at construction: `createCli({ ..., statementVerbs: ['rename', 'delete'] })` (or on the command family, whichever `RESERVED_FLAG_NAMES` is assembled from; the implementer chooses the smaller change and says so). A `statement()` call naming an unregistered verb is a construction error. +- Order across flags is kept: the engine records the position of every verb-flag value in argv, so a product that needs `--rename A:B --delete C` in the order written can read it. This slice only needs to consume values; it exposes the ordered list on the run state so the ORM's statement list can be built from it later. +- At the end of a run that settled successfully, an unconsumed verb-flag value fails the run with a new `CLI.CONSENT_UNUSED`: "`--delete Legacy` was given but nothing in this run asked about `Legacy`". A run that failed for another reason reports that reason only. `--confirm` keeps its current silent behaviour; changing it is not this slice. + +### What stays + +`consent(question, { token })` and `--confirm` are unchanged. The other products keep using them. + +## Coherence rationale + +One primitive, its flags and its error codes ship together because a prompt without its flag-driven equivalent breaks the engine's own rule, and a flag that can be given but never consumed is the silent-typo bug the ORM project refuses to ship. Order-keeping is in because it costs one array at parse time now and would cost a second parse of argv later. + +## Scope + +**In:** `statement` on `PromptSurface` and both renderers (clack, line); verb-flag registration and reservation; consumption and `CLI.CONSENT_UNUSED`; `CLI.CONSENT_REQUIRED` for a statement; tests in `packages/cli-engine/tests/prompts.test.ts` style, with scripted `answers`; the engine `README`, `docs/product/cli-style-guide.md` (consent section: a statement is the second consent form) and `docs/reference/error-reference.md`; `pnpm bump-cli-engine-version minor`. + +**Out:** any ORM command; `--confirm` leftovers; `select`-style menus; help text for the ORM's verbs (the ORM owns its briefs). + +## Pre-investigated edge cases + +| Case | Disposition | +| --- | --- | +| Two questions about the same subject | Two flag values needed; each `statement` call consumes one. Same as `consent`. | +| A subject that contains `:` | The subject check uses the subject string as given; the command picks subjects without `:`. Documented on the option. | +| `--delete Legacy --json` in a TTY | Format never decides interactivity (engine ruling). Flag answers; nothing is rendered. | +| A verb flag value given twice | Both kept; the second is unconsumed and reported by `CLI.CONSENT_UNUSED`. | +| `validate` throws | Treated as a bug: the engine does not catch it. | + +## Slice done conditions + +- `pnpm --filter @prisma/cli-engine test`, `typecheck` and `lint` green; the conformance suite (`pnpm check:conformance`) green. +- A probe command in the tests is driven four ways: flag answer, non-interactive refusal naming both verbs, interactive scripted answer `delete`, interactive scripted answer `rename Legacy:Archive`, plus the unused-flag failure and the rejected-flag failure. +- The engine version bumped minor; the published package is what the ORM's slice 2 pins. + +## Open questions + +None. Will decided on 2026-10-07: consents carry a verb; the answer is a free-text statement the command validates; a human in a terminal is asked interactively. + +## References + +- Project spec [`../../spec.md`](../../spec.md) decisions 2 and 6, requirement 12; [`../../plan.md`](../../plan.md) § Stretch goal. +- prisma-cli: `packages/cli-engine/src/execution/prompts.ts`, `shared-flags.ts`, `clack-renderer.ts`, `src/context.ts`; `docs/product/cli-style-guide.md` § consent; ADR 0004 (engine version pinning). +- `wip/slice-2-consent-options.md`: the option evaluation Will chose from. diff --git a/projects/migration-statements/slices/refusal/plan.md b/projects/migration-statements/slices/refusal/plan.md new file mode 100644 index 000000000000..33b348ce51bf --- /dev/null +++ b/projects/migration-statements/slices/refusal/plan.md @@ -0,0 +1,53 @@ +# Slice plan — Both commands refuse data loss until the user states what each operation means + +**Spec:** [`spec.md`](./spec.md) · **Linear:** [TML-3476](https://linear.app/prisma-company/issue/TML-3476) · **Branch:** `tml-3476-statement-refusal` · **Depends on:** `@prisma/cli-engine` 0.7 from [`../engine-statement-prompt/`](../engine-statement-prompt/spec.md) + +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 classes or rendering change, `pnpm check:error-reference` when error codes change, `pnpm lint:framework-vocabulary` when `packages/1-framework` changes. Never the full integration or package suites locally; run named files, and both rename-statements journeys plus the new ones. + +Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Dispatches 3 to 5 need the engine pin. + +## Dispatches + +### 1 — Destructive means data loss, on every target + +**Outcome.** `SET NOT NULL`, the known safe type widenings, SQLite rebuilds caused only by a nullability tightening, MongoDB index drops, validator tightenings and change-stream image settings are `widening`; drop table, drop column, drop collection, and type changes that can lose values stay `destructive`. The `MigrationOperationClass` doc and the Migration System doc § Operation Model say what each class means. Example migrations and planner goldens are regenerated where a class changed; `examples/prisma7-adoption/test/handover.test.ts` expectations follow. + +**Builds on.** Slice 1. + +**Hands to.** A `destructive` set the refusal can refuse without blocking a required column. + +### 2 — The planner result names what each destructive operation would lose + +**Outcome.** The framework type `MigrationStatementSubject` beside `AppliedMigrationStatement`; the planner success result carries `dataLoss: { operationIndex, subject }[]` and `accessWidening: { operationIndex, subject }[]`; the SQL family maps a dropped or type-changed table or column back to the origin contract's model or field when `fromContract` is present, else to a storage subject; MongoDB maps a dropped collection to its model; the transient DROP of a policy replacement is not listed. The aggregate planner and `planFromDiff` pass both lists through. The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop. + +**Builds on.** Dispatch 1. + +**Hands to.** The structured refusal, ready for the CLI to render and for the prompt to consume. + +### 3 — The engine pin, the verbs, and the refusal on `migration plan` + +**Outcome.** `@prisma/cli-engine` 0.7 pinned; the ORM command family registers the verbs `rename`, `delete`, `allow`; `migration plan` plans, turns every `dataLoss` entry into a `ctx.prompt.statements` question, re-plans with any `rename` answers, matches `delete` answers, and writes only when nothing is left unanswered. The auto-baseline consent (`refuseUnconsentedDestructiveBaseline`, `consentToken`, the plan-hash round trip) is gone; `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` are retired from this command; `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` is new. `Statements applied` and JSON `appliedStatements` carry `delete` lines with `verb`. Statement order comes from the engine's ordered verb-flag list. + +**Builds on.** Dispatch 2 and the engine. + +**Hands to.** The refusal shape on one command; the `delete` path end to end offline. + +### 4 — The refusal and `allow` on `db update` + +**Outcome.** `db update` asks the same questions per destructive operation and, in apply mode, an `allow` question per access-widening operation; `guardDestructiveChanges`, the consent prompt in `orm/db/consent.ts`, `acceptDataLoss`'s CLI path, `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` are gone from the CLI (the control API keeps `acceptDataLoss` and gains `statements`); a dry run lists `dataLoss` and asks nothing; `retryCommandFor` carries every verb. Without an origin snapshot, `delete` works on storage subjects and the refusal says why the names are storage names. + +**Builds on.** Dispatch 3. + +**Hands to.** Both commands on the one consent model. + +### 5 — Journeys, docs, upgrade fragments + +**Outcome.** The slice done conditions hold through journeys on Postgres, SQLite and MongoDB (rename plus delete through both commands; the refusal's `nextActions` flag then succeeds; `--confirm ` no longer consents; a required field plans without refusal; `--allow` on a policy drop). The CLI README's two command sections, the Migration System doc § Statements and § `db update`, the error reference, the CLI Style Guide's consent section, and `skills/prisma-8` describe statements as the data-loss consent. The app upgrade fragment records that `--confirm` no longer consents to data loss and that `migration plan` refuses every plan that loses data; the extension fragment records `dataLoss`, `accessWidening` and `MigrationStatementSubject`. Every `--confirm` in tests and journeys for these two commands is replaced. + +**Builds on.** Dispatch 4. + +**Hands to.** Slice DoD. + +## Open items + +- Whether the Mongo planner's `statementRefused` for `rename` needs its wording changed now that `delete` exists: dispatch 3 reads the text and decides. diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md new file mode 100644 index 000000000000..f4856ce0b99d --- /dev/null +++ b/projects/migration-statements/slices/refusal/spec.md @@ -0,0 +1,108 @@ +# Slice spec — Both commands refuse data loss until the user states what each operation means + +**Project:** [`projects/migration-statements/`](../../spec.md) · **Slice 2** · **Linear:** [TML-3476](https://linear.app/prisma-company/issue/TML-3476) · **Branch:** `tml-3476-statement-refusal` (on top of slice 1, prisma/orm#30638) · **Depends on:** the engine slice [`../engine-statement-prompt/spec.md`](../engine-statement-prompt/spec.md), published as `@prisma/cli-engine` 0.7. + +## At a glance + +A user removes the `Legacy` model and renames `Profile` to `User`, then plans without saying what they mean about `Legacy`: + +```text +$ prisma migration plan --name tidy-users --rename Profile:User +✖ [CLI.CONSENT_REQUIRED] 1 operation would lose data, and the session is not interactive. + Table "Legacy" would be dropped and its rows lost. +→ To rename it: --rename Legacy: +→ To delete it: --delete Legacy +``` + +With `--delete Legacy` the plan is written, and `Drop table "Legacy"` is listed as `destructive` with the statement that allowed it. Run by a human in a terminal, the same command asks: + +```text +? Table "Legacy" would be dropped and its rows lost. What do you mean? + (delete, or rename Legacy:) › delete +``` + +`prisma db update` behaves the same way against a live database. `--confirm ` no longer consents to data loss on either command, and there is no blanket consent: each operation that loses data has its own statement. Before `db update` drops a row-level-security policy or disables row-level security on a table, it asks the same way with the verb `allow`: `--allow User` consents to widening who can read or write `User`'s rows. + +## Chosen design + +### What is refused + +An operation is refused when its class is `destructive`, and after this slice an operation is `destructive` only when it loses rows or values: + +| Operation | Class after this slice | Subject | +| --- | --- | --- | +| Drop table (Postgres, SQLite), drop collection (MongoDB) | destructive | the model | +| Drop column (Postgres, SQLite) | destructive | the field | +| Alter column type outside the known safe widenings (Postgres); SQLite table rebuild caused by a type change | destructive | the field | +| `SET NOT NULL`, safe type widenings (`int2`→`int4`→`int8`, `float4`→`float8`), SQLite rebuilds caused only by a nullability tightening | widening | none | +| MongoDB index drop, validator tightening, change-stream image settings | widening | none | + +`widening` means an operation that changes existing structure without losing data; its doc comment says so. A refused non-drop (the type change) is consented with `--delete ` in this slice, as the project spec's DoD says; `--convert` replaces that in slice 3. + +### The structured refusal + +The family planner's success result gains `dataLoss: readonly DataLossEntry[]`, one entry per destructive operation in plan order: `{ operationIndex, subject }`. `subject` is a `MigrationStatementSubject`: `{ kind: 'model' | 'field', namespaceId, model, field? }` when the family can map the operation's table or column back to the origin contract (`fromContract`), else `{ kind: 'storage', name }` with the storage name as the planner knows it. The framework type lives beside `AppliedMigrationStatement` and carries no family vocabulary. The SQL family maps through `model.storage.table` and `fields[f].column`; MongoDB through the collection name. A table no contract declared (drift) is a `storage` subject. + +The CLI turns each entry into one engine question (`ctx.prompt.statements`): question text from the operation's label, `subject` written as the coordinate (`Legacy`, `User.name`, or the storage name), `verbs: ['rename', 'delete']` for a model or field subject and `['delete']` for a storage subject. The engine handles the command line, the non-interactive refusal that lists every question, and the interactive prompt. The CLI never reads the TTY. + +### `--delete` and how statements consent + +- `--delete ` is an engine verb flag (registered by the ORM command family together with `rename` and `allow`). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) consents to every destructive operation whose subject is in that namespace; the refusal still lists each model. +- A `delete` never reaches the planner. Planning runs first with the `rename` statements; the plan's `dataLoss` entries are then matched against the answers. `validate` for a `delete` answer checks the text equals the subject. `validate` for a `rename` answer resolves the statement with slice 1's resolver against the two contracts and accepts only when its old side is the subject. +- A `rename` answered at the prompt or on the command line that passes validation is added to the statement list and the plan is run again, exactly as if it had been typed on the command line; the second plan must have no `dataLoss` entry for that subject, else the run fails (`MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`). Order between a run's `--rename` and `--delete` values comes from the engine's ordered verb-flag list. +- The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop: with no origin contract, `rename` statements fail with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and `delete` statements still work, because they are matched against the plan, not resolved against a contract. +- `--confirm` is no longer asked for by either command. `guardDestructiveChanges`, the plan-hash consent round trip, `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` go, on both commands, including `migration plan`'s auto-baseline consent, which becomes the same per-operation refusal. The control API keeps `acceptDataLoss` as the programmatic equivalent and gains `statements` as the preferred form. +- `migration plan` refuses before writing anything (slice 1's rule stands). An auto-baseline whose delta is refused writes nothing. + +### Access widening (`allow`) + +`db update` asks before applying an operation that widens who can read or write rows: dropping a row-level-security policy and disabling row-level security. Both stay `widening`. The family's success result lists them in `accessWidening: readonly { operationIndex, subject }[]` with the model as subject; the CLI asks with verb `allow` only (`--allow User`). `migration plan` does not ask: the written migration is reviewed before it runs. The transient DROP in a policy replacement (DROP then CREATE of a changed policy) is not listed. + +### Output + +The `Statements applied` block gains `delete` and `allow` lines (`delete model "Legacy" (1 operation)`), and JSON `appliedStatements` entries carry `verb`. `describeMigrationStatement` writes them. A destructive operation in the human plan output keeps its `⚠` marker. + +### MongoDB + +The Mongo planner keeps refusing `rename` statements. Since `delete` never reaches a planner, a MongoDB project consents to a collection drop with `--delete ` and is not blocked before slice 4. + +## Coherence rationale + +The refusal, the verb that answers it, and the removal of the blanket consent ship together because a release with both consents is contradictory and a refusal with no way out is unshippable (project transitional-shape constraint 2). The reclassification ships here because the refusal would otherwise block adding a required field. The `allow` consent ships here because it is the same prompt shape and Will tied it to this slice on prisma/orm#30638. + +## Scope + +**In:** the reclassification on all three targets; `dataLoss` and `accessWidening` on the planner result and the family mapping to subjects; the `delete` and `allow` verbs through the engine; the per-statement origin check; removal of the blanket consent paths and their errors; output and JSON; the CLI README (`migration plan`, `db update`), the Migration System doc § Statements and § Operation Model, the error reference, the CLI Style Guide's consent section (statements are the data-loss form); upgrade fragments (app: `--confirm` no longer consents to data loss, `migration plan` refuses every plan that loses data; extension: `dataLoss`/`accessWidening` on the success result, `MigrationStatementSubject`); journeys on Postgres, SQLite and MongoDB; the engine pin bumped to 0.7. + +**Out:** `--convert`, `--backfill`, value and namespace renames (slice 3); MongoDB rename statements (slice 4); the `@@map`-only rename gap (decided 2026-10-07: no statement; the refusal keeps slice 1's manual steps); any storage-object statement syntax (deferred). + +## Pre-investigated edge cases + +| Case | Disposition | +| --- | --- | +| `--delete` names a model that is not dropped | `CLI.CONSENT_UNUSED` from the engine. | +| `--delete User` when only `User.name` is dropped | Unused; the refusal said `User.name`. A model delete does not cover its fields. | +| `--rename Legacy:Archive` where `Archive` is not in the destination | Slice 1's `STATEMENT_UNRESOLVED`, before planning. | +| `db update --db ` with no snapshot | `rename` fails as in slice 1; subjects are storage names; `--delete users` works. The refusal says the names are storage names because the origin contract is unknown. | +| A destructive operation inside an extension contract space | Listed with its storage subject; the app's statements cannot name it. | +| `migration plan --to ^` (rollback) that drops a table | Refused like any plan; `--delete` consents. | +| Dry run (`db update --dry-run`) | Plans and lists `dataLoss`; asks nothing. | +| Same `--delete` twice | Second is unused. | + +## Slice done conditions + +- Project DoD journeys restricted to rename plus delete, on Postgres, SQLite and MongoDB: `migration plan --rename ... --delete Legacy` then `migrate`; the same through `db update` with no prompt; both commands refuse without `--delete`, and the refusal's `nextActions` contain the exact flag that then succeeds; `--confirm ` no longer consents; a second `db update` with the same statements fails on the first statement. +- A journey that adds a required field plans and applies with no refusal on all three targets. +- A `db update` on Postgres that drops a policy refuses without `--allow ` and applies with it. +- `pnpm fixtures:check` with no churn except the example migrations whose classes changed; `lint:framework-vocabulary` count unchanged. + +## Open questions + +None. + +## References + +- [`../../spec.md`](../../spec.md) decisions 1, 2, 6; requirements 4, 10, 12. [`../../plan.md`](../../plan.md) slice 2. [`../../deferred.md`](../../deferred.md). +- [`../engine-statement-prompt/spec.md`](../engine-statement-prompt/spec.md). +- `docs/CLI Style Guide.md` § Destructive operation confirmation; `docs/architecture docs/subsystems/7. Migration System.md` § Operation Model. +- prisma-cli `packages/cli-engine/src/execution/prompts.ts`. From 362922ebc77580d9400535550ae996db4d550213 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:12:20 +0200 Subject: [PATCH 004/109] 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 1a5aaf4ce5e9e17f0f7cd5210ae5385de641c01d Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:15:03 +0200 Subject: [PATCH 005/109] TML-3476: Postgres classes SET NOT NULL and the safe type widenings as widening SET NOT NULL's precheck fails when a NULL remains, so it never loses a value. A type change in the known safe widenings (int2 to int4 to int8, float4 to float8) keeps every value. Every other type change stays destructive. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/op-factory-call.ts | 21 ++++++++++-- .../src/core/migrations/operations/columns.ts | 5 +-- .../src/core/migrations/planner-strategies.ts | 4 ++- .../src/core/migrations/postgres-migration.ts | 3 ++ .../op-factory-call.lowering.test.ts | 23 +++++++++++++ .../postgres-migration-op-builders.test.ts | 32 +++++++++++++++++++ .../op-factory-call.lowering.test.ts | 2 +- .../postgres-issue-planner.test.ts | 14 +++++--- 8 files changed, 93 insertions(+), 11 deletions(-) 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 3de2f614c797..89a74be38370 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 @@ -635,6 +635,9 @@ export class DropColumnCall extends PostgresOpFactoryCallNode { } } +/** A type change is `widening` when every value of the old type converts to the new type unchanged. */ +export type AlterColumnTypeClass = 'widening' | 'destructive'; + export interface AlterColumnTypeOptions { readonly qualifiedTargetType: string; readonly formatTypeExpected: string; @@ -644,7 +647,7 @@ export interface AlterColumnTypeOptions { export class AlterColumnTypeCall extends PostgresOpFactoryCallNode { readonly factoryName = 'alterColumnType' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass: AlterColumnTypeClass; readonly schemaName: string; readonly tableName: string; readonly columnName: string; @@ -656,12 +659,14 @@ export class AlterColumnTypeCall extends PostgresOpFactoryCallNode { tableName: string, columnName: string, options: AlterColumnTypeOptions, + operationClass: AlterColumnTypeClass = 'destructive', ) { super(); this.schemaName = schemaName; this.tableName = tableName; this.columnName = columnName; this.options = options; + this.operationClass = operationClass; this.label = `Alter type of "${tableName}"."${columnName}" to ${options.rawTargetTypeForLabel}`; this.freeze(); } @@ -674,7 +679,14 @@ export class AlterColumnTypeCall extends PostgresOpFactoryCallNode { { meta: { factory: 'AlterColumnTypeCall' } }, ); } - return alterColumnType(this.schemaName, this.tableName, this.columnName, this.options, lowerer); + return alterColumnType( + this.schemaName, + this.tableName, + this.columnName, + this.options, + lowerer, + this.operationClass, + ); } renderTypeScript(): string { @@ -685,6 +697,9 @@ export class AlterColumnTypeCall extends PostgresOpFactoryCallNode { opts.push(`table: ${jsonToTsSource(this.tableName)}`); opts.push(`column: ${jsonToTsSource(this.columnName)}`); opts.push(`options: ${jsonToTsSource(this.options)}`); + if (this.operationClass !== 'destructive') { + opts.push(`operationClass: ${jsonToTsSource(this.operationClass)}`); + } return `this.alterColumnType({ ${opts.join(', ')} })`; } @@ -695,7 +710,7 @@ export class AlterColumnTypeCall extends PostgresOpFactoryCallNode { export class SetNotNullCall extends PostgresOpFactoryCallNode { readonly factoryName = 'setNotNull' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; readonly columnName: string; 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 deddbb6e9062..6319ea4a0cf6 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 @@ -127,6 +127,7 @@ export async function alterColumnType( readonly using?: string; }, lowerer: ExecuteRequestLowerer, + operationClass: 'widening' | 'destructive' = 'destructive', ): Promise { const qualified = qualifyTableName(schemaName, tableName); const usingClause = options.using @@ -148,7 +149,7 @@ export async function alterColumnType( return { id: `alterType.${tableName}.${columnName}`, label: `Alter type of "${tableName}"."${columnName}" to ${options.rawTargetTypeForLabel}`, - operationClass: 'destructive', + operationClass, target: targetDetails('column', columnName, schemaName, tableName), precheck: [step(`ensure column "${columnName}" exists`, present.sql, present.params)], execute: [ @@ -194,7 +195,7 @@ export async function setNotNull( return { id: `alterNullability.setNotNull.${tableName}.${columnName}`, label: `Set NOT NULL on "${tableName}"."${columnName}"`, - operationClass: 'destructive', + operationClass: 'widening', target: targetDetails('column', columnName, schemaName, tableName), precheck: [ step(`ensure column "${columnName}" exists`, present.sql, present.params), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index 3d1b69857878..d40991986d80 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -301,7 +301,9 @@ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { rawTargetTypeForLabel: qualifiedTargetType, }; if (isSafeWidening) { - calls.push(new AlterColumnTypeCall(schemaName, tableName, expected.name, alterOpts)); + calls.push( + new AlterColumnTypeCall(schemaName, tableName, expected.name, alterOpts, 'widening'), + ); } else { calls.push( new DataTransformCall( 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 be4713a13036..9f9ac9f68a59 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 @@ -30,6 +30,7 @@ import { AddPrimaryKeyCall, AddUniqueCall, AlterColumnTypeCall, + type AlterColumnTypeClass, type AlterColumnTypeOptions, CreateIndexCall, CreateNativeEnumTypeCall, @@ -510,12 +511,14 @@ export abstract class PostgresMigration< readonly table: string; readonly column: string; readonly options: AlterColumnTypeOptions; + readonly operationClass?: AlterColumnTypeClass; }): Promise> { return new AlterColumnTypeCall( options.schema, options.table, options.column, options.options, + options.operationClass, ).toOp(this.controlAdapterFor('alterColumnType')); } 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 2ac6e997e24d..2b564cd7abe9 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 @@ -222,6 +222,21 @@ describe('AlterColumnTypeCall', () => { expect(call.label).toBe('Alter type of "user"."age" to bigint'); }); + it('is destructive unless the planner says the type change is a safe widening', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new AlterColumnTypeCall('public', 'user', 'age', options); + const op = await call.toOp(lowerer); + expect([call.operationClass, op.operationClass]).toEqual(['destructive', 'destructive']); + }); + + it('is widening when constructed as a safe widening, and renders the class', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new AlterColumnTypeCall('public', 'user', 'age', options, 'widening'); + const op = await call.toOp(lowerer); + expect([call.operationClass, op.operationClass]).toEqual(['widening', 'widening']); + expect(call.renderTypeScript()).toContain('operationClass: "widening"'); + }); + it('uses an explicit USING clause when provided', async () => { const { lowerer } = recordingCheckLowerer(); const call = new AlterColumnTypeCall('public', 'user', 'age', { @@ -258,6 +273,7 @@ describe('AlterColumnTypeCall', () => { expect(ts).toContain('qualifiedTargetType: "bigint"'); expect(ts).toContain('formatTypeExpected: "bigint"'); expect(ts).toContain('rawTargetTypeForLabel: "bigint"'); + expect(ts).not.toContain('operationClass'); expect(call.importRequirements()).toEqual([]); }); }); @@ -285,6 +301,13 @@ describe('SetNotNullCall', () => { expect(call.label).toBe('Set NOT NULL on "user"."email"'); }); + it('is widening, because its precheck fails rather than losing a NULL value', async () => { + const { lowerer } = recordingCheckLowerer(); + const call = new SetNotNullCall('public', 'user', 'email'); + const op = await call.toOp(lowerer); + expect([call.operationClass, op.operationClass]).toEqual(['widening', 'widening']); + }); + it('toOp() throws when no lowerer is provided', async () => { const call = new SetNotNullCall('public', 'user', 'email'); await expect(async () => call.toOp()).rejects.toThrow('createPostgresMigrationPlanner'); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts index bd5cb106317a..9e956adfcea6 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts @@ -158,6 +158,7 @@ class ExposedMigration extends PostgresMigration { readonly table: string; readonly column: string; readonly options: AlterColumnTypeOptions; + readonly operationClass?: 'widening' | 'destructive'; }): Promise { return this.alterColumnType(options); } @@ -546,4 +547,35 @@ describe('PostgresMigration op-builder methods with a ControlStack', () => { expect(op.execute[0]?.description).toBe('drop table "widget"'); expect(typeof op.execute[0]?.sql).toBe('string'); }); + + it('alterColumnType is destructive unless the migration says it is a widening', async () => { + const m = new ExposedMigration(fakeControlStack()); + const options = { + qualifiedTargetType: 'int8', + formatTypeExpected: 'bigint', + rawTargetTypeForLabel: 'int8', + }; + const lossy = await m.callAlterColumnType({ + schema: 'public', + table: 'widget', + column: 'count', + options, + }); + const widening = await m.callAlterColumnType({ + schema: 'public', + table: 'widget', + column: 'count', + options, + operationClass: 'widening', + }); + + expect([lossy.operationClass, widening.operationClass]).toEqual(['destructive', 'widening']); + }); + + it('setNotNull lowers to a widening operation', async () => { + const m = new ExposedMigration(fakeControlStack()); + const op = await m.callSetNotNull({ schema: 'public', table: 'widget', column: 'name' }); + + expect(op.operationClass).toBe('widening'); + }); }); 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 ac2655aae4c6..f65ae2a3bc73 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 @@ -154,7 +154,7 @@ describe('renderOps', () => { }, { id: 'alterNullability.setNotNull.user.email', - operationClass: 'destructive', + operationClass: 'widening', details: schemaObject('column', 'email', 'user'), }, { 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 f9502e62a56a..7003f7acb303 100644 --- a/test/integration/test/cross-package/postgres-issue-planner.test.ts +++ b/test/integration/test/cross-package/postgres-issue-planner.test.ts @@ -196,7 +196,7 @@ describe('planIssues', () => { expect(calls).toHaveLength(3); expect(calls[0]).toMatchObject({ factoryName: 'addColumn' }); expect(calls[1]).toMatchObject({ factoryName: 'dataTransform' }); - expect(calls[2]).toMatchObject({ factoryName: 'setNotNull' }); + expect(calls[2]).toMatchObject({ factoryName: 'setNotNull', operationClass: 'widening' }); }); it('DataTransformCall.toOp() rejects with MIGRATION.UNFILLED_PLACEHOLDER', async () => { @@ -271,7 +271,7 @@ describe('planIssues', () => { const calls = result.value.calls; expect(calls).toHaveLength(2); expect(calls[0]).toMatchObject({ factoryName: 'dataTransform' }); - expect(calls[1]).toMatchObject({ factoryName: 'setNotNull' }); + expect(calls[1]).toMatchObject({ factoryName: 'setNotNull', operationClass: 'widening' }); }); }); @@ -344,7 +344,10 @@ describe('planIssues', () => { if (!result.ok) throw new Error('expected ok'); const calls = result.value.calls; expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ factoryName: 'alterColumnType' }); + expect(calls[0]).toMatchObject({ + factoryName: 'alterColumnType', + operationClass: 'widening', + }); }); it('emits DataTransformCall + AlterColumnTypeCall for unsafe change', () => { @@ -374,7 +377,10 @@ describe('planIssues', () => { const calls = result.value.calls; expect(calls).toHaveLength(2); expect(calls[0]).toMatchObject({ factoryName: 'dataTransform' }); - expect(calls[1]).toMatchObject({ factoryName: 'alterColumnType' }); + expect(calls[1]).toMatchObject({ + factoryName: 'alterColumnType', + operationClass: 'destructive', + }); }); }); From 989d9cffbd360c2acea7d34e95d593a21948fba6 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:15:43 +0200 Subject: [PATCH 006/109] TML-3476: SQLite classes a table rebuild for a nullability change alone as widening The rebuild copies rows with INSERT ... SELECT, which fails on a NULL in a column that becomes NOT NULL rather than dropping the row. A rebuild that changes a column type stays destructive. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/planner-strategies.ts | 24 ++++------- .../migrations/nullability-backfill.test.ts | 6 +-- .../migrations/planner-strategies.test.ts | 43 ++++++++++++------- 3 files changed, 40 insertions(+), 33 deletions(-) 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 4f99e02c21cc..1e37cb6d4041 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 @@ -65,12 +65,10 @@ export type CallMigrationStrategy = ( * all (table/column not-found-or-not-expected, and index issues — those are * standalone ops, never folded into a recreate). * - * Column drift is a single `not-equal` issue now (type AND nullability - * compared together by `SqlColumnIR.isEqualTo`), so this reads both fields - * off the node pair directly rather than trusting a separate issue kind per - * attribute: a type change is always destructive; a pure nullability change - * is destructive when tightening (NOT NULL required) and widening when - * relaxing. + * Column drift is a single `not-equal` issue (type and nullability compared + * together by `SqlColumnIR.isEqualTo`): a type change is destructive, because + * the copy can change values; a nullability change alone is widening, because + * a tightening copy fails on a NULL rather than losing it. */ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | null { const node = issue.expected ?? issue.actual; @@ -88,10 +86,7 @@ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | const actual = blindCast( issue.actual, ); - if (columnTypeChanged(expected, actual)) return 'destructive'; - // Type is unchanged, so `not-equal` here means only nullability - // differs: relaxing (NOT NULL → nullable) is safe; tightening is not. - return expected.nullable ? 'widening' : 'destructive'; + return columnTypeChanged(expected, actual) ? 'destructive' : 'widening'; } case RelationalSchemaNodeKind.columnDefault: case RelationalSchemaNodeKind.primaryKey: @@ -194,11 +189,10 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { * recipe slot in strategy order (backfill first, recreate second), which * matches the required execution order. * - * Mirrors Postgres's `nullableTighteningCallStrategy` / `'data'`-class - * gating. When `'data'` is not in the policy (the default `db update` / - * `db init` path), the strategy short-circuits and the recreate alone - * runs with its current destructive-class gating — preserving today's - * behavior where a tightening blows up at runtime if NULLs are present. + * Mirrors Postgres's `nullableTighteningCallStrategy`. When `'data'` is not + * in the policy (the default `db update` / `db init` path), the strategy + * short-circuits and the recreate alone runs, failing at runtime if NULLs + * are present. */ export const nullabilityTighteningBackfillStrategy: CallMigrationStrategy = (issues, ctx) => { if (!ctx.policy.allowedOperationClasses.includes('data')) { 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 bce118dbc094..d1854ae723d8 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 @@ -112,7 +112,7 @@ describe('nullability-tightening backfill', async () => { const result = planner.plan({ contract: tightenedEmailContract(), schema: nullableEmailSchema(), - policy: { allowedOperationClasses: ['additive', 'destructive'] }, + policy: { allowedOperationClasses: ['additive', 'widening'] }, fromContract: null, origin: null, statements: [], @@ -127,14 +127,14 @@ describe('nullability-tightening backfill', async () => { const ops = await Promise.all(result.plan.operations); expect(ops).toHaveLength(1); expect(ops[0]?.id).toBe('recreateTable.users'); - expect(ops[0]?.operationClass).toBe('destructive'); + expect(ops[0]?.operationClass).toBe('widening'); }); it("with 'data' allowed, emits a backfill data-transform stub before the recreate", async () => { const result = planner.plan({ contract: tightenedEmailContract(), schema: nullableEmailSchema(), - policy: { allowedOperationClasses: ['additive', 'destructive', 'data'] }, + policy: { allowedOperationClasses: ['additive', 'widening', 'data'] }, fromContract: null, origin: null, statements: [], 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 2025750d83e2..67d875f95aae 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 @@ -157,31 +157,44 @@ describe('recreateTableStrategy', () => { expect((result.calls[0] as RecreateTableCall).operationClass).toBe('widening'); }); - it('relaxing nullability (NOT NULL → nullable) is widening, tightening is destructive', () => { + it('classifies a recreate for a nullability change alone as widening, in either direction', () => { const ctx = makeContext({ expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), actual: new SqlSchemaIR({ tables: { user: actualUserTable } }), }); + const nullabilityChange = (expectedNullable: boolean) => + issue({ + path: ['database', 'user', 'column:email'], + expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: expectedNullable }), + actual: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: !expectedNullable }), + }); - const relaxing = issue({ - path: ['database', 'user', 'column:email'], - expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), - actual: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: false }), + const classes = [true, false].map((expectedNullable) => { + const result = recreateTableStrategy([nullabilityChange(expectedNullable)], ctx); + return result.kind === 'match' + ? (result.calls[0] as RecreateTableCall).operationClass + : result.kind; }); - const widening = recreateTableStrategy([relaxing], ctx); - expect(widening.kind).toBe('match'); - if (widening.kind !== 'match') return; - expect((widening.calls[0] as RecreateTableCall).operationClass).toBe('widening'); - const tightening = issue({ + expect(classes).toEqual(['widening', 'widening']); + }); + + it('classifies a recreate for a nullability tightening and a type change as destructive', () => { + const ctx = makeContext({ + expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), + actual: new SqlSchemaIR({ tables: { user: actualUserTable } }), + }); + const tighteningAndTypeChange = issue({ path: ['database', 'user', 'column:email'], expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: false }), - actual: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), + actual: actualColumn({ name: 'email', nativeType: 'INTEGER', nullable: true }), }); - const destructive = recreateTableStrategy([tightening], ctx); - expect(destructive.kind).toBe('match'); - if (destructive.kind !== 'match') return; - expect((destructive.calls[0] as RecreateTableCall).operationClass).toBe('destructive'); + + const result = recreateTableStrategy([tighteningAndTypeChange], ctx); + + expect(result.kind).toBe('match'); + if (result.kind !== 'match') return; + expect((result.calls[0] as RecreateTableCall).operationClass).toBe('destructive'); }); it('groups issues by table and emits one RecreateTableCall per affected table', () => { From 35170f88e224a7916193d94c0eb5ba2bfb6c902e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:17:43 +0200 Subject: [PATCH 007/109] TML-3476: MongoDB classes index drops, validator changes and change-stream image settings as widening None of them removes a document: a validator applies to later writes only, and collMod can set nothing else that loses data, so collMod defaults to widening too. Dropping a collection stays destructive. The validator classification the planner ran is gone, since every validator change has the same class. Signed-off-by: willbot Signed-off-by: Will Madden --- .../core/migrations/migration-factories.ts | 10 +- .../src/core/migrations/mongo-planner.ts | 101 +----------------- .../src/core/migrations/op-factory-call.ts | 4 +- .../1-mongo-target/test/migration-e2e.test.ts | 4 +- .../test/migration-factories.test.ts | 6 +- .../test/mongo-ops-serializer.test.ts | 6 +- .../1-mongo-target/test/mongo-planner.test.ts | 55 +++++----- .../test/op-factory-call.test.ts | 8 +- 8 files changed, 49 insertions(+), 145 deletions(-) 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 8f46a5d5d0db..87c5dcf53702 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,10 +169,6 @@ 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, @@ -183,7 +179,7 @@ export function dropIndex( return { id: buildIndexOpId('drop', collectionName, keys), label: `Drop index on ${collectionName} (${formatKeys(keys)})`, - operationClass: 'destructive', + operationClass: 'widening', precheck: [ { description: `index exists on ${collectionName}`, @@ -261,7 +257,7 @@ export function setValidation( return { id: `collection.${collectionName}.setValidation`, label: `Set validation on ${collectionName}`, - operationClass: 'destructive', + operationClass: 'widening', precheck: [], execute: [ { @@ -287,7 +283,7 @@ export function collMod( return { id: meta?.id ?? `collection.${collectionName}.collMod`, label: meta?.label ?? `Modify collection ${collectionName}`, - operationClass: meta?.operationClass ?? 'destructive', + operationClass: meta?.operationClass ?? 'widening', precheck: options.validator != null ? [ 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 51f9cd6e95b9..e5e548c0a2b5 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 @@ -3,7 +3,6 @@ import { contractToMongoSchemaIR } from '@internal/family-mongo/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import { describeMigrationStatement, - type MigrationOperationClass, type MigrationOperationPolicy, type MigrationPlanner, type MigrationPlannerConflict, @@ -67,103 +66,10 @@ function validatorsEqual( ); } -function classifyValidatorUpdate( - origin: MongoSchemaValidator, - dest: MongoSchemaValidator, -): 'widening' | 'destructive' { - // Moving to a stricter action or level narrows the accepted value space. - if (origin.validationAction !== dest.validationAction && dest.validationAction === 'error') { - return 'destructive'; - } - if (origin.validationLevel !== dest.validationLevel && dest.validationLevel === 'strict') { - return 'destructive'; - } - - if (canonicalize(origin.jsonSchema) === canonicalize(dest.jsonSchema)) { - return 'widening'; - } - - // Check whether the schema change only adds non-required properties (widening). - return isWideningSchemaChange(origin.jsonSchema, dest.jsonSchema) ? 'widening' : 'destructive'; -} - function isPlainObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } -/** - * Returns true when `dest` is a structural superset of `origin` for the common - * additive case: adding non-required properties to a top-level object schema. - * Anything uncertain falls through to the safe `destructive` default. - */ -function isWideningSchemaChange( - origin: Record, - dest: Record, -): boolean { - // Only handle top-level object schemas. - if (origin['bsonType'] !== 'object' || dest['bsonType'] !== 'object') { - return false; - } - - // Any change to keys besides 'required' and 'properties' is uncertain → destructive. - const allKeys = new Set([...Object.keys(origin), ...Object.keys(dest)]); - for (const key of allKeys) { - if (key === 'required' || key === 'properties') continue; - if (canonicalize(origin[key]) !== canonicalize(dest[key])) return false; - } - - // dest.required must be a subset of origin.required — no new required fields. - const originRequired = new Set( - Array.isArray(origin['required']) ? origin['required'] : [], - ); - const destRequired = Array.isArray(dest['required']) ? dest['required'] : []; - for (const field of destRequired) { - if (!originRequired.has(field)) return false; - } - - // All properties that existed in origin must still exist unchanged. - // New properties in dest (absent from origin) are allowed — widening. - const originProps = isPlainObject(origin['properties']) ? origin['properties'] : {}; - const destProps = isPlainObject(dest['properties']) ? dest['properties'] : {}; - for (const field of Object.keys(originProps)) { - if (!Object.hasOwn(destProps, field)) return false; // Property removed → destructive. - if (!admitsEverything(destProps[field], originProps[field])) return false; // Property narrowed → destructive. - } - - return true; -} - -function bsonTypeList(value: unknown): readonly unknown[] | undefined { - if (value === undefined) return undefined; - return Array.isArray(value) ? value : [value]; -} - -/** - * Whether every value `origin` admits, `dest` admits too, for the property schemas Prisma derives: `{}` admits every value, a `bsonType` list may gain types, and an array's `items` may admit more. Any other difference is treated as narrowing. - */ -function admitsEverything(dest: unknown, origin: unknown): boolean { - if (canonicalize(dest) === canonicalize(origin)) return true; - if (!isPlainObject(dest) || !isPlainObject(origin)) return false; - if (Object.keys(dest).length === 0) return true; - for (const key of new Set([...Object.keys(origin), ...Object.keys(dest)])) { - if (key === 'bsonType') { - const destTypes = bsonTypeList(dest['bsonType']); - const originTypes = bsonTypeList(origin['bsonType']); - if (destTypes === undefined) continue; - if (originTypes === undefined || !originTypes.every((type) => destTypes.includes(type))) { - return false; - } - continue; - } - if (key === 'items') { - if (!admitsEverything(dest['items'] ?? {}, origin['items'] ?? {})) return false; - continue; - } - if (canonicalize(origin[key]) !== canonicalize(dest[key])) return false; - } - return true; -} - function propertiesOf(schema: Record): Record { return isPlainObject(schema['properties']) ? schema['properties'] : {}; } @@ -483,9 +389,6 @@ function planValidatorDiffCall( if (validatorsEqual(originValidator, destValidator)) return undefined; if (destValidator) { - const operationClass: MigrationOperationClass = originValidator - ? classifyValidatorUpdate(originValidator, destValidator) - : 'destructive'; return new CollModCall( collName, { @@ -498,7 +401,7 @@ function planValidatorDiffCall( label: originValidator ? validatorUpdateLabel(collName, originValidator, destValidator) : `Add validator on ${collName}`, - operationClass, + operationClass: 'widening', }, ); } @@ -536,7 +439,7 @@ function planMutableOptionsDiffCall( { id: `options.${collName}.update`, label: `Update mutable options on ${collName}`, - operationClass: desiredCSPPI.enabled ? 'widening' : 'destructive', + operationClass: 'widening', }, ); } diff --git a/packages/3-mongo-target/1-mongo-target/src/core/migrations/op-factory-call.ts b/packages/3-mongo-target/1-mongo-target/src/core/migrations/op-factory-call.ts index a56000fb7d98..49e30382823a 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/migrations/op-factory-call.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/migrations/op-factory-call.ts @@ -112,7 +112,7 @@ export class CreateIndexCall extends OpFactoryCallNode { export class DropIndexCall extends OpFactoryCallNode { readonly factoryName = 'dropIndex' as const; - readonly operationClass = 'destructive' as const; + readonly operationClass = 'widening' as const; readonly collection: string; readonly keys: ReadonlyArray; readonly label: string; @@ -195,7 +195,7 @@ export class CollModCall extends OpFactoryCallNode { this.collection = collection; this.options = options; this.meta = meta; - this.operationClass = meta?.operationClass ?? 'destructive'; + this.operationClass = meta?.operationClass ?? 'widening'; this.label = meta?.label ?? `Modify collection ${collection}`; this.freeze(); } diff --git a/packages/3-mongo-target/1-mongo-target/test/migration-e2e.test.ts b/packages/3-mongo-target/1-mongo-target/test/migration-e2e.test.ts index afa2e7c59b79..cfdfa6f9cdbb 100644 --- a/packages/3-mongo-target/1-mongo-target/test/migration-e2e.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/migration-e2e.test.ts @@ -284,7 +284,7 @@ describe('migration file E2E', () => { { id: 'validator.users.add', label: 'Add validator on users', - operationClass: 'destructive', + operationClass: 'widening', }, ), ]; @@ -306,7 +306,7 @@ describe('migration file E2E', () => { expect(ops).toHaveLength(1); expect(ops[0].id).toBe('validator.users.add'); expect(ops[0].label).toBe('Add validator on users'); - expect(ops[0].operationClass).toBe('destructive'); + expect(ops[0].operationClass).toBe('widening'); expect(ops[0].execute[0].command.kind).toBe('collMod'); }); diff --git a/packages/3-mongo-target/1-mongo-target/test/migration-factories.test.ts b/packages/3-mongo-target/1-mongo-target/test/migration-factories.test.ts index 3e2347c07d95..88d001d20a0a 100644 --- a/packages/3-mongo-target/1-mongo-target/test/migration-factories.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/migration-factories.test.ts @@ -124,7 +124,7 @@ describe('dropIndex', () => { expect(op.id).toBe(buildIndexOpId('drop', 'users', keys)); expect(op.label).toBe('Drop index on users (email:1)'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); it('includes precheck that index exists', () => { @@ -282,7 +282,7 @@ describe('setValidation', () => { expect(op.id).toBe('collection.users.setValidation'); expect(op.label).toBe('Set validation on users'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); it('wraps schema in $jsonSchema validator', () => { @@ -378,7 +378,7 @@ describe('collMod', () => { expect(op.id).toBe('collection.users.collMod'); expect(op.label).toBe('Modify collection users'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); }); it('applies meta overrides for id, label, and operationClass', () => { diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-ops-serializer.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-ops-serializer.test.ts index 9f81bf6b75a6..c127697b9a0a 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-ops-serializer.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-ops-serializer.test.ts @@ -67,7 +67,7 @@ function makeDropIndexOp(): MongoMigrationPlanOperation { return { id: 'index.users.drop(email:1)', label: 'Drop index on users (email ascending)', - operationClass: 'destructive', + operationClass: 'widening', precheck: [ { description: 'index exists on users', @@ -129,7 +129,7 @@ describe('serializeMongoOps / deserializeMongoOps', () => { expect(deserialized).toHaveLength(1); const op = asDdlOp(deserialized[0]!); expect(op.id).toBe('index.users.drop(email:1)'); - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); const cmd = op.execute[0]!.command as DropIndexCommand; expect(cmd.kind).toBe('dropIndex'); @@ -638,7 +638,7 @@ describe('serializeMongoOps / deserializeMongoOps', () => { const op: MongoMigrationPlanOperation = { id: 'validator.users.update', label: 'Update validator on users', - operationClass: 'destructive', + operationClass: 'widening', precheck: [], execute: [ { 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 a0ec1ff56219..6f2c1c167d05 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 @@ -149,7 +149,7 @@ describe('MongoMigrationPlanner', () => { expect(plan.operations).toHaveLength(1); const op = plan.operations[0] as MongoMigrationPlanOperation; - expect(op.operationClass).toBe('destructive'); + expect(op.operationClass).toBe('widening'); expect(op.execute).toHaveLength(1); expect(op.execute[0]!.command.kind).toBe('dropIndex'); const cmd = op.execute[0]!.command as DropIndexCommand; @@ -193,7 +193,7 @@ describe('MongoMigrationPlanner', () => { expect(plan.operations).toHaveLength(2); const drop = plan.operations[0] as MongoMigrationPlanOperation; const create = plan.operations[1] as MongoMigrationPlanOperation; - expect(drop.operationClass).toBe('destructive'); + expect(drop.operationClass).toBe('widening'); expect(create.operationClass).toBe('additive'); }); @@ -267,7 +267,11 @@ describe('MongoMigrationPlanner', () => { ]); const plan = planSuccess(planner, contract, origin); expect(plan.operations).toHaveLength(3); - expect(plan.operations.every((op) => op.operationClass === 'destructive')).toBe(true); + expect(plan.operations.map((op) => op.operationClass)).toEqual([ + 'widening', + 'widening', + 'destructive', + ]); expect(plan.operations[2]!.id).toBe('collection.users.drop'); }); @@ -295,7 +299,7 @@ describe('MongoMigrationPlanner', () => { const plan = planSuccess(planner, contract, origin); expect(plan.operations).toHaveLength(2); - expect(plan.operations[0]!.operationClass).toBe('destructive'); + expect(plan.operations[0]!.operationClass).toBe('widening'); expect(plan.operations[1]!.operationClass).toBe('additive'); }); @@ -313,7 +317,7 @@ describe('MongoMigrationPlanner', () => { }); describe('policy gating', () => { - it('returns conflicts when destructive operations are disallowed', () => { + it('returns conflicts when the policy disallows an index drop', () => { const contract = makeContract({ users: {} }); const origin = irWithCollection('users', [ascIndex('email')]); const result = planner.plan({ @@ -364,7 +368,7 @@ describe('MongoMigrationPlanner', () => { expect(result.conflicts).toHaveLength(3); }); - it('rejects destructive validator add with additive-only policy', () => { + it('rejects a validator add, which is widening, with additive-only policy', () => { const contract = makeContract({ users: { validator: { @@ -388,7 +392,7 @@ describe('MongoMigrationPlanner', () => { expect(result.kind).toBe('failure'); if (result.kind !== 'failure') throw new Error('Expected failure'); expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]!.summary).toContain('destructive'); + expect(result.conflicts[0]!.summary).toContain('widening'); }); it('allows widening validator removal with widening policy', () => { @@ -618,6 +622,7 @@ describe('MongoMigrationPlanner', () => { const cmd = collModOps[0]!.execute[0]!.command as CollModCommand; expect(cmd.validator).toEqual({ $jsonSchema: { bsonType: 'object' } }); expect(cmd.validationLevel).toBe('strict'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('validator add has precheck (collection exists) and postcheck (validator applied)', () => { @@ -744,9 +749,9 @@ describe('MongoMigrationPlanner', () => { ).toMatchObject({ operationClass: 'widening' }); }); - it('is destructive when the bsonType list loses a type, as changing Bson to Json does', () => { + it('is widening when the bsonType list loses a type, as changing Bson to Json does', () => { expect(validatorUpdate({}, { bsonType: JSON_TYPES })).toMatchObject({ - operationClass: 'destructive', + operationClass: 'widening', label: 'Update validator on items (changed: meta)', }); }); @@ -823,7 +828,7 @@ describe('MongoMigrationPlanner', () => { expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies the open->closed transition (adding additionalProperties:false) as destructive', () => { + it('classifies the open->closed transition (adding additionalProperties:false) as widening', () => { const contract = makeContract({ users: { validator: { @@ -857,7 +862,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('classifies adding a non-required property to an already-closed schema as widening', () => { @@ -901,7 +906,7 @@ describe('MongoMigrationPlanner', () => { expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies narrowing an existing property type as destructive', () => { + it('classifies narrowing an existing property type as widening', () => { const contract = makeContract({ users: { validator: { @@ -932,10 +937,10 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies adding a field to required as destructive', () => { + it('classifies adding a field to required as widening', () => { const contract = makeContract({ users: { validator: { @@ -974,7 +979,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('treats reordered jsonSchema keys as equivalent (no operation emitted)', () => { @@ -1032,7 +1037,7 @@ describe('MongoMigrationPlanner', () => { expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies validationAction warn->error as destructive', () => { + it('classifies validationAction warn->error as widening', () => { const contract = makeContract({ users: { validator: { @@ -1057,7 +1062,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('classifies validationLevel strict->moderate as widening', () => { @@ -1088,7 +1093,7 @@ describe('MongoMigrationPlanner', () => { expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies validationLevel moderate->strict as destructive', () => { + it('classifies validationLevel moderate->strict as widening', () => { const contract = makeContract({ users: { validator: { @@ -1113,10 +1118,10 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies mixed widening+destructive changes as destructive', () => { + it('classifies a validator that both relaxes and tightens as widening', () => { const contract = makeContract({ users: { validator: { @@ -1141,7 +1146,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('no-ops when validators are identical', () => { @@ -1437,7 +1442,7 @@ describe('MongoMigrationPlanner', () => { expect(collModOps[0]!.operationClass).toBe('widening'); }); - it('classifies disabling changeStreamPreAndPostImages as destructive', () => { + it('classifies disabling changeStreamPreAndPostImages as widening', () => { const contract = makeContract({ events: { options: { changeStreamPreAndPostImages: { enabled: false } }, @@ -1456,7 +1461,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); }); it('emits { enabled: false } when destination removes changeStreamPreAndPostImages', () => { @@ -1474,7 +1479,7 @@ describe('MongoMigrationPlanner', () => { (op) => op.execute[0]?.command.kind === 'collMod', ); expect(collModOps).toHaveLength(1); - expect(collModOps[0]!.operationClass).toBe('destructive'); + expect(collModOps[0]!.operationClass).toBe('widening'); const cmd = collModOps[0]!.execute[0]!.command as CollModCommand; expect(cmd.changeStreamPreAndPostImages).toEqual({ enabled: false }); }); @@ -1622,7 +1627,7 @@ describe('MongoMigrationPlanner', () => { expect((calls[0] as CollModCall).meta).toMatchObject({ id: 'validator.users.update', label: 'Update validator on users', - operationClass: 'destructive', + operationClass: 'widening', }); }); diff --git a/packages/3-mongo-target/1-mongo-target/test/op-factory-call.test.ts b/packages/3-mongo-target/1-mongo-target/test/op-factory-call.test.ts index 93cb225abc94..db2f8cc600ca 100644 --- a/packages/3-mongo-target/1-mongo-target/test/op-factory-call.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/op-factory-call.test.ts @@ -48,13 +48,13 @@ describe('Mongo call classes', () => { ); }); - it('DropIndexCall freezes, labels destructively, and lowers to dropIndex(...)', () => { + it('DropIndexCall freezes, labels as widening, and lowers to dropIndex(...)', () => { const call = new DropIndexCall('users', [{ field: 'legacy', direction: -1 }]); expect(Object.isFrozen(call)).toBe(true); expect(call).toMatchObject({ factoryName: 'dropIndex', - operationClass: 'destructive', + operationClass: 'widening', label: 'Drop index on users (legacy:-1)', }); @@ -87,13 +87,13 @@ describe('Mongo call classes', () => { expect(call.toOp()).toEqual(dropCollection('users')); }); - it('CollModCall defaults operationClass to destructive, uses a default label, and lowers to collMod(...)', () => { + it('CollModCall defaults operationClass to widening, uses a default label, and lowers to collMod(...)', () => { const call = new CollModCall('users', { validator: { $jsonSchema: { type: 'object' } } }); expect(Object.isFrozen(call)).toBe(true); expect(call).toMatchObject({ factoryName: 'collMod', - operationClass: 'destructive', + operationClass: 'widening', label: 'Modify collection users', }); From 37e37ab72efc79d4bfd379fac287787e373309c4 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:18:22 +0200 Subject: [PATCH 008/109] TML-3476: define each operation class by what it does to the data The MigrationOperationClass doc and the Migration System doc say that only destructive operations can lose rows or values, and that an operation which fails rather than losing a value is widening. The deferred item about MongoDB index drops is resolved. Signed-off-by: willbot Signed-off-by: Will Madden --- ...ADR 195 - Planner IR with two renderers.md | 2 ++ .../subsystems/7. Migration System.md | 11 ++++++++-- docs/commands/SUMMARY.md | 4 ++-- .../src/control/control-migration-types.ts | 21 ++++++++++--------- projects/migration-statements/deferred.md | 6 ------ 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 195 - Planner IR with two renderers.md b/docs/architecture docs/adrs/ADR 195 - Planner IR with two renderers.md index 32a622307a9c..60c424f11d09 100644 --- a/docs/architecture docs/adrs/ADR 195 - Planner IR with two renderers.md +++ b/docs/architecture docs/adrs/ADR 195 - Planner IR with two renderers.md @@ -1,5 +1,7 @@ # ADR 195 — Planner IR with two renderers (OpFactoryCall pattern) +> **Note (2026-10-07).** A MongoDB validator change and a change-stream image setting are now classed `widening` whatever they change, because neither removes a document; the planner no longer runs `classifyValidatorUpdate`. The planner still chooses the class where it depends on the origin: a Postgres type change is `widening` when it keeps every value and `destructive` otherwise, so `AlterColumnTypeCall` carries a computed `operationClass` too. + ## At a glance The planner diffs two contracts and determines that a unique ascending index on `users.email` needs to be created. Rather than directly constructing the full operation ([ADR 188](ADR%20188%20-%20MongoDB%20migration%20operation%20model.md)), it produces an IR node: diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 5d73046f3ae2..e5c8ff487d1f 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -124,7 +124,7 @@ Two renderers consume the IR: A scaffolded SQL `migration.ts` writes SQL text as strings, and prettier formats the file with `singleQuote: true`, which picks whichever quote the text does not contain. A text that holds both `'` and `"` on one line, such as `"kind" IN ('admin', 'user')`, would still need escaped quotes, so the TypeScript renderer writes it as an untagged template literal instead (`tsQuotedTextSource` in `@internal/ts-render`). This covers every place that [Opaque SQL in DDL](#opaque-sql-in-ddl) lists, except the `USING` conversion of `alterColumnType`, which the planner never writes. The column defaults include the function default that the Postgres `setDefault` and the SQLite `addColumn` and `recreateTable` write; a literal default is a JSON value and is written as JSON. It also covers SQL text that the planner builds whole: the postcheck SQL of the SQLite `recreateTable`. A text with a line break, a control character or a lone surrogate stays a string literal. The SQL is the same either way, so `ops.json` does not change. -Planner-derived semantics (`operationClass`, `label`) ride on the IR because only the planner has the origin context needed for classification (e.g., whether a `collMod` is `widening` vs `destructive`). The renderers read these values; they don't re-derive them. +Planner-derived semantics (`operationClass`, `label`) ride on the IR because only the planner has the origin context needed for classification (e.g., whether a Postgres type change keeps every value, which makes it `widening`, or not, which makes it `destructive`). The renderers read these values; they don't re-derive them. ### Offline planning via contract-to-schema @@ -420,7 +420,14 @@ 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`. +Every operation has a class that says what it does to the data (`MigrationOperationClass`): + +- `additive` adds structure and leaves existing structure and data as they are. +- `widening` changes existing structure without losing data. An operation that cannot keep every value fails instead of losing it, so tightening a constraint is `widening`: `SET NOT NULL` fails on a NULL (Postgres checks first; a SQLite table rebuild fails on the copy), and a MongoDB validator, new or stricter, applies to later writes only. A rename, a relaxed constraint, a type change that keeps every value (`int2` to `int4` to `int8`, `float4` to `float8` on Postgres), 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), disabling row-level security and changing MongoDB's change-stream image setting are `widening` too. Dropping a policy or disabling row-level security widens who can read and write rows. +- `destructive` can lose rows or values: dropping a table, a column or a collection, and any other type change, including a SQLite table rebuild that changes a column type. +- `data` reads and writes rows, such as a backfill or a type conversion. + +`db update` applies `additive` and `widening` operations without asking for consent. 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. diff --git a/docs/commands/SUMMARY.md b/docs/commands/SUMMARY.md index 0d02e7827743..7c1faef33508 100644 --- a/docs/commands/SUMMARY.md +++ b/docs/commands/SUMMARY.md @@ -74,8 +74,8 @@ For SQL targets, plan mode also prints a DDL preview derived from planned operat The planner supports three operation classes: - **Additive**: Create tables, add columns, add indexes/constraints -- **Widening**: Relax nullability (NOT NULL → nullable) -- **Destructive**: Drop tables, drop columns, alter column types, tighten nullability +- **Widening**: Change existing structure without losing data: relax or tighten nullability, widen a column type, rename, drop an index or a constraint +- **Destructive**: Lose rows or values: drop tables, drop columns, change a column type in a way that can change values ### Scenario 3: Local contract divergent from remote database (conflicts) 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 23077db5a28c..5a250da6f3e1 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 @@ -68,16 +68,17 @@ export interface MigrationMetadata { // ============================================================================ /** - * 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. + * What an operation does to the data. Only `destructive` can lose a row or a value. + * - 'additive': adds structure and leaves existing structure and data as they are. + * - 'widening': changes existing structure without losing data. An operation that cannot keep + * every value fails instead, so a tightened constraint is widening: `SET NOT NULL` fails on a + * NULL, and a MongoDB validator applies to later writes only. So are a rename, a relaxed + * constraint, a type change that keeps every value, and a drop of an object that holds no data, + * such as an index, a constraint, a default or a row-level-security policy. Dropping a policy or + * disabling row-level security widens who can read and write rows. + * - 'destructive': can lose rows or values: dropping a table, a column or a collection, or a type + * change that can change values. + * - 'data': reads and writes rows, such as a backfill or a type conversion. */ export type MigrationOperationClass = 'additive' | 'widening' | 'destructive' | 'data'; diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 8aa4b78b9144..d13614bec477 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -28,12 +28,6 @@ A user keeps `model User` and changes `@@map("users")` to `@@map("app_users")`. 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. From 6fb4428681731265752d0d49ba54ab027d19cd13 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:22:00 +0200 Subject: [PATCH 009/109] TML-3476: regenerate the retail-store migration whose setValidation is now widening Signed-off-by: willbot Signed-off-by: Will Madden --- .../app/20260513T0508_backfill_product_status/migration.json | 2 +- .../app/20260513T0508_backfill_product_status/ops.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/migration.json b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/migration.json index 214965199787..f882b213f3f2 100644 --- a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/migration.json +++ b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/migration.json @@ -3,5 +3,5 @@ "to": "9af6d90aaa4cd03bf7eb2040a1c1b5c1225a2b33883ea3d0552f312f4684f8dc", "providedInvariants": [], "createdAt": "2026-05-13T05:08:23.957Z", - "migrationHash": "5553cd547b573ea9b36d62510050a58a4d3ef213ae0290e2b9bb1cb58b4fc2fe" + "migrationHash": "2bce4ded744ff835ac51e0c815c7567047ccc1acc25069e0bebadaf72fd5f7d7" } diff --git a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/ops.json b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/ops.json index 66250d7d5107..52e7218845ac 100644 --- a/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/ops.json +++ b/examples/retail-store/migrations/app/20260513T0508_backfill_product_status/ops.json @@ -2,7 +2,7 @@ { "id": "collection.products.setValidation", "label": "Set validation on products", - "operationClass": "destructive", + "operationClass": "widening", "precheck": [], "execute": [ { From 95ea6245bc7d47e7bf8176d6c64b598ba0e915dc Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:23:00 +0200 Subject: [PATCH 010/109] TML-3476: upgrade fragment for operations that are now widening Signed-off-by: willbot Signed-off-by: Will Madden --- .../app/instructions.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 upgrade-instructions/pending/migration-statement-refusal/app/instructions.md diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md new file mode 100644 index 000000000000..9098dd8c38e4 --- /dev/null +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -0,0 +1,21 @@ +--- +changes: + - id: destructive-means-data-loss + summary: | + An operation is `destructive` only when it can lose rows or values. Postgres `setNotNull` and the type changes that keep every value (`int2` to `int4` to `int8`, `float4` to `float8`), a SQLite table rebuild that only changes nullability, and MongoDB `dropIndex`, `setValidation`, `collMod` and the planner's validator and change-stream image changes are now `widening`. Running an existing `migration.ts` again writes `widening` for these operations in `ops.json`, and a new `migrationHash`. + detection: + glob: "**/migration.ts" + matches: + - '\bsetNotNull\(' + - '(? Date: Wed, 7 Oct 2026 22:33:09 +0200 Subject: [PATCH 011/109] TML-3476: journeys and end-to-end tests follow the widening classes The Mongo consent journey drops an index without consent and keeps asking for consent to drop a collection. A SQLite nullability tightening applies under a policy without destructive. Signed-off-by: willbot Signed-off-by: Will Madden --- .../sqlite/migrations/destructive.test.ts | 17 -------- .../test/sqlite/migrations/widening.test.ts | 17 ++++++++ .../mongo-db-update-consent.e2e.test.ts | 42 ++++++++++++++----- .../contract-with-events.ts | 23 ++++++++++ .../test/mongo/migration-e2e.test.ts | 2 +- 5 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-with-events.ts diff --git a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts index 0263639b48a2..bb32ade336ee 100644 --- a/test/e2e/framework/test/sqlite/migrations/destructive.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/destructive.test.ts @@ -134,23 +134,6 @@ describe('SQLite Migration E2E - Destructive column changes', () => { ); }); - it('tightens nullability (nullable to NOT NULL)', async () => { - await applyMigration( - { - origin: defineContract({ - models: { User: model('User', { fields: { id: int.id(), name: text.optional() } }) }, - }), - destination: defineContract({ - models: { User: model('User', { fields: { id: int.id(), name: text } }) }, - }), - policy: ALL, - }, - async ({ schema }) => { - expect(schema.tables['User']!.columns['name']!.nullable).toBe(false); - }, - ); - }); - it('drops a column and preserves remaining data', async () => { await applyMigration( { diff --git a/test/e2e/framework/test/sqlite/migrations/widening.test.ts b/test/e2e/framework/test/sqlite/migrations/widening.test.ts index 40b971475521..315dc90aa8a0 100644 --- a/test/e2e/framework/test/sqlite/migrations/widening.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/widening.test.ts @@ -34,6 +34,23 @@ describe('SQLite Migration E2E - Widening operations (recreate-table)', () => { ); }); + it('tightens nullability (nullable to NOT NULL)', async () => { + await applyMigration( + { + origin: defineContract({ + models: { User: model('User', { fields: { id: int.id(), name: text.optional() } }) }, + }), + destination: defineContract({ + models: { User: model('User', { fields: { id: int.id(), name: text } }) }, + }), + policy: WIDENING, + }, + async ({ schema }) => { + expect(schema.tables['User']!.columns['name']!.nullable).toBe(false); + }, + ); + }); + it('changes a column default', async () => { await applyMigration( { diff --git a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts index 55a2755b0483..e13a3eccf347 100644 --- a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts @@ -94,7 +94,7 @@ function setupPslProject(connectionString: string): JourneyContext { return { testDir, configPath, outputDir }; } -describe('Journey: Mongo db update confirms destructive changes', { +describe('Journey: Mongo db update asks for consent only to lose data', { timeout: timeouts.spinUpMongoMemoryServer, }, () => { let replSet: MongoMemoryReplSet; @@ -124,7 +124,7 @@ describe('Journey: Mongo db update confirms destructive changes', { await replSet?.stop().catch(() => {}); }, timeouts.spinUpMongoMemoryServer); - it('drops an index once the database name is passed with --confirm', async () => { + it('drops an index without consent, because dropping an index loses no documents', async () => { const connectionString = withDatabase(replSet.getUri(), DB_NAME); const ctx = setupProject(connectionString); created.add(ctx.testDir); @@ -136,6 +136,32 @@ describe('Journey: Mongo db update confirms destructive changes', { copyFileSync(join(FIXTURES_DIR, 'contract-base.ts'), join(ctx.testDir, 'contract.ts')); expect((await runContractEmit(ctx)).exitCode).toBe(0); + const widening = await runDbUpdate(ctx, ['--no-interactive']); + + expect(widening.exitCode, stripAnsi(widening.stderr)).toBe(0); + const indexKeys = (await client.db(DB_NAME).collection('users').indexes()).map( + (index) => index.key, + ); + expect(indexKeys).toEqual([{ _id: 1 }, { email: 1 }]); + }); + + it('drops a collection once the database name is passed with --confirm', async () => { + const dbName = 'mongo_consent_drop_collection'; + const connectionString = withDatabase(replSet.getUri(), dbName); + const ctx = setupProject(connectionString); + created.add(ctx.testDir); + + copyFileSync(join(FIXTURES_DIR, 'contract-with-events.ts'), join(ctx.testDir, 'contract.ts')); + expect((await runContractEmit(ctx)).exitCode).toBe(0); + const additive = await runDbUpdate(ctx, ['--no-interactive']); + expect(additive.exitCode, stripAnsi(additive.stderr)).toBe(0); + + copyFileSync(join(FIXTURES_DIR, 'contract-additive.ts'), join(ctx.testDir, 'contract.ts')); + expect((await runContractEmit(ctx)).exitCode).toBe(0); + + const unconfirmed = await runDbUpdate(ctx, ['--no-interactive']); + expect(unconfirmed.exitCode).toBe(2); + const destructive = await runDbUpdate(ctx, [ '--no-interactive', '--confirm', @@ -143,10 +169,8 @@ describe('Journey: Mongo db update confirms destructive changes', { ]); expect(destructive.exitCode, stripAnsi(destructive.stderr)).toBe(0); - const indexKeys = (await client.db(DB_NAME).collection('users').indexes()).map( - (index) => index.key, - ); - expect(indexKeys).toEqual([{ _id: 1 }, { email: 1 }]); + const collections = await client.db(dbName).listCollections({ name: 'events' }).toArray(); + expect(collections).toEqual([]); }); it('points db init at db update when a collection with data needs a validator', async () => { @@ -168,11 +192,7 @@ describe('Journey: Mongo db update confirms destructive changes', { ], }); - const update = await runDbUpdate(ctx, [ - '--no-interactive', - '--confirm', - consentTokenFor(connectionString), - ]); + const update = await runDbUpdate(ctx, ['--no-interactive']); expect(update.exitCode, stripAnsi(update.stderr)).toBe(0); const [collection] = await client.db(dbName).listCollections({ name: 'events' }).toArray(); expect(collection).toMatchObject({ diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-with-events.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-with-events.ts new file mode 100644 index 000000000000..3e7afe343440 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-with-events.ts @@ -0,0 +1,23 @@ +import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; + +const User = model('User', { + collection: 'users', + fields: { + _id: field.objectId(), + email: field.string(), + name: field.string(), + }, + indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], +}); + +const Event = model('Event', { + collection: 'events', + fields: { + _id: field.objectId(), + name: field.string(), + }, +}); + +export const contract = defineContract({ + models: { User, Event }, +}); diff --git a/test/integration/test/mongo/migration-e2e.test.ts b/test/integration/test/mongo/migration-e2e.test.ts index ecb1fdabbae4..26d45baab879 100644 --- a/test/integration/test/mongo/migration-e2e.test.ts +++ b/test/integration/test/mongo/migration-e2e.test.ts @@ -372,7 +372,7 @@ describe('MongoDB migration E2E', { timeout: timeouts.spinUpMongoMemoryServer }, expect(dropResult.plan.operations).toHaveLength(1); const dropOp = await dropResult.plan.operations[0]!; - expect(dropOp.operationClass).toBe('destructive'); + expect(dropOp.operationClass).toBe('widening'); expect(dropOp.label).toContain('Drop index'); // Step 3: Apply drop From 7e0d2f080145f867a2543746eed4d80f1e68a542 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 22:43:05 +0200 Subject: [PATCH 012/109] 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 013/109] 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'), { From 2b9a019ad3ee1ae518be088f64ff816409a74228 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:04:36 +0200 Subject: [PATCH 014/109] TML-3476: declare AlterColumnTypeClass once, beside alterColumnType Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/migrations/op-factory-call.ts | 4 +--- .../postgres/src/core/migrations/operations/columns.ts | 5 ++++- .../postgres/src/core/migrations/postgres-migration.ts | 2 +- .../postgres/test/postgres-migration-op-builders.test.ts | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) 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 89a74be38370..042ecacbf1cd 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 @@ -67,6 +67,7 @@ import { import type { PostgresColumnDefault } from '../types'; import { boundSchema } from './bound-schema'; import { + type AlterColumnTypeClass, addNotNullColumnDirect, alterColumnType, dropColumn, @@ -635,9 +636,6 @@ export class DropColumnCall extends PostgresOpFactoryCallNode { } } -/** A type change is `widening` when every value of the old type converts to the new type unchanged. */ -export type AlterColumnTypeClass = 'widening' | 'destructive'; - export interface AlterColumnTypeOptions { readonly qualifiedTargetType: string; readonly formatTypeExpected: string; 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 6319ea4a0cf6..2e76112cb6af 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 @@ -108,6 +108,9 @@ export async function renameColumn( }; } +/** A type change is `widening` when every value of the old type converts to the new type unchanged. */ +export type AlterColumnTypeClass = 'widening' | 'destructive'; + /** * `qualifiedTargetType` is the new column type as it appears in the * `ALTER COLUMN TYPE` clause (schema-qualified for user-defined types, raw @@ -127,7 +130,7 @@ export async function alterColumnType( readonly using?: string; }, lowerer: ExecuteRequestLowerer, - operationClass: 'widening' | 'destructive' = 'destructive', + operationClass: AlterColumnTypeClass = 'destructive', ): Promise { const qualified = qualifyTableName(schemaName, tableName); const usingClause = options.using 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 9f9ac9f68a59..1694629659ef 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 @@ -30,7 +30,6 @@ import { AddPrimaryKeyCall, AddUniqueCall, AlterColumnTypeCall, - type AlterColumnTypeClass, type AlterColumnTypeOptions, CreateIndexCall, CreateNativeEnumTypeCall, @@ -54,6 +53,7 @@ import { SetDefaultCall, SetNotNullCall, } from './op-factory-call'; +import type { AlterColumnTypeClass } from './operations/columns'; import type { RenamableConstraintKind } from './operations/constraints'; import { type DataTransformOptions, dataTransform } from './operations/data-transform'; import { installExtension } from './operations/dependencies'; diff --git a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts index 9e956adfcea6..854582538a36 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts @@ -11,6 +11,7 @@ import { col, lit } from '@internal/sql-relational-core/contract-free'; import { blindCast } from '@internal/utils/casts'; import { describe, expect, it } from 'vitest'; import type { AlterColumnTypeOptions } from '../src/core/migrations/op-factory-call'; +import type { AlterColumnTypeClass } from '../src/core/migrations/operations/columns'; import type { DataTransformOptions } from '../src/core/migrations/operations/data-transform'; import type { CreateIndexExtras } from '../src/core/migrations/operations/indexes'; import type { ForeignKeySpec } from '../src/core/migrations/operations/shared'; @@ -158,7 +159,7 @@ class ExposedMigration extends PostgresMigration { readonly table: string; readonly column: string; readonly options: AlterColumnTypeOptions; - readonly operationClass?: 'widening' | 'destructive'; + readonly operationClass?: AlterColumnTypeClass; }): Promise { return this.alterColumnType(options); } From 8034befa983f7e6b94ee8eb668fb4e2dd2fb21aa Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:04:41 +0200 Subject: [PATCH 015/109] TML-3476: test that a SQLite nullability tightening fails on a NULL and keeps the row Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/sqlite/migrations/harness.ts | 51 +++++++++++++++---- .../test/sqlite/migrations/widening.test.ts | 32 +++++++++++- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/test/e2e/framework/test/sqlite/migrations/harness.ts b/test/e2e/framework/test/sqlite/migrations/harness.ts index c90d5397b62a..68697789e354 100644 --- a/test/e2e/framework/test/sqlite/migrations/harness.ts +++ b/test/e2e/framework/test/sqlite/migrations/harness.ts @@ -118,14 +118,45 @@ function formatFailure(f: MigrationRunnerFailure): string { return parts.join('\n'); } +interface MigrationOptions { + origin?: Contract; + destination: Contract; + policy?: MigrationOperationPolicy; + seed?: (driver: Driver) => Promise; +} + +export interface FailedMigrationResult { + readonly driver: Driver; + readonly failure: MigrationRunnerFailure; +} + export async function applyMigration( - options: { - origin?: Contract; - destination: Contract; - policy?: MigrationOperationPolicy; - seed?: (driver: Driver) => Promise; - }, + options: MigrationOptions, runAssertions: (result: MigrationResult) => Promise, +): Promise { + await runMigration(options, runAssertions, async ({ failure }) => { + throw new Error(`Destination runner failed: ${formatFailure(failure)}`); + }); +} + +/** Applies the destination plan, expects the runner to fail, and hands the failure and the database to `assertFailure`. */ +export async function applyMigrationExpectingFailure( + options: MigrationOptions, + assertFailure: (result: FailedMigrationResult) => Promise, +): Promise { + await runMigration( + options, + async () => { + throw new Error('Expected the destination runner to fail, but it succeeded'); + }, + assertFailure, + ); +} + +async function runMigration( + options: MigrationOptions, + onSuccess: (result: MigrationResult) => Promise, + onFailure: (result: FailedMigrationResult) => Promise, ): Promise { const testDb = createTestDb(); const { driver } = testDb; @@ -200,8 +231,10 @@ export async function applyMigration( }, ], }); - if (!runResult.ok) - throw new Error(`Destination runner failed: ${formatFailure(runResult.failure)}`); + if (!runResult.ok) { + await onFailure({ driver, failure: runResult.failure }); + return; + } const freshSchema = await adapter.introspect(driver); const vr = familyInstance.verifySchema({ @@ -219,7 +252,7 @@ export async function applyMigration( for (const [name, tbl] of Object.entries(freshSchema.tables)) { if (!CONTROL_TABLES.has(name)) userTables[name] = tbl; } - await runAssertions({ + await onSuccess({ driver, schema: new SqlSchemaIR({ ...freshSchema, tables: userTables }), operationsExecuted: runResult.value.perSpaceResults[0]?.value.operationsExecuted ?? 0, diff --git a/test/e2e/framework/test/sqlite/migrations/widening.test.ts b/test/e2e/framework/test/sqlite/migrations/widening.test.ts index 315dc90aa8a0..079849399186 100644 --- a/test/e2e/framework/test/sqlite/migrations/widening.test.ts +++ b/test/e2e/framework/test/sqlite/migrations/widening.test.ts @@ -1,7 +1,7 @@ import { datetimeColumn } from '@prisma/orm-sqlite/adapter/column-types'; import { defineContract, field, model, now, sql } from '@prisma/orm-sqlite/contract-builder'; import { describe, expect, it } from 'vitest'; -import { applyMigration, int, text } from './harness'; +import { applyMigration, applyMigrationExpectingFailure, int, text } from './harness'; describe('SQLite Migration E2E - Widening operations (recreate-table)', () => { const WIDENING = { allowedOperationClasses: ['additive', 'widening'] } as const; @@ -51,6 +51,36 @@ describe('SQLite Migration E2E - Widening operations (recreate-table)', () => { ); }); + it('fails a nullability tightening on a NULL and keeps the row and the nullable column', async () => { + await applyMigrationExpectingFailure( + { + origin: defineContract({ + models: { User: model('User', { fields: { id: int.id(), name: text.optional() } }) }, + }), + destination: defineContract({ + models: { User: model('User', { fields: { id: int.id(), name: text } }) }, + }), + policy: WIDENING, + seed: async (driver) => { + await driver.query('INSERT INTO "User" (id, name) VALUES (?, ?)', [1, null]); + }, + }, + async ({ driver }) => { + const rows = (await driver.query('SELECT id, name FROM "User"')).rows; + const columns = ( + await driver.query<{ name: string; notnull: number }>('PRAGMA table_info("User")') + ).rows.map(({ name, notnull }) => ({ name, notnull })); + expect({ rows, columns }).toEqual({ + rows: [{ id: 1, name: null }], + columns: [ + { name: 'id', notnull: 1 }, + { name: 'name', notnull: 0 }, + ], + }); + }, + ); + }); + it('changes a column default', async () => { await applyMigration( { From d3d67fd560691761b59146e6ef9964f920b79bc2 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:04:42 +0200 Subject: [PATCH 016/109] TML-3476: the Mongo consent journey checks the refusal code and that the collection survives Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cli-journeys/mongo-db-update-consent.e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts index e13a3eccf347..f99ce6cedc6b 100644 --- a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts @@ -11,6 +11,7 @@ import { consentTokenFor, engineError, type JourneyContext, + parseJsonOutput, runContractEmit, runDbInit, runDbUpdate, @@ -159,8 +160,11 @@ describe('Journey: Mongo db update asks for consent only to lose data', { copyFileSync(join(FIXTURES_DIR, 'contract-additive.ts'), join(ctx.testDir, 'contract.ts')); expect((await runContractEmit(ctx)).exitCode).toBe(0); - const unconfirmed = await runDbUpdate(ctx, ['--no-interactive']); + const unconfirmed = await runDbUpdate(ctx, ['--json', '--no-interactive']); expect(unconfirmed.exitCode).toBe(2); + expect(parseJsonOutput(unconfirmed)).toMatchObject({ code: 'CLI.CONSENT_REQUIRED' }); + const kept = await client.db(dbName).listCollections({ name: 'events' }).toArray(); + expect(kept.map(({ name }) => name)).toEqual(['events']); const destructive = await runDbUpdate(ctx, [ '--no-interactive', From ec6c18dd12f6e5855ac7fb550bd37aed9ade1ce3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:04:42 +0200 Subject: [PATCH 017/109] TML-3476: docs name what the widening classes change and document alterColumnType's operationClass Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- skills/prisma-8/references/migrations.md | 2 +- .../pending/migration-statement-refusal/app/instructions.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index e5c8ff487d1f..eb3950f0d4d3 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -140,7 +140,7 @@ The canonical contract IR stays planner-agnostic and hash-stable; it only encode 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). 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). +Core operations cover additive structure: create table, add nullable column, add index/unique, add foreign key, set column default (SQL); create collection, create index (Mongo). Setting a Mongo validation is a core operation too, classed `widening` (see [Operation Model](#operation-model)). 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` diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 7ca3c4d48e03..a837468de19c 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -362,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 })`), `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`. +- 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` (pass `operationClass: 'widening'` only when every value of the old type converts to the new type unchanged, such as `int4` to `int8`; the default is `'destructive'`), `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`. diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 9098dd8c38e4..41bbcb8ff5e5 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -14,7 +14,7 @@ changes: ## `destructive-means-data-loss` -The planner and the migration factories now class an operation as `destructive` only when it can lose rows or values. An operation that fails rather than losing a value is `widening`: `SET NOT NULL` fails on a NULL, and a MongoDB validator applies only to later writes. `prisma migration status` counts fewer destructive operations, and `prisma db update` asks for consent less often. +The planner and the migration factories now class an operation as `destructive` only when it can lose rows or values. An operation that fails rather than losing a value is `widening`: `SET NOT NULL` fails on a NULL, and a MongoDB validator applies only to later writes. `prisma migration show` marks fewer operations with ⚠, `prisma migration plan` and `prisma db update` print the data-loss warning less often, and `prisma db update` asks for consent less often. A migration that is already applied needs nothing: `prisma db migrate` applies its `ops.json` as written, and the database ledger records the migration by its hash. From dc2d87e580cb1520a159542989e40a19d4418e13 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:10:11 +0200 Subject: [PATCH 018/109] TML-3476: dispatch 2 brief, and the engine spec records per-command statement declarations Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/engine-statement-prompt/spec.md | 7 ++-- .../dispatches/02-data-loss-subjects.md | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 projects/migration-statements/slices/refusal/dispatches/02-data-loss-subjects.md diff --git a/projects/migration-statements/slices/engine-statement-prompt/spec.md b/projects/migration-statements/slices/engine-statement-prompt/spec.md index 99e07c8c3db6..db7e0c62bcfd 100644 --- a/projects/migration-statements/slices/engine-statement-prompt/spec.md +++ b/projects/migration-statements/slices/engine-statement-prompt/spec.md @@ -52,11 +52,14 @@ A command that has several questions asks them together: `ctx.prompt.statements( - Non-interactively, or under `--yes`, one `CLI.CONSENT_REQUIRED` is thrown that lists every question still unanswered, each with its flag forms in `nextActions`, and `meta.unanswered: [{ subject, verbs }]`. - Interactively, the unanswered questions are asked one after another, in order. - `statement(q)` is `statements([q])[0]`. +- `statements(questions, { last: true })` says this is the run's final ask: when it has answered its questions and values are still unconsumed, it throws `CLI.CONSENT_UNUSED` there, before the command does anything with the answers. The end-of-run check stays for commands that never say `last`. +- A verb's `arity` is the number of argv values each occurrence takes; a wrong count is `CLI.INVALID_ARGUMENTS`. Verbs are one lowercase word. A declaration carries a `brief` for help, because the product owns its help text. A `server-command` may not declare statements. ### Verb flags -- Verb flags are shared flags, declared by the engine like `--confirm`: `kind: "parsed"`, variadic, optional, reserved names. A command cannot declare a flag with a verb's name, and handlers never see the values. -- The set of verbs is registered at construction: `createCli({ ..., statementVerbs: ['rename', 'delete'] })` (or on the command family, whichever `RESERVED_FLAG_NAMES` is assembled from; the implementer chooses the smaller change and says so). A `statement()` call naming an unregistered verb is a construction error. +- The engine knows no verbs. A command declares the statements it may ask for, with their arity: `defineCommand({ statements: { rename: { arity: 1 }, delete: { arity: 1 } }, ... })`. For that command only, the engine parses `--` followed by `arity` values, repeatable, keeps the values in argv order, and hands them out through `ctx.prompt.statement`. Handlers never see the values, as with `--confirm`. (Will's ruling, 2026-10-07: the verb is the ORM's, declared per command, not registered on the family or the CLI, so no other command parses it.) +- A command cannot declare an ordinary flag with the same name as one of its statements, and a `statement()` call naming a verb the command did not declare is a construction error. +- `arity` is 1 for every ORM verb today; it exists so the engine never assumes the shape of a statement. - Order across flags is kept: the engine records the position of every verb-flag value in argv, so a product that needs `--rename A:B --delete C` in the order written can read it. This slice only needs to consume values; it exposes the ordered list on the run state so the ORM's statement list can be built from it later. - At the end of a run that settled successfully, an unconsumed verb-flag value fails the run with a new `CLI.CONSENT_UNUSED`: "`--delete Legacy` was given but nothing in this run asked about `Legacy`". A run that failed for another reason reports that reason only. `--confirm` keeps its current silent behaviour; changing it is not this slice. diff --git a/projects/migration-statements/slices/refusal/dispatches/02-data-loss-subjects.md b/projects/migration-statements/slices/refusal/dispatches/02-data-loss-subjects.md new file mode 100644 index 000000000000..d3b759b8389f --- /dev/null +++ b/projects/migration-statements/slices/refusal/dispatches/02-data-loss-subjects.md @@ -0,0 +1,32 @@ +# Dispatch 2 — The planner result names what each destructive operation would lose + +**Slice:** [`../spec.md`](../spec.md) § "The structured refusal" · **Plan:** [`../plan.md`](../plan.md) dispatch 2 · **Builds on:** dispatch 1 + +## Outcome + +The planner's success result says, for every operation that loses data and for every operation that widens access to rows, which domain thing it is about, so the CLI can ask the user about it in contract vocabulary without any family code in the framework. + +## What to build + +1. **Framework type** (`packages/1-framework/1-core/framework-components/src/control/migration-statements.ts`): `MigrationStatementSubject = { kind: 'model' | 'field', namespaceId, model, field? } | { kind: 'storage', name }`, with its JSON form next to `migrationStatementJson` (leave `namespaceId` out for the unbound namespace, as statements do). `MigrationPlannerSuccessResult` gains `dataLoss: readonly { operationIndex: number; subject: MigrationStatementSubject }[]` and `accessWidening` of the same shape. Both required; every planner fills them. +2. **SQL family** (`packages/2-sql/9-family/src/core/migrations/`): after a plan is assembled, walk its operations; for each `destructive` one, map its table or column back to a model or field of `fromContract` through `model.storage.table` and `fields[f].column` (reverse of `modelTable`/`fieldColumn` in `statement-planning.ts`), with the rename statements already applied, so a table renamed earlier in the plan is found under its old name in the origin. No `fromContract`, or a table no model stores: `{ kind: 'storage', name }`. Index entries by the operation that loses the data; on SQLite a table rebuild that drops columns is that operation, not the later `dropColumn`, so list the dropped field once, at the rebuild's index. `accessWidening`: the `DropPostgresRlsPolicyCall` and `DisableRowLevelSecurityCall` operations, subject the table's model, excluding the transient DROP of a policy replacement (see `gradePolicyReplacement` and the replacement path in `planner.ts`). +3. **MongoDB**: `dropCollection` entries with the model whose collection it is; `accessWidening` empty. +4. **Safe type widenings on `db update`** (dispatch 1 left this open; the reviewer's finding is in `../reviews/code-review.md` round 1 item 7): in `mapColumnNodeIssue` (`packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts` ~L654-705) the live column has no codec but carries a normalised `resolvedNativeType`. Map that name to a data type id through the written text of the parameterless types (`data-types.ts` ~L140-200) and reuse `SAFE_WIDENINGS`; require `many` false on both sides. A safe pair gets `'widening'`. Test through the issue planner with an introspected `int4` column and an `int8` contract column. +5. **Aggregate and `db update` plumbing** (`packages/1-framework/3-tooling/migration/src/aggregate/`): `PerSpacePlan` and `planFromDiff` carry `dataLoss` and `accessWidening` through; `db-run.ts`'s plan result exposes them with each entry's `operationIndex` offset the way `appliedStatements` positions are (see `operationsBefore`). +6. **Per-statement origin check**: in `resolve-statements.ts`, the `origin.kind === 'missing'` check moves inside the per-statement loop so it applies to `rename` only. Add the `delete` verb to `StatementVerb` and the parser (text is a coordinate or, when the refusal said so, a storage name; both pass through unresolved, since `delete` is matched against the plan in dispatch 3, not resolved against a contract). `describeMigrationStatement` writes `delete model "Legacy"`. + +## Not in this dispatch + +Any prompt, flag, or refusal text; the CLI's matching of `delete` answers to `dataLoss`; the engine pin. Those are dispatch 3. + +## Tests + +Family and target tests for every mapping case: model subject, field subject, renamed-earlier table, storage fallback with and without `fromContract`, SQLite rebuild indexing, Postgres policy drop and RLS disable with the replacement DROP excluded, Mongo collection. The `db update` safe-widening test. Aggregate plumbing and offset tests. Resolver tests for `delete` and for `rename` with a missing origin failing per statement. + +## Halt conditions + +Stop and report if the SQL operations do not carry enough (`target.details`) to find the table and column for some destructive operation; if mapping the live native type needs more than the written-text lookup described; or if excluding the policy-replacement DROP needs the planner to carry new state. + +## Gate + +The standard gate from `../plan.md`, plus `pnpm lint:framework-vocabulary` (the framework type must add no family word) and the two rename-statements journeys. From bdce3435b264ffffdadc3a88f4392ff0b87b31f5 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:15:02 +0200 Subject: [PATCH 019/109] TML-3476: MigrationStatementSubject names what an operation is about, and describeMigrationStatement writes a delete Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/migration-statements.ts | 74 ++++++++++++++++++- .../src/exports/control.ts | 5 ++ .../test/migration-statements.test.ts | 57 ++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) 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 index 9ff9b08a94af..7c6a29963ad7 100644 --- 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 @@ -39,6 +39,28 @@ export type ResolvedMigrationStatement = | ResolvedModelRenameStatement | ResolvedFieldRenameStatement; +/** + * What an operation of a plan is about, in the contract's terms where the family can say: a model + * or a field of the origin contract, or, when no model of the origin contract stores it, the name + * the database knows it by. + */ +export type MigrationStatementSubject = + | ({ readonly kind: 'model' } & ModelCoordinate) + | ({ readonly kind: 'field' } & FieldCoordinate) + | { readonly kind: 'storage'; readonly name: string }; + +/** An operation of a plan, by its position in the plan's `operations`, and its subject. */ +export interface MigrationOperationSubject { + readonly operationIndex: number; + readonly subject: MigrationStatementSubject; +} + +/** A statement that consents to losing the data of its subject. It never reaches a planner. */ +export interface DeleteMigrationStatement { + readonly kind: 'delete'; + readonly subject: MigrationStatementSubject; +} + /** * 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 @@ -92,6 +114,12 @@ export type MigrationStatementJson = readonly to: FieldCoordinateJson; }; +/** A subject in JSON output: `namespaceId` is left out for the unbound namespace. */ +export type MigrationStatementSubjectJson = + | ({ readonly kind: 'model' } & ModelCoordinateJson) + | ({ readonly kind: 'field' } & FieldCoordinateJson) + | { readonly kind: 'storage'; readonly name: string }; + function coordinateJson( coordinate: T, ): Omit & ModelCoordinateJson { @@ -109,16 +137,56 @@ export function migrationStatementJson( : { ...statement, from: coordinateJson(statement.from), to: coordinateJson(statement.to) }; } +/** The subject as JSON output writes it. */ +export function migrationStatementSubjectJson( + subject: MigrationStatementSubject, +): MigrationStatementSubjectJson { + switch (subject.kind) { + case 'model': + return { + kind: 'model', + ...coordinateJson({ namespaceId: subject.namespaceId, model: subject.model }), + }; + case 'field': + return { + kind: 'field', + ...coordinateJson({ + namespaceId: subject.namespaceId, + model: subject.model, + field: subject.field, + }), + }; + case 'storage': + return subject; + } +} + +function describeDelete( + subject: MigrationStatementSubject, + fromContract: ContractWithDomain, +): string { + switch (subject.kind) { + case 'model': + return `delete model "${modelName(fromContract, subject)}"`; + case 'field': + return `delete field "${modelName(fromContract, subject)}.${subject.field}"`; + case 'storage': + return `delete storage "${subject.name}"`; + } +} + /** * 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. + * namespace only when its contract has more than one. A renamed field is named through its model as + * the destination contract names it, as the statement itself is written; a deleted model or field + * as the origin contract names it. */ export function describeMigrationStatement( - statement: ResolvedMigrationStatement, + statement: ResolvedMigrationStatement | DeleteMigrationStatement, fromContract: ContractWithDomain, contract: ContractWithDomain, ): string { + if (statement.kind === 'delete') return describeDelete(statement.subject, fromContract); if (statement.entity === 'model') { return `rename model "${modelName(fromContract, statement.from)}" to "${modelName(contract, statement.to)}"`; } 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 191aa00a2672..ef283a32d8e1 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 @@ -124,9 +124,13 @@ export { } from '../control/control-stack'; export type { AppliedMigrationStatement, + DeleteMigrationStatement, FieldCoordinate, FieldCoordinateJson, + MigrationOperationSubject, MigrationStatementJson, + MigrationStatementSubject, + MigrationStatementSubjectJson, ModelCoordinate, ModelCoordinateJson, ResolvedFieldRenameStatement, @@ -136,6 +140,7 @@ export type { export { describeMigrationStatement, migrationStatementJson, + migrationStatementSubjectJson, modelDisplayName, } from '../control/migration-statements'; export { orderIssuesByDependencies } from '../control/order-issues-by-dependencies'; 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 index 7b82cba57e81..4e9cc59edc7d 100644 --- 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 @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { describeMigrationStatement, migrationStatementJson, + migrationStatementSubjectJson, modelDisplayName, type ResolvedMigrationStatement, } from '../src/control/migration-statements'; @@ -98,6 +99,62 @@ describe('describeMigrationStatement', () => { }); }); +describe('describeMigrationStatement for a delete', () => { + it('names the model, the field or the storage the statement deletes', () => { + const contract = contractWith({ app: ['Legacy', 'User'] }); + expect([ + describeMigrationStatement( + { kind: 'delete', subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, + contract, + contract, + ), + describeMigrationStatement( + { + kind: 'delete', + subject: { kind: 'field', namespaceId: app, model: 'User', field: 'name' }, + }, + contract, + contract, + ), + describeMigrationStatement( + { kind: 'delete', subject: { kind: 'storage', name: 'audit_log' } }, + contract, + contract, + ), + ]).toEqual(['delete model "Legacy"', 'delete field "User.name"', 'delete storage "audit_log"']); + }); + + it('names a deleted model through its namespace when the origin contract has several', () => { + expect( + describeMigrationStatement( + { kind: 'delete', subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, + contractWith({ app: ['Legacy'], billing: ['Bill'] }), + contractWith({ billing: ['Bill'] }), + ), + ).toBe('delete model "app.Legacy"'); + }); +}); + +describe('migrationStatementSubjectJson', () => { + it('leaves namespaceId out for the unbound namespace and keeps it otherwise', () => { + const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); + expect([ + migrationStatementSubjectJson({ + kind: 'field', + namespaceId: unbound, + model: 'User', + field: 'name', + }), + migrationStatementSubjectJson({ kind: 'model', namespaceId: app, model: 'Legacy' }), + migrationStatementSubjectJson({ kind: 'storage', name: 'audit_log' }), + ]).toEqual([ + { kind: 'field', model: 'User', field: 'name' }, + { kind: 'model', namespaceId: 'app', model: 'Legacy' }, + { kind: 'storage', name: 'audit_log' }, + ]); + }); +}); + describe('modelDisplayName', () => { it('qualifies a model with its namespace, except in the unbound namespace', () => { expect([ From a5bbdd1411e7880f0ffcaa070acbea9319e45da0 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:17:16 +0200 Subject: [PATCH 020/109] TML-3476: the SQL family maps an operation's table or column to a model or field of the origin contract Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/operation-subjects.ts | 132 +++++++++++++++++ .../src/core/migrations/statement-planning.ts | 3 +- .../2-sql/9-family/src/exports/control.ts | 6 + .../9-family/test/operation-subjects.test.ts | 138 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/2-sql/9-family/src/core/migrations/operation-subjects.ts create mode 100644 packages/2-sql/9-family/test/operation-subjects.test.ts diff --git a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts new file mode 100644 index 000000000000..225d5e287507 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -0,0 +1,132 @@ +import { asNamespaceId, type ContractWithDomain } from '@internal/contract/types'; +import type { + MigrationOperationSubject, + MigrationStatementSubject, + ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { + fieldRenameStorageEffect, + isSqlModelStorage, + modelRenameStorageEffect, +} from './statement-planning'; + +/** + * What an operation is about, as the plan names it after its rename statements: a table, or a + * column of one, in a storage namespace, or neither, such as a type. `storageName` names the + * subject when no model of the origin contract stores it. + */ +export interface SubjectTarget { + readonly storageName: string; + readonly table: + | { + readonly namespaceId: string; + readonly table: string; + readonly column: string | undefined; + } + | undefined; +} + +/** A call of a plan: how many operations it lowers to, and what the first of them is about. */ +export interface CallSubjects { + readonly operationCount: number; + /** What the call's operation loses, one entry per model, field or storage name. */ + readonly dataLoss: readonly SubjectTarget[]; + /** What the call's operation widens access to. */ + readonly accessWidening: readonly SubjectTarget[]; +} + +export interface PlanSubjects { + readonly dataLoss: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationOperationSubject[]; +} + +interface SubjectContext { + readonly fromContract: ContractWithDomain | null; + readonly contract: ContractWithDomain; + /** The rename statements the plan applied, whose renames a target names the new way. */ + readonly statements: readonly ResolvedMigrationStatement[]; +} + +function originTable( + context: SubjectContext & { readonly fromContract: ContractWithDomain }, + namespaceId: string, + table: string, +): string { + for (const statement of context.statements) { + if (statement.entity !== 'model') continue; + const effect = modelRenameStorageEffect(statement, context.fromContract, context.contract); + if (!effect.ok || effect.value.kind !== 'renameTable') continue; + const { rename } = effect.value; + if (rename.namespaceId === namespaceId && rename.to === table) return rename.from; + } + return table; +} + +function originColumn( + context: SubjectContext & { readonly fromContract: ContractWithDomain }, + namespaceId: string, + table: string, + column: string, +): string { + for (const statement of context.statements) { + if (statement.entity !== 'field') continue; + const effect = fieldRenameStorageEffect(statement, context.fromContract, context.contract); + if (!effect.ok || effect.value.kind !== 'renameColumn') continue; + const rename = effect.value; + if ( + rename.table.namespaceId === namespaceId && + rename.table.table === table && + rename.to === column + ) { + return rename.from; + } + } + return column; +} + +function subjectOf(target: SubjectTarget, context: SubjectContext): MigrationStatementSubject { + const storage: MigrationStatementSubject = { kind: 'storage', name: target.storageName }; + const { fromContract } = context; + if (target.table === undefined || fromContract === null) return storage; + const withOrigin = { ...context, fromContract }; + const { namespaceId } = target.table; + const table = originTable(withOrigin, namespaceId, target.table.table); + for (const [modelNamespace, namespace] of Object.entries(fromContract.domain.namespaces)) { + for (const [model, { storage: modelStorage }] of Object.entries(namespace.models)) { + if (!isSqlModelStorage(modelStorage)) continue; + if (modelStorage.namespaceId !== namespaceId || modelStorage.table !== table) continue; + const coordinate = { namespaceId: asNamespaceId(modelNamespace), model }; + if (target.table.column === undefined) return { kind: 'model', ...coordinate }; + const column = originColumn(withOrigin, namespaceId, table, target.table.column); + const field = Object.entries(modelStorage.fields).find( + ([, stored]) => stored.column === column, + )?.[0]; + return field === undefined ? storage : { kind: 'field', ...coordinate, field }; + } + } + return storage; +} + +/** + * The subject of each operation that loses data and each that widens access, at its position in + * the plan. A subject is a model or a field of the origin contract when one stores it, found under + * its origin name when a rename statement renamed its table or column earlier in the plan. + */ +export function planSubjects( + calls: readonly CallSubjects[], + context: SubjectContext, +): PlanSubjects { + const dataLoss: MigrationOperationSubject[] = []; + const accessWidening: MigrationOperationSubject[] = []; + let operationIndex = 0; + for (const call of calls) { + for (const target of call.dataLoss) { + dataLoss.push({ operationIndex, subject: subjectOf(target, context) }); + } + for (const target of call.accessWidening) { + accessWidening.push({ operationIndex, subject: subjectOf(target, context) }); + } + operationIndex += call.operationCount; + } + return { dataLoss, accessWidening }; +} 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 index 903a71e51dac..5ca1f6a0b836 100644 --- a/packages/2-sql/9-family/src/core/migrations/statement-planning.ts +++ b/packages/2-sql/9-family/src/core/migrations/statement-planning.ts @@ -63,7 +63,8 @@ export interface ColumnOnOneSide { readonly kind: 'columnOnOneSide'; } -function isSqlModelStorage(storage: ModelStorageBase): storage is SqlModelStorage { +/** Whether a model's storage names a table, as a model a SQL contract stores in a table does. */ +export function isSqlModelStorage(storage: ModelStorageBase): storage is SqlModelStorage { return ( typeof storage['table'] === 'string' && typeof storage['namespaceId'] === 'string' && diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 04a20558d0e2..8620bb529fc6 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -39,6 +39,12 @@ export { } from '../core/migrations/control-policy'; export type { PlanFieldEventOperationsOptions } from '../core/migrations/field-event-planner'; export { planFieldEventOperations } from '../core/migrations/field-event-planner'; +export type { + CallSubjects, + PlanSubjects, + SubjectTarget, +} from '../core/migrations/operation-subjects'; +export { planSubjects } from '../core/migrations/operation-subjects'; export { createMigrationPlan, plannerFailure, diff --git a/packages/2-sql/9-family/test/operation-subjects.test.ts b/packages/2-sql/9-family/test/operation-subjects.test.ts new file mode 100644 index 000000000000..30f0765f1811 --- /dev/null +++ b/packages/2-sql/9-family/test/operation-subjects.test.ts @@ -0,0 +1,138 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + type CallSubjects, + planSubjects, + type SubjectTarget, +} from '../src/core/migrations/operation-subjects'; +import { contractOf, renameField, renameModel } from './statement-fixtures'; + +const app = asNamespaceId('app'); + +function table(name: string, storageName = name): SubjectTarget { + return { storageName, table: { namespaceId: 'app', table: name, column: undefined } }; +} + +function column(tableName: string, columnName: string): SubjectTarget { + return { + storageName: `${tableName}.${columnName}`, + table: { namespaceId: 'app', table: tableName, column: columnName }, + }; +} + +function losing(...targets: readonly SubjectTarget[]): CallSubjects { + return { operationCount: 1, dataLoss: targets, accessWidening: [] }; +} + +const unchanged: CallSubjects = { operationCount: 1, dataLoss: [], accessWidening: [] }; + +const origin = contractOf({ + Legacy: { table: 'legacy', fields: { id: 'id' } }, + User: { table: 'user', fields: { id: 'id', name: 'full_name' } }, +}); + +describe('planSubjects', () => { + it('names the model of a dropped table and the field of a dropped column', () => { + expect( + planSubjects([unchanged, losing(table('legacy')), losing(column('user', 'full_name'))], { + fromContract: origin, + contract: contractOf({ User: { table: 'user', fields: { id: 'id' } } }), + statements: [], + }), + ).toEqual({ + dataLoss: [ + { operationIndex: 1, subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, + { + operationIndex: 2, + subject: { kind: 'field', namespaceId: app, model: 'User', field: 'name' }, + }, + ], + accessWidening: [], + }); + }); + + it('counts every operation of a call with companions before the next call', () => { + expect( + planSubjects( + [{ operationCount: 3, dataLoss: [], accessWidening: [] }, losing(table('legacy'))], + { fromContract: origin, contract: origin, statements: [] }, + ).dataLoss, + ).toEqual([ + { operationIndex: 3, subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, + ]); + }); + + it('finds a table and a column renamed earlier in the plan under their origin names', () => { + const destination = contractOf({ + Account: { table: 'account', fields: { id: 'id', displayName: 'display_name' } }, + }); + const from = contractOf({ + User: { table: 'user', fields: { id: 'id', name: 'full_name' } }, + }); + expect( + planSubjects([losing(column('account', 'display_name'))], { + fromContract: from, + contract: destination, + statements: [ + renameModel('User', 'Account'), + renameField('User', 'name', 'displayName', 'Account'), + ], + }).dataLoss, + ).toEqual([ + { + operationIndex: 0, + subject: { kind: 'field', namespaceId: app, model: 'User', field: 'name' }, + }, + ]); + }); + + it('names storage the origin contract does not declare by its storage name', () => { + expect( + planSubjects([losing(table('audit_log', 'public.audit_log'), column('user', 'nickname'))], { + fromContract: origin, + contract: origin, + statements: [], + }).dataLoss, + ).toEqual([ + { operationIndex: 0, subject: { kind: 'storage', name: 'public.audit_log' } }, + { operationIndex: 0, subject: { kind: 'storage', name: 'user.nickname' } }, + ]); + }); + + it('names every subject by its storage name when the plan has no origin contract', () => { + expect( + planSubjects([losing(table('legacy'), column('user', 'full_name'))], { + fromContract: null, + contract: origin, + statements: [], + }).dataLoss, + ).toEqual([ + { operationIndex: 0, subject: { kind: 'storage', name: 'legacy' } }, + { operationIndex: 0, subject: { kind: 'storage', name: 'user.full_name' } }, + ]); + }); + + it('names a subject with no table, such as a type, by its storage name', () => { + expect( + planSubjects([losing({ storageName: 'public.mood', table: undefined })], { + fromContract: origin, + contract: origin, + statements: [], + }).dataLoss, + ).toEqual([{ operationIndex: 0, subject: { kind: 'storage', name: 'public.mood' } }]); + }); + + it('lists access-widening operations with the model of their table', () => { + expect( + planSubjects( + [unchanged, { operationCount: 1, dataLoss: [], accessWidening: [table('user')] }], + { fromContract: origin, contract: origin, statements: [] }, + ), + ).toEqual({ + dataLoss: [], + accessWidening: [ + { operationIndex: 1, subject: { kind: 'model', namespaceId: app, model: 'User' } }, + ], + }); + }); +}); From 1496f57842c15e5bfcc828214e7933d416c2d499 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:18:52 +0200 Subject: [PATCH 021/109] TML-3476: a SQLite recreate call records the columns whose type it changes The planner reads lossyColumns to name what the recreate loses. They stay out of the lowered operation and the rendered migration.ts. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/op-factory-call.ts | 27 ++++++---- .../src/core/migrations/planner-strategies.ts | 49 +++++++++++++------ .../test/migrations/op-factory-call.test.ts | 24 +++++++++ .../migrations/planner-strategies.test.ts | 26 ++++++++++ 4 files changed, 101 insertions(+), 25 deletions(-) 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 5e1938f76783..3e4a3ea3de23 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 @@ -490,17 +490,26 @@ export class RecreateTableCall extends SqliteOpFactoryCallNode { readonly summary: string; readonly postchecks: readonly RecreatePostcheck[]; readonly label: string; + /** + * The columns whose values the copy can change, because their type changes. The planner reads + * them to name what the recreate loses; they never reach the operation or `migration.ts`. + */ + readonly lossyColumns: readonly string[]; - constructor(args: { - tableName: string; - contractTable: SqliteTableSpec; - schemaColumnNames: readonly string[]; - indexes: readonly SqliteIndexSpec[]; - summary: string; - postchecks: readonly RecreatePostcheck[]; - operationClass: MigrationOperationClass; - }) { + constructor( + args: { + tableName: string; + contractTable: SqliteTableSpec; + schemaColumnNames: readonly string[]; + indexes: readonly SqliteIndexSpec[]; + summary: string; + postchecks: readonly RecreatePostcheck[]; + operationClass: MigrationOperationClass; + }, + lossyColumns: readonly string[] = [], + ) { super(); + this.lossyColumns = Object.freeze([...lossyColumns]); this.tableName = args.tableName; this.contractTable = args.contractTable; this.schemaColumnNames = args.schemaColumnNames; 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 1e37cb6d4041..aef8c5684db2 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 @@ -70,6 +70,13 @@ export type CallMigrationStrategy = ( * the copy can change values; a nullability change alone is widening, because * a tightening copy fails on a NULL rather than losing it. */ +/** The column of a column issue, which `classifyNodeIssue` classes destructive only for a type change. */ +function columnNameOf(issue: SchemaDiffIssue): string { + return blindCast( + issue.expected, + ).name; +} + function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | null { const node = issue.expected ?? issue.actual; if (node === undefined) return null; @@ -110,7 +117,10 @@ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | * issue only carries that attribute's own node, never the whole table. */ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { - const byTable = new Map(); + const byTable = new Map< + string, + { issues: SchemaDiffIssue[]; hasDestructive: boolean; lossyColumns: string[] } + >(); const consumed = new Set(); for (const issue of issues) { @@ -118,13 +128,17 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { if (!cls) continue; const tableName = issue.path[1]; if (tableName === undefined) continue; - const entry = byTable.get(tableName); - if (entry) { - entry.issues.push(issue); - if (cls === 'destructive') entry.hasDestructive = true; - } else { - byTable.set(tableName, { issues: [issue], hasDestructive: cls === 'destructive' }); + const entry = byTable.get(tableName) ?? { + issues: [], + hasDestructive: false, + lossyColumns: [], + }; + entry.issues.push(issue); + if (cls === 'destructive') { + entry.hasDestructive = true; + entry.lossyColumns.push(columnNameOf(issue)); } + byTable.set(tableName, entry); consumed.add(issue); } @@ -151,15 +165,18 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { })); calls.push( - new RecreateTableCall({ - tableName, - contractTable: tableSpec, - schemaColumnNames: Object.keys(actualTable.columns), - indexes, - summary: buildRecreateSummary(tableName, entry.issues), - postchecks: buildRecreatePostchecks(tableName, entry.issues, tableSpec), - operationClass, - }), + new RecreateTableCall( + { + tableName, + contractTable: tableSpec, + schemaColumnNames: Object.keys(actualTable.columns), + indexes, + summary: buildRecreateSummary(tableName, entry.issues), + postchecks: buildRecreatePostchecks(tableName, entry.issues, tableSpec), + operationClass, + }, + entry.lossyColumns, + ), ); } 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 84faa69ee248..da3eb2e11dfa 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 @@ -427,6 +427,30 @@ describe('RecreateTableCall', () => { expect(op.postcheck.some((s) => s.description.includes('type'))).toBe(true); }); + it('keeps its lossy columns out of the operation and the rendered migration', async () => { + const args = { + tableName: 'user', + contractTable: tableSpec([ + colSpec({ name: 'id', typeSql: 'INTEGER', nullable: false }), + colSpec({ name: 'age', typeSql: 'INTEGER', nullable: true }), + ]), + schemaColumnNames: ['id', 'age'], + indexes: [], + summary: 'Recreates table user to apply schema changes: type mismatch on age', + postchecks: [], + operationClass: 'destructive' as const, + }; + const withLossy = new RecreateTableCall(args, ['age']); + const without = new RecreateTableCall(args); + + expect(withLossy.lossyColumns).toEqual(['age']); + expect(without.lossyColumns).toEqual([]); + expect(withLossy.renderTypeScript()).toBe(without.renderTypeScript()); + expect(JSON.stringify(await withLossy.toOp(stubLowerer('CHECK SQL')))).toBe( + JSON.stringify(await without.toOp(stubLowerer('CHECK SQL'))), + ); + }); + it('writes each column default the adapter renders, and checks the recreated table carries it', async () => { const lowerer: ExecuteRequestLowerer = { ...stubLowerer('CHECK SQL'), 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 67d875f95aae..0851f351a593 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 @@ -179,6 +179,32 @@ describe('recreateTableStrategy', () => { expect(classes).toEqual(['widening', 'widening']); }); + it('records the columns whose type changes as the lossy columns of the recreate', () => { + const ctx = makeContext({ + expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), + actual: new SqlSchemaIR({ tables: { user: actualUserTable } }), + }); + const typeChange = issue({ + path: ['database', 'user', 'column:email'], + expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), + actual: actualColumn({ name: 'email', nativeType: 'INTEGER', nullable: true }), + }); + const pkDrift = issue({ + path: ['database', 'user', 'primary-key'], + expected: primaryKey(['id']), + actual: primaryKey(['id', 'email']), + }); + + const lossy = recreateTableStrategy([typeChange, pkDrift], ctx); + const lossless = recreateTableStrategy([pkDrift], ctx); + + expect( + [lossy, lossless].map((result) => + result.kind === 'match' ? (result.calls[0] as RecreateTableCall).lossyColumns : result.kind, + ), + ).toEqual([['email'], []]); + }); + it('classifies a recreate for a nullability tightening and a type change as destructive', () => { const ctx = makeContext({ expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), From 8b5595542a6b7d683ecaf16b96c01709a0ec3aac Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:31:29 +0200 Subject: [PATCH 022/109] TML-3476: every planner names what each operation that loses data would lose MigrationPlannerSuccessResult carries dataLoss and accessWidening, one entry per operation with its position in the plan and its subject. Postgres and SQLite read their calls and map each table or column to a model or field of the origin contract; a SQLite recreate that leaves a column out is the operation that loses it. Postgres lists policy drops and disabling row-level security as access widening, leaving out the drop of a policy the plan replaces. MongoDB lists collection drops. The aggregate planner carries both lists per space, and db update reports them positioned in the operations of every space. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 10 +- .../test/control-migration-types.test.ts | 6 + .../cli/src/control-api/operations/db-run.ts | 33 +++ .../cli/src/control-api/operations/migrate.ts | 2 + .../src/control-api/testing/fixture-client.ts | 2 + .../3-tooling/cli/src/control-api/types.ts | 11 + .../cli/test/control-api/client.test.ts | 2 + .../db-update-statement-positions.test.ts | 15 ++ .../control-api/db-update-statements.test.ts | 2 + .../cli/test/control-api/db-update.test.ts | 14 ++ .../migration/src/aggregate/planner-types.ts | 5 + .../migration/src/aggregate/planner.ts | 2 + .../aggregate/strategies/plan-from-diff.ts | 2 + .../strategies/resolve-recorded-path.ts | 2 + .../migration/test/aggregate/planner.test.ts | 44 +++- .../strategies/plan-from-diff.test.ts | 50 +++- .../src/core/migrations/plan-helpers.ts | 4 + .../9-family/test/migrations.types.test.ts | 14 +- .../src/core/migrations/mongo-planner.ts | 36 ++- .../test/mongo-planner.data-loss.test.ts | 102 ++++++++ .../src/core/migrations/operation-subjects.ts | 117 +++++++++ .../postgres/src/core/migrations/planner.ts | 13 + .../test/migrations/planner.data-loss.test.ts | 222 ++++++++++++++++++ .../src/core/migrations/operation-subjects.ts | 71 ++++++ .../sqlite/src/core/migrations/planner.ts | 10 + .../test/migrations/planner.data-loss.test.ts | 184 +++++++++++++++ 26 files changed, 964 insertions(+), 11 deletions(-) create mode 100644 packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts 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 5a250da6f3e1..601f61ca453c 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,7 +20,11 @@ import type { ControlFamilyInstance, } from './control-instances'; import type { OperationContext } from './control-operation-results'; -import type { AppliedMigrationStatement, ResolvedMigrationStatement } from './migration-statements'; +import type { + AppliedMigrationStatement, + MigrationOperationSubject, + ResolvedMigrationStatement, +} from './migration-statements'; // ============================================================================ // Migration Package Metadata @@ -314,6 +318,10 @@ export interface MigrationPlannerSuccessResult { readonly warnings?: readonly MigrationPlannerConflict[]; /** The statements the plan applied, one per statement it was given, in order. */ readonly appliedStatements: readonly AppliedMigrationStatement[]; + /** Each operation that loses data, in plan order, with what it loses. */ + readonly dataLoss: readonly MigrationOperationSubject[]; + /** Each operation that widens who can read or write data, in plan order, with what it is about. */ + readonly accessWidening: readonly MigrationOperationSubject[]; } /** 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 8082b67c94f3..e9942c3dfa2d 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 @@ -12,11 +12,15 @@ describe('MigrationPlannerSuccessResult', () => { kind: 'success', plan: stubPlan, appliedStatements: [], + dataLoss: [], + accessWidening: [], }; expect(result).toEqual({ kind: 'success', plan: stubPlan, appliedStatements: [], + dataLoss: [], + accessWidening: [], }); expect('warnings' in result).toBe(false); }); @@ -30,6 +34,8 @@ describe('MigrationPlannerSuccessResult', () => { kind: 'success', plan: stubPlan, appliedStatements: [], + dataLoss: [], + accessWidening: [], warnings: Object.freeze([Object.freeze(warning)]), }; expect(result.warnings).toEqual([warning]); 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 02781e5e2b37..49be9ff8064e 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 @@ -10,6 +10,7 @@ import type { ControlExtensionDescriptor, ControlFamilyInstance, MigrationOperationPolicy, + MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, OperationPreview, @@ -55,6 +56,7 @@ import type { DbUpdateSuccess, OnControlProgress, PerSpaceExecutionEntry, + PlanSubjectsReport, } from '../types'; import { type BuildAggregateInputs, @@ -280,6 +282,7 @@ export async function executeRun count + resolution.entry.displayOps.length, 0); } +/** + * What the planned operations lose and whose access they widen, across every space, each at its + * position in the operations the result reports. + */ +function planSubjects(orderedResolutions: readonly OrderedResolution[]): PlanSubjectsReport { + const offset = + (resolution: OrderedResolution) => (entries: readonly MigrationOperationSubject[]) => + entries.map((entry) => ({ + ...entry, + operationIndex: + entry.operationIndex + operationsBefore(orderedResolutions, resolution.spaceId), + })); + return { + dataLoss: orderedResolutions.flatMap((resolution) => + offset(resolution)(resolution.entry.dataLoss), + ), + accessWidening: orderedResolutions.flatMap((resolution) => + offset(resolution)(resolution.entry.accessWidening), + ), + }; +} + function aggregatePlannerWarnings( orderedResolutions: readonly OrderedResolution[], ): readonly MigrationPlannerConflict[] | undefined { @@ -530,11 +557,14 @@ function wrapPlanResult(args: { readonly summary: string; /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; + /** `undefined` for `db init`, which reports no data loss. */ + readonly subjects: PlanSubjectsReport | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { mode: 'plan', ...ifDefined('appliedStatements', args.appliedStatements), + ...args.subjects, plan: { operations: stripOperations(args.operations), ...ifDefined('preview', args.preview), @@ -559,11 +589,14 @@ function wrapApplyResult(args: { readonly summary: string; /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; + /** `undefined` for `db init`, which reports no data loss. */ + readonly subjects: PlanSubjectsReport | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { mode: 'apply', ...ifDefined('appliedStatements', args.appliedStatements), + ...args.subjects, plan: { operations: stripOperations(args.operations) }, destination: { storageHash: args.destination.storageHash, 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 b1cba6bf26f5..54caa78be819 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 @@ -509,6 +509,8 @@ function buildAtHeadResolution(args: { destinationContract: space.contract(), strategy: 'declared-state', appliedStatements: [], + dataLoss: [], + accessWidening: [], migrationEdges: [ buildFabricatedMigrationEdge({ currentMarkerStorageHash: liveMarker?.storageHash, 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 f42474dcec4d..2472e55e0597 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 @@ -125,6 +125,8 @@ export function defaultControlClientFixtures(): ControlClientFixtures { const applySuccess = { mode: 'apply' as const, appliedStatements: [], + dataLoss: [], + accessWidening: [], 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 7fe6d7ea92c5..89052d1f65c8 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 @@ -13,6 +13,7 @@ import type { ControlFamilyDescriptor, ControlTargetDescriptor, CoreSchemaView, + MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, OperationPreview, @@ -450,6 +451,12 @@ export interface DbInitFailure { */ export type DbInitResult = Result; +/** What the operations of a plan lose and whose access they widen, by position in the plan. */ +export interface PlanSubjectsReport { + readonly dataLoss: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationOperationSubject[]; +} + /** * Successful dbUpdate result. */ @@ -489,6 +496,10 @@ export interface DbUpdateSuccess { readonly perSpace?: ReadonlyArray; /** The statements the application space's plan applied, in order; empty when none were given. */ readonly appliedStatements: readonly AppliedStatementReport[]; + /** The operations in `plan.operations` that lose data, by position, with what each loses. */ + readonly dataLoss: readonly MigrationOperationSubject[]; + /** The operations in `plan.operations` that widen who can read or write data, by position. */ + readonly accessWidening: readonly MigrationOperationSubject[]; readonly summary: string; readonly warnings?: ReadonlyArray; } 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 6b3efa7019cf..4f8999ac0ddb 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 @@ -719,6 +719,8 @@ describe('ControlClient progress emission', () => { plan: () => ({ kind: 'success', appliedStatements: [], + dataLoss: [], + accessWidening: [], plan: { targetId: 'postgres', destination: { storageHash: 'dest' }, 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 index 40d1dfd705fd..4ef0228aeb65 100644 --- 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 @@ -74,6 +74,8 @@ const RENAME_OP: MigrationPlanOperation = { operationClass: 'widening', }; +const LOST = { kind: 'storage', name: 'audit_log' } as const; + function markerAt(storageHash: string): ContractMarkerRecord { return { storageHash, @@ -111,6 +113,8 @@ const migrations = { statement, operationIndexes: [0], })), + dataLoss: [{ operationIndex: 0, subject: LOST }], + accessWidening: [{ operationIndex: 0, subject: LOST }], plan: { targetId: 'postgres', origin: options.origin, @@ -207,5 +211,16 @@ describe('executeDbUpdate statement positions', () => { applied.operationIndexes.map((index) => operations[index]?.id), ), ).toEqual([[RENAME_OP.id]]); + expect( + [result.value.dataLoss, result.value.accessWidening].map((entries) => + entries.map(({ operationIndex, subject }) => ({ + operation: operations[operationIndex]?.id, + subject, + })), + ), + ).toEqual([ + [{ operation: RENAME_OP.id, subject: LOST }], + [{ operation: RENAME_OP.id, subject: LOST }], + ]); }); }); 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 index 2561b03a134a..3706c02cce0b 100644 --- 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 @@ -106,6 +106,8 @@ function recordingMigrations(operationClass: 'additive' | 'destructive' = 'addit const renamed = options.statements.length > 0; return { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: options.statements.map((statement) => ({ statement, operationIndexes: [0], 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 4fa65427d0e5..a0fde4e2cb63 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,8 @@ function createMockMigrations(overrides?: { }) { const planResult: MigrationPlannerResult = overrides?.planResult ?? { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -176,6 +178,8 @@ describe('executeDbUpdate', () => { const migrations = createMockMigrations({ planResult: { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -328,6 +332,8 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations({ planResult: { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -367,6 +373,8 @@ describe('executeDbUpdate', () => { const migrations = createMockMigrations({ planResult: { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -415,6 +423,8 @@ describe('executeDbUpdate', () => { it('allows additive, widening, and destructive operation classes', async () => { const planFn = vi.fn().mockReturnValue({ kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -468,6 +478,8 @@ describe('executeDbUpdate', () => { return createMockMigrations({ planResult: { kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', @@ -641,6 +653,8 @@ describe('executeDbUpdate', () => { createPlanner: () => ({ plan: vi.fn().mockReturnValue({ kind: 'success', + dataLoss: [], + accessWidening: [], appliedStatements: [], plan: { targetId: 'postgres', 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 f8b7c5459291..d47e3fbbaecf 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 @@ -5,6 +5,7 @@ import type { ControlAdapterInstance, ControlFamilyInstance, MigrationOperationPolicy, + MigrationOperationSubject, MigrationPlan, MigrationPlannerConflict, MigrationPlanOperation, @@ -155,6 +156,10 @@ export interface PerSpacePlan { readonly warnings?: readonly MigrationPlannerConflict[]; /** The statements the plan applied; empty for a space planned without statements. */ readonly appliedStatements: readonly AppliedMigrationStatement[]; + /** The operations of `plan` that lose data, with what each loses; empty for a recorded path. */ + readonly dataLoss: readonly MigrationOperationSubject[]; + /** The operations of `plan` that widen access, with what each is about; empty for a recorded path. */ + readonly accessWidening: readonly MigrationOperationSubject[]; /** * 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 307e448c8b79..92296f377704 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -174,6 +174,8 @@ export async function planMigration 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 22e82a44437e..54e97f13b6c9 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 @@ -115,6 +115,8 @@ export function resolveRecordedPath(input: ResolveRecordedPathInputs): ResolveRe destinationContract: space.contract(), strategy: 'resolve-recorded-path', appliedStatements: [], + dataLoss: [], + accessWidening: [], migrationEdges: edgeRefs, pathDecision: outcome.decision, }, 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 6950201333a4..5c29b5a681fd 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 @@ -96,7 +96,13 @@ describe('planMigration', () => { app: makeSpace({ spaceId: 'app' }), }); const stubPlan = makeSyntheticPlan('placeholder-target-id-from-stub'); - const planner = makeStubPlanner({ kind: 'success', plan: stubPlan, appliedStatements: [] }); + const planner = makeStubPlanner({ + kind: 'success', + plan: stubPlan, + appliedStatements: [], + dataLoss: [], + accessWidening: [], + }); const result = await planMigration({ aggregate, @@ -140,6 +146,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [applied], + dataLoss: [], + accessWidening: [], }; }, emptyMigration: () => { @@ -174,7 +182,13 @@ describe('planMigration', () => { const planner: MigrationPlanner<'sql', 'postgres'> = { plan: () => { planned = true; - return { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [] }; + return { + kind: 'success', + plan: makeSyntheticPlan('postgres'), + appliedStatements: [], + dataLoss: [], + accessWidening: [], + }; }, emptyMigration: () => { throw new Error('not used'); @@ -220,7 +234,13 @@ describe('planMigration', () => { fromContract: options.fromContract, statements: options.statements, }); - return { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [] }; + return { + kind: 'success', + plan: makeSyntheticPlan('postgres'), + appliedStatements: [], + dataLoss: [], + accessWidening: [], + }; }, emptyMigration: () => { throw new Error('not used'); @@ -259,7 +279,13 @@ describe('planMigration', () => { }); const stubPlan = makeSyntheticPlan('postgres'); - const planner = makeStubPlanner({ kind: 'success', plan: stubPlan, appliedStatements: [] }); + const planner = makeStubPlanner({ + kind: 'success', + plan: stubPlan, + appliedStatements: [], + dataLoss: [], + accessWidening: [], + }); const result = await planMigration({ aggregate, @@ -305,6 +331,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [], + dataLoss: [], + accessWidening: [], }), ); const planner: MigrationPlanner<'sql', 'postgres'> = { @@ -364,6 +392,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [], + dataLoss: [], + accessWidening: [], }); const result = await planMigration({ @@ -404,6 +434,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [], + dataLoss: [], + accessWidening: [], }); const result = await planMigration({ @@ -460,6 +492,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [], + dataLoss: [], + accessWidening: [], }); const result = await planMigration({ @@ -514,6 +548,8 @@ describe('planMigration', () => { kind: 'success', plan: makeSyntheticPlan('postgres'), appliedStatements: [], + dataLoss: [], + accessWidening: [], }); const result = await planMigration({ 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 4fc6ffb106af..14531abce35b 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,13 @@ describe('planFromDiff', () => { plan: ({ schema, ownership }) => { observedSchema = schema; observedOwnership = ownership; - return { kind: 'success', plan: makeStubPlan('placeholder'), appliedStatements: [] }; + return { + kind: 'success', + plan: makeStubPlan('placeholder'), + appliedStatements: [], + dataLoss: [], + accessWidening: [], + }; }, emptyMigration: () => { throw new Error('not used'); @@ -149,6 +155,8 @@ describe('planFromDiff', () => { kind: 'success', plan: { ...makeStubPlan('placeholder'), origin: options.origin }, appliedStatements: [], + dataLoss: [], + accessWidening: [], }; }, emptyMigration: () => { @@ -219,4 +227,44 @@ describe('planFromDiff', () => { if (outcome.kind !== 'failure') return; expect(outcome.conflicts).toEqual([{ kind: 'typeMismatch', summary: 'incompatible' }]); }); + + it('passes through what each operation loses and whose access it widens', async () => { + const subject = { kind: 'storage', name: 'audit_log' } as const; + const stubPlanner: MigrationPlanner<'sql', 'postgres'> = { + plan: () => ({ + kind: 'success', + plan: makeStubPlan('placeholder'), + appliedStatements: [], + dataLoss: [{ operationIndex: 0, subject }], + accessWidening: [{ operationIndex: 0, subject }], + }), + emptyMigration: () => { + throw new Error('not used'); + }, + }; + const outcome = await planFromDiff({ + aggregateTargetId: 'postgres', + currentMarker: null, + space: makeSpace('app', {}), + ownership: STUB_OWNERSHIP, + schemaIntrospection: { tables: {} }, + adapter: STUB_ADAPTER, + migrations: { + createPlanner: () => stubPlanner, + createRunner: () => { + throw new Error('runner not used'); + }, + contractToSchema: () => ({ tables: {} }), + }, + frameworkComponents: [], + operationPolicy: POLICY, + fromContract: null, + statements: [], + }); + + expect(outcome.kind === 'ok' && outcome.result).toMatchObject({ + dataLoss: [{ operationIndex: 0, subject }], + accessWidening: [{ operationIndex: 0, subject }], + }); + }); }); 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 8fce797dfe22..4d89dc7e5ff6 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 @@ -2,6 +2,7 @@ import type { AppliedMigrationStatement } from '@internal/framework-components/c import { blindCast } from '@internal/utils/casts'; import type { NotOk, Ok } from '@internal/utils/result'; import { notOk, ok } from '@internal/utils/result'; +import type { PlanSubjects } from './operation-subjects'; import type { AnyRecord, CreateSqlMigrationPlanOptions, @@ -120,12 +121,15 @@ export function createMigrationPlan( export function plannerSuccess( plan: SqlMigrationPlan, appliedStatements: readonly AppliedMigrationStatement[], + subjects: PlanSubjects, warnings?: readonly SqlPlannerConflict[], ): SqlPlannerSuccessResult { return Object.freeze({ kind: 'success', plan, appliedStatements: Object.freeze([...appliedStatements]), + dataLoss: Object.freeze([...subjects.dataLoss]), + accessWidening: Object.freeze([...subjects.accessWidening]), ...(warnings && warnings.length > 0 ? { warnings: Object.freeze( 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 7324d46e2431..acc494a93166 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,9 @@ describe('planner helpers', () => { operations: [], providedInvariants: [], }); - const success = plannerSuccess(plan, []); - expect(success).toEqual({ kind: 'success', plan, appliedStatements: [] }); + const noSubjects = { dataLoss: [], accessWidening: [] }; + const success = plannerSuccess(plan, [], noSubjects); + expect(success).toEqual({ kind: 'success', plan, appliedStatements: [], ...noSubjects }); expect(Object.isFrozen(success)).toBe(true); const warning = { @@ -171,9 +172,14 @@ describe('planner helpers', () => { summary: 'control policy suppressed: createTable(users)', meta: { controlPolicy: 'external', factoryName: 'createTable' }, }; - const successWithWarnings = plannerSuccess(plan, [], [warning]); + const successWithWarnings = plannerSuccess(plan, [], noSubjects, [warning]); expect(successWithWarnings.warnings).toEqual([warning]); - expect(plannerSuccess(plan, [], [])).toEqual({ kind: 'success', plan, appliedStatements: [] }); + expect(plannerSuccess(plan, [], noSubjects, [])).toEqual({ + kind: 'success', + plan, + appliedStatements: [], + ...noSubjects, + }); const conflict = { kind: 'typeMismatch', 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 e5e548c0a2b5..1039bda084b7 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,16 @@ -import type { Contract, ContractWithDomain } from '@internal/contract/types'; +import { asNamespaceId, type Contract, type ContractWithDomain } from '@internal/contract/types'; import { contractToMongoSchemaIR } from '@internal/family-mongo/control'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import { describeMigrationStatement, type MigrationOperationPolicy, + type MigrationOperationSubject, type MigrationPlanner, type MigrationPlannerConflict, type MigrationPlannerResult, type MigrationPlanWithAuthoringSurface, type MigrationScaffoldContext, + type MigrationStatementSubject, type ModelCoordinate, type PlanOrigin, type ResolvedMigrationStatement, @@ -130,6 +132,36 @@ function collectionOf(contract: ContractWithDomain | null, coordinate: ModelCoor return typeof collection === 'string' ? collection : coordinate.model; } +/** The model of `fromContract` that stores its documents in `collection`, if any. */ +function modelStoredIn( + fromContract: ContractWithDomain, + collection: string, +): ModelCoordinate | undefined { + for (const [namespaceId, namespace] of Object.entries(fromContract.domain.namespaces)) { + for (const model of Object.keys(namespace.models)) { + const coordinate = { namespaceId: asNamespaceId(namespaceId), model }; + if (collectionOf(fromContract, coordinate) === collection) return coordinate; + } + } + return undefined; +} + +/** Each collection drop of the plan, with the model whose documents it loses. */ +function collectionDrops( + calls: readonly OpFactoryCall[], + fromContract: ContractWithDomain | null, +): readonly MigrationOperationSubject[] { + return calls.flatMap((call, operationIndex) => { + if (!(call instanceof DropCollectionCall)) return []; + const model = fromContract === null ? undefined : modelStoredIn(fromContract, call.collection); + const subject: MigrationStatementSubject = + model === undefined + ? { kind: 'storage', name: call.collection } + : { kind: 'model', ...model }; + return [{ operationIndex, subject }]; + }); +} + const NOT_IN_THIS_RELEASE = 'MongoDB cannot carry out rename statements in this release.'; function shellString(name: string): string { @@ -357,6 +389,8 @@ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> options.snapshotsImportPath, ), appliedStatements: [], + dataLoss: collectionDrops(result.calls, options.fromContract), + accessWidening: [], }; } diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts new file mode 100644 index 000000000000..501a26273de4 --- /dev/null +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts @@ -0,0 +1,102 @@ +import { asNamespaceId } from '@internal/contract/types'; +import type { MigrationOperationPolicy } from '@internal/framework-components/control'; +import { MongoCollection, type MongoContract } from '@internal/mongo-contract'; +import { MongoSchemaCollection, MongoSchemaIndex, MongoSchemaIR } from '@internal/mongo-schema-ir'; +import { describe, expect, it } from 'vitest'; +import { MongoMigrationPlanner } from '../src/core/migrations/mongo-planner'; +import type { PlannerProducedMongoMigration } from '../src/core/migrations/planner-produced-migration'; + +const ALL_CLASSES_POLICY: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'], +}; + +/** A contract whose models each store their documents in the named collection. */ +function contractWith(models: Readonly>, seed: string): MongoContract { + return { + target: 'mongo', + targetFamily: 'mongo', + profileHash: 'test-profile', + capabilities: {}, + extensions: {}, + meta: {}, + roots: {}, + domain: { + namespaces: { + __unbound__: { + models: Object.fromEntries( + Object.entries(models).map(([model, collection]) => [ + model, + { fields: {}, relations: {}, storage: { collection } }, + ]), + ), + }, + }, + }, + storage: { + storageHash: `storage-${seed}`, + namespaces: { + __unbound__: { + id: '__unbound__', + kind: 'mongo-namespace', + entries: { + collection: Object.fromEntries( + Object.values(models).map((collection) => [collection, new MongoCollection({})]), + ), + }, + }, + }, + }, + } as unknown as MongoContract; +} + +function plan(fromContract: MongoContract | null) { + const result = new MongoMigrationPlanner().plan({ + contract: contractWith({ User: 'users' }, 'to'), + schema: new MongoSchemaIR([ + new MongoSchemaCollection({ name: 'users' }), + new MongoSchemaCollection({ + name: 'events', + indexes: [new MongoSchemaIndex({ keys: [{ field: 'at', direction: 1 }] })], + }), + ]), + policy: ALL_CLASSES_POLICY, + fromContract, + origin: null, + statements: [], + frameworkComponents: [], + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + return { + operations: (result.plan as PlannerProducedMongoMigration).operations.map( + (operation) => operation.label, + ), + dataLoss: result.dataLoss, + accessWidening: result.accessWidening, + }; +} + +describe('MongoDB planner, data loss', () => { + it('names the model whose collection a drop loses, and only the collection drop', () => { + const planned = plan(contractWith({ User: 'users', Event: 'events' }, 'from')); + const dropIndex = planned.operations.findIndex((label) => label.startsWith('Drop index')); + const dropCollection = planned.operations.indexOf('Drop collection events'); + + expect({ dropIndex: dropIndex >= 0, ...planned }).toMatchObject({ + dropIndex: true, + dataLoss: [ + { + operationIndex: dropCollection, + subject: { kind: 'model', namespaceId: asNamespaceId('__unbound__'), model: 'Event' }, + }, + ], + accessWidening: [], + }); + }); + + it('names the collection by its name when the plan has no origin contract', () => { + expect(plan(null).dataLoss).toEqual([ + { operationIndex: expect.any(Number), subject: { kind: 'storage', name: 'events' } }, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts new file mode 100644 index 000000000000..5f42df6b2bbd --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -0,0 +1,117 @@ +import type { Contract } from '@internal/contract/types'; +import type { CallSubjects, SubjectTarget } from '@internal/family-sql/control'; +import type { OpFactoryCall } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { resolveNamespaceIdForDdlSchema } from './control-policy'; +import { + AlterColumnTypeCall, + CreatePostgresRlsPolicyCall, + DisableRowLevelSecurityCall, + DropColumnCall, + DropPostgresRlsPolicyCall, + DropTableCall, + RawSqlCall, +} from './op-factory-call'; + +interface Locator { + /** The contract that names the namespaces: the origin contract when the plan has one. */ + readonly contract: Contract; +} + +function storageNamespaceId(locator: Locator, schemaName: string): string { + return schemaName === UNBOUND_NAMESPACE_ID + ? UNBOUND_NAMESPACE_ID + : resolveNamespaceIdForDdlSchema(locator.contract, schemaName); +} + +function qualified(schemaName: string, name: string): string { + return schemaName === UNBOUND_NAMESPACE_ID ? name : `${schemaName}.${name}`; +} + +function target( + locator: Locator, + schemaName: string, + table: string, + column: string | undefined, +): SubjectTarget { + return { + storageName: qualified(schemaName, column === undefined ? table : `${table}.${column}`), + table: { namespaceId: storageNamespaceId(locator, schemaName), table, column }, + }; +} + +function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectTarget { + const details = call.op.target.details; + if (details === undefined) return { storageName: call.label, table: undefined }; + if (details.objectType === 'table') + return target(locator, details.schema, details.name, undefined); + if (details.objectType === 'column' && details.table !== undefined) { + return target(locator, details.schema, details.table, details.name); + } + return { storageName: qualified(details.schema, details.name), table: undefined }; +} + +function dataLossOf(locator: Locator, call: OpFactoryCall): readonly SubjectTarget[] { + if (call.operationClass !== 'destructive') return []; + if (call instanceof DropTableCall) { + return [target(locator, call.schemaName, call.tableName, undefined)]; + } + if (call instanceof DropColumnCall || call instanceof AlterColumnTypeCall) { + return [target(locator, call.schemaName, call.tableName, call.columnName)]; + } + if (call instanceof RawSqlCall) return [rawSqlTarget(locator, call)]; + return [{ storageName: call.label, table: undefined }]; +} + +function policyKey(schemaName: string, tableName: string, policyName: string): string { + return JSON.stringify([schemaName, tableName, policyName]); +} + +/** + * The policies the plan creates. A drop of one of them is the first half of a replacement, which + * leaves the policy in place, so it widens no access. + */ +function createdPolicies(calls: readonly OpFactoryCall[]): ReadonlySet { + return new Set( + calls.flatMap((call) => + call instanceof CreatePostgresRlsPolicyCall + ? [policyKey(call.schemaName, call.tableName, call.policy.name)] + : [], + ), + ); +} + +function accessWideningOf( + locator: Locator, + call: OpFactoryCall, + replaced: ReadonlySet, +): readonly SubjectTarget[] { + if (call instanceof DisableRowLevelSecurityCall) { + return [target(locator, call.schemaName, call.tableName, undefined)]; + } + if ( + call instanceof DropPostgresRlsPolicyCall && + !replaced.has(policyKey(call.schemaName, call.tableName, call.policyName)) + ) { + return [target(locator, call.schemaName, call.tableName, undefined)]; + } + return []; +} + +function operationCountOf(call: OpFactoryCall): number { + return 'companions' in call && Array.isArray(call.companions) ? 1 + call.companions.length : 1; +} + +/** What each call of a Postgres plan loses, and whose access it widens. */ +export function postgresCallSubjects( + calls: readonly OpFactoryCall[], + locator: Locator, +): readonly CallSubjects[] { + const replaced = createdPolicies(calls); + return calls.map((call) => ({ + operationCount: operationCountOf(call), + dataLoss: dataLossOf(locator, call), + accessWidening: accessWideningOf(locator, call, replaced), + })); +} 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 b25ca1a4672d..60b464943d4f 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 @@ -16,6 +16,7 @@ import { planFieldEventOperations, plannerFailure, planStatements, + planSubjects, sqlTypeLookupsOf, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; @@ -23,6 +24,7 @@ import type { TargetBoundComponentDescriptor } from '@internal/framework-compone import type { AppliedMigrationStatement, MigrationOperationClass, + MigrationOperationSubject, MigrationPlanner, MigrationPlanWithAuthoringSurface, MigrationScaffoldContext, @@ -75,6 +77,7 @@ import { RenamePostgresRlsPolicyCall, type RenameTableCall, } from './op-factory-call'; +import { postgresCallSubjects } from './operation-subjects'; import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration'; import { postgresPlannerStrategies } from './planner-strategies'; import { resolveDdlSchemaForNamespaceStorage } from './resolve-ddl-schema'; @@ -129,6 +132,8 @@ export type PostgresPlanResult = readonly plan: TypeScriptRenderablePostgresMigration; readonly warnings?: readonly SqlPlannerConflict[]; readonly appliedStatements: readonly AppliedMigrationStatement[]; + readonly dataLoss: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationOperationSubject[]; } | SqlPlannerFailureResult; @@ -453,6 +458,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr this.#lowerer, ), appliedStatements: statements.value.appliedStatements, + ...planSubjects( + postgresCallSubjects(calls, { contract: options.fromContract ?? options.contract }), + { + fromContract: options.fromContract, + contract: options.contract, + statements: options.statements, + }, + ), ...(warnings.length > 0 ? { warnings: Object.freeze(warnings) } : {}), }); } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts new file mode 100644 index 000000000000..e8ee219a23e5 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -0,0 +1,222 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import { APP_SPACE_ID, 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'; +import { describe, expect, it } from 'vitest'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; +import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; +import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; +import { PostgresSchema } from '../../src/core/postgres-schema'; +import { postgresTypeComponents } from '../postgres-type-lookups'; + +const stubLowerer: ExecuteRequestLowerer = { + lower: () => ({ sql: 'stub', params: [] }), + renderColumnDefault: async () => '', + lowerToExecuteRequest: async () => ({ sql: 'stub', params: [] }), +}; + +const ALL_CLASSES = { + allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] as const, +}; +const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); + +const columnTypes = { + int4: { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }, + int8: { dataType: 'pg/int8', codecId: 'pg/int8@1', nullable: false }, + text: { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }, +} as const; + +interface TableSpec { + readonly columns: Readonly>; + /** The `using` of the table's one policy, which also turns on row-level security. */ + readonly policy?: string; +} + +function policyOn(table: string, using: string): PostgresRlsPolicy { + return new PostgresRlsPolicy({ + naming: { kind: 'exact', name: `${table} readers` }, + tableName: table, + namespaceId: 'public', + operation: 'select', + roles: ['app_user'], + using, + permissive: true, + withCheck: undefined, + }); +} + +/** A model per table, named as the table, each field stored in the column of the same name. */ +function contract(seed: string, tables: Readonly>): Contract { + const specs = Object.entries(tables); + const policies = specs.flatMap(([table, spec]) => + spec.policy === undefined ? [] : [policyOn(table, spec.policy)], + ); + const schema = new PostgresSchema({ + id: 'public', + entries: { + table: Object.fromEntries( + specs.map(([table, spec]) => [ + table, + new StorageTable({ + columns: Object.fromEntries( + Object.entries(spec.columns).map(([column, type]) => [column, columnTypes[type]]), + ), + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + ]), + ), + policy: Object.fromEntries(policies.map((policy) => [policy.name, policy])), + rls: Object.fromEntries( + policies.map((policy) => [ + policy.tableName, + new PostgresRlsEnablement({ tableName: policy.tableName, namespaceId: 'public' }), + ]), + ), + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash(seed), + storage: new SqlStorage({ storageHash: coreHash(seed), namespaces: { public: schema } }), + roots: {}, + domain: applicationDomainOf({ + models: Object.fromEntries( + specs.map(([table, spec]) => [ + table, + { + fields: Object.fromEntries( + Object.keys(spec.columns).map((column) => [ + column, + { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + ]), + ), + relations: {}, + storage: { + table, + namespaceId: 'public', + fields: Object.fromEntries( + Object.keys(spec.columns).map((column) => [column, { column }]), + ), + }, + }, + ]), + ), + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +async function planned( + from: Contract, + to: Contract, + fromContract: Contract | null = from, +) { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, postgresTypeComponents), + policy: ALL_CLASSES, + fromContract, + origin: planOriginOf(fromContract), + statements: [], + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + const settled = await Promise.allSettled(result.plan.operations); + const labels = settled.map((entry) => + entry.status === 'fulfilled' ? entry.value.label : 'placeholder', + ); + const labelled = (entries: typeof result.dataLoss) => + entries.map(({ operationIndex, subject }) => ({ + operation: labels[operationIndex], + subject, + })); + return { + dataLoss: labelled(result.dataLoss), + accessWidening: labelled(result.accessWidening), + }; +} + +describe('Postgres planner, data loss', () => { + it('names the model of a dropped table and the field of a dropped column', async () => { + const from = contract('from', { + Legacy: { columns: { id: 'int4' } }, + User: { columns: { id: 'int4', nickname: 'text' } }, + }); + const to = contract('to', { User: { columns: { id: 'int4' } } }); + + expect((await planned(from, to)).dataLoss).toEqual([ + { + operation: 'Drop table "Legacy"', + subject: { kind: 'model', namespaceId: unbound, model: 'Legacy' }, + }, + { + operation: 'Drop column "nickname" from "User"', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + ]); + }); + + it('names the field of a type change that can change values, and not of a safe widening', async () => { + const from = contract('from', { + User: { columns: { id: 'int4', age: 'text', score: 'int4' } }, + }); + const to = contract('to', { User: { columns: { id: 'int4', age: 'int4', score: 'int8' } } }); + + expect((await planned(from, to)).dataLoss).toEqual([ + { + operation: 'Alter type of "User"."age" to int4', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'age' }, + }, + ]); + }); + + it('names storage by its schema-qualified name when the plan has no origin contract', async () => { + const from = contract('from', { + Legacy: { columns: { id: 'int4' } }, + User: { columns: { id: 'int4', nickname: 'text' } }, + }); + const to = contract('to', { User: { columns: { id: 'int4' } } }); + + expect((await planned(from, to, null)).dataLoss.map(({ subject }) => subject)).toEqual([ + { kind: 'storage', name: 'public.Legacy' }, + { kind: 'storage', name: 'public.User.nickname' }, + ]); + }); + + it('lists a policy drop and disabling row-level security as widening access to the model', async () => { + const from = contract('from', { + User: { columns: { id: 'int4' }, policy: 'true' }, + }); + const to = contract('to', { User: { columns: { id: 'int4' } } }); + + expect(await planned(from, to)).toEqual({ + dataLoss: [], + accessWidening: [ + expect.objectContaining({ + subject: { kind: 'model', namespaceId: unbound, model: 'User' }, + }), + expect.objectContaining({ + subject: { kind: 'model', namespaceId: unbound, model: 'User' }, + }), + ], + }); + }); + + it('leaves out the drop of a policy the plan replaces', async () => { + const from = contract('from', { User: { columns: { id: 'int4' }, policy: 'true' } }); + const to = contract('to', { User: { columns: { id: 'int4' }, policy: 'false' } }); + + expect(await planned(from, to)).toEqual({ dataLoss: [], accessWidening: [] }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts new file mode 100644 index 000000000000..50b8559d19f2 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts @@ -0,0 +1,71 @@ +import type { CallSubjects, SubjectTarget } from '@internal/family-sql/control'; +import type { OpFactoryCall } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { DropColumnCall, DropTableCall, RecreateTableCall } from './op-factory-call'; + +function tableTarget(table: string): SubjectTarget { + return { + storageName: table, + table: { namespaceId: UNBOUND_NAMESPACE_ID, table, column: undefined }, + }; +} + +function columnTarget(table: string, column: string): SubjectTarget { + return { + storageName: `${table}.${column}`, + table: { namespaceId: UNBOUND_NAMESPACE_ID, table, column }, + }; +} + +function operationCountOf(call: OpFactoryCall): number { + return 'companions' in call && Array.isArray(call.companions) ? 1 + call.companions.length : 1; +} + +/** + * The columns a recreate loses: those the new table leaves out, whose values the copy drops, and, + * when the recreate is destructive, those whose type it changes. + */ +function recreateLosses(call: RecreateTableCall): readonly string[] { + const kept = new Set(call.contractTable.columns.map((column) => column.name)); + const dropped = call.schemaColumnNames.filter((column) => !kept.has(column)); + return [...dropped, ...(call.operationClass === 'destructive' ? call.lossyColumns : [])]; +} + +/** + * What each call of a SQLite plan loses. A recreate that leaves a column out loses its values + * itself, so a later drop of that column is not listed again. + */ +export function sqliteCallSubjects(calls: readonly OpFactoryCall[]): readonly CallSubjects[] { + const lost = new Set(); + return calls.map((call): CallSubjects => { + const operationCount = operationCountOf(call); + if (call instanceof RecreateTableCall) { + const columns = recreateLosses(call); + for (const column of columns) lost.add(`${call.tableName}.${column}`); + const dataLoss = + columns.length === 0 && call.operationClass === 'destructive' + ? [tableTarget(call.tableName)] + : columns.map((column) => columnTarget(call.tableName, column)); + return { operationCount, dataLoss, accessWidening: [] }; + } + if (call instanceof DropColumnCall) { + const alreadyLost = lost.has(`${call.tableName}.${call.columnName}`); + return { + operationCount, + dataLoss: alreadyLost ? [] : [columnTarget(call.tableName, call.columnName)], + accessWidening: [], + }; + } + if (call instanceof DropTableCall) { + return { operationCount, dataLoss: [tableTarget(call.tableName)], accessWidening: [] }; + } + return { + operationCount, + dataLoss: + call.operationClass === 'destructive' + ? [{ storageName: call.label, table: undefined }] + : [], + accessWidening: [], + }; + }); +} 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 88ff43303ae6..5300bd087c02 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 @@ -13,11 +13,13 @@ import { planFieldEventOperations, plannerFailure, planStatements, + planSubjects, } 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, + MigrationOperationSubject, MigrationPlanner, MigrationScaffoldContext, PlanOrigin, @@ -42,6 +44,7 @@ import { planIssues, } from './issue-planner'; import type { RenameColumnCall, RenameTableCall } from './op-factory-call'; +import { sqliteCallSubjects } from './operation-subjects'; import { type SqliteMigrationDestinationInfo, TypeScriptRenderableSqliteMigration, @@ -68,6 +71,8 @@ export type SqlitePlanResult = readonly kind: 'success'; readonly plan: TypeScriptRenderableSqliteMigration; readonly appliedStatements: readonly AppliedMigrationStatement[]; + readonly dataLoss: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationOperationSubject[]; } | SqlPlannerFailureResult; @@ -248,6 +253,11 @@ export class SqliteMigrationPlanner this.#lowerer, ), appliedStatements: statements.value.appliedStatements, + ...planSubjects(sqliteCallSubjects(calls), { + fromContract: options.fromContract, + contract: options.contract, + statements: options.statements, + }), }; } diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts new file mode 100644 index 000000000000..59392e520804 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -0,0 +1,184 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { + APP_SPACE_ID, + planOriginOf, + type ResolvedMigrationStatement, +} 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'; +import { describe, expect, it } from 'vitest'; +import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; +import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; +import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; +import { stubLowerer } from './rename-table-fixtures'; + +const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); +const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; + +const columnTypes = { + text: { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: false }, + integer: { dataType: 'sqlite/integer', codecId: 'sqlite/integer@1', nullable: false }, + 'text?': { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: true }, +} as const; + +type ColumnType = keyof typeof columnTypes; + +/** A model per table, each field stored in the column of the same name. */ +function contract( + seed: string, + tables: Readonly>>>, +): Contract { + 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: Object.fromEntries( + Object.entries(tables).map(([table, columns]) => [ + table, + new StorageTable({ + columns: Object.fromEntries( + Object.entries(columns).map(([column, type]) => [column, columnTypes[type]]), + ), + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }), + ]), + ), + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: Object.fromEntries( + Object.entries(tables).map(([table, columns]) => [ + table, + { + fields: Object.fromEntries( + Object.keys(columns).map((column) => [ + column, + { nullable: false, type: { kind: 'scalar', codecId: 'sqlite/text@1' } }, + ]), + ), + relations: {}, + storage: { + table, + namespaceId: UNBOUND_NAMESPACE_ID, + fields: Object.fromEntries( + Object.keys(columns).map((column) => [column, { column }]), + ), + }, + }, + ]), + ), + }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +async function planned( + from: Contract, + to: Contract, + options: { + readonly fromContract?: Contract | null; + readonly statements?: readonly ResolvedMigrationStatement[]; + } = {}, +) { + const fromContract = options.fromContract === undefined ? from : options.fromContract; + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: sqliteContractToSchema(from, sqliteTestTypes), + policy: ALL_CLASSES, + fromContract, + origin: planOriginOf(fromContract), + statements: options.statements ?? [], + frameworkComponents: sqliteTestComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + const labels = (await Promise.all(result.plan.operations)).map((op) => op.label); + return { + dataLoss: result.dataLoss.map(({ operationIndex, subject }) => ({ + operation: labels[operationIndex], + subject, + })), + accessWidening: result.accessWidening, + }; +} + +describe('SQLite planner, data loss', () => { + it('names the model of a dropped table and the field of a dropped column', async () => { + const from = contract('from', { + Legacy: { id: 'integer' }, + User: { id: 'integer', email: 'text', nickname: 'text' }, + }); + const to = contract('to', { User: { id: 'integer', email: 'text' } }); + + expect(await planned(from, to)).toEqual({ + dataLoss: [ + { + operation: 'Drop column nickname on User', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + { + operation: 'Drop table Legacy', + subject: { kind: 'model', namespaceId: unbound, model: 'Legacy' }, + }, + ], + accessWidening: [], + }); + }); + + it('names the field whose type a recreate changes, at the recreate', async () => { + const from = contract('from', { User: { id: 'integer', age: 'text' } }); + const to = contract('to', { User: { id: 'integer', age: 'integer' } }); + + expect((await planned(from, to)).dataLoss).toEqual([ + { + operation: 'Recreate table User', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'age' }, + }, + ]); + }); + + it('names a column a widening recreate leaves behind once, at the recreate', async () => { + const from = contract('from', { User: { id: 'integer', email: 'text?', nickname: 'text' } }); + const to = contract('to', { User: { id: 'integer', email: 'text' } }); + + expect((await planned(from, to)).dataLoss).toEqual([ + { + operation: 'Recreate table User', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + ]); + }); + + it('names storage by the names the database has when the plan has no origin contract', async () => { + const from = contract('from', { + Legacy: { id: 'integer' }, + User: { id: 'integer', email: 'text', nickname: 'text' }, + }); + const to = contract('to', { User: { id: 'integer', email: 'text' } }); + + expect((await planned(from, to, { fromContract: null })).dataLoss).toEqual([ + { + operation: 'Drop column nickname on User', + subject: { kind: 'storage', name: 'User.nickname' }, + }, + { operation: 'Drop table Legacy', subject: { kind: 'storage', name: 'Legacy' } }, + ]); + }); +}); From 959fecd7eccdbd402238398e348642bbb1e5faa2 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:33:56 +0200 Subject: [PATCH 023/109] TML-3476: db update classes a safe Postgres type widening as widening With no origin contract, the live column carries no codec. Its normalised type name is the text a migration writes for int2, int4, int8, float4 and float8, so the planner maps it to a data type and checks the same safe widenings that migration plan uses. A list column is never a safe widening. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/issue-planner.ts | 13 +-- .../src/core/migrations/planner-strategies.ts | 24 +----- .../src/core/migrations/safe-widenings.ts | 52 ++++++++++++ .../migrations/node-issue-planner.test.ts | 82 +++++++++++++++++++ 4 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/safe-widenings.ts 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 02fa9ae01b66..4ee7c2bc8f96 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 @@ -94,6 +94,7 @@ import { postgresPlannerStrategies, type StrategyContext, } from './planner-strategies'; +import { liveColumnWidensSafely } from './safe-widenings'; export type { CallMigrationStrategy, StrategyContext }; @@ -687,11 +688,13 @@ function mapColumnNodeIssue( if (columnTypeChanged(expected, actual)) { const { qualifiedTargetType, formatTypeExpected } = renderColumnAlterType(expected, types); calls.push( - new AlterColumnTypeCall(schemaName, tableName, expected.name, { - qualifiedTargetType, - formatTypeExpected, - rawTargetTypeForLabel: qualifiedTargetType, - }), + new AlterColumnTypeCall( + schemaName, + tableName, + expected.name, + { qualifiedTargetType, formatTypeExpected, rawTargetTypeForLabel: qualifiedTargetType }, + liveColumnWidensSafely(expected, actual, types) ? 'widening' : 'destructive', + ), ); } if (expected.nullable !== actual.nullable) { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index d40991986d80..7618ffb02852 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -36,7 +36,7 @@ import type { TargetBoundComponentDescriptor } from '@internal/framework-compone 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 SqlTypeLookups, sqlDataTypeOfCodec } from '@internal/sql-contract/data-type'; +import type { SqlTypeLookups } from '@internal/sql-contract/data-type'; import type { SqlStorage, StorageTable, StorageTypeInstance } from '@internal/sql-contract/types'; import * as contractFree from '@internal/sql-relational-core/contract-free'; import { @@ -45,8 +45,6 @@ import { type SqlSchemaIR, } from '@internal/sql-schema-ir/types'; import { blindCast } from '@internal/utils/casts'; -import { InternalError } from '@internal/utils/internal-error'; -import { pgFloat4, pgFloat8, pgInt2, pgInt4, pgInt8 } from '../data-types'; import { isPostgresSchema } from '../postgres-schema'; import { renderColumnAlterType, @@ -68,6 +66,7 @@ import { } from './op-factory-call'; import { buildSchemaLookupMap, hasForeignKey, hasUniqueConstraint } from './planner-schema-lookup'; import { buildTargetDetails, type PostgresPlanTargetDetails } from './planner-target-details'; +import { columnDataType, isSafeTypeWidening } from './safe-widenings'; /** * Look up a storage table by its explicit namespace coordinate. Returns @@ -236,23 +235,6 @@ export const notNullBackfillCallStrategy: CallMigrationStrategy = (issues, ctx) }; }; -const SAFE_WIDENINGS = new Set([ - `${pgInt2.id}→${pgInt4.id}`, - `${pgInt2.id}→${pgInt8.id}`, - `${pgInt4.id}→${pgInt8.id}`, - `${pgFloat4.id}→${pgFloat8.id}`, -]); - -/** The id of the data type a column node's codec represents. */ -function columnDataType(column: SqlColumnIR, types: SqlTypeLookups): string { - if (column.codecRef === undefined) { - throw new InternalError( - `Column "${column.name}" carries no codec, so its data type is unknown; a type change is planned only between columns built from contracts.`, - ); - } - return sqlDataTypeOfCodec(column.codecRef.codecId, types).id; -} - /** * Handles `not-equal` column issues whose TYPE differs. `fromContract` is * only supplied by `migration plan` — for reconciliation (`db update` / @@ -285,7 +267,7 @@ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { const fromType = columnDataType(actual, ctx.types); const toType = columnDataType(expected, ctx.types); - const isSafeWidening = SAFE_WIDENINGS.has(`${fromType}→${toType}`); + const isSafeWidening = isSafeTypeWidening(fromType, toType); if (!isSafeWidening && !dataAllowed) continue; const ddlSchemaName = issueSchemaName(issue); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/safe-widenings.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/safe-widenings.ts new file mode 100644 index 000000000000..264290ec4350 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/safe-widenings.ts @@ -0,0 +1,52 @@ +import { type SqlTypeLookups, sqlDataTypeOfCodec } from '@internal/sql-contract/data-type'; +import type { SqlColumnIR } from '@internal/sql-schema-ir/types'; +import { InternalError } from '@internal/utils/internal-error'; +import { pgFloat4, pgFloat8, pgInt2, pgInt4, pgInt8 } from '../data-types'; + +/** The type changes that keep every value: each pair is `from→to` by data type id. */ +const SAFE_WIDENINGS = new Set([ + `${pgInt2.id}→${pgInt4.id}`, + `${pgInt2.id}→${pgInt8.id}`, + `${pgInt4.id}→${pgInt8.id}`, + `${pgFloat4.id}→${pgFloat8.id}`, +]); + +const WIDENABLE_TYPES = [pgInt2, pgInt4, pgInt8, pgFloat4, pgFloat8]; + +export function isSafeTypeWidening(fromDataType: string, toDataType: string): boolean { + return SAFE_WIDENINGS.has(`${fromDataType}→${toDataType}`); +} + +/** The id of the data type a column node's codec represents. */ +export function columnDataType(column: SqlColumnIR, types: SqlTypeLookups): string { + if (column.codecRef === undefined) { + throw new InternalError( + `Column "${column.name}" carries no codec, so its data type is unknown; a type change is planned only between columns built from contracts.`, + ); + } + return sqlDataTypeOfCodec(column.codecRef.codecId, types).id; +} + +/** The widenable data type a migration writes as `text`, which introspection reports normalised. */ +function widenableDataTypeWritten(text: string): string | undefined { + return WIDENABLE_TYPES.find((type) => + type.sql.texts.some((candidate) => candidate.written === true && candidate.text === text), + )?.id; +} + +/** + * Whether changing an introspected column to the contract's column keeps every value. The live + * column carries no codec, so its type is read from the normalised name introspection reports. + */ +export function liveColumnWidensSafely( + expected: SqlColumnIR, + actual: SqlColumnIR, + types: SqlTypeLookups, +): boolean { + if (expected.many === true || actual.many === true) return false; + const from = + actual.resolvedNativeType === undefined + ? undefined + : widenableDataTypeWritten(actual.resolvedNativeType); + return from !== undefined && isSafeTypeWidening(from, columnDataType(expected, types)); +} diff --git a/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts index 30127a60b734..b37f526cff06 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts @@ -258,6 +258,88 @@ describe('buildPostgresPlanDiff + planNodeIssues (one-differ path)', () => { expect(calls.map((c) => c.factoryName)).toEqual(['alterColumnType', 'setNotNull']); }); + describe('a type change of an introspected column, with no origin contract', () => { + function alterClass( + live: { readonly nativeType: string; readonly many?: boolean }, + contractColumn: TableSpec['columns'][string], + ) { + const contract = makeContract({ + user: { + columns: { + id: { dataType: 'pg/uuid', codecId: 'pg/uuid@1', nullable: false }, + age: contractColumn, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }); + const actual = rootOf({ + user: new PostgresTableSchemaNode({ + name: 'user', + columns: { + id: { name: 'id', nativeType: 'uuid', nullable: false, resolvedNativeType: 'uuid' }, + age: { + name: 'age', + nativeType: live.nativeType, + nullable: false, + resolvedNativeType: live.nativeType, + ...(live.many === undefined ? {} : { many: live.many }), + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], + rlsEnabled: false, + }), + }); + const call = planFor(contract, actual).find((c) => c instanceof AlterColumnTypeCall); + return call?.operationClass; + } + + it('is widening for a safe widening read from the written type name', () => { + expect([ + alterClass( + { nativeType: 'int4' }, + { dataType: 'pg/int8', codecId: 'pg/int8@1', nullable: false }, + ), + alterClass( + { nativeType: 'int2' }, + { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }, + ), + alterClass( + { nativeType: 'float4' }, + { dataType: 'pg/float8', codecId: 'pg/float8@1', nullable: false }, + ), + ]).toEqual(['widening', 'widening', 'widening']); + }); + + it('is destructive for a type change that can change values, or one between lists', () => { + expect([ + alterClass( + { nativeType: 'int8' }, + { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }, + ), + alterClass( + { nativeType: 'text' }, + { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }, + ), + alterClass( + { nativeType: 'int4', many: true }, + { + dataType: 'pg/int8', + codecId: 'pg/int8@1', + nullable: false, + many: { elementNullable: false }, + }, + ), + ]).toEqual(['destructive', 'destructive', 'destructive']); + }); + }); + it('checks a type change of a column that references a storage type against the catalog text of the referenced type', () => { const contract = makeContract( { From ccf01496ed482b4b7aee43d8d2c7705ce030c7b1 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 7 Oct 2026 23:35:20 +0200 Subject: [PATCH 024/109] TML-3476: a delete statement passes through resolution, and only renames need the origin contract delete is a statement verb. Its text names a model, a field or a storage name, and is matched against the plan later, so resolution only checks it is well formed. A missing origin contract now refuses the first rename statement, not every run that has statements. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/statements/parse-delete.ts | 35 +++++++++ .../statements/resolve-statements.ts | 30 +++++--- .../control-api/statements/statement-text.ts | 5 +- .../resolve-delete-statements.test.ts | 77 +++++++++++++++++++ 4 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts new file mode 100644 index 000000000000..1ddb323ead82 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts @@ -0,0 +1,35 @@ +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'; + +export interface ParsedDelete extends StatementText { + readonly verb: 'delete'; +} + +const DELETE_FORMS_FIX = + 'Write the statement as --delete , where the name is Model, namespace.Model, Model.field or namespace.Model.field, or the storage name the refusal gave, for example --delete Legacy or --delete User.nickname.'; + +/** + * Checks the text of one delete statement. The text stays unresolved: a delete names what a plan + * would lose, so it is matched against the plan, not resolved against the contracts. + */ +export function parseDeleteStatement( + statement: StatementText & { readonly verb: 'delete' }, +): Result { + if (statement.text.trim() === '') { + return notOk( + errorStatementInvalid(statement, 'The statement names nothing.', DELETE_FORMS_FIX), + ); + } + if (statement.text.includes(':')) { + return notOk( + errorStatementInvalid( + statement, + 'The statement has a ":", which separates the old and new name of a rename.', + DELETE_FORMS_FIX, + ), + ); + } + return ok({ verb: 'delete', text: statement.text }); +} 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 index b272fcc2db36..3f31a9db2e11 100644 --- 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 @@ -19,6 +19,7 @@ import { errorStatementOriginUnknown, errorStatementUnresolved, } from '../../utils/cli-errors'; +import { parseDeleteStatement } from './parse-delete'; import { mixedSidesFix, type ParsedRename, @@ -693,17 +694,30 @@ function resolveStatement( } /** - * Resolves the statements a user gave, in order, against the origin and + * Resolves the rename 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. + * by namespace, model and field. A delete statement is checked and left out: it + * names what a plan would lose, so it is matched against the plan, and it needs + * no origin contract. */ export function resolveStatements( input: ResolveStatementsInput, ): Result { - if (input.statements.length === 0) return ok([]); - if (input.origin.kind === 'missing') { - return notOk(errorStatementOriginUnknown(input.origin)); + const parsed: ParsedRename[] = []; + for (const entry of input.statements) { + if (entry.verb === 'delete') { + const statement = parseDeleteStatement({ ...entry, verb: entry.verb }); + if (!statement.ok) return statement; + continue; + } + if (input.origin.kind === 'missing') { + return notOk(errorStatementOriginUnknown(input.origin)); + } + const statement = parseRenameStatement({ ...entry, verb: entry.verb }); + if (!statement.ok) return statement; + parsed.push(statement.value); } + if (parsed.length === 0 || input.origin.kind === 'missing') return ok([]); const state: ResolutionState = { origin: { name: 'origin', contract: input.origin.contract }, destination: { name: 'destination', contract: input.destination }, @@ -711,12 +725,6 @@ export function resolveStatements( 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)); 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 index f6e2cf9706f5..885116475023 100644 --- 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 @@ -1,9 +1,10 @@ /** The verbs a statement can have. */ -export type StatementVerb = 'rename'; +export type StatementVerb = 'rename' | 'delete'; /** * 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`. + * `{ verb: 'rename', text: 'Profile:User' }` for `--rename Profile:User` or + * `{ verb: 'delete', text: 'Legacy' }` for `--delete Legacy`. */ export interface StatementText { readonly verb: StatementVerb; diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts new file mode 100644 index 000000000000..2e13a18b4de7 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveStatements, + type StatementOrigin, +} from '../../../src/control-api/statements/resolve-statements'; +import type { StatementText } from '../../../src/control-api/statements/statement-text'; +import { contractOf, expectFailure, expectValue } from './statement-fixtures'; + +const origin = contractOf({ app: { models: { Profile: {}, Legacy: {} } } }); +const destination = contractOf({ app: { models: { User: {} } } }); + +const missingOrigin: StatementOrigin = { + kind: 'missing', + hash: 'sha256:abc', + snapshotDirectory: 'migrations/snapshots', + unreadable: undefined, +}; + +function remove(text: string): StatementText { + return { verb: 'delete', text }; +} + +function rename(text: string): StatementText { + return { verb: 'rename', text }; +} + +describe('resolveStatements, delete statements', () => { + it('passes a delete through unresolved, since it is matched against the plan', () => { + const result = resolveStatements({ + statements: [remove('Legacy'), rename('Profile:User'), remove('audit_log')], + origin: { kind: 'contract', contract: origin }, + destination, + }); + + expect(expectValue(result)).toEqual([ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Profile' }, + to: { namespaceId: 'app', model: 'User' }, + }, + ]); + }); + + it('needs no origin contract for a delete', () => { + const result = resolveStatements({ + statements: [remove('Legacy'), remove('User.name')], + origin: missingOrigin, + destination, + }); + + expect(expectValue(result)).toEqual([]); + }); + + it('refuses a rename beside a delete when the origin contract is unknown', () => { + const result = resolveStatements({ + statements: [remove('Legacy'), rename('Profile:User')], + origin: missingOrigin, + destination, + }); + + expectFailure(result, 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', 'sha256:abc'); + }); + + it('refuses a delete with no name, or with a ":" a rename would have', () => { + expect( + ['', 'Profile:User'].map((text) => { + const result = resolveStatements({ + statements: [remove(text)], + origin: { kind: 'contract', contract: origin }, + destination, + }); + return result.ok ? 'resolved' : result.failure.code; + }), + ).toEqual(['MIGRATION.STATEMENT_INVALID', 'MIGRATION.STATEMENT_INVALID']); + }); +}); From 1808553fc7ae6baca1b3c1e8dd4201528c98fb8a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:00:15 +0200 Subject: [PATCH 025/109] TML-3476: db update lists the destructive operations of recorded migrations, named by storage, never by label A space db update applies from recorded migrations has no planner to map its operations to models, so each destructive operation is listed with a storage subject. The family names it from the operation's target details through the new storageNameOf capability, else by the operation's id. A planner's fallback for an unknown destructive call uses the same naming, never the label. A delete statement accepts any non-empty text, since a storage name can contain a colon. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-capabilities.ts | 14 ++++++ .../src/exports/control.ts | 2 + .../cli/src/control-api/operations/db-run.ts | 48 ++++++++++++++----- .../control-api/statements/parse-delete.ts | 13 ++--- .../db-update-statement-positions.test.ts | 16 ++++++- .../resolve-delete-statements.test.ts | 29 ++++++----- .../9-family/src/core/control-instance.ts | 7 +++ .../core/migrations/operation-storage-name.ts | 44 +++++++++++++++++ .../2-sql/9-family/src/exports/control.ts | 1 + .../test/operation-storage-name.test.ts | 32 +++++++++++++ .../src/core/migrations/operation-subjects.ts | 12 +++-- .../src/core/migrations/operation-subjects.ts | 16 +++++-- 12 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 packages/2-sql/9-family/src/core/migrations/operation-storage-name.ts create mode 100644 packages/2-sql/9-family/test/operation-storage-name.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts index f945c9615057..db5c5eec2921 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts @@ -103,6 +103,20 @@ export function hasOperationPreview( ); } +/** + * Capability declaring that a family can name the storage object an operation is about, as the + * database knows it, for an operation of a recorded migration that no planner mapped to a model. + */ +export interface OperationStorageNaming { + storageNameOf(operation: MigrationPlanOperation): string; +} + +export function hasOperationStorageNaming( + instance: ControlFamilyInstance, +): instance is ControlFamilyInstance & OperationStorageNaming { + return typeof Reflect.get(instance, 'storageNameOf') === 'function'; +} + /** * The granularity of a {@link SchemaDiffIssue}'s subject, resolved on demand * from the issue's node `nodeKind` — never stamped on the issue or the node. 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 ef283a32d8e1..481949f50413 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 @@ -11,6 +11,7 @@ export type { DiffSubjectGranularity, MigratableTargetDescriptor, OperationPreviewCapable, + OperationStorageNaming, PslContractBuildCapable, PslContractDocument, PslContractInferCapable, @@ -21,6 +22,7 @@ export type { export { hasMigrations, hasOperationPreview, + hasOperationStorageNaming, hasPslContractBuild, hasPslContractInfer, hasSchemaSubjectClassifier, 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 49be9ff8064e..abbf549ce945 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 @@ -20,6 +20,7 @@ import type { import { CONTRACT_SNAPSHOTS_DIRNAME, hasOperationPreview, + hasOperationStorageNaming, isStorageHashHex, } from '@internal/framework-components/control'; import type { ContractMarkerRecordLike } from '@internal/migration-tools/aggregate'; @@ -282,7 +283,14 @@ export async function executeRun + hasOperationStorageNaming(familyInstance) + ? familyInstance.storageNameOf(operation) + : operation.id, + ) + : undefined; // 4. Plan-mode: surface aggregate operations without applying. if (mode === 'plan') { @@ -433,24 +441,42 @@ function operationsBefore( .reduce((count, resolution) => count + resolution.entry.displayOps.length, 0); } +/** + * Each destructive operation of a space applied from recorded migrations, which no planner mapped + * to a model, named as the database knows it. + */ +function recordedDataLoss( + resolution: OrderedResolution, + storageNameOf: (operation: MigrationPlanOperation) => string, +): readonly MigrationOperationSubject[] { + if (resolution.entry.strategy === 'plan-from-diff') return resolution.entry.dataLoss; + return resolution.entry.displayOps.flatMap((operation, operationIndex) => + operation.operationClass === 'destructive' + ? [{ operationIndex, subject: { kind: 'storage', name: storageNameOf(operation) } }] + : [], + ); +} + /** * What the planned operations lose and whose access they widen, across every space, each at its * position in the operations the result reports. */ -function planSubjects(orderedResolutions: readonly OrderedResolution[]): PlanSubjectsReport { - const offset = - (resolution: OrderedResolution) => (entries: readonly MigrationOperationSubject[]) => - entries.map((entry) => ({ - ...entry, - operationIndex: - entry.operationIndex + operationsBefore(orderedResolutions, resolution.spaceId), - })); +function planSubjects( + orderedResolutions: readonly OrderedResolution[], + storageNameOf: (operation: MigrationPlanOperation) => string, +): PlanSubjectsReport { + const offset = (resolution: OrderedResolution, entries: readonly MigrationOperationSubject[]) => + entries.map((entry) => ({ + ...entry, + operationIndex: + entry.operationIndex + operationsBefore(orderedResolutions, resolution.spaceId), + })); return { dataLoss: orderedResolutions.flatMap((resolution) => - offset(resolution)(resolution.entry.dataLoss), + offset(resolution, recordedDataLoss(resolution, storageNameOf)), ), accessWidening: orderedResolutions.flatMap((resolution) => - offset(resolution)(resolution.entry.accessWidening), + offset(resolution, resolution.entry.accessWidening), ), }; } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts index 1ddb323ead82..236aca79cbe4 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts @@ -12,7 +12,9 @@ const DELETE_FORMS_FIX = /** * Checks the text of one delete statement. The text stays unresolved: a delete names what a plan - * would lose, so it is matched against the plan, not resolved against the contracts. + * would lose, so it is matched by equality against the plan's subjects, not resolved against the + * contracts. Any name a subject can have is accepted, since a storage name may contain any + * character a quoted identifier can. */ export function parseDeleteStatement( statement: StatementText & { readonly verb: 'delete' }, @@ -22,14 +24,5 @@ export function parseDeleteStatement( errorStatementInvalid(statement, 'The statement names nothing.', DELETE_FORMS_FIX), ); } - if (statement.text.includes(':')) { - return notOk( - errorStatementInvalid( - statement, - 'The statement has a ":", which separates the old and new name of a rename.', - DELETE_FORMS_FIX, - ), - ); - } return ok({ verb: 'delete', text: statement.text }); } 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 index 4ef0228aeb65..761dceeda35f 100644 --- 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 @@ -59,6 +59,7 @@ const AUDIT_CONTRACT: Contract = createSqlContract({ const AUDIT_HEAD = AUDIT_CONTRACT.storage.storageHash; const AUDIT_OPS: readonly MigrationPlanOperation[] = [ { id: 'table.audit_log', label: 'Create table audit_log', operationClass: 'additive' }, + { id: 'dropTable.audit_old', label: 'Drop table audit_old', operationClass: 'destructive' }, { id: 'index.audit_log.at', label: 'Create index on audit_log', operationClass: 'additive' }, ]; const AUDIT_METADATA: Omit = { @@ -100,6 +101,7 @@ const family = { introspect: async () => ({ tables: {} }), deserializeContract: (json: unknown) => json as Contract, toOperationPreview: () => ({ statements: [] }), + storageNameOf: (operation: MigrationPlanOperation) => `stored ${operation.id}`, } as unknown as ControlFamilyInstance<'sql', unknown>; const migrations = { @@ -219,8 +221,20 @@ describe('executeDbUpdate statement positions', () => { })), ), ).toEqual([ - [{ operation: RENAME_OP.id, subject: LOST }], + [ + { + operation: 'dropTable.audit_old', + subject: { kind: 'storage', name: 'stored dropTable.audit_old' }, + }, + { operation: RENAME_OP.id, subject: LOST }, + ], [{ operation: RENAME_OP.id, subject: LOST }], ]); + const listed = new Set(result.value.dataLoss.map(({ operationIndex }) => operationIndex)); + expect( + operations.flatMap((operation, index) => + operation.operationClass === 'destructive' && !listed.has(index) ? [operation.id] : [], + ), + ).toEqual([]); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts index 2e13a18b4de7..a8bc5315e6de 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/resolve-delete-statements.test.ts @@ -62,16 +62,23 @@ describe('resolveStatements, delete statements', () => { expectFailure(result, 'MIGRATION.STATEMENT_ORIGIN_UNKNOWN', 'sha256:abc'); }); - it('refuses a delete with no name, or with a ":" a rename would have', () => { - expect( - ['', 'Profile:User'].map((text) => { - const result = resolveStatements({ - statements: [remove(text)], - origin: { kind: 'contract', contract: origin }, - destination, - }); - return result.ok ? 'resolved' : result.failure.code; - }), - ).toEqual(['MIGRATION.STATEMENT_INVALID', 'MIGRATION.STATEMENT_INVALID']); + it('refuses a delete with no name', () => { + const result = resolveStatements({ + statements: [remove(' ')], + origin: { kind: 'contract', contract: origin }, + destination, + }); + expectFailure(result, 'MIGRATION.STATEMENT_INVALID', 'names nothing'); + }); + + it('accepts any name a subject can have, including storage names with a ":"', () => { + const result = resolveStatements({ + statements: ['public.a:b', 'public.User.nick:name', 'dropTable.audit_old', 'rawSql'].map( + remove, + ), + origin: missingOrigin, + destination, + }); + expect(expectValue(result)).toEqual([]); }); }); diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 51bbc04078ba..a6cb5eca2897 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -63,6 +63,7 @@ import { } from './diff/schema-verify'; import { sqlFamilyError } from './errors'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; +import { storageNameOfOperation } from './migrations/operation-storage-name'; import type { SqlSchemaDiffFn } from './migrations/schema-differ'; import type { SqlControlAdapterDescriptor, @@ -325,6 +326,8 @@ export interface SqlControlFamilyInstance bootstrapControlTableQueries(): readonly DdlNode[]; toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview; + + storageNameOf(operation: MigrationPlanOperation): string; } export type SqlFamilyInstance = SqlControlFamilyInstance; @@ -966,6 +969,10 @@ export function createSqlFamilyInstance( return sqlOperationsToPreview(operations); }, + storageNameOf(operation: MigrationPlanOperation): string { + return storageNameOfOperation(operation); + }, + toSchemaView(schema: SqlSchemaIRNode): CoreSchemaView { // Walk the schema-IR tree's own structure (root → namespaces → tables) // into one flat list of table nodes. A root that exposes a `namespaces` diff --git a/packages/2-sql/9-family/src/core/migrations/operation-storage-name.ts b/packages/2-sql/9-family/src/core/migrations/operation-storage-name.ts new file mode 100644 index 000000000000..12d60742d5d9 --- /dev/null +++ b/packages/2-sql/9-family/src/core/migrations/operation-storage-name.ts @@ -0,0 +1,44 @@ +import type { MigrationPlanOperation } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; + +interface StorageDetails { + readonly objectType: string; + readonly name: string; + readonly schema?: string; + readonly table?: string; +} + +function isStorageDetails(details: unknown): details is StorageDetails { + if (typeof details !== 'object' || details === null) return false; + const [objectType, name, schema, table] = ['objectType', 'name', 'schema', 'table'].map( + (key): unknown => Reflect.get(details, key), + ); + return ( + typeof objectType === 'string' && + typeof name === 'string' && + (schema === undefined || typeof schema === 'string') && + (table === undefined || typeof table === 'string') + ); +} + +function storageDetailsOf(operation: MigrationPlanOperation): StorageDetails | undefined { + const target: unknown = Reflect.get(operation, 'target'); + if (typeof target !== 'object' || target === null) return undefined; + const details: unknown = Reflect.get(target, 'details'); + return isStorageDetails(details) ? details : undefined; +} + +/** + * The name the database knows an operation's object by, read from the operation's target + * details: `schema.table.column` for a column, `schema.name` for anything else, without the + * schema in the unbound namespace. An operation without details is named by its id. + */ +export function storageNameOfOperation(operation: MigrationPlanOperation): string { + const details = storageDetailsOf(operation); + if (details === undefined) return operation.id; + const schema = + details.schema === undefined || details.schema === UNBOUND_NAMESPACE_ID ? [] : [details.schema]; + const table = + details.objectType === 'column' && details.table !== undefined ? [details.table] : []; + return [...schema, ...table, details.name].join('.'); +} diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 8620bb529fc6..a8a2a184d393 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -39,6 +39,7 @@ export { } from '../core/migrations/control-policy'; export type { PlanFieldEventOperationsOptions } from '../core/migrations/field-event-planner'; export { planFieldEventOperations } from '../core/migrations/field-event-planner'; +export { storageNameOfOperation } from '../core/migrations/operation-storage-name'; export type { CallSubjects, PlanSubjects, diff --git a/packages/2-sql/9-family/test/operation-storage-name.test.ts b/packages/2-sql/9-family/test/operation-storage-name.test.ts new file mode 100644 index 000000000000..0686fefe80dd --- /dev/null +++ b/packages/2-sql/9-family/test/operation-storage-name.test.ts @@ -0,0 +1,32 @@ +import type { MigrationPlanOperation } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { storageNameOfOperation } from '../src/core/migrations/operation-storage-name'; + +function operation(details: Record | undefined): MigrationPlanOperation { + const op = { id: 'drop.thing', label: 'Drop a thing: carefully', operationClass: 'destructive' }; + return { + ...op, + ...(details === undefined ? {} : { target: { id: 'postgres', details } }), + } as MigrationPlanOperation; +} + +describe('storageNameOfOperation', () => { + it('names a column through its table and schema, and anything else through its schema', () => { + expect([ + storageNameOfOperation( + operation({ schema: 'public', objectType: 'column', name: 'nickname', table: 'User' }), + ), + storageNameOfOperation(operation({ schema: 'public', objectType: 'table', name: 'Legacy' })), + storageNameOfOperation(operation({ schema: 'public', objectType: 'type', name: 'mood' })), + storageNameOfOperation( + operation({ schema: UNBOUND_NAMESPACE_ID, objectType: 'table', name: 'Legacy' }), + ), + storageNameOfOperation(operation({ objectType: 'column', name: 'nickname', table: 'User' })), + ]).toEqual(['public.User.nickname', 'public.Legacy', 'public.mood', 'Legacy', 'User.nickname']); + }); + + it('names an operation without target details by its id, never its label', () => { + expect(storageNameOfOperation(operation(undefined))).toBe('drop.thing'); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts index 5f42df6b2bbd..09240695d8f1 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -1,5 +1,9 @@ import type { Contract } from '@internal/contract/types'; -import type { CallSubjects, SubjectTarget } from '@internal/family-sql/control'; +import { + type CallSubjects, + type SubjectTarget, + storageNameOfOperation, +} from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlStorage } from '@internal/sql-contract/types'; @@ -43,13 +47,13 @@ function target( function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectTarget { const details = call.op.target.details; - if (details === undefined) return { storageName: call.label, table: undefined }; + if (details === undefined) return { storageName: call.op.id, table: undefined }; if (details.objectType === 'table') return target(locator, details.schema, details.name, undefined); if (details.objectType === 'column' && details.table !== undefined) { return target(locator, details.schema, details.table, details.name); } - return { storageName: qualified(details.schema, details.name), table: undefined }; + return { storageName: storageNameOfOperation(call.op), table: undefined }; } function dataLossOf(locator: Locator, call: OpFactoryCall): readonly SubjectTarget[] { @@ -61,7 +65,7 @@ function dataLossOf(locator: Locator, call: OpFactoryCall): readonly SubjectTarg return [target(locator, call.schemaName, call.tableName, call.columnName)]; } if (call instanceof RawSqlCall) return [rawSqlTarget(locator, call)]; - return [{ storageName: call.label, table: undefined }]; + return [{ storageName: call.factoryName, table: undefined }]; } function policyKey(schemaName: string, tableName: string, policyName: string): string { diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts index 50b8559d19f2..959863d8ba88 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts @@ -1,7 +1,11 @@ -import type { CallSubjects, SubjectTarget } from '@internal/family-sql/control'; +import { + type CallSubjects, + type SubjectTarget, + storageNameOfOperation, +} from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; -import { DropColumnCall, DropTableCall, RecreateTableCall } from './op-factory-call'; +import { DropColumnCall, DropTableCall, RawSqlCall, RecreateTableCall } from './op-factory-call'; function tableTarget(table: string): SubjectTarget { return { @@ -63,7 +67,13 @@ export function sqliteCallSubjects(calls: readonly OpFactoryCall[]): readonly Ca operationCount, dataLoss: call.operationClass === 'destructive' - ? [{ storageName: call.label, table: undefined }] + ? [ + { + storageName: + call instanceof RawSqlCall ? storageNameOfOperation(call.op) : call.factoryName, + table: undefined, + }, + ] : [], accessWidening: [], }; From 300d7259a0babb0395d3d36422451d562588afa8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:00:20 +0200 Subject: [PATCH 026/109] TML-3476: a subject on a table or collection that several models share names the root model, or the model with the field Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/operation-subjects.ts | 51 ++++++++++++++----- .../9-family/test/operation-subjects.test.ts | 31 +++++++++++ .../2-sql/9-family/test/statement-fixtures.ts | 4 ++ .../src/core/migrations/mongo-planner.ts | 22 +++++--- .../test/mongo-planner.data-loss.test.ts | 46 +++++++++++++++-- 5 files changed, 130 insertions(+), 24 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts index 225d5e287507..fd3b38c26270 100644 --- a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -2,8 +2,10 @@ import { asNamespaceId, type ContractWithDomain } from '@internal/contract/types import type { MigrationOperationSubject, MigrationStatementSubject, + ModelCoordinate, ResolvedMigrationStatement, } from '@internal/framework-components/control'; +import type { SqlModelStorage } from '@internal/sql-contract/types'; import { fieldRenameStorageEffect, isSqlModelStorage, @@ -84,6 +86,34 @@ function originColumn( return column; } +interface StoringModel { + readonly coordinate: ModelCoordinate; + readonly isRoot: boolean; + readonly fields: SqlModelStorage['fields']; +} + +/** The models of `contract` that store `table`: a model and its variants share one. */ +function modelsStoring( + contract: ContractWithDomain, + namespaceId: string, + table: string, +): readonly StoringModel[] { + return Object.entries(contract.domain.namespaces).flatMap(([modelNamespace, namespace]) => + Object.entries(namespace.models).flatMap(([model, definition]) => { + const { storage } = definition; + if (!isSqlModelStorage(storage)) return []; + if (storage.namespaceId !== namespaceId || storage.table !== table) return []; + return [ + { + coordinate: { namespaceId: asNamespaceId(modelNamespace), model }, + isRoot: definition.base === undefined, + fields: storage.fields, + }, + ]; + }), + ); +} + function subjectOf(target: SubjectTarget, context: SubjectContext): MigrationStatementSubject { const storage: MigrationStatementSubject = { kind: 'storage', name: target.storageName }; const { fromContract } = context; @@ -91,18 +121,15 @@ function subjectOf(target: SubjectTarget, context: SubjectContext): MigrationSta const withOrigin = { ...context, fromContract }; const { namespaceId } = target.table; const table = originTable(withOrigin, namespaceId, target.table.table); - for (const [modelNamespace, namespace] of Object.entries(fromContract.domain.namespaces)) { - for (const [model, { storage: modelStorage }] of Object.entries(namespace.models)) { - if (!isSqlModelStorage(modelStorage)) continue; - if (modelStorage.namespaceId !== namespaceId || modelStorage.table !== table) continue; - const coordinate = { namespaceId: asNamespaceId(modelNamespace), model }; - if (target.table.column === undefined) return { kind: 'model', ...coordinate }; - const column = originColumn(withOrigin, namespaceId, table, target.table.column); - const field = Object.entries(modelStorage.fields).find( - ([, stored]) => stored.column === column, - )?.[0]; - return field === undefined ? storage : { kind: 'field', ...coordinate, field }; - } + const models = modelsStoring(fromContract, namespaceId, table); + if (target.table.column === undefined) { + const model = models.find(({ isRoot }) => isRoot) ?? models[0]; + return model === undefined ? storage : { kind: 'model', ...model.coordinate }; + } + const column = originColumn(withOrigin, namespaceId, table, target.table.column); + for (const model of [...models].sort((a, b) => Number(b.isRoot) - Number(a.isRoot))) { + const field = Object.entries(model.fields).find(([, stored]) => stored.column === column)?.[0]; + if (field !== undefined) return { kind: 'field', ...model.coordinate, field }; } return storage; } diff --git a/packages/2-sql/9-family/test/operation-subjects.test.ts b/packages/2-sql/9-family/test/operation-subjects.test.ts index 30f0765f1811..e8004031a78f 100644 --- a/packages/2-sql/9-family/test/operation-subjects.test.ts +++ b/packages/2-sql/9-family/test/operation-subjects.test.ts @@ -135,4 +135,35 @@ describe('planSubjects', () => { ], }); }); + + describe('models that share a table', () => { + const sharing = contractOf({ + Admin: { table: 'users', base: 'User', fields: { id: 'id', level: 'level' } }, + Guest: { table: 'users', base: 'User', fields: { id: 'id' } }, + User: { table: 'users', fields: { id: 'id', email: 'email' } }, + }); + + it('names the root model of a dropped table, and the model with a field for a dropped column', () => { + expect( + planSubjects([losing(table('users')), losing(column('users', 'email'))], { + fromContract: sharing, + contract: sharing, + statements: [], + }).dataLoss.map(({ subject }) => subject), + ).toEqual([ + { kind: 'model', namespaceId: app, model: 'User' }, + { kind: 'field', namespaceId: app, model: 'User', field: 'email' }, + ]); + }); + + it('names a field only a variant has through the variant', () => { + expect( + planSubjects([losing(column('users', 'level'))], { + fromContract: sharing, + contract: sharing, + statements: [], + }).dataLoss.map(({ subject }) => subject), + ).toEqual([{ kind: 'field', namespaceId: app, model: 'Admin', field: 'level' }]); + }); + }); }); diff --git a/packages/2-sql/9-family/test/statement-fixtures.ts b/packages/2-sql/9-family/test/statement-fixtures.ts index ba8ffbb4d7ef..561930baeb6c 100644 --- a/packages/2-sql/9-family/test/statement-fixtures.ts +++ b/packages/2-sql/9-family/test/statement-fixtures.ts @@ -3,6 +3,7 @@ import { type Contract, type ContractModelBase, type ControlPolicy, + crossRef, profileHash, type StorageHashBase, } from '@internal/contract/types'; @@ -29,6 +30,8 @@ export interface ModelSpec { readonly control?: ControlPolicy; /** Field name to column name; `null` for a relation field, which has no column. */ readonly fields?: Readonly>; + /** The model this one is a variant of, in the same namespace. */ + readonly base?: string; } const text = { dataType: 'test/text', codecId: 'test/text@1', nullable: false }; @@ -57,6 +60,7 @@ function model(namespaceId: string, spec: ModelSpec): ContractModelBase { }, ]), ), + ...(spec.base === undefined ? {} : { base: crossRef(spec.base, namespaceId) }), storage: spec.table === null ? {} 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 1039bda084b7..1537f5fe6d11 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 @@ -132,18 +132,24 @@ function collectionOf(contract: ContractWithDomain | null, coordinate: ModelCoor return typeof collection === 'string' ? collection : coordinate.model; } -/** The model of `fromContract` that stores its documents in `collection`, if any. */ +/** + * The model of `fromContract` that stores its documents in `collection`, if any: the root model + * when variants share the collection with it. + */ function modelStoredIn( fromContract: ContractWithDomain, collection: string, ): ModelCoordinate | undefined { - for (const [namespaceId, namespace] of Object.entries(fromContract.domain.namespaces)) { - for (const model of Object.keys(namespace.models)) { - const coordinate = { namespaceId: asNamespaceId(namespaceId), model }; - if (collectionOf(fromContract, coordinate) === collection) return coordinate; - } - } - return undefined; + const storing = Object.entries(fromContract.domain.namespaces).flatMap( + ([namespaceId, namespace]) => + Object.entries(namespace.models).flatMap(([model, definition]) => { + const coordinate = { namespaceId: asNamespaceId(namespaceId), model }; + return collectionOf(fromContract, coordinate) === collection + ? [{ coordinate, isRoot: definition.base === undefined }] + : []; + }), + ); + return (storing.find(({ isRoot }) => isRoot) ?? storing[0])?.coordinate; } /** Each collection drop of the plan, with the model whose documents it loses. */ diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts index 501a26273de4..31da4a4c29e5 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts @@ -11,7 +11,11 @@ const ALL_CLASSES_POLICY: MigrationOperationPolicy = { }; /** A contract whose models each store their documents in the named collection. */ -function contractWith(models: Readonly>, seed: string): MongoContract { +function contractWith( + models: Readonly>, + seed: string, + bases: Readonly> = {}, +): MongoContract { return { target: 'mongo', targetFamily: 'mongo', @@ -26,7 +30,14 @@ function contractWith(models: Readonly>, seed: string): M models: Object.fromEntries( Object.entries(models).map(([model, collection]) => [ model, - { fields: {}, relations: {}, storage: { collection } }, + { + fields: {}, + relations: {}, + storage: { collection }, + ...(bases[model] === undefined + ? {} + : { base: { namespace: '__unbound__', model: bases[model] } }), + }, ]), ), }, @@ -95,8 +106,35 @@ describe('MongoDB planner, data loss', () => { }); it('names the collection by its name when the plan has no origin contract', () => { - expect(plan(null).dataLoss).toEqual([ - { operationIndex: expect.any(Number), subject: { kind: 'storage', name: 'events' } }, + const planned = plan(null); + expect( + planned.dataLoss.map(({ operationIndex, subject }) => ({ + operation: planned.operations[operationIndex], + subject, + })), + ).toEqual([ + { operation: 'Drop collection events', subject: { kind: 'storage', name: 'events' } }, + ]); + }); + + it('names the root model when a variant shares the dropped collection', () => { + const fromContract = contractWith( + { Meeting: 'events', User: 'users', Event: 'events' }, + 'from', + { Meeting: 'Event' }, + ); + const planned = plan(fromContract); + + expect( + planned.dataLoss.map(({ operationIndex, subject }) => ({ + operation: planned.operations[operationIndex], + subject, + })), + ).toEqual([ + { + operation: 'Drop collection events', + subject: { kind: 'model', namespaceId: asNamespaceId('__unbound__'), model: 'Event' }, + }, ]); }); }); From a3ab0c97eedabf91b62b2df69770c5942cdb5a72 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:00:24 +0200 Subject: [PATCH 027/109] TML-3476: a SQLite recreate that leaves a live column out is destructive, and planner tests run with a rename statement Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/core/migrations/planner-strategies.ts | 11 ++-- .../migrations/planner-strategies.test.ts | 28 +++++++++ .../test/migrations/planner.data-loss.test.ts | 58 +++++++++++++++++-- 3 files changed, 87 insertions(+), 10 deletions(-) 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 aef8c5684db2..102121f0c5c9 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 @@ -107,7 +107,8 @@ function classifyNodeIssue(issue: SchemaDiffIssue): 'widening' | 'destructive' | /** * Groups recreate-eligible issues by table, decides per-table operation class - * (destructive wins over widening), and emits one `RecreateTableCall` per + * (destructive wins over widening, and a recreate that leaves a live column + * out of the new table loses its values, so it is destructive), and emits one `RecreateTableCall` per * table. Returns unchanged-or-smaller issue list — issues the strategy * consumed are removed so `mapNodeIssueToCall` doesn't double-handle them. * @@ -149,9 +150,11 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { const expectedTable = ctx.expected.tables[tableName]; const actualTable = ctx.actual.tables[tableName]; if (!expectedTable || !actualTable) continue; - const operationClass: MigrationOperationClass = entry.hasDestructive - ? 'destructive' - : 'widening'; + const leavesColumnsOut = Object.keys(actualTable.columns).some( + (column) => !Object.hasOwn(expectedTable.columns, column), + ); + const operationClass: MigrationOperationClass = + entry.hasDestructive || leavesColumnsOut ? 'destructive' : 'widening'; // Flatten the expected table node to a self-contained spec — the Call // holds pre-rendered SQL fragments only, no schema-IR node. 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 0851f351a593..1040e35c292c 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 @@ -179,6 +179,34 @@ describe('recreateTableStrategy', () => { expect(classes).toEqual(['widening', 'widening']); }); + it('classifies a recreate that leaves a live column out as destructive', () => { + const actualWithNickname = table({ + name: 'user', + columns: { + id: actualColumn({ name: 'id', nativeType: 'INTEGER', nullable: false }), + email: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: false }), + nickname: actualColumn({ name: 'nickname', nativeType: 'TEXT', nullable: true }), + }, + primaryKey: primaryKey(['id']), + }); + const relaxing = issue({ + path: ['database', 'user', 'column:email'], + expected: expectedColumn({ name: 'email', nativeType: 'TEXT', nullable: true }), + actual: actualColumn({ name: 'email', nativeType: 'TEXT', nullable: false }), + }); + const result = recreateTableStrategy( + [relaxing], + makeContext({ + expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), + actual: new SqlSchemaIR({ tables: { user: actualWithNickname } }), + }), + ); + + expect(result.kind === 'match' && (result.calls[0] as RecreateTableCall).operationClass).toBe( + 'destructive', + ); + }); + it('records the columns whose type changes as the lossy columns of the recreate', () => { const ctx = makeContext({ expected: new SqlSchemaIR({ tables: { user: expectedUserTable } }), diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts index 59392e520804..e364b94b6061 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -12,7 +12,7 @@ import { sqliteContractToSchema } from '../../src/core/migrations/diff-database- import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; import { sqliteTestComponents, sqliteTestTypes } from '../sqlite-test-types'; -import { stubLowerer } from './rename-table-fixtures'; +import { HANDLE_INDEX_HASH, handleIndex, stubLowerer } from './rename-table-fixtures'; const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); const ALL_CLASSES = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; @@ -29,6 +29,7 @@ type ColumnType = keyof typeof columnTypes; function contract( seed: string, tables: Readonly>>>, + indexed: ReadonlySet = new Set(), ): Contract { return { target: 'sqlite', @@ -49,7 +50,7 @@ function contract( ), primaryKey: { columns: ['id'] }, uniques: [], - indexes: [], + indexes: indexed.has(table) ? [handleIndex(table)] : [], foreignKeys: [], }), ]), @@ -109,13 +110,16 @@ async function planned( snapshotsImportPath: '../../snapshots', }); if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); - const labels = (await Promise.all(result.plan.operations)).map((op) => op.label); + const operations = await Promise.all(result.plan.operations); + const labels = operations.map((op) => op.label); return { + labels, dataLoss: result.dataLoss.map(({ operationIndex, subject }) => ({ operation: labels[operationIndex], subject, })), accessWidening: result.accessWidening, + classes: Object.fromEntries(operations.map((op) => [op.label, op.operationClass])), }; } @@ -127,7 +131,8 @@ describe('SQLite planner, data loss', () => { }); const to = contract('to', { User: { id: 'integer', email: 'text' } }); - expect(await planned(from, to)).toEqual({ + const { dataLoss, accessWidening } = await planned(from, to); + expect({ dataLoss, accessWidening }).toEqual({ dataLoss: [ { operation: 'Drop column nickname on User', @@ -154,11 +159,13 @@ describe('SQLite planner, data loss', () => { ]); }); - it('names a column a widening recreate leaves behind once, at the recreate', async () => { + it('names a column a recreate leaves out once, at the recreate, which is destructive', async () => { const from = contract('from', { User: { id: 'integer', email: 'text?', nickname: 'text' } }); const to = contract('to', { User: { id: 'integer', email: 'text' } }); + const result = await planned(from, to); - expect((await planned(from, to)).dataLoss).toEqual([ + expect(result.classes['Recreate table User']).toBe('destructive'); + expect(result.dataLoss).toEqual([ { operation: 'Recreate table User', subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, @@ -181,4 +188,43 @@ describe('SQLite planner, data loss', () => { { operation: 'Drop table Legacy', subject: { kind: 'storage', name: 'Legacy' } }, ]); }); + + it('names the origin model and fields of a table a statement renamed earlier in the plan', async () => { + const from = contract( + 'from', + { User: { id: 'integer', handle: 'text', nickname: 'text', age: 'text' } }, + new Set(['User']), + ); + const to = contract( + 'to', + { Account: { id: 'integer', handle: 'text', age: 'integer' } }, + new Set(['Account']), + ); + const renameUser: ResolvedMigrationStatement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: unbound, model: 'User' }, + to: { namespaceId: unbound, model: 'Account' }, + }; + + const result = await planned(from, to, { statements: [renameUser] }); + + expect(result.labels).toEqual([ + 'Rename table User to Account', + `Drop index User_handle_idx_${HANDLE_INDEX_HASH} on Account`, + `Create index Account_handle_idx_${HANDLE_INDEX_HASH} on Account`, + 'Recreate table Account', + 'Drop column nickname on Account', + ]); + expect(result.dataLoss).toEqual([ + { + operation: 'Recreate table Account', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + { + operation: 'Recreate table Account', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'age' }, + }, + ]); + }); }); From 450e5bc69651c79571967c9efa51433926e289ae Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:00:28 +0200 Subject: [PATCH 028/109] TML-3476: Postgres data-loss tests run with a rename statement, assert positions by label, and cover more unsafe type changes Signed-off-by: willbot Signed-off-by: Will Madden --- .../migrations/node-issue-planner.test.ts | 14 +++- .../test/migrations/planner.data-loss.test.ts | 69 ++++++++++++++++--- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts index b37f526cff06..4dc73a3b2e4f 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/node-issue-planner.test.ts @@ -336,7 +336,19 @@ describe('buildPostgresPlanDiff + planNodeIssues (one-differ path)', () => { many: { elementNullable: false }, }, ), - ]).toEqual(['destructive', 'destructive', 'destructive']); + alterClass( + { nativeType: 'float4' }, + { dataType: 'pg/int8', codecId: 'pg/int8@1', nullable: false }, + ), + alterClass( + { nativeType: 'numeric(10,2)' }, + { dataType: 'pg/int8', codecId: 'pg/int8@1', nullable: false }, + ), + alterClass( + { nativeType: 'int4' }, + { dataType: 'pg/float8', codecId: 'pg/float8@1', nullable: false }, + ), + ]).toEqual(Array(6).fill('destructive')); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index e8ee219a23e5..41aae726c6a6 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -1,6 +1,10 @@ import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; -import { APP_SPACE_ID, planOriginOf } from '@internal/framework-components/control'; +import { + APP_SPACE_ID, + planOriginOf, + type ResolvedMigrationStatement, +} 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'; @@ -11,6 +15,7 @@ import { PostgresRlsEnablement } from '../../src/core/postgres-rls-enablement'; import { PostgresRlsPolicy } from '../../src/core/postgres-rls-policy'; import { PostgresSchema } from '../../src/core/postgres-schema'; import { postgresTypeComponents } from '../postgres-type-lookups'; +import { emailIndex } from './rename-column-fixtures'; const stubLowerer: ExecuteRequestLowerer = { lower: () => ({ sql: 'stub', params: [] }), @@ -33,6 +38,8 @@ interface TableSpec { readonly columns: Readonly>; /** The `using` of the table's one policy, which also turns on row-level security. */ readonly policy?: string; + /** A column with an index named after the table. */ + readonly indexed?: string; } function policyOn(table: string, using: string): PostgresRlsPolicy { @@ -67,7 +74,7 @@ function contract(seed: string, tables: Readonly>): Co primaryKey: { columns: ['id'] }, foreignKeys: [], uniques: [], - indexes: [], + indexes: spec.indexed === undefined ? [] : [emailIndex(table, spec.indexed)], }), ]), ), @@ -119,6 +126,7 @@ async function planned( from: Contract, to: Contract, fromContract: Contract | null = from, + statements: readonly ResolvedMigrationStatement[] = [], ) { const result = createPostgresMigrationPlanner(stubLowerer).plan({ contract: to, @@ -126,7 +134,7 @@ async function planned( policy: ALL_CLASSES, fromContract, origin: planOriginOf(fromContract), - statements: [], + statements, frameworkComponents: postgresTypeComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', @@ -142,6 +150,7 @@ async function planned( subject, })); return { + labels, dataLoss: labelled(result.dataLoss), accessWidening: labelled(result.accessWidening), }; @@ -199,16 +208,14 @@ describe('Postgres planner, data loss', () => { User: { columns: { id: 'int4' }, policy: 'true' }, }); const to = contract('to', { User: { columns: { id: 'int4' } } }); + const { dataLoss, accessWidening } = await planned(from, to); - expect(await planned(from, to)).toEqual({ + const user = { kind: 'model', namespaceId: unbound, model: 'User' }; + expect({ dataLoss, accessWidening }).toEqual({ dataLoss: [], accessWidening: [ - expect.objectContaining({ - subject: { kind: 'model', namespaceId: unbound, model: 'User' }, - }), - expect.objectContaining({ - subject: { kind: 'model', namespaceId: unbound, model: 'User' }, - }), + { operation: 'Disable row-level security on "User"', subject: user }, + { operation: 'Drop RLS policy "User readers" on "User"', subject: user }, ], }); }); @@ -217,6 +224,46 @@ describe('Postgres planner, data loss', () => { const from = contract('from', { User: { columns: { id: 'int4' }, policy: 'true' } }); const to = contract('to', { User: { columns: { id: 'int4' }, policy: 'false' } }); - expect(await planned(from, to)).toEqual({ dataLoss: [], accessWidening: [] }); + const { dataLoss, accessWidening } = await planned(from, to); + expect({ dataLoss, accessWidening }).toEqual({ dataLoss: [], accessWidening: [] }); + }); + + it('names the origin model and fields of a table a statement renamed earlier in the plan', async () => { + const from = contract('from', { + User: { + columns: { id: 'int4', handle: 'text', nickname: 'text', age: 'text' }, + indexed: 'handle', + }, + }); + const to = contract('to', { + Account: { columns: { id: 'int4', handle: 'text', age: 'int4' }, indexed: 'handle' }, + }); + const renameUser: ResolvedMigrationStatement = { + kind: 'rename', + entity: 'model', + from: { namespaceId: unbound, model: 'User' }, + to: { namespaceId: unbound, model: 'Account' }, + }; + + const result = await planned(from, to, from, [renameUser]); + + expect(result.labels).toEqual([ + 'Rename table "User" to "Account"', + 'Rename primary key "User_pkey" to "Account_pkey" on "Account"', + expect.stringMatching(/^Rename index "User_handle_idx_\w+" to "Account_handle_idx_\w+"/), + 'Drop column "nickname" from "Account"', + 'placeholder', + 'Alter type of "Account"."age" to int4', + ]); + expect(result.dataLoss).toEqual([ + { + operation: 'Drop column "nickname" from "Account"', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + { + operation: 'Alter type of "Account"."age" to int4', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'age' }, + }, + ]); }); }); From 29ea5a0eee0fac51106b6c9f1e545978f4db7995 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:06:26 +0200 Subject: [PATCH 029/109] TML-3476: a destructive codec hook call names its field, and unknown calls get distinct names The field-event planner returns each hook call with the column of its field event, so a destructive hook call maps to Model.field like a dropped column. Any other destructive call a target cannot map is named by its factory name, with a Signed-off-by: willbot Signed-off-by: Will Madden #n ordinal when several calls in the plan share it. --- .../core/migrations/field-event-planner.ts | 29 ++++- .../src/core/migrations/operation-subjects.ts | 34 +++++ .../2-sql/9-family/src/exports/control.ts | 16 ++- .../9-family/test/field-event-planner.test.ts | 49 ++++++- .../9-family/test/operation-subjects.test.ts | 40 ++++++ .../src/core/migrations/operation-subjects.ts | 41 ++++-- .../postgres/src/core/migrations/planner.ts | 10 +- .../migrations/operation-subjects.test.ts | 122 +++++++++++++++++ .../src/core/migrations/operation-subjects.ts | 44 ++++--- .../sqlite/src/core/migrations/planner.ts | 21 ++- .../migrations/operation-subjects.test.ts | 123 ++++++++++++++++++ 11 files changed, 486 insertions(+), 43 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts 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 8a9f541e5532..b35c3be02e65 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 @@ -71,9 +71,25 @@ interface FieldEntry { readonly newField: StorageColumn | undefined; } +/** A call a codec hook returned, with the column of the field event it was returned for. */ +export interface FieldEventCall { + readonly call: OpFactoryCall; + readonly namespaceId: string; + /** The table and column as the new contract names them. */ + readonly tableName: string; + readonly columnName: string; +} + export function planFieldEventOperations( options: PlanFieldEventOperationsOptions, ): readonly OpFactoryCall[] { + return planFieldEventCalls(options).map(({ call }) => call); +} + +/** The calls the codec hooks return, each with the field event's column. */ +export function planFieldEventCalls( + options: PlanFieldEventOperationsOptions, +): readonly FieldEventCall[] { const priorContract = options.priorContract; const newContract = options.newContract; const renamedTo = new Map( @@ -145,7 +161,7 @@ export function planFieldEventOperations( } } - const calls: OpFactoryCall[] = []; + const calls: FieldEventCall[] = []; appendCalls('added', added, options.codecHooks, calls, (e) => e.newField?.codecId); appendCalls('dropped', dropped, options.codecHooks, calls, (e) => e.priorField?.codecId); appendCalls('altered', altered, options.codecHooks, calls, (e) => e.newField?.codecId); @@ -170,7 +186,7 @@ function appendCalls( event: FieldEvent, entries: readonly FieldEntry[], codecHooks: ReadonlyMap, - calls: OpFactoryCall[], + calls: FieldEventCall[], pickCodecId: (entry: FieldEntry) => string | undefined, ): void { for (const entry of entries) { @@ -180,7 +196,14 @@ function appendCalls( if (!hook?.onFieldEvent) continue; const ctx = buildContext(event, entry); const emitted = hook.onFieldEvent(event, ctx); - for (const call of emitted) calls.push(call); + for (const call of emitted) { + calls.push({ + call, + namespaceId: entry.namespaceId, + tableName: entry.tableName, + columnName: entry.fieldName, + }); + } } } diff --git a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts index fd3b38c26270..80794d69b607 100644 --- a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -5,6 +5,7 @@ import type { ModelCoordinate, ResolvedMigrationStatement, } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlModelStorage } from '@internal/sql-contract/types'; import { fieldRenameStorageEffect, @@ -28,6 +29,39 @@ export interface SubjectTarget { | undefined; } +/** The target of a codec hook's call: the column of the field event it was returned for. */ +export function fieldEventTarget(field: { + readonly namespaceId: string; + readonly tableName: string; + readonly columnName: string; +}): SubjectTarget { + const qualifier = field.namespaceId === UNBOUND_NAMESPACE_ID ? [] : [field.namespaceId]; + return { + storageName: [...qualifier, field.tableName, field.columnName].join('.'), + table: { namespaceId: field.namespaceId, table: field.tableName, column: field.columnName }, + }; +} + +/** + * Names for destructive calls a target cannot map to storage: each call's factory name, with a + * `#n` ordinal when several calls of the plan share it, so each name picks out one call. + */ +export function unknownCallNames( + calls: readonly TCall[], +): ReadonlyMap { + const counts = new Map(); + for (const call of calls) counts.set(call.factoryName, (counts.get(call.factoryName) ?? 0) + 1); + const seen = new Map(); + return new Map( + calls.map((call) => { + const ordinal = (seen.get(call.factoryName) ?? 0) + 1; + seen.set(call.factoryName, ordinal); + const shared = (counts.get(call.factoryName) ?? 0) > 1; + return [call, shared ? `${call.factoryName}#${ordinal}` : call.factoryName]; + }), + ); +} + /** A call of a plan: how many operations it lowers to, and what the first of them is about. */ export interface CallSubjects { readonly operationCount: number; diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index a8a2a184d393..8ab924557ae5 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -37,15 +37,25 @@ export { partitionCallsByControlPolicy, partitionIssuesByControlPolicy, } from '../core/migrations/control-policy'; -export type { PlanFieldEventOperationsOptions } from '../core/migrations/field-event-planner'; -export { planFieldEventOperations } from '../core/migrations/field-event-planner'; +export type { + FieldEventCall, + PlanFieldEventOperationsOptions, +} from '../core/migrations/field-event-planner'; +export { + planFieldEventCalls, + planFieldEventOperations, +} from '../core/migrations/field-event-planner'; export { storageNameOfOperation } from '../core/migrations/operation-storage-name'; export type { CallSubjects, PlanSubjects, SubjectTarget, } from '../core/migrations/operation-subjects'; -export { planSubjects } from '../core/migrations/operation-subjects'; +export { + fieldEventTarget, + planSubjects, + unknownCallNames, +} from '../core/migrations/operation-subjects'; export { createMigrationPlan, plannerFailure, 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 17afee1c7b73..5f4664f920b2 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,10 +1,57 @@ import type { MigrationPlanOperation } from '@internal/framework-components/control'; 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 { + planFieldEventCalls, + planFieldEventOperations, +} from '../src/core/migrations/field-event-planner'; import type { CodecControlHooks } from '../src/core/migrations/types'; import { col, contract, makeOp, recordingHook, table } from './field-event-fixtures'; +describe('planFieldEventCalls', () => { + it('returns each call with the column of the field event it was returned for', () => { + const fromContract = contract({ + User: table({ + id: col({ codecId: 'pg/text@1' }), + email: col({ codecId: 'cs/string@1' }), + bio: col({ codecId: 'cs/string@1' }), + }), + }); + const newContract = contract({ User: table({ id: col({ codecId: 'pg/text@1' }) }) }); + const cs = recordingHook((call) => [makeOp(`drop-${call.fieldName}`)]); + + const calls = planFieldEventCalls({ + priorContract: fromContract, + newContract, + codecHooks: new Map([['cs/string@1', cs.hook]]), + tableRenames: [], + columnRenames: [], + }); + + expect( + calls.map(({ call, namespaceId, tableName, columnName }) => ({ + factoryName: call.factoryName, + namespaceId, + tableName, + columnName, + })), + ).toEqual([ + { + factoryName: 'drop-bio', + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'User', + columnName: 'bio', + }, + { + factoryName: 'drop-email', + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'User', + columnName: 'email', + }, + ]); + }); +}); + describe('planFieldEventOperations', () => { it("fires 'added' once per added field on the new field's codec", () => { const fromContract = contract({ diff --git a/packages/2-sql/9-family/test/operation-subjects.test.ts b/packages/2-sql/9-family/test/operation-subjects.test.ts index e8004031a78f..58dae4bc957f 100644 --- a/packages/2-sql/9-family/test/operation-subjects.test.ts +++ b/packages/2-sql/9-family/test/operation-subjects.test.ts @@ -1,9 +1,12 @@ import { asNamespaceId } from '@internal/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { describe, expect, it } from 'vitest'; import { type CallSubjects, + fieldEventTarget, planSubjects, type SubjectTarget, + unknownCallNames, } from '../src/core/migrations/operation-subjects'; import { contractOf, renameField, renameModel } from './statement-fixtures'; @@ -167,3 +170,40 @@ describe('planSubjects', () => { }); }); }); + +describe('unknownCallNames', () => { + it('numbers the calls that share a factory name, and leaves a unique one alone', () => { + const calls = [ + { factoryName: 'dropSearchConfig' }, + { factoryName: 'rebuildThing' }, + { factoryName: 'dropSearchConfig' }, + ]; + expect([...unknownCallNames(calls).values()]).toEqual([ + 'dropSearchConfig#1', + 'rebuildThing', + 'dropSearchConfig#2', + ]); + }); +}); + +describe('fieldEventTarget', () => { + it('names the field event column, qualified by its namespace outside the unbound one', () => { + const subjects = planSubjects( + [ + losing( + fieldEventTarget({ namespaceId: 'app', tableName: 'user', columnName: 'full_name' }), + fieldEventTarget({ namespaceId: 'app', tableName: 'user', columnName: 'nickname' }), + ), + ], + { fromContract: origin, contract: origin, statements: [] }, + ); + expect(subjects.dataLoss.map(({ subject }) => subject)).toEqual([ + { kind: 'field', namespaceId: app, model: 'User', field: 'name' }, + { kind: 'storage', name: 'app.user.nickname' }, + ]); + expect( + fieldEventTarget({ namespaceId: UNBOUND_NAMESPACE_ID, tableName: 'user', columnName: 'bio' }) + .storageName, + ).toBe('user.bio'); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts index 09240695d8f1..9942d74c1296 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -1,8 +1,11 @@ import type { Contract } from '@internal/contract/types'; import { type CallSubjects, + type FieldEventCall, + fieldEventTarget, type SubjectTarget, storageNameOfOperation, + unknownCallNames, } from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -21,6 +24,8 @@ import { interface Locator { /** The contract that names the namespaces: the origin contract when the plan has one. */ readonly contract: Contract; + /** The codec hooks' calls, each with the column of the field event it was returned for. */ + readonly fieldEvents: ReadonlyMap; } function storageNamespaceId(locator: Locator, schemaName: string): string { @@ -56,16 +61,16 @@ function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectTarget { return { storageName: storageNameOfOperation(call.op), table: undefined }; } -function dataLossOf(locator: Locator, call: OpFactoryCall): readonly SubjectTarget[] { - if (call.operationClass !== 'destructive') return []; +function knownTarget(locator: Locator, call: OpFactoryCall): SubjectTarget | undefined { if (call instanceof DropTableCall) { - return [target(locator, call.schemaName, call.tableName, undefined)]; + return target(locator, call.schemaName, call.tableName, undefined); } if (call instanceof DropColumnCall || call instanceof AlterColumnTypeCall) { - return [target(locator, call.schemaName, call.tableName, call.columnName)]; + return target(locator, call.schemaName, call.tableName, call.columnName); } - if (call instanceof RawSqlCall) return [rawSqlTarget(locator, call)]; - return [{ storageName: call.factoryName, table: undefined }]; + if (call instanceof RawSqlCall) return rawSqlTarget(locator, call); + const fieldEvent = locator.fieldEvents.get(call); + return fieldEvent === undefined ? undefined : fieldEventTarget(fieldEvent); } function policyKey(schemaName: string, tableName: string, policyName: string): string { @@ -113,9 +118,23 @@ export function postgresCallSubjects( locator: Locator, ): readonly CallSubjects[] { const replaced = createdPolicies(calls); - return calls.map((call) => ({ - operationCount: operationCountOf(call), - dataLoss: dataLossOf(locator, call), - accessWidening: accessWideningOf(locator, call, replaced), - })); + const destructive = calls.filter((call) => call.operationClass === 'destructive'); + const known = new Map(destructive.map((call) => [call, knownTarget(locator, call)])); + const unknownNames = unknownCallNames( + destructive.filter((call) => known.get(call) === undefined), + ); + return calls.map((call) => { + const lossTarget = known.get(call); + const unknownName = unknownNames.get(call); + return { + operationCount: operationCountOf(call), + dataLoss: + lossTarget !== undefined + ? [lossTarget] + : unknownName !== undefined + ? [{ storageName: unknownName, table: undefined }] + : [], + accessWidening: accessWideningOf(locator, call, replaced), + }; + }); } 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 60b464943d4f..74ae57a3fb37 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 @@ -13,7 +13,7 @@ import { extractCodecControlHooks, partitionCallsByControlPolicy, partitionIssuesByControlPolicy, - planFieldEventOperations, + planFieldEventCalls, plannerFailure, planStatements, planSubjects, @@ -400,13 +400,14 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // within-group sorting by `(tableName, fieldName)` so re-emits are // byte-stable. The hook fires only at the application emitter — // extension-space planning never reaches this helper. - const fieldEventOps = planFieldEventOperations({ + const fieldEventCalls = planFieldEventCalls({ priorContract: options.fromContract, newContract: options.contract, codecHooks, tableRenames: statements.value.tableRenames, columnRenames: statements.value.columnRenames, }); + const fieldEventOps = fieldEventCalls.map(({ call }) => call); // Codec hook ops are target-agnostic `OpFactoryCall`; Postgres planning // lifts them at this integration boundary (see field-event-planner JSDoc). const fieldEventPostgresCalls = blindCast< @@ -459,7 +460,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr ), appliedStatements: statements.value.appliedStatements, ...planSubjects( - postgresCallSubjects(calls, { contract: options.fromContract ?? options.contract }), + postgresCallSubjects(calls, { + contract: options.fromContract ?? options.contract, + fieldEvents: new Map(fieldEventCalls.map((fieldEvent) => [fieldEvent.call, fieldEvent])), + }), { fromContract: options.fromContract, contract: options.contract, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts new file mode 100644 index 000000000000..f27bf100bf4a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts @@ -0,0 +1,122 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { type FieldEventCall, planSubjects } from '@internal/family-sql/control'; +import type { OpFactoryCall } from '@internal/framework-components/control'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { postgresCallSubjects } from '../../src/core/migrations/operation-subjects'; +import { PostgresSchema } from '../../src/core/postgres-schema'; + +const text = { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }; + +const contract: Contract = { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('subjects'), + storage: new SqlStorage({ + storageHash: coreHash('subjects'), + namespaces: { + public: new PostgresSchema({ + id: 'public', + entries: { + table: { + User: new StorageTable({ + columns: { id: text, email: text, bio: text }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: { + User: { + fields: {}, + relations: {}, + storage: { + table: 'User', + namespaceId: 'public', + fields: { id: { column: 'id' }, email: { column: 'email' }, bio: { column: 'bio' } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, +}; + +function destructiveCall(factoryName: string): OpFactoryCall { + return { + factoryName, + operationClass: 'destructive', + label: `Drop the search config: ${factoryName}`, + renderTypeScript: () => '', + importRequirements: () => [], + toOp: () => { + throw new Error('not lowered'); + }, + }; +} + +function subjectsOf(calls: readonly OpFactoryCall[], fieldEvents: readonly FieldEventCall[]) { + return planSubjects( + postgresCallSubjects(calls, { + contract, + fieldEvents: new Map(fieldEvents.map((fieldEvent) => [fieldEvent.call, fieldEvent])), + }), + { fromContract: contract, contract, statements: [] }, + ).dataLoss; +} + +describe('postgresCallSubjects', () => { + it('names the field of each destructive codec hook call', () => { + const onEmail = destructiveCall('dropSearchConfig'); + const onBio = destructiveCall('dropSearchConfig'); + const field = (call: OpFactoryCall, columnName: string): FieldEventCall => ({ + call, + namespaceId: 'public', + tableName: 'User', + columnName, + }); + + expect(subjectsOf([onEmail, onBio], [field(onEmail, 'email'), field(onBio, 'bio')])).toEqual([ + { + operationIndex: 0, + subject: { + kind: 'field', + namespaceId: asNamespaceId('__unbound__'), + model: 'User', + field: 'email', + }, + }, + { + operationIndex: 1, + subject: { + kind: 'field', + namespaceId: asNamespaceId('__unbound__'), + model: 'User', + field: 'bio', + }, + }, + ]); + }); + + it('numbers destructive calls of one unknown factory, never naming them by label', () => { + expect( + subjectsOf( + [destructiveCall('rebuild'), destructiveCall('rebuild'), destructiveCall('purge')], + [], + ), + ).toEqual([ + { operationIndex: 0, subject: { kind: 'storage', name: 'rebuild#1' } }, + { operationIndex: 1, subject: { kind: 'storage', name: 'rebuild#2' } }, + { operationIndex: 2, subject: { kind: 'storage', name: 'purge' } }, + ]); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts index 959863d8ba88..a2203c844629 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts @@ -1,7 +1,10 @@ import { type CallSubjects, + type FieldEventCall, + fieldEventTarget, type SubjectTarget, storageNameOfOperation, + unknownCallNames, } from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; @@ -39,8 +42,22 @@ function recreateLosses(call: RecreateTableCall): readonly string[] { * What each call of a SQLite plan loses. A recreate that leaves a column out loses its values * itself, so a later drop of that column is not listed again. */ -export function sqliteCallSubjects(calls: readonly OpFactoryCall[]): readonly CallSubjects[] { +export function sqliteCallSubjects( + calls: readonly OpFactoryCall[], + fieldEvents: ReadonlyMap, +): readonly CallSubjects[] { const lost = new Set(); + const unknownNames = unknownCallNames( + calls.filter( + (call) => + call.operationClass === 'destructive' && + !(call instanceof RecreateTableCall) && + !(call instanceof DropColumnCall) && + !(call instanceof DropTableCall) && + !(call instanceof RawSqlCall) && + !fieldEvents.has(call), + ), + ); return calls.map((call): CallSubjects => { const operationCount = operationCountOf(call); if (call instanceof RecreateTableCall) { @@ -63,19 +80,16 @@ export function sqliteCallSubjects(calls: readonly OpFactoryCall[]): readonly Ca if (call instanceof DropTableCall) { return { operationCount, dataLoss: [tableTarget(call.tableName)], accessWidening: [] }; } - return { - operationCount, - dataLoss: - call.operationClass === 'destructive' - ? [ - { - storageName: - call instanceof RawSqlCall ? storageNameOfOperation(call.op) : call.factoryName, - table: undefined, - }, - ] - : [], - accessWidening: [], - }; + if (call.operationClass !== 'destructive') { + return { operationCount, dataLoss: [], accessWidening: [] }; + } + const fieldEvent = fieldEvents.get(call); + const lossTarget: SubjectTarget = + call instanceof RawSqlCall + ? { storageName: storageNameOfOperation(call.op), table: undefined } + : fieldEvent !== undefined + ? fieldEventTarget(fieldEvent) + : { storageName: unknownNames.get(call) ?? call.factoryName, table: undefined }; + return { operationCount, dataLoss: [lossTarget], accessWidening: [] }; }); } 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 5300bd087c02..7d52b6202bb3 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 @@ -10,7 +10,7 @@ import type { import { detectTableNameCaseChanges, extractCodecControlHooks, - planFieldEventOperations, + planFieldEventCalls, plannerFailure, planStatements, planSubjects, @@ -215,13 +215,14 @@ export class SqliteMigrationPlanner // `(tableName, fieldName)` deterministic for byte-stable re-emits. // Hook fires only at the application emitter — extension-space planning // (M2 R2) never reaches this helper. - const fieldEventOps = planFieldEventOperations({ + const fieldEventCalls = planFieldEventCalls({ priorContract: options.fromContract, newContract: options.contract, codecHooks, tableRenames: statements.value.tableRenames, columnRenames: statements.value.columnRenames, }); + const fieldEventOps = fieldEventCalls.map(({ call }) => call); // Codec-emitted calls already conform to `OpFactoryCall` — render + // toOp + importRequirements ride directly through the same emit path // as structural ops, no `RawSqlCall` wrap. @@ -253,11 +254,17 @@ export class SqliteMigrationPlanner this.#lowerer, ), appliedStatements: statements.value.appliedStatements, - ...planSubjects(sqliteCallSubjects(calls), { - fromContract: options.fromContract, - contract: options.contract, - statements: options.statements, - }), + ...planSubjects( + sqliteCallSubjects( + calls, + new Map(fieldEventCalls.map((fieldEvent) => [fieldEvent.call, fieldEvent])), + ), + { + fromContract: options.fromContract, + contract: options.contract, + statements: options.statements, + }, + ), }; } diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts new file mode 100644 index 000000000000..dafd219a0fe4 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts @@ -0,0 +1,123 @@ +import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { type FieldEventCall, planSubjects } from '@internal/family-sql/control'; +import type { OpFactoryCall } 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'; +import { describe, expect, it } from 'vitest'; +import { sqliteCallSubjects } from '../../src/core/migrations/operation-subjects'; +import { sqliteCreateNamespace } from '../../src/core/sqlite-unbound-database'; + +const text = { dataType: 'sqlite/text', codecId: 'sqlite/text@1', nullable: false }; + +const contract: Contract = { + target: 'sqlite', + targetFamily: 'sql', + profileHash: profileHash('subjects'), + storage: new SqlStorage({ + storageHash: coreHash('subjects'), + namespaces: { + [UNBOUND_NAMESPACE_ID]: sqliteCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + User: new StorageTable({ + columns: { id: text, email: text, bio: text }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ + models: { + User: { + fields: {}, + relations: {}, + storage: { + table: 'User', + namespaceId: UNBOUND_NAMESPACE_ID, + fields: { id: { column: 'id' }, email: { column: 'email' }, bio: { column: 'bio' } }, + }, + }, + }, + }), + capabilities: {}, + extensions: {}, + meta: {}, +}; + +function destructiveCall(factoryName: string): OpFactoryCall { + return { + factoryName, + operationClass: 'destructive', + label: `Drop the search config: ${factoryName}`, + renderTypeScript: () => '', + importRequirements: () => [], + toOp: () => { + throw new Error('not lowered'); + }, + }; +} + +function subjectsOf(calls: readonly OpFactoryCall[], fieldEvents: readonly FieldEventCall[]) { + return planSubjects( + sqliteCallSubjects( + calls, + new Map(fieldEvents.map((fieldEvent) => [fieldEvent.call, fieldEvent])), + ), + { fromContract: contract, contract, statements: [] }, + ).dataLoss; +} + +describe('sqliteCallSubjects', () => { + it('names the field of each destructive codec hook call', () => { + const onEmail = destructiveCall('dropSearchConfig'); + const onBio = destructiveCall('dropSearchConfig'); + const field = (call: OpFactoryCall, columnName: string): FieldEventCall => ({ + call, + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'User', + columnName, + }); + + expect(subjectsOf([onEmail, onBio], [field(onEmail, 'email'), field(onBio, 'bio')])).toEqual([ + { + operationIndex: 0, + subject: { + kind: 'field', + namespaceId: asNamespaceId('__unbound__'), + model: 'User', + field: 'email', + }, + }, + { + operationIndex: 1, + subject: { + kind: 'field', + namespaceId: asNamespaceId('__unbound__'), + model: 'User', + field: 'bio', + }, + }, + ]); + }); + + it('numbers destructive calls of one unknown factory, never naming them by label', () => { + expect( + subjectsOf( + [destructiveCall('rebuild'), destructiveCall('rebuild'), destructiveCall('purge')], + [], + ), + ).toEqual([ + { operationIndex: 0, subject: { kind: 'storage', name: 'rebuild#1' } }, + { operationIndex: 1, subject: { kind: 'storage', name: 'rebuild#2' } }, + { operationIndex: 2, subject: { kind: 'storage', name: 'purge' } }, + ]); + }); +}); From e556f9680c5fcf07e9511407f844f2ad36495164 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:09:46 +0200 Subject: [PATCH 030/109] TML-3476: every family names the storage of a recorded operation storageNameOf is a required member of ControlFamilyInstance. The Mongo family names the collection the operation's command names, as its planner names a dropped collection, so the capability check and db update's id fallback go. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-capabilities.ts | 14 ---------- .../src/control/control-instances.ts | 7 +++++ .../src/exports/control.ts | 2 -- .../cli/src/control-api/operations/db-run.ts | 7 +---- .../3-tooling/cli/test/config-types.test.ts | 1 + .../9-family/src/core/control-instance.ts | 5 ++++ .../src/core/operation-storage-name.ts | 24 ++++++++++++++++ .../test/operation-storage-name.test.ts | 28 +++++++++++++++++++ 8 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 packages/2-mongo-family/9-family/src/core/operation-storage-name.ts create mode 100644 packages/2-mongo-family/9-family/test/operation-storage-name.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts index db5c5eec2921..f945c9615057 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-capabilities.ts @@ -103,20 +103,6 @@ export function hasOperationPreview( ); } -/** - * Capability declaring that a family can name the storage object an operation is about, as the - * database knows it, for an operation of a recorded migration that no planner mapped to a model. - */ -export interface OperationStorageNaming { - storageNameOf(operation: MigrationPlanOperation): string; -} - -export function hasOperationStorageNaming( - instance: ControlFamilyInstance, -): instance is ControlFamilyInstance & OperationStorageNaming { - return typeof Reflect.get(instance, 'storageNameOf') === 'function'; -} - /** * The granularity of a {@link SchemaDiffIssue}'s subject, resolved on demand * from the issue's node `nodeKind` — never stamped on the issue or the node. diff --git a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts index 0d5ec50ee43c..31b3aafd180d 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts @@ -7,6 +7,7 @@ import type { TargetBoundComponentDescriptor, TargetInstance, } from '../shared/framework-components'; +import type { MigrationPlanOperation } from './control-migration-types'; import type { SpaceSignature, SpaceToSign, @@ -26,6 +27,12 @@ export interface ControlFamilyInstance */ deserializeContract(contractJson: unknown): Contract; + /** + * The name the database knows the object an operation is about by, for an operation of a + * recorded migration that no planner mapped to a model. + */ + storageNameOf(operation: MigrationPlanOperation): string; + verify(options: { readonly driver: ControlDriverInstance; readonly contract: unknown; 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 481949f50413..ef283a32d8e1 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 @@ -11,7 +11,6 @@ export type { DiffSubjectGranularity, MigratableTargetDescriptor, OperationPreviewCapable, - OperationStorageNaming, PslContractBuildCapable, PslContractDocument, PslContractInferCapable, @@ -22,7 +21,6 @@ export type { export { hasMigrations, hasOperationPreview, - hasOperationStorageNaming, hasPslContractBuild, hasPslContractInfer, hasSchemaSubjectClassifier, 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 abbf549ce945..52139e63f025 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 @@ -20,7 +20,6 @@ import type { import { CONTRACT_SNAPSHOTS_DIRNAME, hasOperationPreview, - hasOperationStorageNaming, isStorageHashHex, } from '@internal/framework-components/control'; import type { ContractMarkerRecordLike } from '@internal/migration-tools/aggregate'; @@ -285,11 +284,7 @@ export async function executeRun - hasOperationStorageNaming(familyInstance) - ? familyInstance.storageNameOf(operation) - : operation.id, - ) + ? planSubjects(orderedResolutions, (operation) => familyInstance.storageNameOf(operation)) : undefined; // 4. Plan-mode: surface aggregate operations without applying. diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index bc9c620498ef..84a0d7896f34 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -34,6 +34,7 @@ describe('defineConfig', () => { create: () => ({ familyId: 'sql', deserializeContract: (contract: unknown) => contract as Contract, + storageNameOf: (operation) => operation.id, verify: async () => ({ ok: true, summary: 'test', diff --git a/packages/2-mongo-family/9-family/src/core/control-instance.ts b/packages/2-mongo-family/9-family/src/core/control-instance.ts index fd29d57963ed..c1bf3a1bc6bf 100644 --- a/packages/2-mongo-family/9-family/src/core/control-instance.ts +++ b/packages/2-mongo-family/9-family/src/core/control-instance.ts @@ -38,6 +38,7 @@ import type { import type { MongoControlExtensionDescriptor } from './control-types'; import { MongoContractSerializer } from './ir/mongo-contract-serializer'; import { mongoOperationsToPreview } from './operation-preview'; +import { mongoStorageNameOf } from './operation-storage-name'; import { mongoSchemaToView } from './schema-to-view'; import { verifyMongoSchema } from './schema-verify/verify-mongo-schema'; @@ -383,5 +384,9 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview { return mongoOperationsToPreview(operations); }, + + storageNameOf(operation: MigrationPlanOperation): string { + return mongoStorageNameOf(operation); + }, }; } diff --git a/packages/2-mongo-family/9-family/src/core/operation-storage-name.ts b/packages/2-mongo-family/9-family/src/core/operation-storage-name.ts new file mode 100644 index 000000000000..2c2157a04e07 --- /dev/null +++ b/packages/2-mongo-family/9-family/src/core/operation-storage-name.ts @@ -0,0 +1,24 @@ +import type { MigrationPlanOperation } from '@internal/framework-components/control'; + +function collectionOfStep(step: unknown): string | undefined { + if (typeof step !== 'object' || step === null) return undefined; + const command: unknown = Reflect.get(step, 'command'); + if (typeof command !== 'object' || command === null) return undefined; + const collection: unknown = Reflect.get(command, 'collection'); + return typeof collection === 'string' ? collection : undefined; +} + +/** + * The collection an operation's command names, as the database knows it: the name the Mongo + * planner gives a dropped collection. An operation whose commands name no collection is named by + * its id. + */ +export function mongoStorageNameOf(operation: MigrationPlanOperation): string { + const execute: unknown = Reflect.get(operation, 'execute'); + const steps: readonly unknown[] = Array.isArray(execute) ? execute : []; + for (const step of steps) { + const collection = collectionOfStep(step); + if (collection !== undefined) return collection; + } + return operation.id; +} diff --git a/packages/2-mongo-family/9-family/test/operation-storage-name.test.ts b/packages/2-mongo-family/9-family/test/operation-storage-name.test.ts new file mode 100644 index 000000000000..d92698fbea8b --- /dev/null +++ b/packages/2-mongo-family/9-family/test/operation-storage-name.test.ts @@ -0,0 +1,28 @@ +import type { MigrationPlanOperation } from '@internal/framework-components/control'; +import { DropCollectionCommand } from '@internal/mongo-query-ast/control'; +import { describe, expect, it } from 'vitest'; +import { mongoStorageNameOf } from '../src/core/operation-storage-name'; + +function operation(execute: readonly unknown[]): MigrationPlanOperation { + const op = { id: 'collection.events.drop', label: 'Drop collection events: all of it' }; + return { ...op, operationClass: 'destructive', execute } as MigrationPlanOperation; +} + +describe('mongoStorageNameOf', () => { + it('names the collection the command names, as the planner names a dropped collection', () => { + expect([ + mongoStorageNameOf( + operation([{ description: 'drop', command: new DropCollectionCommand('events') }]), + ), + mongoStorageNameOf( + operation([ + { description: 'drop', command: { kind: 'dropCollection', collection: 'logs' } }, + ]), + ), + ]).toEqual(['events', 'logs']); + }); + + it('names an operation whose commands name no collection by its id, never its label', () => { + expect(mongoStorageNameOf(operation([]))).toBe('collection.events.drop'); + }); +}); From 7df4d8ed51cfac0d7cf85f1ed3bbb1764f6f6c61 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:17:37 +0200 Subject: [PATCH 031/109] TML-3476: dispatch 3 brief, the delete text rule, and a test dispatch 5 owes Signed-off-by: willbot Signed-off-by: Will Madden --- .../03-refusal-on-migration-plan.md | 39 +++++++++++++++++++ .../slices/refusal/plan.md | 2 + .../slices/refusal/spec.md | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 projects/migration-statements/slices/refusal/dispatches/03-refusal-on-migration-plan.md diff --git a/projects/migration-statements/slices/refusal/dispatches/03-refusal-on-migration-plan.md b/projects/migration-statements/slices/refusal/dispatches/03-refusal-on-migration-plan.md new file mode 100644 index 000000000000..3b67a5248d0b --- /dev/null +++ b/projects/migration-statements/slices/refusal/dispatches/03-refusal-on-migration-plan.md @@ -0,0 +1,39 @@ +# Dispatch 3 — The engine pin, the verbs, and the refusal on `migration plan` + +**Slice:** [`../spec.md`](../spec.md) § "The structured refusal", § "`--delete` and how statements consent" · **Plan:** [`../plan.md`](../plan.md) dispatch 3 · **Builds on:** dispatch 2 and the engine change prisma/prisma-cli#337 + +## Outcome + +`migration plan` refuses every plan that would lose data until the user has said what each operation means, with the engine's statement prompt doing the asking: flags on the command line, one refusal that lists every unanswered operation when nobody can answer, and a typed answer when a human runs it. The blanket consent is gone from the command. + +## The engine + +- `@prisma/cli-engine` 0.7.0 (prisma/prisma-cli#337; ADR 0006 there; engine README § statements). Until it is published, pin the PR's preview build in `packages/1-framework/3-tooling/cli/package.json` (`https://pkg.pr.new/@prisma/cli-engine@337`, both the dependency and the peer) and say so in the commit; the pin is switched to `0.7.0` in its own commit when it is on the registry. The PR must not merge on the preview pin. +- A command declares `statements: { rename: { arity: 1, brief }, delete: { arity: 1, brief } }`. The engine parses those flags for that command, keeps the values in argv order on the run state, and never shows them to the handler. Remove the `--rename` flag declaration slice 1 added to `migration plan` (keeping it is a construction error). +- `ctx.prompt.statements(questions, { last: true })` returns `{ verb, text, values }` per question, answered from the flags first; non-interactively it throws one `CLI.CONSENT_REQUIRED` that lists every unanswered question with its flag forms; interactively it asks each in turn and re-asks on a rejected answer. `last: true` throws `CLI.CONSENT_UNUSED` for any leftover value before the command acts. +- The run's ordered statement values are the source of the statement list that slice 1 built from `args.flags.rename`; read them through the engine's API for that (see the engine README; the implementer finds the exact accessor). + +## What to build + +1. **Declare and read.** `migration plan` declares `rename` and `delete` with briefs. The statement list `{ verb, text }[]` is built from the engine's ordered values; `rename` entries resolve as in slice 1; `delete` entries are kept for matching. +2. **Plan, then ask.** Plan with the `rename` statements. For each `dataLoss` entry of the delta leg (and of the baseline leg in an auto-baseline), build one question: text from the operation's label plus what is lost (`Table "Legacy" would be dropped and its rows lost.`); `subject` written as the coordinate (`Legacy`, `User.name`, or the storage name, the `kind: 'storage'` case saying so in the question); `verbs: ['rename', 'delete']` for a model or field subject, `['delete']` for storage; `forms: { rename: ':' }`; `validate`: a `delete` answer must equal the subject; a `rename` answer must parse, resolve against the two contracts with the slice 1 resolver, and have its old side equal to the subject. Ask all questions in one `statements(..., { last: true })` call. Since the engine answers from flags before prompting, a `--delete Legacy` given up front is consumed here. +3. **Re-plan with new renames.** A `rename` answer that came from the prompt is a statement the plan did not have: append it, plan again, and expect no `dataLoss` entry for that subject; if one remains, fail with the new `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` (error reference entry). A `rename` answered from a flag was already in the statement list, so it was in the first plan; only a prompt answer triggers a re-plan. +4. **Write only when answered.** Nothing is written until every question is answered. The auto-baseline consent (`refuseUnconsentedDestructiveBaseline`, `consentToken`, `destructiveBaselineQuestion`, the `consent`/`planHash` round trip through `MigrationPlanOptions`) is removed; `MIGRATION.DESTRUCTIVE_CHANGES` and `MIGRATION.CONSENT_PLAN_MISMATCH` stop being raised by this command (keep the codes if `db update` still raises them until dispatch 4; retire them there). +5. **Output.** `Statements applied` lists `delete` lines (`delete model "Legacy" (1 operation)`) with the operation positions of the entry each consented to; JSON `appliedStatements` entries carry `verb`. `dataLoss` is not separately printed on success. The `⚠` marker on destructive operations stays. +6. **Docs.** The CLI README `migration plan` section: statements, the refusal, the interactive answer, `--confirm` no longer used; the error reference for `CLI.CONSENT_REQUIRED`/`CONSENT_UNUSED` as this command raises them (link the engine's entries) and the new code; the Migration System doc § Statements and § `migration plan`. + +## Not in this dispatch + +`db update` (dispatch 4); `allow`; journeys and upgrade fragments (dispatch 5); retiring the two old error codes from the errors package while `db update` still uses them. + +## Tests + +CLI tests through `createOrmTestCli` with the engine's scripted `answers` and `isTty` runtime options: refusal lists every operation with both flag forms; `--delete Legacy` writes the plan and reports the statement; `--rename Legacy:Archive` on the flag resolves the loss in the first plan; a typed `rename Legacy:Archive` re-plans and succeeds; a typed `delete`; a rejected typed answer is re-asked (clack) or fails (line reader, the test runtime); `--delete Nope` fails with `CLI.CONSENT_UNUSED` before any package is written; `--yes` does not answer; a storage subject offers only `delete`; an auto-baseline with a destructive delta asks before writing either package; the old `--confirm ` no longer consents (the engine still parses it; nothing asks). Update `migration-plan.test.ts` and `migration-plan-statements.test.ts` where they used the old consent. + +## Halt conditions + +Stop and report if the engine gives no way to read the ordered statement values from a handler; if `createOrmTestCli` cannot drive the engine's interactive path; or if the preview package cannot be installed with `pnpm install` in this workspace. + +## Gate + +Standard, plus `pnpm check:error-reference`, `pnpm lint:framework-vocabulary`, both rename-statements journeys, `test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts`, `cli-journeys/data-transform-strategies.e2e.test.ts` and `migration-plan-details.e2e.test.ts` (they plan drops without an auto-baseline and will now need statements or will assert the refusal). diff --git a/projects/migration-statements/slices/refusal/plan.md b/projects/migration-statements/slices/refusal/plan.md index 33b348ce51bf..0527309ed98b 100644 --- a/projects/migration-statements/slices/refusal/plan.md +++ b/projects/migration-statements/slices/refusal/plan.md @@ -50,4 +50,6 @@ Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Di ## Open items +- Dispatch 5 adds a planner test where a codec hook emits a destructive call, proving the hook-call-to-field match (by object identity) survives the control-policy partitions. + - Whether the Mongo planner's `statementRefused` for `rename` needs its wording changed now that `delete` exists: dispatch 3 reads the text and decides. diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md index f4856ce0b99d..3f2a2da19571 100644 --- a/projects/migration-statements/slices/refusal/spec.md +++ b/projects/migration-statements/slices/refusal/spec.md @@ -48,7 +48,7 @@ The CLI turns each entry into one engine question (`ctx.prompt.statements`): que ### `--delete` and how statements consent - `--delete ` is an engine verb flag (registered by the ORM command family together with `rename` and `allow`). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) consents to every destructive operation whose subject is in that namespace; the refusal still lists each model. -- A `delete` never reaches the planner. Planning runs first with the `rename` statements; the plan's `dataLoss` entries are then matched against the answers. `validate` for a `delete` answer checks the text equals the subject. `validate` for a `rename` answer resolves the statement with slice 1's resolver against the two contracts and accepts only when its old side is the subject. +- A `delete` never reaches the planner. Planning runs first with the `rename` statements; the plan's `dataLoss` entries are then matched against the answers. `validate` for a `delete` answer checks the text equals the subject; the text is any non-empty string, because a storage name may contain `:`. `validate` for a `rename` answer resolves the statement with slice 1's resolver against the two contracts and accepts only when its old side is the subject. - A `rename` answered at the prompt or on the command line that passes validation is added to the statement list and the plan is run again, exactly as if it had been typed on the command line; the second plan must have no `dataLoss` entry for that subject, else the run fails (`MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`). Order between a run's `--rename` and `--delete` values comes from the engine's ordered verb-flag list. - The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop: with no origin contract, `rename` statements fail with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and `delete` statements still work, because they are matched against the plan, not resolved against a contract. - `--confirm` is no longer asked for by either command. `guardDestructiveChanges`, the plan-hash consent round trip, `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` go, on both commands, including `migration plan`'s auto-baseline consent, which becomes the same per-operation refusal. The control API keeps `acceptDataLoss` as the programmatic equivalent and gains `statements` as the preferred form. From 0e98b77eb4b3b0981803fdbb7260a80fbe1eb55d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:28:23 +0200 Subject: [PATCH 032/109] TML-3476: pin the @prisma/cli-engine 0.7.0 preview build from prisma/prisma-cli#337 The engine's statement prompts and ctx.statements.take ship in 0.7.0, which is not on the registry yet. This pins the pkg.pr.new build of commit 2455811 of the pull request, through the CLI's dependency and a workspace override so every package runs the same engine, and raises the peer ranges to 0.7.0. The pull request must not merge on this pin: switch it to 0.7.0 from the registry when that is published. Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 1 + .../1-framework/3-tooling/cli/package.json | 4 +- .../cli/test/orm/init-inputs.test.ts | 6 + .../cli/test/orm/init-prisma7-fixtures.ts | 6 + .../3-tooling/config-loader/package.json | 2 +- .../@prisma/orm-toolchain/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 7 files changed, 78 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index e3e2b15d1280..a763ec1196f9 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ }, "pnpm": { "overrides": { + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@2455811", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/packages/1-framework/3-tooling/cli/package.json b/packages/1-framework/3-tooling/cli/package.json index ced7154e8ced..12df36fce7d6 100644 --- a/packages/1-framework/3-tooling/cli/package.json +++ b/packages/1-framework/3-tooling/cli/package.json @@ -52,7 +52,7 @@ "@internal/sql-contract-ts": "workspace:8.0.0-rc.16", "@internal/sql-operations": "workspace:8.0.0-rc.16", "@internal/sql-runtime": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@2455811", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", @@ -62,7 +62,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "typescript": ">=5.9" }, "peerDependenciesMeta": { diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-inputs.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-inputs.test.ts index e0ff35f52152..6f19681a893a 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-inputs.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-inputs.test.ts @@ -49,6 +49,12 @@ function recordingPrompt(): { readonly prompt: PromptSurface; readonly textCalls return opts?.default ?? ''; }, browserWait: async () => undefined, + statement: async () => { + throw new Error('unexpected statement prompt'); + }, + statements: async () => { + throw new Error('unexpected statement prompt'); + }, }; return { prompt, textCalls }; } diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-fixtures.ts b/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-fixtures.ts index 52dd4b2e8bac..baaaab0aed38 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-fixtures.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-prisma7-fixtures.ts @@ -72,6 +72,12 @@ export function scriptedPrompt(answers: Record = {}): { return opts?.default ?? ''; }, browserWait: async () => undefined, + statement: async () => { + throw new Error('unexpected statement prompt'); + }, + statements: async () => { + throw new Error('unexpected statement prompt'); + }, }; return { prompt, calls }; } diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index eeb6ec44e2d3..92c61bbf60c3 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -36,7 +36,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "typescript": ">=5.9" }, "peerDependenciesMeta": { diff --git a/packages/9-public/@prisma/orm-toolchain/package.json b/packages/9-public/@prisma/orm-toolchain/package.json index d08844035299..92dfb761b13f 100644 --- a/packages/9-public/@prisma/orm-toolchain/package.json +++ b/packages/9-public/@prisma/orm-toolchain/package.json @@ -49,7 +49,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "typescript": ">=5.9", "vite": "^7.0.0 || ^8.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2c087df3f0d..b426b9768f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,7 @@ catalogs: version: 1.0.12 overrides: + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@2455811 hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -201,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -238,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -293,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -345,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -388,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -434,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -471,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -517,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -569,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -633,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -682,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -734,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -798,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -904,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -953,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1375,6 +1376,9 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils + '@prisma/cli-engine': + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1427,9 +1431,6 @@ importers: '@internal/sql-runtime': specifier: workspace:8.0.0-rc.16 version: link:../../../2-sql/5-runtime - '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../../test/utils @@ -1533,6 +1534,9 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils + '@prisma/cli-engine': + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1546,9 +1550,6 @@ importers: specifier: 'catalog:' version: 0.2.17 devDependencies: - '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../../test/utils @@ -3246,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3334,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3416,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3792,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5333,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5479,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5497,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5684,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5804,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5879,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: 0.6.3 - version: 0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 + version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7661,8 +7662,9 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@0.6.3': - resolution: {integrity: sha512-rNPu6ulo72abHznDZ76sEa+mcPZ8k9tm5cQTwflb5EzEWq3f0T6/3NQ9GIJsNAX/oJK9H1pKl08tLgTjK8Vc7A==} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@2455811': + resolution: {integrity: sha512-KlmAHVzTLejKGicz9bHtuxM9Kz+ImXQuSYqmQ+FBVWIbB2/WVbk6g4ynb8n0B86xCo2jVRyHn67F2UEbu8aBsA==, tarball: https://pkg.pr.new/@prisma/cli-engine@2455811} + version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: '@prisma/management-api-sdk': ^1.55.0 @@ -13430,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@0.6.3(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From cc038007bf65367f3a280c21cfd264e34e5f2f15 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:41:45 +0200 Subject: [PATCH 033/109] TML-3476: migration plan asks what each operation that loses data means before writing migration plan declares the rename and delete statements. It takes the --rename values from the engine and plans with them, then asks one question per subject of the plan's dataLoss through ctx.prompt.statements with last: true: a --delete flag or a typed delete consents, a typed rename is planned again and must remove the loss, or the run fails with MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS. Nothing is written until every question is answered, an auto-baseline's baseline included. The auto-baseline consent, its plan-hash round trip and --confirm are gone from the command. Statements applied lists delete lines, and JSON appliedStatements carry verb. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migration-plan.ts | 338 +++++++++------- .../statements/data-loss-questions.ts | 126 ++++++ .../statements/report-applied-statements.ts | 53 ++- .../3-tooling/cli/src/orm/define-command.ts | 3 + .../3-tooling/cli/src/orm/migration/plan.ts | 152 +++----- .../3-tooling/cli/src/utils/cli-errors.ts | 21 + .../migration-plan-prologue.test.ts | 16 +- .../cli/test/orm/fixtures/offline-project.ts | 26 ++ .../cli/test/orm/migration-plan.test.ts | 362 ++++++++++++------ 9 files changed, 753 insertions(+), 344 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts 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 93d1310f6c96..414a3fd63a0b 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 @@ -9,6 +9,7 @@ import { createControlStack, hasOperationPreview, type MigrationPlanOperation, + type MigrationStatementSubject, type OperationPreview, planOriginOf, type ResolvedMigrationStatement, @@ -31,12 +32,11 @@ import { join, relative } from 'pathe'; import { type CliErrorConflict, CliStructuredError, - errorConsentPlanMismatch, errorContractValidationFailed, - errorDestructiveChanges, errorFileNotFound, errorMigrationPlanningFailed, errorPlanProducedNoOperations, + errorStatementDidNotResolveLoss, errorTargetMigrationNotSupported, type PlanDestination, type PlanLegOrigin, @@ -50,13 +50,20 @@ 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 AnswerDataLoss, + dataLossQuestion, + type PlannedDataLoss, + subjectText, +} from '../statements/data-loss-questions'; import { type AppliedStatementReport, reportAppliedStatements, + reportDeleteStatement, } 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 type { ControlClient } from '../types'; import { errorFromCaught } from './caught-errors'; import { buildContractSpaceAggregate, @@ -66,7 +73,6 @@ import { type ContractSpaceSeedPhaseRecord, runContractSpaceSeedPhase, } from './contract-space-seed-phase'; -import { computePlanHash } from './plan-identity'; import { resolveFromForPlan, resolveToForPlan } from './plan-resolution'; import { renderSnapshotDeclarations } from './snapshot-declarations'; @@ -84,36 +90,19 @@ export interface MigrationPlanOptions { 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. + * The rename 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; /** - * Consent to the auto-baseline plan a prior `MIGRATION.DESTRUCTIVE_CHANGES` - * refusal named by its `planHash`. The consented run recomputes the baseline - * plan and refuses with `MIGRATION.CONSENT_PLAN_MISMATCH` when it differs. + * Asks what each operation that would lose data means, before anything is + * written. A delete answer consents to the loss; a rename answer is planned + * again with the other renames and must remove the loss. */ - readonly consent?: { readonly planHash: string }; - /** - * Extension-space migration packages a refused first run of this same - * invocation already materialised. The consented re-run finds them on disk - * (its seed phase reports `unchanged`), so the caller threads them back in - * to keep `emittedExtensionDirs` and the summary describing the whole - * invocation, not just the second run. - */ - readonly carryEmittedExtensionDirs?: readonly { - readonly spaceId: string; - readonly dirName: string; - }[]; -} - -/** The verdict a `MIGRATION.DESTRUCTIVE_CHANGES` plan refusal carries in its meta. */ -export interface DestructiveBaselineVerdict { - readonly destructiveOperations: ReadonlyArray; - /** Content hash of the refused baseline plan; consent is granted against it. */ - readonly planHash: string; + readonly answerDataLoss: AnswerDataLoss; + /** Renders the declarations of the destination snapshot from its `contract.json`. */ + readonly client: Pick; } type PlannerSuccess = { @@ -121,6 +110,8 @@ type PlannerSuccess = { readonly migrationTsContent: string; readonly hasPlaceholders: boolean; readonly appliedStatements: readonly AppliedStatementReport[]; + /** The operations that would lose data, positioned in the plan's operations. */ + readonly dataLoss: readonly PlannedDataLoss[]; }; /** The origin of a plan from an empty database: no models, so no statement resolves. */ @@ -220,10 +211,16 @@ async function runPlannerLeg( } } + const labels = await operationLabels(plannerResult.plan.operations); return ok({ plannedOps, migrationTsContent: plannerResult.plan.renderTypeScript(resolveImportSpecifier), hasPlaceholders, + dataLoss: plannerResult.dataLoss.map(({ operationIndex, subject }) => ({ + operationIndex, + subject, + label: labels[operationIndex] ?? subjectText(subject, fromContract ?? EMPTY_ORIGIN), + })), appliedStatements: reportAppliedStatements( plannerResult.appliedStatements, fromContract, @@ -233,6 +230,14 @@ async function runPlannerLeg( }); } +/** The label of each operation of a plan, or `undefined` where an operation is an unfilled placeholder. */ +async function operationLabels( + operations: readonly (MigrationPlanOperation | Promise)[], +): Promise { + const settled = await Promise.allSettled(operations); + return settled.map((entry) => (entry.status === 'fulfilled' ? entry.value.label : undefined)); +} + /** * The operations a result lists. For a leg with unfilled placeholders these are the operations * that resolved; the placeholders themselves are not operations yet. @@ -264,51 +269,86 @@ async function writePlannedMigrationPackage( } /** - * The consent check for an auto-baseline write, mirroring `db update`'s - * destructive-changes refusal: a baseline leg carrying destructive operations - * is only written when the caller consents to that exact plan by its hash. - * Runs before the baseline and delta packages are written, so a refusal - * leaves the app-space migrations directory untouched (the extension seed - * phase runs earlier and unconditionally, as it does for no-op runs). - * A leg with unfilled placeholders is still checked over the operations - * that did resolve. Returns `null` when the write may proceed. + * Asks about every operation of the planned legs that would lose data, until each is answered, + * before anything is written. A delete answer consents to the loss of its subject. A rename typed + * at the prompt is a statement the plan did not have, so the delta is planned again with it, and + * the loss it answered must be gone. */ -function refuseUnconsentedDestructiveBaseline( - leg: PlannerSuccess, - baselineToHash: string, - consent: { readonly planHash: string } | undefined, -): CliStructuredError | null { - const ops = leg.plannedOps; - const destructiveOps = ops.filter((op) => op.operationClass === 'destructive'); - if (destructiveOps.length === 0) { - return null; - } - const planHash = computePlanHash({ - operations: ops.map((op) => ({ - id: op.id, - label: op.label, - operationClass: op.operationClass, - })), - destination: { storageHash: baselineToHash }, - }); - if (consent === undefined) { - const verdict: DestructiveBaselineVerdict = { - destructiveOperations: destructiveOps.map((op) => ({ id: op.id, label: op.label })), - planHash, - }; - return errorDestructiveChanges( - `The baseline migration contains ${destructiveOps.length} destructive operation(s) that require confirmation`, - { - why: 'The migrations directory is empty, so planning writes a baseline derived from the `db` ref — and that baseline contains operations that would remove data when the migration is applied.', - fix: 'Re-run `prisma migration plan` and type the project directory name when asked, or pass `--no-interactive --confirm ` where there is nobody to ask.', - meta: { ...verdict }, - }, +async function answerPlannedDataLoss(input: { + readonly baseline: PlannerSuccess | undefined; + readonly delta: PlannerSuccess | undefined; + readonly renames: readonly StatementText[]; + readonly origin: ContractWithDomain; + readonly destination: ContractWithDomain; + readonly answer: AnswerDataLoss; + readonly planDelta: ( + renames: readonly StatementText[], + ) => Promise>; +}): Promise< + Result< + { + readonly delta: PlannerSuccess | undefined; + readonly deletes: readonly AppliedStatementReport[]; + }, + CliStructuredError + > +> { + let delta = input.delta; + let renames = input.renames; + const consented = new Map(); + const textOf = (loss: PlannedDataLoss) => subjectText(loss.subject, input.origin); + // The first round asks even when nothing is lost, so a statement no question + // consumed is refused before anything is written. + for (let round = 0; ; round += 1) { + const losses = [...(input.baseline?.dataLoss ?? []), ...(delta?.dataLoss ?? [])]; + const unanswered = [ + ...new Map( + losses + .filter((loss) => !consented.has(textOf(loss))) + .map((loss) => [textOf(loss), loss] as const), + ).values(), + ]; + if (unanswered.length === 0 && round > 0) break; + const questions = unanswered.map((loss) => + dataLossQuestion(loss, { origin: input.origin, destination: input.destination, renames }), ); + const answers = await input.answer(questions); + const typedRenames: { readonly text: string; readonly subject: string }[] = []; + answers.forEach((answer, index) => { + const loss = unanswered[index]; + if (loss === undefined) return; + if (answer.verb === 'delete') consented.set(textOf(loss), loss.subject); + else typedRenames.push({ text: answer.text, subject: textOf(loss) }); + }); + if (typedRenames.length === 0) { + if (unanswered.length === 0) break; + continue; + } + renames = [...renames, ...typedRenames.map(({ text }) => ({ verb: 'rename' as const, text }))]; + const replanned = await input.planDelta(renames); + if (!replanned.ok) return replanned; + delta = replanned.value; + const stillLost = new Set(delta.dataLoss.map(textOf)); + const unresolved = typedRenames.find(({ subject }) => stillLost.has(subject)); + if (unresolved !== undefined) { + return notOk( + errorStatementDidNotResolveLoss( + { verb: 'rename', text: unresolved.text }, + unresolved.subject, + ), + ); + } } - if (consent.planHash !== planHash) { - return errorConsentPlanMismatch({ consentedPlanHash: consent.planHash, planHash }); - } - return null; + const deletes = [...consented].map(([text, subject]) => + reportDeleteStatement( + subject, + (delta?.dataLoss ?? []) + .filter((loss) => textOf(loss) === text) + .map(({ operationIndex }) => operationIndex), + input.origin, + ), + ); + return ok({ delta, deletes }); } export interface MigrationPlanResult { @@ -624,19 +664,9 @@ async function executeMigrationPlanCommandInner( for (const record of seedResult.seeded) { callbacks?.onSeeded?.(record); } - const seededThisRun = seedResult.seeded.flatMap((r) => + const emittedExtensionDirs = seedResult.seeded.flatMap((r) => r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })), ); - const carried = options.carryEmittedExtensionDirs ?? []; - const emittedExtensionDirs = [ - ...carried, - ...seededThisRun.filter( - (entry) => - !carried.some( - (prior) => prior.spaceId === entry.spaceId && prior.dirName === entry.dirName, - ), - ), - ]; // 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 @@ -709,6 +739,31 @@ async function executeMigrationPlanCommandInner( hash: toStorageHash, isEmitted: options.to === undefined, }; + const answerDataLoss = ( + baseline: PlannerSuccess | undefined, + delta: PlannerSuccess | undefined, + planDelta: ( + resolved: readonly ResolvedMigrationStatement[], + ) => Promise>, + ) => + answerPlannedDataLoss({ + baseline, + delta, + renames: statementTexts, + origin: fromContract ?? EMPTY_ORIGIN, + destination: toContract, + answer: options.answerDataLoss, + planDelta: async (renames) => { + const resolved = resolveStatements({ + statements: renames, + origin: { kind: 'contract', contract: fromContract ?? EMPTY_ORIGIN }, + destination: toContract, + }); + if (!resolved.ok) return resolved; + const leg = await planDelta(resolved.value); + return leg.ok ? leg : notOk(leg.failure.error); + }, + }); if (isAutoBaseline && fromHash !== null && fromContract !== null && fromContractInStore) { const deltaTimestamp = new Date(); @@ -736,37 +791,46 @@ async function executeMigrationPlanCommandInner( return notOk(baselineLeg.failure.error); } - const consentFailure = refuseUnconsentedDestructiveBaseline( + const planBaselineDelta = (resolved: readonly ResolvedMigrationStatement[]) => + runPlannerLeg( + planner, + migrations, + frameworkComponents, + aggregate.app.contract(), + fromOrigin, + planDestination, + resolved, + false, + aggregate.app.spaceId, + aggregate, + snapshotsImportPathFrom(deltaPackageDir, migrationsDir), + resolveImportSpecifier, + ); + const plannedDelta = + fromHash === toStorageHash ? undefined : await planBaselineDelta(statements); + // 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 ( + plannedDelta !== undefined && + !plannedDelta.ok && + plannedDelta.failure.reason === 'refused' + ) { + return notOk(plannedDelta.failure.error); + } + + const answered = await answerDataLoss( baselineLeg.value, - fromHash, - options.consent, + plannedDelta?.ok === true ? plannedDelta.value : undefined, + planBaselineDelta, ); - if (consentFailure !== null) { - return notOk(consentFailure); + if (!answered.ok) { + return notOk(answered.failure); } - 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); - } + plannedDelta === undefined || !plannedDelta.ok || answered.value.delta === undefined + ? plannedDelta + : ok(answered.value.delta); + const deletes = answered.value.deletes; await writePlannedMigrationPackage( baselinePackageDir, @@ -801,7 +865,7 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, - appliedStatements: statementsWithoutOperations, + appliedStatements: [...statementsWithoutOperations, ...deletes], summary: 'Planned baseline with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -826,7 +890,7 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), ...(warnings.length > 0 ? { warnings } : {}), - appliedStatements: statementsWithoutOperations, + appliedStatements: [...statementsWithoutOperations, ...deletes], summary: buildAutoBaselinePlanSummary(baselineOps.length, 0, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; @@ -857,7 +921,7 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, - appliedStatements: deltaLeg.value.appliedStatements, + appliedStatements: [...deltaLeg.value.appliedStatements, ...deletes], summary: 'Planned baseline + migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -890,7 +954,7 @@ async function executeMigrationPlanCommandInner( emittedExtensionDirs, ...(preview !== undefined ? { preview } : {}), ...(warnings.length > 0 ? { warnings } : {}), - appliedStatements: deltaLeg.value.appliedStatements, + appliedStatements: [...deltaLeg.value.appliedStatements, ...deletes], summary: buildAutoBaselinePlanSummary( baselineOps.length, deltaOps.length, @@ -906,23 +970,31 @@ async function executeMigrationPlanCommandInner( const dirName = formatMigrationDirName(timestamp, slug); const packageDir = join(appMigrationsDir, dirName); - const deltaLeg = await runPlannerLeg( - planner, - migrations, - frameworkComponents, - 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.error); + const planDelta = (resolved: readonly ResolvedMigrationStatement[]) => + runPlannerLeg( + planner, + migrations, + frameworkComponents, + aggregate.app.contract(), + fromOrigin, + planDestination, + resolved, + resolved.length > 0 && fromHash === toStorageHash, + aggregate.app.spaceId, + aggregate, + snapshotsImportPathFrom(packageDir, migrationsDir), + resolveImportSpecifier, + ); + const plannedDelta = await planDelta(statements); + if (!plannedDelta.ok) { + return notOk(plannedDelta.failure.error); + } + const answered = await answerDataLoss(undefined, plannedDelta.value, planDelta); + if (!answered.ok) { + return notOk(answered.failure); } + const deltaLeg = ok(answered.value.delta ?? plannedDelta.value); + const deletes = answered.value.deletes; if (!deltaLeg.value.hasPlaceholders && deltaLeg.value.plannedOps.length === 0) { const result: MigrationPlanResult = { @@ -933,7 +1005,7 @@ async function executeMigrationPlanCommandInner( operations: [], emittedExtensionDirs, ...(warnings.length > 0 ? { warnings } : {}), - appliedStatements: deltaLeg.value.appliedStatements, + appliedStatements: [...deltaLeg.value.appliedStatements, ...deletes], summary: 'No changes to plan: the statements need no operations', timings: { total: Date.now() - startTime }, }; @@ -961,7 +1033,7 @@ async function executeMigrationPlanCommandInner( ...(warnings.length > 0 ? { warnings } : {}), pendingPlaceholders: true, ...(fromDefaulted ? { fromDefaulted } : {}), - appliedStatements: deltaLeg.value.appliedStatements, + appliedStatements: [...deltaLeg.value.appliedStatements, ...deletes], summary: 'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit', timings: { total: Date.now() - startTime }, @@ -988,7 +1060,7 @@ async function executeMigrationPlanCommandInner( ...(preview !== undefined ? { preview } : {}), ...(fromDefaulted ? { fromDefaulted } : {}), ...(warnings.length > 0 ? { warnings } : {}), - appliedStatements: deltaLeg.value.appliedStatements, + appliedStatements: [...deltaLeg.value.appliedStatements, ...deletes], summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length), timings: { total: Date.now() - startTime }, }; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts new file mode 100644 index 000000000000..7ccd46b2f105 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -0,0 +1,126 @@ +import type { ContractWithDomain } from '@internal/contract/types'; +import { + type MigrationStatementSubject, + type ModelCoordinate, + modelDisplayName, + type ResolvedMigrationStatement, +} from '@internal/framework-components/control'; +import { resolveStatements } from './resolve-statements'; +import type { StatementText } from './statement-text'; + +/** A verb a data-loss question accepts. */ +export type DataLossVerb = 'rename' | 'delete'; + +/** + * What a command asks about one operation that would lose data: the question, its subject written + * as the statement names it, and the verbs that answer it. A command hands it to its prompt. + */ +export interface DataLossQuestion { + readonly question: string; + readonly subject: string; + readonly verbs: readonly DataLossVerb[]; + readonly forms: { readonly rename?: string }; + readonly validate: (verb: DataLossVerb, text: string) => string | undefined; +} + +export interface DataLossAnswer { + readonly verb: DataLossVerb; + readonly text: string; +} + +/** Asks every question at once and returns the answers in question order. */ +export type AnswerDataLoss = ( + questions: readonly DataLossQuestion[], +) => Promise; + +/** An operation of a plan that would lose data, with the label of that operation. */ +export interface PlannedDataLoss { + readonly operationIndex: number; + readonly label: string; + readonly subject: MigrationStatementSubject; +} + +function modelText(contract: ContractWithDomain, coordinate: ModelCoordinate): string { + return Object.keys(contract.domain.namespaces).length > 1 + ? modelDisplayName(coordinate) + : coordinate.model; +} + +/** + * The subject as a statement names it: `Model` or `Model.field`, with the namespace when the origin + * contract has several, or the storage name when no model stores what the operation loses. + */ +export function subjectText( + subject: MigrationStatementSubject, + origin: ContractWithDomain, +): string { + switch (subject.kind) { + case 'model': + return modelText(origin, subject); + case 'field': + return `${modelText(origin, subject)}.${subject.field}`; + case 'storage': + return subject.name; + } +} + +function lossText(subject: MigrationStatementSubject, text: string): string { + switch (subject.kind) { + case 'model': + return `would lose the data of model "${text}"`; + case 'field': + return `would lose the values of field "${text}"`; + case 'storage': + return `would lose the data in "${text}", which no model of the origin contract stores`; + } +} + +function sameCoordinate( + statement: ResolvedMigrationStatement, + subject: MigrationStatementSubject, +): boolean { + if (subject.kind === 'storage') return false; + const { from } = statement; + if (from.namespaceId !== subject.namespaceId || from.model !== subject.model) return false; + return subject.kind === 'field' + ? statement.entity === 'field' && statement.from.field === subject.field + : statement.entity === 'model'; +} + +/** + * The question for one operation that would lose data. A model or field may be renamed instead of + * deleted; a rename is resolved against the two contracts after the plan's other renames, and its + * old name must be the subject. Data no model stores can only be deleted. + */ +export function dataLossQuestion( + loss: PlannedDataLoss, + contracts: { + readonly origin: ContractWithDomain; + readonly destination: ContractWithDomain; + readonly renames: readonly StatementText[]; + }, +): DataLossQuestion { + const subject = subjectText(loss.subject, contracts.origin); + const renamable = loss.subject.kind !== 'storage'; + return { + question: `${loss.label} ${lossText(loss.subject, subject)}.`, + subject, + verbs: renamable ? ['rename', 'delete'] : ['delete'], + forms: renamable ? { rename: `${subject}:` } : {}, + validate: (verb, text) => { + if (verb === 'delete') { + return text === subject ? undefined : `Delete names "${subject}": --delete ${subject}.`; + } + const resolved = resolveStatements({ + statements: [...contracts.renames, { verb: 'rename', text }], + origin: { kind: 'contract', contract: contracts.origin }, + destination: contracts.destination, + }); + if (!resolved.ok) return `${resolved.failure.message}. ${resolved.failure.why ?? ''}`.trim(); + const statement = resolved.value.at(-1); + return statement !== undefined && sameCoordinate(statement, loss.subject) + ? undefined + : `The rename's old name is not "${subject}". Write it as ${subject}:.`; + }, + }; +} 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 index 101143dbcaf9..c65ccf7f7e6c 100644 --- 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 @@ -3,19 +3,37 @@ import { type AppliedMigrationStatement, describeMigrationStatement, type MigrationStatementJson, + type MigrationStatementSubject, + type MigrationStatementSubjectJson, migrationStatementJson, + migrationStatementSubjectJson, } from '@internal/framework-components/control'; +/** A delete statement in JSON output: the subject whose data it consents to lose. */ +export interface DeleteStatementJson { + readonly kind: 'delete'; + readonly subject: MigrationStatementSubjectJson; +} + /** - * 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. + * A statement a plan applied, as the CLI reports it: its verb, 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; -} +export type AppliedStatementReport = + | { + readonly verb: 'rename'; + readonly statement: MigrationStatementJson; + readonly operationIndexes: readonly number[]; + /** The statement in domain names, for example `rename model "Profile" to "User"`. */ + readonly description: string; + } + | { + readonly verb: 'delete'; + readonly statement: DeleteStatementJson; + readonly operationIndexes: readonly number[]; + /** The statement in domain names, for example `delete model "Legacy"`. */ + readonly description: string; + }; /** * `operationOffset` is the number of operations the result lists before the plan that applied the @@ -28,8 +46,27 @@ export function reportAppliedStatements( operationOffset: number, ): readonly AppliedStatementReport[] { return applied.map((entry) => ({ + verb: 'rename', statement: migrationStatementJson(entry.statement), operationIndexes: entry.operationIndexes.map((index) => operationOffset + index), description: describeMigrationStatement(entry.statement, fromContract ?? contract, contract), })); } + +/** A delete statement a plan applied: the subject it consents to lose, and that loss's operations. */ +export function reportDeleteStatement( + subject: MigrationStatementSubject, + operationIndexes: readonly number[], + fromContract: ContractWithDomain, +): AppliedStatementReport { + return { + verb: 'delete', + statement: { kind: 'delete', subject: migrationStatementSubjectJson(subject) }, + operationIndexes, + description: describeMigrationStatement( + { kind: 'delete', subject }, + fromContract, + fromContract, + ), + }; +} diff --git a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts index 1d996d0d5819..1731289790ad 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/define-command.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/define-command.ts @@ -8,6 +8,7 @@ import type { NeedsSpec, PositionalSpec, SpawnDeclarations, + StatementSpec, } from '@prisma/cli-engine'; import { defineCommand } from '@prisma/cli-engine'; import { notOk } from '@prisma/cli-engine/protocol'; @@ -44,6 +45,8 @@ export function defineOrmCommand< readonly needs?: NeedsSpec; readonly exitCodes?: Readonly>; readonly installsPackages?: TInstallsPackages; + /** The statement verbs the command may ask with; see the engine README § Statement prompts. */ + readonly statements?: Readonly>; readonly handler: Handler; } & SpawnDeclarations, ): CommandDefinition { 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 c5459f841ec1..b65e588e5fa4 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 @@ -1,5 +1,4 @@ import { ormConfigSection } from '@internal/config-loader'; -import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text, TreeNode } from '@prisma/cli-engine'; import { flag } from '@prisma/cli-engine'; @@ -8,23 +7,20 @@ import { notOk, ok } from '@prisma/cli-engine/protocol'; import { join } from 'pathe'; import { createControlClient } from '../../control-api/client'; import type { ContractSpaceSeedPhaseRecord } from '../../control-api/operations/contract-space-seed-phase'; -import type { - DestructiveBaselineVerdict, - MigrationPlanResult, -} from '../../control-api/operations/migration-plan'; +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 type { + DataLossAnswer, + DataLossQuestion, +} from '../../control-api/statements/data-loss-questions'; +import type { CreateControlClient } from '../../control-api/types'; import { RECORDED_CONTRACT_REF_FORMS, RECORDED_OR_EMPTY_CONTRACT_REF_FORMS, } from '../../utils/contract-ref-forms'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; -import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; import { defineOrmCommand } from '../define-command'; -import { consentToken } from '../init-inputs'; import { normalizeError } from '../normalize-error'; import { appliedStatementBlocks } from '../statement-blocks'; import { @@ -262,14 +258,6 @@ function planPresentations(inputs: { }; } -/** The question the user answers before a destructive baseline is written. */ -function destructiveBaselineQuestion(operations: readonly DestructivePlanOperation[]): string { - return [ - `Write a baseline migration containing ${operations.length} destructive operation(s)? Applying it would remove data that cannot be recovered:`, - destructiveOperationList(operations), - ].join('\n'); -} - export function createMigrationPlanCommand(createClient: CreateControlClient) { return defineOrmCommand({ help: { @@ -278,15 +266,16 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { 'Compares the emitted contract against the latest on-disk migration state\n' + 'and produces a new migration package with the required operations.\n' + 'On an empty migrations directory a baseline package is derived from the\n' + - '`db` ref first; a baseline containing destructive operations is only\n' + - 'written with your consent: the command asks you to type the project\n' + - 'directory name, or takes `--confirm ` where there is nobody\n' + - 'to ask. Offline — does not consult the database.', + '`db` ref first. A plan that would lose data is written only once you say\n' + + 'what each such operation means: --rename to keep the data under a new\n' + + 'name, or --delete to let it go. Where nobody can answer, the command\n' + + 'refuses and lists every question. Offline — does not consult the database.', examples: [ 'migration plan', // biome-ignore lint/plugin/no-family-vocabulary: a migration slug a user would plausibly type, not a schema concept 'migration plan --name add-users-table', 'migration plan --to ^ --name rollback', + 'migration plan --rename Profile:User --delete Legacy', 'migration plan --json', ], }, @@ -301,26 +290,26 @@ 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', - }), + }, + }, + statements: { + rename: { + arity: 1, + brief: + 'Rename a model or field instead of dropping it: old:new, each side Model, namespace.Model, Model.field or namespace.Model.field; repeat for several, applied in order', + }, + delete: { + arity: 1, + brief: + 'Let the plan lose the data of a model, field or storage name the refusal lists: --delete Model', }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - // Dirs the seed phase materialised across this invocation's run(s): the - // consented re-run finds them already on disk, so its own seed records - // come back `unchanged` and the accumulated list is threaded back in. - const seededDirs: { spaceId: string; dirName: string }[] = []; const seeded = (record: ContractSpaceSeedPhaseRecord): void => { if (record.action !== 'updated') { return; } - for (const dirName of record.newMigrationDirs) { - seededDirs.push({ spaceId: record.spaceId, dirName }); - } const step = `Seed contract space ${record.spaceId}`; ctx.report({ kind: 'step-started', step, id: record.spaceId }); ctx.report({ @@ -331,66 +320,45 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { data: { newHash: record.newHash, newMigrationDirs: record.newMigrationDirs }, }); }; - - const plan = (consent?: { readonly planHash: string }) => - executeMigrationPlanCommand( - { - config: ctx.config, - cwd: ctx.cwd, - projectDir: baseDirFor(ctx.config), - ...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', - consent !== undefined && seededDirs.length > 0 ? [...seededDirs] : undefined, - ), - client: createClient({ - family: ctx.config.family, - target: ctx.config.target, - adapter: ctx.config.adapter, - ...ifDefined('driver', ctx.config.driver), - extensions: ctx.config.extensions ?? [], - }), - }, - Date.now(), - { onSeeded: seeded }, + const answerDataLoss = async ( + questions: readonly DataLossQuestion[], + ): Promise => { + const answers = await ctx.prompt.statements( + questions.map((question) => ({ + question: question.question, + subject: question.subject, + verbs: question.verbs, + forms: question.forms, + validate: question.validate, + })), + { last: true }, ); + return answers.map(({ verb, text }) => ({ verb, text })); + }; - let planned = await plan(); - // The destructive verdict is the planner's own: an auto-baseline whose - // operations would remove data is refused before anything is written. - // Consent is asked for here and the plan re-run carrying the refused - // plan's hash — the operation layer refuses if the recomputed baseline is - // no longer that plan. Mirrors the `db update` consent flow. - if (!planned.ok && planned.failure.code === ERROR_CODE_DESTRUCTIVE_CHANGES) { - const verdict = blindCast< - Partial, - 'the meta envelope is produced by refuseUnconsentedDestructiveBaseline; presence is checked below' - >(planned.failure.meta ?? {}); - if ( - verdict.destructiveOperations === undefined || - verdict.destructiveOperations.length === 0 || - verdict.planHash === undefined - ) { - return notOk( - normalizeError( - errorConsentOperationsMissing({ previewCommand: '{bin} migration plan' }), - ), - ); - } - const token = consentToken(ctx.cwd); - const granted = await ctx.prompt.consent( - destructiveBaselineQuestion(verdict.destructiveOperations), - { token }, - ); - if (!granted) { - return notOk(normalizeError(planned.failure)); - } - planned = await plan({ planHash: verdict.planHash }); - } + const planned = await executeMigrationPlanCommand( + { + config: ctx.config, + cwd: ctx.cwd, + projectDir: baseDirFor(ctx.config), + ...ifDefined('name', args.flags.name), + ...ifDefined('from', args.flags.from), + ...ifDefined('to', args.flags.to), + statements: ctx.statements + .take('rename') + .map(({ text }) => ({ verb: 'rename' as const, text })), + answerDataLoss, + client: createClient({ + family: ctx.config.family, + target: ctx.config.target, + adapter: ctx.config.adapter, + ...ifDefined('driver', ctx.config.driver), + extensions: ctx.config.extensions ?? [], + }), + }, + Date.now(), + { onSeeded: seeded }, + ); if (!planned.ok) { return notOk(normalizeError(planned.failure)); } 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 0e639b997daa..552897aaf1a4 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 @@ -612,6 +612,27 @@ export function errorStatementUnresolved( ); } +/** + * A rename typed in answer to a data-loss question was planned, and the plan still loses the + * subject's data: the rename does not account for the operation the question was about. + */ +export function errorStatementDidNotResolveLoss( + statement: WrittenStatement, + subject: string, +): ActionableCliError { + const fix = `Answer the question about "${subject}" with --delete ${subject} if its data may be lost, or give a rename whose old name stores that data.`; + return new ActionableCliError( + 'MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS', + `"--${statement.verb} ${statement.text}" does not keep the data of "${subject}"`, + { + why: `The plan was made again with the rename, and it still loses the data of "${subject}".`, + fix, + nextActions: [chooseAction(fix)], + meta: { statement: statement.text, verb: statement.verb, subject }, + }, + ); +} + /** * 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. diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts index 963d6fd11be6..8250264d404a 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts @@ -37,7 +37,13 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { ); const result = await executeMigrationPlanCommand( - { config, cwd: '/project', projectDir: '/project', client: stubClient }, + { + config, + cwd: '/project', + projectDir: '/project', + client: stubClient, + answerDataLoss: async () => [], + }, Date.now(), ); @@ -59,7 +65,13 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { ); const result = await executeMigrationPlanCommand( - { config, cwd: '/project', projectDir: '/project', client: stubClient }, + { + config, + cwd: '/project', + projectDir: '/project', + client: stubClient, + answerDataLoss: async () => [], + }, Date.now(), ); 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 cc18987c85a6..6caccb6f8f9a 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 @@ -180,6 +180,26 @@ export interface FakePlannerScript { readonly statementsReceived?: unknown[][]; /** Fails every `plan` call that is given statements, as a planner that refuses them does. */ readonly refuseStatements?: boolean; + /** What every `plan` call reports would lose data. */ + readonly dataLoss?: readonly ScriptedDataLoss[]; + /** What each successive `plan` call reports would lose data; overrides `dataLoss`. */ + readonly dataLossByPlan?: ReadonlyArray; + /** Reports `dataLoss` only from a `plan` call given no statements, as a rename removes a loss. */ + readonly statementsResolveDataLoss?: boolean; +} + +/** A `dataLoss` entry of a scripted plan: the position of an operation and what it loses. */ +export interface ScriptedDataLoss { + readonly operationIndex: number; + readonly subject: + | { readonly kind: 'model'; readonly namespaceId: string; readonly model: string } + | { + readonly kind: 'field'; + readonly namespaceId: string; + readonly model: string; + readonly field: string; + } + | { readonly kind: 'storage'; readonly name: string }; } function fakePlanner(script: FakePlannerScript): Record { @@ -191,6 +211,10 @@ function fakePlanner(script: FakePlannerScript): Record { throw script.throwOnPlan; } const operations = script.operationsByPlan?.[planCalls] ?? script.operations; + const dataLoss = + script.statementsResolveDataLoss === true && options.statements.length > 0 + ? [] + : (script.dataLossByPlan?.[planCalls] ?? script.dataLoss ?? []); planCalls += 1; const [refused] = script.refuseStatements === true ? options.statements : []; if (refused !== undefined) { @@ -202,6 +226,8 @@ function fakePlanner(script: FakePlannerScript): Record { return script.conflicts === undefined ? { kind: 'success', + dataLoss, + accessWidening: [], appliedStatements: options.statements.map((statement) => ({ statement, operationIndexes: (operations ?? [ADDITIVE_OP]).map((_, index) => index), diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 76d752f1c243..714451375e74 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -422,163 +422,307 @@ describe('migration plan', () => { }); }); - describe('auto-baseline consent', () => { - /** An empty graph whose db ref demands a destructive baseline. */ - async function destructiveBaselineProject(): Promise { - const project = await createOfflineProject({ storageHash: HASH_TO }); - await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: HASH_FROM }); + describe('data loss', () => { + const LEGACY_LOSS = { + operationIndex: 1, + subject: { kind: 'model', namespaceId: 'app', model: 'Legacy' }, + } as const; + const AUDIT_LOSS = { + operationIndex: 2, + subject: { kind: 'storage', name: 'audit_log' }, + } as const; + const DROP_AUDIT_OP = { + ...DESTRUCTIVE_OP, + id: 'table.drop_audit', + label: 'Drop table "audit_log"', + }; + const losingScript: FakePlannerScript = { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + dataLoss: [LEGACY_LOSS], + }; + + /** A project whose database sits at HASH_FROM, with models Legacy and User, and whose contract is HASH_TO. */ + async function losingProject(): Promise { + const project = await createOfflineProject({ + storageHash: HASH_TO, + models: ['User', 'Archive'], + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_FROM, + models: ['User', 'Legacy'], + }); await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); return project; } - const destructiveScript = { operations: [ADDITIVE_OP, DESTRUCTIVE_OP] } as const; - - it('refuses non-interactively without --confirm and writes nothing', async () => { - const project = await destructiveBaselineProject(); - - const run = await harness(project, { script: destructiveScript }).run( - ['migration', 'plan', '--json'], - { cwd: project.dir }, - ); - expect(run.exitCode).toBe(2); - const terminal = run.json.at(-1); - const envelope = - terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; - expect(envelope).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_REQUIRED', meta: { consentToken: basename(project.dir) } }, + /** An empty graph whose db ref demands a baseline, then a delta that loses data. */ + async function losingBaselineProject(): Promise { + const project = await createOfflineProject({ + storageHash: HASH_TO, + models: ['User', 'Archive'], }); - expect(await plannedDirs(project)).toEqual([]); - }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_FROM, + models: ['User', 'Legacy'], + }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + return project; + } - it('names every destructive operation in the question', async () => { - const project = await destructiveBaselineProject(); + function envelopeOf(run: { readonly json: readonly unknown[] }) { + const terminal = run.json.at(-1) as + | { readonly kind: string; readonly envelope?: unknown } + | undefined; + return terminal?.kind === 'result' ? terminal.envelope : undefined; + } - const run = await harness(project, { script: destructiveScript }).run(['migration', 'plan'], { - cwd: project.dir, - isTty: { stdin: true, stdout: true, stderr: true }, - stdin: `${basename(project.dir)}\n`, - }); + it('refuses where nobody can answer, listing every operation with the flags that answer it', async () => { + const project = await losingProject(); + + const run = await harness(project, { + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP, DROP_AUDIT_OP], + dataLoss: [LEGACY_LOSS, AUDIT_LOSS], + }, + }).run(['migration', 'plan', '--json'], { cwd: project.dir }); - expect(run.stderr).toContain('Drop table "legacy"'); + expect(run.exitCode).toBe(2); + const envelope = envelopeOf(run) as { + readonly error: { readonly code: string; readonly nextActions: readonly unknown[] }; + }; + expect(envelope.error.code).toBe('CLI.CONSENT_REQUIRED'); + const actions = JSON.stringify(envelope.error.nextActions); + expect(actions).toContain('--delete Legacy'); + expect(actions).toContain('--rename Legacy:'); + expect(actions).toContain('--delete audit_log'); + expect(actions).not.toContain('--rename audit_log'); + expect(JSON.stringify(envelope)).toContain( + 'Drop table \\"legacy\\" would lose the data of model \\"Legacy\\".', + ); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); }); - it('writes the baseline once consent is typed', async () => { - const project = await destructiveBaselineProject(); + it('writes the plan once --delete names the subject, and reports the statement', async () => { + const project = await losingProject(); - const run = await harness(project, { script: destructiveScript }).run( - ['migration', 'plan', '--name', 'delta', '--json'], - { cwd: project.dir, isTty: { stdin: true }, answers: [basename(project.dir)] }, + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--delete', 'Legacy', '--json'], + { cwd: project.dir }, ); - const dirs = await plannedDirs(project); expect(run.exitCode).toBe(0); - expect(dirs.map((entry) => entry.replace(/^\d+T\d+_/, ''))).toEqual(['baseline', 'delta']); + expect(run.presented?.data).toMatchObject({ + appliedStatements: [ + { + verb: 'delete', + statement: { + kind: 'delete', + subject: { kind: 'model', namespaceId: 'app', model: 'Legacy' }, + }, + operationIndexes: [1], + description: 'delete model "Legacy"', + }, + ], + }); + expect((await plannedDirs(project)).length).toBe(2); }); - it('writes the baseline when --confirm carries the project directory name', async () => { - const project = await destructiveBaselineProject(); + it('lists the delete under Statements applied, and keeps the marker on the operation', async () => { + const project = await losingProject(); - const run = await harness(project, { script: destructiveScript }).run( - ['migration', 'plan', '--confirm', basename(project.dir), '--json'], - { cwd: project.dir }, + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--delete', 'Legacy'], + { cwd: project.dir, isTty: { stdout: true } }, ); - expect(run.exitCode).toBe(0); - expect((await plannedDirs(project)).length).toBe(2); + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'Statements applied', + children: [{ label: 'delete model "Legacy" (1 operation)' }], + }, + ], + }); + expect(JSON.stringify(run.presented?.presentation.human)).toContain( + '{"label":"Drop table \\"legacy\\"","status":"warn"}', + ); }); - it('still asks when the destructive baseline also carries a placeholder', async () => { - const project = await destructiveBaselineProject(); + it('plans a --rename from the flag in the first plan, which then loses nothing', async () => { + const project = await losingProject(); + const statementsReceived: unknown[][] = []; const run = await harness(project, { - script: { - operations: [ADDITIVE_OP, DESTRUCTIVE_OP], - throwOnOperations: errorUnfilledPlaceholder('backfill'), - }, - }).run(['migration', 'plan', '--json'], { cwd: project.dir }); + script: { ...losingScript, statementsResolveDataLoss: true, statementsReceived }, + }).run(['migration', 'plan', '--rename', 'Legacy:Archive', '--json'], { cwd: project.dir }); - expect(run.exitCode).toBe(2); - const terminal = run.json.at(-1); - const envelope = - terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; - expect(envelope).toMatchObject({ ok: false, error: { code: 'CLI.CONSENT_REQUIRED' } }); - expect(await plannedDirs(project)).toEqual([]); + expect(run.exitCode).toBe(0); + expect(statementsReceived).toEqual([ + [ + { + kind: 'rename', + entity: 'model', + from: { namespaceId: 'app', model: 'Legacy' }, + to: { namespaceId: 'app', model: 'Archive' }, + }, + ], + ]); }); - it('writes the placeholder baseline once --confirm grants consent', async () => { - const project = await destructiveBaselineProject(); + it('plans again with a rename typed at the prompt, and writes the plan', async () => { + const project = await losingProject(); + const statementsReceived: unknown[][] = []; const run = await harness(project, { - script: { - operations: [ADDITIVE_OP, DESTRUCTIVE_OP], - throwOnOperations: errorUnfilledPlaceholder('backfill'), - }, - }).run(['migration', 'plan', '--confirm', basename(project.dir), '--json'], { + script: { ...losingScript, statementsResolveDataLoss: true, statementsReceived }, + }).run(['migration', 'plan', '--json'], { cwd: project.dir, + isTty: { stdin: true }, + answers: ['rename Legacy:Archive'], }); expect(run.exitCode).toBe(0); - expect(run.presented?.data).toMatchObject({ pendingPlaceholders: true }); + expect(statementsReceived.map((statements) => statements.length)).toEqual([0, 1]); + expect(run.presented?.data).toMatchObject({ + appliedStatements: [{ verb: 'rename', description: 'rename model "Legacy" to "Archive"' }], + }); expect((await plannedDirs(project)).length).toBe(2); }); - it('reports extension dirs the refused first run seeded once consent is granted', async () => { - const EXT_HASH = `f00d${'5'.repeat(60)}`; - const extMetadataBase = { - from: null, - to: EXT_HASH, - providedInvariants: [], - createdAt: '2026-01-01T00:00:00.000Z', - }; - const project = await destructiveBaselineProject(); + it('fails when a typed rename does not remove the loss it answered', async () => { + const project = await losingProject(); - const run = await harness(project, { - script: destructiveScript, - overrides: { - extensions: [ - { - kind: 'extension', - id: 'cipherstash', - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: () => ({}), - contractSpace: { - contractJson: contractJson(EXT_HASH), - headRef: { hash: EXT_HASH, invariants: [] }, - migrations: [ - { - dirName: '0001_seed', - metadata: { - ...extMetadataBase, - migrationHash: computeMigrationHash(extMetadataBase, []), - }, - ops: [], - }, - ], - }, - }, - ], + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--json'], + { + cwd: project.dir, + isTty: { stdin: true }, + answers: ['rename Legacy:Archive'], + }, + ); + + expect(run.exitCode).not.toBe(0); + expect(envelopeOf(run)).toMatchObject({ + error: { + code: 'MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS', + meta: { statement: 'Legacy:Archive', subject: 'Legacy' }, }, - }).run(['migration', 'plan', '--confirm', basename(project.dir), '--json'], { - cwd: project.dir, }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('writes the plan when delete is typed at the prompt', async () => { + const project = await losingProject(); + + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--json'], + { + cwd: project.dir, + isTty: { stdin: true }, + answers: ['delete'], + }, + ); expect(run.exitCode).toBe(0); expect(run.presented?.data).toMatchObject({ - emittedExtensionDirs: [{ spaceId: 'cipherstash', dirName: '0001_seed' }], + appliedStatements: [{ verb: 'delete', description: 'delete model "Legacy"' }], }); }); - it('never asks when the baseline is purely additive', async () => { - const project = await destructiveBaselineProject(); + it('fails on a typed answer the question rejects, with scripted input', async () => { + const project = await losingProject(); + + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--json'], + { + cwd: project.dir, + isTty: { stdin: true }, + answers: ['delete Nope'], + }, + ); + + expect(envelopeOf(run)).toMatchObject({ error: { code: 'CLI.PROMPT_INVALID' } }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('refuses a --delete nothing asked for before writing anything', async () => { + const project = await losingProject(); - const run = await harness(project).run(['migration', 'plan', '--json'], { + const run = await harness(project).run(['migration', 'plan', '--delete', 'Nope', '--json'], { cwd: project.dir, }); + expect(envelopeOf(run)).toMatchObject({ error: { code: 'CLI.CONSENT_UNUSED' } }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('does not take --yes as an answer', async () => { + const project = await losingProject(); + + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--yes', '--json'], + { cwd: project.dir, isTty: { stdin: true } }, + ); + + expect(envelopeOf(run)).toMatchObject({ error: { code: 'CLI.CONSENT_REQUIRED' } }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('does not take --confirm as consent', async () => { + const project = await losingProject(); + + const run = await harness(project, { script: losingScript }).run( + ['migration', 'plan', '--confirm', basename(project.dir), '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).not.toBe(0); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + + it('asks before writing either package of an auto-baseline whose delta loses data', async () => { + const project = await losingBaselineProject(); + const script: FakePlannerScript = { + operationsByPlan: [[ADDITIVE_OP], [ADDITIVE_OP, DESTRUCTIVE_OP]], + dataLossByPlan: [[], [LEGACY_LOSS]], + }; + + const refused = await harness(project, { script }).run(['migration', 'plan', '--json'], { + cwd: project.dir, + }); + expect(envelopeOf(refused)).toMatchObject({ error: { code: 'CLI.CONSENT_REQUIRED' } }); + expect(await plannedDirs(project)).toEqual([]); + + const answered = await harness(project, { script }).run( + ['migration', 'plan', '--delete', 'Legacy', '--json'], + { cwd: project.dir }, + ); + expect(answered.exitCode).toBe(0); + expect((await plannedDirs(project)).length).toBe(2); + }); + + it('writes a placeholder plan once its loss is answered', async () => { + const project = await losingBaselineProject(); + + const run = await harness(project, { + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + throwOnOperations: errorUnfilledPlaceholder('backfill'), + dataLoss: [LEGACY_LOSS], + }, + }).run(['migration', 'plan', '--delete', 'Legacy', '--json'], { cwd: project.dir }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ pendingPlaceholders: true }); expect((await plannedDirs(project)).length).toBe(2); }); }); @@ -590,7 +734,7 @@ describe('migration plan', () => { const run = await harness(project, { script: { operations: [ADDITIVE_OP, DESTRUCTIVE_OP] }, - }).run(['migration', 'plan', '--confirm', basename(project.dir)], { + }).run(['migration', 'plan'], { cwd: project.dir, isTty: { stdout: true }, }); From 885939916ffae6ad73777fa817b83c7a20361a8a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:41:49 +0200 Subject: [PATCH 034/109] TML-3476: journeys that plan a drop or a lossy type change pass --delete Signed-off-by: willbot Signed-off-by: Will Madden --- .../data-transform-strategies.e2e.test.ts | 4 +++ .../migration-plan-details.e2e.test.ts | 2 ++ .../cli.migration-plan-ref-aware.e2e.test.ts | 30 ++++++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts index 13338cb41803..e11a53920395 100644 --- a/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts @@ -50,6 +50,8 @@ interface Scenario { readonly seedSql: string; readonly targetContract: ContractVariant; readonly migrationName: string; + /** The statements the plan needs, such as consent to a type change that can lose values. */ + readonly statements?: readonly string[]; readonly dirToken: string; readonly placeholderId: string; readonly assertScaffold: (scaffold: string) => void; @@ -135,6 +137,7 @@ const scenarios: readonly Scenario[] = [ seedSql: `INSERT INTO "public"."user" (id, email, score) VALUES (1, 'alice@example.com', '10'), (2, 'bob@test.org', '20')`, targetContract: 'contract-typechange-int', migrationName: 'retype-score-to-int', + statements: ['--delete', 'User.score'], dirToken: 'retype_score_to_int', placeholderId: 'typechange-user-score', assertScaffold: (scaffold) => { @@ -194,6 +197,7 @@ withTempDir(({ createTempDir }) => { scenario.migrationName, '--from', latestMigrationDirName(ctx), + ...(scenario.statements ?? []), ]); expect(planResult.exitCode, `plan: ${planResult.stderr}`).toBe(0); diff --git a/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts b/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts index e024bf4ff397..ccd907cc5ea6 100644 --- a/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts @@ -135,6 +135,8 @@ withTempDir(({ createTempDir }) => { 'drop-email', '--from', latestMigrationDirName(ctx), + '--delete', + 'User.email', '--json', ]); expect(planDrop.exitCode, 'I.03: plan drop-email').toBe(0); 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..24a38c68724f 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 @@ -197,7 +197,13 @@ withTempDir(({ createTempDir }) => { await withJourney(createTempDir, connectionString, async (ctx) => { const { dbRefHash } = await seedDevToShipTrap(ctx); - const plan = await runMigrationPlan(ctx, ['--name', 'add-phone', '--json']); + const plan = await runMigrationPlan(ctx, [ + '--name', + 'add-phone', + '--delete', + 'User.name', + '--json', + ]); expect(plan.exitCode).toBe(0); const planJson = parseJsonOutput(plan); expect(planJson.baselineDir).toBeDefined(); @@ -246,7 +252,15 @@ withTempDir(({ createTempDir }) => { await withJourney(createTempDir, connectionString, async (ctx) => { await seedDevToShipTrap(ctx); expect( - (await runAutoBaselinePlanAndEmit(ctx, ['--name', 'trap-close', '--json'])).exitCode, + ( + await runAutoBaselinePlanAndEmit(ctx, [ + '--name', + 'trap-close', + '--delete', + 'User.name', + '--json', + ]) + ).exitCode, ).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); @@ -254,7 +268,7 @@ withTempDir(({ createTempDir }) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); const dbRefHash = readDbRefHash(ctx); - const plan = await runMigrationPlan(ctx, ['--json']); + const plan = await runMigrationPlan(ctx, ['--delete', 'User.name', '--json']); expect(plan.exitCode).toBe(0); const planJson = parseJsonOutput(plan); expect(planJson.baselineDir).toBeUndefined(); @@ -490,7 +504,13 @@ withTempDir(({ createTempDir }) => { await withJourney(createTempDir, connectionString, async (ctx) => { await seedDevToShipTrap(ctx); - const implicit = await runMigrationPlan(ctx, ['--name', 'implicit', '--json']); + const implicit = await runMigrationPlan(ctx, [ + '--name', + 'implicit', + '--delete', + 'User.name', + '--json', + ]); expect(implicit.exitCode).toBe(0); const implicitJson = parseJsonOutput(implicit); @@ -501,6 +521,8 @@ withTempDir(({ createTempDir }) => { 'db', '--name', 'explicit', + '--delete', + 'User.name', '--json', ]); expect(explicit.exitCode).toBe(0); From d86db584cd26b44971ff44e78434ece7e817f67d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:41:50 +0200 Subject: [PATCH 035/109] TML-3476: document migration plan's data-loss questions and delete statements Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 4 ++++ docs/reference/error-reference.md | 16 ++++++++++++++-- packages/1-framework/3-tooling/cli/README.md | 10 +++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index eb3950f0d4d3..8548e842363e 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -170,6 +170,8 @@ When `--to` is omitted, `to_contract` is the emitted contract; when `--to` is su An auto-baseline dates the delta package at the current time and the baseline package one minute earlier, so the baseline sorts first. Folder names have minute resolution, so packages planned within the same minute sort by name. +**Data loss.** Before it writes either package, `migration plan` asks what each operation that would lose data means, through the CLI engine's statement prompt (`ctx.prompt.statements`). The planner's `dataLoss` names each such operation's subject; the command asks one question per subject, answered by `--delete ` or, for a model or field, `--rename :` (see [Statements](#statements)). The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start; a rename typed at the prompt is planned again with them, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`, listing every question, and a `--delete` that answers none fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` grants nothing here. + 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 @@ -195,6 +197,8 @@ Rename operations are `widening`, so neither command asks for consent. The `migr **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. +**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` lists it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. + **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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index fdb8d003e9ac..9c08ae4b3561 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -83,6 +83,14 @@ The migration-file CLI (`prisma migration`) received `--config` without a path a `db update` was told the plan is destructive but was given no operations to name, so the consent prompt would have asked you to authorise a list of nothing. The command refuses instead of prompting. This is an inconsistency between the CLI and the control API rather than something your project can be wrong about; run `prisma db update --dry-run` to see the plan, and report the run. Payload: none. +### CLI.CONSENT_REQUIRED + +Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is the reference: [CLI.CONSENT_REQUIRED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_REQUIRED). `migration plan` raises it when the plan would lose data and the run has nobody to answer: it is not interactive, or `--yes` is set. The command asks one question per model, field or storage name whose data an operation would lose, and the error lists every question still unanswered, with the flags that answer each: `--delete ` lets the plan lose that data, and, for a model or field, `--rename :` keeps it under a new name. Data no model of the origin contract stores can only be deleted. Nothing is written. `--confirm` does not answer these questions. Payload: `subject`, `verbs`, `unanswered` (each with its `subject` and `verbs`). + +### CLI.CONSENT_UNUSED + +Raised by the CLI engine; the engine's entry is the reference: [CLI.CONSENT_UNUSED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_UNUSED). `migration plan` raises it when a `--delete` value answers no question the plan asks, for example a mistyped name or a model the plan does not drop, before it writes anything. + ### CLI.CONSENT_TOKEN_UNRESOLVED `db update` could not derive a name for the database it is about to change, so there is nothing for the consent prompt to ask you to type, and an empty token would let a bare Enter (or `--confirm ""`) authorise data loss. The name comes from the `database` a driver connection object carries, or from the connection URL (its first path segment, else its host), falling back to the target id. Name the database in `db.connection` or pass `--db `. Payload: none. @@ -1372,7 +1380,7 @@ A `migration check` finding, carried as an `error` diagnostic on a completed run ### 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`. +An apply carrying consent was refused because the plan recomputed for it is not the plan that was consented to; only `db update` raises it. `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`. ### MIGRATION.CONTRACT_DESERIALIZATION_FAILED @@ -1420,7 +1428,7 @@ Runner-level failure during apply (`db init`, `db update`, `db migrate`): the pl ### MIGRATION.DESTRUCTIVE_CHANGES -The planned operations include destructive changes (e.g. DROP) and the command was run without explicit consent. `db update` asks for that consent instead of failing: interactively it asks you to type the name of the database it is about to change, and outside an interactive terminal it is granted by `--confirm ` (`--yes` accepts declared prompt defaults and never grants consent; `--confirm` is read only when the run is non-interactive or `--yes` is set, so a script run from a terminal needs `--no-interactive --confirm `). The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. A run with nobody to ask and no `--confirm` settles as `CLI.CONSENT_REQUIRED` at exit 2; a run whose prompt is cancelled settles as `CLI.PROMPT_CANCELLED` at exit 3. `--dry-run` never asks; it settles as this error instead. Use it to preview the operations first. `migration plan` raises the same refusal before writing an auto-baseline package (planned on an empty migrations directory from the `db` ref) whose operations would remove data when applied; there the consent token is the project directory name, so a non-interactive run passes `--no-interactive --confirm `, and a consented re-run that no longer plans the consented baseline settles as `MIGRATION.CONSENT_PLAN_MISMATCH`. Payload at the `migration plan` site: `destructiveOperations`, `planHash`. +The planned operations include destructive changes (e.g. DROP) and the command was run without explicit consent. `db update` asks for that consent instead of failing: interactively it asks you to type the name of the database it is about to change, and outside an interactive terminal it is granted by `--confirm ` (`--yes` accepts declared prompt defaults and never grants consent; `--confirm` is read only when the run is non-interactive or `--yes` is set, so a script run from a terminal needs `--no-interactive --confirm `). The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. A run with nobody to ask and no `--confirm` settles as `CLI.CONSENT_REQUIRED` at exit 2; a run whose prompt is cancelled settles as `CLI.PROMPT_CANCELLED` at exit 3. `--dry-run` never asks; it settles as this error instead. Use it to preview the operations first. `migration plan` no longer raises it: a plan that would lose data is answered with `--rename` and `--delete` statements instead (see `CLI.CONSENT_REQUIRED`). ### MIGRATION.DIR_EXISTS @@ -1652,6 +1660,10 @@ 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_DID_NOT_RESOLVE_LOSS + +A `rename` typed at `migration plan`'s data-loss question was planned with the plan's other renames, and the plan still loses the data of the subject the question was about: the rename resolves, but it does not keep that data, for example because it renames a different model or field. Nothing is written. Answer the question with `--delete ` if the data may be lost, or with a rename whose old name stores that data. Payload: `statement` (the rename's text), `verb` (`rename`), `subject`. + ### 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`). diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index d242debc1acc..afb7b5afd26f 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1003,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 ] [--rename ]... [--confirm ] [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] +prisma migration plan [--config ] [--name ] [--from ] [--to ] [--rename ]... [--delete ]... [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** @@ -1012,6 +1012,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `--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. +- `--delete `: Let the plan lose the data of a model, a field, or a storage name the refusal lists. Repeat the flag for several. See **Data loss** below. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) @@ -1032,6 +1033,13 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **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. +**Data loss:** a plan that would lose data is written only once you say what each such operation means. The command asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), before it writes anything, including the baseline package of an auto-baseline. Each question is answered with a statement: + +- `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. +- `--rename :` keeps the data of a model or field under a new name. A rename on the command line is planned from the start, so the drop it replaces is never asked about. + +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn: type `delete` to let the data go, or `rename :`; a rejected answer is asked again. A typed rename is planned again with the other renames, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` no longer consents to anything here. The deletes are listed under `Statements applied` after the renames, for example `delete model "Legacy" (1 operation)`, and in `--json` output each `appliedStatements` entry carries its `verb`, `rename` or `delete`; a delete's `statement` is `{ kind: 'delete', subject }`. The `⚠` marker stays on each destructive operation. + **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` From 4a72d5545cbff5e0bba67466ca1a0e5459a892fe Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:53:50 +0200 Subject: [PATCH 036/109] TML-3476: dispatch 4 brief, and two open items the PR and dispatch 5 must honour Signed-off-by: willbot Signed-off-by: Will Madden --- .../dispatches/04-refusal-on-db-update.md | 37 +++++++++++++++++++ .../slices/refusal/plan.md | 3 ++ 2 files changed, 40 insertions(+) create mode 100644 projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md diff --git a/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md b/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md new file mode 100644 index 000000000000..c6adbfd2a29b --- /dev/null +++ b/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md @@ -0,0 +1,37 @@ +# Dispatch 4 — The refusal and `allow` on `db update` + +**Slice:** [`../spec.md`](../spec.md) § "`--delete` and how statements consent", § "Access widening (`allow`)" · **Plan:** [`../plan.md`](../plan.md) dispatch 4 · **Builds on:** dispatch 3 + +## Step 0 — bring the base in + +`bot/tml-3475-statement-renames` moved after this branch was cut (the merge-queue test fix, a merge of `main`, and two follow-ups). Merge it into `tml-3476-statement-refusal` with `git merge --no-ff`; do not rebase. Resolve conflicts keeping both sides; the base's rewritten test "writes nothing when the delta planner refuses after the baseline was planned" is the one dispatch 3 already satisfies. Run the gate on the merge before starting the rest. + +## Outcome + +`db update` asks the same per-operation questions as `migration plan`, with one engine mechanism; in apply mode it also asks, with the verb `allow`, before it widens who can read or write rows; the blanket consent and its errors are gone from the CLI. + +## What to build + +1. **Declare and read.** `db update` declares `statements: { rename, delete, allow }` with briefs, drops the slice 1 `--rename` flag declaration, builds the rename list from `ctx.statements.take('rename')`, and passes the `delete` and `allow` answers through the same `answerDataLoss`-style callback as `migration plan` (share the question builder in `control-api/statements/data-loss-questions.ts`; add an `accessWidening` question builder next to it). +2. **Plan, then ask, then apply.** `executeDbUpdate` plans once (the current pre-plan), asks for every `dataLoss` entry across all spaces (an extension space's entry has a storage subject and offers `delete` only) and, in apply mode, every `accessWidening` entry (verb `allow` only; subject the model; question text from the label: `Disable row-level security on "User" would widen who can read and write its rows.`), all in one `statements(..., { last: true })` call, then applies. A typed `rename` re-plans as on `migration plan`. A dry run (`--dry-run`) plans, lists `dataLoss` and `accessWidening` in its output, and asks nothing; a `--delete` or `--allow` given to a dry run is unused and fails with `CLI.CONSENT_UNUSED`, which is the engine's rule, so say so in the README. +3. **Remove the blanket consent.** `guardDestructiveChanges`, the consent prompt and helpers in `orm/db/consent.ts`, `acceptDataLoss`'s CLI path, `DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` from `DbUpdateFailureCode`, `computePlanHash`'s consent use, and `MIGRATION.DESTRUCTIVE_CHANGES` / `MIGRATION.CONSENT_PLAN_MISMATCH` from the errors package and the error reference (nothing raises them any more after dispatch 3). `DB_INIT_ADDITIVE_ONLY_FIX` in `db-init-failure.ts` stops telling users to pass `--confirm`. The control API keeps `acceptDataLoss` for programmatic callers and gains `statements`. +4. **Retry command.** `retryCommandFor` carries every verb's values (`rename`, `delete`, `allow`) in order. +5. **No origin snapshot.** With statements but no snapshot, `rename` fails as in slice 1; subjects are storage names, `delete` consents to them, and the refusal says the names are storage names because the origin contract is unknown. +6. **Output.** `Statements applied` and JSON `appliedStatements` carry `delete` and `allow` lines with their positions, offset per space as the positions already are. JSON `dataLoss` and `accessWidening` appear on the dry-run document. +7. **Docs.** CLI README `db update`: statements, the questions, `allow`, the dry run, `--confirm` no longer consents; the Migration System doc § `db update` (including that recorded extension migrations contribute no `accessWidening` question, because a written migration is reviewed before it runs); the error reference; the CLI Style Guide's consent section: statements are the data-loss form, `consent`/`--confirm` stays for one-thing-at-stake commands. + +## Not in this dispatch + +Journeys, upgrade fragments, the skill references (dispatch 5). + +## Tests + +CLI tests through the mocked client and the control-API tests with a mock family: refusal lists every operation across spaces; `--delete` applies; `--allow User` applies a policy drop and its absence refuses in apply mode and is not asked in dry run; a typed answer; `--confirm ` no longer consents (nothing asks for it); `CLI.CONSENT_UNUSED` for a stray value; retry command carries all verbs; no-snapshot storage subjects. Update `db-update-consent.test.ts`, `db-update.test.ts`, `db-update-to-resolution.test.ts` and `control-api/db-update.test.ts`. + +## Halt conditions + +Stop and report if sharing the question builder between the two commands needs the control API to know about the engine's prompt types (it must not: the callback boundary stays at the command); if the dry run's `CONSENT_UNUSED` behaviour under the engine cannot be made to say something useful; or if retiring the two error codes breaks a published surface a fragment must name. + +## Gate + +Standard, plus `pnpm check:error-reference`, `pnpm lint:framework-vocabulary`, both rename-statements journeys, `cli-journeys/db-update-workflows.e2e.test.ts`, `drift-schema.e2e.test.ts`, `mongo-db-update-consent.e2e.test.ts`, `rename-table-migration.sqlite.e2e.test.ts`, `cli.db-update.e2e.test.ts` and `cli.db-update.preflight-gaps.e2e.test.ts` (they pass `--confirm` today and must change to statements). diff --git a/projects/migration-statements/slices/refusal/plan.md b/projects/migration-statements/slices/refusal/plan.md index 0527309ed98b..6e4551e64c11 100644 --- a/projects/migration-statements/slices/refusal/plan.md +++ b/projects/migration-statements/slices/refusal/plan.md @@ -50,6 +50,9 @@ Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Di ## Open items +- The PR must not merge while `pkg.pr.new` appears in any `package.json` or the root `pnpm.overrides`: the engine pin switches to the published `@prisma/cli-engine` 0.7.0 first. +- Dispatch 5's app upgrade fragment names the control-API break: `executeMigrationPlanCommand` requires `answerDataLoss`; `consent` and `carryEmittedExtensionDirs` are gone. + - Dispatch 5 adds a planner test where a codec hook emits a destructive call, proving the hook-call-to-field match (by object identity) survives the control-policy partitions. - Whether the Mongo planner's `statementRefused` for `rename` needs its wording changed now that `delete` exists: dispatch 3 reads the text and decides. From cfdb07c49582d6263ee05c42bfab75fcc1668579 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:58:44 +0200 Subject: [PATCH 037/109] TML-3476: data-loss questions name renamed models' fields, and positions skip placeholders A field of a model the plan renames is written through the model's new name, as a field rename is, and answers are matched by subject rather than by text. Labels come from the operations the guarded read resolved, so a planner whose operations accessor throws on a placeholder cannot escape the leg. Positions in dataLoss, delete reports and rename reports index the operations the result lists, which leave unfilled placeholders out. Tests cover a re-plan that asks a second batch and --confirm settling as CLI.CONSENT_REQUIRED. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migration-plan.ts | 95 ++++++++------ .../statements/data-loss-questions.ts | 64 +++++++--- .../cli/test/orm/fixtures/offline-project.ts | 61 ++++++--- .../cli/test/orm/migration-plan.test.ts | 117 +++++++++++++++++- 4 files changed, 266 insertions(+), 71 deletions(-) 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 414a3fd63a0b..cf7cd0125247 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 @@ -179,11 +179,12 @@ async function runPlannerLeg( }); } - let plannedOps: readonly MigrationPlanOperation[] = []; + // The operation at each position of the plan, or `undefined` where it is an unfilled placeholder. + let resolved: readonly (MigrationPlanOperation | undefined)[] = []; let hasPlaceholders = false; try { - plannedOps = await Promise.all(plannerResult.plan.operations); - if (plannedOps.length === 0 && !noOperationsExpected) { + resolved = await Promise.all(plannerResult.plan.operations); + if (resolved.length === 0 && !noOperationsExpected) { return notOk({ reason: 'noOperations', error: errorPlanProducedNoOperations(origin, destination), @@ -192,37 +193,43 @@ async function runPlannerLeg( } catch (e) { if (CliStructuredError.is(e) && e.code === 'MIGRATION.UNFILLED_PLACEHOLDER') { hasPlaceholders = true; - // The operations that DID resolve still matter: the destructive-consent - // check must see them, or a placeholder would smuggle a destructive - // baseline past the prompt. Writers stay gated on hasPlaceholders. A - // planner whose `operations` accessor throws synchronously on an - // unfilled placeholder (rather than rejecting one op's promise) exposes - // no operations at all; the check then sees none. + // The operations that DID resolve still matter: the data-loss questions + // must see them, or a placeholder would smuggle a loss past them. + // Writers stay gated on hasPlaceholders. A planner whose `operations` + // accessor throws synchronously on an unfilled placeholder (rather than + // rejecting one op's promise) exposes no operations at all. try { const settled = await Promise.allSettled(plannerResult.plan.operations); - plannedOps = settled.flatMap((entry) => - entry.status === 'fulfilled' ? [entry.value] : [], - ); + resolved = settled.map((entry) => (entry.status === 'fulfilled' ? entry.value : undefined)); } catch { - plannedOps = []; + resolved = []; } } else { throw e; } } - const labels = await operationLabels(plannerResult.plan.operations); + const plannedOps = resolved.filter((op): op is MigrationPlanOperation => op !== undefined); + const position = resolvedPositions(resolved); return ok({ plannedOps, migrationTsContent: plannerResult.plan.renderTypeScript(resolveImportSpecifier), hasPlaceholders, dataLoss: plannerResult.dataLoss.map(({ operationIndex, subject }) => ({ - operationIndex, + operationIndex: position(operationIndex), subject, - label: labels[operationIndex] ?? subjectText(subject, fromContract ?? EMPTY_ORIGIN), + label: + resolved[operationIndex]?.label ?? + subjectText(subject, { origin: fromContract ?? EMPTY_ORIGIN, renames: [] }), })), appliedStatements: reportAppliedStatements( - plannerResult.appliedStatements, + plannerResult.appliedStatements.map((applied) => ({ + ...applied, + operationIndexes: applied.operationIndexes.flatMap((index) => { + const at = position(index); + return at === undefined ? [] : [at]; + }), + })), fromContract, contract, 0, @@ -230,12 +237,20 @@ async function runPlannerLeg( }); } -/** The label of each operation of a plan, or `undefined` where an operation is an unfilled placeholder. */ -async function operationLabels( - operations: readonly (MigrationPlanOperation | Promise)[], -): Promise { - const settled = await Promise.allSettled(operations); - return settled.map((entry) => (entry.status === 'fulfilled' ? entry.value.label : undefined)); +/** + * Maps a position in the plan's operations to the position of that operation among those that + * resolved, which is what a result's `operations` lists: `undefined` for an unfilled placeholder. + */ +function resolvedPositions( + resolved: readonly (MigrationPlanOperation | undefined)[], +): (planIndex: number) => number | undefined { + const positions: (number | undefined)[] = []; + let next = 0; + for (const op of resolved) { + positions.push(op === undefined ? undefined : next); + if (op !== undefined) next += 1; + } + return (planIndex) => positions[planIndex]; } /** @@ -296,29 +311,31 @@ async function answerPlannedDataLoss(input: { let delta = input.delta; let renames = input.renames; const consented = new Map(); - const textOf = (loss: PlannedDataLoss) => subjectText(loss.subject, input.origin); + const keyOf = (loss: PlannedDataLoss) => JSON.stringify(loss.subject); + const contracts = () => ({ + origin: input.origin, + destination: input.destination, + renames, + }); // The first round asks even when nothing is lost, so a statement no question // consumed is refused before anything is written. for (let round = 0; ; round += 1) { const losses = [...(input.baseline?.dataLoss ?? []), ...(delta?.dataLoss ?? [])]; const unanswered = [ ...new Map( - losses - .filter((loss) => !consented.has(textOf(loss))) - .map((loss) => [textOf(loss), loss] as const), + losses.filter((loss) => !consented.has(keyOf(loss))).map((loss) => [keyOf(loss), loss]), ).values(), ]; if (unanswered.length === 0 && round > 0) break; - const questions = unanswered.map((loss) => - dataLossQuestion(loss, { origin: input.origin, destination: input.destination, renames }), - ); + const asked = contracts(); + const questions = unanswered.map((loss) => dataLossQuestion(loss, asked)); const answers = await input.answer(questions); - const typedRenames: { readonly text: string; readonly subject: string }[] = []; + const typedRenames: { readonly text: string; readonly loss: PlannedDataLoss }[] = []; answers.forEach((answer, index) => { const loss = unanswered[index]; if (loss === undefined) return; - if (answer.verb === 'delete') consented.set(textOf(loss), loss.subject); - else typedRenames.push({ text: answer.text, subject: textOf(loss) }); + if (answer.verb === 'delete') consented.set(keyOf(loss), loss.subject); + else typedRenames.push({ text: answer.text, loss }); }); if (typedRenames.length === 0) { if (unanswered.length === 0) break; @@ -328,23 +345,23 @@ async function answerPlannedDataLoss(input: { const replanned = await input.planDelta(renames); if (!replanned.ok) return replanned; delta = replanned.value; - const stillLost = new Set(delta.dataLoss.map(textOf)); - const unresolved = typedRenames.find(({ subject }) => stillLost.has(subject)); + const stillLost = new Set(delta.dataLoss.map(keyOf)); + const unresolved = typedRenames.find(({ loss }) => stillLost.has(keyOf(loss))); if (unresolved !== undefined) { return notOk( errorStatementDidNotResolveLoss( { verb: 'rename', text: unresolved.text }, - unresolved.subject, + subjectText(unresolved.loss.subject, asked), ), ); } } - const deletes = [...consented].map(([text, subject]) => + const deletes = [...consented].map(([key, subject]) => reportDeleteStatement( subject, (delta?.dataLoss ?? []) - .filter((loss) => textOf(loss) === text) - .map(({ operationIndex }) => operationIndex), + .filter((loss) => keyOf(loss) === key) + .flatMap(({ operationIndex }) => (operationIndex === undefined ? [] : [operationIndex])), input.origin, ), ); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 7ccd46b2f105..5c6275489a36 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -33,9 +33,13 @@ export type AnswerDataLoss = ( questions: readonly DataLossQuestion[], ) => Promise; -/** An operation of a plan that would lose data, with the label of that operation. */ +/** + * An operation of a plan that would lose data, with the label of that operation. `operationIndex` + * is its position among the operations the result lists; `undefined` when the plan could not + * resolve it. + */ export interface PlannedDataLoss { - readonly operationIndex: number; + readonly operationIndex: number | undefined; readonly label: string; readonly subject: MigrationStatementSubject; } @@ -46,19 +50,55 @@ function modelText(contract: ContractWithDomain, coordinate: ModelCoordinate): s : coordinate.model; } +/** The contracts a statement names things in, and the renames the plan applies so far. */ +export interface StatementContracts { + readonly origin: ContractWithDomain; + /** The destination contract; needed only to name a field of a renamed model. */ + readonly destination?: ContractWithDomain; + readonly renames: readonly StatementText[]; +} + +/** The model a rename statement of `contracts` gives `coordinate` in the destination, if any. */ +function renamedModel( + coordinate: ModelCoordinate, + contracts: StatementContracts, +): ModelCoordinate | undefined { + if (contracts.destination === undefined || contracts.renames.length === 0) return undefined; + const resolved = resolveStatements({ + statements: contracts.renames, + origin: { kind: 'contract', contract: contracts.origin }, + destination: contracts.destination, + }); + if (!resolved.ok) return undefined; + return resolved.value.find( + (statement) => + statement.entity === 'model' && + statement.from.namespaceId === coordinate.namespaceId && + statement.from.model === coordinate.model, + )?.to; +} + /** - * The subject as a statement names it: `Model` or `Model.field`, with the namespace when the origin - * contract has several, or the storage name when no model stores what the operation loses. + * The subject as a statement names it: `Model` or `Model.field`, with the namespace when its + * contract has several, or the storage name when no model stores what the operation loses. A + * field of a model the plan renames is named through the model's new name, as a rename of the + * field is written. */ export function subjectText( subject: MigrationStatementSubject, - origin: ContractWithDomain, + contracts: StatementContracts, ): string { switch (subject.kind) { case 'model': - return modelText(origin, subject); - case 'field': - return `${modelText(origin, subject)}.${subject.field}`; + return modelText(contracts.origin, subject); + case 'field': { + const renamed = renamedModel(subject, contracts); + const model = + renamed === undefined || contracts.destination === undefined + ? modelText(contracts.origin, subject) + : modelText(contracts.destination, renamed); + return `${model}.${subject.field}`; + } case 'storage': return subject.name; } @@ -94,13 +134,9 @@ function sameCoordinate( */ export function dataLossQuestion( loss: PlannedDataLoss, - contracts: { - readonly origin: ContractWithDomain; - readonly destination: ContractWithDomain; - readonly renames: readonly StatementText[]; - }, + contracts: StatementContracts & { readonly destination: ContractWithDomain }, ): DataLossQuestion { - const subject = subjectText(loss.subject, contracts.origin); + const subject = subjectText(loss.subject, contracts); const renamable = loss.subject.kind !== 'storage'; return { question: `${loss.label} ${lossText(loss.subject, subject)}.`, 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 6caccb6f8f9a..90e92c0de560 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 @@ -40,9 +40,30 @@ export async function removeOfflineProjects(): Promise { * An emitted contract. With `models`, it carries an application domain whose * one namespace `app` declares those models, so statements can resolve. */ +/** A model of a fixture contract: its name, or its name and the names of its scalar fields. */ +export type FixtureModel = string | { readonly name: string; readonly fields: readonly string[] }; + +function modelEntry(model: FixtureModel): readonly [string, Record] { + const name = typeof model === 'string' ? model : model.name; + const fields = typeof model === 'string' ? [] : model.fields; + return [ + name, + { + fields: Object.fromEntries( + fields.map((field) => [ + field, + { nullable: false, type: { kind: 'scalar', codecId: 'pg/text@1' } }, + ]), + ), + relations: {}, + storage: {}, + }, + ]; +} + export function contractJson( storageHash: string, - models?: readonly string[], + models?: readonly FixtureModel[], ): Record { return { storage: { storageHash, namespaces: {} }, @@ -56,9 +77,7 @@ export function contractJson( domain: { namespaces: { app: { - models: Object.fromEntries( - models.map((model) => [model, { fields: {}, relations: {}, storage: {} }]), - ), + models: Object.fromEntries(models.map(modelEntry)), }, }, }, @@ -68,7 +87,7 @@ export function contractJson( export async function createOfflineProject(options: { readonly storageHash: string; - readonly models?: readonly string[]; + readonly models?: readonly FixtureModel[]; }): Promise { const dir = createTestProjectDir('orm-offline'); created.push(dir); @@ -143,7 +162,7 @@ export async function seedMigrationPackage(options: { export async function seedContractSnapshot(options: { readonly migrationsDir: string; readonly storageHash: string; - readonly models?: readonly string[]; + readonly models?: readonly FixtureModel[]; }): Promise { await writeContractSnapshot(options.migrationsDir, options.storageHash, { contractJson: contractJson(options.storageHash, options.models), @@ -186,6 +205,12 @@ export interface FakePlannerScript { readonly dataLossByPlan?: ReadonlyArray; /** Reports `dataLoss` only from a `plan` call given no statements, as a rename removes a loss. */ readonly statementsResolveDataLoss?: boolean; + /** Reports `dataLoss` only from a `plan` call given fewer statements than this. */ + readonly statementsResolvingDataLoss?: number; + /** Where in the operations the placeholder that `throwOnOperations` rejects sits; last by default. */ + readonly placeholderAt?: number; + /** Makes the plan's `operations` accessor throw this synchronously. */ + readonly operationsAccessorThrows?: unknown; } /** A `dataLoss` entry of a scripted plan: the position of an operation and what it loses. */ @@ -212,7 +237,9 @@ function fakePlanner(script: FakePlannerScript): Record { } const operations = script.operationsByPlan?.[planCalls] ?? script.operations; const dataLoss = - script.statementsResolveDataLoss === true && options.statements.length > 0 + (script.statementsResolveDataLoss === true && options.statements.length > 0) || + options.statements.length >= + (script.statementsResolvingDataLoss ?? Number.POSITIVE_INFINITY) ? [] : (script.dataLossByPlan?.[planCalls] ?? script.dataLoss ?? []); planCalls += 1; @@ -233,13 +260,19 @@ function fakePlanner(script: FakePlannerScript): Record { operationIndexes: (operations ?? [ADDITIVE_OP]).map((_, index) => index), })), plan: { - operations: - script.throwOnOperations === undefined - ? (operations ?? [ADDITIVE_OP]).map((op) => Promise.resolve(op)) - : [ - ...(operations ?? []).map((op) => Promise.resolve(op)), - Promise.reject(script.throwOnOperations), - ], + get operations() { + if (script.operationsAccessorThrows !== undefined) { + throw script.operationsAccessorThrows; + } + if (script.throwOnOperations === undefined) { + return (operations ?? [ADDITIVE_OP]).map((op) => Promise.resolve(op)); + } + const resolved = (operations ?? []).map((op) => Promise.resolve(op)); + const placeholder = Promise.reject(script.throwOnOperations); + placeholder.catch(() => undefined); + const at = script.placeholderAt ?? resolved.length; + return [...resolved.slice(0, at), placeholder, ...resolved.slice(at)]; + }, renderTypeScript: () => '// planned migration\n', }, } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 714451375e74..d4df002bc1f5 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -685,7 +685,7 @@ describe('migration plan', () => { { cwd: project.dir }, ); - expect(run.exitCode).not.toBe(0); + expect(envelopeOf(run)).toMatchObject({ error: { code: 'CLI.CONSENT_REQUIRED' } }); expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); }); @@ -710,21 +710,130 @@ describe('migration plan', () => { expect((await plannedDirs(project)).length).toBe(2); }); - it('writes a placeholder plan once its loss is answered', async () => { + it('writes a placeholder plan once its loss is answered, positioning the loss among the listed operations', async () => { const project = await losingBaselineProject(); const run = await harness(project, { script: { operations: [ADDITIVE_OP, DESTRUCTIVE_OP], throwOnOperations: errorUnfilledPlaceholder('backfill'), - dataLoss: [LEGACY_LOSS], + placeholderAt: 1, + dataLoss: [{ ...LEGACY_LOSS, operationIndex: 2 }], }, }).run(['migration', 'plan', '--delete', 'Legacy', '--json'], { cwd: project.dir }); expect(run.exitCode).toBe(0); - expect(run.presented?.data).toMatchObject({ pendingPlaceholders: true }); + const data = run.presented?.data as { + readonly pendingPlaceholders: boolean; + readonly operations: readonly { readonly id: string }[]; + readonly appliedStatements: readonly { readonly operationIndexes: readonly number[] }[]; + }; + expect(data.pendingPlaceholders).toBe(true); + expect( + data.appliedStatements.map(({ operationIndexes }) => + operationIndexes.map((index) => data.operations[index]?.id), + ), + ).toEqual([[DESTRUCTIVE_OP.id]]); expect((await plannedDirs(project)).length).toBe(2); }); + + it('still asks about a loss when the operations accessor throws on a placeholder', async () => { + const project = await losingProject(); + const script: FakePlannerScript = { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + operationsAccessorThrows: errorUnfilledPlaceholder('backfill'), + dataLoss: [LEGACY_LOSS], + }; + + const refused = await harness(project, { script }).run(['migration', 'plan', '--json'], { + cwd: project.dir, + }); + expect(envelopeOf(refused)).toMatchObject({ error: { code: 'CLI.CONSENT_REQUIRED' } }); + + const answered = await harness(project, { script }).run( + ['migration', 'plan', '--delete', 'Legacy', '--json'], + { cwd: project.dir }, + ); + expect(answered.exitCode).toBe(0); + expect(answered.presented?.data).toMatchObject({ pendingPlaceholders: true }); + }); + + it('asks a question a re-plan brings up in a second batch', async () => { + const project = await losingProject(); + + const run = await harness(project, { + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP, DROP_AUDIT_OP], + dataLossByPlan: [[LEGACY_LOSS], [AUDIT_LOSS]], + }, + }).run(['migration', 'plan', '--json'], { + cwd: project.dir, + isTty: { stdin: true }, + answers: ['rename Legacy:Archive', 'delete'], + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + appliedStatements: [ + { verb: 'rename', description: 'rename model "Legacy" to "Archive"' }, + { verb: 'delete', description: 'delete storage "audit_log"', operationIndexes: [2] }, + ], + }); + }); + + it('names a field of a renamed model through its new name, and takes a rename written that way', async () => { + const project = await createOfflineProject({ + storageHash: HASH_TO, + models: [{ name: 'User', fields: ['id', 'handle'] }], + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_FROM, + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_FROM, + models: [{ name: 'Profile', fields: ['id', 'nickname'] }], + }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: HASH_FROM }); + const script: FakePlannerScript = { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP], + dataLoss: [ + { + operationIndex: 1, + subject: { kind: 'field', namespaceId: 'app', model: 'Profile', field: 'nickname' }, + }, + ], + statementsResolvingDataLoss: 2, + }; + + const refused = await harness(project, { script }).run( + ['migration', 'plan', '--rename', 'Profile:User', '--json'], + { cwd: project.dir }, + ); + const actions = JSON.stringify( + (envelopeOf(refused) as { readonly error: { readonly nextActions: unknown } }).error + .nextActions, + ); + expect(actions).toContain('--delete User.nickname'); + expect(actions).toContain('--rename User.nickname:'); + + const renamed = await harness(project, { script }).run( + [ + 'migration', + 'plan', + '--rename', + 'Profile:User', + '--rename', + 'User.nickname:User.handle', + '--json', + ], + { cwd: project.dir }, + ); + expect(renamed.exitCode).toBe(0); + }); }); it('reports baseline ops beside the delta and renders one tree root per package', async () => { From 853ed1823ad55314dd3977eb036f63f3eae20f3a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:58:49 +0200 Subject: [PATCH 038/109] TML-3476: docs name a field of a renamed model through its new name Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- docs/reference/error-reference.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 7eedc1ec924f..5d84e52ce09b 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -199,7 +199,7 @@ Rename operations are `widening`, so neither command asks for consent. The `migr **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. -**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` lists it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. +**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` lists it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. **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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 93bf93822d6e..94626b569fc3 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -85,7 +85,7 @@ The migration-file CLI (`prisma migration`) received `--config` without a path a ### CLI.CONSENT_REQUIRED -Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is the reference: [CLI.CONSENT_REQUIRED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_REQUIRED). `migration plan` raises it when the plan would lose data and the run has nobody to answer: it is not interactive, or `--yes` is set. The command asks one question per model, field or storage name whose data an operation would lose, and the error lists every question still unanswered, with the flags that answer each: `--delete ` lets the plan lose that data, and, for a model or field, `--rename :` keeps it under a new name. Data no model of the origin contract stores can only be deleted. Nothing is written. `--confirm` does not answer these questions. Payload: `subject`, `verbs`, `unanswered` (each with its `subject` and `verbs`). +Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is the reference: [CLI.CONSENT_REQUIRED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_REQUIRED). `migration plan` raises it when the plan would lose data and the run has nobody to answer: it is not interactive, or `--yes` is set. The command asks one question per model, field or storage name whose data an operation would lose, and the error lists every question still unanswered, with the flags that answer each: `--delete ` lets the plan lose that data, and, for a model or field, `--rename :` keeps it under a new name. Data no model of the origin contract stores can only be deleted. A field of a model the plan renames is named through the model's new name: after `--rename Profile:User`, the question about the dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. Nothing is written. `--confirm` does not answer these questions. Payload: `subject`, `verbs`, `unanswered` (each with its `subject` and `verbs`). ### CLI.CONSENT_UNUSED diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index afb7b5afd26f..024d259bf838 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1035,7 +1035,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Data loss:** a plan that would lose data is written only once you say what each such operation means. The command asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), before it writes anything, including the baseline package of an auto-baseline. Each question is answered with a statement: -- `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. +- `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. A field of a model the plan renames is named through the model's new name, as a field rename is written: after `--rename Profile:User`, a dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. - `--rename :` keeps the data of a model or field under a new name. A rename on the command line is planned from the start, so the drop it replaces is never asked about. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn: type `delete` to let the data go, or `rename :`; a rejected answer is asked again. A typed rename is planned again with the other renames, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` no longer consents to anything here. The deletes are listed under `Statements applied` after the renames, for example `delete model "Legacy" (1 operation)`, and in `--json` output each `appliedStatements` entry carries its `verb`, `rename` or `delete`; a delete's `statement` is `{ kind: 'delete', subject }`. The `⚠` marker stays on each destructive operation. From a3b124bc12982eecef3927b67f92d352829214ba Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 00:59:37 +0200 Subject: [PATCH 039/109] TML-3476: declare @prisma/cli-engine 0.7.0 everywhere, with the preview build only in the root override Every package that depends on the engine now declares 0.7.0. Until 0.7.0 is on the registry, the root pnpm override alone points it at the pkg.pr.new build of prisma/prisma-cli#337, so switching to the registry deletes one line. Signed-off-by: willbot Signed-off-by: Will Madden --- apps/lsp-playground/package.json | 2 +- apps/telemetry-backend/package.json | 2 +- examples/bundle-size/package.json | 2 +- examples/mongo-blog-leaderboard/package.json | 2 +- examples/mongo-demo/package.json | 2 +- examples/multi-extension-monorepo/package.json | 2 +- examples/paradedb-demo/package.json | 2 +- examples/prisma-8-cloudflare-worker/package.json | 2 +- examples/prisma-8-demo-sqlite/package.json | 2 +- examples/prisma-8-demo/package.json | 2 +- examples/prisma-8-postgis-demo/package.json | 2 +- examples/prisma7-adoption/package.json | 2 +- examples/react-router-demo/package.json | 2 +- examples/retail-store/package.json | 2 +- examples/supabase/package.json | 2 +- packages/1-framework/3-tooling/cli/package.json | 2 +- packages/1-framework/3-tooling/config-loader/package.json | 2 +- packages/3-extensions/paradedb/package.json | 2 +- packages/3-extensions/pgvector/package.json | 2 +- packages/3-extensions/postgis/package.json | 2 +- packages/3-extensions/supabase/package.json | 2 +- test/e2e/framework/test/fixtures/package.json | 2 +- test/e2e/framework/test/sqlite/fixtures/package.json | 2 +- test/integration/package.json | 2 +- .../integration/test/fixtures/cli/cli-e2e-test-app/package.json | 2 +- .../test/fixtures/cli/cli-integration-test-app/package.json | 2 +- 26 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/lsp-playground/package.json b/apps/lsp-playground/package.json index 5db2a18eda73..4900131eccf6 100644 --- a/apps/lsp-playground/package.json +++ b/apps/lsp-playground/package.json @@ -35,7 +35,7 @@ "ws": "^8.21.2" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", "@types/ws": "^8.5.13", diff --git a/apps/telemetry-backend/package.json b/apps/telemetry-backend/package.json index 5f6aca9e5fd2..a623b66493a6 100644 --- a/apps/telemetry-backend/package.json +++ b/apps/telemetry-backend/package.json @@ -27,7 +27,7 @@ "evlog": "^2.24.0" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/compute-sdk": "^0.43.0", "@prisma/management-api-sdk": "^1.76.0", "@repo/test-utils": "workspace:8.0.0-rc.16", diff --git a/examples/bundle-size/package.json b/examples/bundle-size/package.json index 7634038d20bc..620b06c44421 100644 --- a/examples/bundle-size/package.json +++ b/examples/bundle-size/package.json @@ -28,7 +28,7 @@ "pg": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/dev": "catalog:", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", diff --git a/examples/mongo-blog-leaderboard/package.json b/examples/mongo-blog-leaderboard/package.json index 205cae62f4a2..34fb85883efc 100644 --- a/examples/mongo-blog-leaderboard/package.json +++ b/examples/mongo-blog-leaderboard/package.json @@ -19,7 +19,7 @@ "mongodb": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", "mongodb-memory-server": "catalog:", diff --git a/examples/mongo-demo/package.json b/examples/mongo-demo/package.json index ebe605c214a0..6434b5ccc4f2 100644 --- a/examples/mongo-demo/package.json +++ b/examples/mongo-demo/package.json @@ -24,7 +24,7 @@ "react-dom": "^19.2.8" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", diff --git a/examples/multi-extension-monorepo/package.json b/examples/multi-extension-monorepo/package.json index fc19f687a336..f9d23f8491e7 100644 --- a/examples/multi-extension-monorepo/package.json +++ b/examples/multi-extension-monorepo/package.json @@ -18,7 +18,7 @@ "@prisma/orm-postgres": "workspace:8.0.0-rc.16" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", diff --git a/examples/paradedb-demo/package.json b/examples/paradedb-demo/package.json index b504789a30e9..50e2f94a35e4 100644 --- a/examples/paradedb-demo/package.json +++ b/examples/paradedb-demo/package.json @@ -25,7 +25,7 @@ "pg": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", diff --git a/examples/prisma-8-cloudflare-worker/package.json b/examples/prisma-8-cloudflare-worker/package.json index c98fa8bb5ffb..db010dfde7ca 100644 --- a/examples/prisma-8-cloudflare-worker/package.json +++ b/examples/prisma-8-cloudflare-worker/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "0.20.3", "@cloudflare/workers-types": "5.20260804.1", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", "@types/pg": "catalog:", diff --git a/examples/prisma-8-demo-sqlite/package.json b/examples/prisma-8-demo-sqlite/package.json index b8521d002375..7d364e5c9a45 100644 --- a/examples/prisma-8-demo-sqlite/package.json +++ b/examples/prisma-8-demo-sqlite/package.json @@ -20,7 +20,7 @@ "dotenv": "^17.4.2" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", "tsx": "catalog:", diff --git a/examples/prisma-8-demo/package.json b/examples/prisma-8-demo/package.json index 45779b5b7ff1..9b3b81552ad4 100644 --- a/examples/prisma-8-demo/package.json +++ b/examples/prisma-8-demo/package.json @@ -32,7 +32,7 @@ "react-dom": "^19.2.8" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/dev": "catalog:", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", diff --git a/examples/prisma-8-postgis-demo/package.json b/examples/prisma-8-postgis-demo/package.json index da2b43340000..8eb1f7a50287 100644 --- a/examples/prisma-8-postgis-demo/package.json +++ b/examples/prisma-8-postgis-demo/package.json @@ -33,7 +33,7 @@ "react-leaflet": "^5.0.0" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/leaflet": "^1.9.22", diff --git a/examples/prisma7-adoption/package.json b/examples/prisma7-adoption/package.json index b3b08030868a..6f24b4ec21d1 100644 --- a/examples/prisma7-adoption/package.json +++ b/examples/prisma7-adoption/package.json @@ -28,7 +28,7 @@ "pg": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/dev": "catalog:", "@prisma/prisma7": "7.10.0", "@repo/test-utils": "workspace:8.0.0-rc.16", diff --git a/examples/react-router-demo/package.json b/examples/react-router-demo/package.json index f42084b1077a..42a82906aa25 100644 --- a/examples/react-router-demo/package.json +++ b/examples/react-router-demo/package.json @@ -30,7 +30,7 @@ "react-router": "^8.1.0" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/dev": "catalog:", "@react-router/dev": "^8.1.0", "@repo/test-utils": "workspace:8.0.0-rc.16", diff --git a/examples/retail-store/package.json b/examples/retail-store/package.json index feefc92d12c0..9c483c11ecbd 100644 --- a/examples/retail-store/package.json +++ b/examples/retail-store/package.json @@ -39,7 +39,7 @@ "tailwindcss": "^4.3.3" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", diff --git a/examples/supabase/package.json b/examples/supabase/package.json index 1a92abbdfd15..2e4cf6c8c588 100644 --- a/examples/supabase/package.json +++ b/examples/supabase/package.json @@ -18,7 +18,7 @@ "pg": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@types/node": "catalog:", diff --git a/packages/1-framework/3-tooling/cli/package.json b/packages/1-framework/3-tooling/cli/package.json index 12df36fce7d6..a7da364da94a 100644 --- a/packages/1-framework/3-tooling/cli/package.json +++ b/packages/1-framework/3-tooling/cli/package.json @@ -52,7 +52,7 @@ "@internal/sql-contract-ts": "workspace:8.0.0-rc.16", "@internal/sql-operations": "workspace:8.0.0-rc.16", "@internal/sql-runtime": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@2455811", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/packages/1-framework/3-tooling/config-loader/package.json b/packages/1-framework/3-tooling/config-loader/package.json index 92c61bbf60c3..1d6b83be86a0 100644 --- a/packages/1-framework/3-tooling/config-loader/package.json +++ b/packages/1-framework/3-tooling/config-loader/package.json @@ -26,7 +26,7 @@ "tinyglobby": "catalog:" }, "devDependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/packages/3-extensions/paradedb/package.json b/packages/3-extensions/paradedb/package.json index a1e20871d11c..eca119f31de0 100644 --- a/packages/3-extensions/paradedb/package.json +++ b/packages/3-extensions/paradedb/package.json @@ -34,7 +34,7 @@ "@internal/operations": "workspace:8.0.0-rc.16", "@internal/sql-contract-ts": "workspace:8.0.0-rc.16", "@internal/target-postgres": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/packages/3-extensions/pgvector/package.json b/packages/3-extensions/pgvector/package.json index 45805ed55d74..776b0a531c98 100644 --- a/packages/3-extensions/pgvector/package.json +++ b/packages/3-extensions/pgvector/package.json @@ -39,7 +39,7 @@ "@internal/postgres": "workspace:8.0.0-rc.16", "@internal/postgres-codec-testkit": "workspace:8.0.0-rc.16", "@internal/sql-contract-ts": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/packages/3-extensions/postgis/package.json b/packages/3-extensions/postgis/package.json index 51121ac2261b..b6014b9ee210 100644 --- a/packages/3-extensions/postgis/package.json +++ b/packages/3-extensions/postgis/package.json @@ -37,7 +37,7 @@ "@internal/operations": "workspace:8.0.0-rc.16", "@internal/postgres": "workspace:8.0.0-rc.16", "@internal/sql-contract-ts": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/packages/3-extensions/supabase/package.json b/packages/3-extensions/supabase/package.json index f46c554f425f..1c6dcf8a98f4 100644 --- a/packages/3-extensions/supabase/package.json +++ b/packages/3-extensions/supabase/package.json @@ -48,7 +48,7 @@ "@internal/operations": "workspace:8.0.0-rc.16", "@internal/psl-printer": "workspace:8.0.0-rc.16", "@internal/sql-contract-psl": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/test/e2e/framework/test/fixtures/package.json b/test/e2e/framework/test/fixtures/package.json index b70d7616a897..3537adbfb23f 100644 --- a/test/e2e/framework/test/fixtures/package.json +++ b/test/e2e/framework/test/fixtures/package.json @@ -5,7 +5,7 @@ "version": "8.0.0-rc.16", "description": "The Postgres fixture of the e2e suite. Its own manifest because emission reads the nearest one to decide which package names generated files import, and this suite exercises two databases whose halves must not name each other's facade.", "dependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/orm-extension-arktype-json": "workspace:8.0.0-rc.16", "@prisma/orm-extension-pgvector": "workspace:8.0.0-rc.16", "@prisma/orm-postgres": "workspace:8.0.0-rc.16", diff --git a/test/e2e/framework/test/sqlite/fixtures/package.json b/test/e2e/framework/test/sqlite/fixtures/package.json index a6e27e3a34b7..fb5a4b48b0bb 100644 --- a/test/e2e/framework/test/sqlite/fixtures/package.json +++ b/test/e2e/framework/test/sqlite/fixtures/package.json @@ -5,7 +5,7 @@ "version": "8.0.0-rc.16", "description": "The SQLite fixture of the e2e suite. Its own manifest because emission reads the nearest one to decide which package names generated files import, and this suite exercises two databases whose halves must not name each other's facade.", "dependencies": { - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/orm-sqlite": "workspace:8.0.0-rc.16" } } diff --git a/test/integration/package.json b/test/integration/package.json index a3db598f219c..91e68a3678ad 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -82,7 +82,7 @@ "@internal/middleware-cache": "workspace:8.0.0-rc.16", "@internal/mongo-lowering": "workspace:8.0.0-rc.16", "@internal/publish-surface": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@repo/test-utils": "workspace:8.0.0-rc.16", "@repo/tsconfig": "workspace:8.0.0-rc.16", "@repo/tsdown": "workspace:8.0.0-rc.16", diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/package.json b/test/integration/test/fixtures/cli/cli-e2e-test-app/package.json index 3dc88db12414..b3e547b65bfc 100644 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/package.json +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/package.json @@ -19,7 +19,7 @@ "@internal/target-mongo": "workspace:8.0.0-rc.16", "@internal/target-postgres": "workspace:8.0.0-rc.16", "@internal/utils": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/orm-extension-pgvector": "workspace:8.0.0-rc.16", "@prisma/orm-mongo": "workspace:8.0.0-rc.16", "@prisma/orm-postgres": "workspace:8.0.0-rc.16", diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/package.json b/test/integration/test/fixtures/cli/cli-integration-test-app/package.json index f53abfdbbeef..1d4bc33991e2 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/package.json +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/package.json @@ -20,7 +20,7 @@ "@internal/target-mongo": "workspace:8.0.0-rc.16", "@internal/target-postgres": "workspace:8.0.0-rc.16", "@internal/utils": "workspace:8.0.0-rc.16", - "@prisma/cli-engine": "0.6.3", + "@prisma/cli-engine": "0.7.0", "@prisma/orm-extension-pgvector": "workspace:8.0.0-rc.16", "@prisma/orm-mongo": "workspace:8.0.0-rc.16", "@prisma/orm-postgres": "workspace:8.0.0-rc.16", From 4acb1b176e2eb4a69eb24108a0c3ccf67422d2cf Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:11:06 +0200 Subject: [PATCH 040/109] TML-3476: dispatch 4 carries the delete description note from dispatch 3's review Signed-off-by: willbot Signed-off-by: Will Madden --- .../refusal/dispatches/04-refusal-on-db-update.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md b/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md index c6adbfd2a29b..f2a606c7ff77 100644 --- a/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md +++ b/projects/migration-statements/slices/refusal/dispatches/04-refusal-on-db-update.md @@ -2,7 +2,15 @@ **Slice:** [`../spec.md`](../spec.md) § "`--delete` and how statements consent", § "Access widening (`allow`)" · **Plan:** [`../plan.md`](../plan.md) dispatch 4 · **Builds on:** dispatch 3 -## Step 0 — bring the base in +## Step 0 — already done in dispatch 3 + +The slice 1 branch is merged (`0bf6536f64`). Skip to the outcome. + +## Carried from dispatch 3's review (D3-8) + +A delete is reported through the origin model (`delete field "Profile.nickname"`) although the question wrote it as `User.nickname`. Describe it the way the question writes it, in both commands' `Statements applied` and JSON, with a test. + +## Old step 0 (kept for the record) `bot/tml-3475-statement-renames` moved after this branch was cut (the merge-queue test fix, a merge of `main`, and two follow-ups). Merge it into `tml-3476-statement-refusal` with `git merge --no-ff`; do not rebase. Resolve conflicts keeping both sides; the base's rewritten test "writes nothing when the delta planner refuses after the baseline was planned" is the one dispatch 3 already satisfies. Run the gate on the merge before starting the rest. From deb62d263155dbb0a5cb6c134e07bb470c1bdb34 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:16:16 +0200 Subject: [PATCH 041/109] TML-3476: one question loop for every plan question, and a delete is described as its question names it answerPlanQuestions asks about data loss and, for a command that applies, access widening, re-plans with typed renames, and takes delete and allow texts given in advance. migration plan uses it. A delete statement is described with the subject as its question wrote it, so a field of a renamed model reads delete field "User.nickname", and the allow verb parses like delete. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migration-plan.ts | 90 +++------ .../statements/data-loss-questions.ts | 188 ++++++++++++++++-- .../control-api/statements/parse-consent.ts | 34 ++++ .../control-api/statements/parse-delete.ts | 28 --- .../statements/report-applied-statements.ts | 32 ++- .../statements/resolve-statements.ts | 12 +- .../control-api/statements/statement-text.ts | 2 +- .../3-tooling/cli/src/orm/migration/plan.ts | 13 +- .../migration-plan-prologue.test.ts | 4 +- .../cli/test/orm/migration-plan.test.ts | 18 +- 10 files changed, 275 insertions(+), 146 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/parse-consent.ts delete mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts 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 cf7cd0125247..224a11c34c27 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 @@ -9,7 +9,6 @@ import { createControlStack, hasOperationPreview, type MigrationPlanOperation, - type MigrationStatementSubject, type OperationPreview, planOriginOf, type ResolvedMigrationStatement, @@ -36,7 +35,6 @@ import { errorFileNotFound, errorMigrationPlanningFailed, errorPlanProducedNoOperations, - errorStatementDidNotResolveLoss, errorTargetMigrationNotSupported, type PlanDestination, type PlanLegOrigin, @@ -51,15 +49,15 @@ import { assertFrameworkComponentsCompatible } from '../../utils/framework-compo import { createProjectSpecifierResolver } from '../../utils/project-import-root'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { - type AnswerDataLoss, - dataLossQuestion, - type PlannedDataLoss, + type AnswerPlanQuestions, + answerPlanQuestions, + type PlannedSubject, subjectText, } from '../statements/data-loss-questions'; import { type AppliedStatementReport, reportAppliedStatements, - reportDeleteStatement, + reportConsentStatement, } from '../statements/report-applied-statements'; import { resolveStatements } from '../statements/resolve-statements'; import type { StatementText } from '../statements/statement-text'; @@ -100,7 +98,7 @@ export interface MigrationPlanOptions { * written. A delete answer consents to the loss; a rename answer is planned * again with the other renames and must remove the loss. */ - readonly answerDataLoss: AnswerDataLoss; + readonly answerQuestions: AnswerPlanQuestions; /** Renders the declarations of the destination snapshot from its `contract.json`. */ readonly client: Pick; } @@ -111,7 +109,7 @@ type PlannerSuccess = { readonly hasPlaceholders: boolean; readonly appliedStatements: readonly AppliedStatementReport[]; /** The operations that would lose data, positioned in the plan's operations. */ - readonly dataLoss: readonly PlannedDataLoss[]; + readonly dataLoss: readonly PlannedSubject[]; }; /** The origin of a plan from an empty database: no models, so no statement resolves. */ @@ -295,7 +293,7 @@ async function answerPlannedDataLoss(input: { readonly renames: readonly StatementText[]; readonly origin: ContractWithDomain; readonly destination: ContractWithDomain; - readonly answer: AnswerDataLoss; + readonly answer: AnswerPlanQuestions; readonly planDelta: ( renames: readonly StatementText[], ) => Promise>; @@ -308,61 +306,33 @@ async function answerPlannedDataLoss(input: { CliStructuredError > > { - let delta = input.delta; - let renames = input.renames; - const consented = new Map(); - const keyOf = (loss: PlannedDataLoss) => JSON.stringify(loss.subject); - const contracts = () => ({ + const legs = (delta: PlannerSuccess | undefined) => ({ + delta, + dataLoss: [...(input.baseline?.dataLoss ?? []), ...(delta?.dataLoss ?? [])], + accessWidening: [], + }); + const answered = await answerPlanQuestions({ + plan: legs(input.delta), + askAccess: false, + renames: input.renames, + preAnswers: [], + consentAll: false, origin: input.origin, destination: input.destination, - renames, + answer: input.answer, + replan: async (renames) => { + const replanned = await input.planDelta(renames); + return replanned.ok ? ok(legs(replanned.value)) : replanned; + }, }); - // The first round asks even when nothing is lost, so a statement no question - // consumed is refused before anything is written. - for (let round = 0; ; round += 1) { - const losses = [...(input.baseline?.dataLoss ?? []), ...(delta?.dataLoss ?? [])]; - const unanswered = [ - ...new Map( - losses.filter((loss) => !consented.has(keyOf(loss))).map((loss) => [keyOf(loss), loss]), - ).values(), - ]; - if (unanswered.length === 0 && round > 0) break; - const asked = contracts(); - const questions = unanswered.map((loss) => dataLossQuestion(loss, asked)); - const answers = await input.answer(questions); - const typedRenames: { readonly text: string; readonly loss: PlannedDataLoss }[] = []; - answers.forEach((answer, index) => { - const loss = unanswered[index]; - if (loss === undefined) return; - if (answer.verb === 'delete') consented.set(keyOf(loss), loss.subject); - else typedRenames.push({ text: answer.text, loss }); - }); - if (typedRenames.length === 0) { - if (unanswered.length === 0) break; - continue; - } - renames = [...renames, ...typedRenames.map(({ text }) => ({ verb: 'rename' as const, text }))]; - const replanned = await input.planDelta(renames); - if (!replanned.ok) return replanned; - delta = replanned.value; - const stillLost = new Set(delta.dataLoss.map(keyOf)); - const unresolved = typedRenames.find(({ loss }) => stillLost.has(keyOf(loss))); - if (unresolved !== undefined) { - return notOk( - errorStatementDidNotResolveLoss( - { verb: 'rename', text: unresolved.text }, - subjectText(unresolved.loss.subject, asked), - ), - ); - } - } - const deletes = [...consented].map(([key, subject]) => - reportDeleteStatement( - subject, + if (!answered.ok) return answered; + const { delta } = answered.value.plan; + const deletes = answered.value.consented.map((consented) => + reportConsentStatement( + consented, (delta?.dataLoss ?? []) - .filter((loss) => keyOf(loss) === key) + .filter((loss) => JSON.stringify(loss.subject) === JSON.stringify(consented.subject)) .flatMap(({ operationIndex }) => (operationIndex === undefined ? [] : [operationIndex])), - input.origin, ), ); return ok({ delta, deletes }); @@ -769,7 +739,7 @@ async function executeMigrationPlanCommandInner( renames: statementTexts, origin: fromContract ?? EMPTY_ORIGIN, destination: toContract, - answer: options.answerDataLoss, + answer: options.answerQuestions, planDelta: async (renames) => { const resolved = resolveStatements({ statements: renames, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 5c6275489a36..9670e1b7f5ef 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -1,49 +1,59 @@ import type { ContractWithDomain } from '@internal/contract/types'; +import type { CliStructuredError } from '@internal/errors/control'; import { type MigrationStatementSubject, type ModelCoordinate, modelDisplayName, type ResolvedMigrationStatement, } from '@internal/framework-components/control'; +import { notOk, ok, type Result } from '@internal/utils/result'; +import { errorStatementDidNotResolveLoss } from '../../utils/cli-errors'; +import type { ConsentVerb } from './parse-consent'; import { resolveStatements } from './resolve-statements'; import type { StatementText } from './statement-text'; -/** A verb a data-loss question accepts. */ -export type DataLossVerb = 'rename' | 'delete'; +/** A verb a plan question accepts. */ +export type PlanQuestionVerb = 'rename' | ConsentVerb; /** - * What a command asks about one operation that would lose data: the question, its subject written - * as the statement names it, and the verbs that answer it. A command hands it to its prompt. + * What a command asks about one operation of a plan: the question, its subject written as the + * statement names it, and the verbs that answer it. A command hands it to its prompt. */ -export interface DataLossQuestion { +export interface PlanQuestion { readonly question: string; readonly subject: string; - readonly verbs: readonly DataLossVerb[]; + readonly verbs: readonly PlanQuestionVerb[]; readonly forms: { readonly rename?: string }; - readonly validate: (verb: DataLossVerb, text: string) => string | undefined; + readonly validate: (verb: PlanQuestionVerb, text: string) => string | undefined; } -export interface DataLossAnswer { - readonly verb: DataLossVerb; +export interface PlanAnswer { + readonly verb: PlanQuestionVerb; readonly text: string; } /** Asks every question at once and returns the answers in question order. */ -export type AnswerDataLoss = ( - questions: readonly DataLossQuestion[], -) => Promise; +export type AnswerPlanQuestions = ( + questions: readonly PlanQuestion[], +) => Promise; /** - * An operation of a plan that would lose data, with the label of that operation. `operationIndex` - * is its position among the operations the result lists; `undefined` when the plan could not - * resolve it. + * An operation of a plan with its subject and the label of that operation. `operationIndex` is + * its position among the operations the result lists; `undefined` when the plan could not resolve + * it. */ -export interface PlannedDataLoss { +export interface PlannedSubject { readonly operationIndex: number | undefined; readonly label: string; readonly subject: MigrationStatementSubject; } +/** What a plan would lose, and whose access it would widen. */ +export interface PlannedQuestions { + readonly dataLoss: readonly PlannedSubject[]; + readonly accessWidening: readonly PlannedSubject[]; +} + function modelText(contract: ContractWithDomain, coordinate: ModelCoordinate): string { return Object.keys(contract.domain.namespaces).length > 1 ? modelDisplayName(coordinate) @@ -80,7 +90,7 @@ function renamedModel( /** * The subject as a statement names it: `Model` or `Model.field`, with the namespace when its - * contract has several, or the storage name when no model stores what the operation loses. A + * contract has several, or the storage name when no model stores what the operation is about. A * field of a model the plan renames is named through the model's new name, as a rename of the * field is written. */ @@ -104,6 +114,15 @@ export function subjectText( } } +/** The statement's description, written as its question names the subject: `delete field "User.nickname"`. */ +export function consentDescription( + verb: ConsentVerb, + subject: MigrationStatementSubject, + text: string, +): string { + return `${verb} ${subject.kind} "${text}"`; +} + function lossText(subject: MigrationStatementSubject, text: string): string { switch (subject.kind) { case 'model': @@ -127,26 +146,30 @@ function sameCoordinate( : statement.entity === 'model'; } +function namesSubject(verb: ConsentVerb, subject: string) { + return (text: string): string | undefined => + text === subject ? undefined : `${verb} names "${subject}": --${verb} ${subject}.`; +} + /** * The question for one operation that would lose data. A model or field may be renamed instead of * deleted; a rename is resolved against the two contracts after the plan's other renames, and its * old name must be the subject. Data no model stores can only be deleted. */ export function dataLossQuestion( - loss: PlannedDataLoss, + loss: PlannedSubject, contracts: StatementContracts & { readonly destination: ContractWithDomain }, -): DataLossQuestion { +): PlanQuestion { const subject = subjectText(loss.subject, contracts); const renamable = loss.subject.kind !== 'storage'; + const deleteNames = namesSubject('delete', subject); return { question: `${loss.label} ${lossText(loss.subject, subject)}.`, subject, verbs: renamable ? ['rename', 'delete'] : ['delete'], forms: renamable ? { rename: `${subject}:` } : {}, validate: (verb, text) => { - if (verb === 'delete') { - return text === subject ? undefined : `Delete names "${subject}": --delete ${subject}.`; - } + if (verb !== 'rename') return deleteNames(text); const resolved = resolveStatements({ statements: [...contracts.renames, { verb: 'rename', text }], origin: { kind: 'contract', contract: contracts.origin }, @@ -160,3 +183,124 @@ export function dataLossQuestion( }, }; } + +/** The question for one operation that would widen who can read or write its subject's rows. */ +export function accessWideningQuestion( + widening: PlannedSubject, + contracts: StatementContracts, +): PlanQuestion { + const subject = subjectText(widening.subject, contracts); + return { + question: `${widening.label} would widen who can read and write its rows.`, + subject, + verbs: ['allow'], + forms: {}, + validate: (_verb, text) => namesSubject('allow', subject)(text), + }; +} + +/** A subject the user consented to, with the statement's text as its question wrote it. */ +export interface ConsentedSubject { + readonly verb: ConsentVerb; + readonly subject: MigrationStatementSubject; + readonly text: string; +} + +const keyOf = (planned: PlannedSubject) => JSON.stringify(planned.subject); + +/** + * Asks about every operation of a plan that would lose data and, when `askAccess`, every one that + * would widen access, until each is answered. A `delete` or `allow` text in `preAnswers`, or + * `consentAll`, answers its question without asking. A rename typed at the prompt is a statement + * the plan did not have, so the plan is made again with it, and the loss it answered must be gone. + * The first round asks even when nothing is in question, so a statement no question consumed is + * refused before anything is done. + */ +export async function answerPlanQuestions(input: { + readonly plan: TPlan; + readonly askAccess: boolean; + readonly renames: readonly StatementText[]; + readonly preAnswers: readonly StatementText[]; + readonly consentAll: boolean; + readonly origin: ContractWithDomain; + readonly destination: ContractWithDomain; + readonly answer: AnswerPlanQuestions; + readonly replan: ( + renames: readonly StatementText[], + ) => Promise>; +}): Promise< + Result< + { + readonly plan: TPlan; + readonly renames: readonly StatementText[]; + readonly consented: readonly ConsentedSubject[]; + }, + CliStructuredError + > +> { + let plan = input.plan; + let renames = input.renames; + const consented = new Map(); + for (let round = 0; ; round += 1) { + const contracts = { origin: input.origin, destination: input.destination, renames }; + const pending = ( + verb: ConsentVerb, + planned: readonly PlannedSubject[], + ): readonly PlannedSubject[] => [ + ...new Map( + planned + .filter((entry) => !consented.has(`${verb}:${keyOf(entry)}`)) + .map((entry) => [keyOf(entry), entry]), + ).values(), + ]; + const consent = (verb: ConsentVerb, entry: PlannedSubject, text: string) => + consented.set(`${verb}:${keyOf(entry)}`, { verb, subject: entry.subject, text }); + for (const [verb, entries] of [ + ['delete', pending('delete', plan.dataLoss)], + ['allow', input.askAccess ? pending('allow', plan.accessWidening) : []], + ] as const) { + for (const entry of entries) { + const text = subjectText(entry.subject, contracts); + const given = input.preAnswers.some( + (statement) => statement.verb === verb && statement.text === text, + ); + if (input.consentAll || given) consent(verb, entry, text); + } + } + const losses = pending('delete', plan.dataLoss); + const widenings = input.askAccess ? pending('allow', plan.accessWidening) : []; + if (losses.length + widenings.length === 0 && round > 0) break; + const questions = [ + ...losses.map((loss) => dataLossQuestion(loss, contracts)), + ...widenings.map((widening) => accessWideningQuestion(widening, contracts)), + ]; + const answers = await input.answer(questions); + const typedRenames: { readonly text: string; readonly loss: PlannedSubject }[] = []; + answers.forEach((answer, index) => { + const entry = [...losses, ...widenings][index]; + const question = questions[index]; + if (entry === undefined || question === undefined) return; + if (answer.verb === 'rename') typedRenames.push({ text: answer.text, loss: entry }); + else consent(answer.verb, entry, question.subject); + }); + if (typedRenames.length === 0) { + if (losses.length + widenings.length === 0) break; + continue; + } + renames = [...renames, ...typedRenames.map(({ text }) => ({ verb: 'rename' as const, text }))]; + const replanned = await input.replan(renames); + if (!replanned.ok) return replanned; + plan = replanned.value; + const stillLost = new Set(plan.dataLoss.map(keyOf)); + const unresolved = typedRenames.find(({ loss }) => stillLost.has(keyOf(loss))); + if (unresolved !== undefined) { + return notOk( + errorStatementDidNotResolveLoss( + { verb: 'rename', text: unresolved.text }, + subjectText(unresolved.loss.subject, contracts), + ), + ); + } + } + return ok({ plan, renames, consented: [...consented.values()] }); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-consent.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-consent.ts new file mode 100644 index 000000000000..47a6d8940efd --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-consent.ts @@ -0,0 +1,34 @@ +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'; + +/** A verb whose statement consents to what a plan does to its subject. */ +export type ConsentVerb = 'delete' | 'allow'; + +export interface ParsedConsent extends StatementText { + readonly verb: ConsentVerb; +} + +function formsFix(verb: ConsentVerb): string { + return verb === 'delete' + ? 'Write the statement as --delete , where the name is Model, namespace.Model, Model.field or namespace.Model.field, or the storage name the refusal gave, for example --delete Legacy or --delete User.nickname.' + : 'Write the statement as --allow , where the name is the model the refusal gave, for example --allow User.'; +} + +/** + * Checks the text of one delete or allow statement. The text stays unresolved: it names what a + * plan would do to a subject, so it is matched by equality against the plan's subjects, not + * resolved against the contracts. Any name a subject can have is accepted, since a storage name + * may contain any character a quoted identifier can. + */ +export function parseConsentStatement( + statement: StatementText & { readonly verb: ConsentVerb }, +): Result { + if (statement.text.trim() === '') { + return notOk( + errorStatementInvalid(statement, 'The statement names nothing.', formsFix(statement.verb)), + ); + } + return ok({ verb: statement.verb, text: statement.text }); +} diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts deleted file mode 100644 index 236aca79cbe4..000000000000 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/parse-delete.ts +++ /dev/null @@ -1,28 +0,0 @@ -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'; - -export interface ParsedDelete extends StatementText { - readonly verb: 'delete'; -} - -const DELETE_FORMS_FIX = - 'Write the statement as --delete , where the name is Model, namespace.Model, Model.field or namespace.Model.field, or the storage name the refusal gave, for example --delete Legacy or --delete User.nickname.'; - -/** - * Checks the text of one delete statement. The text stays unresolved: a delete names what a plan - * would lose, so it is matched by equality against the plan's subjects, not resolved against the - * contracts. Any name a subject can have is accepted, since a storage name may contain any - * character a quoted identifier can. - */ -export function parseDeleteStatement( - statement: StatementText & { readonly verb: 'delete' }, -): Result { - if (statement.text.trim() === '') { - return notOk( - errorStatementInvalid(statement, 'The statement names nothing.', DELETE_FORMS_FIX), - ); - } - return ok({ verb: 'delete', text: statement.text }); -} 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 index c65ccf7f7e6c..2ad51c5be83d 100644 --- 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 @@ -3,15 +3,16 @@ import { type AppliedMigrationStatement, describeMigrationStatement, type MigrationStatementJson, - type MigrationStatementSubject, type MigrationStatementSubjectJson, migrationStatementJson, migrationStatementSubjectJson, } from '@internal/framework-components/control'; +import { type ConsentedSubject, consentDescription } from './data-loss-questions'; +import type { ConsentVerb } from './parse-consent'; -/** A delete statement in JSON output: the subject whose data it consents to lose. */ -export interface DeleteStatementJson { - readonly kind: 'delete'; +/** A delete or allow statement in JSON output: the subject whose data or access it consents to. */ +export interface ConsentStatementJson { + readonly kind: ConsentVerb; readonly subject: MigrationStatementSubjectJson; } @@ -28,10 +29,10 @@ export type AppliedStatementReport = readonly description: string; } | { - readonly verb: 'delete'; - readonly statement: DeleteStatementJson; + readonly verb: ConsentVerb; + readonly statement: ConsentStatementJson; readonly operationIndexes: readonly number[]; - /** The statement in domain names, for example `delete model "Legacy"`. */ + /** The statement as its question named the subject, for example `delete model "Legacy"`. */ readonly description: string; }; @@ -53,20 +54,15 @@ export function reportAppliedStatements( })); } -/** A delete statement a plan applied: the subject it consents to lose, and that loss's operations. */ -export function reportDeleteStatement( - subject: MigrationStatementSubject, +/** A delete or allow statement a plan applied, with the positions of the operations it consented to. */ +export function reportConsentStatement( + consented: ConsentedSubject, operationIndexes: readonly number[], - fromContract: ContractWithDomain, ): AppliedStatementReport { return { - verb: 'delete', - statement: { kind: 'delete', subject: migrationStatementSubjectJson(subject) }, + verb: consented.verb, + statement: { kind: consented.verb, subject: migrationStatementSubjectJson(consented.subject) }, operationIndexes, - description: describeMigrationStatement( - { kind: 'delete', subject }, - fromContract, - fromContract, - ), + description: consentDescription(consented.verb, consented.subject, consented.text), }; } 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 index 3f31a9db2e11..8105d8d9cc3b 100644 --- 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 @@ -19,7 +19,7 @@ import { errorStatementOriginUnknown, errorStatementUnresolved, } from '../../utils/cli-errors'; -import { parseDeleteStatement } from './parse-delete'; +import { parseConsentStatement } from './parse-consent'; import { mixedSidesFix, type ParsedRename, @@ -696,17 +696,17 @@ function resolveStatement( /** * Resolves the rename 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. A delete statement is checked and left out: it - * names what a plan would lose, so it is matched against the plan, and it needs - * no origin contract. + * by namespace, model and field. A delete or allow statement is checked and + * left out: it names what a plan would do to a subject, so it is matched against + * the plan, and it needs no origin contract. */ export function resolveStatements( input: ResolveStatementsInput, ): Result { const parsed: ParsedRename[] = []; for (const entry of input.statements) { - if (entry.verb === 'delete') { - const statement = parseDeleteStatement({ ...entry, verb: entry.verb }); + if (entry.verb === 'delete' || entry.verb === 'allow') { + const statement = parseConsentStatement({ ...entry, verb: entry.verb }); if (!statement.ok) return statement; continue; } 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 index 885116475023..053892c83eee 100644 --- 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 @@ -1,5 +1,5 @@ /** The verbs a statement can have. */ -export type StatementVerb = 'rename' | 'delete'; +export type StatementVerb = 'rename' | 'delete' | 'allow'; /** * A statement as the user wrote it, before it is parsed: its verb and its text, for example 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 b65e588e5fa4..7c92d55ddf4e 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 @@ -9,10 +9,7 @@ import { createControlClient } from '../../control-api/client'; import type { ContractSpaceSeedPhaseRecord } from '../../control-api/operations/contract-space-seed-phase'; import type { MigrationPlanResult } from '../../control-api/operations/migration-plan'; import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; -import type { - DataLossAnswer, - DataLossQuestion, -} from '../../control-api/statements/data-loss-questions'; +import type { PlanAnswer, PlanQuestion } from '../../control-api/statements/data-loss-questions'; import type { CreateControlClient } from '../../control-api/types'; import { RECORDED_CONTRACT_REF_FORMS, @@ -320,9 +317,9 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { data: { newHash: record.newHash, newMigrationDirs: record.newMigrationDirs }, }); }; - const answerDataLoss = async ( - questions: readonly DataLossQuestion[], - ): Promise => { + const answerQuestions = async ( + questions: readonly PlanQuestion[], + ): Promise => { const answers = await ctx.prompt.statements( questions.map((question) => ({ question: question.question, @@ -347,7 +344,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { statements: ctx.statements .take('rename') .map(({ text }) => ({ verb: 'rename' as const, text })), - answerDataLoss, + answerQuestions, client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts index 8250264d404a..d0cca924efb4 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migration-plan-prologue.test.ts @@ -42,7 +42,7 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { cwd: '/project', projectDir: '/project', client: stubClient, - answerDataLoss: async () => [], + answerQuestions: async () => [], }, Date.now(), ); @@ -70,7 +70,7 @@ describe('executeMigrationPlanCommand — mutation-prologue guard', () => { cwd: '/project', projectDir: '/project', client: stubClient, - answerDataLoss: async () => [], + answerQuestions: async () => [], }, Date.now(), ); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index d4df002bc1f5..334ad1d12b2b 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { readdir, readFile } from 'node:fs/promises'; +import { readdir, readFile, rm } from 'node:fs/promises'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeRef } from '@internal/migration-tools/refs'; @@ -820,6 +820,22 @@ describe('migration plan', () => { expect(actions).toContain('--delete User.nickname'); expect(actions).toContain('--rename User.nickname:'); + const deleted = await harness(project, { script }).run( + ['migration', 'plan', '--rename', 'Profile:User', '--delete', 'User.nickname', '--json'], + { cwd: project.dir }, + ); + expect(deleted.presented?.data).toMatchObject({ + appliedStatements: [ + { verb: 'rename' }, + { verb: 'delete', description: 'delete field "User.nickname"' }, + ], + }); + for (const dir of await plannedDirs(project)) { + if (dir !== '20260101T0000_initial') { + await rm(join(project.appMigrationsDir, dir), { recursive: true }); + } + } + const renamed = await harness(project, { script }).run( [ 'migration', From 2f20f4c26b8a7d500c0af1745629c1b46c422df4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:39:01 +0200 Subject: [PATCH 042/109] TML-3476: db update plans the same operations with and without the origin contract The Postgres type-change strategy now needs the data operation class, as every other strategy that emits a data transform does, so a policy without it never gets a placeholder whether or not the planner has the prior contract. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/plan-identity.ts | 24 -------- .../3-tooling/cli/src/orm/db/consent.ts | 59 ------------------- .../src/core/migrations/planner-strategies.ts | 21 ++++--- .../test/migrations/planner.data-loss.test.ts | 47 +++++++++++++++ .../test/migrations/planner.data-loss.test.ts | 40 +++++++++++++ 5 files changed, 97 insertions(+), 94 deletions(-) delete mode 100644 packages/1-framework/3-tooling/cli/src/control-api/operations/plan-identity.ts delete mode 100644 packages/1-framework/3-tooling/cli/src/orm/db/consent.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-identity.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-identity.ts deleted file mode 100644 index 5d8817a270a2..000000000000 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/plan-identity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createHash } from 'node:crypto'; -import { canonicalizeJson } from '@internal/framework-components/utils'; - -/** - * Content identity of a `db update` plan: what it does (the ordered display - * operations) and where it lands (the destination storage hash). A - * `DESTRUCTIVE_CHANGES` refusal names the refused plan by this hash, and the - * apply that carries the consent back recomputes it to prove it is still - * applying the plan that was consented to. - */ -export function computePlanHash(plan: { - readonly operations: ReadonlyArray<{ - readonly id: string; - readonly label: string; - readonly operationClass: string; - }>; - readonly destination: { readonly storageHash: string }; -}): string { - const canonical = canonicalizeJson({ - operations: plan.operations, - destination: { storageHash: plan.destination.storageHash }, - }); - return createHash('sha256').update(canonical).digest('hex'); -} diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts b/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts deleted file mode 100644 index 545bd08173af..000000000000 --- a/packages/1-framework/3-tooling/cli/src/orm/db/consent.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { CliStructuredError } from '@prisma/cli-engine/protocol'; -import type { DestructivePlanOperation } from '../../control-api/types'; - -/** - * A token nobody can type is not consent: an empty one makes the engine's - * type-to-confirm accept a bare Enter, and `--confirm ""` grant outright. - */ -export function errorConsentTokenUnresolved(targetId: string): CliStructuredError { - return new CliStructuredError( - 'CLI.CONSENT_TOKEN_UNRESOLVED', - 'The database this run would change has no name to confirm.', - { - why: `The connected database reports no name, and the target id "${targetId}" yields none either, so the consent prompt has nothing to ask you to type and the destructive operations cannot be authorised.`, - nextActions: [ - { - kind: 'user-choice', - label: - 'Name the database in `db.connection` (or pass `--db `) and run `{bin} db update` again.', - }, - ], - }, - ); -} - -/** A prompt that names nothing cannot be consented to knowingly. */ -export function errorConsentOperationsMissing(options?: { - readonly previewCommand?: string; -}): CliStructuredError { - return new CliStructuredError( - 'CLI.CONSENT_OPERATIONS_MISSING', - 'The plan was refused as destructive but named no operations to confirm.', - { - why: 'The consent prompt has to list what is about to be destroyed, and the refusal carried no operations to list.', - nextActions: [ - { - kind: 'run-command', - label: 'Preview the plan', - command: options?.previewCommand ?? '{bin} db update --dry-run', - }, - ], - }, - ); -} - -/** The indented per-operation list every destructive-consent question renders. */ -export function destructiveOperationList(operations: readonly DestructivePlanOperation[]): string { - return operations.map((operation) => ` - ${operation.label}`).join('\n'); -} - -/** The question the user answers before anything is dropped. */ -export function destructiveConsentQuestion( - operations: readonly DestructivePlanOperation[], - token: string, -): string { - return [ - `Apply ${operations.length} destructive operation(s) to ${token}? Data they remove cannot be recovered:`, - destructiveOperationList(operations), - ].join('\n'); -} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index 7618ffb02852..4217ff5ed53a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -236,11 +236,12 @@ export const notNullBackfillCallStrategy: CallMigrationStrategy = (issues, ctx) }; /** - * Handles `not-equal` column issues whose TYPE differs. `fromContract` is - * only supplied by `migration plan` — for reconciliation (`db update` / - * `db init`, `fromContract === null`) this strategy never fires, mirroring - * the legacy `typeChangeCallStrategy`'s requirement of a prior contract: - * `mapNodeIssueToCall`'s in-place ALTER covers reconciliation directly. + * Handles `not-equal` column issues whose TYPE differs, for a plan that has an + * origin contract and may include `data` operations: `migration plan`. Under + * `db update` or `db init`, whose policies leave out `data`, it never fires, + * whether or not an origin contract is known: it could only plan the in-place + * ALTER that `mapNodeIssueToCall` plans, so leaving that to the mapper keeps + * the plan, and its order, the same either way. * * A single node issue can carry BOTH type and nullability drift (Postgres * alters in place, so the differ emits one `not-equal` column issue where @@ -252,8 +253,9 @@ export const notNullBackfillCallStrategy: CallMigrationStrategy = (issues, ctx) * partially handled. */ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { - if (ctx.fromContract === null) return { kind: 'no_match' }; - const dataAllowed = ctx.policy.allowedOperationClasses.includes('data'); + if (ctx.fromContract === null || !ctx.policy.allowedOperationClasses.includes('data')) { + return { kind: 'no_match' }; + } const matched: SchemaDiffIssue[] = []; const calls: PostgresOpFactoryCall[] = []; @@ -268,7 +270,6 @@ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { const fromType = columnDataType(actual, ctx.types); const toType = columnDataType(expected, ctx.types); const isSafeWidening = isSafeTypeWidening(fromType, toType); - if (!isSafeWidening && !dataAllowed) continue; const ddlSchemaName = issueSchemaName(issue); const tableName = issueTableName(issue); @@ -300,7 +301,7 @@ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { if (expected.nullable !== actual.nullable) { if (expected.nullable) { calls.push(new DropNotNullCall(schemaName, tableName, expected.name)); - } else if (dataAllowed) { + } else { calls.push( new DataTransformCall( `handle-nulls-${tableName}-${expected.name}`, @@ -309,8 +310,6 @@ export const typeChangeCallStrategy: CallMigrationStrategy = (issues, ctx) => { ), new SetNotNullCall(schemaName, tableName, expected.name), ); - } else { - calls.push(new SetNotNullCall(schemaName, tableName, expected.name)); } } } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index 41aae726c6a6..41f04c63f6d1 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -32,6 +32,7 @@ const columnTypes = { int4: { dataType: 'pg/int4', codecId: 'pg/int4@1', nullable: false }, int8: { dataType: 'pg/int8', codecId: 'pg/int8@1', nullable: false }, text: { dataType: 'pg/text', codecId: 'pg/text@1', nullable: false }, + 'text?': { dataType: 'pg/text', codecId: 'pg/text@1', nullable: true }, } as const; interface TableSpec { @@ -267,3 +268,49 @@ describe('Postgres planner, data loss', () => { ]); }); }); + +describe('Postgres planner under db update', () => { + const DB_UPDATE = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; + + async function dbUpdatePlan( + from: Contract, + to: Contract, + fromContract: Contract | null, + ) { + const result = createPostgresMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: postgresContractToSchema(from, postgresTypeComponents), + policy: DB_UPDATE, + fromContract, + origin: null, + statements: [], + frameworkComponents: postgresTypeComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + const operations = (await Promise.all(result.plan.operations)).map( + ({ id, label, operationClass }) => ({ id, label, operationClass }), + ); + return { origin: result.plan.origin, operations }; + } + + it('plans the same operations with and without the origin contract, and asserts no origin', async () => { + const from = contract('from', { + Legacy: { columns: { id: 'int4' } }, + User: { + columns: { id: 'int4', nickname: 'text', age: 'text', score: 'int4', email: 'text?' }, + }, + }); + const to = contract('to', { + User: { columns: { id: 'int4', age: 'int4', score: 'int8', email: 'text', bio: 'text?' } }, + }); + + const withOrigin = await dbUpdatePlan(from, to, from); + const without = await dbUpdatePlan(from, to, null); + + expect(withOrigin).toEqual(without); + expect(withOrigin.origin).toBeNull(); + expect(withOrigin.operations.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts index e364b94b6061..200146ffd357 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -228,3 +228,43 @@ describe('SQLite planner, data loss', () => { ]); }); }); + +describe('SQLite planner under db update', () => { + async function dbUpdatePlan( + from: Contract, + to: Contract, + fromContract: Contract | null, + ) { + const result = createSqliteMigrationPlanner(stubLowerer).plan({ + contract: to, + schema: sqliteContractToSchema(from, sqliteTestTypes), + policy: ALL_CLASSES, + fromContract, + origin: null, + statements: [], + frameworkComponents: sqliteTestComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + const operations = (await Promise.all(result.plan.operations)).map( + ({ id, label, operationClass }) => ({ id, label, operationClass }), + ); + return { origin: result.plan.origin, operations }; + } + + it('plans the same operations with and without the origin contract, and asserts no origin', async () => { + const from = contract('from', { + Legacy: { id: 'integer' }, + User: { id: 'integer', email: 'text?', nickname: 'text', age: 'text' }, + }); + const to = contract('to', { User: { id: 'integer', email: 'text', age: 'integer' } }); + + const withOrigin = await dbUpdatePlan(from, to, from); + const without = await dbUpdatePlan(from, to, null); + + expect(withOrigin).toEqual(without); + expect(withOrigin.origin).toBeNull(); + expect(withOrigin.operations.length).toBeGreaterThan(0); + }); +}); From 529657166ed4848a98fe7bc3df36e17334a29e61 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:39:14 +0200 Subject: [PATCH 043/109] TML-3476: db update asks what each data loss means and whether each access widening may run An apply asks one question per subject the plan would lose data from, answered by --delete or --rename, and one per subject whose rows it would open up, answered by --allow, before it applies anything. The consent token by database name, the plan-hash binding, and the DESTRUCTIVE_CHANGES and CONSENT_PLAN_MISMATCH failures are gone. The control API takes a required answerQuestions callback, and rejects one that leaves a question unanswered. The origin snapshot is read on every run. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/1-core/errors/README.md | 2 +- .../1-core/errors/src/execution.ts | 48 -- .../1-core/errors/src/exports/execution.ts | 4 - .../1-core/errors/test/execution.test.ts | 18 - .../3-tooling/cli/src/control-api/client.ts | 2 +- .../cli/src/control-api/operations/db-run.ts | 234 +++++--- .../src/control-api/operations/db-update.ts | 92 +-- .../control-api/operations/ref-resolution.ts | 7 +- .../statements/data-loss-questions.ts | 36 +- .../3-tooling/cli/src/control-api/types.ts | 70 +-- .../cli/src/orm/db/migration-blocks.ts | 40 +- .../3-tooling/cli/src/orm/db/prepare.ts | 12 +- .../3-tooling/cli/src/orm/db/update.ts | 142 ++--- .../3-tooling/cli/src/orm/migration/plan.ts | 20 +- .../cli/src/orm/plan-question-prompt.ts | 26 + .../3-tooling/cli/src/utils/cli-errors.ts | 3 - .../cli/src/utils/db-init-failure.ts | 4 +- .../cli/src/utils/db-update-failure.ts | 35 +- .../cli/src/utils/formatters/migrations.ts | 13 +- .../cli/test/control-api/client.test.ts | 3 + .../db-update-statement-positions.test.ts | 1 + .../control-api/db-update-statements.test.ts | 1 + .../cli/test/control-api/db-update.test.ts | 263 +++++---- .../testing/fixture-client.test.ts | 28 +- .../3-tooling/cli/test/orm/db-init.test.ts | 2 +- .../cli/test/orm/db-update-consent.test.ts | 543 ++++++------------ .../test/orm/db-update-to-resolution.test.ts | 6 + .../3-tooling/cli/test/orm/db-update.test.ts | 30 + .../test/skeleton.integration.test.ts | 20 + .../test/db-update-statements.test.ts | 5 + .../db-init-update.cli.integration.test.ts | 10 + .../migrations/db-init-update.cli.test.ts | 15 + .../authoring/psl.pgvector-dbinit.test.ts | 5 + 33 files changed, 835 insertions(+), 905 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts diff --git a/packages/1-framework/1-core/errors/README.md b/packages/1-framework/1-core/errors/README.md index 567dde8c652b..f56b005b3d3b 100644 --- a/packages/1-framework/1-core/errors/README.md +++ b/packages/1-framework/1-core/errors/README.md @@ -36,4 +36,4 @@ if (CliStructuredError.is(caught)) { | Entry point | Domain | Code range | Contents | |---|---|---|---| | `./control` | CLI | `PN-CLI-4xxx` | `CliStructuredError` class, `CliErrorEnvelope`/`CliErrorConflict` types, 14 CLI factory functions, `errorUnexpected` | -| `./execution` | RUN | `PN-RUN-3xxx` | 8 runtime factory functions, `ERROR_CODE_DESTRUCTIVE_CHANGES` constant | +| `./execution` | RUN | `PN-RUN-3xxx` | Runtime factory functions | diff --git a/packages/1-framework/1-core/errors/src/execution.ts b/packages/1-framework/1-core/errors/src/execution.ts index e70f6eeb30b1..1750be0791ff 100644 --- a/packages/1-framework/1-core/errors/src/execution.ts +++ b/packages/1-framework/1-core/errors/src/execution.ts @@ -281,54 +281,6 @@ export function errorRunnerFailed( }); } -/** Error code for destructive changes that require explicit confirmation. */ -export const ERROR_CODE_DESTRUCTIVE_CHANGES = 'MIGRATION.DESTRUCTIVE_CHANGES'; - -/** - * Destructive operations require explicit confirmation via -y/--yes. - */ -export function errorDestructiveChanges( - summary: string, - options?: { - readonly why?: string; - readonly fix?: string; - readonly meta?: Record; - }, -): CliStructuredError { - return new CliStructuredError(ERROR_CODE_DESTRUCTIVE_CHANGES, summary, { - why: options?.why ?? 'Planned operations include destructive changes that require confirmation', - fix: options?.fix ?? 'Re-run with `-y` to apply, or use `--dry-run` to preview first', - ...(options?.meta ? { meta: options.meta } : {}), - }); -} - -export const ERROR_CODE_CONSENT_PLAN_MISMATCH = 'MIGRATION.CONSENT_PLAN_MISMATCH'; - -/** - * An apply carrying consent was refused because the plan recomputed for it is - * not the plan that was consented to. - */ -export function errorConsentPlanMismatch(options: { - readonly consentedPlanHash: string; - readonly planHash: string; - readonly why?: string; -}): CliStructuredError { - return new CliStructuredError( - ERROR_CODE_CONSENT_PLAN_MISMATCH, - 'The plan changed between consent and apply', - { - why: - options.why ?? - 'The plan recomputed for the consented apply is not the plan that was consented to, so applying it could destroy something nobody agreed to.', - fix: 'Re-run the command and review the freshly planned operations before consenting again', - meta: { - consentedPlanHash: options.consentedPlanHash, - planHash: options.planHash, - }, - }, - ); -} - /** * Generic runtime error carrying a caller-provided dotted code. */ diff --git a/packages/1-framework/1-core/errors/src/exports/execution.ts b/packages/1-framework/1-core/errors/src/exports/execution.ts index b7e46b86b547..dd9bcac52400 100644 --- a/packages/1-framework/1-core/errors/src/exports/execution.ts +++ b/packages/1-framework/1-core/errors/src/exports/execution.ts @@ -1,8 +1,4 @@ export { - ERROR_CODE_CONSENT_PLAN_MISMATCH, - ERROR_CODE_DESTRUCTIVE_CHANGES, - errorConsentPlanMismatch, - errorDestructiveChanges, errorHashMismatch, errorMarkerMissing, errorMarkerReadFailed, diff --git a/packages/1-framework/1-core/errors/test/execution.test.ts b/packages/1-framework/1-core/errors/test/execution.test.ts index 955e3d93d520..3fc19130582e 100644 --- a/packages/1-framework/1-core/errors/test/execution.test.ts +++ b/packages/1-framework/1-core/errors/test/execution.test.ts @@ -2,7 +2,6 @@ import { structuredError } from '@internal/utils/structured-error'; import { describe, expect, it } from 'vitest'; import { CliStructuredError } from '../src/control'; import { - errorDestructiveChanges, errorHashMismatch, errorMarkerMissing, errorMarkerReadFailed, @@ -107,23 +106,6 @@ describe('Runtime Errors', () => { expect(error.cause).toBe(cause); }); - it('errorDestructiveChanges creates correct error', () => { - const error = errorDestructiveChanges('Destructive changes detected'); - expect(error.code).toBe('MIGRATION.DESTRUCTIVE_CHANGES'); - expect(error.message).toBe('Destructive changes detected'); - }); - - it('errorDestructiveChanges with all options', () => { - const error = errorDestructiveChanges('Destructive changes detected', { - why: 'Custom why', - fix: 'Custom fix', - meta: { key: 'value' }, - }); - expect(error.why).toBe('Custom why'); - expect(error.fix).toBe('Custom fix'); - expect(error.meta).toEqual({ key: 'value' }); - }); - it('errorRuntime carries the caller-provided code', () => { const error = errorRuntime('MIGRATION.SPACE_NOT_FOUND', 'Something failed'); expect(error.code).toBe('MIGRATION.SPACE_NOT_FOUND'); 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 1f2ec11223c5..a2ccd4388a9c 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 @@ -419,8 +419,8 @@ class ControlClientImpl implements ControlClient { targetId: this.options.target.targetId, extensions: this.options.extensions ?? [], ...ifDefined('acceptDataLoss', options.acceptDataLoss), - ...ifDefined('consent', options.consent), ...ifDefined('statements', options.statements), + answerQuestions: options.answerQuestions, ...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 52139e63f025..a79175391734 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,6 +13,7 @@ import type { MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, + MigrationStatementSubject, OperationPreview, ResolvedMigrationStatement, TargetMigrationsCapability, @@ -24,6 +25,7 @@ import { } from '@internal/framework-components/control'; import type { ContractMarkerRecordLike } from '@internal/migration-tools/aggregate'; import { + type PlannerSuccess as AggregatePlan, type ContractSpaceAggregate, collectAggregateNamespaces, type PlannerError, @@ -38,12 +40,19 @@ 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 { notOk, ok, type Result } from '@internal/utils/result'; import { join } from 'pathe'; import { CliStructuredError } from '../../utils/cli-errors'; +import { + type AnswerPlanQuestions, + answerPlanQuestions, + type ConsentedSubject, + type PlannedQuestions, +} from '../statements/data-loss-questions'; import { type AppliedStatementReport, reportAppliedStatements, + reportConsentStatement, } from '../statements/report-applied-statements'; import { resolveStatements, type StatementOrigin } from '../statements/resolve-statements'; import type { StatementText } from '../statements/statement-text'; @@ -63,7 +72,6 @@ import { buildContractSpaceAggregate, } from './contract-space-aggregate-loader'; import { stripOperations } from './migration-helpers'; -import { computePlanHash } from './plan-identity'; import { buildPerSpaceBreakdown, collectOrdered, @@ -106,26 +114,29 @@ export interface ExecuteRunSharedOptions>; readonly targetId: TTargetId; readonly policy: MigrationOperationPolicy; - /** - * Identity of the plan the caller consented to (`db update` only). When - * set, the apply refuses with `CONSENT_PLAN_MISMATCH` if the freshly - * computed plan differs — consent binds to one plan, not to data loss in - * general. - */ - readonly consentedPlanHash?: string; /** Content check for contract snapshots the aggregate loader resolves. */ readonly verifySnapshotContent?: SnapshotContentVerifier; readonly onProgress?: OnControlProgress; } -/** `db init` takes no statements; `db update` takes the statements as the user wrote them, in order. */ +/** + * `db init` takes no statements. `db update` takes the statements as the user wrote them, in + * order, and the answer to each question its plan raises: what an operation that would lose data + * means and, when it applies, whether an operation may widen access. `acceptDataLoss` answers + * every question with consent. + */ export type ExecuteRunOptions< TFamilyId extends string, TTargetId extends string, > = ExecuteRunSharedOptions & ( | { readonly action: 'dbInit' } - | { readonly action: 'dbUpdate'; readonly statements: readonly StatementText[] } + | { + readonly action: 'dbUpdate'; + readonly statements: readonly StatementText[]; + readonly answerQuestions: AnswerPlanQuestions; + readonly acceptDataLoss: boolean; + } ); /** @@ -185,30 +196,36 @@ export async function executeRun familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), + }) + : undefined; + const fromContract = appOrigin?.contract ?? null; const statementTexts = options.action === 'dbUpdate' ? options.statements : []; - if (statementTexts.length > 0) { - const appOrigin = await readAppOrigin({ - marker: markerRows.get(aggregate.app.spaceId) ?? null, - migrationsDir, - deserializeContract: (json) => familyInstance.deserializeContract(json), - ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), - }); + const resolveRenames = ( + texts: readonly StatementText[], + ): readonly ResolvedMigrationStatement[] => { + if (texts.length === 0 || appOrigin === undefined) return []; const resolved = resolveStatements({ - statements: statementTexts, + statements: texts, origin: appOrigin.origin, destination: contract, }); if (!resolved.ok) { throw resolved.failure; } - statements = resolved.value; - fromContract = appOrigin.contract; - } + return resolved.value; + }; + const statements = resolveRenames(statementTexts); // 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 @@ -239,29 +256,84 @@ export async function executeRun({ - aggregate, - currentDBState: { markersBySpaceId: markerRows, schemaIntrospection: schemaIR }, - adapter, - migrations, - frameworkComponents, - callerPolicy: { ignoreGraphFor: new Set([aggregate.app.spaceId]) }, - operationPolicy: policy, - appSpace: { fromContract, statements }, - }); - if (!planResult.ok) { - onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'error' }); - return mapPlannerError(planResult.failure); - } - onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'ok' }); + const plan = async ( + resolved: readonly ResolvedMigrationStatement[], + ): Promise> => { + onProgress?.({ + action, + kind: 'spanStart', + spanId: SPAN_IDS.plan, + label: 'Planning migration', + }); + const planned = await planMigration({ + aggregate, + currentDBState: { markersBySpaceId: markerRows, schemaIntrospection: schemaIR }, + adapter, + migrations, + frameworkComponents, + callerPolicy: { ignoreGraphFor: new Set([aggregate.app.spaceId]) }, + operationPolicy: policy, + appSpace: { fromContract, statements: resolved }, + }); + if (!planned.ok) { + onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'error' }); + return notOk(mapPlannerError(planned.failure)); + } + onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'ok' }); + const orderedResolutions = collectOrdered(planned.value.applyOrder, planned.value.perSpace); + const subjects: PlanSubjectsReport = + action === 'dbUpdate' + ? planSubjects(orderedResolutions, (operation) => familyInstance.storageNameOf(operation)) + : { dataLoss: [], accessWidening: [] }; + const operations = orderedResolutions.flatMap((r) => r.entry.displayOps); + const labelled = (entries: readonly MigrationOperationSubject[]) => + entries.map(({ operationIndex, subject }) => ({ + operationIndex, + subject, + label: operations[operationIndex]?.label ?? JSON.stringify(subject), + })); + return ok({ + planned: planned.value, + orderedResolutions, + subjects, + dataLoss: labelled(subjects.dataLoss), + accessWidening: labelled(subjects.accessWidening), + }); + }; - const orderedResolutions = collectOrdered(planResult.value.applyOrder, planResult.value.perSpace); + const first = await plan(statements); + if (!first.ok) { + return first.failure; + } + let run = first.value; + let renameTexts = statementTexts.filter((statement) => statement.verb === 'rename'); + let consented: readonly ConsentedSubject[] = []; + // 3a. Questions: an apply of `db update` asks what each operation that would lose data means, + // and whether each that would widen access may run, before it applies anything. A dry run asks + // nothing and lists them. + if (options.action === 'dbUpdate' && mode === 'apply') { + const answered = await answerPlanQuestions({ + plan: run, + askAccess: true, + renames: renameTexts, + preAnswers: statementTexts.filter((statement) => statement.verb !== 'rename'), + consentAll: options.acceptDataLoss, + origin: fromContract ?? EMPTY_ORIGIN, + destination: contract, + answer: options.answerQuestions, + replan: (renames) => plan(resolveRenames(renames)), + }); + if (!answered.ok) { + if (CliStructuredError.is(answered.failure)) { + throw answered.failure; + } + return answered.failure; + } + run = answered.value.plan; + renameTexts = [...answered.value.renames]; + consented = answered.value.consented; + } + const { planned, orderedResolutions } = run; const plannerWarnings = aggregatePlannerWarnings(orderedResolutions); // The destination's structural shape comes from the app's plan — its @@ -273,19 +345,27 @@ export async function executeRun JSON.stringify(subject); const appliedStatements = action === 'dbUpdate' - ? reportAppliedStatements( - appResolution.entry.appliedStatements, - fromContract, - contract, - operationsBefore(orderedResolutions, aggregate.app.spaceId), - ) - : undefined; - const subjects = - action === 'dbUpdate' - ? planSubjects(orderedResolutions, (operation) => familyInstance.storageNameOf(operation)) + ? [ + ...reportAppliedStatements( + appResolution.entry.appliedStatements, + fromContract, + contract, + operationsBefore(orderedResolutions, aggregate.app.spaceId), + ), + ...consented.map((entry) => + reportConsentStatement( + entry, + (entry.verb === 'delete' ? run.subjects.dataLoss : run.subjects.accessWidening) + .filter(({ subject }) => subjectKey(subject) === subjectKey(entry.subject)) + .map(({ operationIndex }) => operationIndex), + ), + ), + ] : undefined; + const subjects = action === 'dbUpdate' ? run.subjects : undefined; // 4. Plan-mode: surface aggregate operations without applying. if (mode === 'plan') { @@ -309,28 +389,6 @@ export async function executeRun r.entry.displayOps)), - destination: appPlan.destination, - }); - if (planHash !== options.consentedPlanHash) { - const failure: DbUpdateFailure = { - code: 'CONSENT_PLAN_MISMATCH', - summary: 'The plan changed between consent and apply', - why: 'The plan recomputed for the consented apply is not the plan that was consented to, so applying it could destroy something nobody agreed to.', - conflicts: undefined, - meta: undefined, - consentPlanMismatch: { consentedPlanHash: options.consentedPlanHash, planHash }, - ...ifDefined('warnings', plannerWarnings), - }; - return notOk(failure); - } - } - // 5. Run mode: hand off to the shared `runMigration` primitive. // The runner-driving tail is identical for `db init` / `db update` / // `migrate` — only how each caller produces `perSpacePlans` @@ -339,8 +397,8 @@ export async function executeRun { readonly driver: ControlDriverInstance; @@ -40,39 +38,28 @@ export interface ExecuteDbUpdateOptions >; readonly frameworkComponents: ReadonlyArray>; + /** Consents to every operation that would lose data or widen access, without asking. */ readonly acceptDataLoss?: boolean; - /** - * Consent to the plan a prior `DESTRUCTIVE_CHANGES` refusal named by its - * `planHash`. The apply recomputes its plan and refuses with - * `CONSENT_PLAN_MISMATCH` when the fresh plan differs. - */ - readonly consent?: { readonly planHash: string }; readonly migrationsDir: string; readonly targetId: TTargetId; readonly extensions?: ReadonlyArray>; /** 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. + * The statements as the user wrote them, in the order given. A `rename` is planned; a `delete` + * or `allow` answers the question about the subject it names. */ readonly statements?: readonly StatementText[]; + /** Asks the questions no statement answered; see {@link AnswerPlanQuestions}. */ + readonly answerQuestions: AnswerPlanQuestions; readonly onProgress?: OnControlProgress; } -/** - * Execute `db update` against the configured contract. - * - * Routes through the loader → planner → runner pipeline. Destructive - * operations require either `acceptDataLoss: true` or a prior - * `mode: 'plan'` invocation that surfaces the destructive ops; the - * confirmation gate is implemented here so the lower-level applier - * remains policy-agnostic. - */ +/** Execute `db update` against the configured contract. */ export async function executeDbUpdate( options: ExecuteDbUpdateOptions, ): Promise { - const sharedInputs = { + return (await executeRun({ driver: options.driver, adapter: options.adapter, familyInstance: options.familyInstance, @@ -83,59 +70,12 @@ export async function executeDbUpdate(sharedInputs); - if (refusal !== null) return refusal; - } - return (await executeRun({ - ...sharedInputs, - mode: options.mode, - ...ifDefined('consentedPlanHash', options.consent?.planHash), - })) as DbUpdateResult; -} - -/** - * Pre-plan once when running `db update apply` without `acceptDataLoss`. - * Surfaces destructive operations across every space; if any are - * planned, returns a `DESTRUCTIVE_CHANGES` failure that the CLI shows - * as a confirmation prompt. Returns `null` when the apply is safe to - * run. - */ -async function guardDestructiveChanges( - sharedInputs: Omit, 'mode'> & { - readonly action: 'dbUpdate'; - readonly statements: readonly StatementText[]; - }, -): Promise { - const planResult = (await executeRun({ - ...sharedInputs, - mode: 'plan', })) as DbUpdateResult; - if (!planResult.ok) return planResult; - const destructiveOps = planResult.value.plan.operations - .filter((op) => op.operationClass === 'destructive') - .map((op) => ({ id: op.id, label: op.label })); - if (destructiveOps.length === 0) return null; - const databaseName = await sharedInputs.driver.databaseName?.(); - return notOk({ - code: 'DESTRUCTIVE_CHANGES', - summary: `Planned ${destructiveOps.length} destructive operation(s) that require confirmation`, - why: 'Destructive operations require explicit consent, which the caller has not given', - conflicts: undefined, - meta: undefined, - destructiveChanges: { - destructiveOperations: destructiveOps, - databaseName, - planHash: computePlanHash({ - operations: planResult.value.plan.operations, - destination: planResult.value.destination, - }), - }, - ...ifDefined('warnings', planResult.value.warnings), - }); } 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 28eaa661426d..08bc7130c66b 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 @@ -17,6 +17,7 @@ import { mapRefResolutionError, requireLiveDatabase, } from '../../utils/cli-errors'; +import type { StatementText } from '../statements/statement-text'; export interface RefResolutionContext { readonly graph: MigrationGraph; @@ -62,8 +63,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 statements as given: each verb's values in order, `rename` first. */ + readonly statements?: readonly StatementText[] | undefined; /** The command runs without a database when `--from` names a contract. */ readonly canRunOffline: boolean; }): string { @@ -76,7 +77,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}`), + ...(args.statements ?? []).map((statement) => `--${statement.verb} ${statement.text}`), ...(needsConnection ? ['--db $DATABASE_URL'] : []), ].join(' '); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 9670e1b7f5ef..fce42b83d003 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -32,7 +32,10 @@ export interface PlanAnswer { readonly text: string; } -/** Asks every question at once and returns the answers in question order. */ +/** + * Asks every question at once and returns one answer per question, in question order, with a verb + * and text the question accepts. To refuse, throw. + */ export type AnswerPlanQuestions = ( questions: readonly PlanQuestion[], ) => Promise; @@ -208,6 +211,28 @@ export interface ConsentedSubject { const keyOf = (planned: PlannedSubject) => JSON.stringify(planned.subject); +function checkAnswers(questions: readonly PlanQuestion[], answers: readonly PlanAnswer[]): void { + if (answers.length !== questions.length) { + const noun = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; + throw new Error( + `answerQuestions gave ${noun(answers.length, 'answer')} for ${noun(questions.length, 'question')}; answer each question in order, or throw to refuse.`, + ); + } + questions.forEach((question, index) => { + const answer = answers[index]; + if (answer === undefined) return; + if (!question.verbs.includes(answer.verb)) { + throw new Error( + `answerQuestions answered "${question.question}" with ${answer.verb}, which it does not accept; it accepts ${question.verbs.join(' or ')}.`, + ); + } + const rejected = question.validate(answer.verb, answer.text); + if (rejected !== undefined) { + throw new Error(`answerQuestions answered "${question.question}" wrongly: ${rejected}`); + } + }); +} + /** * Asks about every operation of a plan that would lose data and, when `askAccess`, every one that * would widen access, until each is answered. A `delete` or `allow` text in `preAnswers`, or @@ -216,7 +241,7 @@ const keyOf = (planned: PlannedSubject) => JSON.stringify(planned.subject); * The first round asks even when nothing is in question, so a statement no question consumed is * refused before anything is done. */ -export async function answerPlanQuestions(input: { +export async function answerPlanQuestions(input: { readonly plan: TPlan; readonly askAccess: boolean; readonly renames: readonly StatementText[]; @@ -225,9 +250,7 @@ export async function answerPlanQuestions(input: readonly origin: ContractWithDomain; readonly destination: ContractWithDomain; readonly answer: AnswerPlanQuestions; - readonly replan: ( - renames: readonly StatementText[], - ) => Promise>; + readonly replan: (renames: readonly StatementText[]) => Promise>; }): Promise< Result< { @@ -235,7 +258,7 @@ export async function answerPlanQuestions(input: readonly renames: readonly StatementText[]; readonly consented: readonly ConsentedSubject[]; }, - CliStructuredError + TFailure | CliStructuredError > > { let plan = input.plan; @@ -275,6 +298,7 @@ export async function answerPlanQuestions(input: ...widenings.map((widening) => accessWideningQuestion(widening, contracts)), ]; const answers = await input.answer(questions); + checkAnswers(questions, answers); const typedRenames: { readonly text: string; readonly loss: PlannedSubject }[] = []; answers.forEach((answer, index) => { const entry = [...losses, ...widenings][index]; 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 89052d1f65c8..b8238fbc3a1b 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 @@ -25,6 +25,7 @@ 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 { AnswerPlanQuestions } from './statements/data-loss-questions'; import type { AppliedStatementReport } from './statements/report-applied-statements'; import type { StatementText } from './statements/statement-text'; @@ -211,22 +212,8 @@ export interface DbUpdateOptions { * The type is driver-specific (e.g., string URL for Postgres). */ readonly connection?: unknown; - /** - * When true, allows applying plans that contain destructive operations - * (e.g., DROP TABLE, DROP COLUMN, ALTER TYPE). - * When false (default), the operation returns a failure if the plan - * includes destructive operations, which `db update` turns into its consent - * prompt: the user types the database name, or passes `--confirm ` - * where there is nobody to ask. - */ + /** Consents to every operation that would lose data or widen access, without asking. */ readonly acceptDataLoss?: boolean; - /** - * Consent to the destructive plan a prior `DESTRUCTIVE_CHANGES` refusal - * named, identified by that refusal's `planHash`. The apply recomputes its - * plan and refuses with `CONSENT_PLAN_MISMATCH` when the fresh plan is not - * the one that was consented to. - */ - readonly consent?: { readonly planHash: string }; /** * On-disk migrations directory. Always required — every `db update` * routes through the per-space flow, which reads on-disk @@ -235,11 +222,19 @@ export interface DbUpdateOptions { */ 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. + * The statements as the user wrote them, in the order given. A `rename` + * resolves against the contract the database marker names, read from the + * snapshot store, and the destination contract; a `delete` or `allow` + * answers the question about the subject it names. */ readonly statements?: readonly StatementText[]; + /** + * Asks, before an apply, what each operation that would lose data means and + * whether each that would widen who can read or write rows may run, for the + * questions no statement answered. The `db update` command asks through its + * prompt. + */ + readonly answerQuestions: AnswerPlanQuestions; /** Optional progress callback for observing operation progress */ readonly onProgress?: OnControlProgress; } @@ -507,40 +502,7 @@ export interface DbUpdateSuccess { /** * Failure codes for dbUpdate operation. */ -export type DbUpdateFailureCode = - | 'PLANNING_FAILED' - | 'RUNNER_FAILED' - | 'DESTRUCTIVE_CHANGES' - | 'CONSENT_PLAN_MISMATCH'; - -/** One planned operation whose class is destructive, as the refusal names it. */ -export interface DestructivePlanOperation { - readonly id: string; - readonly label: string; -} - -/** - * The verdict a `DESTRUCTIVE_CHANGES` refusal carries: what the plan would - * destroy, the database it would destroy it in, and the identity of the plan - * that was refused. Consent is granted against `planHash` — the caller passes - * it back as `DbUpdateOptions.consent`. - */ -export interface DestructiveChangesVerdict { - readonly destructiveOperations: ReadonlyArray; - /** The connected database's name, when the driver can name it. */ - readonly databaseName: string | undefined; - /** Content hash of the refused plan. */ - readonly planHash: string; -} - -/** - * Why an apply carrying consent was refused: the plan recomputed for the - * apply is not the plan that was consented to. - */ -export interface ConsentPlanMismatchVerdict { - readonly consentedPlanHash: string; - readonly planHash: string; -} +export type DbUpdateFailureCode = 'PLANNING_FAILED' | 'RUNNER_FAILED'; /** * Failure details for dbUpdate operation. @@ -552,10 +514,6 @@ export interface DbUpdateFailure { readonly conflicts: ReadonlyArray | undefined; readonly warnings?: ReadonlyArray; readonly meta: Record | undefined; - /** Present exactly when `code` is `'DESTRUCTIVE_CHANGES'`. */ - readonly destructiveChanges?: DestructiveChangesVerdict; - /** Present exactly when `code` is `'CONSENT_PLAN_MISMATCH'`. */ - readonly consentPlanMismatch?: ConsentPlanMismatchVerdict; /** Underlying failure or error for diagnostics; never serialized into envelopes. */ readonly cause?: unknown; } 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 466431422b25..290aff8b852b 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 @@ -1,4 +1,7 @@ -import type { OperationPreview } from '@internal/framework-components/control'; +import type { + MigrationStatementSubjectJson, + OperationPreview, +} from '@internal/framework-components/control'; import type { Block, TreeNode } from '@prisma/cli-engine'; import type { NextAction } from '@prisma/cli-engine/protocol'; import type { PerSpaceExecutionEntry } from '../../control-api/types'; @@ -153,6 +156,40 @@ function applySummaryText(result: MigrationCommandResult): string { return `Applied ${executed} operation(s)${across}`; } +function subjectDisplay(subject: MigrationStatementSubjectJson): string { + if (subject.kind === 'storage') return subject.name; + const model = + subject.namespaceId === undefined ? subject.model : `${subject.namespaceId}.${subject.model}`; + return subject.kind === 'field' ? `${model}.${subject.field}` : model; +} + +/** + * The questions an apply would ask, as a dry run lists them: each operation that would lose data, + * answered by `--delete` or `--rename`, and each that would widen access, answered by `--allow`. + */ +function questionBlocks(result: MigrationCommandResult): readonly Block[] { + const entries = [ + ...(result.dataLoss ?? []).map((entry) => ({ entry, verb: 'delete' })), + ...(result.accessWidening ?? []).map((entry) => ({ entry, verb: 'allow' })), + ]; + if (entries.length === 0) { + return []; + } + return [ + { + kind: 'tree', + roots: [ + { + label: 'An apply asks about', + children: entries.map(({ entry, verb }) => ({ + label: `${result.plan.operations[entry.operationIndex]?.label ?? 'an operation'}: --${verb} ${subjectDisplay(entry.subject)}`, + })), + }, + ], + }, + ]; +} + function planBlocks(result: MigrationCommandResult): readonly Block[] { const planned = result.plannedAdvanceRef; return [ @@ -160,6 +197,7 @@ function planBlocks(result: MigrationCommandResult): readonly Block[] { ...plannerWarningBlocks(result), ...operationBlocks(result), ...appliedStatementBlocks(result.appliedStatements ?? []), + ...questionBlocks(result), { kind: 'fields', rows: [ diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts index 2dec9e5b6997..1db207b8f348 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts @@ -74,8 +74,11 @@ export async function prepareMigrationRun(inputs: { readonly db: string | undefined; readonly commandName: string; readonly createClient: CreateControlClient; - /** The command the missing-connection error suggests; defaults to `{bin} --db `. */ - readonly retryCommand?: string; + /** + * The command the missing-connection error suggests; defaults to `{bin} --db `. + * A function is called only when the error is raised. + */ + readonly retryCommand?: string | (() => string); }): Promise> { const { config, cwd, commandName } = inputs; const contractPath = contractPathFor(config); @@ -103,7 +106,10 @@ export async function prepareMigrationRun(inputs: { why: `Database connection is required for ${commandName} (set db.connection in prisma.config.ts, or pass --db )`, commandName, missingFlags: ['--db'], - ...ifDefined('retryCommand', inputs.retryCommand), + ...ifDefined( + 'retryCommand', + typeof inputs.retryCommand === 'function' ? inputs.retryCommand() : inputs.retryCommand, + ), }), ), ); 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 5995bd1319a5..d7d4dbcf7b58 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 @@ -1,4 +1,8 @@ import { ormConfigSection } from '@internal/config-loader'; +import { + type MigrationOperationSubject, + migrationStatementSubjectJson, +} from '@internal/framework-components/control'; import { ifDefined } from '@internal/utils/defined'; import { isStructuredError } from '@internal/utils/structured-error'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -22,8 +26,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 type { CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; @@ -33,12 +36,8 @@ import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { baseDirFor, migrationsDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; +import { promptPlanQuestions } from '../plan-question-prompt'; import { controlProgressReporter } from '../progress'; -import { - destructiveConsentQuestion, - errorConsentOperationsMissing, - errorConsentTokenUnresolved, -} from './consent'; import { migrationResultBlocks, migrationResultNextActions } from './migration-blocks'; import { prepareMigrationRun } from './prepare'; @@ -70,6 +69,13 @@ function updatePresentations(inputs: { }; } +function subjectEntryJson(entry: MigrationOperationSubject) { + return { + operationIndex: entry.operationIndex, + subject: migrationStatementSubjectJson(entry.subject), + }; +} + function updateDocument(inputs: { readonly value: DbUpdateSuccess; readonly targetId: string; @@ -112,6 +118,12 @@ function updateDocument(inputs: { }), ...ifDefined('perSpace', value.perSpace), appliedStatements: value.appliedStatements, + ...(value.mode === 'plan' + ? { + dataLoss: value.dataLoss.map(subjectEntryJson), + accessWidening: value.accessWidening.map(subjectEntryJson), + } + : {}), ...ifDefined('warnings', value.warnings), advancedRef: inputs.advancedRef, plannedAdvanceRef: inputs.plannedAdvanceRef, @@ -127,14 +139,16 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { description: 'Compares the database to the emitted contract and applies the changes that\n' + 'close the gap, whether or not the database was bootstrapped with `db init`.\n' + - 'An operation that would destroy data is applied only with your consent: the\n' + - 'command asks you to type the database name. A run with nobody to ask — a CI\n' + - 'job, or `--no-interactive` — takes it from `--confirm ` instead.\n' + - 'Use --dry-run to see the operations without applying them.', + 'Before it applies an operation that would lose data, it asks what the\n' + + 'operation means: --rename keeps the data under a new name, --delete lets it\n' + + 'go. Before it widens who can read or write rows, it asks for --allow. Where\n' + + 'nobody can answer, it refuses and lists every question. Use --dry-run to see\n' + + 'the operations and the questions without applying anything.', examples: [ 'db update', 'db update --dry-run', - 'db update --no-interactive --confirm appdb', + 'db update --delete Legacy', + 'db update --rename Profile:User --allow User', 'db update --to production', ], }, @@ -150,16 +164,30 @@ 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', - }), + }, + }, + statements: { + rename: { + arity: 1, + brief: + 'Rename a model or field instead of dropping it: old:new, each side Model, namespace.Model, Model.field or namespace.Model.field; repeat for several, applied in order', + }, + delete: { + arity: 1, + brief: + 'Let the update lose the data of a model, field or storage name the refusal lists: --delete Model', + }, + allow: { + arity: 1, + brief: 'Let the update widen who can read or write the rows of a model: --allow Model', }, }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { const startedAt = Date.now(); + const renames = ctx.statements + .take('rename') + .map(({ text }) => ({ verb: 'rename' as const, text })); let destination: ResolveContractRefToSnapshotSuccess | undefined; if (args.flags.to !== undefined) { const resolved = await resolveContractRefToSnapshot({ @@ -181,13 +209,20 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db update', createClient, - retryCommand: retryCommandFor({ - commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', - to: args.flags.to, - advanceRef: args.flags.advanceRef, - renames: args.flags.rename, - canRunOffline: false, - }), + // Only a failed preparation suggests a retry. The engine keeps the delete and allow + // values for the questions, so they are taken here, where no question will be asked. + retryCommand: () => + retryCommandFor({ + commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', + to: args.flags.to, + advanceRef: args.flags.advanceRef, + statements: [ + ...renames, + ...ctx.statements.take('delete'), + ...ctx.statements.take('allow'), + ], + canRunOffline: false, + }), }); if (!prepared.ok) { return notOk(prepared.failure); @@ -220,56 +255,23 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { try { await client.connect(dbConnection); - const update = (consent?: { readonly planHash: string }): Promise => - client.dbUpdate({ - contract: contractJson, - mode, - migrationsDir, - statements: renameStatements(args.flags.rename), - ...(consent === undefined ? {} : { consent }), - onProgress: controlProgressReporter(ctx.report), - }); - - // A successful run shows the planner's warnings in its blocks. A refused - // or failed one has no result to carry them, and an errored envelope - // renders no meta, so they are reported as events to reach both channels. - // The refusal's warnings matter most of all: they are what the user is - // told just before consenting. - const reported = new Set(); + // A refused or failed run has no result to carry the planner's warnings, + // and an errored envelope renders no meta, so they are reported as events + // to reach both channels. const reportPlannerWarnings = (warnings: readonly { readonly summary: string }[]): void => { for (const warning of warnings) { - if (!reported.has(warning.summary)) { - reported.add(warning.summary); - ctx.report({ kind: 'message', severity: 'warn', text: warning.summary }); - } + ctx.report({ kind: 'message', severity: 'warn', text: warning.summary }); } }; - let result = await update(); - // The destructive verdict is the planner's, so it arrives only after the - // connection is open. Consent is asked for on that same connection and - // the apply is re-run carrying the refused plan's hash — the control API - // refuses if the plan it is about to apply is no longer that plan. Only - // an apply can ask: a dry run has nothing to authorise. - if (mode === 'apply' && !result.ok && result.failure.code === 'DESTRUCTIVE_CHANGES') { - reportPlannerWarnings(result.failure.warnings ?? []); - const refusal = result.failure.destructiveChanges; - if (refusal === undefined || refusal.destructiveOperations.length === 0) { - return notOk(normalizeError(errorConsentOperationsMissing())); - } - const token = refusal.databaseName ?? ''; - if (token.trim().length === 0) { - return notOk(normalizeError(errorConsentTokenUnresolved(ctx.config.target.targetId))); - } - const granted = await ctx.prompt.consent( - destructiveConsentQuestion(refusal.destructiveOperations, token), - { token }, - ); - if (!granted) { - return notOk(normalizeError(mapDbUpdateFailure(result.failure))); - } - result = await update({ planHash: refusal.planHash }); - } + const result = await client.dbUpdate({ + contract: contractJson, + mode, + migrationsDir, + statements: renames, + answerQuestions: promptPlanQuestions(ctx.prompt), + onProgress: controlProgressReporter(ctx.report), + }); if (!result.ok) { reportPlannerWarnings(result.failure.warnings ?? []); return notOk(normalizeError(mapDbUpdateFailure(result.failure))); @@ -302,7 +304,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { startedAt, }); } catch (error) { - // A refused, mistyped or cancelled consent is the engine's own error, and + // A refused, mistyped or cancelled answer is the engine's own error, and // the engine settles it: cancellation exits 3, everything else 2. Catching // it here would restate it as this command's failure and lose that. if (error instanceof EngineStructuredError) { 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 7c92d55ddf4e..d9d8df4b2aac 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 @@ -9,7 +9,6 @@ import { createControlClient } from '../../control-api/client'; import type { ContractSpaceSeedPhaseRecord } from '../../control-api/operations/contract-space-seed-phase'; import type { MigrationPlanResult } from '../../control-api/operations/migration-plan'; import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; -import type { PlanAnswer, PlanQuestion } from '../../control-api/statements/data-loss-questions'; import type { CreateControlClient } from '../../control-api/types'; import { RECORDED_CONTRACT_REF_FORMS, @@ -19,6 +18,7 @@ import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; +import { promptPlanQuestions } from '../plan-question-prompt'; import { appliedStatementBlocks } from '../statement-blocks'; import { appMigrationsDirFor, @@ -317,22 +317,6 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { data: { newHash: record.newHash, newMigrationDirs: record.newMigrationDirs }, }); }; - const answerQuestions = async ( - questions: readonly PlanQuestion[], - ): Promise => { - const answers = await ctx.prompt.statements( - questions.map((question) => ({ - question: question.question, - subject: question.subject, - verbs: question.verbs, - forms: question.forms, - validate: question.validate, - })), - { last: true }, - ); - return answers.map(({ verb, text }) => ({ verb, text })); - }; - const planned = await executeMigrationPlanCommand( { config: ctx.config, @@ -344,7 +328,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { statements: ctx.statements .take('rename') .map(({ text }) => ({ verb: 'rename' as const, text })), - answerQuestions, + answerQuestions: promptPlanQuestions(ctx.prompt), client: createClient({ family: ctx.config.family, target: ctx.config.target, diff --git a/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts b/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts new file mode 100644 index 000000000000..c513125204b0 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts @@ -0,0 +1,26 @@ +import type { PromptSurface } from '@prisma/cli-engine'; +import type { + AnswerPlanQuestions, + PlanAnswer, +} from '../control-api/statements/data-loss-questions'; + +/** + * Asks a plan's questions through the engine's statement prompt, all in one batch that refuses a + * statement nothing consumed before the command acts. Flags answer first; where nobody can answer, + * the engine refuses and lists every question. + */ +export function promptPlanQuestions(prompt: PromptSurface): AnswerPlanQuestions { + return async (questions): Promise => { + const answers = await prompt.statements( + questions.map((question) => ({ + question: question.question, + subject: question.subject, + verbs: question.verbs, + forms: question.forms, + validate: question.validate, + })), + { last: true }, + ); + return answers.map(({ verb, text }) => ({ verb, text })); + }; +} 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 552897aaf1a4..aadfd1c8dd27 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 @@ -33,9 +33,6 @@ import type { MigrateFailure } from '../control-api/types'; import { chooseAction, runCommandAction } from './next-actions'; export { - ERROR_CODE_DESTRUCTIVE_CHANGES, - errorConsentPlanMismatch, - errorDestructiveChanges, errorHashMismatch, errorMarkerMissing, errorMarkerRequired, diff --git a/packages/1-framework/3-tooling/cli/src/utils/db-init-failure.ts b/packages/1-framework/3-tooling/cli/src/utils/db-init-failure.ts index 51a0468a26d2..4824c96c58e4 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/db-init-failure.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/db-init-failure.ts @@ -14,7 +14,7 @@ import { import { runCommandAction } from './next-actions'; const DB_INIT_ADDITIVE_ONLY_FIX = - '`db init` applies only additive changes. Run `{bin} db update`, which also applies widening and destructive ones after you confirm them by typing the database name, or pass `--no-interactive --confirm ` where there is nobody to ask.'; + '`db init` applies only additive changes. Run `{bin} db update`, which also applies widening and destructive ones once you answer what each operation that would lose data means, with `--delete` or `--rename`.'; const DB_INIT_NEEDS_MIGRATION_FIX = '`db init` applies only additive changes, and `db update` does not apply data operations. Plan a migration with `{bin} migration plan`, which can include them, and apply it with `{bin} db migrate`.'; @@ -42,7 +42,7 @@ function adviceForRefusedClasses(refused: ReadonlySet): fix: DB_INIT_ADDITIVE_ONLY_FIX, nextAction: runCommandAction( refused.has('destructive') - ? 'Apply the change with db update, which lists the destructive operations and asks you to confirm them' + ? 'Apply the change with db update, which asks what each operation that would lose data means' : 'Apply the change with db update', '{bin} db update', ), diff --git a/packages/1-framework/3-tooling/cli/src/utils/db-update-failure.ts b/packages/1-framework/3-tooling/cli/src/utils/db-update-failure.ts index a34d26975a10..fd31e338b8f0 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/db-update-failure.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/db-update-failure.ts @@ -2,20 +2,11 @@ import { ifDefined } from '@internal/utils/defined'; import { assertNever } from '@internal/utils/internal-error'; import type { DbUpdateFailure } from '../control-api/types'; import type { CliStructuredError } from './cli-errors'; -import { - errorConsentPlanMismatch, - errorDestructiveChanges, - errorMigrationPlanningFailed, - errorRunnerFailed, -} from './cli-errors'; +import { errorMigrationPlanningFailed, errorRunnerFailed } from './cli-errors'; /** * A `db update` failure as the CLI's structured error. * - * `DESTRUCTIVE_CHANGES` is handled by the command, which asks for consent and - * calls again; it is mapped here for the case the control API returns it from a - * call that already carried consent, which would otherwise degrade silently. - * * The `assertNever` is deliberate: a control-API failure code this does not * handle must stop the command rather than degrade into a generic message. */ @@ -47,30 +38,6 @@ export function mapDbUpdateFailure(failure: DbUpdateFailure): CliStructuredError }); } - if (failure.code === 'DESTRUCTIVE_CHANGES') { - return errorDestructiveChanges(failure.summary, { - ...ifDefined('why', failure.why), - fix: 'Re-run `{bin} db update` and type the database name when asked, or pass `--no-interactive --confirm ` where there is nobody to ask. Use `--dry-run` to preview the operations first.', - ...(failure.destructiveChanges - ? { - meta: { - destructiveOperations: failure.destructiveChanges.destructiveOperations, - databaseName: failure.destructiveChanges.databaseName, - planHash: failure.destructiveChanges.planHash, - }, - } - : {}), - }); - } - - if (failure.code === 'CONSENT_PLAN_MISMATCH') { - return errorConsentPlanMismatch({ - consentedPlanHash: failure.consentPlanMismatch?.consentedPlanHash ?? '', - planHash: failure.consentPlanMismatch?.planHash ?? '', - ...ifDefined('why', failure.why), - }); - } - const exhaustive: never = failure.code; return assertNever(exhaustive, `Unhandled DbUpdateFailure code: ${String(exhaustive)}`); } 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 f6868580bca6..44b2e5e0ba1c 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 @@ -1,5 +1,6 @@ import type { MigrationPlannerConflict, + MigrationStatementSubjectJson, OperationPreview, } from '@internal/framework-components/control'; import type { AppliedStatementReport } from '../../control-api/statements/report-applied-statements'; @@ -45,6 +46,12 @@ export function previewBlockHeader(preview: OperationPreview): string { /** * Shared CLI output type for migration commands (db init, db update). */ +/** An operation of a plan, by its position in `plan.operations`, and its subject. */ +export interface PlannedSubjectJson { + readonly operationIndex: number; + readonly subject: MigrationStatementSubjectJson; +} + export interface MigrationCommandResult { readonly ok: true; readonly mode: 'plan' | 'apply'; @@ -82,8 +89,12 @@ 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. */ + /** `db update` only: the statements the plan applied, in order; empty when none were given. */ readonly appliedStatements?: readonly AppliedStatementReport[]; + /** `db update --dry-run` only: the operations an apply would ask about because they lose data. */ + readonly dataLoss?: readonly PlannedSubjectJson[]; + /** `db update --dry-run` only: the operations an apply would ask about because they widen access. */ + readonly accessWidening?: readonly PlannedSubjectJson[]; 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/control-api/client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts index 4f8999ac0ddb..b5c23577c308 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 @@ -787,6 +787,7 @@ describe('ControlClient progress emission', () => { mode: 'apply', connection: 'postgres://test', migrationsDir: '/tmp/__test-client-migrations', + answerQuestions: async () => [], acceptDataLoss: true, onProgress: (event) => events.push(event), }); @@ -849,6 +850,7 @@ describe('ControlClient progress emission', () => { mode: 'plan', connection: 'postgres://test', migrationsDir: '/tmp/__test-client-migrations', + answerQuestions: async () => [], }); expect(result.ok).toBe(true); @@ -875,6 +877,7 @@ describe('ControlClient progress emission', () => { mode: 'plan', connection: 'postgres://test', migrationsDir: '/tmp/__test-client-migrations', + answerQuestions: async () => [], }); await client.close(); 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 index 761dceeda35f..4517c8791168 100644 --- 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 @@ -199,6 +199,7 @@ describe('executeDbUpdate statement positions', () => { migrationsDir: await migrationsDirWithPendingExtension(), targetId: 'postgres', extensions: [auditExtension], + answerQuestions: async () => [], statements: renameStatements(['Profile:User']), }); 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 index 3706c02cce0b..d5de7c42a636 100644 --- 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 @@ -177,6 +177,7 @@ function update(options: { migrations: options.migrations, frameworkComponents: [], migrationsDir: options.migrationsDir, + answerQuestions: async () => [], targetId: 'postgres', statements: renameStatements(options.renames), ...(options.verifySnapshotContent === undefined 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 a0fde4e2cb63..63f66fd743b6 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 @@ -10,10 +10,14 @@ import type { import { notOk, ok } from '@internal/utils/result'; import { describe, expect, it, vi } from 'vitest'; import { executeDbUpdate } from '../../src/control-api/operations/db-update'; +import type { PlanQuestion } from '../../src/control-api/statements/data-loss-questions'; import type { ControlProgressEvent } from '../../src/control-api/types'; const FAKE_MIGRATIONS_DIR = '/tmp/__test-db-update-migrations'; +const noQuestions = async (questions: readonly PlanQuestion[]) => + questions.map((question) => ({ verb: 'delete' as const, text: question.subject })); + function markerRecord(fields: { readonly storageHash: string; readonly profileHash?: string; @@ -130,6 +134,7 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations(), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -162,6 +167,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -219,6 +225,7 @@ describe('executeDbUpdate', () => { migrations, frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -255,6 +262,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -295,6 +303,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -352,6 +361,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -408,6 +418,7 @@ describe('executeDbUpdate', () => { migrations, frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -461,6 +472,7 @@ describe('executeDbUpdate', () => { migrations, frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -473,13 +485,17 @@ describe('executeDbUpdate', () => { ); }); - describe('destructive changes gate', () => { - function createDestructiveMigrations() { + describe('questions before an apply', () => { + const NICKNAME = { kind: 'storage', name: 'user.nickname' } as const; + const USER = { kind: 'storage', name: 'user' } as const; + + function createDestructiveMigrations(executeSpy?: ReturnType) { return createMockMigrations({ + ...(executeSpy === undefined ? {} : { executeSpy }), planResult: { kind: 'success', - dataLoss: [], - accessWidening: [], + dataLoss: [{ operationIndex: 0, subject: NICKNAME }], + accessWidening: [{ operationIndex: 2, subject: USER }], appliedStatements: [], plan: { targetId: 'postgres', @@ -495,6 +511,11 @@ describe('executeDbUpdate', () => { label: 'Add column bio to user', operationClass: 'additive', }, + { + id: 'rls.user.disable', + label: 'Disable row-level security on user', + operationClass: 'widening', + }, ], renderTypeScript: () => { throw new Error('not used in db update tests'); @@ -503,143 +524,157 @@ describe('executeDbUpdate', () => { }, runnerResult: ok({ perSpaceResults: [ - { space: 'app', value: { operationsPlanned: 2, operationsExecuted: 2 } }, + { space: 'app', value: { operationsPlanned: 3, operationsExecuted: 3 } }, ], }), }); } - it('returns DESTRUCTIVE_CHANGES in apply mode without acceptDataLoss', async () => { - const result = await executeDbUpdate({ + function applyInputs(overrides: Partial[0]> = {}) { + return { driver: createMockDriver(), adapter: STUB_ADAPTER, familyInstance: createMockFamilyInstance({ readAllMarkers: async () => new Map([['app', markerRecord({ storageHash: 'origin' })]]), }), contract: dummyContract, - mode: 'apply', + mode: 'apply' as const, migrations: createDestructiveMigrations(), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, - targetId: 'postgres', - }); + targetId: 'postgres' as const, + answerQuestions: noQuestions, + ...overrides, + }; + } - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.failure.code).toBe('DESTRUCTIVE_CHANGES'); - expect(result.failure.summary).toContain('destructive'); - expect(result.failure.destructiveChanges).toEqual({ - destructiveOperations: [ - { id: 'dropColumn.user.nickname', label: 'Drop column nickname from user' }, + it('asks about each loss and each access widening in one batch before applying', async () => { + const asked: { question: string; subject: string; verbs: readonly string[] }[] = []; + const execute = vi.fn().mockResolvedValue( + ok({ + perSpaceResults: [ + { space: 'app', value: { operationsPlanned: 3, operationsExecuted: 3 } }, ], - databaseName: 'appdb', - planHash: expect.stringMatching(/^[0-9a-f]{64}$/), - }); - } - }); - - it('applies when the consent names the refused plan', async () => { - const sharedInputs = () => ({ - driver: createMockDriver(), - adapter: STUB_ADAPTER, - familyInstance: createMockFamilyInstance({ - readAllMarkers: async () => new Map([['app', markerRecord({ storageHash: 'origin' })]]), }), - contract: dummyContract, - mode: 'apply' as const, - migrations: createDestructiveMigrations(), - frameworkComponents: [], - migrationsDir: FAKE_MIGRATIONS_DIR, - targetId: 'postgres' as const, - }); + ); + const result = await executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + answerQuestions: async (questions) => { + expect(execute).not.toHaveBeenCalled(); + asked.push( + ...questions.map(({ question, subject, verbs }) => ({ question, subject, verbs })), + ); + return questions.map((question) => ({ + verb: question.verbs.includes('allow') ? 'allow' : 'delete', + text: question.subject, + })); + }, + }), + ); - const refused = await executeDbUpdate(sharedInputs()); - expect(refused.ok).toBe(false); - const planHash = !refused.ok ? refused.failure.destructiveChanges?.planHash : undefined; - expect(planHash).toBeDefined(); + expect(asked).toEqual([ + { + question: + 'Drop column nickname from user would lose the data in "user.nickname", which no model of the origin contract stores.', + subject: 'user.nickname', + verbs: ['delete'], + }, + { + question: + 'Disable row-level security on user would widen who can read and write its rows.', + subject: 'user', + verbs: ['allow'], + }, + ]); + expect(result.ok && result.value.appliedStatements).toEqual([ + { + verb: 'delete', + statement: { kind: 'delete', subject: NICKNAME }, + operationIndexes: [0], + description: 'delete storage "user.nickname"', + }, + { + verb: 'allow', + statement: { kind: 'allow', subject: USER }, + operationIndexes: [2], + description: 'allow storage "user"', + }, + ]); + expect(execute).toHaveBeenCalledTimes(1); + }); - const result = await executeDbUpdate({ - ...sharedInputs(), - consent: { planHash: planHash as string }, - }); + it('takes delete and allow statements as answers without asking', async () => { + const answerQuestions = vi.fn(noQuestions); + const result = await executeDbUpdate( + applyInputs({ + statements: [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'allow', text: 'user' }, + ], + answerQuestions, + }), + ); expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.mode).toBe('apply'); - expect(result.value.execution).toBeDefined(); - } + expect(answerQuestions).toHaveBeenCalledWith([]); }); - it('refuses a consent naming a plan it is no longer about to apply', async () => { - const consentedPlanHash = 'f'.repeat(64); - const result = await executeDbUpdate({ - driver: createMockDriver(), - adapter: STUB_ADAPTER, - familyInstance: createMockFamilyInstance({ - readAllMarkers: async () => new Map([['app', markerRecord({ storageHash: 'origin' })]]), - }), - contract: dummyContract, - mode: 'apply', - consent: { planHash: consentedPlanHash }, - migrations: createDestructiveMigrations(), - frameworkComponents: [], - migrationsDir: FAKE_MIGRATIONS_DIR, - targetId: 'postgres', - }); + it('answers every question with acceptDataLoss: true', async () => { + const answerQuestions = vi.fn(noQuestions); + const result = await executeDbUpdate(applyInputs({ acceptDataLoss: true, answerQuestions })); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.failure.code).toBe('CONSENT_PLAN_MISMATCH'); - expect(result.failure.consentPlanMismatch).toEqual({ - consentedPlanHash, - planHash: expect.stringMatching(/^[0-9a-f]{64}$/), - }); - } + expect(result.ok).toBe(true); + expect(answerQuestions).toHaveBeenCalledWith([]); }); - it('proceeds to runner in apply mode with acceptDataLoss: true', async () => { - const result = await executeDbUpdate({ - driver: createMockDriver(), - adapter: STUB_ADAPTER, - familyInstance: createMockFamilyInstance({ - readAllMarkers: async () => new Map([['app', markerRecord({ storageHash: 'origin' })]]), - }), - contract: dummyContract, - mode: 'apply', - acceptDataLoss: true, - migrations: createDestructiveMigrations(), - frameworkComponents: [], - migrationsDir: FAKE_MIGRATIONS_DIR, - targetId: 'postgres', - }); + it('rejects an answer callback that leaves a question unanswered, before applying', async () => { + const execute = vi.fn(); + await expect( + executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + answerQuestions: async () => [{ verb: 'delete', text: 'user.nickname' }], + }), + ), + ).rejects.toThrow( + 'answerQuestions gave 1 answer for 2 questions; answer each question in order, or throw to refuse.', + ); + expect(execute).not.toHaveBeenCalled(); + }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.mode).toBe('apply'); - expect(result.value.execution).toBeDefined(); - } + it('rejects an answer the question does not accept, before applying', async () => { + const execute = vi.fn(); + await expect( + executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + answerQuestions: async () => [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'delete', text: 'user' }, + ], + }), + ), + ).rejects.toThrow( + 'answerQuestions answered "Disable row-level security on user would widen who can read and write its rows." with delete, which it does not accept; it accepts allow.', + ); + expect(execute).not.toHaveBeenCalled(); }); - it('returns success in plan mode regardless of destructive operations', async () => { - const result = await executeDbUpdate({ - driver: createMockDriver(), - adapter: STUB_ADAPTER, - familyInstance: createMockFamilyInstance({ - readAllMarkers: async () => new Map([['app', markerRecord({ storageHash: 'origin' })]]), - }), - contract: dummyContract, - mode: 'plan', - migrations: createDestructiveMigrations(), - frameworkComponents: [], - migrationsDir: FAKE_MIGRATIONS_DIR, - targetId: 'postgres', - }); + it('asks nothing in plan mode, and lists the questions an apply would ask', async () => { + const answerQuestions = vi.fn(noQuestions); + const result = await executeDbUpdate(applyInputs({ mode: 'plan', answerQuestions })); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.mode).toBe('plan'); - expect(result.value.plan.operations).toHaveLength(2); - } + expect(answerQuestions).not.toHaveBeenCalled(); + expect( + result.ok && { + dataLoss: result.value.dataLoss, + accessWidening: result.value.accessWidening, + }, + ).toEqual({ + dataLoss: [{ operationIndex: 0, subject: NICKNAME }], + accessWidening: [{ operationIndex: 2, subject: USER }], + }); }); }); @@ -688,6 +723,7 @@ describe('executeDbUpdate', () => { migrations, frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); @@ -719,6 +755,7 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations(), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', onProgress: (event) => events.push(event), }); @@ -746,6 +783,7 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations(), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', onProgress: (event) => events.push(event), }); @@ -779,6 +817,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', onProgress: (event) => events.push(event), }); @@ -807,6 +846,7 @@ describe('executeDbUpdate', () => { }), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', onProgress: (event) => events.push(event), }); @@ -825,6 +865,7 @@ describe('executeDbUpdate', () => { migrations: createMockMigrations(), frameworkComponents: [], migrationsDir: FAKE_MIGRATIONS_DIR, + answerQuestions: noQuestions, targetId: 'postgres', }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts index f1417fd5c73d..4062f573bc58 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/testing/fixture-client.test.ts @@ -38,6 +38,7 @@ describe('createFixtureControlClient', () => { contract: {}, mode: 'plan', migrationsDir: 'migrations', + answerQuestions: async () => [], }); expect(dbUpdate.ok).toBe(true); @@ -100,7 +101,11 @@ describe('createFixtureControlClient', () => { }); await client.connect('postgres://fixture'); - const dbInit = await client.dbInit({ contract: {}, mode: 'plan', migrationsDir: 'migrations' }); + const dbInit = await client.dbInit({ + contract: {}, + mode: 'plan', + migrationsDir: 'migrations', + }); expect(dbInit.assertNotOk().summary).toBe('planner exploded'); expect(await client.readMarker()).toBeNull(); @@ -154,8 +159,25 @@ describe('createFixtureControlClient', () => { ['verify', (c) => c.verify({ contract: {} })], ['schemaVerify', (c) => c.schemaVerify({ contract: {} })], ['dbSign', (c) => c.dbSign({ contract: {} as never, migrationsDir: 'migrations' })], - ['dbInit', (c) => c.dbInit({ contract: {}, mode: 'plan', migrationsDir: 'migrations' })], - ['dbUpdate', (c) => c.dbUpdate({ contract: {}, mode: 'plan', migrationsDir: 'migrations' })], + [ + 'dbInit', + (c) => + c.dbInit({ + contract: {}, + mode: 'plan', + migrationsDir: 'migrations', + }), + ], + [ + 'dbUpdate', + (c) => + c.dbUpdate({ + contract: {}, + mode: 'plan', + migrationsDir: 'migrations', + answerQuestions: async () => [], + }), + ], [ 'dbVerify', (c) => 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 753c3bfbd476..11f2b0e32cc3 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 @@ -448,7 +448,7 @@ describe('db init', () => { { kind: 'run-command', label: - 'Apply the change with db update, which lists the destructive operations and asks you to confirm them', + 'Apply the change with db update, which asks what each operation that would lose data means', command: 'prisma-test db update', }, ], diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index ff33f3b0d04e..bcc99924d008 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -1,10 +1,16 @@ import { rmSync, writeFileSync } from 'node:fs'; -import { notOk, ok } from '@internal/utils/result'; -import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; +import { asNamespaceId } from '@internal/contract/types'; +import { ok } from '@internal/utils/result'; +import type { StreamEvent } from '@prisma/cli-engine'; import { join } from 'pathe'; -import stripAnsi from 'strip-ansi'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ControlClient } from '../../src/control-api/types'; +import { + accessWideningQuestion, + dataLossQuestion, + type PlanAnswer, + type PlanQuestion, +} from '../../src/control-api/statements/data-loss-questions'; +import type { ControlClient, DbUpdateOptions } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; import { createOrmTestCli } from '../helpers/orm-test-cli'; import { createTestProjectDir, writeProjectManifest } from '../utils/test-project-dir'; @@ -39,14 +45,70 @@ const DESCRIPTOR = { familyId: 'sql', targetId: 'postgres', version: '1.0.0', cr const DEST_HASH = 'd'.repeat(64); const MARKER_HASH = 'a'.repeat(64); const CONNECTION = 'postgres://user:secret@localhost:5432/appdb'; -const TOKEN = 'appdb'; -const PLAN_HASH = 'c'.repeat(64); -const FRESH_PLAN_HASH = 'e'.repeat(64); -const QUESTION = 'Apply 1 destructive operation(s) to appdb?'; let projectDir: string; const projectDirs: string[] = []; +/** The contracts the questions name subjects in: the origin has Legacy and User, the destination User. */ +const origin = { + domain: { + namespaces: { + app: { + models: { + Legacy: { fields: {}, relations: {}, storage: {} }, + User: { fields: {}, relations: {}, storage: {} }, + }, + }, + }, + }, +}; +const destination = { + domain: { namespaces: { app: { models: { User: { fields: {}, relations: {}, storage: {} } } } } }, +}; +const contracts = { origin, destination, renames: [] }; + +/** One question of each kind, across two spaces: an extension's recorded drop is a storage subject. */ +function planQuestions(): readonly PlanQuestion[] { + return [ + dataLossQuestion( + { + operationIndex: 0, + label: 'Drop table "audit_old"', + subject: { kind: 'storage', name: 'audit_old' }, + }, + contracts, + ), + dataLossQuestion( + { + operationIndex: 1, + label: 'Drop table "Legacy"', + subject: { kind: 'model', namespaceId: asNamespaceId('app'), model: 'Legacy' }, + }, + contracts, + ), + accessWideningQuestion( + { + operationIndex: 2, + label: 'Disable row-level security on "User"', + subject: { kind: 'model', namespaceId: asNamespaceId('app'), model: 'User' }, + }, + contracts, + ), + ]; +} + +/** The control API's shape: an apply asks its questions, then applies; a dry run asks nothing. */ +function askThenApply(questions: () => readonly PlanQuestion[]) { + return async (options: DbUpdateOptions) => { + if (options.mode === 'apply') { + answers.push(...(await options.answerQuestions(questions()))); + } + return ok(applySuccess(options.mode)); + }; +} + +const answers: PlanAnswer[] = []; + beforeEach(() => { projectDir = createTestProjectDir('orm-db-update-consent'); projectDirs.push(projectDir); @@ -56,9 +118,10 @@ beforeEach(() => { JSON.stringify({ storage: { storageHash: MARKER_HASH } }), ); writeFileSync(join(projectDir, 'contract.d.ts'), 'export type Contract = never;\n'); + answers.splice(0); mocks.connect.mockReset().mockResolvedValue(undefined); mocks.close.mockReset().mockResolvedValue(undefined); - mocks.dbUpdate.mockReset().mockImplementation(refuseUntilConsented()); + mocks.dbUpdate.mockReset().mockImplementation(askThenApply(planQuestions)); mocks.renderContractDts .mockReset() .mockResolvedValue(ok({ contractDts: 'export type Contract = never;\n' })); @@ -86,73 +149,22 @@ function ormConfig(overrides: Record = {}): Record { +function applySuccess(mode: 'plan' | 'apply'): Record { return { - mode: 'apply', + mode, destination: { storageHash: DEST_HASH }, plan: { - operations: [{ id: 'op-2', label: 'drop relation legacy', operationClass: 'destructive' }], + operations: [{ id: 'op-2', label: 'Drop table "Legacy"', operationClass: 'destructive' }], }, - execution: { operationsPlanned: 1, operationsExecuted: 1 }, + ...(mode === 'apply' ? { execution: { operationsPlanned: 1, operationsExecuted: 1 } } : {}), marker: { storageHash: MARKER_HASH }, - perSpace: [ - { - spaceId: 'app', - kind: 'app', - operations: [{ id: 'op-2', label: 'drop relation legacy', operationClass: 'destructive' }], - marker: { storageHash: MARKER_HASH }, - }, - ], + appliedStatements: [], + dataLoss: [], + accessWidening: [], summary: 'Database updated', }; } -function destructiveRefusal(overrides: Record = {}): Record { - return { - code: 'DESTRUCTIVE_CHANGES', - summary: 'Planned 1 destructive operation(s) that require confirmation', - why: 'Destructive operations require confirmation', - conflicts: undefined, - meta: undefined, - destructiveChanges: { - destructiveOperations: [{ id: 'op-2', label: 'drop relation legacy' }], - databaseName: TOKEN, - planHash: PLAN_HASH, - }, - ...overrides, - }; -} - -/** The refusal an apply carrying a stale consent comes back with. */ -function planMismatchFailure(): Record { - return { - code: 'CONSENT_PLAN_MISMATCH', - summary: 'The plan changed between consent and apply', - why: 'The freshly computed plan is not the plan that was consented to', - conflicts: undefined, - meta: undefined, - consentPlanMismatch: { consentedPlanHash: PLAN_HASH, planHash: FRESH_PLAN_HASH }, - }; -} - -function warnTexts(events: readonly EngineEvent[]): readonly string[] { - return events.flatMap((event) => - event.kind === 'message' && event.severity === 'warn' ? [event.text] : [], - ); -} - -/** - * The control API's own shape: an apply without consent refuses when the plan - * carries destructive operations, and the call consenting to that exact plan - * (by its hash) applies. - */ -function refuseUntilConsented() { - return (options: { readonly consent?: { readonly planHash: string } }) => - Promise.resolve( - options.consent?.planHash === PLAN_HASH ? ok(applySuccess()) : notOk(destructiveRefusal()), - ); -} - function harness(config: Record = ormConfig()) { return createOrmTestCli({ commands, groups, orm: config }); } @@ -162,327 +174,134 @@ function envelopeOf(json: readonly StreamEvent[]): unknown { return terminal?.kind === 'result' ? terminal.envelope : undefined; } -function applyCalls(): readonly { readonly consent?: { readonly planHash: string } }[] { - return mocks.dbUpdate.mock.calls.map((call) => call[0]); -} - -describe('db update consent', () => { - describe('interactively', () => { - it('applies the destructive plan once the database name is typed', async () => { - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); - - expect(run.exitCode).toBe(0); - expect(envelopeOf(run.json)).toMatchObject({ ok: true, result: { mode: 'apply' } }); - expect(mocks.connect).toHaveBeenCalledTimes(1); - expect(applyCalls().map((call) => call.consent)).toEqual([ - undefined, - { planHash: PLAN_HASH }, - ]); - }); - - /** - * Typed on stdin rather than scripted through `answers`: a scripted answer - * is returned without the prompt ever being rendered, so the question only - * reaches stderr when the run reads it from the stream. - */ - it('names the database and every destructive operation in the question', async () => { - const run = await harness().run(['db', 'update'], { - cwd: projectDir, - isTty: { stdin: true, stdout: true, stderr: true }, - stdin: `${TOKEN}\n`, - }); - - const rendered = stripAnsi(run.stderr); - expect(rendered).toContain(QUESTION); - expect(rendered).toContain('drop relation legacy'); - }); - - it('shows the planner`s warnings before it asks', async () => { - const warning = 'Column user.legacy is dropped without a backfill'; - mocks.dbUpdate - .mockReset() - .mockImplementation((options: { consent?: { planHash: string } }) => - Promise.resolve( - options.consent?.planHash === PLAN_HASH - ? ok(applySuccess()) - : notOk(destructiveRefusal({ warnings: [{ summary: warning }] })), - ), - ); - - const run = await harness().run(['db', 'update'], { - cwd: projectDir, - isTty: { stdin: true, stdout: true, stderr: true }, - stdin: `${TOKEN}\n`, - }); - - const rendered = stripAnsi(run.stderr); - expect(warnTexts(run.events)).toContain(warning); - expect(rendered).toContain(QUESTION); - expect(rendered.indexOf(warning)).toBeLessThan(rendered.indexOf(QUESTION)); - }); +describe('db update questions', () => { + it('refuses where nobody can answer, listing every question across spaces with its flags', async () => { + const run = await harness().run(['db', 'update', '--json'], { cwd: projectDir }); + + expect(run.exitCode).toBe(2); + const envelope = envelopeOf(run.json) as { + readonly error: { readonly code: string; readonly nextActions: unknown }; + }; + expect(envelope.error.code).toBe('CLI.CONSENT_REQUIRED'); + const actions = JSON.stringify(envelope.error.nextActions); + for (const flag of [ + '--delete audit_old', + '--delete Legacy', + '--rename Legacy:', + '--allow User', + ]) { + expect(actions).toContain(flag); + } + expect(actions).not.toContain('--rename audit_old'); + expect(actions).not.toContain('--delete User'); + }); - it('applies nothing when the answer is not the database name', async () => { - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: ['no'], - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.PROMPT_INVALID' }, - }); - expect(applyCalls()).toHaveLength(1); - }); + it('applies once every question is answered with flags', async () => { + const run = await harness().run( + ['db', 'update', '--delete', 'audit_old', '--delete', 'Legacy', '--allow', 'User', '--json'], + { cwd: projectDir }, + ); - it('exits 3 when the prompt is cancelled', async () => { - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - stdin: '', - }); - - expect(run.exitCode).toBe(3); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.PROMPT_CANCELLED' }, - }); - expect(applyCalls()).toHaveLength(1); - }); + expect(run.exitCode).toBe(0); + expect(answers).toEqual([ + { verb: 'delete', text: 'audit_old' }, + { verb: 'delete', text: 'Legacy' }, + { verb: 'allow', text: 'User' }, + ]); }); - describe('non-interactively', () => { - it('refuses without --confirm, naming the token to pass', async () => { - const run = await harness().run(['db', 'update', '--json'], { cwd: projectDir }); + it('refuses an apply that widens access without --allow', async () => { + const run = await harness().run( + ['db', 'update', '--delete', 'audit_old', '--delete', 'Legacy', '--json'], + { cwd: projectDir }, + ); - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_REQUIRED', meta: { consentToken: TOKEN } }, - }); - expect(applyCalls()).toHaveLength(1); + expect(envelopeOf(run.json)).toMatchObject({ + error: { + code: 'CLI.CONSENT_REQUIRED', + meta: { unanswered: [{ subject: 'User', verbs: ['allow'] }] }, + }, }); + }); - it('applies when --confirm carries the database name', async () => { - const run = await harness().run(['db', 'update', '--confirm', TOKEN, '--json'], { - cwd: projectDir, - }); - - expect(run.exitCode).toBe(0); - expect(envelopeOf(run.json)).toMatchObject({ ok: true, result: { mode: 'apply' } }); - expect(applyCalls().map((call) => call.consent)).toEqual([ - undefined, - { planHash: PLAN_HASH }, - ]); + it('takes typed answers', async () => { + const run = await harness().run(['db', 'update', '--json'], { + cwd: projectDir, + isTty: { stdin: true }, + answers: ['delete', 'delete', 'allow'], }); - it('refuses when --confirm carries another name', async () => { - const run = await harness().run(['db', 'update', '--confirm', 'otherdb', '--json'], { - cwd: projectDir, - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_REQUIRED' }, - }); - expect(applyCalls()).toHaveLength(1); - }); + expect(run.exitCode).toBe(0); + expect(answers.map(({ verb }) => verb)).toEqual(['delete', 'delete', 'allow']); }); - describe('--yes', () => { - it('does not accept data loss on its own', async () => { - const run = await harness().run(['db', 'update', '--yes', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_REQUIRED' }, - }); - expect(applyCalls()).toHaveLength(1); + it('does not take --confirm or --yes as an answer', async () => { + const confirmed = await harness().run(['db', 'update', '--confirm', 'appdb', '--json'], { + cwd: projectDir, }); - - it('does not stop --confirm from granting', async () => { - const run = await harness().run(['db', 'update', '--yes', '--confirm', TOKEN, '--json'], { - cwd: projectDir, - }); - - expect(run.exitCode).toBe(0); - expect(applyCalls().map((call) => call.consent)).toEqual([ - undefined, - { planHash: PLAN_HASH }, - ]); + const yes = await harness().run(['db', 'update', '--yes', '--json'], { + cwd: projectDir, + isTty: { stdin: true }, }); - }); - - describe('the consent token', () => { - it('asks with the database name the refusal carries', async () => { - mocks.dbUpdate.mockReset().mockResolvedValue( - notOk( - destructiveRefusal({ - destructiveChanges: { - destructiveOperations: [{ id: 'op-2', label: 'drop relation legacy' }], - databaseName: 'otherdb', - planHash: PLAN_HASH, - }, - }), - ), - ); - const run = await harness().run(['db', 'update', '--json'], { cwd: projectDir }); - - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { meta: { consentToken: 'otherdb' } }, - }); - }); + expect([envelopeOf(confirmed.json), envelopeOf(yes.json)]).toMatchObject([ + { error: { code: 'CLI.CONSENT_REQUIRED' } }, + { error: { code: 'CLI.CONSENT_REQUIRED' } }, + ]); }); - describe('when the question would be unanswerable', () => { - it('refuses rather than ask for a blank token', async () => { - mocks.dbUpdate.mockReset().mockResolvedValue( - notOk( - destructiveRefusal({ - destructiveChanges: { - destructiveOperations: [{ id: 'op-2', label: 'drop relation legacy' }], - databaseName: undefined, - planHash: PLAN_HASH, - }, - }), - ), - ); + it('refuses a --delete no question asks about', async () => { + mocks.dbUpdate.mockReset().mockImplementation(askThenApply(() => [])); - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_TOKEN_UNRESOLVED' }, - }); - expect(applyCalls()).toHaveLength(1); + const run = await harness().run(['db', 'update', '--delete', 'Nope', '--json'], { + cwd: projectDir, }); - it('refuses rather than ask about operations the refusal did not name', async () => { - mocks.dbUpdate.mockReset().mockResolvedValue( - notOk( - destructiveRefusal({ - destructiveChanges: { - destructiveOperations: [], - databaseName: TOKEN, - planHash: PLAN_HASH, - }, - }), - ), - ); - - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CLI.CONSENT_OPERATIONS_MISSING' }, - }); - expect(applyCalls()).toHaveLength(1); - }); + expect(envelopeOf(run.json)).toMatchObject({ error: { code: 'CLI.CONSENT_UNUSED' } }); }); - describe('when the applied plan is not the plan consented to', () => { - it('surfaces the control API`s typed mismatch refusal as an error', async () => { - mocks.dbUpdate - .mockReset() - .mockImplementation((options: { consent?: { planHash: string } }) => - Promise.resolve( - options.consent === undefined - ? notOk(destructiveRefusal()) - : notOk(planMismatchFailure()), - ), - ); - - const run = await harness().run(['db', 'update', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'MIGRATION.CONSENT_PLAN_MISMATCH' }, - }); - expect(applyCalls().map((call) => call.consent)).toEqual([ - undefined, - { planHash: PLAN_HASH }, - ]); - }); - - it('carries the consented plan through the re-run untouched', async () => { - const run = await harness().run(['db', 'update', '--json'], { + it('asks nothing on a dry run, so a --delete given to one is unused', async () => { + const asked = await harness().run(['db', 'update', '--dry-run', '--json'], { cwd: projectDir }); + const unused = await harness().run( + ['db', 'update', '--dry-run', '--delete', 'Legacy', '--json'], + { cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); + }, + ); - expect(run.exitCode).toBe(0); - expect(warnTexts(run.events)).toEqual([]); - }); + expect(asked.exitCode).toBe(0); + expect(answers).toEqual([]); + expect(mocks.dbUpdate).toHaveBeenCalledWith(expect.objectContaining({ mode: 'plan' })); + expect(envelopeOf(unused.json)).toMatchObject({ error: { code: 'CLI.CONSENT_UNUSED' } }); }); - describe('--dry-run', () => { - it('never asks, even when the plan call comes back destructive', async () => { - const run = await harness().run(['db', 'update', '--dry-run', '--json'], { - cwd: projectDir, - isTty: { stdin: true }, - answers: [TOKEN], - }); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'MIGRATION.DESTRUCTIVE_CHANGES' }, - }); - expect(mocks.dbUpdate).toHaveBeenCalledWith(expect.objectContaining({ mode: 'plan' })); - expect(applyCalls()).toHaveLength(1); - }); - }); - - describe('when nothing destructive is planned', () => { - beforeEach(() => { - mocks.dbUpdate.mockReset().mockResolvedValue(ok(applySuccess())); - }); - - it('applies without asking, even non-interactively', async () => { - const run = await harness().run(['db', 'update', '--json'], { cwd: projectDir }); - - expect(run.exitCode).toBe(0); - expect(applyCalls()).toHaveLength(1); - expect(applyCalls()[0]?.consent).toBeUndefined(); - expect(run.stderr).not.toContain('confirm'); - }); - - it('plans without asking under --dry-run', async () => { - const run = await harness().run(['db', 'update', '--dry-run', '--json'], { - cwd: projectDir, - }); + it('keeps every statement in the retry command when no connection is configured', async () => { + const run = await harness({ ...ormConfig(), db: undefined }).run( + [ + 'db', + 'update', + '--rename', + 'Profile:User', + '--delete', + 'Legacy', + '--allow', + 'User', + '--json', + ], + { cwd: projectDir }, + ); - expect(run.exitCode).toBe(0); - expect(mocks.dbUpdate).toHaveBeenCalledWith(expect.objectContaining({ mode: 'plan' })); - expect(applyCalls()).toHaveLength(1); + expect(envelopeOf(run.json)).toMatchObject({ + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + 'prisma-test db update --rename Profile:User --delete Legacy --allow User --db $DATABASE_URL', + ), + }), + ], + }, }); }); }); 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 f43beed9efa6..93d59f703cc4 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 @@ -265,6 +265,9 @@ describe('db update --to bundle resolution', () => { preview: undefined, }, destination: { storageHash: HASH_B }, + appliedStatements: [], + dataLoss: [], + accessWidening: [], summary: 'Plan ready', }), ); @@ -287,6 +290,9 @@ describe('db update --to bundle resolution', () => { destination: { storageHash: HASH_B }, operations: [{ id: 'table.users', label: 'Create users', operationClass: 'additive' }], }, + appliedStatements: [], + dataLoss: [], + accessWidening: [], advancedRef: null, plannedAdvanceRef: { name: 'db', hash: HASH_B }, summary: 'Plan ready', 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 a40cd9cc2cb8..882889381246 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 @@ -97,6 +97,8 @@ function applySuccess(): Record { return { mode: 'apply', appliedStatements: [], + dataLoss: [], + accessWidening: [], destination: { storageHash: DEST_HASH }, plan: { operations: [ @@ -125,6 +127,8 @@ function planSuccess(): Record { return { mode: 'plan', appliedStatements: [], + dataLoss: [], + accessWidening: [], destination: { storageHash: DEST_HASH }, plan: { operations: [{ id: 'op-2', label: 'drop relation legacy', operationClass: 'destructive' }], @@ -206,6 +210,32 @@ describe('db update --rename', () => { expect(run.presented?.data).toMatchObject({ appliedStatements: [PROFILE_TO_USER] }); }); + it('lists what an apply would ask about on a dry run, in its output and its JSON', async () => { + const legacy = { kind: 'model', namespaceId: 'app', model: 'Legacy' } as const; + mocks.dbUpdate.mockResolvedValue( + ok({ ...planSuccess(), dataLoss: [{ operationIndex: 0, subject: legacy }] }), + ); + const human = await harness().run(['db', 'update', '--dry-run'], { + cwd: projectDir, + isTty: { stdout: true }, + }); + const json = await harness().run(['db', 'update', '--dry-run', '--json'], { cwd: projectDir }); + + expect(human.presented?.presentation.human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'An apply asks about', + children: [{ label: 'drop relation legacy: --delete app.Legacy' }], + }, + ], + }); + expect(json.presented?.data).toMatchObject({ + dataLoss: [{ operationIndex: 0, subject: legacy }], + accessWidening: [], + }); + }); + it('lists the applied statements on a dry run', async () => { mocks.dbUpdate.mockResolvedValue( ok({ ...planSuccess(), appliedStatements: [{ ...PROFILE_TO_USER, operationIndexes: [] }] }), diff --git a/packages/3-extensions/supabase/test/skeleton.integration.test.ts b/packages/3-extensions/supabase/test/skeleton.integration.test.ts index e28d4418a2f7..8717c5d2fc4b 100644 --- a/packages/3-extensions/supabase/test/skeleton.integration.test.ts +++ b/packages/3-extensions/supabase/test/skeleton.integration.test.ts @@ -624,6 +624,11 @@ describe('supabase RLS behavioral e2e — filtering + drift-fails-verify', () => // Plan: exactly one drop per policy — no enablement change anywhere. const planResult = await client.dbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), contract: noPolicyContract, mode: 'plan', migrationsDir, @@ -638,6 +643,11 @@ describe('supabase RLS behavioral e2e — filtering + drift-fails-verify', () => ); const applyResult = await client.dbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), contract: noPolicyContract, mode: 'apply', migrationsDir, @@ -696,6 +706,11 @@ describe('supabase RLS behavioral e2e — filtering + drift-fails-verify', () => // Plan: exactly one rename — no drop, no create, no enablement change. const planResult = await client.dbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), contract: renamedContract, mode: 'plan', migrationsDir, @@ -707,6 +722,11 @@ describe('supabase RLS behavioral e2e — filtering + drift-fails-verify', () => expect(planIds).toEqual([`rlsPolicy.public.profile.${POLICY_WIRE_NAME}.rename`]); const applyResult = await client.dbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), contract: renamedContract, mode: 'apply', migrationsDir, 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 index 2dd443e16e02..06e6bbbd8c9f 100644 --- 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 @@ -66,6 +66,11 @@ describe('db update with statements on MongoDB', () => { }); const result = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: { close: async () => {}, databaseName: async () => 'appdb', diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/db-init-update.cli.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/db-init-update.cli.integration.test.ts index 36babf6906e8..2df20c890f31 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/db-init-update.cli.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/db-init-update.cli.integration.test.ts @@ -371,6 +371,11 @@ describe('db init / db update aggregate pipeline (CLI) - postgres', { concurrent } const updateResult = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: drv, adapter: controlAdapter, familyInstance, @@ -467,6 +472,11 @@ describe('db init / db update aggregate pipeline (CLI) - postgres', { concurrent } const updateResult = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: drv, adapter: controlAdapter, familyInstance, diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/db-init-update.cli.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/db-init-update.cli.test.ts index c81432a8a11c..2134af0f6f11 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/db-init-update.cli.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/db-init-update.cli.test.ts @@ -372,6 +372,11 @@ describe('db init / db update aggregate pipeline (CLI) - sqlite', { } const updateResult = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: testDb!.driver, adapter: controlAdapter, familyInstance, @@ -583,6 +588,11 @@ describe('db init / db update aggregate pipeline (CLI) - sqlite', { // a no-op — proves the aggregate path's marker / hash check still // short-circuits when n=1. const updateResult = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: testDb!.driver, adapter: controlAdapter, familyInstance, @@ -660,6 +670,11 @@ describe('db init / db update aggregate pipeline (CLI) - sqlite', { } const updateResult = await executeDbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), driver: testDb!.driver, adapter: controlAdapter, familyInstance, diff --git a/test/integration/test/authoring/psl.pgvector-dbinit.test.ts b/test/integration/test/authoring/psl.pgvector-dbinit.test.ts index 642a3dad65de..6fc4b3662d36 100644 --- a/test/integration/test/authoring/psl.pgvector-dbinit.test.ts +++ b/test/integration/test/authoring/psl.pgvector-dbinit.test.ts @@ -222,6 +222,11 @@ model Document { await withPgvectorControlClient(connectionString, async (client) => { const update = await client.dbUpdate({ + answerQuestions: async (questions) => + questions.map((question) => ({ + verb: question.verbs.includes('allow') ? ('allow' as const) : ('delete' as const), + text: question.subject, + })), contract: emittedContract, mode: 'apply', migrationsDir, From 66bc75e343f576bbed83f69afb1d1fa75b315b42 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:45:49 +0200 Subject: [PATCH 044/109] TML-3476: a storage subject says the origin contract is unknown when db update has no snapshot Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/operations/db-run.ts | 1 + .../control-api/operations/migration-plan.ts | 3 ++ .../statements/data-loss-questions.ts | 21 +++++++--- .../cli/test/control-api/db-update.test.ts | 2 +- .../statements/data-loss-question.test.ts | 41 +++++++++++++++++++ .../cli/test/orm/db-update-consent.test.ts | 2 +- 6 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts 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 a79175391734..21d0f750ff23 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 @@ -319,6 +319,7 @@ export async function executeRun statement.verb !== 'rename'), consentAll: options.acceptDataLoss, origin: fromContract ?? EMPTY_ORIGIN, + originKnown: fromContract !== null, destination: contract, answer: options.answerQuestions, replan: (renames) => plan(resolveRenames(renames)), 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 224a11c34c27..713afabbe366 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 @@ -292,6 +292,7 @@ async function answerPlannedDataLoss(input: { readonly delta: PlannerSuccess | undefined; readonly renames: readonly StatementText[]; readonly origin: ContractWithDomain; + readonly originKnown: boolean; readonly destination: ContractWithDomain; readonly answer: AnswerPlanQuestions; readonly planDelta: ( @@ -318,6 +319,7 @@ async function answerPlannedDataLoss(input: { preAnswers: [], consentAll: false, origin: input.origin, + originKnown: input.originKnown, destination: input.destination, answer: input.answer, replan: async (renames) => { @@ -738,6 +740,7 @@ async function executeMigrationPlanCommandInner( delta, renames: statementTexts, origin: fromContract ?? EMPTY_ORIGIN, + originKnown: true, destination: toContract, answer: options.answerQuestions, planDelta: async (renames) => { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index fce42b83d003..7fa8104af9dd 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -126,14 +126,16 @@ export function consentDescription( return `${verb} ${subject.kind} "${text}"`; } -function lossText(subject: MigrationStatementSubject, text: string): string { +function lossText(subject: MigrationStatementSubject, text: string, originKnown: boolean): string { switch (subject.kind) { case 'model': return `would lose the data of model "${text}"`; case 'field': return `would lose the values of field "${text}"`; case 'storage': - return `would lose the data in "${text}", which no model of the origin contract stores`; + return originKnown + ? `would lose the data in "${text}", which no model of the origin contract stores` + : `would lose the data in "${text}", named by its storage name because the origin contract is unknown`; } } @@ -161,13 +163,16 @@ function namesSubject(verb: ConsentVerb, subject: string) { */ export function dataLossQuestion( loss: PlannedSubject, - contracts: StatementContracts & { readonly destination: ContractWithDomain }, + contracts: StatementContracts & { + readonly destination: ContractWithDomain; + readonly originKnown: boolean; + }, ): PlanQuestion { const subject = subjectText(loss.subject, contracts); const renamable = loss.subject.kind !== 'storage'; const deleteNames = namesSubject('delete', subject); return { - question: `${loss.label} ${lossText(loss.subject, subject)}.`, + question: `${loss.label} ${lossText(loss.subject, subject, contracts.originKnown)}.`, subject, verbs: renamable ? ['rename', 'delete'] : ['delete'], forms: renamable ? { rename: `${subject}:` } : {}, @@ -248,6 +253,7 @@ export async function answerPlanQuestions Promise>; @@ -265,7 +271,12 @@ export async function answerPlanQuestions(); for (let round = 0; ; round += 1) { - const contracts = { origin: input.origin, destination: input.destination, renames }; + const contracts = { + origin: input.origin, + originKnown: input.originKnown, + destination: input.destination, + renames, + }; const pending = ( verb: ConsentVerb, planned: readonly PlannedSubject[], 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 63f66fd743b6..e80dbae3a56b 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 @@ -576,7 +576,7 @@ describe('executeDbUpdate', () => { expect(asked).toEqual([ { question: - 'Drop column nickname from user would lose the data in "user.nickname", which no model of the origin contract stores.', + 'Drop column nickname from user would lose the data in "user.nickname", named by its storage name because the origin contract is unknown.', subject: 'user.nickname', verbs: ['delete'], }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts new file mode 100644 index 000000000000..4d6726353453 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { dataLossQuestion } from '../../../src/control-api/statements/data-loss-questions'; +import { contractOf } from './statement-fixtures'; + +const origin = contractOf({ app: { models: { User: { fields: ['email'] } } } }); +const destination = contractOf({ app: { models: { User: { fields: ['email'] } } } }); +const drop = { + operationIndex: 0, + label: 'Drop column age from user', + subject: { kind: 'storage', name: 'public.user.age' }, +} as const; + +describe('dataLossQuestion for data no model names', () => { + it('says no model of a known origin contract stores it', () => { + const question = dataLossQuestion(drop, { + origin, + destination, + renames: [], + originKnown: true, + }); + + expect({ question: question.question, verbs: question.verbs }).toEqual({ + question: + 'Drop column age from user would lose the data in "public.user.age", which no model of the origin contract stores.', + verbs: ['delete'], + }); + }); + + it('says the name is a storage name when the origin contract is unknown', () => { + const question = dataLossQuestion(drop, { + origin, + destination, + renames: [], + originKnown: false, + }); + + expect(question.question).toBe( + 'Drop column age from user would lose the data in "public.user.age", named by its storage name because the origin contract is unknown.', + ); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index bcc99924d008..d90724179559 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -65,7 +65,7 @@ const origin = { const destination = { domain: { namespaces: { app: { models: { User: { fields: {}, relations: {}, storage: {} } } } } }, }; -const contracts = { origin, destination, renames: [] }; +const contracts = { origin, destination, renames: [], originKnown: true }; /** One question of each kind, across two spaces: an extension's recorded drop is a storage subject. */ function planQuestions(): readonly PlanQuestion[] { From 264dcda70e9268c7a104b7aa03bd3730dc50cef4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:45:53 +0200 Subject: [PATCH 045/109] TML-3476: db update journeys answer data loss with --delete instead of --confirm A run that only adds structure passes no statement; a drop names its subject, the model field when the snapshot names it and the storage name otherwise. Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 4 +-- .../db-update-workflows.e2e.test.ts | 29 +++++++++++++------ .../cli-journeys/drift-schema.e2e.test.ts | 21 ++++++++------ .../mongo-db-update-consent.e2e.test.ts | 14 ++++----- .../rename-table-migration.sqlite.e2e.test.ts | 2 +- .../test/cli.db-update.e2e.test.ts | 12 ++++---- .../cli.db-update.preflight-gaps.e2e.test.ts | 22 ++------------ .../test/utils/db-update-test-helpers.ts | 15 ---------- .../test/utils/journey-test-helpers.ts | 15 ---------- 9 files changed, 50 insertions(+), 84 deletions(-) diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 4e78494d769c..132ec7c9f9a5 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -27,7 +27,7 @@ pnpm test:journeys | `multi-step-migration.e2e.test.ts` | Planning two migrations (base → additive → v3) without applying either, then batch-applying both at once. Verifies pending/applied status reporting | | `migration-plan-details.e2e.test.ts` | **Plan JSON envelope**: full `--json` output shape with operations, attestation round-trip (plan → verify). **Destructive planning**: drop-column migration produces destructive operation class | | `migration-apply-edge-cases.e2e.test.ts` | **No path**: apply fails when contract changed without planning. **Resume**: partial failure (NOT NULL violation) leaves marker at last success, re-apply resumes. **Destructive apply**: single drop-column migration verifies column removed + marker updated. **Multi-step destructive**: three-migration batch (create → add → drop) in one apply | -| `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column, tests `--no-interactive` rejection, `--json` error envelope, and `--json -y` auto-accept. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | +| `db-update-workflows.e2e.test.ts` | **Direct update**: `db update` without migrations (additive-only, dry-run, noop). **Destructive update**: drops a column; `--no-interactive` and `--yes` are refused with `CLI.CONSENT_REQUIRED` naming the question, `--confirm` answers nothing, and `--delete User.email` applies. **Re-init conflict**: `db init` on an already-initialized DB with a different contract fails; recovery via `db update` | | `contract-infer-workflow.e2e.test.ts` | **PSL inference workflow**: refresh `contract.prisma` from the live database with `contract infer`, emit from the inferred PSL, verify the schema, and confirm a second infer is stable | | `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 | @@ -50,7 +50,7 @@ pnpm test:journeys | File | What it covers | |---|---| -| `drift-schema.e2e.test.ts` | **Manual schema change with unchanged marker**: a DBA drops a column; `db verify` now fails by default because it runs schema verification, while `db verify --marker-only` reproduces marker-only verification. **Extra column drift**: DBA adds a column via manual DDL; tolerant `db verify` / `db verify --schema-only` pass, strict `db verify` fails; recover by expanding the contract and running `db update` | +| `drift-schema.e2e.test.ts` | **Manual schema change with unchanged marker**: a DBA drops a column; `db verify` now fails by default because it runs schema verification, while `db verify --marker-only` reproduces marker-only verification. **Extra column drift**: DBA adds a column via manual DDL; tolerant `db verify` / `db verify --schema-only` pass, strict `db verify` fails; recover by expanding the contract and running `db update`, which asks about the unmanaged column by its storage name and applies with `--delete public.user.age` | | `drift-marker.e2e.test.ts` | **Missing marker**: contract emitted but `db init` never run — `db verify` fails, `db verify --schema-only` shows the schema mismatch, init recovers. **Stale marker**: contract changed without updating DB — verify fails, schema-only verify shows the missing column, `db update` recovers. **Mixed-mode evolution**: iterate through multiple contract versions using `db update` (no migration files). **Corrupt marker**: marker row overwritten with garbage — verify fails, `db verify --schema-only` passes (schema intact), `db sign` recovers | | `drift-migration-dag.e2e.test.ts` | **Chain breakage**: after building a migration history, a migration directory is deleted from disk. `migration apply` fails (no path to destination), recovery by re-planning the missing edge | diff --git a/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts b/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts index 2fe703e0896d..677ec11c8a7a 100644 --- a/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts +++ b/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts @@ -4,9 +4,9 @@ * D — Direct update without migrations: swap to an additive contract, dry-run * to preview changes, apply, confirm noop on re-run, then verify. * - * E — Destructive update with confirmation: swap to a contract that drops a - * column, test that --no-interactive blocks destructive changes, --json - * returns an error envelope, and --json -y auto-accepts and succeeds. + * E — Destructive update answered by a statement: swap to a contract that + * drops a column, test that --no-interactive and --yes refuse it and name + * the question, that --confirm answers nothing, and that --delete applies. * * Marker-aware violations (orphan markers, declared-but-unmigrated * extension spaces) are caught by the contract-space verifier — see @@ -18,7 +18,6 @@ import stripAnsi from 'strip-ansi'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { - consentTokenFor, type JourneyContext, parseJsonOutput, runContractEmit, @@ -107,11 +106,12 @@ withTempDir(({ createTempDir }) => { const dryRun = await runDbUpdate(ctx, ['--dry-run']); expect(dryRun.exitCode, 'E.02: db update dry-run').toBe(0); - // E.03: db update --no-interactive — nobody to ask, so consent is required + // E.03: db update --no-interactive — nobody to ask, so the question is unanswered const noInteractive = await runDbUpdate(ctx, ['--json', '--no-interactive']); expect(noInteractive.exitCode, 'E.03: non-interactive destructive fails').toBe(2); expect(parseJsonOutput(noInteractive), 'E.03: consent error').toMatchObject({ code: 'CLI.CONSENT_REQUIRED', + meta: { unanswered: [{ subject: 'User.email', verbs: ['rename', 'delete'] }] }, }); // E.04: db update --yes — --yes accepts declared defaults, never data loss @@ -121,14 +121,25 @@ withTempDir(({ createTempDir }) => { code: 'CLI.CONSENT_REQUIRED', }); - // E.05: db update --confirm — the non-interactive grant + // E.05: db update --confirm — answers no question const confirmed = await runDbUpdate(ctx, [ '--json', + '--no-interactive', '--confirm', - consentTokenFor(db.connectionString), + 'postgres', ]); - expect(confirmed.exitCode, 'E.05: --confirm applies').toBe(0); - expect(parseJsonOutput(confirmed), 'E.05: success envelope').toMatchObject({ ok: true }); + expect(confirmed.exitCode, 'E.05: --confirm does not answer').toBe(2); + expect(parseJsonOutput(confirmed), 'E.05: consent error').toMatchObject({ + code: 'CLI.CONSENT_REQUIRED', + }); + + // E.06: db update --delete User.email — the statement that answers it + const deleted = await runDbUpdate(ctx, ['--json', '--delete', 'User.email']); + expect(deleted.exitCode, 'E.06: --delete applies').toBe(0); + expect(parseJsonOutput(deleted), 'E.06: the delete is applied').toMatchObject({ + ok: true, + appliedStatements: [{ verb: 'delete', description: 'delete field "User.email"' }], + }); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/drift-schema.e2e.test.ts b/test/integration/test/cli-journeys/drift-schema.e2e.test.ts index a48ce1fab83d..467a65145b9f 100644 --- a/test/integration/test/cli-journeys/drift-schema.e2e.test.ts +++ b/test/integration/test/cli-journeys/drift-schema.e2e.test.ts @@ -18,9 +18,9 @@ import stripAnsi from 'strip-ansi'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { - consentTokenFor, engineDiagnosticCodes, type JourneyContext, + parseJsonOutput, runContractEmit, runDbInit, runDbSchema, @@ -82,7 +82,7 @@ withTempDir(({ createTempDir }) => { // M.05: db update recovers by re-adding the NOT NULL column with a temporary default, // then dropping that default so future inserts must provide an explicit value. - const update = await runDbUpdate(ctx, ['--confirm', consentTokenFor(db.connectionString)]); + const update = await runDbUpdate(ctx); expect(update.exitCode, 'M.05: db update recovers dropped column drift').toBe(0); // M.06: db verify passes after reconciliation @@ -161,15 +161,18 @@ withTempDir(({ createTempDir }) => { // N.06: db update --no-interactive rejects (drift from unmanaged 'age' column // makes the planner classify this as destructive) - const update = await runDbUpdate(ctx, ['--no-interactive']); + const update = await runDbUpdate(ctx, ['--json', '--no-interactive']); expect(update.exitCode, 'N.06: --no-interactive rejects destructive').toBe(2); + expect(parseJsonOutput(update), 'N.06: the unmanaged column is the question').toMatchObject( + { + code: 'CLI.CONSENT_REQUIRED', + meta: { unanswered: [{ subject: 'public.user.age', verbs: ['delete'] }] }, + }, + ); - // N.07: db update --confirm explicitly accepts the destructive plan - const updateConfirmed = await runDbUpdate(ctx, [ - '--confirm', - consentTokenFor(db.connectionString), - ]); - expect(updateConfirmed.exitCode, 'N.07: db update --confirm accepts').toBe(0); + // N.07: db update --delete names the storage no model stores, and applies + const updateConfirmed = await runDbUpdate(ctx, ['--delete', 'public.user.age']); + expect(updateConfirmed.exitCode, 'N.07: db update --delete applies').toBe(0); // N.08: db verify --schema-only tolerant (passes — all contract columns present; 'age' tolerated as extra) const tolerantAfter = await runDbVerify(ctx, ['--schema-only']); diff --git a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts index f99ce6cedc6b..5fada18d872c 100644 --- a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts @@ -8,7 +8,6 @@ import stripAnsi from 'strip-ansi'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { fixtureAppDir } from '../utils/cli-test-helpers'; import { - consentTokenFor, engineError, type JourneyContext, parseJsonOutput, @@ -146,7 +145,7 @@ describe('Journey: Mongo db update asks for consent only to lose data', { expect(indexKeys).toEqual([{ _id: 1 }, { email: 1 }]); }); - it('drops a collection once the database name is passed with --confirm', async () => { + it('drops a collection once --delete names its model', async () => { const dbName = 'mongo_consent_drop_collection'; const connectionString = withDatabase(replSet.getUri(), dbName); const ctx = setupProject(connectionString); @@ -162,15 +161,14 @@ describe('Journey: Mongo db update asks for consent only to lose data', { const unconfirmed = await runDbUpdate(ctx, ['--json', '--no-interactive']); expect(unconfirmed.exitCode).toBe(2); - expect(parseJsonOutput(unconfirmed)).toMatchObject({ code: 'CLI.CONSENT_REQUIRED' }); + expect(parseJsonOutput(unconfirmed)).toMatchObject({ + code: 'CLI.CONSENT_REQUIRED', + meta: { unanswered: [{ subject: 'Event', verbs: ['rename', 'delete'] }] }, + }); const kept = await client.db(dbName).listCollections({ name: 'events' }).toArray(); expect(kept.map(({ name }) => name)).toEqual(['events']); - const destructive = await runDbUpdate(ctx, [ - '--no-interactive', - '--confirm', - consentTokenFor(connectionString), - ]); + const destructive = await runDbUpdate(ctx, ['--no-interactive', '--delete', 'Event']); expect(destructive.exitCode, stripAnsi(destructive.stderr)).toBe(0); const collections = await client.db(dbName).listCollections({ name: 'events' }).toArray(); diff --git a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts index 25d01fafd5f8..2320a1746202 100644 --- a/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts +++ b/test/integration/test/cli-journeys/rename-table-migration.sqlite.e2e.test.ts @@ -294,7 +294,7 @@ withTempDir(({ createTempDir }) => { db.exec('ALTER TABLE "_prisma_rename_UserProfile" RENAME TO "UserProfile"'); }); - const update = await runDbUpdate(ctx, ['--json', '--confirm', 'journey.db']); + const update = await runDbUpdate(ctx, ['--json']); expect(update.exitCode, `R4.05: db update after the rename: ${update.stdout}`).toBe(0); const state = withDatabase(ctx.dbPath, (db) => ({ rows: db.prepare(`SELECT id, email FROM "UserProfile" ORDER BY id`).all(), diff --git a/test/integration/test/cli.db-update.e2e.test.ts b/test/integration/test/cli.db-update.e2e.test.ts index 5fce1536491a..3078a3b676e1 100644 --- a/test/integration/test/cli.db-update.e2e.test.ts +++ b/test/integration/test/cli.db-update.e2e.test.ts @@ -7,7 +7,6 @@ import { fixtureAppDir, runOnEngine, withTempDir } from './utils/cli-test-helper import { replaceInFileOrThrow } from './utils/contract-fixture-editing'; import { runDbInit } from './utils/db-init-test-helpers'; import { - consentTokenFor, type DbUpdateTestSetup, runDbUpdate, runDbUpdateAllowFailure, @@ -346,9 +345,9 @@ withTempDir(({ createTempDir }) => { timeouts.spinUpPpgDev, ); - // Scenario 7a: Destructive changes require explicit consent + // Scenario 7a: Destructive changes need a statement that answers them it( - 'fails with DESTRUCTIVE_CHANGES when destructive ops are not confirmed', + 'refuses a drop no statement answers, naming the storage it would lose', async () => { await withDevDatabase(async ({ connectionString }) => { const { testSetup, configPath } = await setupDbUpdateFixture( @@ -372,8 +371,9 @@ withTempDir(({ createTempDir }) => { expect(run.exitCode).toBe(2); expect(stripAnsi(run.stderr)).toMatch(/CONSENT_REQUIRED/); + expect(stripAnsi(run.stderr)).toContain('--delete public.project.legacy_notes'); - // Verify the confirmation requirement actually blocked the update — + // Verify the refusal actually blocked the update — // the drift column must still exist await withClient(connectionString, async (client) => { const result = await client.query( @@ -410,8 +410,8 @@ withTempDir(({ createTempDir }) => { const run = await runDbUpdateAllowFailure(testSetup, [ '--config', configPath, - '--confirm', - consentTokenFor(connectionString), + '--delete', + 'public.project.legacy_notes', ]); expect(run.exitCode).not.toBe(0); diff --git a/test/integration/test/cli.db-update.preflight-gaps.e2e.test.ts b/test/integration/test/cli.db-update.preflight-gaps.e2e.test.ts index 78d630e1e58e..d895d5e7fd1a 100644 --- a/test/integration/test/cli.db-update.preflight-gaps.e2e.test.ts +++ b/test/integration/test/cli.db-update.preflight-gaps.e2e.test.ts @@ -20,7 +20,6 @@ import { describe, expect, it } from 'vitest'; import { fixtureAppDir, runOnEngine, withTempDir } from './utils/cli-test-helpers'; import { runDbInit } from './utils/db-init-test-helpers'; import { - consentTokenFor, type DbUpdateTestSetup, runDbUpdate, runDbUpdateAllowFailure, @@ -68,12 +67,7 @@ withTempDir(({ createTempDir }) => { await swapToVariant(testSetup, 'contract-add-fk.ts'); - const run = await runDbUpdate(testSetup, [ - '--config', - configPath, - '--confirm', - consentTokenFor(connectionString), - ]); + const run = await runDbUpdate(testSetup, ['--config', configPath]); expect(run.exitCode).toBe(0); await withClient(connectionString, async (client) => { @@ -115,12 +109,7 @@ withTempDir(({ createTempDir }) => { await swapToVariant(testSetup, 'contract-add-required-unique.ts'); - const run = await runDbUpdate(testSetup, [ - '--config', - configPath, - '--confirm', - consentTokenFor(connectionString), - ]); + const run = await runDbUpdate(testSetup, ['--config', configPath]); expect(run.exitCode).toBe(0); await withClient(connectionString, async (client) => { @@ -167,12 +156,7 @@ withTempDir(({ createTempDir }) => { await swapToVariant(testSetup, 'contract-add-required-unique.ts'); - const run = await runDbUpdateAllowFailure(testSetup, [ - '--config', - configPath, - '--confirm', - consentTokenFor(connectionString), - ]); + const run = await runDbUpdateAllowFailure(testSetup, ['--config', configPath]); expect(run.exitCode).not.toBe(0); await withClient(connectionString, async (client) => { diff --git a/test/integration/test/utils/db-update-test-helpers.ts b/test/integration/test/utils/db-update-test-helpers.ts index 804673ed6069..8ee1710727b6 100644 --- a/test/integration/test/utils/db-update-test-helpers.ts +++ b/test/integration/test/utils/db-update-test-helpers.ts @@ -85,18 +85,3 @@ export async function runDbUpdate( * which of the two they mean. */ export const runDbUpdateAllowFailure = runDbUpdate; - -/** - * What `db update` asks the user to type before it destroys anything: the name - * of the connected database, which for these Postgres-backed tests is the - * database segment of the connection URL. A run that means to accept data loss - * passes it as `--confirm`, because `--yes` cannot grant a consent. - */ -export function consentTokenFor(connectionString: string): string { - const parsed = new URL(connectionString); - const name = parsed.pathname.split('/').filter((segment) => segment.length > 0)[0]; - if (name === undefined) { - throw new Error(`Connection URL names no database: ${connectionString}`); - } - return decodeURIComponent(name); -} diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 82178a70eae4..82abd945eb55 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -339,21 +339,6 @@ export async function runDbUpdate( return runOnEngine(ctx, ['db', 'update', ...extraArgs], options); } -/** - * What `db update` asks the user to type before it destroys anything: the name - * of the connected database, which for these Postgres-backed tests is the - * database segment of the connection URL. A run that means to accept data loss - * passes it as `--confirm`, because `--yes` cannot grant a consent. - */ -export function consentTokenFor(connectionString: string): string { - const parsed = new URL(connectionString); - const name = parsed.pathname.split('/').filter((segment) => segment.length > 0)[0]; - if (name === undefined) { - throw new Error(`Connection URL names no database: ${connectionString}`); - } - return decodeURIComponent(name); -} - export async function runDbVerify( ctx: JourneyContext, extraArgs: readonly string[] = [], From f3606b85e26663a72694ad66c7a495acd36aec2f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:45:54 +0200 Subject: [PATCH 046/109] TML-3476: docs describe db update's questions, --allow, and the snapshot read on every run The error reference drops the four retired codes, the style guide makes statements the consent form for data loss and keeps --confirm for commands with one thing at stake, and the control API examples pass answerQuestions. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/CLI Style Guide.md | 14 +++++++--- .../subsystems/7. Migration System.md | 14 ++++++---- docs/commands/SUMMARY.md | 9 ++++-- docs/reference/error-reference.md | 22 ++------------- packages/1-framework/3-tooling/cli/README.md | 28 +++++++++++++++---- packages/3-extensions/mongo/README.md | 8 +++++- packages/3-extensions/sqlite/README.md | 8 +++++- projects/migration-statements/deferred.md | 6 ++-- 8 files changed, 69 insertions(+), 40 deletions(-) diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index e4309b07cf04..fcec5760ae4b 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -117,23 +117,29 @@ The CLI checks `process.stdout.isTTY` once at startup to determine the output mo ### Destructive operation confirmation -Destructive operations (drops, type changes, overwriting generated files, overwriting an existing signature marker, …) require **explicit consent**: the user types the name of the thing being changed, or passes that name as `--confirm `. Consent is separate from the `-y` "accept prompt defaults" mechanism, and no command invents a flag that skips it. +Destructive operations (drops, type changes, overwriting generated files, overwriting an existing signature marker, …) require **explicit consent**. Consent takes one of two forms: + +- **Statements**, for a plan that would lose data or widen access. The command asks one question per model, field or storage name at stake, and each is answered with a statement naming it: `--delete `, `--rename :` or `--allow `. `migration plan` and `db update` use this form. +- **A consent token**, for a command with one thing at stake. The user types the name of the thing being changed, or passes that name as `--confirm `. `init` uses this form. + +Consent is separate from the `-y` "accept prompt defaults" mechanism, and no command invents a flag that skips it. This is a deliberate divergence from clig.dev §Arguments §Confirmation. AI agents and CI scripts routinely pass `-y` to suppress prompts on long-running pipelines; conflating "skip prompts" with "consent to destruction" is a footgun the project has decided to avoid. Typing a name also rules out the other accident a yes/no prompt allows — consenting to the right operation against the wrong database. #### Rules -- A command that performs a destructive action MUST ask for it with `ctx.prompt.consent(question, { token })`. The token is the natural noun of what is at stake: the database name for `db update`, the working directory's basename for an `init` re-scaffold. Interactively the user types the token; non-interactively `--confirm ` grants it. There is no `--force`. +- A command whose plan can lose data or widen access MUST ask about each subject with `ctx.prompt.statements(questions)`, one question per subject, before it writes or applies anything. A statement that answers no question fails the run with the engine's `CLI.CONSENT_UNUSED`; non-interactively, unanswered questions fail it with `CLI.CONSENT_REQUIRED`, listing each question and the flags that answer it. `--confirm` answers none of these questions. +- A command with one thing at stake MUST ask for it with `ctx.prompt.consent(question, { token })`. The token is the natural noun of what is at stake: the working directory's basename for an `init` re-scaffold. Interactively the user types the token; non-interactively `--confirm ` grants it. There is no `--force`. - The question MUST list the destructive operations (or describe them concretely, e.g. "this will overwrite all generated files") so the user can decline knowing what's at stake. A command that cannot fill the list, or cannot derive a token, MUST fail with a structured error rather than ask a question that says nothing or accepts anything. - The token MUST identify the thing being changed as precisely as the invocation allows. Falling back to something a whole class of runs shares — a target id, a product name — makes one `--confirm` value grant data loss in every project that shares it. - Non-interactively (closed stdin, CI, `--no-interactive`) without `--confirm`: no prompt is shown; the command fails with the engine's `CLI.CONSENT_REQUIRED` (exit `2`), whose `meta.consentToken` and `nextActions` name the token to pass. A cancelled prompt is `CLI.PROMPT_CANCELLED` (exit `3`); a mistyped token is `CLI.PROMPT_INVALID` (exit `2`). - `--confirm` is read only when the run is non-interactive or `--yes` is set. A script that runs from a terminal must pass `--no-interactive --confirm `, or it will stop at the prompt. - Each `--confirm` value grants at most one consent; a command that asks twice needs two. -- The internal control API retains a programmatic equivalent (e.g. `acceptDataLoss: boolean`) for consumers that drive the planner directly; `--confirm` is the user-facing CLI form. Note that consent authorises the operations the user was shown, and the plan is recomputed on the call that carries it — a command that cannot bind the two SHOULD report what it applied beyond what was consented to. +- The control API takes the same consent programmatically: `migration plan` and `db update` take an answer callback for their questions, and `db update`'s `acceptDataLoss: true` answers every one. #### Examples -- `db update`: when the plan includes destructive ops, asks the user to type the database name; `--no-interactive --confirm ` applies without a prompt. The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. +- `db update`: asks one question per model, field or storage name an operation would lose data from, answered by `--delete` or `--rename`, and one per model whose rows an operation would open up, answered by `--allow`; `--no-interactive --delete Legacy --allow User` applies without a prompt. - `init`: re-running `init` in a directory with a generated `prisma.config.ts` asks the user to type the directory's basename; `-y` alone is not sufficient to authorise overwriting generated files. (The commander-era `--force` retired with the commander shell in the S5 cutover; the engine-hosted `init` uses the consent form above.) - `db sign`: overwriting a marker that holds a different hash happens without consent (the previous hash is reported). This is an intentional exception to the rule above: `db sign` verifies the live schema against the contract before writing, so the overwrite only ever records a contract the database already satisfies. If that ever grows a switch, it takes the consent form above — not a `--force`. diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 5d84e52ce09b..313f8c2be2f7 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -199,11 +199,13 @@ Rename operations are `widening`, so neither command asks for consent. The `migr **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. -**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` lists it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. +**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. + +**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. **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. +**The origin of `db update`.** `migration plan` already has an origin contract: the one `--from` resolves to. `db update` introspects the database instead, and on every run reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). The planner gets it as `fromContract`, and names each subject of `dataLoss` and `accessWidening` through it. A missing or unreadable snapshot fails only a run with `--rename`; otherwise each subject is named by its storage name. The plan it hands the runner keeps `origin: null` either way (see [`db update`](#db-update-live-reconciliation)). The planner's operations do not depend on whether the snapshot was read: every strategy that emits a data transform needs the `data` operation class, which `db update` does not allow. `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 renames 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.** @@ -212,7 +214,7 @@ Rename operations are `widening`, so neither command asks for consent. The `migr - 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. +The rest of the migration-statements work adds `--convert`, `--backfill`, the remaining renames and model moves across namespaces; and brings statements to MongoDB. ## Authoring Surface @@ -433,7 +435,7 @@ Every operation has a class that says what it does to the data (`MigrationOperat - `destructive` can lose rows or values: dropping a table, a column or a collection, and any other type change, including a SQLite table rebuild that changes a column type. - `data` reads and writes rows, such as a backfill or a type conversion. -`db update` applies `additive` and `widening` operations without asking for consent. +`db update` applies `additive` operations, and `widening` operations that do not widen access, without asking. Before an apply it asks about each operation that would lose data and each that would widen who can read and write rows (see [`db update`](#db-update-live-reconciliation)). 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. @@ -805,7 +807,9 @@ 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. 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. +Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#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. + +**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback; `delete` and `allow` statements answer their questions without asking, and `acceptDataLoss: true` answers all of them. 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. diff --git a/docs/commands/SUMMARY.md b/docs/commands/SUMMARY.md index 7c1faef33508..dd6764216219 100644 --- a/docs/commands/SUMMARY.md +++ b/docs/commands/SUMMARY.md @@ -121,8 +121,9 @@ Both commands share the same flag surface: | `--db ` | Database connection string | | `--config ` | Path to `prisma.config.ts` | | `--dry-run` | Preview planned operations without applying | -| `-y, --yes` | Accept the declared default of every prompt. It cannot grant consent, so it never authorises `db update`'s destructive operations | -| `--confirm ` | Grant `db update`'s destructive consent without being asked. Read only when the run is non-interactive, so a script running from a terminal needs `--no-interactive --confirm ` | +| `-y, --yes` | Accept the declared default of every prompt. It answers none of `db update`'s questions | + +`db update` also takes statements, each repeatable, which answer the questions it asks before an apply: `--rename :` keeps a model's or field's data under a new name, `--delete ` lets the update lose a model's, field's or storage name's data, and `--allow ` lets it widen who can read or write a model's rows. Without them, an apply that would lose data or widen access asks in a terminal, and fails with `CLI.CONSENT_REQUIRED` elsewhere. `--confirm` answers none of them. See the CLI README's `db update` section. | `--json [format]` | Output as JSON (`object` format only) | | `-q, --quiet` | Quiet mode: errors only | | `-v, --verbose` | Verbose output: debug info, timings | @@ -154,6 +155,10 @@ const updateResult = await client.dbUpdate({ contractIR: contractJson, mode: 'plan', // or 'apply' connection: databaseUrl, + answerQuestions: async (questions) => { + if (questions.length > 0) throw new Error('db update would lose data or widen access'); + return []; + }, }); await client.close(); diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 94626b569fc3..f4de7a83201a 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -79,21 +79,13 @@ The config module evaluated, but its default export was not created by the curre The migration-file CLI (`prisma migration`) received `--config` without a path argument, either a bare trailing `--config`, or `--config` immediately followed by another flag (e.g. `--config --dry-run`). The CLI fails fast instead of consuming the next flag as the config path or silently falling back to default config discovery. Payload: `nextToken` (present only when another flag followed `--config`). -### CLI.CONSENT_OPERATIONS_MISSING - -`db update` was told the plan is destructive but was given no operations to name, so the consent prompt would have asked you to authorise a list of nothing. The command refuses instead of prompting. This is an inconsistency between the CLI and the control API rather than something your project can be wrong about; run `prisma db update --dry-run` to see the plan, and report the run. Payload: none. - ### CLI.CONSENT_REQUIRED -Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is the reference: [CLI.CONSENT_REQUIRED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_REQUIRED). `migration plan` raises it when the plan would lose data and the run has nobody to answer: it is not interactive, or `--yes` is set. The command asks one question per model, field or storage name whose data an operation would lose, and the error lists every question still unanswered, with the flags that answer each: `--delete ` lets the plan lose that data, and, for a model or field, `--rename :` keeps it under a new name. Data no model of the origin contract stores can only be deleted. A field of a model the plan renames is named through the model's new name: after `--rename Profile:User`, the question about the dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. Nothing is written. `--confirm` does not answer these questions. Payload: `subject`, `verbs`, `unanswered` (each with its `subject` and `verbs`). +Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is the reference: [CLI.CONSENT_REQUIRED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_REQUIRED). `migration plan` and `db update` raise it when the plan would lose data, or for `db update` widen who can read or write rows, and the run has nobody to answer: it is not interactive, or `--yes` is set. The command asks one question per model, field or storage name whose data an operation would lose, and `db update` also one per model whose rows an operation would open up, such as disabling row-level security. The error lists every question still unanswered, with the flags that answer each: `--delete ` lets the plan lose that data, for a model or field `--rename :` keeps it under a new name, and `--allow ` lets `db update` widen access to that model's rows. Data no model of the origin contract stores can only be deleted. A field of a model the plan renames is named through the model's new name: after `--rename Profile:User`, the question about the dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. Nothing is written or applied. `--confirm` does not answer these questions. Payload: `subject`, `verbs`, `unanswered` (each with its `subject` and `verbs`). ### CLI.CONSENT_UNUSED -Raised by the CLI engine; the engine's entry is the reference: [CLI.CONSENT_UNUSED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_UNUSED). `migration plan` raises it when a `--delete` value answers no question the plan asks, for example a mistyped name or a model the plan does not drop, before it writes anything. - -### CLI.CONSENT_TOKEN_UNRESOLVED - -`db update` could not derive a name for the database it is about to change, so there is nothing for the consent prompt to ask you to type, and an empty token would let a bare Enter (or `--confirm ""`) authorise data loss. The name comes from the `database` a driver connection object carries, or from the connection URL (its first path segment, else its host), falling back to the target id. Name the database in `db.connection` or pass `--db `. Payload: none. +Raised by the CLI engine; the engine's entry is the reference: [CLI.CONSENT_UNUSED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_UNUSED). `migration plan` and `db update` raise it when a `--delete` or `--allow` value answers no question the plan asks, for example a mistyped name or a model the plan does not drop, before they write or apply anything. A `db update --dry-run` asks no questions, so it raises it for every `--delete` and `--allow` it is given. ### CLI.CONTRACT_ARG_CONFLICT @@ -1382,10 +1374,6 @@ A `migration check` finding, carried as an `error` diagnostic on a completed run `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; only `db update` raises it. `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`. - ### MIGRATION.CONTRACT_DESERIALIZATION_FAILED A contract JSON on disk failed to deserialize into a valid contract: either a snapshot-store entry read while migration tooling resolved a contract at a ref or hash, or the emitted `contract.json` read as the fallback source by `db sign` / `db update --to` (invalid JSON, or a value that is not a JSON object). Re-emit the owning migration package (or re-run `prisma contract emit` for the emitted contract), or restore the file from version control. Payload: `filePath`, `message`. Also raised by `migration new` when the emitted `contract.json` fails to deserialize; that site has no meta and attaches the deserialization failure as `cause`. @@ -1430,10 +1418,6 @@ An extension descriptor publishes a `contractSpace` whose `headRef.hash` does no Runner-level failure during apply (`db init`, `db update`, `db migrate`): the plan's destination storage hash (or profile hash) does not match the destination contract handed to the runner alongside it. Indicates the plan and contract came from different emits. Payload: `planStorageHash`/`contractStorageHash` (or `planProfileHash`/`contractProfileHash`). -### MIGRATION.DESTRUCTIVE_CHANGES - -The planned operations include destructive changes (e.g. DROP) and the command was run without explicit consent. `db update` asks for that consent instead of failing: interactively it asks you to type the name of the database it is about to change, and outside an interactive terminal it is granted by `--confirm ` (`--yes` accepts declared prompt defaults and never grants consent; `--confirm` is read only when the run is non-interactive or `--yes` is set, so a script run from a terminal needs `--no-interactive --confirm `). The name is the `database` a driver connection object carries, or the connection URL's first path segment, else its host, falling back to the target id. A run with nobody to ask and no `--confirm` settles as `CLI.CONSENT_REQUIRED` at exit 2; a run whose prompt is cancelled settles as `CLI.PROMPT_CANCELLED` at exit 3. `--dry-run` never asks; it settles as this error instead. Use it to preview the operations first. `migration plan` no longer raises it: a plan that would lose data is answered with `--rename` and `--delete` statements instead (see `CLI.CONSENT_REQUIRED`). - ### MIGRATION.DIR_EXISTS `migration new`/`migration plan` refused to scaffold because the target migration directory already exists: each migration needs a unique directory. Pick a different `--name` or delete the existing directory. Payload: `dir`. @@ -1576,7 +1560,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`; 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 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 what each operation that would lose data means. 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`. diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 024d259bf838..86b7e28b4e6a 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -943,18 +943,27 @@ Update your database schema to match the currently emitted contract. - Allows `additive`, `widening`, and `destructive` operation classes where supported by planner/runner - 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 `--confirm ` is provided +- Before an apply, asks what each operation that would lose data means and whether each that would widen access may run (see **Questions before an apply** below) **Command:** ```bash -prisma db update [--db ] [--to ] [--advance-ref ] [--config ] [--dry-run] [--rename ]... [--confirm ] [--interactive|--no-interactive] [--json] [-v] [-q] [--color/--no-color] +prisma db update [--db ] [--to ] [--advance-ref ] [--config ] [--dry-run] [--rename ]... [--delete ]... [--allow ]... [--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. +**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 on every run, so the planner sees the prior contract whenever one is stored; a missing or unreadable snapshot fails only a run with `--rename`. 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. + +**Questions before an apply:** an apply asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), and one per model whose rows an operation would let more people read or write (disabling row-level security, for example). It asks before it applies anything. Subjects are named through the origin contract read from the snapshot store, as `migration plan` names them; without a readable snapshot each subject is its storage name, which can only be deleted or allowed, and the question says the origin contract is unknown. Recorded migrations of extension spaces are asked about for the data they would lose, never for widened access. Each question is answered with a statement: + +- `--delete ` lets the update lose that data, for example `--delete Legacy` or `--delete User.nickname`. A field of a model the update renames is named through the model's new name. +- `--rename :` keeps the data of a model or field under a new name instead. +- `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. + +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse; `delete` and `allow` statements in `statements` answer their questions without asking, and `acceptDataLoss: true` answers all of them. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) +- `CLI.CONSENT_REQUIRED`, `CLI.CONSENT_UNUSED`: a question nobody answered, or a `--delete` or `--allow` that answered no question +- `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`: a rename typed at the prompt did not stop the loss it answered - `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`):** @@ -1446,7 +1455,16 @@ try { // Run operations const verifyResult = await client.verify({ contract }); const initResult = await client.dbInit({ contract, mode: 'apply' }); - const updateResult = await client.dbUpdate({ contract, mode: 'apply' }); + const updateResult = await client.dbUpdate({ + contract, + mode: 'apply', + // Asked before an apply about each operation that would lose data or widen access. + // Answer each question in order, as `{ verb, text }`, or throw to refuse. + answerQuestions: async (questions) => { + if (questions.length > 0) throw new Error('db update would lose data or widen access'); + return []; + }, + }); const introspectResult = await client.introspect(); } finally { // Clean up diff --git a/packages/3-extensions/mongo/README.md b/packages/3-extensions/mongo/README.md index af9ce3f2eceb..de94529706ec 100644 --- a/packages/3-extensions/mongo/README.md +++ b/packages/3-extensions/mongo/README.md @@ -167,7 +167,13 @@ import { createMongoControlClient } from '@internal/mongo/control'; const control = createMongoControlClient({ connection: process.env['MONGODB_URL']!, }); -await control.dbUpdate({ migrations: { dir: 'migrations/app' } }); +await control.dbUpdate({ + migrations: { dir: 'migrations/app' }, + answerQuestions: async (questions) => { + if (questions.length > 0) throw new Error('db update would lose data or widen access'); + return []; + }, +}); ``` ### `@internal/mongo/bson` diff --git a/packages/3-extensions/sqlite/README.md b/packages/3-extensions/sqlite/README.md index 46103aad4af5..8a4206c1f9ac 100644 --- a/packages/3-extensions/sqlite/README.md +++ b/packages/3-extensions/sqlite/README.md @@ -71,7 +71,13 @@ import { createSqliteControlClient } from '@internal/sqlite/control'; const control = createSqliteControlClient({ connection: 'path/to/app.db', }); -await control.dbUpdate({ migrations: { dir: 'migrations/app' } }); +await control.dbUpdate({ + migrations: { dir: 'migrations/app' }, + answerQuestions: async (questions) => { + if (questions.length > 0) throw new Error('db update would lose data or widen access'); + return []; + }, +}); ``` ### `@internal/sqlite/migration` diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index d13614bec477..15e1cc3dd5ae 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -16,11 +16,11 @@ A user keeps `model User` and changes `@@map("users")` to `@@map("app_users")`. **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 +## A codec's `onFieldEvent` hook sees different events under `db update` with and without a snapshot -**Found:** slice 1, dispatch 5 review (2026-10-06). +**Found:** slice 1, dispatch 5 review (2026-10-06). Changed by slice 2, dispatch 4 (2026-10-08). -`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`). +Since slice 2, `db update` reads the origin contract from the snapshot store on every run and passes it to the planner whenever the snapshot exists, with or without statements. So field-event planning sees the prior contract and reports only real changes on every run with a snapshot; on a run without one (no marker, a marker whose hash has no snapshot, or an unreadable snapshot) it has no prior contract and reports every column as added. No codec in the repository implements `onFieldEvent` today, so nothing observable differs. If a codec starts relying on the hook under `db update`, decide what it should see when the snapshot is missing. ## Which field name a MongoDB statement uses From c238652de4042b89e79d1c462e4d00ce242b4492 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:56:46 +0200 Subject: [PATCH 047/109] TML-3476: dispatch 5 brief Signed-off-by: willbot Signed-off-by: Will Madden --- .../dispatches/05-journeys-docs-fragments.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md diff --git a/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md b/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md new file mode 100644 index 000000000000..d6f8314e1af3 --- /dev/null +++ b/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md @@ -0,0 +1,45 @@ +# Dispatch 5 — Journeys, docs, upgrade fragments + +**Slice:** [`../spec.md`](../spec.md) § "Slice done conditions" · **Plan:** [`../plan.md`](../plan.md) dispatch 5 · **Builds on:** dispatch 4 + +## Outcome + +The slice done conditions hold through journeys on all three targets; every document and skill that describes consent describes statements; the upgrade fragments name every behaviour change and every removed export, and are validated by execution. + +## Journeys (`test/integration/test/cli-journeys/`) + +Next to the `rename-statements-migration*` journeys, one Postgres file, one SQLite file and one MongoDB file, labelled `S` steps as the existing ones are: + +1. **Rename plus delete through `migration plan`:** rename a model and a field and remove another model; without `--delete`, `migration plan --json` exits 2 with `CLI.CONSENT_REQUIRED` whose `nextActions` contain `--delete `; with that exact flag it writes the plan; `migrate` applies it on tables with rows, a unique, a foreign key from another table, a secondary index and (Postgres) a check; rows and objects present under the new names, the deleted table gone, a further plan empty, `db verify --schema-only` clean. +2. **The same through `db update`:** no prompt; `--confirm ` alone is refused with `CLI.CONSENT_REQUIRED`; a second `db update` with the same statements fails on the first statement (`MIGRATION.STATEMENT_UNRESOLVED`). +3. **A required field:** adding a non-nullable field plans and applies on all three targets with no refusal. +4. **Postgres only, `allow`:** `db update` that drops a policy refuses in apply mode without `--allow ` (`nextActions` names it), applies with it, and a dry run lists it under `accessWidening` without asking. +5. **MongoDB:** a collection drop is refused and `--delete ` consents; a `rename` is still refused by the Mongo planner with `statementRefused`. +6. **No snapshot (Postgres and SQLite, one each):** `db update --db ` with a dropped model refuses with a storage subject and the note that the origin is unknown; `--delete ` applies; the SQLite form is the unqualified name, the Postgres form is `public.user`. + +Add the planner test the review asked for: a codec hook that emits a destructive call, proving the hook-call-to-field match survives the control-policy partitions (`../plan.md` open items). + +## Docs and skills + +- `skills/prisma-8/references/migrations.md`, `debug.md` and `SKILL.md`: replace `MIGRATION.DESTRUCTIVE_CHANGES` and `--confirm ` with the statements and the refusal; the error-code table lists `CLI.CONSENT_REQUIRED`, `CLI.CONSENT_UNUSED` and `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; `skills/journey-tests/*` that mention `--confirm` for these commands. +- `docs/commands/SUMMARY.md` and `docs/reference/error-reference.md`: already touched in dispatches 3 and 4; re-read for stale consent wording. +- `docs/architecture docs/subsystems/7. Migration System.md` § Statements: one coherent section for `rename`, `delete`, `allow`, the questions and the interactive answer; § `db update`: recorded extension migrations contribute no `accessWidening` question. +- The slice spec and the project spec: nothing to change unless the implementation diverged; report any divergence instead of editing them. + +## Upgrade fragments (`upgrade-instructions/pending/migration-statement-refusal/`) + +**app/instructions.md** (extend the fragment dispatch 1 created): +- `migration plan` refuses every plan that loses data, not only auto-baselines; the way out is `--delete ` or `--rename`; detection: scripts and CI that run `migration plan` on a contract change that drops something (no reliable regex; say so and give the `nextActions` form). +- `db update --confirm ` no longer consents; detection regex on `--confirm` next to `db update`; replacement `--delete ` per operation, `--allow ` before an access widening. +- `db update` reads the origin snapshot on every run (behaviour note, no action). +- Control API: `executeMigrationPlanCommand` and `executeDbUpdate` require `answerQuestions`; `consent`, `carryEmittedExtensionDirs` and `acceptDataLoss`'s CLI path are gone; `retryCommandFor` takes `statements`. Detection on each identifier. +- Removed from `@prisma/orm-framework/errors/execution`: `ERROR_CODE_DESTRUCTIVE_CHANGES`, `errorDestructiveChanges`, `ERROR_CODE_CONSENT_PLAN_MISMATCH`, `errorConsentPlanMismatch`. Removed from `@prisma/orm-toolchain/cli/control-api`: `'DESTRUCTIVE_CHANGES'` and `'CONSENT_PLAN_MISMATCH'` in `DbUpdateFailureCode`, `DbUpdateFailure.destructiveChanges` / `.consentPlanMismatch`, `DestructivePlanOperation`, `DestructiveChangesVerdict`, `ConsentPlanMismatchVerdict`. Removed CLI codes `CLI.CONSENT_TOKEN_UNRESOLVED`, `CLI.CONSENT_OPERATIONS_MISSING`. + +**extension/instructions.md** (new): +- `MigrationPlannerSuccessResult` requires `dataLoss` and `accessWidening` (`MigrationOperationSubject[]`); `MigrationStatementSubject`; `PerSpacePlan` carries both; `ControlFamilyInstance.storageNameOf` is required; `StatementVerb` gains `delete`; `DeleteMigrationStatement`; `RecreateTableCall` (SQLite) takes `lossyColumns`; `AlterColumnTypeCall` (Postgres) takes `operationClass`; `planFieldEventCalls`; the class changes from dispatch 1 as they affect a planner an extension implements. Detection regexes per identifier, with the import paths. + +Validate both by execution per `skills-contrib/record-upgrade-instructions/SKILL.md` § validation (restore `examples/` and `packages/3-extensions/` to the base, apply the fragment, no diff outside tests; test dirs stay at base; run the example tests). The engine pin must not be in the fragments: it is a dependency bump the PR body states. + +## Gate + +Standard, plus `pnpm check:upgrade-coverage --mode pr --prev bot/tml-3475-statement-renames` (must pass now), `pnpm lint:skills`, `pnpm lint:docs`, every journey named above and every journey dispatch 4 changed, and the example tests the fragment validation runs. From b38f7b7538ae8c4c2ebda453920c0b45a9137f7e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 01:59:01 +0200 Subject: [PATCH 048/109] TML-3476: codec field-event hooks get the prior contract only when the plan has an origin So a plain db update plans the same operations with or without the origin snapshot, hooks included. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- .../postgres/src/core/migrations/planner.ts | 2 +- .../test/migrations/planner.data-loss.test.ts | 45 ++++++++++++++++++- .../sqlite/src/core/migrations/planner.ts | 2 +- .../test/migrations/planner.data-loss.test.ts | 43 +++++++++++++++++- projects/migration-statements/deferred.md | 6 +-- 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 313f8c2be2f7..0833671549e9 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -205,7 +205,7 @@ Rename operations are `widening`, so neither command asks for consent. The `migr **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, and on every run reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). The planner gets it as `fromContract`, and names each subject of `dataLoss` and `accessWidening` through it. A missing or unreadable snapshot fails only a run with `--rename`; otherwise each subject is named by its storage name. The plan it hands the runner keeps `origin: null` either way (see [`db update`](#db-update-live-reconciliation)). The planner's operations do not depend on whether the snapshot was read: every strategy that emits a data transform needs the `data` operation class, which `db update` does not allow. `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 renames 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. +**The origin of `db update`.** `migration plan` already has an origin contract: the one `--from` resolves to. `db update` introspects the database instead, and on every run reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). The planner gets it as `fromContract`, and names each subject of `dataLoss` and `accessWidening` through it. A missing or unreadable snapshot fails only a run with `--rename`; otherwise each subject is named by its storage name. The plan it hands the runner keeps `origin: null` either way (see [`db update`](#db-update-live-reconciliation)). The planner's operations do not depend on whether the snapshot was read: every strategy that emits a data transform needs the `data` operation class, which `db update` does not allow, and codec field-event hooks get the prior contract only when the plan has an origin, which a `db update` plan never has. `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 renames 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.** 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 da89e5b491e5..c16b34457226 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 @@ -406,7 +406,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr // byte-stable. The hook fires only at the application emitter — // extension-space planning never reaches this helper. const fieldEventCalls = planFieldEventCalls({ - priorContract: options.fromContract, + priorContract: options.origin === null ? null : options.fromContract, newContract: options.contract, codecHooks, tableRenames: statements.value.tableRenames, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index 41f04c63f6d1..51d2a77eb174 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -1,4 +1,5 @@ import { asNamespaceId, 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 { APP_SPACE_ID, @@ -269,6 +270,46 @@ describe('Postgres planner, data loss', () => { }); }); +/** A codec hook that adds an operation for every field it is told was added. */ +function withAddedFieldHook( + components: typeof postgresTypeComponents, +): typeof postgresTypeComponents { + const hooks: CodecControlHooks = { + onFieldEvent: (event, ctx) => + event === 'added' + ? [ + { + factoryName: 'codecAdded', + operationClass: 'additive', + label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, + renderTypeScript: () => 'codecAdded()', + importRequirements: () => [], + toOp: () => ({ + id: `codec.added.${ctx.tableName}.${ctx.fieldName}`, + label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, + operationClass: 'additive', + target: { id: 'postgres' }, + precheck: [], + execute: [], + postcheck: [], + }), + }, + ] + : [], + }; + return [ + ...components, + { + kind: 'adapter', + id: 'field-event-hook', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.0', + types: { codecTypes: { controlPlaneHooks: { 'pg/text@1': hooks } } }, + } as (typeof components)[number], + ]; +} + describe('Postgres planner under db update', () => { const DB_UPDATE = { allowedOperationClasses: ['additive', 'widening', 'destructive'] as const }; @@ -284,7 +325,7 @@ describe('Postgres planner under db update', () => { fromContract, origin: null, statements: [], - frameworkComponents: postgresTypeComponents, + frameworkComponents: withAddedFieldHook(postgresTypeComponents), spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', }); @@ -295,7 +336,7 @@ describe('Postgres planner under db update', () => { return { origin: result.plan.origin, operations }; } - it('plans the same operations with and without the origin contract, and asserts no origin', async () => { + it('plans the same operations with and without the origin contract, codec field-event hooks included, and asserts no origin', async () => { const from = contract('from', { Legacy: { columns: { id: 'int4' } }, User: { 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 7d52b6202bb3..96dbb09d3d95 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 @@ -216,7 +216,7 @@ export class SqliteMigrationPlanner // Hook fires only at the application emitter — extension-space planning // (M2 R2) never reaches this helper. const fieldEventCalls = planFieldEventCalls({ - priorContract: options.fromContract, + priorContract: options.origin === null ? null : options.fromContract, newContract: options.contract, codecHooks, tableRenames: statements.value.tableRenames, diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts index 200146ffd357..b075ff2a0194 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -1,4 +1,5 @@ import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { CodecControlHooks } from '@internal/family-sql/control'; import { APP_SPACE_ID, planOriginOf, @@ -229,6 +230,44 @@ describe('SQLite planner, data loss', () => { }); }); +/** A codec hook that adds an operation for every field it is told was added. */ +function withAddedFieldHook(components: typeof sqliteTestComponents): typeof sqliteTestComponents { + const hooks: CodecControlHooks = { + onFieldEvent: (event, ctx) => + event === 'added' + ? [ + { + factoryName: 'codecAdded', + operationClass: 'additive', + label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, + renderTypeScript: () => 'codecAdded()', + importRequirements: () => [], + toOp: () => ({ + id: `codec.added.${ctx.tableName}.${ctx.fieldName}`, + label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, + operationClass: 'additive', + target: { id: 'sqlite' }, + precheck: [], + execute: [], + postcheck: [], + }), + }, + ] + : [], + }; + return [ + ...components, + { + kind: 'adapter', + id: 'field-event-hook', + familyId: 'sql', + targetId: 'sqlite', + version: '0.0.0', + types: { codecTypes: { controlPlaneHooks: { 'sqlite/text@1': hooks } } }, + } as (typeof components)[number], + ]; +} + describe('SQLite planner under db update', () => { async function dbUpdatePlan( from: Contract, @@ -242,7 +281,7 @@ describe('SQLite planner under db update', () => { fromContract, origin: null, statements: [], - frameworkComponents: sqliteTestComponents, + frameworkComponents: withAddedFieldHook(sqliteTestComponents), spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', }); @@ -253,7 +292,7 @@ describe('SQLite planner under db update', () => { return { origin: result.plan.origin, operations }; } - it('plans the same operations with and without the origin contract, and asserts no origin', async () => { + it('plans the same operations with and without the origin contract, codec field-event hooks included, and asserts no origin', async () => { const from = contract('from', { Legacy: { id: 'integer' }, User: { id: 'integer', email: 'text?', nickname: 'text', age: 'text' }, diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 15e1cc3dd5ae..ec8ee3bf225f 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -16,11 +16,11 @@ A user keeps `model User` and changes `@@map("users")` to `@@map("app_users")`. **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 a snapshot +## A codec's `onFieldEvent` hook never sees the prior contract under `db update` -**Found:** slice 1, dispatch 5 review (2026-10-06). Changed by slice 2, dispatch 4 (2026-10-08). +**Found:** slice 1, dispatch 5 review (2026-10-06). Settled by slice 2, dispatch 4 (2026-10-08). -Since slice 2, `db update` reads the origin contract from the snapshot store on every run and passes it to the planner whenever the snapshot exists, with or without statements. So field-event planning sees the prior contract and reports only real changes on every run with a snapshot; on a run without one (no marker, a marker whose hash has no snapshot, or an unreadable snapshot) it has no prior contract and reports every column as added. No codec in the repository implements `onFieldEvent` today, so nothing observable differs. If a codec starts relying on the hook under `db update`, decide what it should see when the snapshot is missing. +Since slice 2, `db update` reads the origin contract on every run with a snapshot, but the planners give field-event planning the prior contract only when the plan has an origin. `db init` and `db update` plan with `origin: null`, so the hook reports every column as added on every run, with or without a snapshot, as before slice 1, and a plain `db update` plans the same operations whether or not the snapshot exists. No codec in the repository implements `onFieldEvent` today. If a codec needs real `added`, `dropped` and `altered` events under `db update`, the plan's operations would then depend on the snapshot, and that needs a decision. ## Which field name a MongoDB statement uses From c7019fb297edb55bafd3ecb7fe922ab4db5dfd5b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:01:01 +0200 Subject: [PATCH 049/109] TML-3476: a wrong answer callback throws InternalError, and an unused delete or allow statement fails the apply A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller: it throws InternalError carrying the questions and answers. A delete or allow statement passed to dbUpdate that answers no question fails with MIGRATION.STATEMENT_ANSWERS_NO_QUESTION before anything is applied. Signed-off-by: willbot Signed-off-by: Will Madden --- .../statements/data-loss-questions.ts | 36 ++++++-- .../3-tooling/cli/src/utils/cli-errors.ts | 32 ++++++++ .../cli/test/control-api/db-update.test.ts | 82 +++++++++++++------ 3 files changed, 120 insertions(+), 30 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 7fa8104af9dd..165ac578e245 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -6,8 +6,12 @@ import { modelDisplayName, type ResolvedMigrationStatement, } from '@internal/framework-components/control'; +import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; -import { errorStatementDidNotResolveLoss } from '../../utils/cli-errors'; +import { + errorStatementAnswersNoQuestion, + errorStatementDidNotResolveLoss, +} from '../../utils/cli-errors'; import type { ConsentVerb } from './parse-consent'; import { resolveStatements } from './resolve-statements'; import type { StatementText } from './statement-text'; @@ -34,7 +38,9 @@ export interface PlanAnswer { /** * Asks every question at once and returns one answer per question, in question order, with a verb - * and text the question accepts. To refuse, throw. + * and text the question accepts. To refuse, throw. It is called at least once per apply, with an + * empty list when nothing is in question, including under `acceptDataLoss: true`; return `[]` + * then. */ export type AnswerPlanQuestions = ( questions: readonly PlanQuestion[], @@ -219,21 +225,27 @@ const keyOf = (planned: PlannedSubject) => JSON.stringify(planned.subject); function checkAnswers(questions: readonly PlanQuestion[], answers: readonly PlanAnswer[]): void { if (answers.length !== questions.length) { const noun = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; - throw new Error( + throw new InternalError( `answerQuestions gave ${noun(answers.length, 'answer')} for ${noun(questions.length, 'question')}; answer each question in order, or throw to refuse.`, + { cause: { questions: questions.map(({ subject }) => subject), answers } }, ); } questions.forEach((question, index) => { const answer = answers[index]; if (answer === undefined) return; + const cause = { cause: { question: question.subject, verbs: question.verbs, answer } }; if (!question.verbs.includes(answer.verb)) { - throw new Error( + throw new InternalError( `answerQuestions answered "${question.question}" with ${answer.verb}, which it does not accept; it accepts ${question.verbs.join(' or ')}.`, + cause, ); } const rejected = question.validate(answer.verb, answer.text); if (rejected !== undefined) { - throw new Error(`answerQuestions answered "${question.question}" wrongly: ${rejected}`); + throw new InternalError( + `answerQuestions answered "${question.question}" wrongly: ${rejected}`, + cause, + ); } }); } @@ -270,6 +282,7 @@ export async function answerPlanQuestions(); + const usedPreAnswers = new Set(); for (let round = 0; ; round += 1) { const contracts = { origin: input.origin, @@ -295,10 +308,11 @@ export async function answerPlanQuestions statement.verb === verb && statement.text === text, ); - if (input.consentAll || given) consent(verb, entry, text); + for (const statement of given) usedPreAnswers.add(statement); + if (input.consentAll || given.length > 0) consent(verb, entry, text); } } const losses = pending('delete', plan.dataLoss); @@ -337,5 +351,13 @@ export async function answerPlanQuestions !usedPreAnswers.has(statement)); + if (unused.length > 0) { + const contracts = { origin: input.origin, destination: input.destination, renames }; + const subjects = [...plan.dataLoss, ...(input.askAccess ? plan.accessWidening : [])].map( + (entry) => subjectText(entry.subject, contracts), + ); + return notOk(errorStatementAnswersNoQuestion(unused, [...new Set(subjects)])); + } return ok({ plan, renames, consented: [...consented.values()] }); } 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 aadfd1c8dd27..badfe85d62d2 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 @@ -630,6 +630,38 @@ export function errorStatementDidNotResolveLoss( ); } +/** + * `delete` or `allow` statements given to a programmatic call match no question the plan asks, + * so they consent to nothing. + */ +export function errorStatementAnswersNoQuestion( + statements: readonly WrittenStatement[], + subjects: readonly string[], +): ActionableCliError { + const written = statements.map(({ verb, text }) => `"--${verb} ${text}"`); + const listed = + written.length === 1 + ? `Statement ${written[0]} answers` + : `Statements ${written.slice(0, -1).join(', ')} and ${written.at(-1)} answer`; + const fix = + subjects.length === 0 + ? 'Leave the statements out: the plan asks no question.' + : `Leave them out, or name a subject the plan asks about: ${subjects.join(', ')}.`; + return new ActionableCliError( + 'MIGRATION.STATEMENT_ANSWERS_NO_QUESTION', + `${listed} no question of the plan`, + { + why: 'A delete or allow statement consents to an operation the plan asks about, and these name no subject the plan would lose data from or widen access to.', + fix, + nextActions: [chooseAction(fix)], + meta: { + statements: statements.map(({ verb, text }) => ({ verb, text })), + subjects, + }, + }, + ); +} + /** * 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. 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 e80dbae3a56b..65471b838823 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 @@ -7,6 +7,7 @@ import type { MigrationRunnerResult, TargetMigrationsCapability, } from '@internal/framework-components/control'; +import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok } from '@internal/utils/result'; import { describe, expect, it, vi } from 'vitest'; import { executeDbUpdate } from '../../src/control-api/operations/db-update'; @@ -620,6 +621,34 @@ describe('executeDbUpdate', () => { expect(answerQuestions).toHaveBeenCalledWith([]); }); + it('refuses delete and allow statements that answer no question, before applying', async () => { + const execute = vi.fn(); + const error = await executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + statements: [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'delete', text: 'Nope' }, + { verb: 'allow', text: 'user' }, + { verb: 'allow', text: 'post' }, + ], + }), + ).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: 'MIGRATION.STATEMENT_ANSWERS_NO_QUESTION', + message: 'Statements "--delete Nope" and "--allow post" answer no question of the plan', + meta: { + statements: [ + { verb: 'delete', text: 'Nope' }, + { verb: 'allow', text: 'post' }, + ], + subjects: ['user.nickname', 'user'], + }, + }); + expect(execute).not.toHaveBeenCalled(); + }); + it('answers every question with acceptDataLoss: true', async () => { const answerQuestions = vi.fn(noQuestions); const result = await executeDbUpdate(applyInputs({ acceptDataLoss: true, answerQuestions })); @@ -630,34 +659,41 @@ describe('executeDbUpdate', () => { it('rejects an answer callback that leaves a question unanswered, before applying', async () => { const execute = vi.fn(); - await expect( - executeDbUpdate( - applyInputs({ - migrations: createDestructiveMigrations(execute), - answerQuestions: async () => [{ verb: 'delete', text: 'user.nickname' }], - }), - ), - ).rejects.toThrow( - 'answerQuestions gave 1 answer for 2 questions; answer each question in order, or throw to refuse.', - ); + const answers = [{ verb: 'delete', text: 'user.nickname' }] as const; + const error = await executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + answerQuestions: async () => answers, + }), + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(InternalError); + expect(error).toMatchObject({ + message: + 'answerQuestions gave 1 answer for 2 questions; answer each question in order, or throw to refuse.', + cause: { questions: ['user.nickname', 'user'], answers }, + }); expect(execute).not.toHaveBeenCalled(); }); it('rejects an answer the question does not accept, before applying', async () => { const execute = vi.fn(); - await expect( - executeDbUpdate( - applyInputs({ - migrations: createDestructiveMigrations(execute), - answerQuestions: async () => [ - { verb: 'delete', text: 'user.nickname' }, - { verb: 'delete', text: 'user' }, - ], - }), - ), - ).rejects.toThrow( - 'answerQuestions answered "Disable row-level security on user would widen who can read and write its rows." with delete, which it does not accept; it accepts allow.', - ); + const error = await executeDbUpdate( + applyInputs({ + migrations: createDestructiveMigrations(execute), + answerQuestions: async () => [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'delete', text: 'user' }, + ], + }), + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(InternalError); + expect(error).toMatchObject({ + message: + 'answerQuestions answered "Disable row-level security on user would widen who can read and write its rows." with delete, which it does not accept; it accepts allow.', + cause: { question: 'user', verbs: ['allow'], answer: { verb: 'delete', text: 'user' } }, + }); expect(execute).not.toHaveBeenCalled(); }); From d2586eea480268e9375827d995c081ba7444112d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:01:05 +0200 Subject: [PATCH 050/109] TML-3476: docs say answerQuestions is called with an empty list when nothing is in question And document MIGRATION.STATEMENT_ANSWERS_NO_QUESTION and the InternalError a wrong callback throws. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- docs/reference/error-reference.md | 4 ++++ packages/1-framework/3-tooling/cli/README.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 0833671549e9..098d43f5b235 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -809,7 +809,7 @@ When run against the default dev database URL, `db update` implicitly advances t Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#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. -**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback; `delete` and `allow` statements answer their questions without asking, and `acceptDataLoss: true` answers all of them. +**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` before anything is applied; `acceptDataLoss: true` answers all of them. 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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index f4de7a83201a..7d0c19c2fa98 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1648,6 +1648,10 @@ 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_ANSWERS_NO_QUESTION + +A program called the control API's `dbUpdate` with `delete` or `allow` statements in `statements`, and some of them name no subject the plan asks about: no operation would lose that subject's data or widen access to its rows. Such a statement would consent to nothing, so the apply fails before anything is applied. The CLI never raises it; there an unused `--delete` or `--allow` is the engine's `CLI.CONSENT_UNUSED`. Leave the statements out, or name a subject the plan asks about. Payload: `statements` (each `{ verb, text }` that answered nothing), `subjects` (the subjects the plan asks about). + ### MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS A `rename` typed at `migration plan`'s data-loss question was planned with the plan's other renames, and the plan still loses the data of the subject the question was about: the rename resolves, but it does not keep that data, for example because it renames a different model or field. Nothing is written. Answer the question with `--delete ` if the data may be lost, or with a rename whose old name stores that data. Payload: `statement` (the rename's text), `verb` (`rename`), `subject`. diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 86b7e28b4e6a..7314fbeb47ad 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse; `delete` and `allow` statements in `statements` answer their questions without asking, and `acceptDataLoss: true` answers all of them. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails the apply with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` before anything is applied. `acceptDataLoss: true` answers every question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) From b33b7e281cc649ff3a3bc760771e5ddfa0c100d4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:09:50 +0200 Subject: [PATCH 051/109] TML-3476: dispatch 5 carries the dry-run statement check from dispatch 4's review Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/refusal/dispatches/05-journeys-docs-fragments.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md b/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md index d6f8314e1af3..3cfb1bcf2819 100644 --- a/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md +++ b/projects/migration-statements/slices/refusal/dispatches/05-journeys-docs-fragments.md @@ -19,6 +19,10 @@ Next to the `rename-statements-migration*` journeys, one Postgres file, one SQLi Add the planner test the review asked for: a codec hook that emits a destructive call, proving the hook-call-to-field match survives the control-policy partitions (`../plan.md` open items). +## Carried from dispatch 4's review (D4-6) + +A programmatic dry run (`mode: 'plan'`) ignores a `delete` or `allow` statement that matches no subject, where apply mode fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. Check them in plan mode too, without asking, so both modes refuse; test it. The new code goes in the app upgrade fragment's control-API list. + ## Docs and skills - `skills/prisma-8/references/migrations.md`, `debug.md` and `SKILL.md`: replace `MIGRATION.DESTRUCTIVE_CHANGES` and `--confirm ` with the statements and the refusal; the error-code table lists `CLI.CONSENT_REQUIRED`, `CLI.CONSENT_UNUSED` and `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; `skills/journey-tests/*` that mention `--confirm` for these commands. From 0cd5c7dafb315e33fb1def6f0923a07a04ea9c7f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:10:55 +0200 Subject: [PATCH 052/109] TML-3476: a db update dry run refuses delete and allow statements that answer no question The control API's plan mode now checks them as apply mode does, without asking, so both modes fail with MIGRATION.STATEMENT_ANSWERS_NO_QUESTION. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- docs/reference/error-reference.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../cli/src/control-api/operations/db-run.ts | 15 +++++++ .../statements/data-loss-questions.ts | 42 +++++++++++++++++-- .../cli/test/control-api/db-update.test.ts | 34 +++++++++++++++ 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 098d43f5b235..a2e6d12e35bd 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -809,7 +809,7 @@ When run against the default dev database URL, `db update` implicitly advances t Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#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. -**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` before anything is applied; `acceptDataLoss: true` answers all of them. +**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers all of them. 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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7d0c19c2fa98..8b219b030550 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1650,7 +1650,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.STATEMENT_ANSWERS_NO_QUESTION -A program called the control API's `dbUpdate` with `delete` or `allow` statements in `statements`, and some of them name no subject the plan asks about: no operation would lose that subject's data or widen access to its rows. Such a statement would consent to nothing, so the apply fails before anything is applied. The CLI never raises it; there an unused `--delete` or `--allow` is the engine's `CLI.CONSENT_UNUSED`. Leave the statements out, or name a subject the plan asks about. Payload: `statements` (each `{ verb, text }` that answered nothing), `subjects` (the subjects the plan asks about). +A program called the control API's `dbUpdate` with `delete` or `allow` statements in `statements`, and some of them name no subject the plan asks about: no operation would lose that subject's data or widen access to its rows. Such a statement would consent to nothing, so the call fails before anything is applied, in plan mode as in apply mode. The CLI never raises it; there an unused `--delete` or `--allow` is the engine's `CLI.CONSENT_UNUSED`. Leave the statements out, or name a subject the plan asks about. Payload: `statements` (each `{ verb, text }` that answered nothing), `subjects` (the subjects the plan asks about). ### MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 7314fbeb47ad..13a0070b6ac3 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails the apply with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` before anything is applied. `acceptDataLoss: true` answers every question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) 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 21d0f750ff23..11c8107efd77 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 @@ -48,6 +48,7 @@ import { answerPlanQuestions, type ConsentedSubject, type PlannedQuestions, + refuseUnusedConsents, } from '../statements/data-loss-questions'; import { type AppliedStatementReport, @@ -334,6 +335,20 @@ export async function executeRun !usedPreAnswers.has(statement)); if (unused.length > 0) { - const contracts = { origin: input.origin, destination: input.destination, renames }; - const subjects = [...plan.dataLoss, ...(input.askAccess ? plan.accessWidening : [])].map( - (entry) => subjectText(entry.subject, contracts), + return notOk( + errorStatementAnswersNoQuestion( + unused, + questionSubjects(plan, input.askAccess, { ...input, renames }), + ), ); - return notOk(errorStatementAnswersNoQuestion(unused, [...new Set(subjects)])); } return ok({ plan, renames, consented: [...consented.values()] }); } + +function questionSubjects( + plan: PlannedQuestions, + askAccess: boolean, + contracts: StatementContracts, +): readonly string[] { + const subjects = [...plan.dataLoss, ...(askAccess ? plan.accessWidening : [])].map((entry) => + subjectText(entry.subject, contracts), + ); + return [...new Set(subjects)]; +} + +/** + * Refuses the `delete` and `allow` statements that name no subject a plan asks about, without + * asking anything: a run that only plans still says which statements would consent to nothing. + */ +export function refuseUnusedConsents(input: { + readonly plan: PlannedQuestions; + readonly statements: readonly StatementText[]; + readonly contracts: StatementContracts; +}): Result { + const subjects = questionSubjects(input.plan, true, input.contracts); + const matches = (statement: StatementText, entries: readonly PlannedSubject[]) => + entries.some((entry) => subjectText(entry.subject, input.contracts) === statement.text); + const unused = input.statements.filter( + (statement) => + (statement.verb === 'delete' && !matches(statement, input.plan.dataLoss)) || + (statement.verb === 'allow' && !matches(statement, input.plan.accessWidening)), + ); + return unused.length === 0 + ? ok(undefined) + : notOk(errorStatementAnswersNoQuestion(unused, subjects)); +} 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 65471b838823..91dae7e76bbc 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 @@ -697,6 +697,40 @@ describe('executeDbUpdate', () => { expect(execute).not.toHaveBeenCalled(); }); + it('refuses, without asking, delete and allow statements that answer no question in plan mode', async () => { + const answerQuestions = vi.fn(noQuestions); + const error = await executeDbUpdate( + applyInputs({ + mode: 'plan', + answerQuestions, + statements: [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'allow', text: 'post' }, + ], + }), + ).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: 'MIGRATION.STATEMENT_ANSWERS_NO_QUESTION', + meta: { statements: [{ verb: 'allow', text: 'post' }] }, + }); + expect(answerQuestions).not.toHaveBeenCalled(); + }); + + it('takes delete and allow statements that answer questions in plan mode', async () => { + const result = await executeDbUpdate( + applyInputs({ + mode: 'plan', + statements: [ + { verb: 'delete', text: 'user.nickname' }, + { verb: 'allow', text: 'user' }, + ], + }), + ); + + expect(result.ok).toBe(true); + }); + it('asks nothing in plan mode, and lists the questions an apply would ask', async () => { const answerQuestions = vi.fn(noQuestions); const result = await executeDbUpdate(applyInputs({ mode: 'plan', answerQuestions })); From edc7bf5c7a762dab0002ed26818aac4ea97bbf22 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:11:34 +0200 Subject: [PATCH 053/109] TML-3476: a destructive codec-hook call is named by its field after the control-policy partition Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/migrations/planner.data-loss.test.ts | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index 51d2a77eb174..8c5d44a42b19 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -129,6 +129,7 @@ async function planned( to: Contract, fromContract: Contract | null = from, statements: readonly ResolvedMigrationStatement[] = [], + frameworkComponents: typeof postgresTypeComponents = postgresTypeComponents, ) { const result = createPostgresMigrationPlanner(stubLowerer).plan({ contract: to, @@ -137,7 +138,7 @@ async function planned( fromContract, origin: planOriginOf(fromContract), statements, - frameworkComponents: postgresTypeComponents, + frameworkComponents, spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', }); @@ -178,6 +179,25 @@ describe('Postgres planner, data loss', () => { ]); }); + it('names the field a destructive codec-hook call is about, after the control-policy partition', async () => { + const from = contract('from', { User: { columns: { id: 'int4', nickname: 'text' } } }); + const to = contract('to', { User: { columns: { id: 'int4' } } }); + const nickname = { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }; + + const plan = await planned( + from, + to, + from, + [], + withFieldHook(postgresTypeComponents, 'dropped', 'destructive'), + ); + + expect(plan.dataLoss).toEqual([ + { operation: 'Drop column "nickname" from "User"', subject: nickname }, + { operation: 'Codec hook for dropped User.nickname', subject: nickname }, + ]); + }); + it('names the field of a type change that can change values, and not of a safe widening', async () => { const from = contract('from', { User: { columns: { id: 'int4', age: 'text', score: 'int4' } }, @@ -270,24 +290,26 @@ describe('Postgres planner, data loss', () => { }); }); -/** A codec hook that adds an operation for every field it is told was added. */ -function withAddedFieldHook( +/** A codec hook that adds an operation of `operationClass` for every field of the `on` event. */ +function withFieldHook( components: typeof postgresTypeComponents, + on: 'added' | 'dropped', + operationClass: 'additive' | 'destructive', ): typeof postgresTypeComponents { const hooks: CodecControlHooks = { onFieldEvent: (event, ctx) => - event === 'added' + event === on ? [ { - factoryName: 'codecAdded', - operationClass: 'additive', - label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, - renderTypeScript: () => 'codecAdded()', + factoryName: 'codecHook', + operationClass, + label: `Codec hook for ${on} ${ctx.tableName}.${ctx.fieldName}`, + renderTypeScript: () => 'codecHook()', importRequirements: () => [], toOp: () => ({ - id: `codec.added.${ctx.tableName}.${ctx.fieldName}`, - label: `Codec hook for added ${ctx.tableName}.${ctx.fieldName}`, - operationClass: 'additive', + id: `codec.${on}.${ctx.tableName}.${ctx.fieldName}`, + label: `Codec hook for ${on} ${ctx.tableName}.${ctx.fieldName}`, + operationClass, target: { id: 'postgres' }, precheck: [], execute: [], @@ -325,7 +347,7 @@ describe('Postgres planner under db update', () => { fromContract, origin: null, statements: [], - frameworkComponents: withAddedFieldHook(postgresTypeComponents), + frameworkComponents: withFieldHook(postgresTypeComponents, 'added', 'additive'), spaceId: APP_SPACE_ID, snapshotsImportPath: '../../snapshots', }); From 1e8ee99a24c96ce12e69a9e69e3286d45315b93c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:17:17 +0200 Subject: [PATCH 054/109] TML-3476: journeys for delete and allow statements on Postgres, SQLite and MongoDB Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 3 + .../delete-statements-migration.e2e.test.ts | 450 ++++++++++++++++++ ...ete-statements-migration.mongo.e2e.test.ts | 281 +++++++++++ ...te-statements-migration.sqlite.e2e.test.ts | 332 +++++++++++++ 4 files changed, 1066 insertions(+) create mode 100644 test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts create mode 100644 test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts create mode 100644 test/integration/test/cli-journeys/delete-statements-migration.sqlite.e2e.test.ts diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index 132ec7c9f9a5..4c3044e2bbd0 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -32,6 +32,9 @@ 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 | +| `delete-statements-migration.e2e.test.ts` | **Statements that consent to data loss and widened access (Postgres)**: journey S1 renames `Profile` to `User` and its field, and removes `Legacy`, through `migration plan`: refused with `CLI.CONSENT_REQUIRED` naming `--delete Legacy`, planned with it, and migrated on tables with rows, a unique, a foreign key, an index and a check. S2 does the same through `db update`, where `--confirm ` is refused, and the same statements fail on a second run (`MIGRATION.STATEMENT_UNRESOLVED`). S3 adds a required field to a table with rows without asking. S4 drops a policy: a dry run lists it under `accessWidening`, the apply is refused without `--allow User` and applies with it. S5 drops a model with `db update --db ` and no snapshot: the subject is `public.Legacy`, the question says the origin contract is unknown, and `--delete public.Legacy` applies | +| `delete-statements-migration.sqlite.e2e.test.ts` | **The SQLite twin**: S1 and S2 as on Postgres; S3 adds a required field to an empty table (SQLite's `db update` has no temporary default for existing rows); S4 removes the snapshot store, so the dropped table is asked about as `Legacy`, its unqualified storage name | +| `delete-statements-migration.mongo.e2e.test.ts` | **The MongoDB twin**: dropping a collection is refused by `migration plan` and `db update` until `--delete Event`; `--confirm ` is refused; a required field applies without asking; a `--rename` is refused by the planner with `statementRefused` | | `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 | diff --git a/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts new file mode 100644 index 000000000000..d0cc017659d5 --- /dev/null +++ b/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts @@ -0,0 +1,450 @@ +/** + * Statements that consent to data loss and widened access (Postgres). + * + * A plan that would lose data asks what each such operation means: `--delete ` lets the data go, `--rename :` keeps it. `db update` also asks, with `--allow `, before it widens who can read or write a model's rows. Where nobody can answer, the command fails with `CLI.CONSENT_REQUIRED`, whose next actions name the flags. + * + * Journey S1 renames `Profile` to `User` and its field `name` to `fullName`, and removes `Legacy`, through `migration plan`: refused without `--delete Legacy`, planned with it, and applied by `migrate` on tables with rows, a unique, a foreign key from `Post`, a secondary index and a check. Journey S2 does the same through `db update`, where `--confirm ` no longer consents, and the same statements fail on a second run. Journey S3 adds a required field with no refusal. Journey S4 drops a policy: refused without `--allow User`, applied with it, and listed under `accessWidening` by a dry run. Journey S5 drops a model with `db update --db ` and no snapshot, so the subject is its storage name. + */ + +import { writeFileSync } 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, + setupJourney, + sql, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const USERS = ` 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]) + } +`; + +const POLICY = (target: string) => ` + policy_select tenant_read { + target = ${target} + roles = [app_user] + using = "(tenant_id = 1)" + } +`; + +const LEGACY = ` + model Legacy { + id Int @id + note String + } +`; + +const FROM_PSL = `// 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]) + } +${LEGACY}${POLICY('Profile')}} +`; + +const TO_PSL = `// use prisma-8 + +namespace public { +${USERS}${POLICY('User')}} +`; + +const TO_WITH_LEGACY_PSL = `// use prisma-8 + +namespace public { +${USERS}${LEGACY}${POLICY('User')}} +`; + +const TO_REQUIRED_FIELD_PSL = TO_PSL.replace( + ' handle String\n', + ' handle String\n nickname String\n', +); + +const TO_NO_POLICY_PSL = `// use prisma-8 + +namespace public { +${USERS}} +`; + +const RENAMES = ['--rename', 'Profile:User', '--rename', 'User.name:User.fullName'] as const; + +interface AppliedStatementReport { + readonly description: string; +} + +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); + INSERT INTO "public"."Legacy" (id, note) VALUES (1, 'old')`, + ); +} + +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 nextActionsOf(result: Parameters[0]): string { + return JSON.stringify(engineError(result)?.nextActions ?? []); +} + +async function expectRenamedWithoutLegacy( + 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 profile, + to_regclass('"public"."Legacy"') AS legacy, + (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(confrelid::regclass::text) FROM pg_constraint + WHERE conrelid = '"public"."Post"'::regclass AND contype = 'f') AS post_references`, + ); + expect( + live.rows[0], + `${label}: old and deleted tables gone, objects under the new names`, + ).toEqual({ + profile: null, + legacy: null, + 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'], + post_references: ['"User"'], + }); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +function databaseName(connectionString: string): string { + return decodeURIComponent(new URL(connectionString).pathname.slice(1)); +} + +withTempDir(({ createTempDir }) => { + describe('Journey S1: rename and delete with migration plan', () => { + const db = useDevDatabase(); + + it( + 'refuses the plan until --delete names the dropped model, then migrates it', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + 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); + await seedRows(db.connectionString); + const origin = latestMigrationDirName(ctx); + await emitContract(ctx, TO_PSL, 'S1.04'); + + const refused = await runMigrationPlan(ctx, ['--from', origin, ...RENAMES, '--json']); + expect(refused.exitCode, 'S1.05: the plan loses Legacy, so it is refused').toBe(2); + expect(engineError(refused)?.code, 'S1.05: nobody to answer').toBe('CLI.CONSENT_REQUIRED'); + expect(nextActionsOf(refused), 'S1.05: names the flag that answers').toContain( + '--delete Legacy', + ); + expect(getMigrationDirs(ctx), 'S1.05: nothing written').toHaveLength(1); + + const plan = await runMigrationPlan(ctx, [ + '--name', + 'tidy-users', + '--from', + origin, + ...RENAMES, + '--delete', + 'Legacy', + '--json', + ]); + expect(plan.exitCode, `S1.06: plan with --delete: ${plan.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + plan, + ).appliedStatements.map((entry) => entry.description), + 'S1.06: the renames, then the delete', + ).toEqual([ + 'rename model "Profile" to "User"', + 'rename field "User.name" to "User.fullName"', + 'delete model "Legacy"', + ]); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `S1.07: migrate: ${apply.stderr}`).toBe(0); + await expectRenamedWithoutLegacy(ctx, db.connectionString, 'S1.08'); + + const planned = latestMigrationDirName(ctx); + const fresh = await runMigrationPlan(ctx, ['--from', planned, '--json']); + expect(fresh.exitCode, `S1.09: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'S1.09: plan is empty').toBe(true); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S2: rename and delete with db update', () => { + const db = useDevDatabase(); + + it( + 'refuses --confirm, applies with --delete, 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, FROM_PSL, 'S2.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S2.02: db update creates the tables: ${create.stderr}`).toBe(0); + await seedRows(db.connectionString); + await emitContract(ctx, TO_PSL, 'S2.03'); + + const confirmed = await runDbUpdate(ctx, [ + ...RENAMES, + '--no-interactive', + '--confirm', + databaseName(db.connectionString), + '--json', + ]); + expect(confirmed.exitCode, 'S2.04: --confirm does not consent').toBe(2); + expect(engineError(confirmed)?.code, 'S2.04: the question is unanswered').toBe( + 'CLI.CONSENT_REQUIRED', + ); + expect(nextActionsOf(confirmed), 'S2.04: names the flag that answers').toContain( + '--delete Legacy', + ); + + const update = await runDbUpdate(ctx, [...RENAMES, '--delete', 'Legacy', '--json']); + expect(update.exitCode, `S2.05: db update with --delete: ${update.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + update, + ).appliedStatements.map((entry) => entry.description), + 'S2.05: the renames, then the delete', + ).toEqual([ + 'rename model "Profile" to "User"', + 'rename field "User.name" to "User.fullName"', + 'delete model "Legacy"', + ]); + await expectRenamedWithoutLegacy(ctx, db.connectionString, 'S2.06'); + + const again = await runDbUpdate(ctx, [...RENAMES, '--delete', 'Legacy', '--json']); + expect(again.exitCode, 'S2.07: the same statements fail on a second run').not.toBe(0); + expect(engineError(again)?.code, 'S2.07: the first statement no longer resolves').toBe( + 'MIGRATION.STATEMENT_UNRESOLVED', + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S3: a required field', () => { + const db = useDevDatabase(); + + it( + 'adds a non-nullable field to a table with rows without asking', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, TO_PSL, 'S3.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S3.02: db update creates User: ${create.stderr}`).toBe(0); + await sql( + db.connectionString, + `INSERT INTO "public"."User" (id, "fullName", handle, tenant_id) VALUES (1, 'alice', 'al', 1)`, + ); + await emitContract(ctx, TO_REQUIRED_FIELD_PSL, 'S3.03'); + + const update = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(update.exitCode, `S3.04: db update adds the field: ${update.stderr}`).toBe(0); + const rows = await sql(db.connectionString, `SELECT id, nickname FROM "public"."User"`); + expect(rows.rows, 'S3.04: the existing row has the backfilled value').toEqual([ + { id: 1, nickname: '' }, + ]); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S4: a policy drop widens access', () => { + const db = useDevDatabase(); + + it( + 'lists the widening on a dry run, refuses without --allow, and applies with it', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, TO_PSL, 'S4.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S4.02: db update creates User: ${create.stderr}`).toBe(0); + await emitContract(ctx, TO_NO_POLICY_PSL, 'S4.03'); + + const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); + expect(dryRun.exitCode, `S4.04: dry run: ${dryRun.stderr}`).toBe(0); + expect( + parseJsonOutput<{ accessWidening: readonly { subject: unknown }[] }>(dryRun) + .accessWidening, + 'S4.04: the dry run lists the widening without asking', + ).toEqual([ + expect.objectContaining({ subject: expect.objectContaining({ model: 'User' }) }), + ]); + + const refused = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(refused.exitCode, 'S4.05: the apply is refused without --allow').toBe(2); + expect(engineError(refused)?.code, 'S4.05: the question is unanswered').toBe( + 'CLI.CONSENT_REQUIRED', + ); + expect(nextActionsOf(refused), 'S4.05: names the flag that answers').toContain( + '--allow User', + ); + const kept = await sql( + db.connectionString, + `SELECT count(*)::int AS policies FROM pg_policies WHERE tablename = 'User'`, + ); + expect(kept.rows, 'S4.05: the policy is still there').toEqual([{ policies: 1 }]); + + const allowed = await runDbUpdate(ctx, ['--allow', 'User', '--json']); + expect(allowed.exitCode, `S4.06: db update --allow User: ${allowed.stderr}`).toBe(0); + const dropped = await sql( + db.connectionString, + `SELECT count(*)::int AS policies FROM pg_policies WHERE tablename = 'User'`, + ); + expect(dropped.rows, 'S4.06: the policy is gone').toEqual([{ policies: 0 }]); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S5: db update --db with no snapshot', () => { + const db = useDevDatabase(); + + it( + 'names the dropped table by its storage name, and deletes it by that name', + async () => { + const ctx = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + await createAppUserRole(db.connectionString); + await emitContract(ctx, TO_WITH_LEGACY_PSL, 'S5.01'); + const create = await runDbUpdate(ctx, ['--db', db.connectionString, '--json']); + expect(create.exitCode, `S5.02: db update --db creates the tables: ${create.stderr}`).toBe( + 0, + ); + await emitContract(ctx, TO_PSL, 'S5.03'); + + const refused = await runDbUpdate(ctx, [ + '--db', + db.connectionString, + '--no-interactive', + '--json', + ]); + expect(refused.exitCode, 'S5.04: the drop is refused').toBe(2); + const refusal = engineError(refused); + expect(refusal?.code, 'S5.04: the question is unanswered').toBe('CLI.CONSENT_REQUIRED'); + expect(refusal?.meta, 'S5.04: the subject is the storage name').toMatchObject({ + unanswered: [{ subject: 'public.Legacy', verbs: ['delete'] }], + }); + expect(JSON.stringify(refusal), 'S5.04: the question says why').toContain( + 'named by its storage name because the origin contract is unknown', + ); + + const deleted = await runDbUpdate(ctx, [ + '--db', + db.connectionString, + '--delete', + 'public.Legacy', + '--json', + ]); + expect(deleted.exitCode, `S5.05: --delete public.Legacy: ${deleted.stderr}`).toBe(0); + const legacy = await sql( + db.connectionString, + `SELECT to_regclass('"public"."Legacy"') AS legacy`, + ); + expect(legacy.rows, 'S5.05: the table is gone').toEqual([{ legacy: null }]); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts new file mode 100644 index 000000000000..2aa276b4deef --- /dev/null +++ b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts @@ -0,0 +1,281 @@ +/** + * Statements that consent to data loss (MongoDB). + * + * Dropping a collection loses its documents, so `migration plan` and `db update` ask what the drop means; `--delete ` answers. The MongoDB planner refuses rename statements in this release. + * + * Journey S1 removes `Event` through `migration plan`: refused without `--delete Event`, planned with it, and applied by `migrate`. Journey S2 does the same through `db update`, where `--confirm ` no longer consents. Journey S3 adds a required field with no refusal. Journey S4 gives a rename, which the planner refuses with `statementRefused`. + */ + +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { timeouts } from '@repo/test-utils'; +import { MongoClient } from 'mongodb'; +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { fixtureAppDir } from '../utils/cli-test-helpers'; +import { + engineError, + type JourneyContext, + latestMigrationDirName, + parseJsonOutput, + runContractEmit, + runDbUpdate, + runMigrate, + runMigrationPlan, +} from '../utils/journey-test-helpers'; + +const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); + +const USER_WITH_NICKNAME = `import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; + +const User = model('User', { + collection: 'users', + fields: { + _id: field.objectId(), + email: field.string(), + name: field.string(), + nickname: field.string(), + }, + indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], +}); + +export const contract = defineContract({ + models: { User }, +}); +`; + +const EVENT_RENAMED = `import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; + +const User = model('User', { + collection: 'users', + fields: { + _id: field.objectId(), + email: field.string(), + name: field.string(), + }, + indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], +}); + +const Occasion = model('Occasion', { + collection: 'occasions', + fields: { + _id: field.objectId(), + name: field.string(), + }, +}); + +export const contract = defineContract({ + models: { User, Occasion }, +}); +`; + +interface AppliedStatementReport { + readonly description: string; +} + +function withDatabase(baseUri: string, dbName: string): string { + const url = new URL(baseUri); + url.pathname = `/${dbName}`; + return url.toString(); +} + +function setupProject(connectionString: string): JourneyContext { + const testDir = join( + fixtureAppDir, + `test-mongo-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const outputDir = join(testDir, 'output'); + mkdirSync(outputDir, { recursive: true }); + mkdirSync(join(testDir, 'migrations'), { recursive: true }); + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + name: 'mongo-delete-journey', + private: true, + type: 'module', + dependencies: { '@prisma/orm-mongo': 'workspace:0.16.0' }, + }), + 'utf-8', + ); + copyFileSync(join(FIXTURES_DIR, 'contract-with-events.ts'), join(testDir, 'contract.ts')); + const config = readFileSync(join(FIXTURES_DIR, 'prisma.config.with-db.ts'), 'utf-8').replace( + /\{\{DB_URL\}\}/g, + () => connectionString, + ); + const configPath = join(testDir, 'prisma.config.ts'); + writeFileSync(configPath, config, 'utf-8'); + return { testDir, configPath, outputDir }; +} + +function useContract(ctx: JourneyContext, source: string): void { + writeFileSync(join(ctx.testDir, 'contract.ts'), source, 'utf-8'); +} + +function useFixture(ctx: JourneyContext, name: string): void { + copyFileSync(join(FIXTURES_DIR, name), join(ctx.testDir, 'contract.ts')); +} + +async function emit(ctx: JourneyContext, label: string): Promise { + const emitted = await runContractEmit(ctx); + expect(emitted.exitCode, `${label}: emit: ${emitted.stderr}`).toBe(0); +} + +function nextActionsOf(result: Parameters[0]): string { + return JSON.stringify(engineError(result)?.nextActions ?? []); +} + +describe('Journeys (MongoDB): statements that consent to data loss', { + timeout: timeouts.spinUpMongoMemoryServer, +}, () => { + let replSet: MongoMemoryReplSet; + let client: MongoClient; + const created = new Set(); + + beforeAll(async () => { + replSet = await MongoMemoryReplSet.create({ + instanceOpts: [ + { launchTimeout: timeouts.spinUpMongoMemoryServer, storageEngine: 'wiredTiger' }, + ], + replSet: { count: 1, storageEngine: 'wiredTiger' }, + }); + client = new MongoClient(replSet.getUri()); + await client.connect(); + }, timeouts.spinUpMongoMemoryServer); + + afterEach(async () => { + for (const dir of created) { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + created.clear(); + }); + + afterAll(async () => { + await client?.close().catch(() => {}); + await replSet?.stop().catch(() => {}); + }, timeouts.spinUpMongoMemoryServer); + + async function collections(dbName: string): Promise { + const listed = await client.db(dbName).listCollections().toArray(); + return listed + .map(({ name }) => name) + .filter((name) => !name.startsWith('_prisma')) + .sort(); + } + + it('S1: migration plan refuses the drop until --delete names the model, then migrates it', async () => { + const dbName = 'mongo_delete_plan'; + const ctx = setupProject(withDatabase(replSet.getUri(), dbName)); + created.add(ctx.testDir); + await emit(ctx, 'S1.01'); + const initial = await runMigrationPlan(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 client.db(dbName).collection('events').insertOne({ name: 'launch' }); + const origin = latestMigrationDirName(ctx); + useFixture(ctx, 'contract-additive.ts'); + await emit(ctx, 'S1.04'); + + const refused = await runMigrationPlan(ctx, ['--from', origin, '--json']); + expect(refused.exitCode, 'S1.05: the plan drops events, so it is refused').toBe(2); + expect(engineError(refused)?.code, 'S1.05: nobody to answer').toBe('CLI.CONSENT_REQUIRED'); + expect(nextActionsOf(refused), 'S1.05: names the flag that answers').toContain( + '--delete Event', + ); + + const plan = await runMigrationPlan(ctx, [ + '--name', + 'drop-events', + '--from', + origin, + '--delete', + 'Event', + '--json', + ]); + expect(plan.exitCode, `S1.06: plan with --delete: ${plan.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + plan, + ).appliedStatements.map((entry) => entry.description), + 'S1.06: the delete is applied', + ).toEqual(['delete model "Event"']); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `S1.07: migrate: ${apply.stderr}`).toBe(0); + expect(await collections(dbName), 'S1.07: the collection is gone').toEqual(['users']); + }); + + it('S2: db update refuses --confirm and drops the collection with --delete', async () => { + const dbName = 'mongo_delete_update'; + const ctx = setupProject(withDatabase(replSet.getUri(), dbName)); + created.add(ctx.testDir); + await emit(ctx, 'S2.01'); + const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(create.exitCode, `S2.02: db update creates the collections: ${create.stderr}`).toBe(0); + await client.db(dbName).collection('events').insertOne({ name: 'launch' }); + useFixture(ctx, 'contract-additive.ts'); + await emit(ctx, 'S2.03'); + + const confirmed = await runDbUpdate(ctx, ['--no-interactive', '--confirm', dbName, '--json']); + expect(confirmed.exitCode, 'S2.04: --confirm does not consent').toBe(2); + expect(engineError(confirmed)?.code, 'S2.04: the question is unanswered').toBe( + 'CLI.CONSENT_REQUIRED', + ); + expect(nextActionsOf(confirmed), 'S2.04: names the flag that answers').toContain( + '--delete Event', + ); + expect(await collections(dbName), 'S2.04: nothing dropped').toContain('events'); + + const deleted = await runDbUpdate(ctx, ['--no-interactive', '--delete', 'Event', '--json']); + expect(deleted.exitCode, `S2.05: db update --delete Event: ${deleted.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + deleted, + ).appliedStatements.map((entry) => entry.description), + 'S2.05: the delete is applied', + ).toEqual(['delete model "Event"']); + expect(await collections(dbName), 'S2.05: the collection is gone').toEqual(['users']); + }); + + it('S3: db update adds a required field to a collection with documents without asking', async () => { + const dbName = 'mongo_delete_required'; + const ctx = setupProject(withDatabase(replSet.getUri(), dbName)); + created.add(ctx.testDir); + useFixture(ctx, 'contract-additive.ts'); + await emit(ctx, 'S3.01'); + const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(create.exitCode, `S3.02: db update creates users: ${create.stderr}`).toBe(0); + await client.db(dbName).collection('users').insertOne({ email: 'a@example.com', name: 'a' }); + useContract(ctx, USER_WITH_NICKNAME); + await emit(ctx, 'S3.03'); + + const update = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(update.exitCode, `S3.04: db update adds the field: ${update.stdout}`).toBe(0); + }); + + it('S4: the planner refuses a rename statement', async () => { + const dbName = 'mongo_delete_rename'; + const ctx = setupProject(withDatabase(replSet.getUri(), dbName)); + created.add(ctx.testDir); + await emit(ctx, 'S4.01'); + const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(create.exitCode, `S4.02: db update creates the collections: ${create.stderr}`).toBe(0); + useContract(ctx, EVENT_RENAMED); + await emit(ctx, 'S4.03'); + + const renamed = await runDbUpdate(ctx, [ + '--no-interactive', + '--rename', + 'Event:Occasion', + '--json', + ]); + expect(renamed.exitCode, 'S4.04: the rename is refused').not.toBe(0); + const refusal = engineError(renamed); + expect(refusal?.code, 'S4.04: planning failed').toBe('MIGRATION.PLANNING_FAILED'); + expect(JSON.stringify(refusal?.meta), 'S4.04: the conflict is the refused statement').toContain( + 'statementRefused', + ); + expect(await collections(dbName), 'S4.04: nothing changed').toEqual(['events', 'users']); + }); +}); diff --git a/test/integration/test/cli-journeys/delete-statements-migration.sqlite.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.sqlite.e2e.test.ts new file mode 100644 index 000000000000..826b9a2627b5 --- /dev/null +++ b/test/integration/test/cli-journeys/delete-statements-migration.sqlite.e2e.test.ts @@ -0,0 +1,332 @@ +/** + * Statements that consent to data loss (SQLite). + * + * The SQLite twin of `delete-statements-migration.e2e.test.ts`. 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`, and there is no access to widen. + * + * Journey S1 renames `Profile` to `User` and its field `name` to `fullName`, and removes `Legacy`, through `migration plan`: refused without `--delete Legacy`, planned with it, and applied by `migrate`. Journey S2 does the same through `db update`, where `--confirm ` no longer consents, and the same statements fail on a second run. Journey S3 adds a required field with no refusal, to an empty table: SQLite's `db update` has no temporary default to fill existing rows, so on a table with rows the runner fails with `MIGRATION.RUNNER_FAILED` rather than asking. Journey S4 drops a model with no snapshot of the database's contract, so the subject is the table's unqualified name. + */ + +import { readFileSync, rmSync, 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, + sqlitePslConfigFixture, + timeouts, +} from '../utils/journey-test-helpers'; + +const USERS = `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 LEGACY = ` +model Legacy { + id Int @id + note String +} +`; + +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]) +} +${LEGACY}`; + +const TO_PSL = `// use prisma-8 + +${USERS}`; + +const TO_WITH_LEGACY_PSL = `${TO_PSL}${LEGACY}`; + +const TO_REQUIRED_FIELD_PSL = TO_PSL.replace( + ' handle String\n', + ' handle String\n nickname String\n', +); + +const RENAMES = ['--rename', 'Profile:User', '--rename', 'User.name:User.fullName'] as const; + +interface AppliedStatementReport { + readonly description: string; +} + +type SqliteJourney = JourneyContext & { readonly dbPath: string }; + +function setupSqliteJourney(createTempDir: () => string): SqliteJourney { + 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); + INSERT INTO "Legacy" (id, note) VALUES (1, 'old')`, + ); + }); +} + +function tables(dbPath: string): readonly unknown[] { + return withDatabase(dbPath, (db) => + db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`) + .all() + .map((row) => row['name']), + ); +} + +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 nextActionsOf(result: Parameters[0]): string { + return JSON.stringify(engineError(result)?.nextActions ?? []); +} + +async function expectRenamedWithoutLegacy(ctx: SqliteJourney, label: string): Promise { + const state = withDatabase(ctx.dbPath, (db) => ({ + rows: db.prepare(`SELECT id, "fullName", handle FROM "User" ORDER BY id`).all(), + 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, `${label}: rows and objects under the new names`).toEqual({ + rows: [ + { id: 1, fullName: 'alice', handle: 'al' }, + { id: 2, fullName: 'bob', handle: 'bo' }, + ], + indexes: [ + expect.stringMatching(/^User_fullName_handle_idx_[0-9a-f]+$/), + 'sqlite_autoindex_User_1', + ], + postReferences: [{ table: 'User', from: 'profileId', to: 'id' }], + }); + const remaining = tables(ctx.dbPath); + expect(remaining.includes('Profile'), `${label}: the old table is gone`).toBe(false); + expect(remaining.includes('Legacy'), `${label}: the deleted table is gone`).toBe(false); + const verify = await runDbVerify(ctx, ['--schema-only']); + expect(verify.exitCode, `${label}: db verify --schema-only: ${verify.stderr}`).toBe(0); +} + +const STATEMENT_DESCRIPTIONS = [ + 'rename model "Profile" to "User"', + 'rename field "User.name" to "User.fullName"', + 'delete model "Legacy"', +]; + +withTempDir(({ createTempDir }) => { + describe('Journey S1 (SQLite): rename and delete with migration plan', () => { + it( + 'refuses the plan until --delete names the dropped model, then migrates it', + 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 refused = await runMigrationPlan(ctx, ['--from', origin, ...RENAMES, '--json']); + expect(refused.exitCode, 'S1.05: the plan loses Legacy, so it is refused').toBe(2); + expect(engineError(refused)?.code, 'S1.05: nobody to answer').toBe('CLI.CONSENT_REQUIRED'); + expect(nextActionsOf(refused), 'S1.05: names the flag that answers').toContain( + '--delete Legacy', + ); + expect(getMigrationDirs(ctx), 'S1.05: nothing written').toHaveLength(1); + + const plan = await runMigrationPlan(ctx, [ + '--name', + 'tidy-users', + '--from', + origin, + ...RENAMES, + '--delete', + 'Legacy', + '--json', + ]); + expect(plan.exitCode, `S1.06: plan with --delete: ${plan.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + plan, + ).appliedStatements.map((entry) => entry.description), + 'S1.06: the renames, then the delete', + ).toEqual(STATEMENT_DESCRIPTIONS); + + const apply = await runMigrate(ctx); + expect(apply.exitCode, `S1.07: migrate: ${apply.stderr}`).toBe(0); + await expectRenamedWithoutLegacy(ctx, 'S1.08'); + + const fresh = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(fresh.exitCode, `S1.09: plan with no schema change: ${fresh.stderr}`).toBe(0); + expect(parseJsonOutput<{ noOp: boolean }>(fresh).noOp, 'S1.09: plan is empty').toBe(true); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S2 (SQLite): rename and delete with db update', () => { + it( + 'refuses --confirm, applies with --delete, 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 the tables: ${create.stderr}`).toBe(0); + seedRows(ctx.dbPath); + await emitContract(ctx, TO_PSL, 'S2.03'); + + const confirmed = await runDbUpdate(ctx, [ + ...RENAMES, + '--no-interactive', + '--confirm', + 'journey.db', + '--json', + ]); + expect(confirmed.exitCode, 'S2.04: --confirm does not consent').toBe(2); + expect(engineError(confirmed)?.code, 'S2.04: the question is unanswered').toBe( + 'CLI.CONSENT_REQUIRED', + ); + expect(nextActionsOf(confirmed), 'S2.04: names the flag that answers').toContain( + '--delete Legacy', + ); + + const update = await runDbUpdate(ctx, [...RENAMES, '--delete', 'Legacy', '--json']); + expect(update.exitCode, `S2.05: db update with --delete: ${update.stderr}`).toBe(0); + expect( + parseJsonOutput<{ appliedStatements: readonly AppliedStatementReport[] }>( + update, + ).appliedStatements.map((entry) => entry.description), + 'S2.05: the renames, then the delete', + ).toEqual(STATEMENT_DESCRIPTIONS); + await expectRenamedWithoutLegacy(ctx, 'S2.06'); + + const again = await runDbUpdate(ctx, [...RENAMES, '--delete', 'Legacy', '--json']); + expect(again.exitCode, 'S2.07: the same statements fail on a second run').not.toBe(0); + expect(engineError(again)?.code, 'S2.07: the first statement no longer resolves').toBe( + 'MIGRATION.STATEMENT_UNRESOLVED', + ); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S3 (SQLite): a required field', () => { + it( + 'adds a non-nullable field to an empty table without asking', + async () => { + const ctx = setupSqliteJourney(createTempDir); + await emitContract(ctx, TO_PSL, 'S3.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S3.02: db update creates User: ${create.stderr}`).toBe(0); + await emitContract(ctx, TO_REQUIRED_FIELD_PSL, 'S3.03'); + + const update = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(update.exitCode, `S3.04: db update adds the field: ${update.stdout}`).toBe(0); + const columns = withDatabase(ctx.dbPath, (db) => + db + .prepare(`SELECT name FROM pragma_table_info('User') ORDER BY name`) + .all() + .map((row) => row['name']), + ); + expect(columns, 'S3.04: the field has a column').toContain('nickname'); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Journey S4 (SQLite): no snapshot of the database contract', () => { + it( + 'names the dropped table by its unqualified storage name, and deletes it by that name', + async () => { + const ctx = setupSqliteJourney(createTempDir); + await emitContract(ctx, TO_WITH_LEGACY_PSL, 'S4.01'); + const create = await runDbUpdate(ctx, ['--json']); + expect(create.exitCode, `S4.02: db update creates the tables: ${create.stderr}`).toBe(0); + rmSync(join(ctx.testDir, 'migrations', 'snapshots'), { recursive: true, force: true }); + await emitContract(ctx, TO_PSL, 'S4.03'); + + const refused = await runDbUpdate(ctx, ['--no-interactive', '--json']); + expect(refused.exitCode, 'S4.04: the drop is refused').toBe(2); + const refusal = engineError(refused); + expect(refusal?.code, 'S4.04: the question is unanswered').toBe('CLI.CONSENT_REQUIRED'); + expect(refusal?.meta, 'S4.04: the subject is the table name').toMatchObject({ + unanswered: [{ subject: 'Legacy', verbs: ['delete'] }], + }); + expect(JSON.stringify(refusal), 'S4.04: the question says why').toContain( + 'named by its storage name because the origin contract is unknown', + ); + + const deleted = await runDbUpdate(ctx, ['--delete', 'Legacy', '--json']); + expect(deleted.exitCode, `S4.05: --delete Legacy: ${deleted.stderr}`).toBe(0); + expect(tables(ctx.dbPath).includes('Legacy'), 'S4.05: the table is gone').toBe(false); + }, + timeouts.spinUpPpgDev, + ); + }); +}); From 439d232b432bf3bd33956eaef407dfd3cc9ffb46 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:18:43 +0200 Subject: [PATCH 055/109] TML-3476: skills and the Migration System doc describe statements as the consent to data loss The Statements section covers rename, delete and allow and the questions in one place; the skill references replace MIGRATION.DESTRUCTIVE_CHANGES and --confirm with the questions and their flags. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 40 +++++++++++++------ docs/reference/error-reference.md | 2 +- .../02b-rename-with-statement.md | 1 + skills/prisma-8/SKILL.md | 2 +- skills/prisma-8/references/debug.md | 4 +- .../prisma-8/references/migration-review.md | 2 +- skills/prisma-8/references/migrations.md | 38 +++++++++--------- 7 files changed, 54 insertions(+), 35 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index a2e6d12e35bd..c318a6f0c649 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -170,7 +170,7 @@ When `--to` is omitted, `to_contract` is the emitted contract; when `--to` is su An auto-baseline dates the delta package at the current time and the baseline package one minute earlier, so the baseline sorts first. Folder names have minute resolution, so packages planned within the same minute sort by name. -**Data loss.** Before it writes either package, `migration plan` asks what each operation that would lose data means, through the CLI engine's statement prompt (`ctx.prompt.statements`). The planner's `dataLoss` names each such operation's subject; the command asks one question per subject, answered by `--delete ` or, for a model or field, `--rename :` (see [Statements](#statements)). The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start; a rename typed at the prompt is planned again with them, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`, listing every question, and a `--delete` that answers none fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` grants nothing here. +**Data loss.** Before it writes either package, including the baseline of an auto-baseline, `migration plan` asks what each operation that would lose data means (see [The questions](#statements)). It does not ask about access: a written package is reviewed before it is applied. The diff never compares constraint names, so the planner compares names itself: a primary key, unique constraint or foreign key that the start tree and the end contract both have, otherwise unchanged, is renamed with `RENAME CONSTRAINT` when its start name and end name differ, each the stated name or the one the planner derives. The start tree comes from the start contract for `migration plan` and from the database for `db update`, and `renameTable` applies the same rule across a table rename. The runner skips a rename when the database already has the new name ([`constraint-name-renames.ts`](../../../packages/3-targets/3-targets/postgres/src/core/migrations/constraint-name-renames.ts)). @@ -178,13 +178,23 @@ Plan-time refuse diagnostics name the resolved hash, list refs pointing at graph ### 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. +The origin and destination contracts cannot say whether a model or field that is gone was renamed or meant to be deleted, nor whether an operation that opens up a model's rows is meant. The user says so on the command line with statements, each a repeatable flag: + +- `--rename :` keeps a model's or field's data under a new name. Renames are the only statements that reach the planner, which takes them as its third input, beside the destination contract and the origin schema. +- `--delete ` lets the plan lose a subject's data. +- `--allow ` lets `db update` widen who can read or write a model's rows. + +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 +prisma migration plan --name tidy-users --rename Profile:User --rename User.name:User.fullName --delete Legacy +prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationStatementSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field, `rename`; `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `answerPlanQuestions` in [`data-loss-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. + +#### Rename statements + **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`. @@ -195,25 +205,29 @@ prisma db update --rename Profile:User --rename User.name:User.fullName - 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`. +Rename operations are `widening`, so neither command asks about them. 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. -**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. - -**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. - **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, and on every run reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). The planner gets it as `fromContract`, and names each subject of `dataLoss` and `accessWidening` through it. A missing or unreadable snapshot fails only a run with `--rename`; otherwise each subject is named by its storage name. The plan it hands the runner keeps `origin: null` either way (see [`db update`](#db-update-live-reconciliation)). The planner's operations do not depend on whether the snapshot was read: every strategy that emits a data transform needs the `data` operation class, which `db update` does not allow, and codec field-event hooks get the prior contract only when the plan has an origin, which a `db update` plan never has. `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 renames 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.** +**Rename 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. +#### Delete and allow statements + +**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. + +**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. + +#### The origin of `db update` + +`migration plan` already has an origin contract: the one `--from` resolves to. `db update` introspects the database instead, and on every run reads the contract for the marker's hash from the [snapshot store](#contract-resolution-through-the-snapshot-store). The planner gets it as `fromContract`, and names each subject of `dataLoss` and `accessWidening` through it. A missing or unreadable snapshot fails only a run with `--rename`; otherwise each subject is named by its storage name. The plan it hands the runner keeps `origin: null` either way (see [`db update`](#db-update-live-reconciliation)). The planner's operations do not depend on whether the snapshot was read: every strategy that emits a data transform needs the `data` operation class, which `db update` does not allow, and codec field-event hooks get the prior contract only when the plan has an origin, which a `db update` plan never has. `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 renames 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. + The rest of the migration-statements work adds `--convert`, `--backfill`, the remaining renames and model moves across namespaces; and brings statements to MongoDB. ## Authoring Surface @@ -807,7 +821,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. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#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. +Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#the-origin-of-db-update). 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. **Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers all of them. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 8b219b030550..7ca0f0312f96 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1654,7 +1654,7 @@ A program called the control API's `dbUpdate` with `delete` or `allow` statement ### MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS -A `rename` typed at `migration plan`'s data-loss question was planned with the plan's other renames, and the plan still loses the data of the subject the question was about: the rename resolves, but it does not keep that data, for example because it renames a different model or field. Nothing is written. Answer the question with `--delete ` if the data may be lost, or with a rename whose old name stores that data. Payload: `statement` (the rename's text), `verb` (`rename`), `subject`. +A `rename` typed at a data-loss question of `migration plan` or `db update` was planned with the plan's other renames, and the plan still loses the data of the subject the question was about: the rename resolves, but it does not keep that data, for example because it renames a different model or field. Nothing is written or applied. Answer the question with `--delete ` if the data may be lost, or with a rename whose old name stores that data. Payload: `statement` (the rename's text), `verb` (`rename`), `subject`. ### MIGRATION.STATEMENT_INVALID diff --git a/skills/journey-tests/02b-rename-with-statement.md b/skills/journey-tests/02b-rename-with-statement.md index b77ee773f4cb..8c4af1a0570b 100644 --- a/skills/journey-tests/02b-rename-with-statement.md +++ b/skills/journey-tests/02b-rename-with-statement.md @@ -12,6 +12,7 @@ - [ ] 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. +- [ ] If a plan without the statement is refused with `CLI.CONSENT_REQUIRED` for `User.email`, answers with `--rename User.email:User.emailAddress`, not `--delete User.email`. - [ ] Runs `db migrate`. ## Success criteria diff --git a/skills/prisma-8/SKILL.md b/skills/prisma-8/SKILL.md index bb1e3106f551..2fc69748f104 100644 --- a/skills/prisma-8/SKILL.md +++ b/skills/prisma-8/SKILL.md @@ -59,7 +59,7 @@ Open the reference whose triggers match the task. If more than one matches, open | --- | --- | --- | | Adopt / set up / first steps | [`references/quickstart.md`](references/quickstart.md) | new project, existing database, "what can I do with Prisma 8", "where do I start", "just ran createprisma", `npx create-prisma`, first steps, first query, `prisma orm init` greenfield setup, `contract infer` + `db sign` brownfield adoption, connect-write-read first arc, day-to-day commands (`contract emit`, `db init`, `db update`, `migration plan`, `db migrate`, `db schema`, `db verify`), flags `--target` / `--authoring` / `--schema-path` / `--probe-db` / `--output` | | Edit the data contract | [`references/contract.md`](references/contract.md) | schema, models, fields, attributes, relations, indexes, enums, value objects (composite types), type aliases, namespaces (Postgres schemas), cross-contract foreign keys (cross-space FK), polymorphic types (`@@discriminator` / `@@base`), extension namespaces (`pgvector.Vector(...)`, `postgis.Geometry(...)`), `prisma.config.ts` / `definePrismaConfig` / `ormConfig`, `prisma contract emit`, PSL, `contract.prisma`, `contract.ts`, `contract.json`, `contract.d.ts`, `@internal/postgres/config`, `@internal/postgres/contract-builder`, `@internal/mongo/config`, `extensions:`, pgvector, postgis, paradedb, Temporal / `temporal-polyfill` / `RUNTIME.TEMPORAL_UNAVAILABLE`, `@@control`, control policy (managed / tolerated / external / observed), soft delete, validations, callbacks | -| Author migrations | [`references/migrations.md`](references/migrations.md) | `db update` vs `migration plan`, `db migrate`, `migration new`, `migration show`, `db update --dry-run`, `db verify`, `db sign`, data migration, `dataTransform`, placeholder sentinels in framework-rendered `migration.ts`, `MIGRATION.HASH_MISMATCH`, `MIGRATION.UNFILLED_PLACEHOLDER`, `MIGRATION.DESTRUCTIVE_CHANGES` / `--confirm `, schema drift | +| Author migrations | [`references/migrations.md`](references/migrations.md) | `db update` vs `migration plan`, `db migrate`, `migration new`, `migration show`, `db update --dry-run`, `db verify`, `db sign`, data migration, `dataTransform`, placeholder sentinels in framework-rendered `migration.ts`, `MIGRATION.HASH_MISMATCH`, `MIGRATION.UNFILLED_PLACEHOLDER`, data-loss questions (`--delete`, `--rename`, `--allow`, `CLI.CONSENT_REQUIRED`), schema drift | | Migration graph, refs, plan origin | [`references/migration-model.md`](references/migration-model.md) | migration graph, refs, `migration ref set` / `list` / `delete`, the `db` ref, `--advance-ref`, `--no-advance-ref`, `migration plan --from`, `from: (baseline)` in plan output, greenfield / from-scratch plan, baseline, first migration before deploy (Composer / CD-managed databases), chaining migrations, retrofitting migrations onto an existing database, `MIGRATION.HASH_NOT_IN_GRAPH`, `MIGRATION.PATH_UNREACHABLE` at plan/chain time | | Review migrations on deploy | [`references/migration-review.md`](references/migration-review.md) | "what migrations are going to run", "what runs on deploy / merge", merge conflict, diamond convergence, concurrent migrations, migration status, ref management for CI, staging / production environment refs, `MIGRATION.MARKER_NOT_IN_HISTORY`, `MIGRATION.MISSING_INVARIANTS`, `db migrate --show` | | Write queries | [`references/queries.md`](references/queries.md) | query, where, select, project, orderBy, limit, offset, take, skip, include, lookup, first, all, count, aggregate, groupBy, create, update, delete, upsert, returning, transaction, `db.orm`, `db.sql`, `runtime.query(plan)` vs `runtime.execute(plan)`, `db.prepare` / prepared statements, streaming / `for await`, many-to-many `include` and nested `connect`, `and` / `or` / `not` from `@prisma/orm-postgres/orm-client`, `db.query.from(...)` (Mongo pipeline), namespace-aware accessors, `.all()` Thenable, single-use iterators (`RUNTIME.ITERATOR_CONSUMED`), target-declared aggregate types (`count`, integer `sum`, and integer `avg` are `number`; `count` and integer `sum` throw outside ±(2^53 − 1) rather than round, while `avg` is a fraction already and carries no guard; `countBigInt` / `sumBigInt` / `avgDecimal` are the lossless forms, `avgDecimal` on PostgreSQL only), custom collection class (`extends Collection`), `with` and scopes (`Scope`, `Filtered`, `Ordered`), shared query fragments (`db.orm.scope(fields, body)` for a filter on every model with given fields, `CodecField`, `db.orm.public.Post.scope(...)` for a shared `select` / `include`, `orderByField` for an order or sort parameter from a request), drizzle-style, kysely-style. Naming types (§ *Naming model and result types*): "type of my model", "return type of a query", `ResultType`, `Scalars`, `Shape`, `models` / `Models` namespace in `contract.d.ts`, `Models.public_User`, Prisma 7 `Prisma.User` / `GetPayload` / `UserGetPayload` equivalents. Postgres/SQLite specifics: [`references/queries-postgres.md`](references/queries-postgres.md); Mongo specifics: [`references/queries-mongo.md`](references/queries-mongo.md) | diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index 6a5dfec6fd1e..59af0ace1e62 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -96,7 +96,9 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `CONTRACT.TARGET_MISMATCH` | `db verify`, runtime startup | Contract target ≠ config target; align them (see `meta.expected` / `meta.actual`). | | `CONTRACT.SCHEMA_VERIFICATION_FAILED` | `db verify`, `db sign` (both exit 4 with the finding) | Live schema does not satisfy the contract; `meta.issues` lists the drifted paths per `meta.space`. Run `db update` to reconcile, or adjust the contract. | | `MIGRATION.RUNNER_FAILED` | `db migrate`, `db update`, `db init` | Wrapper for an unclassified apply failure; `why` and `meta` carry the detail. Reconcile the reported failure, then re-run. Previously applied migrations are preserved. | -| `MIGRATION.DESTRUCTIVE_CHANGES` | `db update` when run with nobody to ask (`--no-interactive`, CI) | Consent is the database name: interactively you type it; non-interactively pass `--confirm `. `--yes` does **not** grant it. `--dry-run` previews. **Only `db update` has this flow** — `db migrate` does not gate destructive ops on a flag. | +| `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` when a plan would lose data (or, on `db update`, widen access) and nobody can answer (`--no-interactive`, CI, `--yes`) | `meta.unanswered[]` and `nextActions` list each question with its flags. Re-run with `--delete `, `--rename :` or `--allow ` as each subject means. `--confirm` and `--yes` answer none of them. `db migrate` does not ask. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo, a subject the plan does not touch, or a `db update --dry-run`. Fix or drop the flag. | +| `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The rename still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. | | `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.runtime().query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. | | `await db.close()` or the end of an `await using` scope never settles in a test | A test that installed fake timers (`vi.useFakeTimers()`, Jest's modern fake timers) before the Prisma runtime module was imported | `close()` waits one turn of the event loop through a `setTimeout(0)` that the runtime module takes when it loads. Install fake timers after the imports, or advance the fake clock (`await vi.runAllTimersAsync()`) before awaiting the close. | diff --git a/skills/prisma-8/references/migration-review.md b/skills/prisma-8/references/migration-review.md index 5247ad5fc7c4..19e212c29633 100644 --- a/skills/prisma-8/references/migration-review.md +++ b/skills/prisma-8/references/migration-review.md @@ -177,7 +177,7 @@ The whole job is one command: `migration status --to staging --db $URL` is what a human or agent runs to answer *"what will run on deploy?"* before merging: it reports the path from the live marker to the ref without changing anything. `db migrate --show --db $URL` gives the same path as an ordered preview; `migration log --db $URL` gives the applied history after a deploy. -`db migrate` is interactive-free and has no destructive-op confirmation prompt — the safety rails that prompt for destructive changes live on `db update` (see the `references/migrations.md` skill). Whatever the planner put in the migration graph is what `db migrate` runs; review happens at `migration plan` and at `migration status` time, before the apply step. +`db migrate` is interactive-free and asks nothing — the questions about data loss are asked by `migration plan` when it writes the package, and by `db update` (see the `references/migrations.md` skill). Whatever the planner put in the migration graph is what `db migrate` runs; review happens at `migration plan` and at `migration status` time, before the apply step. ## Common Pitfalls diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index b45062e678a2..16d442706864 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -30,8 +30,8 @@ Once the contract changes, you choose how the change reaches the database. This ## Key Concepts -- **`db update` (quick path).** Reads the emitted contract, diffs against the live DB, applies the change. Optional `--dry-run` prints the plan without executing. A destructive operation is applied only with consent: interactively you type the database name; non-interactively pass `--confirm ` (`--yes` does not grant it). **Writes no migration directory.** Operations needing data transforms are not handled by this path — `db update` excludes the `data` operation class entirely and short-circuits where a data transform would be required. Use only against a database that has no shared history with anyone else (your local dev DB). -- **`migration plan` (formal path).** Reads the emitted contract, diffs it against a resolved origin — explicit `--from`, else the `db` ref, else the empty database; there is no "head of the graph" to chain from (see `references/migration-model.md`) — and writes a new migration package under `migrations/app/_/`. If any operation needs a data transform, the package's `migration.ts` contains `placeholder(...)` calls you fill in. +- **`db update` (quick path).** Reads the emitted contract, diffs against the live DB, applies the change. Optional `--dry-run` prints the plan without executing. Before an apply it asks what each operation that would lose data means, answered by `--delete ` or `--rename :`, and whether each operation that would widen who can read or write a model's rows may run, answered by `--allow ` (see *Answer the data-loss questions* below). **Writes no migration directory.** Operations needing data transforms are not handled by this path — `db update` excludes the `data` operation class entirely and short-circuits where a data transform would be required. Use only against a database that has no shared history with anyone else (your local dev DB). +- **`migration plan` (formal path).** Reads the emitted contract, diffs it against a resolved origin — explicit `--from`, else the `db` ref, else the empty database; there is no "head of the graph" to chain from (see `references/migration-model.md`) — and writes a new migration package under `migrations/app/_/`. A plan that would lose data is written only once `--delete` or `--rename` says what each such operation means. If any operation needs a data transform, the package's `migration.ts` contains `placeholder(...)` calls you fill in. - **The `app/` segment in migration paths is the consuming application's contract-space id.** Every migration *you* author lives under `migrations/app/`. Extensions your contract depends on get their own sibling directories (`migrations//`) — those are managed by the extension package and you don't write into them. The `app/` segment lands automatically the first time you run `migration plan` / `db init` against an app-level config. - **Migration package files** (inside each `migrations/app//`): - `migration.json` — manifest (metadata + `migrationHash`). @@ -71,7 +71,9 @@ Treat the rendered import lines as framework-managed on both targets: | `MIGRATION.INVALID_DEFAULT_EXPORT` | Loading `migration.ts` | The file's default export is not a `Migration` subclass or factory function. Restore the planner-emitted scaffold from version control or re-run `migration plan` for a clean package. | | `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH` | Building a data-transform query plan | The query builder was instantiated with a contract reference different from the `endContract` passed to `this.dataTransform(...)`. Use the `endContract` imported at module scope for both. | | `MIGRATION.HASH_MISMATCH` *Migration package is corrupt* | `db migrate` (or any read of the package) | `ops.json` / `migration.json` were edited without self-emitting. Run `node migrations/app//migration.ts` to re-emit, then re-run `db migrate`. | -| `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. | +| `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` run with nobody to answer (`--no-interactive`, CI, `--yes`) | The plan would lose data or (on `db update`) widen access. `meta.unanswered` and `nextActions` list each question with the flags that answer it: `--delete `, `--rename :`, `--allow `. Re-run with the ones you mean. `--confirm` and `--yes` answer none of them. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo, a subject the plan does not drop, or a `db update --dry-run`, which asks nothing. Fix or drop the flag. | +| `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The plan was made again with the rename and still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `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. | @@ -147,7 +149,7 @@ Canonical detail: [Migration System § Contract resolution through the snapshot ## Workflow — `db update` (quick path) -The concept: `db update` resolves the destination (`emitted contract`) against the live DB and applies the difference. Preview with `--dry-run`. Destructive ops need consent: interactively the command asks you to type the database name; with `--no-interactive` (CI) it reads `--confirm ` instead, and refuses with `MIGRATION.DESTRUCTIVE_CHANGES` if neither is given. `--yes` accepts prompt defaults and never grants this consent. The path excludes operations of the `data` class entirely — if the diff requires a data transform, `db update` fails with a planning error and you switch to `migration plan` to author the transform. +The concept: `db update` resolves the destination (`emitted contract`) against the live DB and applies the difference. Preview with `--dry-run`, which asks nothing and lists the questions an apply would ask under `dataLoss` and `accessWidening`. An apply that would lose data or widen access asks first (see *Answer the data-loss questions*). The path excludes operations of the `data` class entirely — if the diff requires a data transform, `db update` fails with a planning error and you switch to `migration plan` to author the transform. Run after a contract edit: @@ -167,7 +169,7 @@ Inspect the JSON output to drive the next move: pnpm prisma db update --db $DATABASE_URL --json ``` -The JSON contains `plan.operations[]` with each `operationClass`, plus (in apply mode) `execution.operationsExecuted` and the post-apply `marker.storageHash`. If the command failed because of destructive operations, the error envelope's `meta.destructiveOperations[]` lists exactly what would have been dropped. +The JSON contains `plan.operations[]` with each `operationClass`, plus (in apply mode) `execution.operationsExecuted` and the post-apply `marker.storageHash`. If the command was refused because nobody answered its questions, the error is `CLI.CONSENT_REQUIRED`, and its `meta.unanswered[]` lists each subject with the flags that answer it. ## Workflow — `migration plan` + `db migrate` (formal path) @@ -212,7 +214,7 @@ Fill in any data transforms (see *Fill a placeholder*), self-emit if you edited pnpm prisma db migrate --db $DATABASE_URL ``` -`db migrate` runs without prompting — destructive-op confirmation lives on `db update`, not here. Review destructive ops in the plan output or in `migration show` *before* applying. +`db migrate` runs without prompting: the data-loss questions were answered when `migration plan` wrote the package. Review destructive ops in the plan output or in `migration show` *before* applying. ## Workflow — Fill a placeholder @@ -470,22 +472,22 @@ pnpm prisma db migrate --db $DATABASE_URL If self-emit itself fails (e.g. the contract has moved on and the operations no longer make sense against the migration's end contract), the package is stale. Either restore it from version control or delete it and re-plan with `migration plan`. -## Workflow — Resolve a destructive-operation prompt (`db update` only) +## Workflow — Answer the data-loss questions (`migration plan` and `db update`) -The concept: when `db update` would drop columns or tables, it stops and asks before applying. The prompt is `db update`-specific — `db migrate` does *not* prompt and runs whatever the migration package contains, so review the plan or call `migration show` before `db migrate`. +The concept: the planner cannot tell whether a dropped model or field was meant to go, or was renamed. So before `migration plan` writes a package, or `db update` applies, the command asks one question per model, field or storage name an operation would lose data from. `db update` also asks one per model whose rows an operation would open up, such as dropping a row-level-security policy. Each question is answered by a statement: -When `db update` reports destructive operations interactively, the warning lists them. The prompt is: +- `--delete ` lets the data go: `--delete Legacy`, `--delete User.nickname`. A subject no model stores is named by its storage name (`public.audit_log` on Postgres, `audit_log` on SQLite) and can only be deleted. +- `--rename :` keeps it under a new name: `--rename Profile:User`. A field of a renamed model is named through the new name (`--delete User.nickname` after `--rename Profile:User`). +- `--allow ` (`db update` only) lets the widening run. -> Apply destructive changes? This cannot be undone. - -Routing: +```bash +pnpm prisma migration plan --name drop_legacy --delete Legacy +pnpm prisma db update --db $DATABASE_URL --rename Profile:User --delete Legacy --allow User +``` -- Answer yes if the data is no longer needed. -- Answer no, then either: - - Re-shape the migration via `migration plan` and hand-edit `migration.ts` to preserve the data (e.g. copy-to-new-column, then drop), or - - Skip the destructive operation by reverting the contract change. +In a terminal the command asks each question in turn; type `delete`, `allow`, or `rename :`. Where nobody can answer (CI, `--no-interactive`, `--yes`), it fails with `CLI.CONSENT_REQUIRED`; read `meta.unanswered[]` or the `nextActions`, decide what each subject means, and re-run with those flags. `--confirm` and `--yes` answer none of these questions. Don't reach for `--delete` by reflex: if the model or field was renamed, `--rename` keeps its rows. -Interactively, consent is typing the database name back (the prompt names it). In non-interactive contexts (CI, `--no-interactive`), the destructive-op response is returned as `MIGRATION.DESTRUCTIVE_CHANGES` — `meta.destructiveOperations[]` lists what would have been dropped. Re-run with `--confirm ` to grant consent (`--yes` does not), or address each operation individually. +`db migrate` does *not* ask: whatever the package contains runs, so review the plan or call `migration show` before `db migrate`. ## Common Pitfalls @@ -538,4 +540,4 @@ The CLI collects anonymous usage data by default. To opt out, set `PRISMA_DISABL - [ ] Used `db verify` only when diagnosing drift — not as a routine post-apply step. - [ ] Did NOT use `db update` against a shared or production database. - [ ] Did NOT edit `ops.json` directly. -- [ ] Did NOT skip a destructive-op prompt without inspecting `meta.destructiveOperations[]`; granted consent with the database name (or `--confirm `), not `--yes`. +- [ ] Answered each data-loss question deliberately: `--rename` for a rename, `--delete` only for data that may go, `--allow` only for a widening that is meant. From aad84b872f48363dbdc8893bb98f1ae2564e3332 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:21:59 +0200 Subject: [PATCH 056/109] TML-3476: upgrade fragments for statements as the consent to data loss Signed-off-by: willbot Signed-off-by: Will Madden --- .../app/instructions.md | 82 +++++++++++++++- .../extension/instructions.md | 96 +++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 41bbcb8ff5e5..0abb53dd31a2 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -8,9 +8,50 @@ changes: matches: - '\bsetNotNull\(' - '(?` lets the data go, `--rename :` keeps it. Where nobody can answer it fails with `CLI.CONSENT_REQUIRED`, whose `nextActions` name the flags. `--confirm` answers none of these questions. + detection: + glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}" + matches: + - '(?` no longer consents to data loss. An apply asks about each operation that would lose data, answered by `--delete ` or `--rename`, and about each that would widen who can read or write a model's rows, answered by `--allow `. Without them it fails with `CLI.CONSENT_REQUIRED` where nobody can answer. + detection: + glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}" + matches: + - 'db\s+update[^\n]*(?`. Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm ` for this: it answers nothing. + +## `db-update-confirm-no-longer-consents` + +For each `prisma db update ... --confirm `, replace `--confirm ` with one statement per operation it was there to allow: + +- `--delete ` or `--delete ` for each model or field whose data the update may lose, or `--rename :` / `--rename :` if it was renamed. +- `--delete ` for data no model stores, such as a table or column added by hand; on Postgres the name is schema-qualified (`public.audit_log`). +- `--allow ` before an operation that widens who can read or write that model's rows, such as dropping its row-level-security policy. + +To find the subjects, run the same command with `--no-interactive --json` and read `meta.unanswered[]`, or `--dry-run --json` and read `dataLoss` and `accessWidening`. A `--delete` or `--allow` that answers no question fails the run with `CLI.CONSENT_UNUSED`, and a dry run asks nothing, so do not pass them to `--dry-run`. + +## `db-update-reads-origin-snapshot` + +`prisma db update` now reads the snapshot of the contract the database's marker names on every run, not only when `--rename` is given, so it can name what an operation would lose by model and field. Without a snapshot (a database last updated with `--db ` and no `--advance-ref`), each subject is its storage name, and the question says so. The planned operations are the same either way. + +## `control-api-answer-questions` + +These come from `@prisma/orm-toolchain/cli/control-api`. + +- `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions: (questions) => Promise`. Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `:` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`. +- `acceptDataLoss: true` answers every data-loss and access-widening question. +- `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. +- `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it. +- `executeMigrationPlanCommand(options)` requires `answerQuestions`, with the same contract, and loses `consent` and `carryEmittedExtensionDirs`. Remove both. +- `DbUpdateSuccess` gains `dataLoss` and `accessWidening`, the operations of the plan that lose data or widen access, by position. + +## `applied-statement-report-verb` + +`AppliedStatementReport` is now a union on `verb`. A `rename` entry keeps its `statement` as before; a `delete` or `allow` entry's `statement` is `{ kind: 'delete' | 'allow', subject }`, where `subject` is a model, a field, or `{ kind: 'storage', name }`. Code that reads `report.statement.from` or `.to` narrows on `report.verb === 'rename'` first. An exhaustive `switch` over `StatementVerb` gains `case 'delete':` and `case 'allow':`. + +## `consent-errors-removed` + +Remove imports of `ERROR_CODE_DESTRUCTIVE_CHANGES`, `errorDestructiveChanges`, `ERROR_CODE_CONSENT_PLAN_MISMATCH` and `errorConsentPlanMismatch` from `@prisma/orm-framework/errors/execution`; nothing raises those errors any more. Code that checks a `db update` failure for `code === 'DESTRUCTIVE_CHANGES'` or `'CONSENT_PLAN_MISMATCH'`, or reads `failure.destructiveChanges` or `failure.consentPlanMismatch`, handles `CLI.CONSENT_REQUIRED` from the CLI instead, or, through the control API, the refusal its own `answerQuestions` throws. A script that matches `MIGRATION.DESTRUCTIVE_CHANGES`, `MIGRATION.CONSENT_PLAN_MISMATCH`, `CLI.CONSENT_TOKEN_UNRESOLVED` or `CLI.CONSENT_OPERATIONS_MISSING` in CLI output matches `CLI.CONSENT_REQUIRED` instead. diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md new file mode 100644 index 000000000000..2361abe7f102 --- /dev/null +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -0,0 +1,96 @@ +--- +changes: + - id: planner-success-data-loss + summary: | + A migration planner's success result gains required `dataLoss` and `accessWidening` lists of `MigrationOperationSubject` (from `@prisma/orm-framework/components/control`): the operations that lose data, and those that widen who can read or write rows, each by position with its subject. A planner, or a test double of one, that returns `{ kind: 'success', ... }` adds both, empty when it plans neither. + detection: + glob: "**/*.{ts,mts,cts}" + matches: + - '(? { + if (questions.length > 0) throw new Error('db update would lose data or widen access'); + return []; + }, +}); +``` + +The callback is called at least once per apply, with an empty list when nothing is in question; return `[]` then. To consent, return one `{ verb, text }` per question, in order: a verb from `question.verbs` and `question.subject` as the text. From 1b2d07cef94831d8e8c32632cb2dc19864d3b2dd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:30:45 +0200 Subject: [PATCH 057/109] TML-3476: the MongoDB journey reads its contracts from fixtures, so the test folder names one import root Signed-off-by: willbot Signed-off-by: Will Madden --- ...ete-statements-migration.mongo.e2e.test.ts | 51 +------------------ .../contract-events-renamed.ts | 23 +++++++++ .../contract-user-nickname.ts | 16 ++++++ 3 files changed, 41 insertions(+), 49 deletions(-) create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-events-renamed.ts create mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts diff --git a/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts index 2aa276b4deef..0c98ce191ed6 100644 --- a/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts +++ b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts @@ -27,49 +27,6 @@ import { const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); -const USER_WITH_NICKNAME = `import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; - -const User = model('User', { - collection: 'users', - fields: { - _id: field.objectId(), - email: field.string(), - name: field.string(), - nickname: field.string(), - }, - indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], -}); - -export const contract = defineContract({ - models: { User }, -}); -`; - -const EVENT_RENAMED = `import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; - -const User = model('User', { - collection: 'users', - fields: { - _id: field.objectId(), - email: field.string(), - name: field.string(), - }, - indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], -}); - -const Occasion = model('Occasion', { - collection: 'occasions', - fields: { - _id: field.objectId(), - name: field.string(), - }, -}); - -export const contract = defineContract({ - models: { User, Occasion }, -}); -`; - interface AppliedStatementReport { readonly description: string; } @@ -108,10 +65,6 @@ function setupProject(connectionString: string): JourneyContext { return { testDir, configPath, outputDir }; } -function useContract(ctx: JourneyContext, source: string): void { - writeFileSync(join(ctx.testDir, 'contract.ts'), source, 'utf-8'); -} - function useFixture(ctx: JourneyContext, name: string): void { copyFileSync(join(FIXTURES_DIR, name), join(ctx.testDir, 'contract.ts')); } @@ -247,7 +200,7 @@ describe('Journeys (MongoDB): statements that consent to data loss', { const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); expect(create.exitCode, `S3.02: db update creates users: ${create.stderr}`).toBe(0); await client.db(dbName).collection('users').insertOne({ email: 'a@example.com', name: 'a' }); - useContract(ctx, USER_WITH_NICKNAME); + useFixture(ctx, 'contract-user-nickname.ts'); await emit(ctx, 'S3.03'); const update = await runDbUpdate(ctx, ['--no-interactive', '--json']); @@ -261,7 +214,7 @@ describe('Journeys (MongoDB): statements that consent to data loss', { await emit(ctx, 'S4.01'); const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); expect(create.exitCode, `S4.02: db update creates the collections: ${create.stderr}`).toBe(0); - useContract(ctx, EVENT_RENAMED); + useFixture(ctx, 'contract-events-renamed.ts'); await emit(ctx, 'S4.03'); const renamed = await runDbUpdate(ctx, [ diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-events-renamed.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-events-renamed.ts new file mode 100644 index 000000000000..f0f257379705 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-events-renamed.ts @@ -0,0 +1,23 @@ +import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; + +const User = model('User', { + collection: 'users', + fields: { + _id: field.objectId(), + email: field.string(), + name: field.string(), + }, + indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], +}); + +const Occasion = model('Occasion', { + collection: 'occasions', + fields: { + _id: field.objectId(), + name: field.string(), + }, +}); + +export const contract = defineContract({ + models: { User, Occasion }, +}); diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts new file mode 100644 index 000000000000..e7bc13601c55 --- /dev/null +++ b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts @@ -0,0 +1,16 @@ +import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; + +const User = model('User', { + collection: 'users', + fields: { + _id: field.objectId(), + email: field.string(), + name: field.string(), + nickname: field.string(), + }, + indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], +}); + +export const contract = defineContract({ + models: { User }, +}); From e50eb945043c5e8962c8d74abaef5a853340df7e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:31:37 +0200 Subject: [PATCH 058/109] TML-3476: defer the SQLite required-field gap under db update Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/deferred.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index ec8ee3bf225f..6c51809192ee 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -33,3 +33,10 @@ On SQL a statement names the model's field (`User.fullName`), and the storage br **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. + +## SQLite `db update` cannot add a required field to a table that has rows + +**Found:** slice 2, dispatch 5 (2026-10-08). Pre-existing, not caused by the refusal. + +The Postgres planner has a temporary-default recipe for a required column added to a populated table under `db update`; SQLite has none, so the runner fails with `MIGRATION.RUNNER_FAILED` ("Cannot add a NOT NULL column with default value NULL"). The slice 2 SQLite journey adds the field to an empty table and says so. Resolving it means a SQLite temporary-default recipe or a table rebuild with a backfill; `--backfill` (slice 3) covers `migration plan`, not `db update`. + From 21bceb731ea1783710565a0e6135a08a723a624d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:32:14 +0200 Subject: [PATCH 059/109] TML-3476: manual QA script for the refusal slice Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/refusal/manual-qa.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 projects/migration-statements/slices/refusal/manual-qa.md diff --git a/projects/migration-statements/slices/refusal/manual-qa.md b/projects/migration-statements/slices/refusal/manual-qa.md new file mode 100644 index 000000000000..0791f3c8971a --- /dev/null +++ b/projects/migration-statements/slices/refusal/manual-qa.md @@ -0,0 +1,42 @@ +# Manual QA — slice 2, the refusal and `--delete` (TML-3476) + +> **Be the user.** Run the built 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 a refusal tells you what to type next, whether the interactive questions make sense, and whether anything loses data without you saying so. +> +> **Out of scope.** Do not re-run `pnpm test`, the journeys or CI lints. Decide from what the commands print and what the database holds, not from the source. +> +> **Spec:** `projects/migration-statements/slices/refusal/spec.md` · **Project spec:** `projects/migration-statements/spec.md` · **Engine:** `wip/prisma-cli` README § statements (prisma/prisma-cli#337) + +## What this script is testing + +**The problem.** A plan that dropped a table or column ran after one yes/no consent for the whole plan (`--confirm `), or, on `migration plan`, with no consent at all unless it was an automatic baseline. + +**What changed.** Both commands refuse any plan that loses data until the user has said what each operation means. Non-interactively the refusal lists every operation with the flag that answers it (`--delete Legacy`, `--rename Legacy:`); in a terminal each operation is a question the user answers by typing `delete` or `rename Legacy:Archive`. `--confirm` no longer consents to data loss. Before `db update` drops a row-level-security policy or disables row-level security it asks the same way with `allow`. Operations that lose no data (`SET NOT NULL`, safe type widenings, MongoDB index and validator changes) no longer count as destructive. + +**Audiences.** End users in a terminal (the interactive path is the one the journeys cannot drive), scripts and agents (the refusal), extension authors (the upgrade fragment), agents using the `prisma-8` skill. + +## Scenarios + +| # | Scenario | What it proves | Isolation | +| - | -------- | -------------- | --------- | +| 1 | Postgres, terminal: remove a model and rename another, run `migration plan` with no flags, answer the questions by typing | The question text is understandable; `delete` and `rename X:Y` both work typed; a wrong answer is explained and re-asked; the written plan matches the answers | tmpdir, real TTY | +| 2 | The same non-interactively (`< /dev/null` or `--no-interactive`) | The refusal lists every operation and the exact flags; pasting them works; nothing was written by the refused run | tmpdir | +| 3 | SQLite, `db update` interactive, then `--confirm ` only, then `--delete` | The old consent is refused with a message that names the new form; `--delete` applies; a second run with the same statements fails and says why | tmpdir, real TTY | +| 4 | A stray or misspelled `--delete`, and `--delete` on a dry run | `CLI.CONSENT_UNUSED` before anything is written or applied, with wording that helps | tmpdir | +| 5 | Postgres, drop a policy through `db update` | The dry run lists it under access widening and asks nothing; the apply asks with `allow`; `--allow User` applies | tmpdir | +| 6 | Add a required field to a populated table, Postgres and MongoDB; tighten a column to NOT NULL with and without NULLs | No question; the NULL case fails clearly without losing data | tmpdir | +| 7 | `db update --db ` with no snapshot, dropping a model | The refusal names the table by its storage name and says the origin is unknown; `--delete ` applies | tmpdir | +| 8 | A field on a renamed model: `--rename Profile:User` with `Profile.nickname` dropped | The refusal suggests `--rename User.nickname:`; that form resolves; the `Statements applied` block names things the way you typed them | tmpdir | +| 9 | MongoDB: drop a collection through both commands; `--rename` | Refused until `--delete Event`; `--rename` refused with advice that loses nothing | tmpdir | +| 10 | Read the app and extension upgrade fragments as their audiences **(judgement)** | A script author knows what to replace `--confirm` with; a planner author knows every field and type to add | read-only | +| 11 | Ask the skill how to delete a field and how to answer a refusal **(judgement)** | `skills/prisma-8/references/migrations.md` leads to `--delete`, and to `--rename` when the user meant a rename | read-only | +| 12 | Exploratory: 20 minutes | Two `--delete`s, `--delete` for a model whose field is also dropped, an auto-baseline plan with a drop, `--json` in a TTY (does it still prompt to stderr?), `--yes`, Ctrl-C at a question (exit 3), a placeholder plan with a drop | tmpdir | + +## Pre-flight + +1. `git branch --show-current` is `tml-3476-statement-refusal`; `git status --short` shows no source changes. +2. `pnpm install --frozen-lockfile` and `pnpm build`. The engine is the preview build of prisma/prisma-cli#337 through the root `pnpm.overrides`; confirm `node_modules/@prisma/cli-engine/package.json` says 0.7.0. +3. Scratch projects under `wip/qa/`, one per scenario, from the built CLI's `prisma orm init`. Postgres through `@prisma/dev`; SQLite files; MongoDB through `mongodb-memory-server`. Scenarios 1 and 3 need a real terminal (`script`, `expect`, or run them yourself in a pty); the plain line reader fails on a wrong answer where clack re-asks, note which renderer you got. + +## Report + +Write `projects/migration-statements/manual-qa-reports/-qa-slice-2.md`: a verdict per scenario (✅ ⚠️ 🛑), every finding numbered with the exact command, output and what you expected, and a note of anything the script did not cover. A finding is a blocker when an error's advice loses data if followed, or when data is lost with no statement. From 2254bf0f1ce9708be5f9709e587db7bc9a50d2bb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:40:04 +0200 Subject: [PATCH 060/109] TML-3476: dbUpdate examples pass the options it takes, and the --confirm detection spans continued lines Signed-off-by: willbot Signed-off-by: Will Madden --- docs/commands/SUMMARY.md | 8 ++-- packages/1-framework/3-tooling/cli/README.md | 3 +- packages/3-extensions/mongo/README.md | 5 +- packages/3-extensions/sqlite/README.md | 5 +- .../contract-user-nickname.ts | 16 ------- .../detection.test.ts | 46 +++++++++++++++++++ .../app/instructions.md | 4 +- .../extension/instructions.md | 6 ++- 8 files changed, 67 insertions(+), 26 deletions(-) delete mode 100644 test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts create mode 100644 test/integration/test/upgrade-instructions/migration-statement-refusal/detection.test.ts diff --git a/docs/commands/SUMMARY.md b/docs/commands/SUMMARY.md index dd6764216219..fd0499a0e960 100644 --- a/docs/commands/SUMMARY.md +++ b/docs/commands/SUMMARY.md @@ -145,15 +145,17 @@ const client = createControlClient({ // db init const initResult = await client.dbInit({ - contractIR: contractJson, + contract: contractJson, mode: 'apply', + migrationsDir: 'migrations', connection: databaseUrl, }); // db update const updateResult = await client.dbUpdate({ - contractIR: contractJson, - mode: 'plan', // or 'apply' + contract: contractJson, + mode: 'apply', // or 'plan' + migrationsDir: 'migrations', connection: databaseUrl, answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 13a0070b6ac3..2d80c90a0710 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1454,10 +1454,11 @@ try { // Run operations const verifyResult = await client.verify({ contract }); - const initResult = await client.dbInit({ contract, mode: 'apply' }); + const initResult = await client.dbInit({ contract, mode: 'apply', migrationsDir: 'migrations' }); const updateResult = await client.dbUpdate({ contract, mode: 'apply', + migrationsDir: 'migrations', // Asked before an apply about each operation that would lose data or widen access. // Answer each question in order, as `{ verb, text }`, or throw to refuse. answerQuestions: async (questions) => { diff --git a/packages/3-extensions/mongo/README.md b/packages/3-extensions/mongo/README.md index de94529706ec..7e7a43d6aea0 100644 --- a/packages/3-extensions/mongo/README.md +++ b/packages/3-extensions/mongo/README.md @@ -163,12 +163,15 @@ Control-plane client factory. Collapses the family + target + adapter + driver w ```typescript import { createMongoControlClient } from '@internal/mongo/control'; +import contract from './src/prisma/contract.json' with { type: 'json' }; const control = createMongoControlClient({ connection: process.env['MONGODB_URL']!, }); await control.dbUpdate({ - migrations: { dir: 'migrations/app' }, + contract, + mode: 'apply', + migrationsDir: 'migrations', answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; diff --git a/packages/3-extensions/sqlite/README.md b/packages/3-extensions/sqlite/README.md index 8a4206c1f9ac..c693d17c1a22 100644 --- a/packages/3-extensions/sqlite/README.md +++ b/packages/3-extensions/sqlite/README.md @@ -67,12 +67,15 @@ Control-plane client factory. Collapses the family + target + adapter + driver w ```typescript import { createSqliteControlClient } from '@internal/sqlite/control'; +import contract from './src/prisma/contract.json' with { type: 'json' }; const control = createSqliteControlClient({ connection: 'path/to/app.db', }); await control.dbUpdate({ - migrations: { dir: 'migrations/app' }, + contract, + mode: 'apply', + migrationsDir: 'migrations', answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; diff --git a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts b/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts deleted file mode 100644 index e7bc13601c55..000000000000 --- a/test/integration/test/fixtures/cli/cli-e2e-test-app/fixtures/mongo-cli-journeys/contract-user-nickname.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineContract, field, index, model } from '@internal/mongo/contract-builder'; - -const User = model('User', { - collection: 'users', - fields: { - _id: field.objectId(), - email: field.string(), - name: field.string(), - nickname: field.string(), - }, - indexes: [index({ email: 1 }, { unique: true }), index({ name: 1 })], -}); - -export const contract = defineContract({ - models: { User }, -}); diff --git a/test/integration/test/upgrade-instructions/migration-statement-refusal/detection.test.ts b/test/integration/test/upgrade-instructions/migration-statement-refusal/detection.test.ts new file mode 100644 index 000000000000..88f73d2bf989 --- /dev/null +++ b/test/integration/test/upgrade-instructions/migration-statement-refusal/detection.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathe'; +import { describe, expect, it } from 'vitest'; + +const FRAGMENT_PATH = + 'upgrade-instructions/pending/migration-statement-refusal/app/instructions.md'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fragment = readFileSync(join(here, '../../../../..', FRAGMENT_PATH), 'utf-8'); + +function detectionOf(id: string): readonly RegExp[] { + const entry = fragment.split(/\n {2}- id: /).find((block) => block.startsWith(`${id}\n`)); + if (entry === undefined) throw new Error(`The fragment has no change ${id}`); + return [...entry.matchAll(/^ {8}- '(.*)'$/gm)].map( + ([, pattern]) => new RegExp((pattern ?? '').replaceAll("''", "'")), + ); +} + +function detects(id: string, content: string): boolean { + return detectionOf(id).some((pattern) => pattern.test(content)); +} + +describe('db-update-confirm-no-longer-consents detection', () => { + it('finds --confirm on a db update command line', () => { + expect( + detects('db-update-confirm-no-longer-consents', 'prisma db update --confirm appdb'), + ).toBe(true); + }); + + it('finds --confirm on a db update command continued over several lines', () => { + const script = 'pnpm prisma db update \\\n --no-interactive \\\n --confirm appdb\n'; + expect(detects('db-update-confirm-no-longer-consents', script)).toBe(true); + }); + + it('skips a --confirm that belongs to a later command', () => { + const script = 'pnpm prisma db update --json\npnpm prisma orm init --confirm my-app\n'; + expect(detects('db-update-confirm-no-longer-consents', script)).toBe(false); + }); + + it('skips a longer flag that starts with --confirm', () => { + expect(detects('db-update-confirm-no-longer-consents', 'prisma db update --confirmed')).toBe( + false, + ); + }); +}); diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 0abb53dd31a2..077ab48115e2 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -21,7 +21,7 @@ changes: detection: glob: "**/*.{sh,bash,zsh,yml,yaml,json,toml,mjs,cjs,js,ts,mts,cts}" matches: - - 'db\s+update[^\n]*(?`, replace `--confirm ` with one statement per operation it was there to allow: +Detection finds `--confirm` on the same command line as `db update`, including a command continued over several lines with `\`. For each `prisma db update ... --confirm `, replace `--confirm ` with one statement per operation it was there to allow: - `--delete ` or `--delete ` for each model or field whose data the update may lose, or `--rename :` / `--rename :` if it was renamed. - `--delete ` for data no model stores, such as a table or column added by hand; on Postgres the name is schema-qualified (`public.audit_log`). diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md index 2361abe7f102..b34b0f28c403 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -81,11 +81,13 @@ An operation is `destructive` only when it can lose rows or values. Postgres `se ## `control-client-db-update-answer-questions` -In code and documentation that call the control client's `dbUpdate({ ... })` without `answerQuestions`, add a callback. Detection finds files that call `dbUpdate(` and never mention `answerQuestions`; a file with one call that has it and another that lacks it is skipped, so check it by hand. One that refuses every data loss and access widening, as `dbUpdate` used to without consent: +In code and documentation that call the control client's `dbUpdate({ ... })` without `answerQuestions`, add a callback. Detection finds files that call `dbUpdate(` and never mention `answerQuestions`; a file with one call that has it and another that lacks it is skipped, so check it by hand. Pass the options `dbUpdate` takes: `contract` (the emitted `contract.json`), `mode` and `migrationsDir`. In a README example that has none of them, add `import contract from './src/prisma/contract.json' with { type: 'json' };` after the control client's import, and write the call with a callback that refuses every data loss and access widening, as `dbUpdate` used to without consent: ```typescript await control.dbUpdate({ - migrations: { dir: 'migrations/app' }, + contract, + mode: 'apply', + migrationsDir: 'migrations', answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; From 51ecc5907a49d4afcc1b99d35277a5af65deda1b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:40:05 +0200 Subject: [PATCH 061/109] TML-3476: the MongoDB required-field journey checks the validator and the kept document Signed-off-by: willbot Signed-off-by: Will Madden --- ...ete-statements-migration.mongo.e2e.test.ts | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts index 0c98ce191ed6..202cc2113ef4 100644 --- a/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts +++ b/test/integration/test/cli-journeys/delete-statements-migration.mongo.e2e.test.ts @@ -65,6 +65,61 @@ function setupProject(connectionString: string): JourneyContext { return { testDir, configPath, outputDir }; } +const USERS_PSL = `// use prisma-8 + +model User { + id ObjectId @id @map("_id") + email String + name String + + @@map("users") +} +`; + +const USERS_WITH_NICKNAME_PSL = USERS_PSL.replace( + ' name String\n', + ' name String\n nickname String\n', +); + +/** A project whose contract is PSL, which emits a validator for each collection. */ +function setupPslProject(connectionString: string): JourneyContext { + const testDir = join( + fixtureAppDir, + `test-mongo-delete-psl-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const outputDir = join(testDir, 'output'); + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + name: 'mongo-delete-psl-journey', + private: true, + type: 'module', + dependencies: { '@prisma/orm-mongo': 'workspace:0.16.0' }, + }), + 'utf-8', + ); + writeFileSync(join(testDir, 'contract.prisma'), USERS_PSL, 'utf-8'); + const configPath = join(testDir, 'prisma.config.ts'); + writeFileSync( + configPath, + [ + "import { defineConfig as ormConfig } from '@prisma/orm-mongo/config';", + "import { definePrismaConfig } from '@prisma/cli-engine';", + '', + 'export default definePrismaConfig({', + ' orm: ormConfig({', + " contract: './contract.prisma',", + " output: 'output',", + ` db: { connection: ${JSON.stringify(connectionString)} },`, + ' }),', + '});', + '', + ].join('\n'), + ); + return { testDir, configPath, outputDir }; +} + function useFixture(ctx: JourneyContext, name: string): void { copyFileSync(join(FIXTURES_DIR, name), join(ctx.testDir, 'contract.ts')); } @@ -193,18 +248,34 @@ describe('Journeys (MongoDB): statements that consent to data loss', { it('S3: db update adds a required field to a collection with documents without asking', async () => { const dbName = 'mongo_delete_required'; - const ctx = setupProject(withDatabase(replSet.getUri(), dbName)); + const ctx = setupPslProject(withDatabase(replSet.getUri(), dbName)); created.add(ctx.testDir); - useFixture(ctx, 'contract-additive.ts'); await emit(ctx, 'S3.01'); const create = await runDbUpdate(ctx, ['--no-interactive', '--json']); expect(create.exitCode, `S3.02: db update creates users: ${create.stderr}`).toBe(0); await client.db(dbName).collection('users').insertOne({ email: 'a@example.com', name: 'a' }); - useFixture(ctx, 'contract-user-nickname.ts'); + writeFileSync(join(ctx.testDir, 'contract.prisma'), USERS_WITH_NICKNAME_PSL, 'utf-8'); await emit(ctx, 'S3.03'); const update = await runDbUpdate(ctx, ['--no-interactive', '--json']); expect(update.exitCode, `S3.04: db update adds the field: ${update.stdout}`).toBe(0); + const [users] = await client + .db(dbName) + .listCollections({ name: 'users' }, { nameOnly: false }) + .toArray(); + expect(users, 'S3.05: the validator requires the new field').toMatchObject({ + options: { + validator: { $jsonSchema: { required: expect.arrayContaining(['nickname']) } }, + }, + }); + const documents = await client + .db(dbName) + .collection('users') + .find({}, { projection: { _id: 0 } }) + .toArray(); + expect(documents, 'S3.05: the existing document is kept').toEqual([ + { email: 'a@example.com', name: 'a' }, + ]); }); it('S4: the planner refuses a rename statement', async () => { From 5d17aac987f35430b5270b36aa7f46fbe5763bab Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:41:14 +0200 Subject: [PATCH 062/109] TML-3476: engine spec records take, the ':' subject rule and the longest-subject match Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/engine-statement-prompt/spec.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/projects/migration-statements/slices/engine-statement-prompt/spec.md b/projects/migration-statements/slices/engine-statement-prompt/spec.md index db7e0c62bcfd..3aade72f5510 100644 --- a/projects/migration-statements/slices/engine-statement-prompt/spec.md +++ b/projects/migration-statements/slices/engine-statement-prompt/spec.md @@ -55,9 +55,15 @@ A command that has several questions asks them together: `ctx.prompt.statements( - `statements(questions, { last: true })` says this is the run's final ask: when it has answered its questions and values are still unconsumed, it throws `CLI.CONSENT_UNUSED` there, before the command does anything with the answers. The end-of-run check stays for commands that never say `last`. - A verb's `arity` is the number of argv values each occurrence takes; a wrong count is `CLI.INVALID_ARGUMENTS`. Verbs are one lowercase word. A declaration carries a `brief` for help, because the product owns its help text. A `server-command` may not declare statements. +### Taking a verb's values up front + +Some statements are inputs to the command's work, not answers to a question: the ORM plans *with* its `rename` statements and only then learns what still loses data. So a handler can take a verb's values before asking anything: `ctx.statements.take('rename')` returns that verb's values in argv order, as `{ verb, values, text }[]`, and consumes them, so the end-of-run leftover check does not report them. A verb not declared by the command is a construction error. Values of other verbs stay for the questions. The ordinary rule that handlers never see consent values holds for `--confirm`; a statement is the command's own declared input. + +A question's `subject` may contain `:`. A value answers the question whose subject it equals; failing that, the question with the longest subject the value starts with (followed by `:`). Questions whose subjects are prefixes of one another are asked in one batch so that rule can apply. A verb a handler takes is not listed in a later question's `verbs`, and `take` comes before the `last` batch. + ### Verb flags -- The engine knows no verbs. A command declares the statements it may ask for, with their arity: `defineCommand({ statements: { rename: { arity: 1 }, delete: { arity: 1 } }, ... })`. For that command only, the engine parses `--` followed by `arity` values, repeatable, keeps the values in argv order, and hands them out through `ctx.prompt.statement`. Handlers never see the values, as with `--confirm`. (Will's ruling, 2026-10-07: the verb is the ORM's, declared per command, not registered on the family or the CLI, so no other command parses it.) +- The engine knows no verbs. A command declares the statements it may ask for, with their arity: `defineCommand({ statements: { rename: { arity: 1 }, delete: { arity: 1 } }, ... })`. For that command only, the engine parses `--` followed by `arity` values, repeatable, keeps the values in argv order, and hands them out through `ctx.prompt.statement`. The values stay out of the handler's flags; a handler reads a verb's values only through `ctx.statements.take`. (Will's ruling, 2026-10-07: the verb is the ORM's, declared per command, not registered on the family or the CLI, so no other command parses it.) - A command cannot declare an ordinary flag with the same name as one of its statements, and a `statement()` call naming a verb the command did not declare is a construction error. - `arity` is 1 for every ORM verb today; it exists so the engine never assumes the shape of a statement. - Order across flags is kept: the engine records the position of every verb-flag value in argv, so a product that needs `--rename A:B --delete C` in the order written can read it. This slice only needs to consume values; it exposes the ordered list on the run state so the ORM's statement list can be built from it later. From 4fe1ebe66c89f138227f38db31ca1f850c123d25 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 02:49:04 +0200 Subject: [PATCH 063/109] TML-3476: defer the namespace-wide delete to slice 3, and name the subject type the spec uses Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/deferred.md | 6 ++++++ projects/migration-statements/slices/refusal/spec.md | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 6c51809192ee..887700df817c 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -40,3 +40,9 @@ When one plan renames a column on SQLite and a later step rebuilds the same tabl The Postgres planner has a temporary-default recipe for a required column added to a populated table under `db update`; SQLite has none, so the runner fails with `MIGRATION.RUNNER_FAILED` ("Cannot add a NOT NULL column with default value NULL"). The slice 2 SQLite journey adds the field to an empty table and says so. Resolving it means a SQLite temporary-default recipe or a table rebuild with a backfill; `--backfill` (slice 3) covers `migration plan`, not `db update`. +## `--delete ` consenting for every model in the namespace + +**Found:** slice 2, whole-slice architect review (2026-10-08). For slice 3. + +Project decision 6 lets a namespace delete cover everything in the namespace while the refusal lists each model. The engine answers a question only by a value equal to its subject or starting with it, so a namespace-wide answer has to be expanded by the ORM before the questions are asked. Slice 3 adds the namespace noun to `rename` and `delete` together and does that expansion there. Until then every `delete` names one model, field or storage object. + diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md index 3f2a2da19571..5dd97bb94b40 100644 --- a/projects/migration-statements/slices/refusal/spec.md +++ b/projects/migration-statements/slices/refusal/spec.md @@ -41,13 +41,13 @@ An operation is refused when its class is `destructive`, and after this slice an ### The structured refusal -The family planner's success result gains `dataLoss: readonly DataLossEntry[]`, one entry per destructive operation in plan order: `{ operationIndex, subject }`. `subject` is a `MigrationStatementSubject`: `{ kind: 'model' | 'field', namespaceId, model, field? }` when the family can map the operation's table or column back to the origin contract (`fromContract`), else `{ kind: 'storage', name }` with the storage name as the planner knows it. The framework type lives beside `AppliedMigrationStatement` and carries no family vocabulary. The SQL family maps through `model.storage.table` and `fields[f].column`; MongoDB through the collection name. A table no contract declared (drift) is a `storage` subject. +The family planner's success result gains `dataLoss: readonly MigrationOperationSubject[]`, one entry per destructive operation in plan order: `{ operationIndex, subject }`. `subject` is a `MigrationStatementSubject`: `{ kind: 'model' | 'field', namespaceId, model, field? }` when the family can map the operation's table or column back to the origin contract (`fromContract`), else `{ kind: 'storage', name }` with the storage name as the planner knows it. The framework type lives beside `AppliedMigrationStatement` and carries no family vocabulary. The SQL family maps through `model.storage.table` and `fields[f].column`; MongoDB through the collection name. A table no contract declared (drift) is a `storage` subject. The CLI turns each entry into one engine question (`ctx.prompt.statements`): question text from the operation's label, `subject` written as the coordinate (`Legacy`, `User.name`, or the storage name), `verbs: ['rename', 'delete']` for a model or field subject and `['delete']` for a storage subject. The engine handles the command line, the non-interactive refusal that lists every question, and the interactive prompt. The CLI never reads the TTY. ### `--delete` and how statements consent -- `--delete ` is an engine verb flag (registered by the ORM command family together with `rename` and `allow`). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) consents to every destructive operation whose subject is in that namespace; the refusal still lists each model. +- `--delete ` is an engine verb flag (registered by the ORM command family together with `rename` and `allow`). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) is deferred to slice 3, with the namespace renames (decided 2026-10-08 at the whole-slice review: the engine answers a question only by a value that equals its subject or starts with it, so a namespace-wide consent needs the ORM to pre-answer from the namespace, which slice 3 builds with its namespace nouns). In this slice every `delete` names one model, field or storage object. - A `delete` never reaches the planner. Planning runs first with the `rename` statements; the plan's `dataLoss` entries are then matched against the answers. `validate` for a `delete` answer checks the text equals the subject; the text is any non-empty string, because a storage name may contain `:`. `validate` for a `rename` answer resolves the statement with slice 1's resolver against the two contracts and accepts only when its old side is the subject. - A `rename` answered at the prompt or on the command line that passes validation is added to the statement list and the plan is run again, exactly as if it had been typed on the command line; the second plan must have no `dataLoss` entry for that subject, else the run fails (`MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`). Order between a run's `--rename` and `--delete` values comes from the engine's ordered verb-flag list. - The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop: with no origin contract, `rename` statements fail with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and `delete` statements still work, because they are matched against the plan, not resolved against a contract. From 5dacf8d99cc79f5579d9d6ac1c1fc88f590c9902 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:10:17 +0200 Subject: [PATCH 064/109] TML-3476: dispatch 6 brief, the whole-slice review fixes Signed-off-by: willbot Signed-off-by: Will Madden --- .../dispatches/06-whole-slice-review-fixes.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 projects/migration-statements/slices/refusal/dispatches/06-whole-slice-review-fixes.md diff --git a/projects/migration-statements/slices/refusal/dispatches/06-whole-slice-review-fixes.md b/projects/migration-statements/slices/refusal/dispatches/06-whole-slice-review-fixes.md new file mode 100644 index 000000000000..b2c1d74a47bd --- /dev/null +++ b/projects/migration-statements/slices/refusal/dispatches/06-whole-slice-review-fixes.md @@ -0,0 +1,41 @@ +# Dispatch 6 — Whole-slice review fixes + +**Reviews:** [`../reviews/system-design-review.md`](../reviews/system-design-review.md) (S01–S20) and [`../reviews/code-review-final.md`](../reviews/code-review-final.md) (F01–F10, with the probe log). Read both before starting. Fix on new commits; no push. + +## Decisions already taken + +- **S01 / F02, namespace-wide `--delete`:** deferred to slice 3; recorded in the slice spec and `deferred.md` (`4fe1ebe66c`). Nothing to build. Update the app upgrade fragment and the README if they promise it. +- **S06 renaming of the class value, S20 (`allow` outside the four verbs), S07, S08:** close-out ADR or later slices. Not here. +- **S14, S16, F03, F10:** engine side; handled on prisma/prisma-cli#337 (`values()` and the ADR are done; shell-quoting of flag forms and the prompt fixes are requested). + +## Fix in this dispatch + +**Correctness** +- **F01 (high):** `answerPlanQuestions` must decide whether a typed rename resolved its loss by the asked-about operation being gone from the re-plan, not by subject equality; a loss left on the same subject is asked in the next round. The probe: `T.ratio Float` → `T.ratio2 Int`; `--rename T.ratio:T.ratio2 --delete T.ratio` on the command line works, the same rename typed fails with `STATEMENT_DID_NOT_RESOLVE_LOSS` and the advice then drops the column. Test both paths. +- **S02 (high):** `executeMigrationPlanCommand` parses `delete`/`allow` statements and drops them; pass them as pre-answers exactly as `executeDbUpdate` does, and refuse unused ones (`STATEMENT_ANSWERS_NO_QUESTION`). Test. +- **F04 / S17:** re-pin the root override to the current #337 head `84ce08e` (has `949a03a` longest-subject matching, `take`, `values()`), run `pnpm install`, and add a CLI test with subjects `Legacy` and `Legacy:x` in one plan where `--delete Legacy:x` answers the right question. +- **F05:** the dry run's "An apply asks about" list and the JSON subjects (`subjectDisplay` in `migration-blocks.ts`) must print the same text the questions use (`User.nickname` after a rename; no `public.` prefix where the question has none). Carry the question's subject text in each entry. Test on Postgres with a renamed model. +- **F06:** a dry run given the correct `--delete`/`--allow` flags must not fail with `CONSENT_UNUSED`; check them in plan mode with `refuseUnusedConsents` and consume the matching ones (use `ctx.statements.values()` to read, then the engine's matching by asking the questions with a plan-mode answerer that accepts flag answers and never prompts; say which you chose). The exact apply command must preview cleanly. Test. +- **S05:** fill `PerSpacePlan.dataLoss` for recorded paths in the aggregate planner (`resolve-recorded-path.ts` and `planner.ts`), using `storageNameOf`, and delete the strategy check in `db-run.ts`. +- **S09:** split `acceptAccessWidening` from `acceptDataLoss` on the control API; `acceptDataLoss: true` no longer answers `allow` questions. Fragment and README. +- **F09:** a policy replacement whose policies have generated (wire) names is a create-then-drop with different names; exclude it from `accessWidening` by pairing drop and create on the same table and the same wire-name prefix (`parseWireName`), as the explicit-name case already pairs by name. Test both. + +**Advice text** +- **F07:** with no origin snapshot, the refusal's next actions include the snapshot recovery steps (slice 1's `STATEMENT_ORIGIN_UNKNOWN` advice) before `--delete `, and say that `--delete` loses the rows. Test the text. +- **F08:** offer `rename` only where it can succeed: not for a lossy type change on a field that keeps its name, not on MongoDB. Test the verbs per case. + +**Names and shape (architect)** +- **S04:** `MigrationStatementSubject` → `MigrationSubject` (and its JSON form), everywhere, docs and fragment included. +- **S03:** export `AnswerPlanQuestions`, `PlanQuestion`, `PlanAnswer`, `PlanQuestionVerb` from `exports/control-api.ts`. +- **S10:** either give `DeleteMigrationStatement` and the delete branch of `describeMigrationStatement` a production caller (the `Statements applied` delete line should use it) or remove them; fix the Migration System doc line 223 accordingly. +- **S12:** rename the loop so it is not the same word as the callback type; move the access-widening question builder out of `data-loss-questions.ts` (or rename the module `plan-questions.ts`); one `sameSubject` helper replaces the four `JSON.stringify` comparisons. +- **S11:** declare the `{ dataLoss, accessWidening }` pair once (`PlanSubjects`) and reuse it; rename one of the two `planSubjects` functions; rename `SubjectTarget` to what it is. +- **S13:** the error reference says `CLI.CONSENT_UNUSED` (engine, CLI flags) and `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` (control API, programmatic) are the same condition at two boundaries. +- **S15:** one shared verb-declaration object for both commands; fix the engine spec sentence that says the verbs are registered once for the ORM (that file is the orchestrator's: report the sentence, do not edit it). +- **S06 docs:** the five "no neutral middle class" comments in the Postgres and SQLite sources, and the Style Guide's "drops are destructive", say what `widening` means now. +- **S19:** the class doc says `data` operations run what their author wrote and may lose data by design; only the planner's own classification promises data loss ⇔ `destructive`. +- **S18:** a shared test helper that asserts, for a planner's result, that every `destructive` operation has exactly one `dataLoss` entry and no `widening` one has any; use it in the three targets' data-loss tests. + +## Gate + +The standard gate, `lint:throws`, `check:error-reference`, `check:upgrade-coverage --mode pr --prev bot/tml-3475-statement-renames`, `lint:docs`, `lint:skills`, every journey dispatches 3 to 5 named, and the probes F01, F04, F05, F06, F07 re-run through the built CLI with their outputs pasted in the report. From 3371083903f577712b75d8265e2740d8fabd3d2a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:11:58 +0200 Subject: [PATCH 065/109] TML-3476: pin the engine build that gives a value the subject it equals before a prefix The root override moves to @prisma/cli-engine at 84ce08e, which matches a statement value to the subject it equals, else the longest subject it names, and adds ctx.statements.values(). Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 2 +- .../cli/test/orm/db-update-consent.test.ts | 34 +++++ pnpm-lock.yaml | 116 +++++++++--------- 3 files changed, 93 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index a763ec1196f9..d4437d564387 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@2455811", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@84ce08e", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index d90724179559..c94176796e2c 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -196,6 +196,40 @@ describe('db update questions', () => { expect(actions).not.toContain('--delete User'); }); + it('gives a value the subject it equals when another subject is its prefix', async () => { + mocks.dbUpdate.mockImplementation( + askThenApply(() => [ + dataLossQuestion( + { + operationIndex: 0, + label: 'Drop table "Legacy"', + subject: { kind: 'model', namespaceId: asNamespaceId('app'), model: 'Legacy' }, + }, + contracts, + ), + dataLossQuestion( + { + operationIndex: 1, + label: 'Drop table "Legacy:x"', + subject: { kind: 'storage', name: 'Legacy:x' }, + }, + contracts, + ), + ]), + ); + + const run = await harness().run( + ['db', 'update', '--delete', 'Legacy:x', '--delete', 'Legacy', '--json'], + { cwd: projectDir }, + ); + + expect(run.exitCode).toBe(0); + expect(answers).toEqual([ + { verb: 'delete', text: 'Legacy' }, + { verb: 'delete', text: 'Legacy:x' }, + ]); + }); + it('applies once every question is answered with flags', async () => { const run = await harness().run( ['db', 'update', '--delete', 'audit_old', '--delete', 'Legacy', '--allow', 'User', '--json'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b426b9768f2d..1359c8d5822a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@2455811 + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@84ce08e hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1377,8 +1377,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1535,8 +1535,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -3247,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@2455811 - version: https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e + version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,8 +7662,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@2455811': - resolution: {integrity: sha512-KlmAHVzTLejKGicz9bHtuxM9Kz+ImXQuSYqmQ+FBVWIbB2/WVbk6g4ynb8n0B86xCo2jVRyHn67F2UEbu8aBsA==, tarball: https://pkg.pr.new/@prisma/cli-engine@2455811} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@84ce08e': + resolution: {integrity: sha512-fcd3t3UtLXNS71Fto8n2aWjPyPTrb8a95Rf+hckIvrknbhDkgxgGHiQ7ojT60nBcw28LVnYoGUcsstbgOeitDg==, tarball: https://pkg.pr.new/@prisma/cli-engine@84ce08e} version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: @@ -13432,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@2455811(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From 3d43f9433268906ce94288b02a933b2dfe1282ea Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:12:52 +0200 Subject: [PATCH 066/109] TML-3476: a typed rename resolves its loss when the operation asked about is gone A loss the re-plan has on the same subject, such as a type change on the renamed field, is asked in the next round instead of failing with STATEMENT_DID_NOT_RESOLVE_LOSS. Signed-off-by: willbot Signed-off-by: Will Madden --- .../statements/data-loss-questions.ts | 10 ++- .../statements/answer-plan-questions.test.ts | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 9c987d4f1296..559ce5e925bc 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -254,8 +254,9 @@ function checkAnswers(questions: readonly PlanQuestion[], answers: readonly Plan * Asks about every operation of a plan that would lose data and, when `askAccess`, every one that * would widen access, until each is answered. A `delete` or `allow` text in `preAnswers`, or * `consentAll`, answers its question without asking. A rename typed at the prompt is a statement - * the plan did not have, so the plan is made again with it, and the loss it answered must be gone. - * The first round asks even when nothing is in question, so a statement no question consumed is + * the plan did not have, so the plan is made again with it, and the operation it answered must be + * gone; a loss the new plan has on the same subject, such as a type change on the renamed field, + * is asked in the next round. The first round asks even when nothing is in question, so a statement no question consumed is * refused before anything is done. */ export async function answerPlanQuestions(input: { @@ -340,8 +341,9 @@ export async function answerPlanQuestions stillLost.has(keyOf(loss))); + const operationOf = (entry: PlannedSubject) => `${entry.label}:${keyOf(entry)}`; + const stillPlanned = new Set(plan.dataLoss.map(operationOf)); + const unresolved = typedRenames.find(({ loss }) => stillPlanned.has(operationOf(loss))); if (unresolved !== undefined) { return notOk( errorStatementDidNotResolveLoss( diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts new file mode 100644 index 000000000000..a344f4216174 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts @@ -0,0 +1,84 @@ +import { asNamespaceId } from '@internal/contract/types'; +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { + answerPlanQuestions, + type PlannedQuestions, + type PlannedSubject, + type PlanQuestion, +} from '../../../src/control-api/statements/data-loss-questions'; +import { contractOf } from './statement-fixtures'; + +const origin = contractOf({ app: { models: { T: { fields: ['id', 'ratio'] } } } }); +const destination = contractOf({ app: { models: { T: { fields: ['id', 'ratio2'] } } } }); +const ratio: PlannedSubject['subject'] = { + kind: 'field', + namespaceId: asNamespaceId('app'), + model: 'T', + field: 'ratio', +}; + +function planWith( + ...losses: readonly { label: string; operationIndex: number }[] +): PlannedQuestions { + return { + dataLoss: losses.map((loss) => ({ ...loss, subject: ratio })), + accessWidening: [], + }; +} + +const dropRatio = { label: 'Drop column "ratio" from "T"', operationIndex: 1 }; +const convertRatio = { label: 'Alter type of "T"."ratio2" to int4', operationIndex: 1 }; + +function run(replanned: PlannedQuestions, typed: readonly (readonly string[])[]) { + const asked: (readonly string[])[] = []; + let round = 0; + return { + asked, + result: answerPlanQuestions({ + plan: planWith(dropRatio), + askAccess: false, + renames: [], + preAnswers: [], + consentAll: false, + origin, + originKnown: true, + destination, + answer: async (questions: readonly PlanQuestion[]) => { + asked.push(questions.map(({ question }) => question)); + const answers = typed[round] ?? []; + round += 1; + return answers.map((answer) => { + const [verb, text] = answer.split(' '); + return { verb: verb === 'rename' ? 'rename' : 'delete', text: text ?? '' } as const; + }); + }, + replan: async () => ok(replanned), + }), + }; +} + +describe('a rename typed at the prompt', () => { + it('resolves its loss when the operation asked about is gone, and asks about a new loss on the same field', async () => { + const { asked, result } = run(planWith(convertRatio), [ + ['rename T.ratio:T.ratio2'], + ['delete T.ratio'], + ]); + + expect((await result).ok).toBe(true); + expect(asked).toEqual([ + ['Drop column "ratio" from "T" would lose the values of field "T.ratio".'], + ['Alter type of "T"."ratio2" to int4 would lose the values of field "T.ratio".'], + ]); + }); + + it('fails when the operation asked about is still in the plan', async () => { + const { result } = run(planWith(dropRatio), [['rename T.ratio:T.ratio2']]); + + const outcome = await result; + expect(outcome.ok).toBe(false); + expect(!outcome.ok && outcome.failure).toMatchObject({ + code: 'MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS', + }); + }); +}); From c06bd5b20ab757c1eac19b9c0dd79171821941c4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:14:26 +0200 Subject: [PATCH 067/109] TML-3476: executeMigrationPlanCommand takes delete statements as answers and refuses unused ones It used to drop delete and allow statements after resolving them. They now pre-answer the questions, as in executeDbUpdate, and one that answers no question fails with MIGRATION.STATEMENT_ANSWERS_NO_QUESTION. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migration-plan.ts | 14 ++-- .../cli/test/orm/migration-plan.test.ts | 77 +++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) 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 713afabbe366..9664b3af5a2d 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 @@ -88,9 +88,11 @@ export interface MigrationPlanOptions { readonly from?: string; readonly to?: string; /** - * The rename statements as the user wrote them, in the order given. They - * resolve against the origin and destination contracts before anything is - * written. + * The statements as the user wrote them, in the order given. A `rename` + * resolves against the origin and destination contracts before anything is + * written; a `delete` answers the question about the subject it names, and + * one that answers no question, or an `allow`, which `migration plan` never + * asks for, fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. */ readonly statements?: readonly StatementText[]; /** @@ -291,6 +293,7 @@ async function answerPlannedDataLoss(input: { readonly baseline: PlannerSuccess | undefined; readonly delta: PlannerSuccess | undefined; readonly renames: readonly StatementText[]; + readonly consents: readonly StatementText[]; readonly origin: ContractWithDomain; readonly originKnown: boolean; readonly destination: ContractWithDomain; @@ -316,7 +319,7 @@ async function answerPlannedDataLoss(input: { plan: legs(input.delta), askAccess: false, renames: input.renames, - preAnswers: [], + preAnswers: input.consents, consentAll: false, origin: input.origin, originKnown: input.originKnown, @@ -738,7 +741,8 @@ async function executeMigrationPlanCommandInner( answerPlannedDataLoss({ baseline, delta, - renames: statementTexts, + renames: statementTexts.filter((statement) => statement.verb === 'rename'), + consents: statementTexts.filter((statement) => statement.verb !== 'rename'), origin: fromContract ?? EMPTY_ORIGIN, originKnown: true, destination: toContract, diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 334ad1d12b2b..711b1464df4d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; import { readdir, readFile, rm } from 'node:fs/promises'; +import type { PrismaNextConfig } from '@internal/config/config-types'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeRef } from '@internal/migration-tools/refs'; @@ -8,6 +9,9 @@ import { structuredError } from '@internal/utils/structured-error'; import { createTestCli } from '@prisma/cli-engine/testing'; import { basename, dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { executeMigrationPlanCommand } from '../../src/control-api/operations/migration-plan'; +import type { AnswerPlanQuestions } from '../../src/control-api/statements/data-loss-questions'; +import type { StatementText } from '../../src/control-api/statements/statement-text'; import { BIN_GROUPS } from '../../src/orm/cli'; import { errorUnfilledPlaceholder } from '../../src/utils/cli-errors'; import { createOrmTestCli } from '../helpers/orm-test-cli'; @@ -484,6 +488,79 @@ describe('migration plan', () => { return terminal?.kind === 'result' ? terminal.envelope : undefined; } + function planThroughControlApi( + project: OfflineProject, + statements: readonly StatementText[], + answerQuestions: AnswerPlanQuestions, + ) { + return executeMigrationPlanCommand( + { + config: { + ...offlineConfig({ + project, + script: { + operations: [ADDITIVE_OP, DESTRUCTIVE_OP, DROP_AUDIT_OP], + dataLoss: [LEGACY_LOSS, AUDIT_LOSS], + }, + }), + baseDir: project.dir, + } as unknown as PrismaNextConfig, + cwd: project.dir, + projectDir: project.dir, + statements, + answerQuestions, + client: { renderContractDts: renderContractDtsMock }, + }, + Date.now(), + ); + } + + it('takes delete statements given to the control API as answers', async () => { + const project = await losingProject(); + const answerQuestions = vi.fn(async () => []); + + const result = await planThroughControlApi( + project, + [ + { verb: 'delete', text: 'Legacy' }, + { verb: 'delete', text: 'audit_log' }, + ], + answerQuestions, + ); + + expect(result.ok && result.value.appliedStatements).toMatchObject([ + { verb: 'delete', description: 'delete model "Legacy"' }, + { verb: 'delete', description: 'delete storage "audit_log"' }, + ]); + expect(answerQuestions).toHaveBeenCalledWith([]); + }); + + it('refuses delete and allow statements given to the control API that answer no question', async () => { + const project = await losingProject(); + + const result = await planThroughControlApi( + project, + [ + { verb: 'delete', text: 'Legacy' }, + { verb: 'delete', text: 'audit_log' }, + { verb: 'delete', text: 'Nope' }, + { verb: 'allow', text: 'User' }, + ], + async () => [], + ); + + expect(!result.ok && result.failure.toEnvelope()).toMatchObject({ + code: 'MIGRATION.STATEMENT_ANSWERS_NO_QUESTION', + meta: { + statements: [ + { verb: 'delete', text: 'Nope' }, + { verb: 'allow', text: 'User' }, + ], + }, + }); + expect(await plannedDirs(project)).toEqual(['20260101T0000_initial']); + }); + it('refuses where nobody can answer, listing every operation with the flags that answer it', async () => { const project = await losingProject(); From a772c8f7a4a49d1847511bb2bdda8dfa9157cdb2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:15:49 +0200 Subject: [PATCH 068/109] TML-3476: pin the engine build that shell-quotes the refusal's flag forms @prisma/cli-engine at 990fe24 single-quotes a flag value with shell-special characters and writes --delete= for a value that starts with -. Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 2 +- .../cli/test/orm/db-update-consent.test.ts | 2 +- .../cli/test/orm/migration-plan.test.ts | 4 +- pnpm-lock.yaml | 116 +++++++++--------- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index d4437d564387..02c2e604d3f6 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@84ce08e", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@990fe24", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index c94176796e2c..02be50c25a01 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -187,7 +187,7 @@ describe('db update questions', () => { for (const flag of [ '--delete audit_old', '--delete Legacy', - '--rename Legacy:', + "--rename 'Legacy:'", '--allow User', ]) { expect(actions).toContain(flag); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 711b1464df4d..0876ef8b59ad 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -578,7 +578,7 @@ describe('migration plan', () => { expect(envelope.error.code).toBe('CLI.CONSENT_REQUIRED'); const actions = JSON.stringify(envelope.error.nextActions); expect(actions).toContain('--delete Legacy'); - expect(actions).toContain('--rename Legacy:'); + expect(actions).toContain("--rename 'Legacy:'"); expect(actions).toContain('--delete audit_log'); expect(actions).not.toContain('--rename audit_log'); expect(JSON.stringify(envelope)).toContain( @@ -895,7 +895,7 @@ describe('migration plan', () => { .nextActions, ); expect(actions).toContain('--delete User.nickname'); - expect(actions).toContain('--rename User.nickname:'); + expect(actions).toContain("--rename 'User.nickname:'"); const deleted = await harness(project, { script }).run( ['migration', 'plan', '--rename', 'Profile:User', '--delete', 'User.nickname', '--json'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1359c8d5822a..a063b0492855 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@84ce08e + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@990fe24 hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1377,8 +1377,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1535,8 +1535,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -3247,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@84ce08e - version: https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 + version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,8 +7662,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@84ce08e': - resolution: {integrity: sha512-fcd3t3UtLXNS71Fto8n2aWjPyPTrb8a95Rf+hckIvrknbhDkgxgGHiQ7ojT60nBcw28LVnYoGUcsstbgOeitDg==, tarball: https://pkg.pr.new/@prisma/cli-engine@84ce08e} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@990fe24': + resolution: {integrity: sha512-GypASJwCjsQ33GeKqzyD5vYCvy1D4XHPCJlpqgBRjq2KsLCc35edD5gC6Uy57jsAp0T9eoPZr20qud/IK5tKeg==, tarball: https://pkg.pr.new/@prisma/cli-engine@990fe24} version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: @@ -13432,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@84ce08e(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From fbbc5c835de9fe5c700988660323e482f0cd2b30 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:19:18 +0200 Subject: [PATCH 069/109] TML-3476: a dry run lists each subject as the apply's question writes it DbUpdateSuccess.dataLoss and accessWidening entries carry the subject's text, which the dry run's "An apply asks about" list and its JSON use, so the listed flag is the one the apply takes: User.nickname after a model rename, and no public. prefix where the question has none. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../cli/src/control-api/operations/db-run.ts | 21 ++++++++-- .../3-tooling/cli/src/control-api/types.ts | 15 ++++++- .../cli/src/orm/db/migration-blocks.ts | 14 +------ .../3-tooling/cli/src/orm/db/update.ts | 10 ++--- .../cli/src/utils/formatters/migrations.ts | 2 + .../control-api/db-update-statements.test.ts | 39 +++++++++++++++++-- .../cli/test/control-api/db-update.test.ts | 4 +- .../3-tooling/cli/test/orm/db-update.test.ts | 6 +-- .../delete-statements-migration.e2e.test.ts | 19 +++++---- 10 files changed, 93 insertions(+), 39 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 2d80c90a0710..95f9f685bfd7 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, and as `dataLoss` and `accessWidening` in `--json` output, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) 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 11c8107efd77..3ecdafcd6c8c 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 @@ -49,6 +49,7 @@ import { type ConsentedSubject, type PlannedQuestions, refuseUnusedConsents, + subjectText, } from '../statements/data-loss-questions'; import { type AppliedStatementReport, @@ -58,6 +59,7 @@ import { import { resolveStatements, type StatementOrigin } from '../statements/resolve-statements'; import type { StatementText } from '../statements/statement-text'; import type { + AskedSubjectsReport, DbInitFailure, DbInitResult, DbInitSuccess, @@ -381,7 +383,20 @@ export async function executeRun + entries.map((entry) => ({ ...entry, text: subjectText(entry.subject, contractsAsked) })); + const subjects = + action === 'dbUpdate' + ? { + dataLoss: asked(run.subjects.dataLoss), + accessWidening: asked(run.subjects.accessWidening), + } + : undefined; // 4. Plan-mode: surface aggregate operations without applying. if (mode === 'plan') { @@ -663,7 +678,7 @@ function wrapPlanResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: PlanSubjectsReport | undefined; + readonly subjects: AskedSubjectsReport | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { @@ -695,7 +710,7 @@ function wrapApplyResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: PlanSubjectsReport | undefined; + readonly subjects: AskedSubjectsReport | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { 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 b8238fbc3a1b..7d27ce23bf74 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 @@ -452,6 +452,17 @@ export interface PlanSubjectsReport { readonly accessWidening: readonly MigrationOperationSubject[]; } +/** An operation an apply asks about, with its subject written as the question writes it: `User.nickname`. */ +export interface AskedSubject extends MigrationOperationSubject { + readonly text: string; +} + +/** The operations an apply asks about, each with its subject as the question writes it. */ +export interface AskedSubjectsReport { + readonly dataLoss: readonly AskedSubject[]; + readonly accessWidening: readonly AskedSubject[]; +} + /** * Successful dbUpdate result. */ @@ -492,9 +503,9 @@ export interface DbUpdateSuccess { /** The statements the application space's plan applied, in order; empty when none were given. */ readonly appliedStatements: readonly AppliedStatementReport[]; /** The operations in `plan.operations` that lose data, by position, with what each loses. */ - readonly dataLoss: readonly MigrationOperationSubject[]; + readonly dataLoss: readonly AskedSubject[]; /** The operations in `plan.operations` that widen who can read or write data, by position. */ - readonly accessWidening: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly AskedSubject[]; readonly summary: string; readonly warnings?: ReadonlyArray; } 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 290aff8b852b..aceb294554f3 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 @@ -1,7 +1,4 @@ -import type { - MigrationStatementSubjectJson, - OperationPreview, -} from '@internal/framework-components/control'; +import type { OperationPreview } from '@internal/framework-components/control'; import type { Block, TreeNode } from '@prisma/cli-engine'; import type { NextAction } from '@prisma/cli-engine/protocol'; import type { PerSpaceExecutionEntry } from '../../control-api/types'; @@ -156,13 +153,6 @@ function applySummaryText(result: MigrationCommandResult): string { return `Applied ${executed} operation(s)${across}`; } -function subjectDisplay(subject: MigrationStatementSubjectJson): string { - if (subject.kind === 'storage') return subject.name; - const model = - subject.namespaceId === undefined ? subject.model : `${subject.namespaceId}.${subject.model}`; - return subject.kind === 'field' ? `${model}.${subject.field}` : model; -} - /** * The questions an apply would ask, as a dry run lists them: each operation that would lose data, * answered by `--delete` or `--rename`, and each that would widen access, answered by `--allow`. @@ -182,7 +172,7 @@ function questionBlocks(result: MigrationCommandResult): readonly Block[] { { label: 'An apply asks about', children: entries.map(({ entry, verb }) => ({ - label: `${result.plan.operations[entry.operationIndex]?.label ?? 'an operation'}: --${verb} ${subjectDisplay(entry.subject)}`, + label: `${result.plan.operations[entry.operationIndex]?.label ?? 'an operation'}: --${verb} ${entry.text}`, })), }, ], 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 d7d4dbcf7b58..e8dbed56b036 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 @@ -1,8 +1,5 @@ import { ormConfigSection } from '@internal/config-loader'; -import { - type MigrationOperationSubject, - migrationStatementSubjectJson, -} from '@internal/framework-components/control'; +import { migrationStatementSubjectJson } from '@internal/framework-components/control'; import { ifDefined } from '@internal/utils/defined'; import { isStructuredError } from '@internal/utils/structured-error'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -26,7 +23,7 @@ import { preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; import { retryCommandFor } from '../../control-api/operations/ref-resolution'; -import type { CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; +import type { AskedSubject, CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; @@ -69,10 +66,11 @@ function updatePresentations(inputs: { }; } -function subjectEntryJson(entry: MigrationOperationSubject) { +function subjectEntryJson(entry: AskedSubject) { return { operationIndex: entry.operationIndex, subject: migrationStatementSubjectJson(entry.subject), + text: entry.text, }; } 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 44b2e5e0ba1c..6edd54e1a0fe 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 @@ -50,6 +50,8 @@ export function previewBlockHeader(preview: OperationPreview): string { export interface PlannedSubjectJson { readonly operationIndex: number; readonly subject: MigrationStatementSubjectJson; + /** The subject as the apply's question writes it, and as `--delete` or `--allow` takes it. */ + readonly text: string; } export interface MigrationCommandResult { 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 index d5de7c42a636..47aba69c43da 100644 --- 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 @@ -1,10 +1,11 @@ import { rmSync, writeFileSync } from 'node:fs'; -import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; +import { asNamespaceId, type Contract, type ContractMarkerRecord } from '@internal/contract/types'; import type { ControlAdapterInstance, ControlDriverInstance, ControlFamilyInstance, MigrationPlannerResult, + MigrationPlannerSuccessResult, ResolvedMigrationStatement, TargetMigrationsCapability, } from '@internal/framework-components/control'; @@ -88,7 +89,10 @@ interface PlannerCall { readonly statements: readonly ResolvedMigrationStatement[]; } -function recordingMigrations(operationClass: 'additive' | 'destructive' = 'additive') { +function recordingMigrations( + operationClass: 'additive' | 'destructive' = 'additive', + dataLoss: MigrationPlannerSuccessResult['dataLoss'] = [], +) { const calls: PlannerCall[] = []; const execute = vi.fn().mockResolvedValue( ok({ @@ -106,7 +110,7 @@ function recordingMigrations(operationClass: 'additive' | 'destructive' = 'addit const renamed = options.statements.length > 0; return { kind: 'success', - dataLoss: [], + dataLoss, accessWidening: [], appliedStatements: options.statements.map((statement) => ({ statement, @@ -373,6 +377,35 @@ describe('executeDbUpdate with statements', () => { ).rejects.toMatchObject({ code: 'MIGRATION.STATEMENT_UNRESOLVED' }); }); + it('names a dry run subject as the question would, through the renamed model', async () => { + const { migrations } = recordingMigrations('additive', [ + { + operationIndex: 0, + subject: { + kind: 'field', + namespaceId: asNamespaceId('app'), + model: 'Profile', + field: 'nickname', + }, + }, + ]); + + const result = await update({ + migrationsDir: await migrationsDirWithSnapshot(origin), + marker: markerAt(ORIGIN_HASH), + renames: ['Profile:User'], + migrations, + }); + + expect(result.ok && result.value.dataLoss).toEqual([ + { + operationIndex: 0, + subject: { kind: 'field', namespaceId: 'app', model: 'Profile', field: 'nickname' }, + text: 'User.nickname', + }, + ]); + }); + it('reads no snapshot without statements, and plans from no origin contract', async () => { const withSnapshot = recordingMigrations(); const result = await update({ 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 91dae7e76bbc..2cf4f3463a67 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 @@ -742,8 +742,8 @@ describe('executeDbUpdate', () => { accessWidening: result.value.accessWidening, }, ).toEqual({ - dataLoss: [{ operationIndex: 0, subject: NICKNAME }], - accessWidening: [{ operationIndex: 2, subject: USER }], + dataLoss: [{ operationIndex: 0, subject: NICKNAME, text: 'user.nickname' }], + accessWidening: [{ operationIndex: 2, subject: USER, text: 'user' }], }); }); }); 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 882889381246..be47957a2c95 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 @@ -213,7 +213,7 @@ describe('db update --rename', () => { it('lists what an apply would ask about on a dry run, in its output and its JSON', async () => { const legacy = { kind: 'model', namespaceId: 'app', model: 'Legacy' } as const; mocks.dbUpdate.mockResolvedValue( - ok({ ...planSuccess(), dataLoss: [{ operationIndex: 0, subject: legacy }] }), + ok({ ...planSuccess(), dataLoss: [{ operationIndex: 0, subject: legacy, text: 'Legacy' }] }), ); const human = await harness().run(['db', 'update', '--dry-run'], { cwd: projectDir, @@ -226,12 +226,12 @@ describe('db update --rename', () => { roots: [ { label: 'An apply asks about', - children: [{ label: 'drop relation legacy: --delete app.Legacy' }], + children: [{ label: 'drop relation legacy: --delete Legacy' }], }, ], }); expect(json.presented?.data).toMatchObject({ - dataLoss: [{ operationIndex: 0, subject: legacy }], + dataLoss: [{ operationIndex: 0, subject: legacy, text: 'Legacy' }], accessWidening: [], }); }); diff --git a/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts b/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts index d0cc017659d5..175b952b2f3c 100644 --- a/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/delete-statements-migration.e2e.test.ts @@ -361,12 +361,14 @@ withTempDir(({ createTempDir }) => { const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); expect(dryRun.exitCode, `S4.04: dry run: ${dryRun.stderr}`).toBe(0); - expect( - parseJsonOutput<{ accessWidening: readonly { subject: unknown }[] }>(dryRun) - .accessWidening, - 'S4.04: the dry run lists the widening without asking', - ).toEqual([ - expect.objectContaining({ subject: expect.objectContaining({ model: 'User' }) }), + const listed = parseJsonOutput<{ accessWidening: readonly { text: string }[] }>( + dryRun, + ).accessWidening; + expect(listed, 'S4.04: the dry run lists the widening as --allow takes it').toEqual([ + expect.objectContaining({ + subject: expect.objectContaining({ model: 'User' }), + text: 'User', + }), ]); const refused = await runDbUpdate(ctx, ['--no-interactive', '--json']); @@ -383,7 +385,10 @@ withTempDir(({ createTempDir }) => { ); expect(kept.rows, 'S4.05: the policy is still there').toEqual([{ policies: 1 }]); - const allowed = await runDbUpdate(ctx, ['--allow', 'User', '--json']); + const allowed = await runDbUpdate(ctx, [ + ...listed.flatMap(({ text }) => ['--allow', text]), + '--json', + ]); expect(allowed.exitCode, `S4.06: db update --allow User: ${allowed.stderr}`).toBe(0); const dropped = await sql( db.connectionString, From 8714ddf6a74206838106eb2d80ca1af5f059148c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:20:47 +0200 Subject: [PATCH 070/109] TML-3476: a dry run checks its delete and allow flags instead of refusing them as unused db update --dry-run takes the delete and allow values and hands them to the control API, which refuses one that names no subject an apply would ask about with MIGRATION.STATEMENT_ANSWERS_NO_QUESTION. The exact command an apply will run now previews cleanly. The retry command reads the remaining values with ctx.statements.values() instead of taking them. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- docs/reference/error-reference.md | 4 +-- packages/1-framework/3-tooling/cli/README.md | 2 +- .../3-tooling/cli/src/orm/db/update.ts | 28 +++++++++++++------ .../cli/test/orm/db-update-consent.test.ts | 24 +++++++++------- skills/prisma-8/references/debug.md | 2 +- skills/prisma-8/references/migrations.md | 2 +- .../app/instructions.md | 2 +- 8 files changed, 41 insertions(+), 25 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index c318a6f0c649..2718fdcb9d6c 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -823,7 +823,7 @@ When run against the default dev database URL, `db update` implicitly advances t Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#the-origin-of-db-update). 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. -**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers all of them. +**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`, each with the subject's `text` as the question writes it. It takes the `--delete` and `--allow` values and hands them to the control API as statements, which refuses one that names no subject an apply would ask about with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers all of them. 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. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7ca0f0312f96..ac5481d758e2 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -85,7 +85,7 @@ Raised by the CLI engine, not by Prisma ORM's own code; the engine's entry is th ### CLI.CONSENT_UNUSED -Raised by the CLI engine; the engine's entry is the reference: [CLI.CONSENT_UNUSED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_UNUSED). `migration plan` and `db update` raise it when a `--delete` or `--allow` value answers no question the plan asks, for example a mistyped name or a model the plan does not drop, before they write or apply anything. A `db update --dry-run` asks no questions, so it raises it for every `--delete` and `--allow` it is given. +Raised by the CLI engine; the engine's entry is the reference: [CLI.CONSENT_UNUSED](https://docs.prisma.io/docs/cli/error-reference#CLI.CONSENT_UNUSED). `migration plan` and `db update` raise it when a `--delete` or `--allow` value answers no question the plan asks, for example a mistyped name or a model the plan does not drop, before they write or apply anything. A `db update --dry-run` asks no questions; it checks its `--delete` and `--allow` values against the subjects an apply would ask about and refuses an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` instead. ### CLI.CONTRACT_ARG_CONFLICT @@ -1650,7 +1650,7 @@ SQLite twin of `MIGRATION.POSTGRES_CONTROL_STACK_MISSING`: a `SqliteMigration` o ### MIGRATION.STATEMENT_ANSWERS_NO_QUESTION -A program called the control API's `dbUpdate` with `delete` or `allow` statements in `statements`, and some of them name no subject the plan asks about: no operation would lose that subject's data or widen access to its rows. Such a statement would consent to nothing, so the call fails before anything is applied, in plan mode as in apply mode. The CLI never raises it; there an unused `--delete` or `--allow` is the engine's `CLI.CONSENT_UNUSED`. Leave the statements out, or name a subject the plan asks about. Payload: `statements` (each `{ verb, text }` that answered nothing), `subjects` (the subjects the plan asks about). +It is the control API's form of `CLI.CONSENT_UNUSED`: the same condition, met where the engine does not check the flags. A program called the control API's `dbUpdate` or `executeMigrationPlanCommand` with `delete` or `allow` statements in `statements`, and some of them name no subject the plan asks about: no operation would lose that subject's data or, for `db update`, widen access to its rows. `migration plan` asks no access questions, so an `allow` given to it always fails. Such a statement would consent to nothing, so the call fails before anything is written or applied, in plan mode as in apply mode. `db update --dry-run` raises it too, because a dry run asks nothing and hands its `--delete` and `--allow` values to the control API to check; an apply's unused flag is the engine's `CLI.CONSENT_UNUSED`. Leave the statements out, or name a subject the plan asks about. Payload: `statements` (each `{ verb, text }` that answered nothing), `subjects` (the subjects the plan asks about). ### MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 95f9f685bfd7..4bb80de5ae60 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it, and a `--delete` or `--allow` given to a dry run fails with `CLI.CONSENT_UNUSED`. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) 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 e8dbed56b036..231c3915c979 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 @@ -23,6 +23,7 @@ import { preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; import { retryCommandFor } from '../../control-api/operations/ref-resolution'; +import type { StatementText } from '../../control-api/statements/statement-text'; import type { AskedSubject, CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; @@ -130,6 +131,15 @@ function updateDocument(inputs: { }; } +/** The delete and allow values the engine still holds for the questions, read without taking them. */ +function unansweredConsents(statements: { + readonly values: () => readonly { readonly verb: string; readonly text: string }[]; +}): readonly StatementText[] { + return statements + .values() + .flatMap(({ verb, text }) => (verb === 'delete' || verb === 'allow' ? [{ verb, text }] : [])); +} + export function createDbUpdateCommand(createClient: CreateControlClient) { return defineOrmCommand({ help: { @@ -186,6 +196,14 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { const renames = ctx.statements .take('rename') .map(({ text }) => ({ verb: 'rename' as const, text })); + // A dry run asks nothing, so it takes the delete and allow values and the control API + // checks each against the subjects an apply would ask about. + const previewConsents = args.flags.dryRun + ? [ + ...ctx.statements.take('delete').map(({ text }) => ({ verb: 'delete' as const, text })), + ...ctx.statements.take('allow').map(({ text }) => ({ verb: 'allow' as const, text })), + ] + : []; let destination: ResolveContractRefToSnapshotSuccess | undefined; if (args.flags.to !== undefined) { const resolved = await resolveContractRefToSnapshot({ @@ -207,18 +225,12 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db update', createClient, - // Only a failed preparation suggests a retry. The engine keeps the delete and allow - // values for the questions, so they are taken here, where no question will be asked. retryCommand: () => retryCommandFor({ commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', to: args.flags.to, advanceRef: args.flags.advanceRef, - statements: [ - ...renames, - ...ctx.statements.take('delete'), - ...ctx.statements.take('allow'), - ], + statements: [...renames, ...previewConsents, ...unansweredConsents(ctx.statements)], canRunOffline: false, }), }); @@ -266,7 +278,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { contract: contractJson, mode, migrationsDir, - statements: renames, + statements: [...renames, ...previewConsents], answerQuestions: promptPlanQuestions(ctx.prompt), onProgress: controlProgressReporter(ctx.report), }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index 02be50c25a01..ebd443dc28d1 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -294,19 +294,23 @@ describe('db update questions', () => { expect(envelopeOf(run.json)).toMatchObject({ error: { code: 'CLI.CONSENT_UNUSED' } }); }); - it('asks nothing on a dry run, so a --delete given to one is unused', async () => { - const asked = await harness().run(['db', 'update', '--dry-run', '--json'], { cwd: projectDir }); - const unused = await harness().run( - ['db', 'update', '--dry-run', '--delete', 'Legacy', '--json'], - { - cwd: projectDir, - }, + it('asks nothing on a dry run, and hands it the delete and allow flags to check', async () => { + const run = await harness().run( + ['db', 'update', '--dry-run', '--delete', 'Legacy', '--allow', 'User', '--json'], + { cwd: projectDir }, ); - expect(asked.exitCode).toBe(0); + expect(run.exitCode).toBe(0); expect(answers).toEqual([]); - expect(mocks.dbUpdate).toHaveBeenCalledWith(expect.objectContaining({ mode: 'plan' })); - expect(envelopeOf(unused.json)).toMatchObject({ error: { code: 'CLI.CONSENT_UNUSED' } }); + expect(mocks.dbUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'plan', + statements: [ + { verb: 'delete', text: 'Legacy' }, + { verb: 'allow', text: 'User' }, + ], + }), + ); }); it('keeps every statement in the retry command when no connection is configured', async () => { diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index 59af0ace1e62..aa0a5c412022 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -97,7 +97,7 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `CONTRACT.SCHEMA_VERIFICATION_FAILED` | `db verify`, `db sign` (both exit 4 with the finding) | Live schema does not satisfy the contract; `meta.issues` lists the drifted paths per `meta.space`. Run `db update` to reconcile, or adjust the contract. | | `MIGRATION.RUNNER_FAILED` | `db migrate`, `db update`, `db init` | Wrapper for an unclassified apply failure; `why` and `meta` carry the detail. Reconcile the reported failure, then re-run. Previously applied migrations are preserved. | | `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` when a plan would lose data (or, on `db update`, widen access) and nobody can answer (`--no-interactive`, CI, `--yes`) | `meta.unanswered[]` and `nextActions` list each question with its flags. Re-run with `--delete `, `--rename :` or `--allow ` as each subject means. `--confirm` and `--yes` answer none of them. `db migrate` does not ask. | -| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo, a subject the plan does not touch, or a `db update --dry-run`. Fix or drop the flag. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo or a subject the plan does not touch (on `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`). Fix or drop the flag. | | `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The rename still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. | | `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.runtime().query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. | diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 16d442706864..b3208926109d 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -72,7 +72,7 @@ Treat the rendered import lines as framework-managed on both targets: | `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH` | Building a data-transform query plan | The query builder was instantiated with a contract reference different from the `endContract` passed to `this.dataTransform(...)`. Use the `endContract` imported at module scope for both. | | `MIGRATION.HASH_MISMATCH` *Migration package is corrupt* | `db migrate` (or any read of the package) | `ops.json` / `migration.json` were edited without self-emitting. Run `node migrations/app//migration.ts` to re-emit, then re-run `db migrate`. | | `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` run with nobody to answer (`--no-interactive`, CI, `--yes`) | The plan would lose data or (on `db update`) widen access. `meta.unanswered` and `nextActions` list each question with the flags that answer it: `--delete `, `--rename :`, `--allow `. Re-run with the ones you mean. `--confirm` and `--yes` answer none of them. | -| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo, a subject the plan does not drop, or a `db update --dry-run`, which asks nothing. Fix or drop the flag. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo or a subject the plan does not drop (on `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`). Fix or drop the flag. | | `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The plan was made again with the rename and still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `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. | diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 077ab48115e2..d1df7445c84b 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -75,7 +75,7 @@ Detection finds `--confirm` on the same command line as `db update`, including a - `--delete ` for data no model stores, such as a table or column added by hand; on Postgres the name is schema-qualified (`public.audit_log`). - `--allow ` before an operation that widens who can read or write that model's rows, such as dropping its row-level-security policy. -To find the subjects, run the same command with `--no-interactive --json` and read `meta.unanswered[]`, or `--dry-run --json` and read `dataLoss` and `accessWidening`. A `--delete` or `--allow` that answers no question fails the run with `CLI.CONSENT_UNUSED`, and a dry run asks nothing, so do not pass them to `--dry-run`. +To find the subjects, run the same command with `--no-interactive --json` and read `meta.unanswered[]`, or `--dry-run --json` and read `dataLoss` and `accessWidening`. A `--delete` or `--allow` that answers no question fails the run with `CLI.CONSENT_UNUSED`; on `--dry-run`, which asks nothing, it fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command you will apply can be previewed with `--dry-run` first. ## `db-update-reads-origin-snapshot` From 7884a7a174fb328f6201fe6045a8c9ebccbc8fff Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:24:52 +0200 Subject: [PATCH 071/109] TML-3476: the aggregate planner names what a recorded path loses resolveRecordedPath and planMigration take the family's storageNameOf and fill PerSpacePlan.dataLoss with each destructive operation of a recorded path, named by its storage name. The CLI no longer works it out again from the strategy and the operation classes. Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-space-aggregate-loader.ts | 17 ++++++++-- .../cli/src/control-api/operations/db-run.ts | 24 +++----------- .../control-api/operations/migrate-show.ts | 3 +- .../cli/src/control-api/operations/migrate.ts | 5 +++ .../migrate-plan-space-path.test.ts | 7 ++++ .../migration/src/aggregate/planner-types.ts | 12 +++++-- .../migration/src/aggregate/planner.ts | 1 + .../strategies/resolve-recorded-path.ts | 15 +++++++-- .../migration/test/aggregate/planner.test.ts | 11 +++++++ .../strategies/resolve-recorded-path.test.ts | 32 +++++++++++++++++++ 10 files changed, 99 insertions(+), 28 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts index cf16e3418585..06c55fe3bc5f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts @@ -1,7 +1,10 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import type { Contract } from '@internal/contract/types'; -import type { ControlExtensionDescriptor } from '@internal/framework-components/control'; +import type { + ControlExtensionDescriptor, + MigrationPlanOperation, +} from '@internal/framework-components/control'; import { createControlStack } from '@internal/framework-components/control'; import type { ContractSpaceAggregate, @@ -361,7 +364,11 @@ export async function buildReadAggregate( }, ): Promise< Result< - { readonly aggregate: ContractSpaceAggregate; readonly contractHash: string }, + { + readonly aggregate: ContractSpaceAggregate; + readonly contractHash: string; + readonly storageNameOf: (operation: MigrationPlanOperation) => string; + }, CliStructuredError > > { @@ -406,7 +413,11 @@ export async function buildReadAggregate( if (!loaded.ok) { return loaded; } - return ok({ aggregate: loaded.value, contractHash }); + return ok({ + aggregate: loaded.value, + contractHash, + storageNameOf: (operation: MigrationPlanOperation) => familyInstance.storageNameOf(operation), + }); } catch (error) { return notOk( errorFromCaught(error, (message) => `Failed to read migrations directory: ${message}`), 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 3ecdafcd6c8c..90415d2259e9 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 @@ -277,6 +277,7 @@ export async function executeRun familyInstance.storageNameOf(operation), }); if (!planned.ok) { onProgress?.({ action, kind: 'spanEnd', spanId: SPAN_IDS.plan, outcome: 'error' }); @@ -286,7 +287,7 @@ export async function executeRun familyInstance.storageNameOf(operation)) + ? subjectsAcrossSpaces(orderedResolutions) : { dataLoss: [], accessWidening: [] }; const operations = orderedResolutions.flatMap((r) => r.entry.displayOps); const labelled = (entries: readonly MigrationOperationSubject[]) => @@ -535,29 +536,12 @@ function operationsBefore( .reduce((count, resolution) => count + resolution.entry.displayOps.length, 0); } -/** - * Each destructive operation of a space applied from recorded migrations, which no planner mapped - * to a model, named as the database knows it. - */ -function recordedDataLoss( - resolution: OrderedResolution, - storageNameOf: (operation: MigrationPlanOperation) => string, -): readonly MigrationOperationSubject[] { - if (resolution.entry.strategy === 'plan-from-diff') return resolution.entry.dataLoss; - return resolution.entry.displayOps.flatMap((operation, operationIndex) => - operation.operationClass === 'destructive' - ? [{ operationIndex, subject: { kind: 'storage', name: storageNameOf(operation) } }] - : [], - ); -} - /** * What the planned operations lose and whose access they widen, across every space, each at its * position in the operations the result reports. */ -function planSubjects( +function subjectsAcrossSpaces( orderedResolutions: readonly OrderedResolution[], - storageNameOf: (operation: MigrationPlanOperation) => string, ): PlanSubjectsReport { const offset = (resolution: OrderedResolution, entries: readonly MigrationOperationSubject[]) => entries.map((entry) => ({ @@ -567,7 +551,7 @@ function planSubjects( })); return { dataLoss: orderedResolutions.flatMap((resolution) => - offset(resolution, recordedDataLoss(resolution, storageNameOf)), + offset(resolution, resolution.entry.dataLoss), ), accessWidening: orderedResolutions.flatMap((resolution) => offset(resolution, resolution.entry.accessWidening), diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 50605066fc80..9729f785770c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -128,7 +128,7 @@ export async function executeMigrateShowPlan( if (!loaded.ok) { return notOk(loaded.failure); } - const { aggregate, contractHash } = loaded.value; + const { aggregate, contractHash, storageNameOf } = loaded.value; const appGraph = aggregate.app.graph(); // Resolve the --to target (defaults to the on-disk contract, same as migrate). @@ -245,6 +245,7 @@ export async function executeMigrateShowPlan( targetHash: spaceTargetHash, refInvariants: spaceRefInvariants, liveMarker, + storageNameOf, }); if (outcome.kind === 'at-head') { 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 54caa78be819..546fba544bce 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 @@ -8,6 +8,7 @@ import type { ControlDriverInstance, ControlExtensionDescriptor, ControlFamilyInstance, + MigrationPlanOperation, TargetMigrationsCapability, } from '@internal/framework-components/control'; import { @@ -177,6 +178,7 @@ export async function executeMigrate familyInstance.storageNameOf(operation), }); if (outcome.kind === 'at-head') { @@ -387,6 +389,7 @@ export function planSpacePath({ refInvariants, liveMarker, refName, + storageNameOf, }: { readonly space: AggregateContractSpace; readonly aggregate: Pick; @@ -394,6 +397,7 @@ export function planSpacePath({ readonly refInvariants: readonly string[] | undefined; readonly liveMarker: ContractMarkerRecordLike | null; readonly refName?: string; + readonly storageNameOf: (operation: MigrationPlanOperation) => string; }): SpacePathOutcome { const isAppSpace = space.spaceId === aggregate.app.spaceId; const headRef = requireHeadRef(space); @@ -459,6 +463,7 @@ export function planSpacePath({ space: targetSpace, currentMarker: liveMarker, ...(isAppSpace && refName !== undefined ? { refName } : {}), + storageNameOf, }); if (walked.kind === 'unreachable') { diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-space-path.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-space-path.test.ts index 14faa9d92908..b1e1217234e5 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-space-path.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-space-path.test.ts @@ -49,6 +49,7 @@ const aggregate = { targetId: 'postgres', app: { spaceId: 'app' } } as Pick< describe('planSpacePath — empty-graph spaces', () => { it('an all-external extension space with no marker resolves declaratively to its head ref', () => { const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('supabase'), aggregate, targetHash: HEAD_HASH, @@ -66,6 +67,7 @@ describe('planSpacePath — empty-graph spaces', () => { it('an extension space with a stale marker advances declaratively to the new head', () => { const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('supabase'), aggregate, targetHash: HEAD_HASH, @@ -84,6 +86,7 @@ describe('planSpacePath — empty-graph spaces', () => { it('an extension space already at head stays an at-head resolution (no marker rewrite)', () => { const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('supabase'), aggregate, targetHash: HEAD_HASH, @@ -96,6 +99,7 @@ describe('planSpacePath — empty-graph spaces', () => { it('an extension space whose head requires invariants stays unsatisfiable', () => { const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('supabase', ['ext:install-v1']), aggregate, targetHash: HEAD_HASH, @@ -113,6 +117,7 @@ describe('planSpacePath — empty-graph spaces', () => { // the space is Prisma 8-managed. A managed element with no authored // graph is an authoring bug and must fail loudly. const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('broken-extension', [], 'managed'), aggregate, targetHash: HEAD_HASH, @@ -129,6 +134,7 @@ describe('planSpacePath — empty-graph spaces', () => { it('the APP space with an empty graph and a pending target stays never-planned', () => { const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space: makeEmptyGraphSpace('app'), aggregate, targetHash: HEAD_HASH, @@ -152,6 +158,7 @@ describe('planSpacePath — empty-graph spaces', () => { deserializeContract: (json) => json as Contract, }); const outcome = planSpacePath({ + storageNameOf: (operation) => operation.id, space, aggregate, targetHash: EMPTY_CONTRACT_HASH, 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 d47e3fbbaecf..63d33be79ce6 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 @@ -94,6 +94,8 @@ export interface PlannerInput string; } /** See {@link PlannerInput.appSpace}. */ @@ -156,9 +158,15 @@ export interface PerSpacePlan { readonly warnings?: readonly MigrationPlannerConflict[]; /** The statements the plan applied; empty for a space planned without statements. */ readonly appliedStatements: readonly AppliedMigrationStatement[]; - /** The operations of `plan` that lose data, with what each loses; empty for a recorded path. */ + /** + * The operations of `plan` that lose data, with what each loses. A recorded path names each + * destructive operation by its storage name, since no planner mapped it to a model. + */ readonly dataLoss: readonly MigrationOperationSubject[]; - /** The operations of `plan` that widen access, with what each is about; empty for a recorded path. */ + /** + * The operations of `plan` that widen access, with what each is about. Empty for a recorded path: + * a written migration is reviewed before it runs. + */ readonly accessWidening: readonly MigrationOperationSubject[]; /** * Per-edge breakdown of the chain. `resolve-recorded-path` plans carry 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 92296f377704..efeed19e76c8 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/planner.ts @@ -113,6 +113,7 @@ export async function planMigration string; } /** @@ -115,7 +117,16 @@ export function resolveRecordedPath(input: ResolveRecordedPathInputs): ResolveRe destinationContract: space.contract(), strategy: 'resolve-recorded-path', appliedStatements: [], - dataLoss: [], + dataLoss: pathOps.flatMap((operation, operationIndex) => + operation.operationClass === 'destructive' + ? [ + { + operationIndex, + subject: { kind: 'storage' as const, name: input.storageNameOf(operation) }, + }, + ] + : [], + ), accessWidening: [], migrationEdges: edgeRefs, pathDecision: outcome.decision, 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 5c29b5a681fd..91f5fa74e68b 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 @@ -116,6 +116,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(true); @@ -164,6 +165,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: origin, statements: [statement] }, + storageNameOf: (operation) => operation.id, }); expect(received).toEqual({ fromContract: origin, statements: [statement] }); @@ -204,6 +206,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set() }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [statement] }, + storageNameOf: (operation) => operation.id, }); expect(result.assertNotOk()).toEqual({ @@ -256,6 +259,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, operationPolicy: POLICY, appSpace: { fromContract: origin, statements: [statement] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(true); @@ -299,6 +303,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(true); @@ -354,6 +359,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set() }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(true); @@ -410,6 +416,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app', 'cipherstash']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(false); @@ -452,6 +459,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(false); @@ -508,6 +516,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(false); @@ -564,6 +573,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(true); @@ -595,6 +605,7 @@ describe('planMigration', () => { callerPolicy: { ignoreGraphFor: new Set(['app']) }, operationPolicy: POLICY, appSpace: { fromContract: null, statements: [] }, + storageNameOf: (operation) => operation.id, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/resolve-recorded-path.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/resolve-recorded-path.test.ts index 1b649fa3786b..94898cc07421 100644 --- a/packages/1-framework/3-tooling/migration/test/aggregate/strategies/resolve-recorded-path.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/strategies/resolve-recorded-path.test.ts @@ -20,6 +20,29 @@ function makeSpace( } describe('resolveRecordedPath', () => { + it('names each destructive operation of the path by its storage name, and widens no access', () => { + const headHash = 'cipher-head'; + const pkg = createAttestedPackage('20260101T0000_init', { from: null, to: headHash }, [ + { id: 'table.events', label: 'Create table events', operationClass: 'additive' }, + { id: 'table.legacy', label: 'Drop table legacy', operationClass: 'destructive' }, + { id: 'rls.events', label: 'Disable RLS on events', operationClass: 'widening' }, + ]); + + const outcome = resolveRecordedPath({ + aggregateTargetId: 'postgres', + space: makeSpace([pkg], headHash), + currentMarker: null, + storageNameOf: (operation) => `storage of ${operation.id}`, + }); + + expect(outcome.kind === 'ok' && outcome.result).toMatchObject({ + dataLoss: [ + { operationIndex: 1, subject: { kind: 'storage', name: 'storage of table.legacy' } }, + ], + accessWidening: [], + }); + }); + it('walks the shortest path from the live marker to the on-disk head ref', () => { const headHash = 'cipher-head'; const pkg = createAttestedPackage('20260101T0000_init', { from: null, to: headHash }); @@ -28,6 +51,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([pkg], headHash), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -55,6 +79,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([baseline, delta], headHash), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -74,6 +99,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([pkg], headHash), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -93,6 +119,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([pkg], headHash), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('unreachable'); @@ -108,6 +135,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space, currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('unsatisfiable'); @@ -124,6 +152,7 @@ describe('resolveRecordedPath', () => { space: makeSpace([pkg], headHash), currentMarker: null, refName: 'prod', + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -139,6 +168,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([pkg], headHash), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -154,6 +184,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([pkg], headHash), currentMarker: { storageHash: headHash, invariants: [] }, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); @@ -170,6 +201,7 @@ describe('resolveRecordedPath', () => { aggregateTargetId: 'postgres', space: makeSpace([], EMPTY_CONTRACT_HASH), currentMarker: null, + storageNameOf: (operation) => operation.id, }); expect(outcome.kind).toBe('ok'); From bd62decb34d13d587f270c1676e6eec8761093a7 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:26:02 +0200 Subject: [PATCH 072/109] TML-3476: acceptAccessWidening answers access questions, apart from acceptDataLoss acceptDataLoss: true answers every data-loss question only; a caller that also lets db update widen who can read or write rows passes acceptAccessWidening: true. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/CLI Style Guide.md | 2 +- .../subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../3-tooling/cli/src/control-api/client.ts | 1 + .../cli/src/control-api/operations/db-run.ts | 5 +++-- .../src/control-api/operations/db-update.ts | 5 ++++- .../control-api/operations/migration-plan.ts | 2 +- .../statements/data-loss-questions.ts | 6 ++--- .../3-tooling/cli/src/control-api/types.ts | 4 +++- .../cli/test/control-api/db-update.test.ts | 22 +++++++++++++++++-- .../statements/answer-plan-questions.test.ts | 2 +- .../app/instructions.md | 4 ++-- 12 files changed, 41 insertions(+), 16 deletions(-) diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index fcec5760ae4b..410e86d1c5c9 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -135,7 +135,7 @@ This is a deliberate divergence from clig.dev §Arguments §Confirmation. AI age - Non-interactively (closed stdin, CI, `--no-interactive`) without `--confirm`: no prompt is shown; the command fails with the engine's `CLI.CONSENT_REQUIRED` (exit `2`), whose `meta.consentToken` and `nextActions` name the token to pass. A cancelled prompt is `CLI.PROMPT_CANCELLED` (exit `3`); a mistyped token is `CLI.PROMPT_INVALID` (exit `2`). - `--confirm` is read only when the run is non-interactive or `--yes` is set. A script that runs from a terminal must pass `--no-interactive --confirm `, or it will stop at the prompt. - Each `--confirm` value grants at most one consent; a command that asks twice needs two. -- The control API takes the same consent programmatically: `migration plan` and `db update` take an answer callback for their questions, and `db update`'s `acceptDataLoss: true` answers every one. +- The control API takes the same consent programmatically: `migration plan` and `db update` take an answer callback for their questions, and `db update`'s `acceptDataLoss: true` answers every data-loss question and `acceptAccessWidening: true` every access question. #### Examples diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 2718fdcb9d6c..01f8d30b1ec3 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -823,7 +823,7 @@ When run against the default dev database URL, `db update` implicitly advances t Unlike `db migrate`, which replays serialized edges, `db update` always works from a live introspection. It also reads the origin contract from the snapshot store entry for the marker's hash, to name what an operation would lose and resolve rename statements; see [The origin of `db update`](#the-origin-of-db-update). 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. -**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`, each with the subject's `text` as the question writes it. It takes the `--delete` and `--allow` values and hands them to the control API as statements, which refuses one that names no subject an apply would ask about with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers all of them. +**Questions before an apply.** An apply asks, through the CLI engine's statement prompt, one question per subject of the plan's `dataLoss`, answered by `--delete` or `--rename`, and one per subject of its `accessWidening`, answered by `--allow` (see [Statements](#statements)). It asks before it applies anything. Extension spaces that apply recorded migrations contribute a data-loss question for each destructive operation, named by its storage name, and no access question. A run where nobody can answer fails with `CLI.CONSENT_REQUIRED`; a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`. A dry run asks nothing and lists the questions as `dataLoss` and `accessWidening`, each with the subject's `text` as the question writes it. It takes the `--delete` and `--allow` values and hands them to the control API as statements, which refuses one that names no subject an apply would ask about with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. The control API's `dbUpdate` takes the same questions through a required `answerQuestions` callback, called at least once per apply, with an empty list when nothing is in question. A callback that answers too few questions, or with a verb or text its question rejects, is a bug in the caller and throws `InternalError`. `delete` and `allow` statements answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied; `acceptDataLoss: true` answers every data-loss question and `acceptAccessWidening: true` every access question. 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. diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 4bb80de5ae60..93332b34c4c9 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) 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 a2ccd4388a9c..533d8cd4fc6b 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 @@ -419,6 +419,7 @@ class ControlClientImpl implements ControlClient { targetId: this.options.target.targetId, extensions: this.options.extensions ?? [], ...ifDefined('acceptDataLoss', options.acceptDataLoss), + ...ifDefined('acceptAccessWidening', options.acceptAccessWidening), ...ifDefined('statements', options.statements), answerQuestions: options.answerQuestions, ...ifDefined('verifySnapshotContent', this.snapshotVerifier), 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 90415d2259e9..f211ff659ef0 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 @@ -126,7 +126,7 @@ export interface ExecuteRunSharedOptions statement.verb !== 'rename'), - consentAll: options.acceptDataLoss, + consentAll: { delete: options.acceptDataLoss, allow: options.acceptAccessWidening }, origin: fromContract ?? EMPTY_ORIGIN, originKnown: fromContract !== null, destination: contract, 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 4149f48fa605..3c30072ed13b 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 @@ -38,8 +38,10 @@ export interface ExecuteDbUpdateOptions >; readonly frameworkComponents: ReadonlyArray>; - /** Consents to every operation that would lose data or widen access, without asking. */ + /** Consents to every operation that would lose data, without asking. */ readonly acceptDataLoss?: boolean; + /** Consents to every operation that would widen who can read or write rows, without asking. */ + readonly acceptAccessWidening?: boolean; readonly migrationsDir: string; readonly targetId: TTargetId; readonly extensions?: ReadonlyArray>; @@ -75,6 +77,7 @@ export async function executeDbUpdate statement.verb === verb && statement.text === text, ); for (const statement of given) usedPreAnswers.add(statement); - if (input.consentAll || given.length > 0) consent(verb, entry, text); + if (input.consentAll[verb] || given.length > 0) consent(verb, entry, text); } } const losses = pending('delete', plan.dataLoss); 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 7d27ce23bf74..fb9b161459e0 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 @@ -212,8 +212,10 @@ export interface DbUpdateOptions { * The type is driver-specific (e.g., string URL for Postgres). */ readonly connection?: unknown; - /** Consents to every operation that would lose data or widen access, without asking. */ + /** Consents to every operation that would lose data, without asking. */ readonly acceptDataLoss?: boolean; + /** Consents to every operation that would widen who can read or write rows, without asking. */ + readonly acceptAccessWidening?: boolean; /** * On-disk migrations directory. Always required — every `db update` * routes through the per-space flow, which reads on-disk 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 2cf4f3463a67..030e086e396d 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 @@ -649,9 +649,27 @@ describe('executeDbUpdate', () => { expect(execute).not.toHaveBeenCalled(); }); - it('answers every question with acceptDataLoss: true', async () => { + it('answers every data-loss question with acceptDataLoss: true, and still asks about access', async () => { + const asked: string[] = []; + const result = await executeDbUpdate( + applyInputs({ + acceptDataLoss: true, + answerQuestions: async (questions) => { + asked.push(...questions.map(({ subject }) => subject)); + return questions.map(({ subject }) => ({ verb: 'allow', text: subject })); + }, + }), + ); + + expect(result.ok).toBe(true); + expect(asked).toEqual(['user']); + }); + + it('answers every question with acceptDataLoss and acceptAccessWidening', async () => { const answerQuestions = vi.fn(noQuestions); - const result = await executeDbUpdate(applyInputs({ acceptDataLoss: true, answerQuestions })); + const result = await executeDbUpdate( + applyInputs({ acceptDataLoss: true, acceptAccessWidening: true, answerQuestions }), + ); expect(result.ok).toBe(true); expect(answerQuestions).toHaveBeenCalledWith([]); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts index a344f4216174..157762f513fc 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts @@ -40,7 +40,7 @@ function run(replanned: PlannedQuestions, typed: readonly (readonly string[])[]) askAccess: false, renames: [], preAnswers: [], - consentAll: false, + consentAll: { delete: false, allow: false }, origin, originKnown: true, destination, diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index d1df7445c84b..91b4d09cdb51 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -27,7 +27,7 @@ changes: `prisma db update` reads the snapshot of the contract the database is at on every run, to name what an operation would lose. Nothing to change. - id: control-api-answer-questions summary: | - In `@prisma/orm-toolchain/cli/control-api`, `executeMigrationPlanCommand` and `executeDbUpdate`, and the control client's `dbUpdate`, require an `answerQuestions` callback; `consent` and `carryEmittedExtensionDirs` are gone. `delete` and `allow` statements in `statements` answer questions without asking, and one that answers none fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. + In `@prisma/orm-toolchain/cli/control-api`, `executeMigrationPlanCommand` and `executeDbUpdate`, and the control client's `dbUpdate`, require an `answerQuestions` callback; `consent` and `carryEmittedExtensionDirs` are gone. `delete` and `allow` statements in `statements` answer questions without asking, and one that answers none fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. `acceptDataLoss: true` no longer answers access-widening questions; `acceptAccessWidening: true` does. detection: glob: "**/*.{ts,mts,cts,js,mjs,cjs}" matches: @@ -86,7 +86,7 @@ To find the subjects, run the same command with `--no-interactive --json` and re These come from `@prisma/orm-toolchain/cli/control-api`. - `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions: (questions) => Promise`. Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `:` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`. -- `acceptDataLoss: true` answers every data-loss and access-widening question. +- `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. A caller that passed `acceptDataLoss: true` to apply whatever the plan holds passes both. - `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. - `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it. - `executeMigrationPlanCommand(options)` requires `answerQuestions`, with the same contract, and loses `consent` and `carryEmittedExtensionDirs`. Remove both. From f90595d97b7cbc99d69e63ec3936366d2c321bbf Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:26:57 +0200 Subject: [PATCH 073/109] TML-3476: replacing a generated-name policy is not an access widening A drop is paired with a create on the same table by the policy's generated-name prefix, as a stated name already pairs, so a body edit asks --allow for neither naming. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- .../src/core/migrations/operation-subjects.ts | 11 ++++-- .../test/migrations/planner.data-loss.test.ts | 38 +++++++++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 01f8d30b1ec3..f414118e1a20 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -222,7 +222,7 @@ Rename operations are `widening`, so neither command asks about them. The `migra **Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. -**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. +**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. A policy whose generated name changes with its body is created again when the plan creates a policy with the same name prefix on the same table, so editing its body asks nothing, as editing a policy with a stated name does. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. #### The origin of `db update` diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts index 9942d74c1296..ebaf3076d1c4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -10,6 +10,7 @@ import { import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { SqlStorage } from '@internal/sql-contract/types'; +import { parseWireName } from '@internal/sql-schema-ir/naming'; import { resolveNamespaceIdForDdlSchema } from './control-policy'; import { AlterColumnTypeCall, @@ -73,13 +74,17 @@ function knownTarget(locator: Locator, call: OpFactoryCall): SubjectTarget | und return fieldEvent === undefined ? undefined : fieldEventTarget(fieldEvent); } +/** + * A policy's identity across a replacement: its name, or the prefix of a generated (wire) name, + * whose hash changes with the policy's body. + */ function policyKey(schemaName: string, tableName: string, policyName: string): string { - return JSON.stringify([schemaName, tableName, policyName]); + return JSON.stringify([schemaName, tableName, parseWireName(policyName)?.prefix ?? policyName]); } /** - * The policies the plan creates. A drop of one of them is the first half of a replacement, which - * leaves the policy in place, so it widens no access. + * The policies the plan creates. A drop of one of them, by name or by generated-name prefix, is + * half of a replacement, which leaves a policy in place, so it widens no access. */ function createdPolicies(calls: readonly OpFactoryCall[]): ReadonlySet { return new Set( diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index 8c5d44a42b19..acaa17ae4531 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -42,11 +42,16 @@ interface TableSpec { readonly policy?: string; /** A column with an index named after the table. */ readonly indexed?: string; + /** The hash of the policy's generated (wire) name; without it the policy has an exact name. */ + readonly policyHash?: string; } -function policyOn(table: string, using: string): PostgresRlsPolicy { +function policyOn(table: string, using: string, hash: string | undefined): PostgresRlsPolicy { return new PostgresRlsPolicy({ - naming: { kind: 'exact', name: `${table} readers` }, + naming: + hash === undefined + ? { kind: 'exact', name: `${table} readers` } + : { kind: 'wire', prefix: `${table}_readers`, hash }, tableName: table, namespaceId: 'public', operation: 'select', @@ -61,7 +66,7 @@ function policyOn(table: string, using: string): PostgresRlsPolicy { function contract(seed: string, tables: Readonly>): Contract { const specs = Object.entries(tables); const policies = specs.flatMap(([table, spec]) => - spec.policy === undefined ? [] : [policyOn(table, spec.policy)], + spec.policy === undefined ? [] : [policyOn(table, spec.policy, spec.policyHash)], ); const schema = new PostgresSchema({ id: 'public', @@ -242,6 +247,33 @@ describe('Postgres planner, data loss', () => { }); }); + it('leaves out the drop of a generated-name policy the plan replaces under a new hash', async () => { + const from = contract('from', { + User: { columns: { id: 'int4' }, policy: 'true', policyHash: 'aaaaaaaa' }, + }); + const to = contract('to', { + User: { columns: { id: 'int4' }, policy: 'false', policyHash: 'bbbbbbbb' }, + }); + + const { labels, accessWidening } = await planned(from, to); + expect(labels).toEqual( + expect.arrayContaining([expect.stringContaining('User_readers_aaaaaaaa')]), + ); + expect(accessWidening).toEqual([]); + }); + + it('lists the drop of a generated-name policy the plan does not replace', async () => { + const from = contract('from', { + User: { columns: { id: 'int4' }, policy: 'true', policyHash: 'aaaaaaaa' }, + }); + const to = contract('to', { User: { columns: { id: 'int4' } } }); + + const { accessWidening } = await planned(from, to); + expect(accessWidening.map(({ operation }) => operation)).toEqual( + expect.arrayContaining([expect.stringContaining('User_readers_aaaaaaaa')]), + ); + }); + it('leaves out the drop of a policy the plan replaces', async () => { const from = contract('from', { User: { columns: { id: 'int4' }, policy: 'true' } }); const to = contract('to', { User: { columns: { id: 'int4' }, policy: 'false' } }); From 6e761d42477438ad1a204f082de8b0b022ab269f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:30:23 +0200 Subject: [PATCH 074/109] TML-3476: a data-loss question offers rename only where one can succeed, and says what delete loses rename is offered for a model or field the destination no longer has, and not where the target's planner refuses renames (MongoDB, through the new TargetMigrationsCapability.refusesRenameStatements) or for a field that keeps its name, such as one whose type changes. Without an origin snapshot, the question says --delete loses the rows and gives the steps that store the snapshot so a rename can keep them. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 5 +++ .../cli/src/control-api/operations/db-run.ts | 1 + .../control-api/operations/migration-plan.ts | 3 ++ .../statements/data-loss-questions.ts | 31 +++++++++++++--- .../cli/test/control-api/db-update.test.ts | 2 +- .../statements/answer-plan-questions.test.ts | 1 + .../statements/data-loss-question.test.ts | 36 ++++++++++++++++++- .../cli/test/orm/db-update-consent.test.ts | 2 +- .../src/core/migrations/control-target.ts | 1 + .../mongo-db-update-consent.e2e.test.ts | 2 +- 10 files changed, 75 insertions(+), 9 deletions(-) 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 601f61ca453c..618ccd4d4ede 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 @@ -655,6 +655,11 @@ export interface TargetMigrationsCapability< adapter: ControlAdapterInstance, ): MigrationPlanner; createRunner(family: TFamilyInstance): MigrationRunner; + /** + * Set when the target's planner refuses every rename statement, so the CLI does not offer + * `--rename` as an answer to a data-loss question. + */ + readonly refusesRenameStatements?: true; /** * Synthesizes a family-specific schema IR from a contract for offline planning. * The returned schema can be passed to `planner.plan({ schema })` as the "from" state. 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 f211ff659ef0..1e76381bf501 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 @@ -325,6 +325,7 @@ export async function executeRun plan(resolveRenames(renames)), 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 622b86eb211d..38800729268c 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 @@ -296,6 +296,7 @@ async function answerPlannedDataLoss(input: { readonly consents: readonly StatementText[]; readonly origin: ContractWithDomain; readonly originKnown: boolean; + readonly renamesPlannable: boolean; readonly destination: ContractWithDomain; readonly answer: AnswerPlanQuestions; readonly planDelta: ( @@ -323,6 +324,7 @@ async function answerPlannedDataLoss(input: { consentAll: { delete: false, allow: false }, origin: input.origin, originKnown: input.originKnown, + renamesPlannable: input.renamesPlannable, destination: input.destination, answer: input.answer, replan: async (renames) => { @@ -745,6 +747,7 @@ async function executeMigrationPlanCommandInner( consents: statementTexts.filter((statement) => statement.verb !== 'rename'), origin: fromContract ?? EMPTY_ORIGIN, originKnown: true, + renamesPlannable: migrations.refusesRenameStatements !== true, destination: toContract, answer: options.answerQuestions, planDelta: async (renames) => { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts index 35e352ef01cc..a1db48e2a3df 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts @@ -141,7 +141,7 @@ function lossText(subject: MigrationStatementSubject, text: string, originKnown: case 'storage': return originKnown ? `would lose the data in "${text}", which no model of the origin contract stores` - : `would lose the data in "${text}", named by its storage name because the origin contract is unknown`; + : `would lose the data in "${text}", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename`; } } @@ -162,20 +162,39 @@ function namesSubject(verb: ConsentVerb, subject: string) { text === subject ? undefined : `${verb} names "${subject}": --${verb} ${subject}.`; } +/** Whether the destination still has the subject's model or field, under the name a rename gives it. */ +function inDestination( + subject: MigrationStatementSubject, + contracts: StatementContracts & { readonly destination: ContractWithDomain }, +): boolean { + if (subject.kind === 'storage') return false; + const model = renamedModel(subject, contracts) ?? subject; + const destinationModel = + contracts.destination.domain.namespaces[model.namespaceId]?.models[model.model]; + if (destinationModel === undefined) return false; + return subject.kind === 'model' || Object.hasOwn(destinationModel.fields, subject.field); +} + /** - * The question for one operation that would lose data. A model or field may be renamed instead of - * deleted; a rename is resolved against the two contracts after the plan's other renames, and its - * old name must be the subject. Data no model stores can only be deleted. + * The question for one operation that would lose data. A model or field the destination no longer + * has may be renamed instead of deleted, when the planner can carry out a rename; a rename is + * resolved against the two contracts after the plan's other renames, and its old name must be the + * subject. A subject the destination keeps, such as a field whose type changes, and data no model + * stores, can only be deleted. */ export function dataLossQuestion( loss: PlannedSubject, contracts: StatementContracts & { readonly destination: ContractWithDomain; readonly originKnown: boolean; + readonly renamesPlannable: boolean; }, ): PlanQuestion { const subject = subjectText(loss.subject, contracts); - const renamable = loss.subject.kind !== 'storage'; + const renamable = + contracts.renamesPlannable && + loss.subject.kind !== 'storage' && + !inDestination(loss.subject, contracts); const deleteNames = namesSubject('delete', subject); return { question: `${loss.label} ${lossText(loss.subject, subject, contracts.originKnown)}.`, @@ -267,6 +286,7 @@ export async function answerPlanQuestions Promise>; @@ -288,6 +308,7 @@ export async function answerPlanQuestions { expect(asked).toEqual([ { question: - 'Drop column nickname from user would lose the data in "user.nickname", named by its storage name because the origin contract is unknown.', + 'Drop column nickname from user would lose the data in "user.nickname", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename.', subject: 'user.nickname', verbs: ['delete'], }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts index 157762f513fc..a1ed54c0eb5c 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts @@ -43,6 +43,7 @@ function run(replanned: PlannedQuestions, typed: readonly (readonly string[])[]) consentAll: { delete: false, allow: false }, origin, originKnown: true, + renamesPlannable: true, destination, answer: async (questions: readonly PlanQuestion[]) => { asked.push(questions.map(({ question }) => question)); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts index 4d6726353453..8516d81503e2 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -1,3 +1,4 @@ +import { asNamespaceId } from '@internal/contract/types'; import { describe, expect, it } from 'vitest'; import { dataLossQuestion } from '../../../src/control-api/statements/data-loss-questions'; import { contractOf } from './statement-fixtures'; @@ -17,6 +18,7 @@ describe('dataLossQuestion for data no model names', () => { destination, renames: [], originKnown: true, + renamesPlannable: true, }); expect({ question: question.question, verbs: question.verbs }).toEqual({ @@ -32,10 +34,42 @@ describe('dataLossQuestion for data no model names', () => { destination, renames: [], originKnown: false, + renamesPlannable: true, }); expect(question.question).toBe( - 'Drop column age from user would lose the data in "public.user.age", named by its storage name because the origin contract is unknown.', + 'Drop column age from user would lose the data in "public.user.age", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename.', ); }); }); + +describe('dataLossQuestion verbs', () => { + const unbound = asNamespaceId('app'); + const destinationWithoutNickname = contractOf({ + app: { models: { User: { fields: ['email'] } } }, + }); + const withNickname = contractOf({ app: { models: { User: { fields: ['email', 'nickname'] } } } }); + + function verbsFor(field: string, destination: typeof origin, renamesPlannable: boolean) { + return dataLossQuestion( + { + operationIndex: 0, + label: 'An operation', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field }, + }, + { origin: withNickname, destination, renames: [], originKnown: true, renamesPlannable }, + ).verbs; + } + + it('offers rename for a field the destination no longer has', () => { + expect(verbsFor('nickname', destinationWithoutNickname, true)).toEqual(['rename', 'delete']); + }); + + it('offers only delete for a field that keeps its name, such as one whose type changes', () => { + expect(verbsFor('email', destinationWithoutNickname, true)).toEqual(['delete']); + }); + + it('offers only delete where the planner cannot carry out a rename', () => { + expect(verbsFor('nickname', destinationWithoutNickname, false)).toEqual(['delete']); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index ebd443dc28d1..5549b40f6fd7 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -65,7 +65,7 @@ const origin = { const destination = { domain: { namespaces: { app: { models: { User: { fields: {}, relations: {}, storage: {} } } } } }, }; -const contracts = { origin, destination, renames: [], originKnown: true }; +const contracts = { origin, destination, renames: [], originKnown: true, renamesPlannable: true }; /** One question of each kind, across two spaces: an extension's recorded drop is a storage subject. */ function planQuestions(): readonly PlanQuestion[] { diff --git a/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts index d8e1460cc605..735dc697d45a 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts @@ -42,6 +42,7 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor) { return new MongoMigrationPlanner(); }, diff --git a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts index 5fada18d872c..3fd52b2730c4 100644 --- a/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-db-update-consent.e2e.test.ts @@ -163,7 +163,7 @@ describe('Journey: Mongo db update asks for consent only to lose data', { expect(unconfirmed.exitCode).toBe(2); expect(parseJsonOutput(unconfirmed)).toMatchObject({ code: 'CLI.CONSENT_REQUIRED', - meta: { unanswered: [{ subject: 'Event', verbs: ['rename', 'delete'] }] }, + meta: { unanswered: [{ subject: 'Event', verbs: ['delete'] }] }, }); const kept = await client.db(dbName).listCollections({ name: 'events' }).toArray(); expect(kept.map(({ name }) => name)).toEqual(['events']); From 0aee3bc78792b7e5518806d6e8d722294d511191 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:36:32 +0200 Subject: [PATCH 075/109] TML-3476: name the subject for what it is, and declare the subject lists once - MigrationStatementSubject is MigrationSubject (and its JSON form). - MigrationPlanSubjects declares dataLoss and accessWidening once; the planner result, PerSpacePlan, the SQL family and DbUpdateSuccess use it. - migrationSubjectKey replaces the JSON.stringify comparisons of subjects. - DeleteMigrationStatement and the delete branch of describeMigrationStatement had no production caller and are removed. - The SQL family's planSubjects is subjectsOfCalls, SubjectTarget is SubjectStorage, and the CLI's question module is plan-questions.ts with askPlanQuestions as its loop. - The callback types AnswerPlanQuestions, PlanQuestion, PlanAnswer and PlanQuestionVerb are exported from the control API. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 6 +- .../src/control/control-instances.ts | 5 +- .../src/control/control-migration-types.ts | 8 +-- .../src/control/migration-statements.ts | 57 ++++++++-------- .../src/exports/control.ts | 9 +-- .../test/migration-statements.test.ts | 66 +++++++------------ .../cli/src/control-api/operations/db-run.ts | 28 ++++---- .../src/control-api/operations/db-update.ts | 2 +- .../control-api/operations/migration-plan.ts | 11 ++-- ...ta-loss-questions.ts => plan-questions.ts} | 27 ++++---- .../statements/report-applied-statements.ts | 10 +-- .../3-tooling/cli/src/control-api/types.ts | 21 +----- .../3-tooling/cli/src/exports/control-api.ts | 6 ++ .../3-tooling/cli/src/orm/db/update.ts | 4 +- .../cli/src/orm/plan-question-prompt.ts | 5 +- .../cli/src/utils/formatters/migrations.ts | 4 +- .../cli/test/control-api/db-update.test.ts | 2 +- ...ons.test.ts => ask-plan-questions.test.ts} | 6 +- .../statements/data-loss-question.test.ts | 2 +- .../cli/test/orm/db-update-consent.test.ts | 2 +- .../cli/test/orm/migration-plan.test.ts | 2 +- .../migration/src/aggregate/planner-types.ts | 54 +++++++-------- .../src/core/migrations/operation-subjects.ts | 26 ++++---- .../src/core/migrations/plan-helpers.ts | 8 ++- .../2-sql/9-family/src/exports/control.ts | 7 +- .../9-family/test/operation-subjects.test.ts | 53 ++++++++------- .../src/core/migrations/mongo-planner.ts | 4 +- .../src/core/migrations/operation-subjects.ts | 14 ++-- .../postgres/src/core/migrations/planner.ts | 4 +- .../migrations/operation-subjects.test.ts | 4 +- .../src/core/migrations/operation-subjects.ts | 12 ++-- .../sqlite/src/core/migrations/planner.ts | 4 +- .../migrations/operation-subjects.test.ts | 4 +- .../extension/instructions.md | 6 +- 34 files changed, 221 insertions(+), 262 deletions(-) rename packages/1-framework/3-tooling/cli/src/control-api/statements/{data-loss-questions.ts => plan-questions.ts} (95%) rename packages/1-framework/3-tooling/cli/test/control-api/statements/{answer-plan-questions.test.ts => ask-plan-questions.test.ts} (95%) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index f414118e1a20..4ea008dcb408 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -191,7 +191,7 @@ prisma migration plan --name tidy-users --rename Profile:User --rename User.name prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` -**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationStatementSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field, `rename`; `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `answerPlanQuestions` in [`data-loss-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field, `rename`; `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. #### Rename statements @@ -220,9 +220,9 @@ Rename operations are `widening`, so neither command asks about them. The `migra #### Delete and allow statements -**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. `describeMigrationStatement` writes a delete as `delete model "Legacy"`, `delete field "User.name"` or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. +**Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. The CLI writes a delete as its question named the subject (`consentDescription` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts)): `delete model "Legacy"`, `delete field "User.nickname"` after a model rename, or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. -**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. A policy whose generated name changes with its body is created again when the plan creates a policy with the same name prefix on the same table, so editing its body asks nothing, as editing a policy with a stated name does. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; `describeMigrationStatement` writes it as `allow model "User"` or `allow storage "user"`. +**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. A policy whose generated name changes with its body is created again when the plan creates a policy with the same name prefix on the same table, so editing its body asks nothing, as editing a policy with a stated name does. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; the CLI writes it the same way, as `allow model "User"` or `allow storage "user"`. #### The origin of `db update` diff --git a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts index 31b3aafd180d..3b3f2778afa8 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts @@ -27,10 +27,7 @@ export interface ControlFamilyInstance */ deserializeContract(contractJson: unknown): Contract; - /** - * The name the database knows the object an operation is about by, for an operation of a - * recorded migration that no planner mapped to a model. - */ + /** The name the database knows the object an operation acts on by. */ storageNameOf(operation: MigrationPlanOperation): string; verify(options: { 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 618ccd4d4ede..e799afa09a43 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 @@ -22,7 +22,7 @@ import type { import type { OperationContext } from './control-operation-results'; import type { AppliedMigrationStatement, - MigrationOperationSubject, + MigrationPlanSubjects, ResolvedMigrationStatement, } from './migration-statements'; @@ -312,16 +312,12 @@ export interface MigrationPlannerConflict { * The plan is typed as `MigrationPlanWithAuthoringSurface` so the CLI can * uniformly ask any plan to render itself to TypeScript. */ -export interface MigrationPlannerSuccessResult { +export interface MigrationPlannerSuccessResult extends MigrationPlanSubjects { 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[]; - /** Each operation that loses data, in plan order, with what it loses. */ - readonly dataLoss: readonly MigrationOperationSubject[]; - /** Each operation that widens who can read or write data, in plan order, with what it is about. */ - readonly accessWidening: readonly MigrationOperationSubject[]; } /** 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 index 7c6a29963ad7..12491bc59244 100644 --- 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 @@ -42,23 +42,40 @@ export type ResolvedMigrationStatement = /** * What an operation of a plan is about, in the contract's terms where the family can say: a model * or a field of the origin contract, or, when no model of the origin contract stores it, the name - * the database knows it by. + * the database knows it by. Told apart by `kind`, not by a statement's `entity`, because a storage + * name is not an entity of the contract. */ -export type MigrationStatementSubject = +export type MigrationSubject = | ({ readonly kind: 'model' } & ModelCoordinate) | ({ readonly kind: 'field' } & FieldCoordinate) | { readonly kind: 'storage'; readonly name: string }; +/** A key that is equal for two subjects exactly when they name the same thing. */ +export function migrationSubjectKey(subject: MigrationSubject): string { + switch (subject.kind) { + case 'model': + return JSON.stringify(['model', subject.namespaceId, subject.model]); + case 'field': + return JSON.stringify(['field', subject.namespaceId, subject.model, subject.field]); + case 'storage': + return JSON.stringify(['storage', subject.name]); + } +} + /** An operation of a plan, by its position in the plan's `operations`, and its subject. */ export interface MigrationOperationSubject { readonly operationIndex: number; - readonly subject: MigrationStatementSubject; + readonly subject: MigrationSubject; } -/** A statement that consents to losing the data of its subject. It never reaches a planner. */ -export interface DeleteMigrationStatement { - readonly kind: 'delete'; - readonly subject: MigrationStatementSubject; +/** What the operations of a plan lose and whose access they widen, each by its position in the plan. */ +export interface MigrationPlanSubjects< + TEntry extends MigrationOperationSubject = MigrationOperationSubject, +> { + /** Each operation that loses data, in plan order, with what it loses. */ + readonly dataLoss: readonly TEntry[]; + /** Each operation that widens who can read or write data, in plan order, with what it is about. */ + readonly accessWidening: readonly TEntry[]; } /** @@ -115,7 +132,7 @@ export type MigrationStatementJson = }; /** A subject in JSON output: `namespaceId` is left out for the unbound namespace. */ -export type MigrationStatementSubjectJson = +export type MigrationSubjectJson = | ({ readonly kind: 'model' } & ModelCoordinateJson) | ({ readonly kind: 'field' } & FieldCoordinateJson) | { readonly kind: 'storage'; readonly name: string }; @@ -138,9 +155,7 @@ export function migrationStatementJson( } /** The subject as JSON output writes it. */ -export function migrationStatementSubjectJson( - subject: MigrationStatementSubject, -): MigrationStatementSubjectJson { +export function migrationSubjectJson(subject: MigrationSubject): MigrationSubjectJson { switch (subject.kind) { case 'model': return { @@ -161,32 +176,16 @@ export function migrationStatementSubjectJson( } } -function describeDelete( - subject: MigrationStatementSubject, - fromContract: ContractWithDomain, -): string { - switch (subject.kind) { - case 'model': - return `delete model "${modelName(fromContract, subject)}"`; - case 'field': - return `delete field "${modelName(fromContract, subject)}.${subject.field}"`; - case 'storage': - return `delete storage "${subject.name}"`; - } -} - /** * 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 renamed field is named through its model as - * the destination contract names it, as the statement itself is written; a deleted model or field - * as the origin contract names it. + * the destination contract names it, as the statement itself is written. */ export function describeMigrationStatement( - statement: ResolvedMigrationStatement | DeleteMigrationStatement, + statement: ResolvedMigrationStatement, fromContract: ContractWithDomain, contract: ContractWithDomain, ): string { - if (statement.kind === 'delete') return describeDelete(statement.subject, fromContract); if (statement.entity === 'model') { return `rename model "${modelName(fromContract, statement.from)}" to "${modelName(contract, statement.to)}"`; } 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 ef283a32d8e1..e5241c75bd90 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 @@ -124,13 +124,13 @@ export { } from '../control/control-stack'; export type { AppliedMigrationStatement, - DeleteMigrationStatement, FieldCoordinate, FieldCoordinateJson, MigrationOperationSubject, + MigrationPlanSubjects, MigrationStatementJson, - MigrationStatementSubject, - MigrationStatementSubjectJson, + MigrationSubject, + MigrationSubjectJson, ModelCoordinate, ModelCoordinateJson, ResolvedFieldRenameStatement, @@ -140,7 +140,8 @@ export type { export { describeMigrationStatement, migrationStatementJson, - migrationStatementSubjectJson, + migrationSubjectJson, + migrationSubjectKey, modelDisplayName, } from '../control/migration-statements'; export { orderIssuesByDependencies } from '../control/order-issues-by-dependencies'; 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 index 4e9cc59edc7d..971518ecd23b 100644 --- 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 @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'; import { describeMigrationStatement, migrationStatementJson, - migrationStatementSubjectJson, + migrationSubjectJson, + migrationSubjectKey, modelDisplayName, type ResolvedMigrationStatement, } from '../src/control/migration-statements'; @@ -99,54 +100,18 @@ describe('describeMigrationStatement', () => { }); }); -describe('describeMigrationStatement for a delete', () => { - it('names the model, the field or the storage the statement deletes', () => { - const contract = contractWith({ app: ['Legacy', 'User'] }); - expect([ - describeMigrationStatement( - { kind: 'delete', subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, - contract, - contract, - ), - describeMigrationStatement( - { - kind: 'delete', - subject: { kind: 'field', namespaceId: app, model: 'User', field: 'name' }, - }, - contract, - contract, - ), - describeMigrationStatement( - { kind: 'delete', subject: { kind: 'storage', name: 'audit_log' } }, - contract, - contract, - ), - ]).toEqual(['delete model "Legacy"', 'delete field "User.name"', 'delete storage "audit_log"']); - }); - - it('names a deleted model through its namespace when the origin contract has several', () => { - expect( - describeMigrationStatement( - { kind: 'delete', subject: { kind: 'model', namespaceId: app, model: 'Legacy' } }, - contractWith({ app: ['Legacy'], billing: ['Bill'] }), - contractWith({ billing: ['Bill'] }), - ), - ).toBe('delete model "app.Legacy"'); - }); -}); - -describe('migrationStatementSubjectJson', () => { +describe('migrationSubjectJson', () => { it('leaves namespaceId out for the unbound namespace and keeps it otherwise', () => { const unbound = asNamespaceId(UNBOUND_NAMESPACE_ID); expect([ - migrationStatementSubjectJson({ + migrationSubjectJson({ kind: 'field', namespaceId: unbound, model: 'User', field: 'name', }), - migrationStatementSubjectJson({ kind: 'model', namespaceId: app, model: 'Legacy' }), - migrationStatementSubjectJson({ kind: 'storage', name: 'audit_log' }), + migrationSubjectJson({ kind: 'model', namespaceId: app, model: 'Legacy' }), + migrationSubjectJson({ kind: 'storage', name: 'audit_log' }), ]).toEqual([ { kind: 'field', model: 'User', field: 'name' }, { kind: 'model', namespaceId: 'app', model: 'Legacy' }, @@ -191,3 +156,22 @@ describe('migrationStatementJson', () => { ]); }); }); + +describe('migrationSubjectKey', () => { + it('is equal for subjects that name the same thing, whatever their key order', () => { + const app = asNamespaceId('app'); + expect( + migrationSubjectKey({ kind: 'field', namespaceId: app, model: 'User', field: 'name' }), + ).toBe(migrationSubjectKey({ field: 'name', model: 'User', namespaceId: app, kind: 'field' })); + }); + + it('differs between a model, a field and a storage name that share a name', () => { + const app = asNamespaceId('app'); + const keys = new Set([ + migrationSubjectKey({ kind: 'model', namespaceId: app, model: 'User' }), + migrationSubjectKey({ kind: 'field', namespaceId: app, model: 'User', field: 'User' }), + migrationSubjectKey({ kind: 'storage', name: 'User' }), + ]); + expect(keys.size).toBe(3); + }); +}); 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 1e76381bf501..99b3b5612d73 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,7 +13,7 @@ import type { MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, - MigrationStatementSubject, + MigrationPlanSubjects, OperationPreview, ResolvedMigrationStatement, TargetMigrationsCapability, @@ -22,6 +22,7 @@ import { CONTRACT_SNAPSHOTS_DIRNAME, hasOperationPreview, isStorageHashHex, + migrationSubjectKey, } from '@internal/framework-components/control'; import type { ContractMarkerRecordLike } from '@internal/migration-tools/aggregate'; import { @@ -45,12 +46,12 @@ import { join } from 'pathe'; import { CliStructuredError } from '../../utils/cli-errors'; import { type AnswerPlanQuestions, - answerPlanQuestions, + askPlanQuestions, type ConsentedSubject, type PlannedQuestions, refuseUnusedConsents, subjectText, -} from '../statements/data-loss-questions'; +} from '../statements/plan-questions'; import { type AppliedStatementReport, reportAppliedStatements, @@ -59,7 +60,7 @@ import { import { resolveStatements, type StatementOrigin } from '../statements/resolve-statements'; import type { StatementText } from '../statements/statement-text'; import type { - AskedSubjectsReport, + AskedSubject, DbInitFailure, DbInitResult, DbInitSuccess, @@ -68,7 +69,6 @@ import type { DbUpdateSuccess, OnControlProgress, PerSpaceExecutionEntry, - PlanSubjectsReport, } from '../types'; import { type BuildAggregateInputs, @@ -286,7 +286,7 @@ export async function executeRun JSON.stringify(subject); const appliedStatements = action === 'dbUpdate' ? [ @@ -380,7 +379,10 @@ export async function executeRun subjectKey(subject) === subjectKey(entry.subject)) + .filter( + ({ subject }) => + migrationSubjectKey(subject) === migrationSubjectKey(entry.subject), + ) .map(({ operationIndex }) => operationIndex), ), ), @@ -479,7 +481,7 @@ const EMPTY_ORIGIN = { domain: { namespaces: {} } }; interface PlannedRun extends PlannedQuestions { readonly planned: AggregatePlan; readonly orderedResolutions: readonly OrderedResolution[]; - readonly subjects: PlanSubjectsReport; + readonly subjects: MigrationPlanSubjects; } /** @@ -544,7 +546,7 @@ function operationsBefore( */ function subjectsAcrossSpaces( orderedResolutions: readonly OrderedResolution[], -): PlanSubjectsReport { +): MigrationPlanSubjects { const offset = (resolution: OrderedResolution, entries: readonly MigrationOperationSubject[]) => entries.map((entry) => ({ ...entry, @@ -664,7 +666,7 @@ function wrapPlanResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: AskedSubjectsReport | undefined; + readonly subjects: MigrationPlanSubjects | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { @@ -696,7 +698,7 @@ function wrapApplyResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: AskedSubjectsReport | undefined; + readonly subjects: MigrationPlanSubjects | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { 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 3c30072ed13b..9c09cb1bc11b 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 @@ -9,7 +9,7 @@ import type { } from '@internal/framework-components/control'; import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { ifDefined } from '@internal/utils/defined'; -import type { AnswerPlanQuestions } from '../statements/data-loss-questions'; +import type { AnswerPlanQuestions } from '../statements/plan-questions'; import type { StatementText } from '../statements/statement-text'; import type { DbUpdateResult, OnControlProgress } from '../types'; import { executeRun } from './db-run'; 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 38800729268c..8e5e50552ed2 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 @@ -9,6 +9,7 @@ import { createControlStack, hasOperationPreview, type MigrationPlanOperation, + migrationSubjectKey, type OperationPreview, planOriginOf, type ResolvedMigrationStatement, @@ -50,10 +51,10 @@ import { createProjectSpecifierResolver } from '../../utils/project-import-root' import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { type AnswerPlanQuestions, - answerPlanQuestions, + askPlanQuestions, type PlannedSubject, subjectText, -} from '../statements/data-loss-questions'; +} from '../statements/plan-questions'; import { type AppliedStatementReport, reportAppliedStatements, @@ -316,7 +317,7 @@ async function answerPlannedDataLoss(input: { dataLoss: [...(input.baseline?.dataLoss ?? []), ...(delta?.dataLoss ?? [])], accessWidening: [], }); - const answered = await answerPlanQuestions({ + const answered = await askPlanQuestions({ plan: legs(input.delta), askAccess: false, renames: input.renames, @@ -338,7 +339,9 @@ async function answerPlannedDataLoss(input: { reportConsentStatement( consented, (delta?.dataLoss ?? []) - .filter((loss) => JSON.stringify(loss.subject) === JSON.stringify(consented.subject)) + .filter( + (loss) => migrationSubjectKey(loss.subject) === migrationSubjectKey(consented.subject), + ) .flatMap(({ operationIndex }) => (operationIndex === undefined ? [] : [operationIndex])), ), ); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts similarity index 95% rename from packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts rename to packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index a1db48e2a3df..3ccbd5d51880 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/data-loss-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -1,8 +1,9 @@ import type { ContractWithDomain } from '@internal/contract/types'; import type { CliStructuredError } from '@internal/errors/control'; import { - type MigrationStatementSubject, + type MigrationSubject, type ModelCoordinate, + migrationSubjectKey, modelDisplayName, type ResolvedMigrationStatement, } from '@internal/framework-components/control'; @@ -54,7 +55,7 @@ export type AnswerPlanQuestions = ( export interface PlannedSubject { readonly operationIndex: number | undefined; readonly label: string; - readonly subject: MigrationStatementSubject; + readonly subject: MigrationSubject; } /** What a plan would lose, and whose access it would widen. */ @@ -103,10 +104,7 @@ function renamedModel( * field of a model the plan renames is named through the model's new name, as a rename of the * field is written. */ -export function subjectText( - subject: MigrationStatementSubject, - contracts: StatementContracts, -): string { +export function subjectText(subject: MigrationSubject, contracts: StatementContracts): string { switch (subject.kind) { case 'model': return modelText(contracts.origin, subject); @@ -126,13 +124,13 @@ export function subjectText( /** The statement's description, written as its question names the subject: `delete field "User.nickname"`. */ export function consentDescription( verb: ConsentVerb, - subject: MigrationStatementSubject, + subject: MigrationSubject, text: string, ): string { return `${verb} ${subject.kind} "${text}"`; } -function lossText(subject: MigrationStatementSubject, text: string, originKnown: boolean): string { +function lossText(subject: MigrationSubject, text: string, originKnown: boolean): string { switch (subject.kind) { case 'model': return `would lose the data of model "${text}"`; @@ -145,10 +143,7 @@ function lossText(subject: MigrationStatementSubject, text: string, originKnown: } } -function sameCoordinate( - statement: ResolvedMigrationStatement, - subject: MigrationStatementSubject, -): boolean { +function sameCoordinate(statement: ResolvedMigrationStatement, subject: MigrationSubject): boolean { if (subject.kind === 'storage') return false; const { from } = statement; if (from.namespaceId !== subject.namespaceId || from.model !== subject.model) return false; @@ -164,7 +159,7 @@ function namesSubject(verb: ConsentVerb, subject: string) { /** Whether the destination still has the subject's model or field, under the name a rename gives it. */ function inDestination( - subject: MigrationStatementSubject, + subject: MigrationSubject, contracts: StatementContracts & { readonly destination: ContractWithDomain }, ): boolean { if (subject.kind === 'storage') return false; @@ -235,11 +230,11 @@ export function accessWideningQuestion( /** A subject the user consented to, with the statement's text as its question wrote it. */ export interface ConsentedSubject { readonly verb: ConsentVerb; - readonly subject: MigrationStatementSubject; + readonly subject: MigrationSubject; readonly text: string; } -const keyOf = (planned: PlannedSubject) => JSON.stringify(planned.subject); +const keyOf = (planned: PlannedSubject) => migrationSubjectKey(planned.subject); function checkAnswers(questions: readonly PlanQuestion[], answers: readonly PlanAnswer[]): void { if (answers.length !== questions.length) { @@ -278,7 +273,7 @@ function checkAnswers(questions: readonly PlanQuestion[], answers: readonly Plan * is asked in the next round. The first round asks even when nothing is in question, so a statement no question consumed is * refused before anything is done. */ -export async function answerPlanQuestions(input: { +export async function askPlanQuestions(input: { readonly plan: TPlan; readonly askAccess: boolean; readonly renames: readonly StatementText[]; 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 index 2ad51c5be83d..60456e16da1d 100644 --- 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 @@ -3,17 +3,17 @@ import { type AppliedMigrationStatement, describeMigrationStatement, type MigrationStatementJson, - type MigrationStatementSubjectJson, + type MigrationSubjectJson, migrationStatementJson, - migrationStatementSubjectJson, + migrationSubjectJson, } from '@internal/framework-components/control'; -import { type ConsentedSubject, consentDescription } from './data-loss-questions'; import type { ConsentVerb } from './parse-consent'; +import { type ConsentedSubject, consentDescription } from './plan-questions'; /** A delete or allow statement in JSON output: the subject whose data or access it consents to. */ export interface ConsentStatementJson { readonly kind: ConsentVerb; - readonly subject: MigrationStatementSubjectJson; + readonly subject: MigrationSubjectJson; } /** @@ -61,7 +61,7 @@ export function reportConsentStatement( ): AppliedStatementReport { return { verb: consented.verb, - statement: { kind: consented.verb, subject: migrationStatementSubjectJson(consented.subject) }, + statement: { kind: consented.verb, subject: migrationSubjectJson(consented.subject) }, operationIndexes, description: consentDescription(consented.verb, consented.subject, consented.text), }; 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 fb9b161459e0..35496a71a236 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 @@ -16,6 +16,7 @@ import type { MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, + MigrationPlanSubjects, OperationPreview, VerifyDatabaseResult, VerifyDatabaseSchemaResult, @@ -25,7 +26,7 @@ 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 { AnswerPlanQuestions } from './statements/data-loss-questions'; +import type { AnswerPlanQuestions } from './statements/plan-questions'; import type { AppliedStatementReport } from './statements/report-applied-statements'; import type { StatementText } from './statements/statement-text'; @@ -448,27 +449,15 @@ export interface DbInitFailure { */ export type DbInitResult = Result; -/** What the operations of a plan lose and whose access they widen, by position in the plan. */ -export interface PlanSubjectsReport { - readonly dataLoss: readonly MigrationOperationSubject[]; - readonly accessWidening: readonly MigrationOperationSubject[]; -} - /** An operation an apply asks about, with its subject written as the question writes it: `User.nickname`. */ export interface AskedSubject extends MigrationOperationSubject { readonly text: string; } -/** The operations an apply asks about, each with its subject as the question writes it. */ -export interface AskedSubjectsReport { - readonly dataLoss: readonly AskedSubject[]; - readonly accessWidening: readonly AskedSubject[]; -} - /** * Successful dbUpdate result. */ -export interface DbUpdateSuccess { +export interface DbUpdateSuccess extends MigrationPlanSubjects { readonly mode: 'plan' | 'apply'; readonly plan: { readonly operations: ReadonlyArray<{ @@ -504,10 +493,6 @@ export interface DbUpdateSuccess { readonly perSpace?: ReadonlyArray; /** The statements the application space's plan applied, in order; empty when none were given. */ readonly appliedStatements: readonly AppliedStatementReport[]; - /** The operations in `plan.operations` that lose data, by position, with what each loses. */ - readonly dataLoss: readonly AskedSubject[]; - /** The operations in `plan.operations` that widen who can read or write data, by position. */ - readonly accessWidening: readonly AskedSubject[]; 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 97b2c37c955e..6bd3c20eedd3 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 @@ -142,6 +142,12 @@ export type { RenderContractDtsResult, RenderContractDtsSuccess, } from '../control-api/render-contract-dts'; +export type { + AnswerPlanQuestions, + PlanAnswer, + PlanQuestion, + PlanQuestionVerb, +} from '../control-api/statements/plan-questions'; export type { AppliedStatementReport } from '../control-api/statements/report-applied-statements'; export { type ResolveStatementsInput, 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 231c3915c979..4e8cfa1b7b42 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 @@ -1,5 +1,5 @@ import { ormConfigSection } from '@internal/config-loader'; -import { migrationStatementSubjectJson } from '@internal/framework-components/control'; +import { migrationSubjectJson } from '@internal/framework-components/control'; import { ifDefined } from '@internal/utils/defined'; import { isStructuredError } from '@internal/utils/structured-error'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -70,7 +70,7 @@ function updatePresentations(inputs: { function subjectEntryJson(entry: AskedSubject) { return { operationIndex: entry.operationIndex, - subject: migrationStatementSubjectJson(entry.subject), + subject: migrationSubjectJson(entry.subject), text: entry.text, }; } diff --git a/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts b/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts index c513125204b0..1522e28f5755 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/plan-question-prompt.ts @@ -1,8 +1,5 @@ import type { PromptSurface } from '@prisma/cli-engine'; -import type { - AnswerPlanQuestions, - PlanAnswer, -} from '../control-api/statements/data-loss-questions'; +import type { AnswerPlanQuestions, PlanAnswer } from '../control-api/statements/plan-questions'; /** * Asks a plan's questions through the engine's statement prompt, all in one batch that refuses a 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 6edd54e1a0fe..4c349e8f3c30 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 @@ -1,6 +1,6 @@ import type { MigrationPlannerConflict, - MigrationStatementSubjectJson, + MigrationSubjectJson, OperationPreview, } from '@internal/framework-components/control'; import type { AppliedStatementReport } from '../../control-api/statements/report-applied-statements'; @@ -49,7 +49,7 @@ export function previewBlockHeader(preview: OperationPreview): string { /** An operation of a plan, by its position in `plan.operations`, and its subject. */ export interface PlannedSubjectJson { readonly operationIndex: number; - readonly subject: MigrationStatementSubjectJson; + readonly subject: MigrationSubjectJson; /** The subject as the apply's question writes it, and as `--delete` or `--allow` takes it. */ readonly text: string; } 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 39fc6280af9a..930042f05f86 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 @@ -11,7 +11,7 @@ import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok } from '@internal/utils/result'; import { describe, expect, it, vi } from 'vitest'; import { executeDbUpdate } from '../../src/control-api/operations/db-update'; -import type { PlanQuestion } from '../../src/control-api/statements/data-loss-questions'; +import type { PlanQuestion } from '../../src/control-api/statements/plan-questions'; import type { ControlProgressEvent } from '../../src/control-api/types'; const FAKE_MIGRATIONS_DIR = '/tmp/__test-db-update-migrations'; diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts similarity index 95% rename from packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts rename to packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index a1ed54c0eb5c..40efb542a5ee 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/answer-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -2,11 +2,11 @@ import { asNamespaceId } from '@internal/contract/types'; import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; import { - answerPlanQuestions, + askPlanQuestions, type PlannedQuestions, type PlannedSubject, type PlanQuestion, -} from '../../../src/control-api/statements/data-loss-questions'; +} from '../../../src/control-api/statements/plan-questions'; import { contractOf } from './statement-fixtures'; const origin = contractOf({ app: { models: { T: { fields: ['id', 'ratio'] } } } }); @@ -35,7 +35,7 @@ function run(replanned: PlannedQuestions, typed: readonly (readonly string[])[]) let round = 0; return { asked, - result: answerPlanQuestions({ + result: askPlanQuestions({ plan: planWith(dropRatio), askAccess: false, renames: [], diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts index 8516d81503e2..9ffe2d9c53ff 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -1,6 +1,6 @@ import { asNamespaceId } from '@internal/contract/types'; import { describe, expect, it } from 'vitest'; -import { dataLossQuestion } from '../../../src/control-api/statements/data-loss-questions'; +import { dataLossQuestion } from '../../../src/control-api/statements/plan-questions'; import { contractOf } from './statement-fixtures'; const origin = contractOf({ app: { models: { User: { fields: ['email'] } } } }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index 5549b40f6fd7..e428ad9d31f4 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -9,7 +9,7 @@ import { dataLossQuestion, type PlanAnswer, type PlanQuestion, -} from '../../src/control-api/statements/data-loss-questions'; +} from '../../src/control-api/statements/plan-questions'; import type { ControlClient, DbUpdateOptions } from '../../src/control-api/types'; import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; import { createOrmTestCli } from '../helpers/orm-test-cli'; diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 0876ef8b59ad..1ef6e40bc581 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -10,7 +10,7 @@ import { createTestCli } from '@prisma/cli-engine/testing'; import { basename, dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { executeMigrationPlanCommand } from '../../src/control-api/operations/migration-plan'; -import type { AnswerPlanQuestions } from '../../src/control-api/statements/data-loss-questions'; +import type { AnswerPlanQuestions } from '../../src/control-api/statements/plan-questions'; import type { StatementText } from '../../src/control-api/statements/statement-text'; import { BIN_GROUPS } from '../../src/orm/cli'; import { errorUnfilledPlaceholder } from '../../src/utils/cli-errors'; 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 63d33be79ce6..f31af6face40 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 @@ -5,10 +5,10 @@ import type { ControlAdapterInstance, ControlFamilyInstance, MigrationOperationPolicy, - MigrationOperationSubject, MigrationPlan, MigrationPlannerConflict, MigrationPlanOperation, + MigrationPlanSubjects, ResolvedMigrationStatement, TargetMigrationsCapability, } from '@internal/framework-components/control'; @@ -104,24 +104,6 @@ export interface AppSpacePlanningInputs { readonly statements: readonly ResolvedMigrationStatement[]; } -/** - * Per-space output of the planner. The runner ingests this - * shape directly via a thin `toRunnerInput` adapter at the CLI. - * - * - `plan`: ready-to-execute `MigrationPlan` with `targetId` already - * set from `aggregate.targetId`. - * - `displayOps`: same operation list, surfaced separately so plan-mode - * output can render without touching the runner-bound `plan`. - * - `destinationContract`: the typed contract value the runner uses - * for post-apply verification. For the app space, the user's - * contract; for extension spaces, the on-disk `contract.json`. - * - `strategy`: which operation produced this plan — `'resolve-recorded-path'` - * (walked a recorded migration path), `'plan-from-diff'` (fabricated a - * plan from a state diff), or `'declared-state'` (the space ships no - * migration packages at all; the aggregate declares the no-op directly - * without invoking either). Surfaced for diagnostics; not consumed by - * the runner. - */ /** * Per-edge metadata for the chain assembled by resolving a contract * space's recorded migration path. Lets `migrate` surface a per-migration @@ -150,7 +132,29 @@ export interface AggregateMigrationEdgeRef { readonly destinationContractJson?: unknown; } -export interface PerSpacePlan { +/** + * Per-space output of the planner. The runner ingests this + * shape directly via a thin `toRunnerInput` adapter at the CLI. + * + * - `plan`: ready-to-execute `MigrationPlan` with `targetId` already + * set from `aggregate.targetId`. + * - `displayOps`: same operation list, surfaced separately so plan-mode + * output can render without touching the runner-bound `plan`. + * - `destinationContract`: the typed contract value the runner uses + * for post-apply verification. For the app space, the user's + * contract; for extension spaces, the on-disk `contract.json`. + * - `strategy`: which operation produced this plan — `'resolve-recorded-path'` + * (walked a recorded migration path), `'plan-from-diff'` (fabricated a + * plan from a state diff), or `'declared-state'` (the space ships no + * migration packages at all; the aggregate declares the no-op directly + * without invoking either). Surfaced for diagnostics; not consumed by + * the runner. + * - `dataLoss` and `accessWidening`: what the operations of `plan` lose + * and whose access they widen. A recorded path names each destructive + * operation by its storage name, since no planner mapped it to a model, + * and lists no widening: a written migration is reviewed before it runs. + */ +export interface PerSpacePlan extends MigrationPlanSubjects { readonly plan: MigrationPlan; readonly displayOps: readonly MigrationPlanOperation[]; readonly destinationContract: Contract; @@ -158,16 +162,6 @@ export interface PerSpacePlan { readonly warnings?: readonly MigrationPlannerConflict[]; /** The statements the plan applied; empty for a space planned without statements. */ readonly appliedStatements: readonly AppliedMigrationStatement[]; - /** - * The operations of `plan` that lose data, with what each loses. A recorded path names each - * destructive operation by its storage name, since no planner mapped it to a model. - */ - readonly dataLoss: readonly MigrationOperationSubject[]; - /** - * The operations of `plan` that widen access, with what each is about. Empty for a recorded path: - * a written migration is reviewed before it runs. - */ - readonly accessWidening: readonly MigrationOperationSubject[]; /** * 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/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts index 80794d69b607..8895d546527a 100644 --- a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -1,7 +1,8 @@ import { asNamespaceId, type ContractWithDomain } from '@internal/contract/types'; import type { MigrationOperationSubject, - MigrationStatementSubject, + MigrationPlanSubjects, + MigrationSubject, ModelCoordinate, ResolvedMigrationStatement, } from '@internal/framework-components/control'; @@ -18,7 +19,7 @@ import { * column of one, in a storage namespace, or neither, such as a type. `storageName` names the * subject when no model of the origin contract stores it. */ -export interface SubjectTarget { +export interface SubjectStorage { readonly storageName: string; readonly table: | { @@ -30,11 +31,11 @@ export interface SubjectTarget { } /** The target of a codec hook's call: the column of the field event it was returned for. */ -export function fieldEventTarget(field: { +export function fieldEventStorage(field: { readonly namespaceId: string; readonly tableName: string; readonly columnName: string; -}): SubjectTarget { +}): SubjectStorage { const qualifier = field.namespaceId === UNBOUND_NAMESPACE_ID ? [] : [field.namespaceId]; return { storageName: [...qualifier, field.tableName, field.columnName].join('.'), @@ -66,14 +67,9 @@ export function unknownCallNames export interface CallSubjects { readonly operationCount: number; /** What the call's operation loses, one entry per model, field or storage name. */ - readonly dataLoss: readonly SubjectTarget[]; + readonly dataLoss: readonly SubjectStorage[]; /** What the call's operation widens access to. */ - readonly accessWidening: readonly SubjectTarget[]; -} - -export interface PlanSubjects { - readonly dataLoss: readonly MigrationOperationSubject[]; - readonly accessWidening: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly SubjectStorage[]; } interface SubjectContext { @@ -148,8 +144,8 @@ function modelsStoring( ); } -function subjectOf(target: SubjectTarget, context: SubjectContext): MigrationStatementSubject { - const storage: MigrationStatementSubject = { kind: 'storage', name: target.storageName }; +function subjectOf(target: SubjectStorage, context: SubjectContext): MigrationSubject { + const storage: MigrationSubject = { kind: 'storage', name: target.storageName }; const { fromContract } = context; if (target.table === undefined || fromContract === null) return storage; const withOrigin = { ...context, fromContract }; @@ -173,10 +169,10 @@ function subjectOf(target: SubjectTarget, context: SubjectContext): MigrationSta * the plan. A subject is a model or a field of the origin contract when one stores it, found under * its origin name when a rename statement renamed its table or column earlier in the plan. */ -export function planSubjects( +export function subjectsOfCalls( calls: readonly CallSubjects[], context: SubjectContext, -): PlanSubjects { +): MigrationPlanSubjects { const dataLoss: MigrationOperationSubject[] = []; const accessWidening: MigrationOperationSubject[] = []; let operationIndex = 0; 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 4d89dc7e5ff6..b34d8549e183 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,8 +1,10 @@ -import type { AppliedMigrationStatement } from '@internal/framework-components/control'; +import type { + AppliedMigrationStatement, + MigrationPlanSubjects, +} 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'; -import type { PlanSubjects } from './operation-subjects'; import type { AnyRecord, CreateSqlMigrationPlanOptions, @@ -121,7 +123,7 @@ export function createMigrationPlan( export function plannerSuccess( plan: SqlMigrationPlan, appliedStatements: readonly AppliedMigrationStatement[], - subjects: PlanSubjects, + subjects: MigrationPlanSubjects, warnings?: readonly SqlPlannerConflict[], ): SqlPlannerSuccessResult { return Object.freeze({ diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 8ab924557ae5..72217d9595c7 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -48,12 +48,11 @@ export { export { storageNameOfOperation } from '../core/migrations/operation-storage-name'; export type { CallSubjects, - PlanSubjects, - SubjectTarget, + SubjectStorage, } from '../core/migrations/operation-subjects'; export { - fieldEventTarget, - planSubjects, + fieldEventStorage, + subjectsOfCalls, unknownCallNames, } from '../core/migrations/operation-subjects'; export { diff --git a/packages/2-sql/9-family/test/operation-subjects.test.ts b/packages/2-sql/9-family/test/operation-subjects.test.ts index 58dae4bc957f..06d578b417d3 100644 --- a/packages/2-sql/9-family/test/operation-subjects.test.ts +++ b/packages/2-sql/9-family/test/operation-subjects.test.ts @@ -3,27 +3,27 @@ import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { describe, expect, it } from 'vitest'; import { type CallSubjects, - fieldEventTarget, - planSubjects, - type SubjectTarget, + fieldEventStorage, + type SubjectStorage, + subjectsOfCalls, unknownCallNames, } from '../src/core/migrations/operation-subjects'; import { contractOf, renameField, renameModel } from './statement-fixtures'; const app = asNamespaceId('app'); -function table(name: string, storageName = name): SubjectTarget { +function table(name: string, storageName = name): SubjectStorage { return { storageName, table: { namespaceId: 'app', table: name, column: undefined } }; } -function column(tableName: string, columnName: string): SubjectTarget { +function column(tableName: string, columnName: string): SubjectStorage { return { storageName: `${tableName}.${columnName}`, table: { namespaceId: 'app', table: tableName, column: columnName }, }; } -function losing(...targets: readonly SubjectTarget[]): CallSubjects { +function losing(...targets: readonly SubjectStorage[]): CallSubjects { return { operationCount: 1, dataLoss: targets, accessWidening: [] }; } @@ -34,10 +34,10 @@ const origin = contractOf({ User: { table: 'user', fields: { id: 'id', name: 'full_name' } }, }); -describe('planSubjects', () => { +describe('subjectsOfCalls', () => { it('names the model of a dropped table and the field of a dropped column', () => { expect( - planSubjects([unchanged, losing(table('legacy')), losing(column('user', 'full_name'))], { + subjectsOfCalls([unchanged, losing(table('legacy')), losing(column('user', 'full_name'))], { fromContract: origin, contract: contractOf({ User: { table: 'user', fields: { id: 'id' } } }), statements: [], @@ -56,7 +56,7 @@ describe('planSubjects', () => { it('counts every operation of a call with companions before the next call', () => { expect( - planSubjects( + subjectsOfCalls( [{ operationCount: 3, dataLoss: [], accessWidening: [] }, losing(table('legacy'))], { fromContract: origin, contract: origin, statements: [] }, ).dataLoss, @@ -73,7 +73,7 @@ describe('planSubjects', () => { User: { table: 'user', fields: { id: 'id', name: 'full_name' } }, }); expect( - planSubjects([losing(column('account', 'display_name'))], { + subjectsOfCalls([losing(column('account', 'display_name'))], { fromContract: from, contract: destination, statements: [ @@ -91,11 +91,14 @@ describe('planSubjects', () => { it('names storage the origin contract does not declare by its storage name', () => { expect( - planSubjects([losing(table('audit_log', 'public.audit_log'), column('user', 'nickname'))], { - fromContract: origin, - contract: origin, - statements: [], - }).dataLoss, + subjectsOfCalls( + [losing(table('audit_log', 'public.audit_log'), column('user', 'nickname'))], + { + fromContract: origin, + contract: origin, + statements: [], + }, + ).dataLoss, ).toEqual([ { operationIndex: 0, subject: { kind: 'storage', name: 'public.audit_log' } }, { operationIndex: 0, subject: { kind: 'storage', name: 'user.nickname' } }, @@ -104,7 +107,7 @@ describe('planSubjects', () => { it('names every subject by its storage name when the plan has no origin contract', () => { expect( - planSubjects([losing(table('legacy'), column('user', 'full_name'))], { + subjectsOfCalls([losing(table('legacy'), column('user', 'full_name'))], { fromContract: null, contract: origin, statements: [], @@ -117,7 +120,7 @@ describe('planSubjects', () => { it('names a subject with no table, such as a type, by its storage name', () => { expect( - planSubjects([losing({ storageName: 'public.mood', table: undefined })], { + subjectsOfCalls([losing({ storageName: 'public.mood', table: undefined })], { fromContract: origin, contract: origin, statements: [], @@ -127,7 +130,7 @@ describe('planSubjects', () => { it('lists access-widening operations with the model of their table', () => { expect( - planSubjects( + subjectsOfCalls( [unchanged, { operationCount: 1, dataLoss: [], accessWidening: [table('user')] }], { fromContract: origin, contract: origin, statements: [] }, ), @@ -148,7 +151,7 @@ describe('planSubjects', () => { it('names the root model of a dropped table, and the model with a field for a dropped column', () => { expect( - planSubjects([losing(table('users')), losing(column('users', 'email'))], { + subjectsOfCalls([losing(table('users')), losing(column('users', 'email'))], { fromContract: sharing, contract: sharing, statements: [], @@ -161,7 +164,7 @@ describe('planSubjects', () => { it('names a field only a variant has through the variant', () => { expect( - planSubjects([losing(column('users', 'level'))], { + subjectsOfCalls([losing(column('users', 'level'))], { fromContract: sharing, contract: sharing, statements: [], @@ -186,13 +189,13 @@ describe('unknownCallNames', () => { }); }); -describe('fieldEventTarget', () => { +describe('fieldEventStorage', () => { it('names the field event column, qualified by its namespace outside the unbound one', () => { - const subjects = planSubjects( + const subjects = subjectsOfCalls( [ losing( - fieldEventTarget({ namespaceId: 'app', tableName: 'user', columnName: 'full_name' }), - fieldEventTarget({ namespaceId: 'app', tableName: 'user', columnName: 'nickname' }), + fieldEventStorage({ namespaceId: 'app', tableName: 'user', columnName: 'full_name' }), + fieldEventStorage({ namespaceId: 'app', tableName: 'user', columnName: 'nickname' }), ), ], { fromContract: origin, contract: origin, statements: [] }, @@ -202,7 +205,7 @@ describe('fieldEventTarget', () => { { kind: 'storage', name: 'app.user.nickname' }, ]); expect( - fieldEventTarget({ namespaceId: UNBOUND_NAMESPACE_ID, tableName: 'user', columnName: 'bio' }) + fieldEventStorage({ namespaceId: UNBOUND_NAMESPACE_ID, tableName: 'user', columnName: 'bio' }) .storageName, ).toBe('user.bio'); }); 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 1537f5fe6d11..b0b03ac4794e 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 @@ -10,7 +10,7 @@ import { type MigrationPlannerResult, type MigrationPlanWithAuthoringSurface, type MigrationScaffoldContext, - type MigrationStatementSubject, + type MigrationSubject, type ModelCoordinate, type PlanOrigin, type ResolvedMigrationStatement, @@ -160,7 +160,7 @@ function collectionDrops( return calls.flatMap((call, operationIndex) => { if (!(call instanceof DropCollectionCall)) return []; const model = fromContract === null ? undefined : modelStoredIn(fromContract, call.collection); - const subject: MigrationStatementSubject = + const subject: MigrationSubject = model === undefined ? { kind: 'storage', name: call.collection } : { kind: 'model', ...model }; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts index ebaf3076d1c4..77a10935d499 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -2,8 +2,8 @@ import type { Contract } from '@internal/contract/types'; import { type CallSubjects, type FieldEventCall, - fieldEventTarget, - type SubjectTarget, + fieldEventStorage, + type SubjectStorage, storageNameOfOperation, unknownCallNames, } from '@internal/family-sql/control'; @@ -44,14 +44,14 @@ function target( schemaName: string, table: string, column: string | undefined, -): SubjectTarget { +): SubjectStorage { return { storageName: qualified(schemaName, column === undefined ? table : `${table}.${column}`), table: { namespaceId: storageNamespaceId(locator, schemaName), table, column }, }; } -function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectTarget { +function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectStorage { const details = call.op.target.details; if (details === undefined) return { storageName: call.op.id, table: undefined }; if (details.objectType === 'table') @@ -62,7 +62,7 @@ function rawSqlTarget(locator: Locator, call: RawSqlCall): SubjectTarget { return { storageName: storageNameOfOperation(call.op), table: undefined }; } -function knownTarget(locator: Locator, call: OpFactoryCall): SubjectTarget | undefined { +function knownTarget(locator: Locator, call: OpFactoryCall): SubjectStorage | undefined { if (call instanceof DropTableCall) { return target(locator, call.schemaName, call.tableName, undefined); } @@ -71,7 +71,7 @@ function knownTarget(locator: Locator, call: OpFactoryCall): SubjectTarget | und } if (call instanceof RawSqlCall) return rawSqlTarget(locator, call); const fieldEvent = locator.fieldEvents.get(call); - return fieldEvent === undefined ? undefined : fieldEventTarget(fieldEvent); + return fieldEvent === undefined ? undefined : fieldEventStorage(fieldEvent); } /** @@ -100,7 +100,7 @@ function accessWideningOf( locator: Locator, call: OpFactoryCall, replaced: ReadonlySet, -): readonly SubjectTarget[] { +): readonly SubjectStorage[] { if (call instanceof DisableRowLevelSecurityCall) { return [target(locator, call.schemaName, call.tableName, undefined)]; } 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 c16b34457226..9e2d984d3777 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 @@ -16,8 +16,8 @@ import { planFieldEventCalls, plannerFailure, planStatements, - planSubjects, sqlTypeLookupsOf, + subjectsOfCalls, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -464,7 +464,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr this.#lowerer, ), appliedStatements: statements.value.appliedStatements, - ...planSubjects( + ...subjectsOfCalls( postgresCallSubjects(calls, { contract: options.fromContract ?? options.contract, fieldEvents: new Map(fieldEventCalls.map((fieldEvent) => [fieldEvent.call, fieldEvent])), diff --git a/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts index f27bf100bf4a..edaa5399aceb 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/operation-subjects.test.ts @@ -1,5 +1,5 @@ import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; -import { type FieldEventCall, planSubjects } from '@internal/family-sql/control'; +import { type FieldEventCall, subjectsOfCalls } from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; @@ -65,7 +65,7 @@ function destructiveCall(factoryName: string): OpFactoryCall { } function subjectsOf(calls: readonly OpFactoryCall[], fieldEvents: readonly FieldEventCall[]) { - return planSubjects( + return subjectsOfCalls( postgresCallSubjects(calls, { contract, fieldEvents: new Map(fieldEvents.map((fieldEvent) => [fieldEvent.call, fieldEvent])), diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts index a2203c844629..592b98afac95 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/operation-subjects.ts @@ -1,8 +1,8 @@ import { type CallSubjects, type FieldEventCall, - fieldEventTarget, - type SubjectTarget, + fieldEventStorage, + type SubjectStorage, storageNameOfOperation, unknownCallNames, } from '@internal/family-sql/control'; @@ -10,14 +10,14 @@ import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { DropColumnCall, DropTableCall, RawSqlCall, RecreateTableCall } from './op-factory-call'; -function tableTarget(table: string): SubjectTarget { +function tableTarget(table: string): SubjectStorage { return { storageName: table, table: { namespaceId: UNBOUND_NAMESPACE_ID, table, column: undefined }, }; } -function columnTarget(table: string, column: string): SubjectTarget { +function columnTarget(table: string, column: string): SubjectStorage { return { storageName: `${table}.${column}`, table: { namespaceId: UNBOUND_NAMESPACE_ID, table, column }, @@ -84,11 +84,11 @@ export function sqliteCallSubjects( return { operationCount, dataLoss: [], accessWidening: [] }; } const fieldEvent = fieldEvents.get(call); - const lossTarget: SubjectTarget = + const lossTarget: SubjectStorage = call instanceof RawSqlCall ? { storageName: storageNameOfOperation(call.op), table: undefined } : fieldEvent !== undefined - ? fieldEventTarget(fieldEvent) + ? fieldEventStorage(fieldEvent) : { storageName: unknownNames.get(call) ?? call.factoryName, table: undefined }; return { operationCount, dataLoss: [lossTarget], accessWidening: [] }; }); 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 96dbb09d3d95..32ecb9a20466 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 @@ -13,7 +13,7 @@ import { planFieldEventCalls, plannerFailure, planStatements, - planSubjects, + subjectsOfCalls, } from '@internal/family-sql/control'; import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; @@ -254,7 +254,7 @@ export class SqliteMigrationPlanner this.#lowerer, ), appliedStatements: statements.value.appliedStatements, - ...planSubjects( + ...subjectsOfCalls( sqliteCallSubjects( calls, new Map(fieldEventCalls.map((fieldEvent) => [fieldEvent.call, fieldEvent])), diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts index dafd219a0fe4..3e326cf0cce2 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/operation-subjects.test.ts @@ -1,5 +1,5 @@ import { asNamespaceId, type Contract, coreHash, profileHash } from '@internal/contract/types'; -import { type FieldEventCall, planSubjects } from '@internal/family-sql/control'; +import { type FieldEventCall, subjectsOfCalls } from '@internal/family-sql/control'; import type { OpFactoryCall } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; @@ -66,7 +66,7 @@ function destructiveCall(factoryName: string): OpFactoryCall { } function subjectsOf(calls: readonly OpFactoryCall[], fieldEvents: readonly FieldEventCall[]) { - return planSubjects( + return subjectsOfCalls( sqliteCallSubjects( calls, new Map(fieldEvents.map((fieldEvent) => [fieldEvent.call, fieldEvent])), diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md index b34b0f28c403..81ae6e925dc5 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -55,15 +55,15 @@ changes: - `dataLoss: readonly MigrationOperationSubject[]`: one entry per operation, in plan order, that can lose rows or values (dropping a table, a column or a collection, or a type change that can change values). - `accessWidening: readonly MigrationOperationSubject[]`: one entry per operation that widens who can read or write rows, such as dropping a row-level-security policy or disabling row-level security. -A `MigrationOperationSubject` is `{ operationIndex, subject }`: the operation's position in the plan's `operations`, and a `MigrationStatementSubject`, which is `{ kind: 'model', namespaceId, model }` or `{ kind: 'field', namespaceId, model, field }` when the operation is about a model or field of `fromContract`, else `{ kind: 'storage', name }` with the name the database knows it by. The CLI turns each entry into a question the user answers with `--delete`, `--rename` or `--allow` before anything is written or applied, so a planner must list every operation of these kinds; an operation it leaves out is applied without asking. +A `MigrationOperationSubject` is `{ operationIndex, subject }`: the operation's position in the plan's `operations`, and a `MigrationSubject`, which is `{ kind: 'model', namespaceId, model }` or `{ kind: 'field', namespaceId, model, field }` when the operation is about a model or field of `fromContract`, else `{ kind: 'storage', name }` with the name the database knows it by. The CLI turns each entry into a question the user answers with `--delete`, `--rename` or `--allow` before anything is written or applied, so a planner must list every operation of these kinds; an operation it leaves out is applied without asking. -`MigrationStatementSubject`, `MigrationOperationSubject`, `MigrationStatementSubjectJson`, `migrationStatementSubjectJson` and `DeleteMigrationStatement` are imported from `@prisma/orm-framework/components/control`, and `describeMigrationStatement` also accepts a `DeleteMigrationStatement`. In a planner or a test double that returns `{ kind: 'success', plan, appliedStatements }`, add `dataLoss: []` and `accessWidening: []` when it plans no such operation. +`MigrationSubject`, `MigrationOperationSubject`, `MigrationSubjectJson` and `migrationSubjectJson` are imported from `@prisma/orm-framework/components/control`. In a planner or a test double that returns `{ kind: 'success', plan, appliedStatements }`, add `dataLoss: []` and `accessWidening: []` when it plans no such operation. Detection finds files that name `MigrationPlanner`, `MigrationPlannerResult` or `MigrationPlannerSuccessResult` and return `kind: 'success'` without `dataLoss` anywhere in the file. Check by hand a file that mentions `dataLoss` once, and a success result typed through a family type such as `SqlPlannerSuccessResult`. ## `sql-planner-success-subjects` -Change `plannerSuccess(plan, appliedStatements)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] })`, and `plannerSuccess(plan, appliedStatements, warnings)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] }, warnings)`, when the planner plans no operation that loses data or widens access. A SQL planner that does computes the lists with `planSubjects(calls, context)` from the same module, which takes what each call loses and widens (`CallSubjects`, as the Postgres and SQLite planners build them) and names each subject through the origin contract; `planFieldEventCalls(...)` returns the calls of the codec field-event hooks with the column each was returned for (`FieldEventCall`), so their subjects can be named too. +Change `plannerSuccess(plan, appliedStatements)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] })`, and `plannerSuccess(plan, appliedStatements, warnings)` to `plannerSuccess(plan, appliedStatements, { dataLoss: [], accessWidening: [] }, warnings)`, when the planner plans no operation that loses data or widens access. A SQL planner that does computes the lists with `subjectsOfCalls(calls, context)` from the same module, which takes what each call loses and widens (`CallSubjects`, as the Postgres and SQLite planners build them) and names each subject through the origin contract; `planFieldEventCalls(...)` returns the calls of the codec field-event hooks with the column each was returned for (`FieldEventCall`), so their subjects can be named too. ## `per-space-plan-subjects` From 4bca5d7727574761f44e06b9c6f6c401d3288db1 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:37:21 +0200 Subject: [PATCH 076/109] TML-3476: one table declares the statement verbs both ORM commands use Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/db/update.ts | 18 ++--------------- .../3-tooling/cli/src/orm/migration/plan.ts | 14 ++----------- .../3-tooling/cli/src/orm/statement-verbs.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 28 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/orm/statement-verbs.ts 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 4e8cfa1b7b42..74d30932da82 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 @@ -36,6 +36,7 @@ import { baseDirFor, migrationsDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { promptPlanQuestions } from '../plan-question-prompt'; import { controlProgressReporter } from '../progress'; +import { ormStatementVerbs } from '../statement-verbs'; import { migrationResultBlocks, migrationResultNextActions } from './migration-blocks'; import { prepareMigrationRun } from './prepare'; @@ -174,22 +175,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { }), }, }, - statements: { - rename: { - arity: 1, - brief: - 'Rename a model or field instead of dropping it: old:new, each side Model, namespace.Model, Model.field or namespace.Model.field; repeat for several, applied in order', - }, - delete: { - arity: 1, - brief: - 'Let the update lose the data of a model, field or storage name the refusal lists: --delete Model', - }, - allow: { - arity: 1, - brief: 'Let the update widen who can read or write the rows of a model: --allow Model', - }, - }, + statements: ormStatementVerbs, needs: { config: ormConfigSection }, handler: async (args, ctx) => { const startedAt = Date.now(); 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 d9d8df4b2aac..abd2bc4afd54 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 @@ -20,6 +20,7 @@ import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; import { promptPlanQuestions } from '../plan-question-prompt'; import { appliedStatementBlocks } from '../statement-blocks'; +import { ormStatementVerbs } from '../statement-verbs'; import { appMigrationsDirFor, baseDirFor, @@ -289,18 +290,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { }), }, }, - statements: { - rename: { - arity: 1, - brief: - 'Rename a model or field instead of dropping it: old:new, each side Model, namespace.Model, Model.field or namespace.Model.field; repeat for several, applied in order', - }, - delete: { - arity: 1, - brief: - 'Let the plan lose the data of a model, field or storage name the refusal lists: --delete Model', - }, - }, + statements: { rename: ormStatementVerbs.rename, delete: ormStatementVerbs.delete }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { const seeded = (record: ContractSpaceSeedPhaseRecord): void => { diff --git a/packages/1-framework/3-tooling/cli/src/orm/statement-verbs.ts b/packages/1-framework/3-tooling/cli/src/orm/statement-verbs.ts new file mode 100644 index 000000000000..5b51228c38f0 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/statement-verbs.ts @@ -0,0 +1,20 @@ +/** + * The statement verbs the ORM commands declare. Each command declares the verbs its questions + * use: `migration plan` takes `rename` and `delete`, and `db update` also `allow`. + */ +export const ormStatementVerbs = { + rename: { + arity: 1, + brief: + 'Rename a model or field instead of dropping it: old:new, each side Model, namespace.Model, Model.field or namespace.Model.field; repeat for several, applied in order', + }, + delete: { + arity: 1, + brief: + 'Let the command lose the data of a model, field or storage name the refusal lists: --delete Model', + }, + allow: { + arity: 1, + brief: 'Let the command widen who can read or write the rows of a model: --allow Model', + }, +} as const; From dea0b3372903a917fda48014c34925e3a6bf4836 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:38:04 +0200 Subject: [PATCH 077/109] TML-3476: comments and docs say what widening and data mean now The rename calls' comments described a class tradeoff the slice removed, the Style Guide's consent section said drops are destructive, and the class doc promised that only destructive operations lose data, where a data operation runs what its author wrote. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/CLI Style Guide.md | 2 +- .../subsystems/7. Migration System.md | 2 +- .../src/control/control-migration-types.ts | 5 +++-- .../src/core/migrations/op-factory-call.ts | 16 ---------------- .../src/core/migrations/operations/indexes.ts | 7 +------ .../src/core/migrations/op-factory-call.ts | 3 --- 6 files changed, 6 insertions(+), 29 deletions(-) diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index 410e86d1c5c9..702f81796a6e 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -117,7 +117,7 @@ The CLI checks `process.stdout.isTTY` once at startup to determine the output mo ### Destructive operation confirmation -Destructive operations (drops, type changes, overwriting generated files, overwriting an existing signature marker, …) require **explicit consent**. Consent takes one of two forms: +Operations that lose data (dropping a table or a column, a type change that can change values), operations that widen who can read or write rows, overwriting generated files, and overwriting an existing signature marker require **explicit consent**. Dropping an object that holds no data, such as an index or a constraint, is `widening` and asks nothing. Consent takes one of two forms: - **Statements**, for a plan that would lose data or widen access. The command asks one question per model, field or storage name at stake, and each is answered with a statement naming it: `--delete `, `--rename :` or `--allow `. `migration plan` and `db update` use this form. - **A consent token**, for a command with one thing at stake. The user types the name of the thing being changed, or passes that name as `--confirm `. `init` uses this form. diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 4ea008dcb408..e3f2e501eea9 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -447,7 +447,7 @@ Every operation has a class that says what it does to the data (`MigrationOperat - `additive` adds structure and leaves existing structure and data as they are. - `widening` changes existing structure without losing data. An operation that cannot keep every value fails instead of losing it, so tightening a constraint is `widening`: `SET NOT NULL` fails on a NULL (Postgres checks first; a SQLite table rebuild fails on the copy), and a MongoDB validator, new or stricter, applies to later writes only. A rename, a relaxed constraint, a type change that keeps every value (`int2` to `int4` to `int8`, `float4` to `float8` on Postgres), 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), disabling row-level security and changing MongoDB's change-stream image setting are `widening` too. Dropping a policy or disabling row-level security widens who can read and write rows. - `destructive` can lose rows or values: dropping a table, a column or a collection, and any other type change, including a SQLite table rebuild that changes a column type. -- `data` reads and writes rows, such as a backfill or a type conversion. +- `data` reads and writes rows, such as a backfill or a type conversion. It runs what its author wrote, which may lose data by design; only the planner's own classification promises that an operation loses data exactly when it is `destructive`. `db update` applies `additive` operations, and `widening` operations that do not widen access, without asking. Before an apply it asks about each operation that would lose data and each that would widen who can read and write rows (see [`db update`](#db-update-live-reconciliation)). 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 e799afa09a43..0f338d6efce3 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 @@ -72,7 +72,8 @@ export interface MigrationMetadata { // ============================================================================ /** - * What an operation does to the data. Only `destructive` can lose a row or a value. + * What an operation does to the data. Of the classes a planner chooses, only `destructive` loses + * data by itself; a `data` operation runs what its author wrote, which may lose data by design. * - 'additive': adds structure and leaves existing structure and data as they are. * - 'widening': changes existing structure without losing data. An operation that cannot keep * every value fails instead, so a tightened constraint is widening: `SET NOT NULL` fails on a @@ -82,7 +83,7 @@ export interface MigrationMetadata { * disabling row-level security widens who can read and write rows. * - 'destructive': can lose rows or values: dropping a table, a column or a collection, or a type * change that can change values. - * - 'data': reads and writes rows, such as a backfill or a type conversion. + * - 'data': reads and writes rows, such as a backfill or a type conversion, as its author wrote. */ export type MigrationOperationClass = 'additive' | 'widening' | 'destructive' | 'data'; 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 042ecacbf1cd..e5574f91c52a 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 @@ -389,10 +389,6 @@ 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 - // neither additive creation nor destructive, and the class vocabulary has no - // neutral middle class, so this is the class that plans under every - // allowance set except additive-only init. readonly operationClass = 'widening' as const; readonly schemaName: string; readonly oldTableName: string; @@ -1214,10 +1210,6 @@ export class DropConstraintCall extends PostgresOpFactoryCallNode { export class RenameConstraintCall extends PostgresOpFactoryCallNode { readonly factoryName = 'renameConstraint' as const; - // `widening` is chosen so the rename plans under every allowance set except - // additive-only init — a rename is neither additive-creation nor - // destructive, and the class vocabulary has no neutral middle class. It is - // NOT that a rename widens anything; this is the accepted typology tradeoff. readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; @@ -1469,10 +1461,6 @@ export class CreateIndexCall extends PostgresOpFactoryCallNode { export class RenameIndexCall extends PostgresOpFactoryCallNode { readonly factoryName = 'renameIndex' as const; - // `widening` is chosen so the rename plans under every allowance set except - // additive-only init — a rename is neither additive-creation nor - // destructive, and the class vocabulary has no neutral middle class. It is - // NOT that a rename widens anything; this is the accepted typology tradeoff. readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; @@ -2095,10 +2083,6 @@ export class DisableRowLevelSecurityCall extends PostgresOpFactoryCallNode { export class RenamePostgresRlsPolicyCall extends PostgresOpFactoryCallNode { readonly factoryName = 'renameRlsPolicy' as const; - // `widening` is chosen so the rename plans under every allowance set except - // additive-only init — a rename is neither additive-creation nor - // destructive, and the class vocabulary has no neutral middle class. It is - // NOT that a rename widens anything; this is the accepted typology tradeoff. readonly operationClass = 'widening' as const; readonly schemaName: string; readonly tableName: string; 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 616aed1e7559..c832c5a659e9 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 @@ -70,12 +70,7 @@ export async function createIndex( }; } -/** - * `ALTER INDEX … RENAME TO`. `widening` for the same typology reason as the - * RLS policy rename: a rename is neither additive creation nor destructive, - * and the class vocabulary has no neutral middle class — it is NOT that a - * rename widens anything. - */ +/** `ALTER INDEX … RENAME TO`: `widening`, because a rename keeps every row and value. */ export function renameIndexOperationId( schemaName: string, tableName: string, 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 3e4a3ea3de23..7480a82cecf1 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 @@ -314,9 +314,6 @@ export function indexReplacementCalls( export class RenameTableCall extends SqliteOpFactoryCallNode { readonly factoryName = 'renameTable' as const; - // `widening`: a rename is neither additive creation nor destructive, and the - // class vocabulary has no neutral middle class, so this is the class that - // plans under every allowance set except additive-only init. readonly operationClass = 'widening' as const; readonly oldTableName: string; /** The new name: the table's contract-side identity after the rename. */ From 3f99617e3e05fc1877782c99b064e94411ce5996 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:39:42 +0200 Subject: [PATCH 078/109] TML-3476: the three targets' data-loss tests check dataLoss against the operation classes expectDataLossMatchesDestructive in @repo/test-utils asserts that an entry points only at a destructive operation and that every destructive operation is named, unless an earlier one already names its loss, as a SQLite recreate does for the column a later drop removes again. Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/mongo-planner.data-loss.test.ts | 5 ++++ .../test/migrations/planner.data-loss.test.ts | 7 ++++++ .../test/migrations/planner.data-loss.test.ts | 2 ++ test/utils/package.json | 4 ++++ test/utils/src/data-loss-expectations.ts | 24 +++++++++++++++++++ test/utils/tsdown.config.ts | 1 + 6 files changed, 43 insertions(+) create mode 100644 test/utils/src/data-loss-expectations.ts diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts index 31da4a4c29e5..6100bb55cc34 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts @@ -2,6 +2,7 @@ import { asNamespaceId } from '@internal/contract/types'; import type { MigrationOperationPolicy } from '@internal/framework-components/control'; import { MongoCollection, type MongoContract } from '@internal/mongo-contract'; import { MongoSchemaCollection, MongoSchemaIndex, MongoSchemaIR } from '@internal/mongo-schema-ir'; +import { expectDataLossMatchesDestructive } from '@repo/test-utils/data-loss-expectations'; import { describe, expect, it } from 'vitest'; import { MongoMigrationPlanner } from '../src/core/migrations/mongo-planner'; import type { PlannerProducedMongoMigration } from '../src/core/migrations/planner-produced-migration'; @@ -78,6 +79,10 @@ function plan(fromContract: MongoContract | null) { snapshotsImportPath: '../../snapshots', }); if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); + expectDataLossMatchesDestructive( + result, + (result.plan as PlannerProducedMongoMigration).operations, + ); return { operations: (result.plan as PlannerProducedMongoMigration).operations.map( (operation) => operation.label, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts index acaa17ae4531..21c357f35da9 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/planner.data-loss.test.ts @@ -9,6 +9,7 @@ import { import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; +import { expectDataLossMatchesDestructive } from '@repo/test-utils/data-loss-expectations'; import { describe, expect, it } from 'vitest'; import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; import { postgresContractToSchema } from '../../src/core/migrations/postgres-contract-to-schema'; @@ -149,6 +150,12 @@ async function planned( }); if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); const settled = await Promise.allSettled(result.plan.operations); + expectDataLossMatchesDestructive( + result, + settled.map((entry) => + entry.status === 'fulfilled' ? entry.value : { operationClass: 'data' }, + ), + ); const labels = settled.map((entry) => entry.status === 'fulfilled' ? entry.value.label : 'placeholder', ); diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts index b075ff2a0194..a9f4bcc39c79 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -8,6 +8,7 @@ import { import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; import { applicationDomainOf } from '@repo/test-utils'; +import { expectDataLossMatchesDestructive } from '@repo/test-utils/data-loss-expectations'; import { describe, expect, it } from 'vitest'; import { sqliteContractToSchema } from '../../src/core/migrations/diff-database-schema'; import { createSqliteMigrationPlanner } from '../../src/core/migrations/planner'; @@ -112,6 +113,7 @@ async function planned( }); if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); const operations = await Promise.all(result.plan.operations); + expectDataLossMatchesDestructive(result, operations); const labels = operations.map((op) => op.label); return { labels, diff --git a/test/utils/package.json b/test/utils/package.json index af6de60c8183..46527cc090c8 100644 --- a/test/utils/package.json +++ b/test/utils/package.json @@ -55,6 +55,10 @@ "types": "./dist/exports/timeouts.d.mts", "import": "./dist/exports/timeouts.mjs" }, + "./data-loss-expectations": { + "types": "./dist/exports/data-loss-expectations.d.mts", + "import": "./dist/exports/data-loss-expectations.mjs" + }, "./typed-expectations": { "types": "./dist/exports/typed-expectations.d.mts", "import": "./dist/exports/typed-expectations.mjs" diff --git a/test/utils/src/data-loss-expectations.ts b/test/utils/src/data-loss-expectations.ts new file mode 100644 index 000000000000..2c46fbdd3b91 --- /dev/null +++ b/test/utils/src/data-loss-expectations.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest'; + +/** + * Asserts a planner's `dataLoss` against its own classification: an entry points only at a + * `destructive` operation, and every `destructive` operation has an entry unless an earlier + * destructive operation already names what it loses, as a SQLite recreate does for a column a + * later drop removes again. + */ +export function expectDataLossMatchesDestructive( + result: { readonly dataLoss: readonly { readonly operationIndex: number }[] }, + operations: readonly { readonly operationClass: string }[], +): void { + const named = new Set(result.dataLoss.map(({ operationIndex }) => operationIndex)); + const classesNamed = [...named].map((index) => operations[index]?.operationClass); + expect(classesNamed.every((operationClass) => operationClass === 'destructive')).toBe(true); + const unnamedDestructive = operations.flatMap(({ operationClass }, index) => + operationClass === 'destructive' && + !named.has(index) && + ![...named].some((namedIndex) => namedIndex < index) + ? [index] + : [], + ); + expect(unnamedDestructive).toEqual([]); +} diff --git a/test/utils/tsdown.config.ts b/test/utils/tsdown.config.ts index d808612bc255..88c8166e59cd 100644 --- a/test/utils/tsdown.config.ts +++ b/test/utils/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/exports/index.ts', 'contract-factories': 'src/contract-factories.ts', + 'data-loss-expectations': 'src/data-loss-expectations.ts', 'column-descriptors': 'src/column-descriptors.ts', 'lowered-params': 'src/lowered-params.ts', 'operation-descriptors': 'src/operation-descriptors.ts', From 23d4c36edc9fc460aee8adba334ffcee1e0caf0b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:40:26 +0200 Subject: [PATCH 079/109] TML-3476: upgrade fragments and docs follow the review fixes The extension fragment names MigrationPlanSubjects, the storageNameOf the aggregate planner now takes and refusesRenameStatements; the app fragment names the exported callback types, the shell-quoted flag forms and where --rename is offered. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../migration-statement-refusal/app/instructions.md | 4 ++-- .../extension/instructions.md | 13 +++++++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index e3f2e501eea9..e197f37107a2 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -191,7 +191,7 @@ prisma migration plan --name tidy-users --rename Profile:User --rename User.name prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` -**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field, `rename`; `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename` (not where the target's migrations capability sets `refusesRenameStatements`, as MongoDB's does); `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. #### Rename statements diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 93332b34c4c9..3e4f44b59bfd 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1045,7 +1045,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Data loss:** a plan that would lose data is written only once you say what each such operation means. The command asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), before it writes anything, including the baseline package of an auto-baseline. Each question is answered with a statement: - `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. A field of a model the plan renames is named through the model's new name, as a field rename is written: after `--rename Profile:User`, a dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. -- `--rename :` keeps the data of a model or field under a new name. A rename on the command line is planned from the start, so the drop it replaces is never asked about. +- `--rename :` keeps the data of a model or field under a new name. A rename on the command line is planned from the start, so the drop it replaces is never asked about. The question offers it only for a model or field the destination no longer has, so a field whose type changes is answered with `--delete`, and not on MongoDB, whose planner refuses renames in this release. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn: type `delete` to let the data go, or `rename :`; a rejected answer is asked again. A typed rename is planned again with the other renames, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` no longer consents to anything here. The deletes are listed under `Statements applied` after the renames, for example `delete model "Legacy" (1 operation)`, and in `--json` output each `appliedStatements` entry carries its `verb`, `rename` or `delete`; a delete's `statement` is `{ kind: 'delete', subject }`. The `⚠` marker stays on each destructive operation. diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 91b4d09cdb51..4b1552e13f14 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -65,7 +65,7 @@ For each migration package that no database has applied yet and whose `migration `prisma migration plan` used to ask for consent only before an auto-baseline that would lose data. It now asks about every operation of every plan that would lose data: dropping a table, a column or a collection, or a type change that can change values. It asks one question per model, field or storage name, before it writes anything. A rename on the command line is planned from the start, so the drop it replaces is never asked about. -Detection lists every script and CI file that runs `migration plan`; it cannot tell which of them plan a drop. For each one that runs non-interactively, run it once against a contract change that drops something, or read the failure when it next happens. The failure is `CLI.CONSENT_REQUIRED`; its `meta.unanswered[]` names each subject and the verbs that answer it, and its `nextActions` give the flags, for example `--delete Legacy` or `--rename Legacy:`. Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm ` for this: it answers nothing. +Detection lists every script and CI file that runs `migration plan`; it cannot tell which of them plan a drop. For each one that runs non-interactively, run it once against a contract change that drops something, or read the failure when it next happens. The failure is `CLI.CONSENT_REQUIRED`; its `meta.unanswered[]` names each subject and the verbs that answer it, and its `nextActions` give the flags, for example `--delete Legacy` or `--rename 'Legacy:'` (a value with characters a shell would read is single-quoted). Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. `--rename` is offered only for a model or field the new contract no longer has, so a field whose type changes is answered with `--delete`, and on MongoDB, whose planner refuses renames in this release, only `--delete` is offered. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm ` for this: it answers nothing. ## `db-update-confirm-no-longer-consents` @@ -85,7 +85,7 @@ To find the subjects, run the same command with `--no-interactive --json` and re These come from `@prisma/orm-toolchain/cli/control-api`. -- `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions: (questions) => Promise`. Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `:` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`. +- `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions`, typed `AnswerPlanQuestions` (with `PlanQuestion`, `PlanAnswer` and `PlanQuestionVerb`, all exported from `@prisma/orm-toolchain/cli/control-api`). Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `:` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`. - `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. A caller that passed `acceptDataLoss: true` to apply whatever the plan holds passes both. - `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. - `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it. diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md index 81ae6e925dc5..819f42fba50e 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -16,14 +16,15 @@ changes: - '(? operation.id` in a test. The aggregate planner fills the lists from the planner's result for a space it plans from a diff, and for a space it applies from recorded migrations it lists each destructive operation in `dataLoss` under its storage name, and no access widening, since a written migration is reviewed before it runs. ## `family-instance-storage-name-of` -A `ControlFamilyInstance` implementation adds `storageNameOf(operation: MigrationPlanOperation): string`. The CLI calls it to name what a destructive operation of a recorded migration is about, since no planner mapped it to a model. A SQL family returns the name from the operation's target details, `schema.table.column` for a column and `schema.name` for anything else; `storageNameOfOperation` from `@prisma/orm-family-sql/family/control` does that. A test double returns any stable name. +A `ControlFamilyInstance` implementation adds `storageNameOf(operation: MigrationPlanOperation): string`. The aggregate planner calls it to name what a destructive operation of a recorded migration loses, since no planner mapped it to a model. A SQL family returns the name from the operation's target details, `schema.table.column` for a column and `schema.name` for anything else; `storageNameOfOperation` from `@prisma/orm-family-sql/family/control` does that. A test double returns any stable name. + +A target whose planner refuses every rename statement sets `refusesRenameStatements: true` on its `migrations` capability, so the CLI does not offer `--rename` as an answer to a data-loss question. MongoDB's target sets it in this release. ## `operation-classes-and-calls` From 0ad977effcf7944b7167c31a9682456414646d99 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:41:21 +0200 Subject: [PATCH 080/109] TML-3476: pin the engine build that also quotes a value starting with = Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 2 +- pnpm-lock.yaml | 116 ++++++++++++++++++++++++------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 02c2e604d3f6..c27115032d5f 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@990fe24", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@c560ced", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a063b0492855..5040e56254fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@990fe24 + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@c560ced hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1377,8 +1377,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1535,8 +1535,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -3247,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@990fe24 - version: https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced + version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,8 +7662,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@990fe24': - resolution: {integrity: sha512-GypASJwCjsQ33GeKqzyD5vYCvy1D4XHPCJlpqgBRjq2KsLCc35edD5gC6Uy57jsAp0T9eoPZr20qud/IK5tKeg==, tarball: https://pkg.pr.new/@prisma/cli-engine@990fe24} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@c560ced': + resolution: {integrity: sha512-KlnnLWcKKhCOH/1BtfnIEjS9/UxwukevcSWOjShtmQWOvy5KpZADyba2k1bEtDLXmhx7XQjMjlrsj7Md/fSrlQ==, tarball: https://pkg.pr.new/@prisma/cli-engine@c560ced} version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: @@ -13432,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@990fe24(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From 41f6a14dbd47977a56b090ee6fc181e80c75dde9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 03:49:57 +0200 Subject: [PATCH 081/109] TML-3476: a question does not offer rename for a field the plan already renamed Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/statements/plan-questions.ts | 20 +++++++++++++++++-- .../statements/data-loss-question.test.ts | 19 ++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 3ccbd5d51880..274ae5a59495 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -157,6 +157,20 @@ function namesSubject(verb: ConsentVerb, subject: string) { text === subject ? undefined : `${verb} names "${subject}": --${verb} ${subject}.`; } +/** Whether a rename statement of the plan already gives the subject a new name. */ +function renamedByPlan( + subject: MigrationSubject, + contracts: StatementContracts & { readonly destination: ContractWithDomain }, +): boolean { + if (subject.kind === 'storage' || contracts.renames.length === 0) return false; + const resolved = resolveStatements({ + statements: contracts.renames, + origin: { kind: 'contract', contract: contracts.origin }, + destination: contracts.destination, + }); + return resolved.ok && resolved.value.some((statement) => sameCoordinate(statement, subject)); +} + /** Whether the destination still has the subject's model or field, under the name a rename gives it. */ function inDestination( subject: MigrationSubject, @@ -172,7 +186,8 @@ function inDestination( /** * The question for one operation that would lose data. A model or field the destination no longer - * has may be renamed instead of deleted, when the planner can carry out a rename; a rename is + * has, and no rename of the plan renamed, may be renamed instead of deleted, when the planner can + * carry out a rename; a rename is * resolved against the two contracts after the plan's other renames, and its old name must be the * subject. A subject the destination keeps, such as a field whose type changes, and data no model * stores, can only be deleted. @@ -189,7 +204,8 @@ export function dataLossQuestion( const renamable = contracts.renamesPlannable && loss.subject.kind !== 'storage' && - !inDestination(loss.subject, contracts); + !inDestination(loss.subject, contracts) && + !renamedByPlan(loss.subject, contracts); const deleteNames = namesSubject('delete', subject); return { question: `${loss.label} ${lossText(loss.subject, subject, contracts.originKnown)}.`, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts index 9ffe2d9c53ff..e3aa8da1b254 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -69,6 +69,25 @@ describe('dataLossQuestion verbs', () => { expect(verbsFor('email', destinationWithoutNickname, true)).toEqual(['delete']); }); + it('offers only delete for a field a rename statement of the plan already renamed', () => { + const destination = contractOf({ app: { models: { User: { fields: ['email', 'handle'] } } } }); + const question = dataLossQuestion( + { + operationIndex: 1, + label: 'Recreate table User', + subject: { kind: 'field', namespaceId: unbound, model: 'User', field: 'nickname' }, + }, + { + origin: withNickname, + destination, + renames: [{ verb: 'rename', text: 'User.nickname:User.handle' }], + originKnown: true, + renamesPlannable: true, + }, + ); + expect(question.verbs).toEqual(['delete']); + }); + it('offers only delete where the planner cannot carry out a rename', () => { expect(verbsFor('nickname', destinationWithoutNickname, false)).toEqual(['delete']); }); From 72bb682c615f12849b3240f1f23537a49503d267 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:11:13 +0200 Subject: [PATCH 082/109] TML-3476: the slice spec and plan say each command declares its verbs, and name MigrationSubject Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/slices/refusal/plan.md | 2 +- projects/migration-statements/slices/refusal/spec.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/migration-statements/slices/refusal/plan.md b/projects/migration-statements/slices/refusal/plan.md index 6e4551e64c11..457b68505abe 100644 --- a/projects/migration-statements/slices/refusal/plan.md +++ b/projects/migration-statements/slices/refusal/plan.md @@ -26,7 +26,7 @@ Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Di ### 3 — The engine pin, the verbs, and the refusal on `migration plan` -**Outcome.** `@prisma/cli-engine` 0.7 pinned; the ORM command family registers the verbs `rename`, `delete`, `allow`; `migration plan` plans, turns every `dataLoss` entry into a `ctx.prompt.statements` question, re-plans with any `rename` answers, matches `delete` answers, and writes only when nothing is left unanswered. The auto-baseline consent (`refuseUnconsentedDestructiveBaseline`, `consentToken`, the plan-hash round trip) is gone; `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` are retired from this command; `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` is new. `Statements applied` and JSON `appliedStatements` carry `delete` lines with `verb`. Statement order comes from the engine's ordered verb-flag list. +**Outcome.** `@prisma/cli-engine` 0.7 pinned; each command declares its verbs from one shared table (`rename`, `delete`, and on `db update` `allow`); `migration plan` plans, turns every `dataLoss` entry into a `ctx.prompt.statements` question, re-plans with any `rename` answers, matches `delete` answers, and writes only when nothing is left unanswered. The auto-baseline consent (`refuseUnconsentedDestructiveBaseline`, `consentToken`, the plan-hash round trip) is gone; `MIGRATION.DESTRUCTIVE_CHANGES` and `CONSENT_PLAN_MISMATCH` are retired from this command; `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` is new. `Statements applied` and JSON `appliedStatements` carry `delete` lines with `verb`. Statement order comes from the engine's ordered verb-flag list. **Builds on.** Dispatch 2 and the engine. diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md index 5dd97bb94b40..393bd9ce3f3e 100644 --- a/projects/migration-statements/slices/refusal/spec.md +++ b/projects/migration-statements/slices/refusal/spec.md @@ -41,13 +41,13 @@ An operation is refused when its class is `destructive`, and after this slice an ### The structured refusal -The family planner's success result gains `dataLoss: readonly MigrationOperationSubject[]`, one entry per destructive operation in plan order: `{ operationIndex, subject }`. `subject` is a `MigrationStatementSubject`: `{ kind: 'model' | 'field', namespaceId, model, field? }` when the family can map the operation's table or column back to the origin contract (`fromContract`), else `{ kind: 'storage', name }` with the storage name as the planner knows it. The framework type lives beside `AppliedMigrationStatement` and carries no family vocabulary. The SQL family maps through `model.storage.table` and `fields[f].column`; MongoDB through the collection name. A table no contract declared (drift) is a `storage` subject. +The family planner's success result gains `dataLoss: readonly MigrationOperationSubject[]`, one entry per destructive operation in plan order: `{ operationIndex, subject }`. `subject` is a `MigrationSubject`: `{ kind: 'model' | 'field', namespaceId, model, field? }` when the family can map the operation's table or column back to the origin contract (`fromContract`), else `{ kind: 'storage', name }` with the storage name as the planner knows it. The framework type lives beside `AppliedMigrationStatement` and carries no family vocabulary. The SQL family maps through `model.storage.table` and `fields[f].column`; MongoDB through the collection name. A table no contract declared (drift) is a `storage` subject. The CLI turns each entry into one engine question (`ctx.prompt.statements`): question text from the operation's label, `subject` written as the coordinate (`Legacy`, `User.name`, or the storage name), `verbs: ['rename', 'delete']` for a model or field subject and `['delete']` for a storage subject. The engine handles the command line, the non-interactive refusal that lists every question, and the interactive prompt. The CLI never reads the TTY. ### `--delete` and how statements consent -- `--delete ` is an engine verb flag (registered by the ORM command family together with `rename` and `allow`). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) is deferred to slice 3, with the namespace renames (decided 2026-10-08 at the whole-slice review: the engine answers a question only by a value that equals its subject or starts with it, so a namespace-wide consent needs the ORM to pre-answer from the namespace, which slice 3 builds with its namespace nouns). In this slice every `delete` names one model, field or storage object. +- `--delete ` is an engine statement flag (each command declares its verbs from one shared table: `migration plan` declares `rename` and `delete`, `db update` all three). Its text is a model or field coordinate in slice 1's grammar, or the storage name when the refusal said so. A namespace coordinate (`--delete billing`) is deferred to slice 3, with the namespace renames (decided 2026-10-08 at the whole-slice review: the engine answers a question only by a value that equals its subject or starts with it, so a namespace-wide consent needs the ORM to pre-answer from the namespace, which slice 3 builds with its namespace nouns). In this slice every `delete` names one model, field or storage object. - A `delete` never reaches the planner. Planning runs first with the `rename` statements; the plan's `dataLoss` entries are then matched against the answers. `validate` for a `delete` answer checks the text equals the subject; the text is any non-empty string, because a storage name may contain `:`. `validate` for a `rename` answer resolves the statement with slice 1's resolver against the two contracts and accepts only when its old side is the subject. - A `rename` answered at the prompt or on the command line that passes validation is added to the statement list and the plan is run again, exactly as if it had been typed on the command line; the second plan must have no `dataLoss` entry for that subject, else the run fails (`MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`). Order between a run's `--rename` and `--delete` values comes from the engine's ordered verb-flag list. - The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop: with no origin contract, `rename` statements fail with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and `delete` statements still work, because they are matched against the plan, not resolved against a contract. @@ -72,7 +72,7 @@ The refusal, the verb that answers it, and the removal of the blanket consent sh ## Scope -**In:** the reclassification on all three targets; `dataLoss` and `accessWidening` on the planner result and the family mapping to subjects; the `delete` and `allow` verbs through the engine; the per-statement origin check; removal of the blanket consent paths and their errors; output and JSON; the CLI README (`migration plan`, `db update`), the Migration System doc § Statements and § Operation Model, the error reference, the CLI Style Guide's consent section (statements are the data-loss form); upgrade fragments (app: `--confirm` no longer consents to data loss, `migration plan` refuses every plan that loses data; extension: `dataLoss`/`accessWidening` on the success result, `MigrationStatementSubject`); journeys on Postgres, SQLite and MongoDB; the engine pin bumped to 0.7. +**In:** the reclassification on all three targets; `dataLoss` and `accessWidening` on the planner result and the family mapping to subjects; the `delete` and `allow` verbs through the engine; the per-statement origin check; removal of the blanket consent paths and their errors; output and JSON; the CLI README (`migration plan`, `db update`), the Migration System doc § Statements and § Operation Model, the error reference, the CLI Style Guide's consent section (statements are the data-loss form); upgrade fragments (app: `--confirm` no longer consents to data loss, `migration plan` refuses every plan that loses data; extension: `dataLoss`/`accessWidening` on the success result, `MigrationSubject`); journeys on Postgres, SQLite and MongoDB; the engine pin bumped to 0.7. **Out:** `--convert`, `--backfill`, value and namespace renames (slice 3); MongoDB rename statements (slice 4); the `@@map`-only rename gap (decided 2026-10-07: no statement; the refusal keeps slice 1's manual steps); any storage-object statement syntax (deferred). From 2b2cad2d6c5c67184f054c798b3ed14baa36aca9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:11:18 +0200 Subject: [PATCH 083/109] TML-3476: the plan names MigrationSubject Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/slices/refusal/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/migration-statements/slices/refusal/plan.md b/projects/migration-statements/slices/refusal/plan.md index 457b68505abe..3f344ed2b162 100644 --- a/projects/migration-statements/slices/refusal/plan.md +++ b/projects/migration-statements/slices/refusal/plan.md @@ -18,7 +18,7 @@ Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Di ### 2 — The planner result names what each destructive operation would lose -**Outcome.** The framework type `MigrationStatementSubject` beside `AppliedMigrationStatement`; the planner success result carries `dataLoss: { operationIndex, subject }[]` and `accessWidening: { operationIndex, subject }[]`; the SQL family maps a dropped or type-changed table or column back to the origin contract's model or field when `fromContract` is present, else to a storage subject; MongoDB maps a dropped collection to its model; the transient DROP of a policy replacement is not listed. The aggregate planner and `planFromDiff` pass both lists through. The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop. +**Outcome.** The framework type `MigrationSubject` beside `AppliedMigrationStatement`; the planner success result carries `dataLoss: { operationIndex, subject }[]` and `accessWidening: { operationIndex, subject }[]`; the SQL family maps a dropped or type-changed table or column back to the origin contract's model or field when `fromContract` is present, else to a storage subject; MongoDB maps a dropped collection to its model; the transient DROP of a policy replacement is not listed. The aggregate planner and `planFromDiff` pass both lists through. The missing-origin check in `resolve-statements.ts` moves inside the per-statement loop. **Builds on.** Dispatch 1. @@ -42,7 +42,7 @@ Dispatches 1 and 2 do not touch the prompt and start before the engine ships. Di ### 5 — Journeys, docs, upgrade fragments -**Outcome.** The slice done conditions hold through journeys on Postgres, SQLite and MongoDB (rename plus delete through both commands; the refusal's `nextActions` flag then succeeds; `--confirm ` no longer consents; a required field plans without refusal; `--allow` on a policy drop). The CLI README's two command sections, the Migration System doc § Statements and § `db update`, the error reference, the CLI Style Guide's consent section, and `skills/prisma-8` describe statements as the data-loss consent. The app upgrade fragment records that `--confirm` no longer consents to data loss and that `migration plan` refuses every plan that loses data; the extension fragment records `dataLoss`, `accessWidening` and `MigrationStatementSubject`. Every `--confirm` in tests and journeys for these two commands is replaced. +**Outcome.** The slice done conditions hold through journeys on Postgres, SQLite and MongoDB (rename plus delete through both commands; the refusal's `nextActions` flag then succeeds; `--confirm ` no longer consents; a required field plans without refusal; `--allow` on a policy drop). The CLI README's two command sections, the Migration System doc § Statements and § `db update`, the error reference, the CLI Style Guide's consent section, and `skills/prisma-8` describe statements as the data-loss consent. The app upgrade fragment records that `--confirm` no longer consents to data loss and that `migration plan` refuses every plan that loses data; the extension fragment records `dataLoss`, `accessWidening` and `MigrationSubject`. Every `--confirm` in tests and journeys for these two commands is replaced. **Builds on.** Dispatch 4. From 812e8a5a1926288f0a8278a95c085e0223aa0348 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:12:56 +0200 Subject: [PATCH 084/109] TML-3476: slice 4 removes the temporary refusesRenameStatements member Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/migration-statements/plan.md b/projects/migration-statements/plan.md index 53031a39e2ed..0090e6f8d0d8 100644 --- a/projects/migration-statements/plan.md +++ b/projects/migration-statements/plan.md @@ -46,7 +46,7 @@ Four slices. The first three stack: the statement surface with renames, then the **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. +**Builds on.** Slice 2. Slice 2 left a temporary member for it: `TargetMigrationsCapability.refusesRenameStatements`, set by MongoDB so a data-loss question offers no `rename` there. Slice 4 deletes the member, its one CLI read and its doc mention when the Mongo planner carries out renames. **Hands to.** Project close-out for Mongo. From 70809618a95103222b9480908ff460890a269049 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:21:26 +0200 Subject: [PATCH 085/109] TML-3476: the no-snapshot refusal gives the snapshot steps once, with --db and the dry-run check The first storage question of a refusal without an origin contract carries the steps MIGRATION.STATEMENT_ORIGIN_UNKNOWN gives, including running db update --advance-ref with the same --db and checking it with --dry-run first; every storage question says --delete loses its rows. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/statements/plan-questions.ts | 30 ++++++++++++++- .../3-tooling/cli/src/utils/cli-errors.ts | 2 +- .../cli/test/control-api/db-update.test.ts | 8 ++-- .../statements/ask-plan-questions.test.ts | 37 +++++++++++++++++++ .../statements/data-loss-question.test.ts | 2 +- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 274ae5a59495..b17ae037163b 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -12,6 +12,7 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import { errorStatementAnswersNoQuestion, errorStatementDidNotResolveLoss, + STORE_ORIGIN_SNAPSHOT_STEPS, } from '../../utils/cli-errors'; import type { ConsentVerb } from './parse-consent'; import { resolveStatements } from './resolve-statements'; @@ -139,10 +140,20 @@ function lossText(subject: MigrationSubject, text: string, originKnown: boolean) case 'storage': return originKnown ? `would lose the data in "${text}", which no model of the origin contract stores` - : `would lose the data in "${text}", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename`; + : `would lose the data in "${text}", named by its storage name because the origin contract is unknown; --delete loses its rows`; } } +/** + * How to keep the rows of a storage subject that was renamed, when the origin contract is unknown: + * store the snapshot of the contract the database is at, as `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` + * advises, then answer with a rename. The refusal gives it once, on its first storage question. + */ +export const ORIGIN_SNAPSHOT_RECOVERY = [ + 'If a table or column was renamed, keep its rows instead: store the snapshot of the contract the database is at, then run this command again and answer with --rename.', + ...STORE_ORIGIN_SNAPSHOT_STEPS.map((step) => step.replaceAll('`{bin} ', '`')), +].join(' '); + function sameCoordinate(statement: ResolvedMigrationStatement, subject: MigrationSubject): boolean { if (subject.kind === 'storage') return false; const { from } = statement; @@ -280,6 +291,18 @@ function checkAnswers(questions: readonly PlanQuestion[], answers: readonly Plan }); } +function withRecoveryOnFirstStorageQuestion( + asked: readonly { readonly loss: PlannedSubject; readonly question: PlanQuestion }[], + originKnown: boolean, +): readonly PlanQuestion[] { + const first = originKnown ? -1 : asked.findIndex(({ loss }) => loss.subject.kind === 'storage'); + return asked.map(({ question }, index) => + index === first + ? { ...question, question: `${question.question} ${ORIGIN_SNAPSHOT_RECOVERY}` } + : question, + ); +} + /** * Asks about every operation of a plan that would lose data and, when `askAccess`, every one that * would widen access, until each is answered. A `delete` or `allow` text in `preAnswers`, or @@ -352,7 +375,10 @@ export async function askPlanQuestions const widenings = input.askAccess ? pending('allow', plan.accessWidening) : []; if (losses.length + widenings.length === 0 && round > 0) break; const questions = [ - ...losses.map((loss) => dataLossQuestion(loss, contracts)), + ...withRecoveryOnFirstStorageQuestion( + losses.map((loss) => ({ loss, question: dataLossQuestion(loss, contracts) })), + input.originKnown, + ), ...widenings.map((widening) => accessWideningQuestion(widening, contracts)), ]; const answers = await input.answer(questions); 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 badfe85d62d2..76a0603b3b50 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 @@ -670,7 +670,7 @@ 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 = [ +export 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 `.', 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 930042f05f86..5c04733f9c67 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 @@ -11,7 +11,10 @@ import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok } from '@internal/utils/result'; import { describe, expect, it, vi } from 'vitest'; import { executeDbUpdate } from '../../src/control-api/operations/db-update'; -import type { PlanQuestion } from '../../src/control-api/statements/plan-questions'; +import { + ORIGIN_SNAPSHOT_RECOVERY, + type PlanQuestion, +} from '../../src/control-api/statements/plan-questions'; import type { ControlProgressEvent } from '../../src/control-api/types'; const FAKE_MIGRATIONS_DIR = '/tmp/__test-db-update-migrations'; @@ -576,8 +579,7 @@ describe('executeDbUpdate', () => { expect(asked).toEqual([ { - question: - 'Drop column nickname from user would lose the data in "user.nickname", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename.', + question: `Drop column nickname from user would lose the data in "user.nickname", named by its storage name because the origin contract is unknown; --delete loses its rows. ${ORIGIN_SNAPSHOT_RECOVERY}`, subject: 'user.nickname', verbs: ['delete'], }, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index 40efb542a5ee..15b4adb94aaf 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -3,6 +3,7 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; import { askPlanQuestions, + ORIGIN_SNAPSHOT_RECOVERY, type PlannedQuestions, type PlannedSubject, type PlanQuestion, @@ -83,3 +84,39 @@ describe('a rename typed at the prompt', () => { }); }); }); + +describe('questions about storage when the origin contract is unknown', () => { + it('give the steps that store the snapshot once, on the first such question', async () => { + const asked: string[] = []; + const storage = (name: string, operationIndex: number) => ({ + operationIndex, + label: `Drop table ${name}`, + subject: { kind: 'storage', name } as const, + }); + await askPlanQuestions({ + plan: { dataLoss: [storage('Legacy', 0), storage('Profile', 1)], accessWidening: [] }, + askAccess: false, + renames: [], + preAnswers: [], + consentAll: { delete: false, allow: false }, + origin, + originKnown: false, + renamesPlannable: true, + destination, + answer: async (questions) => { + asked.push(...questions.map(({ question }) => question)); + return questions.map(({ subject }) => ({ verb: 'delete', text: subject })); + }, + replan: async () => ok({ dataLoss: [], accessWidening: [] }), + }); + + expect(asked).toEqual([ + `Drop table Legacy would lose the data in "Legacy", named by its storage name because the origin contract is unknown; --delete loses its rows. ${ORIGIN_SNAPSHOT_RECOVERY}`, + 'Drop table Profile would lose the data in "Profile", named by its storage name because the origin contract is unknown; --delete loses its rows.', + ]); + expect(ORIGIN_SNAPSHOT_RECOVERY).toContain('--dry-run'); + expect(ORIGIN_SNAPSHOT_RECOVERY).toContain( + 'with the same `--db` as this command if it has one', + ); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts index e3aa8da1b254..28386d89153e 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -38,7 +38,7 @@ describe('dataLossQuestion for data no model names', () => { }); expect(question.question).toBe( - 'Drop column age from user would lose the data in "public.user.age", named by its storage name because the origin contract is unknown. --delete loses its rows. If it was renamed, keep them instead: emit the contract the database is at, run db update --advance-ref to store its snapshot (it changes nothing), then emit the new contract and answer with --rename.', + 'Drop column age from user would lose the data in "public.user.age", named by its storage name because the origin contract is unknown; --delete loses its rows.', ); }); }); From c1889e45bac3d1a11bedad264c1eb050c21527bd Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:22:47 +0200 Subject: [PATCH 086/109] TML-3476: the data-loss check passes an unnamed destructive operation only when an earlier entry names its loss expectDataLossMatchesDestructive takes subjectOf, what an unnamed operation loses, and passes it only when an earlier entry has that subject; before, any earlier entry let every later destructive operation through. Its own tests show it fails on a listed drop followed by an unlisted one. Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/migrations/planner.data-loss.test.ts | 16 ++++- test/utils/src/data-loss-expectations.ts | 56 +++++++++++++---- .../utils/test/data-loss-expectations.test.ts | 60 +++++++++++++++++++ 3 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 test/utils/test/data-loss-expectations.test.ts diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts index a9f4bcc39c79..407e90b1a83d 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner.data-loss.test.ts @@ -113,7 +113,21 @@ async function planned( }); if (result.kind !== 'success') throw new Error(JSON.stringify(result.conflicts)); const operations = await Promise.all(result.plan.operations); - expectDataLossMatchesDestructive(result, operations); + const originModel = (table: string) => + (options.statements ?? []).find( + (statement) => statement.entity === 'model' && statement.to.model === table, + )?.from.model ?? table; + expectDataLossMatchesDestructive(result, operations, { + subjectOf: ({ target }) => + target.details?.objectType === 'column' && target.details.table !== undefined + ? { + kind: 'field', + namespaceId: unbound, + model: originModel(target.details.table), + field: target.details.name, + } + : undefined, + }); const labels = operations.map((op) => op.label); return { labels, diff --git a/test/utils/src/data-loss-expectations.ts b/test/utils/src/data-loss-expectations.ts index 2c46fbdd3b91..dcc4ae0f91af 100644 --- a/test/utils/src/data-loss-expectations.ts +++ b/test/utils/src/data-loss-expectations.ts @@ -1,24 +1,54 @@ import { expect } from 'vitest'; +/** A subject as a planner's `dataLoss` names it: a model, a field or a storage name. */ +interface LossSubject { + readonly kind: string; + readonly namespaceId?: string; + readonly model?: string; + readonly field?: string; + readonly name?: string; +} + +function keyOf(subject: LossSubject): string { + return JSON.stringify([ + subject.kind, + subject.namespaceId, + subject.model, + subject.field, + subject.name, + ]); +} + /** * Asserts a planner's `dataLoss` against its own classification: an entry points only at a - * `destructive` operation, and every `destructive` operation has an entry unless an earlier - * destructive operation already names what it loses, as a SQLite recreate does for a column a - * later drop removes again. + * `destructive` operation, and every `destructive` operation has an entry. The one exception is an + * operation whose loss an earlier entry already names, as a SQLite recreate names a column that a + * later drop removes again; `subjectOf` says what such an operation loses, and the operation + * passes only when an earlier entry has that subject. */ -export function expectDataLossMatchesDestructive( - result: { readonly dataLoss: readonly { readonly operationIndex: number }[] }, - operations: readonly { readonly operationClass: string }[], +export function expectDataLossMatchesDestructive< + TOperation extends { readonly operationClass: string }, +>( + result: { + readonly dataLoss: readonly { + readonly operationIndex: number; + readonly subject: LossSubject; + }[]; + }, + operations: readonly TOperation[], + options: { readonly subjectOf?: (operation: TOperation) => LossSubject | undefined } = {}, ): void { const named = new Set(result.dataLoss.map(({ operationIndex }) => operationIndex)); const classesNamed = [...named].map((index) => operations[index]?.operationClass); expect(classesNamed.every((operationClass) => operationClass === 'destructive')).toBe(true); - const unnamedDestructive = operations.flatMap(({ operationClass }, index) => - operationClass === 'destructive' && - !named.has(index) && - ![...named].some((namedIndex) => namedIndex < index) - ? [index] - : [], - ); + const namedBefore = (index: number, subject: LossSubject) => + result.dataLoss.some( + (entry) => entry.operationIndex < index && keyOf(entry.subject) === keyOf(subject), + ); + const unnamedDestructive = operations.flatMap((operation, index) => { + if (operation.operationClass !== 'destructive' || named.has(index)) return []; + const subject = options.subjectOf?.(operation); + return subject !== undefined && namedBefore(index, subject) ? [] : [index]; + }); expect(unnamedDestructive).toEqual([]); } diff --git a/test/utils/test/data-loss-expectations.test.ts b/test/utils/test/data-loss-expectations.test.ts new file mode 100644 index 000000000000..e497f1303c3b --- /dev/null +++ b/test/utils/test/data-loss-expectations.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { expectDataLossMatchesDestructive } from '../src/data-loss-expectations'; + +const dropA = { label: 'Drop table A', operationClass: 'destructive' }; +const dropB = { label: 'Drop table B', operationClass: 'destructive' }; +const addIndex = { label: 'Create index', operationClass: 'additive' }; +const tableA = { kind: 'model', namespaceId: 'app', model: 'A' }; +const tableB = { kind: 'model', namespaceId: 'app', model: 'B' }; + +describe('expectDataLossMatchesDestructive', () => { + it('passes when each destructive operation is named', () => { + expectDataLossMatchesDestructive( + { + dataLoss: [ + { operationIndex: 0, subject: tableA }, + { operationIndex: 2, subject: tableB }, + ], + }, + [dropA, addIndex, dropB], + ); + }); + + it('fails on a destructive operation no entry names, after one that is named', () => { + expect(() => + expectDataLossMatchesDestructive({ dataLoss: [{ operationIndex: 0, subject: tableA }] }, [ + dropA, + dropB, + ]), + ).toThrow(); + }); + + it('fails on an entry at an operation that is not destructive', () => { + expect(() => + expectDataLossMatchesDestructive({ dataLoss: [{ operationIndex: 0, subject: tableA }] }, [ + addIndex, + ]), + ).toThrow(); + }); + + it('passes an unnamed destructive operation whose loss an earlier entry names', () => { + expectDataLossMatchesDestructive( + { dataLoss: [{ operationIndex: 0, subject: tableA }] }, + [ + { label: 'Recreate table A', operationClass: 'destructive' }, + { label: 'Drop table A again', operationClass: 'destructive' }, + ], + { subjectOf: (operation) => (operation.label.endsWith('again') ? tableA : undefined) }, + ); + }); + + it('fails an unnamed destructive operation whose loss no earlier entry names', () => { + expect(() => + expectDataLossMatchesDestructive( + { dataLoss: [{ operationIndex: 0, subject: tableA }] }, + [dropA, dropB], + { subjectOf: () => tableB }, + ), + ).toThrow(); + }); +}); From b3d90528c027a71085f309a3d892c9baa3b1789b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:24:37 +0200 Subject: [PATCH 087/109] TML-3476: a dry run marks the questions its flags answer and suggests the apply with those flags The flags are written shell-safe, single-quoted where a shell would read a character and joined with = when the value starts with -, as are the statements a missing-connection retry repeats. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../control-api/operations/ref-resolution.ts | 3 +- .../control-api/statements/statement-flag.ts | 19 ++++++++++ .../cli/src/orm/db/migration-blocks.ts | 7 ++-- .../3-tooling/cli/src/orm/db/update.ts | 23 ++++++++--- .../cli/src/utils/formatters/migrations.ts | 2 + .../3-tooling/cli/test/orm/db-update.test.ts | 38 +++++++++++++++++++ 7 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 3e4f44b59bfd..aa2fd2434b30 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) 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 08bc7130c66b..ddd404e444b3 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 @@ -17,6 +17,7 @@ import { mapRefResolutionError, requireLiveDatabase, } from '../../utils/cli-errors'; +import { statementFlag } from '../statements/statement-flag'; import type { StatementText } from '../statements/statement-text'; export interface RefResolutionContext { @@ -77,7 +78,7 @@ export function retryCommandFor(args: { ...(suggestsOffline ? ['--from '] : []), ...(args.to === undefined ? [] : [`--to ${args.to}`]), ...(args.advanceRef === undefined ? [] : [`--advance-ref ${args.advanceRef}`]), - ...(args.statements ?? []).map((statement) => `--${statement.verb} ${statement.text}`), + ...(args.statements ?? []).map(statementFlag), ...(needsConnection ? ['--db $DATABASE_URL'] : []), ].join(' '); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts new file mode 100644 index 000000000000..74c8d41e2f06 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts @@ -0,0 +1,19 @@ +import type { StatementText } from './statement-text'; + +const SHELL_SAFE = /^[A-Za-z0-9_.:/@%+,-]+$/; + +function shellQuoted(value: string): string { + return SHELL_SAFE.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * A statement written as a flag a shell passes through unchanged: the value is single-quoted when + * it has a character a shell would read, and joined with `=` when it starts with `-`, which a + * parser would otherwise read as a flag. + */ +export function statementFlag(statement: StatementText): string { + const value = shellQuoted(statement.text); + return statement.text.startsWith('-') + ? `--${statement.verb}=${value}` + : `--${statement.verb} ${value}`; +} 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 aceb294554f3..5fd651d371f5 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 @@ -1,6 +1,7 @@ import type { OperationPreview } from '@internal/framework-components/control'; import type { Block, TreeNode } from '@prisma/cli-engine'; import type { NextAction } from '@prisma/cli-engine/protocol'; +import { statementFlag } from '../../control-api/statements/statement-flag'; import type { PerSpaceExecutionEntry } from '../../control-api/types'; import { type MigrationCommandResult, @@ -159,8 +160,8 @@ function applySummaryText(result: MigrationCommandResult): string { */ function questionBlocks(result: MigrationCommandResult): readonly Block[] { const entries = [ - ...(result.dataLoss ?? []).map((entry) => ({ entry, verb: 'delete' })), - ...(result.accessWidening ?? []).map((entry) => ({ entry, verb: 'allow' })), + ...(result.dataLoss ?? []).map((entry) => ({ entry, verb: 'delete' as const })), + ...(result.accessWidening ?? []).map((entry) => ({ entry, verb: 'allow' as const })), ]; if (entries.length === 0) { return []; @@ -172,7 +173,7 @@ function questionBlocks(result: MigrationCommandResult): readonly Block[] { { label: 'An apply asks about', children: entries.map(({ entry, verb }) => ({ - label: `${result.plan.operations[entry.operationIndex]?.label ?? 'an operation'}: --${verb} ${entry.text}`, + label: `${result.plan.operations[entry.operationIndex]?.label ?? 'an operation'}: ${statementFlag({ verb, text: entry.text })}${entry.answered ? ' (answered)' : ''}`, })), }, ], 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 74d30932da82..45b7136679a5 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 @@ -23,6 +23,7 @@ import { preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; import { retryCommandFor } from '../../control-api/operations/ref-resolution'; +import { statementFlag } from '../../control-api/statements/statement-flag'; import type { StatementText } from '../../control-api/statements/statement-text'; import type { AskedSubject, CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; @@ -46,6 +47,7 @@ function updatePresentations(inputs: { readonly database: string | undefined; readonly to: string | undefined; readonly dryRun: boolean; + readonly statements: readonly StatementText[]; }): Presentations { const { document, database, dryRun } = inputs; return { @@ -64,16 +66,21 @@ function updatePresentations(inputs: { ...migrationResultBlocks(document), ], json: () => document, - next: () => migrationResultNextActions(document, '{bin} db update'), + next: () => + migrationResultNextActions( + document, + ['{bin} db update', ...inputs.statements.map(statementFlag)].join(' '), + ), }; } -function subjectEntryJson(entry: AskedSubject) { - return { +function subjectEntryJson(verb: 'delete' | 'allow', given: readonly StatementText[]) { + return (entry: AskedSubject) => ({ operationIndex: entry.operationIndex, subject: migrationSubjectJson(entry.subject), text: entry.text, - }; + answered: given.some((statement) => statement.verb === verb && statement.text === entry.text), + }); } function updateDocument(inputs: { @@ -82,6 +89,8 @@ function updateDocument(inputs: { readonly advancedRef: { readonly name: string; readonly hash: string } | null; readonly plannedAdvanceRef: { readonly name: string; readonly hash: string } | null; readonly startedAt: number; + /** The statements the run was given, which a dry run's questions are marked answered by. */ + readonly statements: readonly StatementText[]; }): MigrationCommandResult { const { value } = inputs; return { @@ -120,8 +129,8 @@ function updateDocument(inputs: { appliedStatements: value.appliedStatements, ...(value.mode === 'plan' ? { - dataLoss: value.dataLoss.map(subjectEntryJson), - accessWidening: value.accessWidening.map(subjectEntryJson), + dataLoss: value.dataLoss.map(subjectEntryJson('delete', inputs.statements)), + accessWidening: value.accessWidening.map(subjectEntryJson('allow', inputs.statements)), } : {}), ...ifDefined('warnings', value.warnings), @@ -298,6 +307,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { advancedRef: advanced.value.advancedRef, plannedAdvanceRef: advanced.value.plannedAdvanceRef, startedAt, + statements: previewConsents, }); } catch (error) { // A refused, mistyped or cancelled answer is the engine's own error, and @@ -345,6 +355,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { database: prepared.value.database, to: args.flags.to, dryRun: args.flags.dryRun, + statements: [...renames, ...previewConsents], }), ), ); 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 4c349e8f3c30..66ff1226cd97 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 @@ -52,6 +52,8 @@ export interface PlannedSubjectJson { readonly subject: MigrationSubjectJson; /** The subject as the apply's question writes it, and as `--delete` or `--allow` takes it. */ readonly text: string; + /** Whether a `--delete` or `--allow` the dry run was given answers this question. */ + readonly answered: boolean; } export interface MigrationCommandResult { 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 be47957a2c95..3536b0dd7d4a 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 @@ -236,6 +236,44 @@ describe('db update --rename', () => { }); }); + it('marks the questions the flags of a dry run answer, and repeats the flags in the apply it suggests', async () => { + const legacy = { kind: 'model', namespaceId: 'app', model: 'Legacy' } as const; + const audit = { kind: 'storage', name: "audit log's" } as const; + mocks.dbUpdate.mockResolvedValue( + ok({ + ...planSuccess(), + dataLoss: [ + { operationIndex: 0, subject: legacy, text: 'Legacy' }, + { operationIndex: 0, subject: audit, text: "audit log's" }, + ], + }), + ); + const run = await harness().run( + ['db', 'update', '--dry-run', '--rename', 'Profile:User', '--delete', 'Legacy'], + { cwd: projectDir, isTty: { stdout: true } }, + ); + + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'tree', + roots: [ + { + label: 'An apply asks about', + children: [ + { label: 'drop relation legacy: --delete Legacy (answered)' }, + { label: "drop relation legacy: --delete 'audit log'\\''s'" }, + ], + }, + ], + }); + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'run-command', + label: 'Apply the planned operations', + command: 'prisma-test db update --rename Profile:User --delete Legacy', + }, + ]); + }); + it('lists the applied statements on a dry run', async () => { mocks.dbUpdate.mockResolvedValue( ok({ ...planSuccess(), appliedStatements: [{ ...PROFILE_TO_USER, operationIndexes: [] }] }), From 1ba19fd67ed84d12e2f8d5893f3d1ebcfd834c8a Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:25:21 +0200 Subject: [PATCH 088/109] TML-3476: export the subject types DbUpdateSuccess uses, and fix three doc lines AskedSubject, MigrationSubject, MigrationOperationSubject and MigrationPlanSubjects are exported from the control API. The Migration System doc says a typed rename must remove the operation asked about and that a dry run reports an unmatched flag as MIGRATION.STATEMENT_ANSWERS_NO_QUESTION; the refusesRenameStatements and fieldEventStorage docs say what they are. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- .../src/control/control-migration-types.ts | 4 ++-- packages/1-framework/3-tooling/cli/src/exports/control-api.ts | 4 ++++ .../2-sql/9-family/src/core/migrations/operation-subjects.ts | 2 +- .../pending/migration-statement-refusal/app/instructions.md | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index e197f37107a2..406b9dfbffc3 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -191,7 +191,7 @@ prisma migration plan --name tidy-users --rename Profile:User --rename User.name prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` -**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename` (not where the target's migrations capability sets `refusesRenameStatements`, as MongoDB's does); `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still loses the subject's data fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename` (not where the target's migrations capability sets `refusesRenameStatements`, as MongoDB's does); `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still has the operation asked about fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; a loss the new plan has on the same subject, such as a type change on the renamed field, is asked next. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. A `db update --dry-run` asks nothing; it checks the same flags against the subjects an apply would ask about and fails an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so a script that checks the code handles both. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. #### Rename statements 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 0f338d6efce3..59df191e6313 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 @@ -653,8 +653,8 @@ export interface TargetMigrationsCapability< ): MigrationPlanner; createRunner(family: TFamilyInstance): MigrationRunner; /** - * Set when the target's planner refuses every rename statement, so the CLI does not offer - * `--rename` as an answer to a data-loss question. + * Set when the target's planner carries out no rename statement; a data-loss question then + * offers no rename. */ readonly refusesRenameStatements?: true; /** 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 6bd3c20eedd3..07b76bd13077 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,9 @@ // Re-export core control plane types for consumer convenience export type { ControlStack, + MigrationOperationSubject, + MigrationPlanSubjects, + MigrationSubject, ResolvedMigrationStatement, VerifyDatabaseResult, VerifyDatabaseSchemaResult, @@ -157,6 +160,7 @@ export { export type { StatementText, StatementVerb } from '../control-api/statements/statement-text'; // CLI-specific types export type { + AskedSubject, ContractEmitOptions, ContractEmitResult, ControlActionName, diff --git a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts index 8895d546527a..22136798f0d1 100644 --- a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -30,7 +30,7 @@ export interface SubjectStorage { | undefined; } -/** The target of a codec hook's call: the column of the field event it was returned for. */ +/** What a codec hook's call is about: the column of the field event it was returned for. */ export function fieldEventStorage(field: { readonly namespaceId: string; readonly tableName: string; diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 4b1552e13f14..93563c697195 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -90,7 +90,7 @@ These come from `@prisma/orm-toolchain/cli/control-api`. - `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. - `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it. - `executeMigrationPlanCommand(options)` requires `answerQuestions`, with the same contract, and loses `consent` and `carryEmittedExtensionDirs`. Remove both. -- `DbUpdateSuccess` gains `dataLoss` and `accessWidening`, the operations of the plan that lose data or widen access, by position. +- `DbUpdateSuccess` gains `dataLoss` and `accessWidening`, the operations of the plan that lose data or widen access, by position, each an `AskedSubject` with the subject's `text` as the question writes it. `AskedSubject`, `MigrationSubject`, `MigrationOperationSubject` and `MigrationPlanSubjects` are exported from `@prisma/orm-toolchain/cli/control-api`. ## `applied-statement-report-verb` From 7cbc9f9e8ac58e6bbc1756882b8bf3ab47bf05b5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:31:24 +0200 Subject: [PATCH 089/109] TML-3476: the snapshot recovery text names no storage object Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/control-api/statements/plan-questions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index b17ae037163b..013604db2b92 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -150,7 +150,7 @@ function lossText(subject: MigrationSubject, text: string, originKnown: boolean) * advises, then answer with a rename. The refusal gives it once, on its first storage question. */ export const ORIGIN_SNAPSHOT_RECOVERY = [ - 'If a table or column was renamed, keep its rows instead: store the snapshot of the contract the database is at, then run this command again and answer with --rename.', + 'If it was renamed, keep its data instead: store the snapshot of the contract the database is at, then run this command again and answer with --rename.', ...STORE_ORIGIN_SNAPSHOT_STEPS.map((step) => step.replaceAll('`{bin} ', '`')), ].join(' '); From dacbca4e3859cd42f502947332f33934de5e4d10 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:52:20 +0200 Subject: [PATCH 090/109] TML-3476: a target that refuses renames says how to keep the data by hand, and a field's rename form keeps its model TargetMigrationsCapability.refusesRenameStatements becomes renameStatements: { refused: true, keepDataByHand }, and MongoDB's says to rename the collection in mongosh and run the command again. A data-loss question on such a target ends with that sentence. The rename form of a field is written User.nickname:User.. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/control-migration-types.ts | 16 +++++-- packages/1-framework/3-tooling/cli/README.md | 4 +- .../cli/src/control-api/operations/db-run.ts | 3 +- .../control-api/operations/migration-plan.ts | 8 ++-- .../control-api/statements/plan-questions.ts | 47 ++++++++++++++----- .../statements/ask-plan-questions.test.ts | 4 +- .../statements/data-loss-question.test.ts | 37 +++++++++++---- .../cli/test/orm/db-update-consent.test.ts | 8 +++- .../cli/test/orm/migration-plan.test.ts | 2 +- .../src/core/migrations/control-target.ts | 4 +- .../src/core/migrations/mongo-planner.ts | 13 +++++ .../test/mongo-planner.data-loss.test.ts | 21 ++++++++- skills/prisma-8/references/migrations.md | 2 +- 13 files changed, 131 insertions(+), 38 deletions(-) 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 59df191e6313..8d1f0b85e152 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 @@ -9,7 +9,7 @@ * with additional fields for execution (precheck SQL, execute SQL, etc.). */ -import type { Contract } from '@internal/contract/types'; +import type { Contract, ContractWithDomain } from '@internal/contract/types'; import type { ImportRequirement } from '@internal/ts-render'; import type { Result } from '@internal/utils/result'; import type { TargetBoundComponentDescriptor } from '../shared/framework-components'; @@ -23,6 +23,7 @@ import type { OperationContext } from './control-operation-results'; import type { AppliedMigrationStatement, MigrationPlanSubjects, + MigrationSubject, ResolvedMigrationStatement, } from './migration-statements'; @@ -653,10 +654,17 @@ export interface TargetMigrationsCapability< ): MigrationPlanner; createRunner(family: TFamilyInstance): MigrationRunner; /** - * Set when the target's planner carries out no rename statement; a data-loss question then - * offers no rename. + * Set when the target's planner carries out no rename statement: a data-loss question then + * offers no rename, and says instead how to keep the data by hand. */ - readonly refusesRenameStatements?: true; + readonly renameStatements?: { + readonly refused: true; + /** How to keep the data of `subject` by hand, so that the plan made afterwards loses nothing. */ + readonly keepDataByHand: ( + subject: MigrationSubject, + fromContract: ContractWithDomain, + ) => string; + }; /** * Synthesizes a family-specific schema IR from a contract for offline planning. * The returned schema can be passed to `planner.plan({ schema })` as the "from" state. diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index aa2fd2434b30..8547fb546c9a 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -955,7 +955,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config **Questions before an apply:** an apply asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), and one per model whose rows an operation would let more people read or write (disabling row-level security, for example). It asks before it applies anything. Subjects are named through the origin contract read from the snapshot store, as `migration plan` names them; without a readable snapshot each subject is its storage name, which can only be deleted or allowed, and the question says the origin contract is unknown. Recorded migrations of extension spaces are asked about for the data they would lose, never for widened access. Each question is answered with a statement: - `--delete ` lets the update lose that data, for example `--delete Legacy` or `--delete User.nickname`. A field of a model the update renames is named through the model's new name. -- `--rename :` keeps the data of a model or field under a new name instead. +- `--rename :` keeps the data of a model or field under a new name instead. A field's new name keeps its model: `--rename User.nickname:User.handle`, which the question writes as `User.nickname:User.`. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. @@ -1045,7 +1045,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Data loss:** a plan that would lose data is written only once you say what each such operation means. The command asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), before it writes anything, including the baseline package of an auto-baseline. Each question is answered with a statement: - `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. A field of a model the plan renames is named through the model's new name, as a field rename is written: after `--rename Profile:User`, a dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. -- `--rename :` keeps the data of a model or field under a new name. A rename on the command line is planned from the start, so the drop it replaces is never asked about. The question offers it only for a model or field the destination no longer has, so a field whose type changes is answered with `--delete`, and not on MongoDB, whose planner refuses renames in this release. +- `--rename :` keeps the data of a model or field under a new name; a field's new name keeps its model (`--rename User.nickname:User.handle`). A rename on the command line is planned from the start, so the drop it replaces is never asked about. The question offers it only for a model or field the destination no longer has, so a field whose type changes is answered with `--delete`, and not on MongoDB, whose planner refuses renames in this release. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn: type `delete` to let the data go, or `rename :`; a rejected answer is asked again. A typed rename is planned again with the other renames, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` no longer consents to anything here. The deletes are listed under `Statements applied` after the renames, for example `delete model "Legacy" (1 operation)`, and in `--json` output each `appliedStatements` entry carries its `verb`, `rename` or `delete`; a delete's `statement` is `{ kind: 'delete', subject }`. The `⚠` marker stays on each destructive operation. 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 99b3b5612d73..df493d43a9d7 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 @@ -48,6 +48,7 @@ import { type AnswerPlanQuestions, askPlanQuestions, type ConsentedSubject, + keepDataByHandFor, type PlannedQuestions, refuseUnusedConsents, subjectText, @@ -325,7 +326,7 @@ export async function executeRun plan(resolveRenames(renames)), 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 8e5e50552ed2..b58f25d10613 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 @@ -9,6 +9,7 @@ import { createControlStack, hasOperationPreview, type MigrationPlanOperation, + type MigrationSubject, migrationSubjectKey, type OperationPreview, planOriginOf, @@ -52,6 +53,7 @@ import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { type AnswerPlanQuestions, askPlanQuestions, + keepDataByHandFor, type PlannedSubject, subjectText, } from '../statements/plan-questions'; @@ -297,7 +299,7 @@ async function answerPlannedDataLoss(input: { readonly consents: readonly StatementText[]; readonly origin: ContractWithDomain; readonly originKnown: boolean; - readonly renamesPlannable: boolean; + readonly keepDataByHand: ((subject: MigrationSubject) => string) | undefined; readonly destination: ContractWithDomain; readonly answer: AnswerPlanQuestions; readonly planDelta: ( @@ -325,7 +327,7 @@ async function answerPlannedDataLoss(input: { consentAll: { delete: false, allow: false }, origin: input.origin, originKnown: input.originKnown, - renamesPlannable: input.renamesPlannable, + keepDataByHand: input.keepDataByHand, destination: input.destination, answer: input.answer, replan: async (renames) => { @@ -750,7 +752,7 @@ async function executeMigrationPlanCommandInner( consents: statementTexts.filter((statement) => statement.verb !== 'rename'), origin: fromContract ?? EMPTY_ORIGIN, originKnown: true, - renamesPlannable: migrations.refusesRenameStatements !== true, + keepDataByHand: keepDataByHandFor(migrations, fromContract ?? EMPTY_ORIGIN), destination: toContract, answer: options.answerQuestions, planDelta: async (renames) => { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 013604db2b92..62c50e21556a 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -6,6 +6,7 @@ import { migrationSubjectKey, modelDisplayName, type ResolvedMigrationStatement, + type TargetMigrationsCapability, } from '@internal/framework-components/control'; import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; @@ -168,6 +169,25 @@ function namesSubject(verb: ConsentVerb, subject: string) { text === subject ? undefined : `${verb} names "${subject}": --${verb} ${subject}.`; } +/** + * How the target says to keep a subject's data by hand when its planner carries out no rename, or + * `undefined` when it carries them out. + */ +export function keepDataByHandFor( + migrations: { readonly renameStatements?: TargetMigrationsCapability['renameStatements'] }, + origin: ContractWithDomain, +): ((subject: MigrationSubject) => string) | undefined { + const refused = migrations.renameStatements; + return refused === undefined ? undefined : (subject) => refused.keepDataByHand(subject, origin); +} + +/** A rename as a statement is written: a field's new name keeps the model, `User.nickname:User.`. */ +function renameForm(subject: MigrationSubject, text: string): string { + return subject.kind === 'field' + ? `${text}:${text.slice(0, text.lastIndexOf('.'))}.` + : `${text}:`; +} + /** Whether a rename statement of the plan already gives the subject a new name. */ function renamedByPlan( subject: MigrationSubject, @@ -197,32 +217,35 @@ function inDestination( /** * The question for one operation that would lose data. A model or field the destination no longer - * has, and no rename of the plan renamed, may be renamed instead of deleted, when the planner can - * carry out a rename; a rename is - * resolved against the two contracts after the plan's other renames, and its old name must be the - * subject. A subject the destination keeps, such as a field whose type changes, and data no model - * stores, can only be deleted. + * has, and no rename of the plan renamed, may be renamed instead of deleted; a rename is resolved + * against the two contracts after the plan's other renames, and its old name must be the subject. + * A subject the destination keeps, such as a field whose type changes, and data no model stores, + * can only be deleted. Where the target's planner carries out no rename, `keepDataByHand` says how + * to keep the data instead. */ export function dataLossQuestion( loss: PlannedSubject, contracts: StatementContracts & { readonly destination: ContractWithDomain; readonly originKnown: boolean; - readonly renamesPlannable: boolean; + readonly keepDataByHand: ((subject: MigrationSubject) => string) | undefined; }, ): PlanQuestion { const subject = subjectText(loss.subject, contracts); const renamable = - contracts.renamesPlannable && + contracts.keepDataByHand === undefined && loss.subject.kind !== 'storage' && !inDestination(loss.subject, contracts) && !renamedByPlan(loss.subject, contracts); const deleteNames = namesSubject('delete', subject); return { - question: `${loss.label} ${lossText(loss.subject, subject, contracts.originKnown)}.`, + question: [ + `${loss.label} ${lossText(loss.subject, subject, contracts.originKnown)}.`, + ...(contracts.keepDataByHand === undefined ? [] : [contracts.keepDataByHand(loss.subject)]), + ].join(' '), subject, verbs: renamable ? ['rename', 'delete'] : ['delete'], - forms: renamable ? { rename: `${subject}:` } : {}, + forms: renamable ? { rename: renameForm(loss.subject, subject) } : {}, validate: (verb, text) => { if (verb !== 'rename') return deleteNames(text); const resolved = resolveStatements({ @@ -234,7 +257,7 @@ export function dataLossQuestion( const statement = resolved.value.at(-1); return statement !== undefined && sameCoordinate(statement, loss.subject) ? undefined - : `The rename's old name is not "${subject}". Write it as ${subject}:.`; + : `The rename's old name is not "${subject}". Write it as ${renameForm(loss.subject, subject)}.`; }, }; } @@ -320,7 +343,7 @@ export async function askPlanQuestions readonly consentAll: { readonly [verb in ConsentVerb]: boolean }; readonly origin: ContractWithDomain; readonly originKnown: boolean; - readonly renamesPlannable: boolean; + readonly keepDataByHand: ((subject: MigrationSubject) => string) | undefined; readonly destination: ContractWithDomain; readonly answer: AnswerPlanQuestions; readonly replan: (renames: readonly StatementText[]) => Promise>; @@ -342,7 +365,7 @@ export async function askPlanQuestions const contracts = { origin: input.origin, originKnown: input.originKnown, - renamesPlannable: input.renamesPlannable, + keepDataByHand: input.keepDataByHand, destination: input.destination, renames, }; diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index 15b4adb94aaf..77dd71b71178 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -44,7 +44,7 @@ function run(replanned: PlannedQuestions, typed: readonly (readonly string[])[]) consentAll: { delete: false, allow: false }, origin, originKnown: true, - renamesPlannable: true, + keepDataByHand: undefined, destination, answer: async (questions: readonly PlanQuestion[]) => { asked.push(questions.map(({ question }) => question)); @@ -101,7 +101,7 @@ describe('questions about storage when the origin contract is unknown', () => { consentAll: { delete: false, allow: false }, origin, originKnown: false, - renamesPlannable: true, + keepDataByHand: undefined, destination, answer: async (questions) => { asked.push(...questions.map(({ question }) => question)); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts index 28386d89153e..5231178ba9a7 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/data-loss-question.test.ts @@ -18,7 +18,7 @@ describe('dataLossQuestion for data no model names', () => { destination, renames: [], originKnown: true, - renamesPlannable: true, + keepDataByHand: undefined, }); expect({ question: question.question, verbs: question.verbs }).toEqual({ @@ -34,7 +34,7 @@ describe('dataLossQuestion for data no model names', () => { destination, renames: [], originKnown: false, - renamesPlannable: true, + keepDataByHand: undefined, }); expect(question.question).toBe( @@ -50,15 +50,25 @@ describe('dataLossQuestion verbs', () => { }); const withNickname = contractOf({ app: { models: { User: { fields: ['email', 'nickname'] } } } }); - function verbsFor(field: string, destination: typeof origin, renamesPlannable: boolean) { + const keepByHand = () => 'Rename it by hand first.'; + + function questionFor( + field: string, + destination: typeof origin, + keepDataByHand: (() => string) | undefined, + ) { return dataLossQuestion( { operationIndex: 0, label: 'An operation', subject: { kind: 'field', namespaceId: unbound, model: 'User', field }, }, - { origin: withNickname, destination, renames: [], originKnown: true, renamesPlannable }, - ).verbs; + { origin: withNickname, destination, renames: [], originKnown: true, keepDataByHand }, + ); + } + + function verbsFor(field: string, destination: typeof origin, renamesPlannable: boolean) { + return questionFor(field, destination, renamesPlannable ? undefined : keepByHand).verbs; } it('offers rename for a field the destination no longer has', () => { @@ -82,13 +92,24 @@ describe('dataLossQuestion verbs', () => { destination, renames: [{ verb: 'rename', text: 'User.nickname:User.handle' }], originKnown: true, - renamesPlannable: true, + keepDataByHand: undefined, }, ); expect(question.verbs).toEqual(['delete']); }); - it('offers only delete where the planner cannot carry out a rename', () => { - expect(verbsFor('nickname', destinationWithoutNickname, false)).toEqual(['delete']); + it('offers only delete where the planner cannot carry out a rename, and says how to keep the data by hand', () => { + const question = questionFor('nickname', destinationWithoutNickname, keepByHand); + expect({ verbs: question.verbs, question: question.question }).toEqual({ + verbs: ['delete'], + question: + 'An operation would lose the values of field "User.nickname". Rename it by hand first.', + }); + }); + + it('writes the rename form of a field with its model, as a field rename is written', () => { + expect(questionFor('nickname', destinationWithoutNickname, undefined).forms).toEqual({ + rename: 'User.nickname:User.', + }); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index e428ad9d31f4..ea95546bd859 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -65,7 +65,13 @@ const origin = { const destination = { domain: { namespaces: { app: { models: { User: { fields: {}, relations: {}, storage: {} } } } } }, }; -const contracts = { origin, destination, renames: [], originKnown: true, renamesPlannable: true }; +const contracts = { + origin, + destination, + renames: [], + originKnown: true, + keepDataByHand: undefined, +}; /** One question of each kind, across two spaces: an extension's recorded drop is a storage subject. */ function planQuestions(): readonly PlanQuestion[] { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts index 1ef6e40bc581..231205172693 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-plan.test.ts @@ -895,7 +895,7 @@ describe('migration plan', () => { .nextActions, ); expect(actions).toContain('--delete User.nickname'); - expect(actions).toContain("--rename 'User.nickname:'"); + expect(actions).toContain("--rename 'User.nickname:User.'"); const deleted = await harness(project, { script }).run( ['migration', 'plan', '--rename', 'Profile:User', '--delete', 'User.nickname', '--json'], diff --git a/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts b/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts index 735dc697d45a..21479ec048f3 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/migrations/control-target.ts @@ -20,7 +20,7 @@ import { notOk, ok } from '@internal/utils/result'; import { mongoTargetDescriptorMeta } from '../descriptor-meta'; import type { MongoTargetContract } from '../mongo-target-contract'; import { MongoTargetContractSerializer } from '../mongo-target-contract-serializer'; -import { MongoMigrationPlanner } from './mongo-planner'; +import { keepDataByHand, MongoMigrationPlanner } from './mongo-planner'; import { MongoMigrationRunner, type MongoMigrationRunnerExecuteOptions } from './mongo-runner'; import { MongoTargetSchemaVerifier } from './mongo-target-schema-verifier'; import { entityNamesDeclaredBy, scopeVerifyResultToSpace } from './scope-verify-result'; @@ -42,7 +42,7 @@ export const mongoTargetDescriptor: MongoControlTargetDescriptor) { return new MongoMigrationPlanner(); }, 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 b0b03ac4794e..ffd9c1575103 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 @@ -174,6 +174,19 @@ function shellString(name: string): string { return JSON.stringify(name); } +/** + * How to keep the documents of a subject a plan would drop, since the planner carries out no + * rename: rename the collection by hand, after which the plan finds nothing to drop. + */ +export function keepDataByHand( + subject: MigrationSubject, + fromContract: ContractWithDomain, +): string { + const collection = + subject.kind === 'storage' ? subject.name : collectionOf(fromContract, subject); + return `If it was renamed, keep its documents instead: rename collection "${collection}" by hand on each database, for example with db.getCollection(${shellString(collection)}).renameCollection("") in mongosh, then run this command again, which then drops nothing.`; +} + /** * 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. diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts index 6100bb55cc34..7d84be59865d 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts @@ -4,7 +4,7 @@ import { MongoCollection, type MongoContract } from '@internal/mongo-contract'; import { MongoSchemaCollection, MongoSchemaIndex, MongoSchemaIR } from '@internal/mongo-schema-ir'; import { expectDataLossMatchesDestructive } from '@repo/test-utils/data-loss-expectations'; import { describe, expect, it } from 'vitest'; -import { MongoMigrationPlanner } from '../src/core/migrations/mongo-planner'; +import { keepDataByHand, MongoMigrationPlanner } from '../src/core/migrations/mongo-planner'; import type { PlannerProducedMongoMigration } from '../src/core/migrations/planner-produced-migration'; const ALL_CLASSES_POLICY: MigrationOperationPolicy = { @@ -143,3 +143,22 @@ describe('MongoDB planner, data loss', () => { ]); }); }); + +describe('keepDataByHand', () => { + it('says to rename the collection a dropped model stores its documents in', () => { + expect( + keepDataByHand( + { kind: 'model', namespaceId: asNamespaceId('__unbound__'), model: 'Event' }, + contractWith({ Event: 'events' }, 'from'), + ), + ).toBe( + 'If it was renamed, keep its documents instead: rename collection "events" by hand on each database, for example with db.getCollection("events").renameCollection("") in mongosh, then run this command again, which then drops nothing.', + ); + }); + + it('names a collection no model stores by its name', () => { + expect(keepDataByHand({ kind: 'storage', name: 'audit' }, contractWith({}, 'from'))).toBe( + 'If it was renamed, keep its documents instead: rename collection "audit" by hand on each database, for example with db.getCollection("audit").renameCollection("") in mongosh, then run this command again, which then drops nothing.', + ); + }); +}); diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index b3208926109d..ceee4285afe3 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -477,7 +477,7 @@ If self-emit itself fails (e.g. the contract has moved on and the operations no The concept: the planner cannot tell whether a dropped model or field was meant to go, or was renamed. So before `migration plan` writes a package, or `db update` applies, the command asks one question per model, field or storage name an operation would lose data from. `db update` also asks one per model whose rows an operation would open up, such as dropping a row-level-security policy. Each question is answered by a statement: - `--delete ` lets the data go: `--delete Legacy`, `--delete User.nickname`. A subject no model stores is named by its storage name (`public.audit_log` on Postgres, `audit_log` on SQLite) and can only be deleted. -- `--rename :` keeps it under a new name: `--rename Profile:User`. A field of a renamed model is named through the new name (`--delete User.nickname` after `--rename Profile:User`). +- `--rename :` keeps it under a new name: `--rename Profile:User`, or for a field `--rename User.nickname:User.handle` (the new name keeps its model). A field of a renamed model is named through the new name (`--delete User.nickname` after `--rename Profile:User`). - `--allow ` (`db update` only) lets the widening run. ```bash From 202731cbb207a13daf16766ca1b273fa0ab09acf Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:57:01 +0200 Subject: [PATCH 091/109] TML-3476: access questions say whether an operation widens or changes access, one per operation An accessWidening entry is a MigrationAccessChange with widens: disabling row-level security widens who can read and write rows; dropping a policy changes it, since a permissive policy grants access. Each such operation is its own question answered by its own --allow, so a policy drop and a row-level-security disable on one model are both listed. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/migration-statements.ts | 16 +++++- .../src/exports/control.ts | 1 + .../cli/src/control-api/operations/db-run.ts | 35 +++++++----- .../control-api/statements/plan-questions.ts | 45 +++++++++++---- .../3-tooling/cli/src/control-api/types.ts | 6 +- .../3-tooling/cli/src/orm/db/update.ts | 34 +++++++++--- .../db-update-statement-positions.test.ts | 2 +- .../cli/test/control-api/db-update.test.ts | 4 +- .../statements/ask-plan-questions.test.ts | 55 +++++++++++++++++++ .../cli/test/orm/db-update-consent.test.ts | 1 + .../src/core/migrations/operation-subjects.ts | 13 +++-- .../9-family/test/operation-subjects.test.ts | 15 ++++- .../src/core/migrations/operation-subjects.ts | 6 +- .../postgres/src/core/migrations/planner.ts | 3 +- .../sqlite/src/core/migrations/planner.ts | 3 +- 15 files changed, 187 insertions(+), 52 deletions(-) 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 index 12491bc59244..d646cfd14a32 100644 --- 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 @@ -68,14 +68,24 @@ export interface MigrationOperationSubject { readonly subject: MigrationSubject; } -/** What the operations of a plan lose and whose access they widen, each by its position in the plan. */ +/** An operation that changes who can read or write the rows of its subject. */ +export interface MigrationAccessChange extends MigrationOperationSubject { + /** + * True when it lets more people read or write, such as disabling row-level security; false when + * the change can go either way, such as dropping a policy. + */ + readonly widens: boolean; +} + +/** What the operations of a plan lose and whose access they change, each by its position in the plan. */ export interface MigrationPlanSubjects< TEntry extends MigrationOperationSubject = MigrationOperationSubject, + TAccess extends MigrationAccessChange = MigrationAccessChange, > { /** Each operation that loses data, in plan order, with what it loses. */ readonly dataLoss: readonly TEntry[]; - /** Each operation that widens who can read or write data, in plan order, with what it is about. */ - readonly accessWidening: readonly TEntry[]; + /** Each operation that changes who can read or write data, in plan order, with what it is about. */ + readonly accessWidening: readonly TAccess[]; } /** 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 e5241c75bd90..e2aa10f9de94 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 @@ -126,6 +126,7 @@ export type { AppliedMigrationStatement, FieldCoordinate, FieldCoordinateJson, + MigrationAccessChange, MigrationOperationSubject, MigrationPlanSubjects, MigrationStatementJson, 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 df493d43a9d7..c099b09e038c 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 @@ -61,6 +61,7 @@ import { import { resolveStatements, type StatementOrigin } from '../statements/resolve-statements'; import type { StatementText } from '../statements/statement-text'; import type { + AskedAccessChange, AskedSubject, DbInitFailure, DbInitResult, @@ -292,11 +293,10 @@ export async function executeRun r.entry.displayOps); - const labelled = (entries: readonly MigrationOperationSubject[]) => - entries.map(({ operationIndex, subject }) => ({ - operationIndex, - subject, - label: operations[operationIndex]?.label ?? JSON.stringify(subject), + const labelled = (entries: readonly TEntry[]) => + entries.map((entry) => ({ + ...entry, + label: operations[entry.operationIndex]?.label ?? JSON.stringify(entry.subject), })); return ok({ planned: planned.value, @@ -379,12 +379,14 @@ export async function executeRun reportConsentStatement( entry, - (entry.verb === 'delete' ? run.subjects.dataLoss : run.subjects.accessWidening) - .filter( - ({ subject }) => - migrationSubjectKey(subject) === migrationSubjectKey(entry.subject), - ) - .map(({ operationIndex }) => operationIndex), + entry.verb === 'allow' && entry.operationIndex !== undefined + ? [entry.operationIndex] + : run.subjects.dataLoss + .filter( + ({ subject }) => + migrationSubjectKey(subject) === migrationSubjectKey(entry.subject), + ) + .map(({ operationIndex }) => operationIndex), ), ), ] @@ -394,7 +396,7 @@ export async function executeRun + const asked = (entries: readonly TEntry[]) => entries.map((entry) => ({ ...entry, text: subjectText(entry.subject, contractsAsked) })); const subjects = action === 'dbUpdate' @@ -548,7 +550,10 @@ function operationsBefore( function subjectsAcrossSpaces( orderedResolutions: readonly OrderedResolution[], ): MigrationPlanSubjects { - const offset = (resolution: OrderedResolution, entries: readonly MigrationOperationSubject[]) => + const offset = ( + resolution: OrderedResolution, + entries: readonly TEntry[], + ) => entries.map((entry) => ({ ...entry, operationIndex: @@ -667,7 +672,7 @@ function wrapPlanResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: MigrationPlanSubjects | undefined; + readonly subjects: MigrationPlanSubjects | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { @@ -699,7 +704,7 @@ function wrapApplyResult(args: { /** `undefined` for `db init`, which reports no statements. */ readonly appliedStatements: readonly AppliedStatementReport[] | undefined; /** `undefined` for `db init`, which reports no data loss. */ - readonly subjects: MigrationPlanSubjects | undefined; + readonly subjects: MigrationPlanSubjects | undefined; readonly warnings?: readonly MigrationPlannerConflict[]; }): DbInitResult | DbUpdateResult { const success: DbInitSuccess | DbUpdateSuccess = { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 62c50e21556a..85c574d7226c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -60,10 +60,15 @@ export interface PlannedSubject { readonly subject: MigrationSubject; } -/** What a plan would lose, and whose access it would widen. */ +/** An operation of a plan that changes who can read or write its subject's rows. */ +export interface PlannedAccessChange extends PlannedSubject { + readonly widens: boolean; +} + +/** What a plan would lose, and whose access it would change. */ export interface PlannedQuestions { readonly dataLoss: readonly PlannedSubject[]; - readonly accessWidening: readonly PlannedSubject[]; + readonly accessWidening: readonly PlannedAccessChange[]; } function modelText(contract: ContractWithDomain, coordinate: ModelCoordinate): string { @@ -262,14 +267,18 @@ export function dataLossQuestion( }; } -/** The question for one operation that would widen who can read or write its subject's rows. */ +/** + * The question for one operation that changes who can read or write its subject's rows: it widens + * access, as disabling row-level security does, or changes it either way, as dropping a policy + * does. Each such operation is its own question, answered by its own `allow`. + */ export function accessWideningQuestion( - widening: PlannedSubject, + widening: PlannedAccessChange, contracts: StatementContracts, ): PlanQuestion { const subject = subjectText(widening.subject, contracts); return { - question: `${widening.label} would widen who can read and write its rows.`, + question: `${widening.label} would ${widening.widens ? 'widen' : 'change'} who can read and write its rows.`, subject, verbs: ['allow'], forms: {}, @@ -282,10 +291,19 @@ export interface ConsentedSubject { readonly verb: ConsentVerb; readonly subject: MigrationSubject; readonly text: string; + /** The position of the operation an `allow` consented to; a `delete` covers every loss of its subject. */ + readonly operationIndex: number | undefined; } const keyOf = (planned: PlannedSubject) => migrationSubjectKey(planned.subject); +/** A `delete` answers for its subject; an `allow` for its one operation. */ +function questionKey(verb: ConsentVerb, entry: PlannedSubject): string { + return verb === 'allow' + ? `allow:${entry.operationIndex}:${entry.label}:${keyOf(entry)}` + : `delete:${keyOf(entry)}`; +} + function checkAnswers(questions: readonly PlanQuestion[], answers: readonly PlanAnswer[]): void { if (answers.length !== questions.length) { const noun = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; @@ -369,18 +387,23 @@ export async function askPlanQuestions destination: input.destination, renames, }; - const pending = ( + const pending = ( verb: ConsentVerb, - planned: readonly PlannedSubject[], - ): readonly PlannedSubject[] => [ + planned: readonly TEntry[], + ): readonly TEntry[] => [ ...new Map( planned - .filter((entry) => !consented.has(`${verb}:${keyOf(entry)}`)) - .map((entry) => [keyOf(entry), entry]), + .filter((entry) => !consented.has(questionKey(verb, entry))) + .map((entry) => [questionKey(verb, entry), entry]), ).values(), ]; const consent = (verb: ConsentVerb, entry: PlannedSubject, text: string) => - consented.set(`${verb}:${keyOf(entry)}`, { verb, subject: entry.subject, text }); + consented.set(questionKey(verb, entry), { + verb, + subject: entry.subject, + text, + operationIndex: entry.operationIndex, + }); for (const [verb, entries] of [ ['delete', pending('delete', plan.dataLoss)], ['allow', input.askAccess ? pending('allow', plan.accessWidening) : []], 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 35496a71a236..6a9f681debd9 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 @@ -13,6 +13,7 @@ import type { ControlFamilyDescriptor, ControlTargetDescriptor, CoreSchemaView, + MigrationAccessChange, MigrationOperationSubject, MigrationPlannerConflict, MigrationPlanOperation, @@ -454,10 +455,13 @@ export interface AskedSubject extends MigrationOperationSubject { readonly text: string; } +/** An operation an apply asks about because it changes who can read or write its subject's rows. */ +export interface AskedAccessChange extends AskedSubject, MigrationAccessChange {} + /** * Successful dbUpdate result. */ -export interface DbUpdateSuccess extends MigrationPlanSubjects { +export interface DbUpdateSuccess extends MigrationPlanSubjects { readonly mode: 'plan' | 'apply'; readonly plan: { readonly operations: ReadonlyArray<{ 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 45b7136679a5..464c3bfebc44 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 @@ -74,12 +74,30 @@ function updatePresentations(inputs: { }; } -function subjectEntryJson(verb: 'delete' | 'allow', given: readonly StatementText[]) { - return (entry: AskedSubject) => ({ - operationIndex: entry.operationIndex, - subject: migrationSubjectJson(entry.subject), - text: entry.text, - answered: given.some((statement) => statement.verb === verb && statement.text === entry.text), +/** + * The entries a dry run lists, each marked answered when a flag it was given answers its question: + * a `--delete` answers every loss of its subject, and an `--allow` one operation, in order. + */ +function subjectEntriesJson( + entries: readonly AskedSubject[], + verb: 'delete' | 'allow', + given: readonly StatementText[], +) { + const flagsLeft = new Map(); + for (const statement of given) { + if (statement.verb === verb) { + flagsLeft.set(statement.text, (flagsLeft.get(statement.text) ?? 0) + 1); + } + } + return entries.map((entry) => { + const left = flagsLeft.get(entry.text) ?? 0; + if (left > 0 && verb === 'allow') flagsLeft.set(entry.text, left - 1); + return { + operationIndex: entry.operationIndex, + subject: migrationSubjectJson(entry.subject), + text: entry.text, + answered: left > 0, + }; }); } @@ -129,8 +147,8 @@ function updateDocument(inputs: { appliedStatements: value.appliedStatements, ...(value.mode === 'plan' ? { - dataLoss: value.dataLoss.map(subjectEntryJson('delete', inputs.statements)), - accessWidening: value.accessWidening.map(subjectEntryJson('allow', inputs.statements)), + dataLoss: subjectEntriesJson(value.dataLoss, 'delete', inputs.statements), + accessWidening: subjectEntriesJson(value.accessWidening, 'allow', inputs.statements), } : {}), ...ifDefined('warnings', value.warnings), 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 index 4517c8791168..fdc9a1abac13 100644 --- 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 @@ -116,7 +116,7 @@ const migrations = { operationIndexes: [0], })), dataLoss: [{ operationIndex: 0, subject: LOST }], - accessWidening: [{ operationIndex: 0, subject: LOST }], + accessWidening: [{ operationIndex: 0, subject: LOST, widens: true }], plan: { targetId: 'postgres', origin: options.origin, 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 5c04733f9c67..b82f805b80e7 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 @@ -499,7 +499,7 @@ describe('executeDbUpdate', () => { planResult: { kind: 'success', dataLoss: [{ operationIndex: 0, subject: NICKNAME }], - accessWidening: [{ operationIndex: 2, subject: USER }], + accessWidening: [{ operationIndex: 2, subject: USER, widens: true }], appliedStatements: [], plan: { targetId: 'postgres', @@ -763,7 +763,7 @@ describe('executeDbUpdate', () => { }, ).toEqual({ dataLoss: [{ operationIndex: 0, subject: NICKNAME, text: 'user.nickname' }], - accessWidening: [{ operationIndex: 2, subject: USER, text: 'user' }], + accessWidening: [{ operationIndex: 2, subject: USER, widens: true, text: 'user' }], }); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index 77dd71b71178..e55e3cf6c72e 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -120,3 +120,58 @@ describe('questions about storage when the origin contract is unknown', () => { ); }); }); + +describe('questions about access', () => { + it('ask once per operation, and say a policy drop changes access where disabling row-level security widens it', async () => { + const user = { kind: 'model', namespaceId: asNamespaceId('app'), model: 'T' } as const; + const asked: string[] = []; + const result = await askPlanQuestions({ + plan: { + dataLoss: [], + accessWidening: [ + { + operationIndex: 0, + label: 'Drop RLS policy "readers" on "T"', + subject: user, + widens: false, + }, + { + operationIndex: 1, + label: 'Disable row-level security on "T"', + subject: user, + widens: true, + }, + ], + }, + askAccess: true, + renames: [], + preAnswers: [], + consentAll: { delete: false, allow: false }, + origin, + originKnown: true, + keepDataByHand: undefined, + destination, + answer: async (questions) => { + asked.push(...questions.map(({ question }) => question)); + return questions.map(({ subject }) => ({ verb: 'allow', text: subject })); + }, + replan: async () => ok({ dataLoss: [], accessWidening: [] }), + }); + + expect(asked).toEqual([ + 'Drop RLS policy "readers" on "T" would change who can read and write its rows.', + 'Disable row-level security on "T" would widen who can read and write its rows.', + ]); + expect( + result.ok && + result.value.consented.map(({ verb, text, operationIndex }) => ({ + verb, + text, + operationIndex, + })), + ).toEqual([ + { verb: 'allow', text: 'T', operationIndex: 0 }, + { verb: 'allow', text: 'T', operationIndex: 1 }, + ]); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts index ea95546bd859..350daa383e85 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-consent.test.ts @@ -97,6 +97,7 @@ function planQuestions(): readonly PlanQuestion[] { operationIndex: 2, label: 'Disable row-level security on "User"', subject: { kind: 'model', namespaceId: asNamespaceId('app'), model: 'User' }, + widens: true, }, contracts, ), diff --git a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts index 22136798f0d1..e6c77bcc9696 100644 --- a/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts +++ b/packages/2-sql/9-family/src/core/migrations/operation-subjects.ts @@ -1,5 +1,6 @@ import { asNamespaceId, type ContractWithDomain } from '@internal/contract/types'; import type { + MigrationAccessChange, MigrationOperationSubject, MigrationPlanSubjects, MigrationSubject, @@ -68,8 +69,8 @@ export interface CallSubjects { readonly operationCount: number; /** What the call's operation loses, one entry per model, field or storage name. */ readonly dataLoss: readonly SubjectStorage[]; - /** What the call's operation widens access to. */ - readonly accessWidening: readonly SubjectStorage[]; + /** What the call's operation changes access to, and whether it widens it. */ + readonly accessWidening: readonly (SubjectStorage & { readonly widens: boolean })[]; } interface SubjectContext { @@ -174,14 +175,18 @@ export function subjectsOfCalls( context: SubjectContext, ): MigrationPlanSubjects { const dataLoss: MigrationOperationSubject[] = []; - const accessWidening: MigrationOperationSubject[] = []; + const accessWidening: MigrationAccessChange[] = []; let operationIndex = 0; for (const call of calls) { for (const target of call.dataLoss) { dataLoss.push({ operationIndex, subject: subjectOf(target, context) }); } for (const target of call.accessWidening) { - accessWidening.push({ operationIndex, subject: subjectOf(target, context) }); + accessWidening.push({ + operationIndex, + subject: subjectOf(target, context), + widens: target.widens, + }); } operationIndex += call.operationCount; } diff --git a/packages/2-sql/9-family/test/operation-subjects.test.ts b/packages/2-sql/9-family/test/operation-subjects.test.ts index 06d578b417d3..b8383d007b47 100644 --- a/packages/2-sql/9-family/test/operation-subjects.test.ts +++ b/packages/2-sql/9-family/test/operation-subjects.test.ts @@ -131,13 +131,24 @@ describe('subjectsOfCalls', () => { it('lists access-widening operations with the model of their table', () => { expect( subjectsOfCalls( - [unchanged, { operationCount: 1, dataLoss: [], accessWidening: [table('user')] }], + [ + unchanged, + { + operationCount: 1, + dataLoss: [], + accessWidening: [{ ...table('user'), widens: false }], + }, + ], { fromContract: origin, contract: origin, statements: [] }, ), ).toEqual({ dataLoss: [], accessWidening: [ - { operationIndex: 1, subject: { kind: 'model', namespaceId: app, model: 'User' } }, + { + operationIndex: 1, + subject: { kind: 'model', namespaceId: app, model: 'User' }, + widens: false, + }, ], }); }); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts index 77a10935d499..facf28678bdd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operation-subjects.ts @@ -100,15 +100,15 @@ function accessWideningOf( locator: Locator, call: OpFactoryCall, replaced: ReadonlySet, -): readonly SubjectStorage[] { +): CallSubjects['accessWidening'] { if (call instanceof DisableRowLevelSecurityCall) { - return [target(locator, call.schemaName, call.tableName, undefined)]; + return [{ ...target(locator, call.schemaName, call.tableName, undefined), widens: true }]; } if ( call instanceof DropPostgresRlsPolicyCall && !replaced.has(policyKey(call.schemaName, call.tableName, call.policyName)) ) { - return [target(locator, call.schemaName, call.tableName, undefined)]; + return [{ ...target(locator, call.schemaName, call.tableName, undefined), widens: false }]; } return []; } 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 9e2d984d3777..e68e81317db8 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 @@ -23,6 +23,7 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { AppliedMigrationStatement, + MigrationAccessChange, MigrationOperationClass, MigrationOperationSubject, MigrationPlanner, @@ -134,7 +135,7 @@ export type PostgresPlanResult = readonly warnings?: readonly SqlPlannerConflict[]; readonly appliedStatements: readonly AppliedMigrationStatement[]; readonly dataLoss: readonly MigrationOperationSubject[]; - readonly accessWidening: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationAccessChange[]; } | SqlPlannerFailureResult; 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 32ecb9a20466..3c8b12fe76c6 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 @@ -19,6 +19,7 @@ import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter import type { TargetBoundComponentDescriptor } from '@internal/framework-components/components'; import type { AppliedMigrationStatement, + MigrationAccessChange, MigrationOperationSubject, MigrationPlanner, MigrationScaffoldContext, @@ -72,7 +73,7 @@ export type SqlitePlanResult = readonly plan: TypeScriptRenderableSqliteMigration; readonly appliedStatements: readonly AppliedMigrationStatement[]; readonly dataLoss: readonly MigrationOperationSubject[]; - readonly accessWidening: readonly MigrationOperationSubject[]; + readonly accessWidening: readonly MigrationAccessChange[]; } | SqlPlannerFailureResult; From 791e0e05e8d92074387199689678e60d655df5c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:58:38 +0200 Subject: [PATCH 092/109] TML-3476: the aggregate planner test gives its access change a widens flag Signed-off-by: willbot Signed-off-by: Will Madden --- .../migration/test/aggregate/strategies/plan-from-diff.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 14531abce35b..c4f1a81c403b 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 @@ -236,7 +236,7 @@ describe('planFromDiff', () => { plan: makeStubPlan('placeholder'), appliedStatements: [], dataLoss: [{ operationIndex: 0, subject }], - accessWidening: [{ operationIndex: 0, subject }], + accessWidening: [{ operationIndex: 0, subject, widens: true }], }), emptyMigration: () => { throw new Error('not used'); From 62a513e12592c00f69637f06f580935c4aea158b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:58:53 +0200 Subject: [PATCH 093/109] TML-3476: the snapshot steps in a question name the prisma command Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/statements/plan-questions.ts | 5 +++-- .../test/control-api/statements/ask-plan-questions.test.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 85c574d7226c..3eac03561b9e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -153,11 +153,12 @@ function lossText(subject: MigrationSubject, text: string, originKnown: boolean) /** * How to keep the rows of a storage subject that was renamed, when the origin contract is unknown: * store the snapshot of the contract the database is at, as `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` - * advises, then answer with a rename. The refusal gives it once, on its first storage question. + * advises, then answer with a rename. The refusal gives it once, on its first storage question. The + * engine does not put the CLI's name in a question's text, so the steps name `prisma`. */ export const ORIGIN_SNAPSHOT_RECOVERY = [ 'If it was renamed, keep its data instead: store the snapshot of the contract the database is at, then run this command again and answer with --rename.', - ...STORE_ORIGIN_SNAPSHOT_STEPS.map((step) => step.replaceAll('`{bin} ', '`')), + ...STORE_ORIGIN_SNAPSHOT_STEPS.map((step) => step.replaceAll('{bin}', 'prisma')), ].join(' '); function sameCoordinate(statement: ResolvedMigrationStatement, subject: MigrationSubject): boolean { diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index e55e3cf6c72e..92540193a314 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -115,6 +115,7 @@ describe('questions about storage when the origin contract is unknown', () => { 'Drop table Profile would lose the data in "Profile", named by its storage name because the origin contract is unknown; --delete loses its rows.', ]); expect(ORIGIN_SNAPSHOT_RECOVERY).toContain('--dry-run'); + expect(ORIGIN_SNAPSHOT_RECOVERY).toContain('`prisma db update --advance-ref --dry-run`'); expect(ORIGIN_SNAPSHOT_RECOVERY).toContain( 'with the same `--db` as this command if it has one', ); From acbda5d3d00662a08591b0f29eb826aab7ed8744 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 04:59:42 +0200 Subject: [PATCH 094/109] TML-3476: a dry run's suggested apply repeats --to, --advance-ref and --db as a placeholder Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../control-api/statements/statement-flag.ts | 3 ++- .../3-tooling/cli/src/orm/db/update.ts | 17 +++++++++++-- .../3-tooling/cli/test/orm/db-update.test.ts | 24 +++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 8547fb546c9a..fc1eb76eb00f 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. A field's new name keeps its model: `--rename User.nickname:User.handle`, which the question writes as `User.nickname:User.`. - `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given. The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given, `--to` and `--advance-ref`, and `--db ` as a placeholder when the dry run named its database (the URL is never printed). The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts index 74c8d41e2f06..4f5dddc7faae 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/statement-flag.ts @@ -2,7 +2,8 @@ import type { StatementText } from './statement-text'; const SHELL_SAFE = /^[A-Za-z0-9_.:/@%+,-]+$/; -function shellQuoted(value: string): string { +/** The value as a shell passes it through unchanged, single-quoted when needed. */ +export function shellQuoted(value: string): string { return SHELL_SAFE.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; } 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 464c3bfebc44..0b2215f1916a 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 @@ -23,7 +23,7 @@ import { preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; import { retryCommandFor } from '../../control-api/operations/ref-resolution'; -import { statementFlag } from '../../control-api/statements/statement-flag'; +import { shellQuoted, statementFlag } from '../../control-api/statements/statement-flag'; import type { StatementText } from '../../control-api/statements/statement-text'; import type { AskedSubject, CreateControlClient, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; @@ -47,6 +47,9 @@ function updatePresentations(inputs: { readonly database: string | undefined; readonly to: string | undefined; readonly dryRun: boolean; + /** Whether the run named its database with `--db`, which the suggested apply repeats as a placeholder. */ + readonly dbGiven: boolean; + readonly advanceRef: string | undefined; readonly statements: readonly StatementText[]; }): Presentations { const { document, database, dryRun } = inputs; @@ -69,7 +72,15 @@ function updatePresentations(inputs: { next: () => migrationResultNextActions( document, - ['{bin} db update', ...inputs.statements.map(statementFlag)].join(' '), + [ + '{bin} db update', + ...(inputs.to === undefined ? [] : [`--to ${shellQuoted(inputs.to)}`]), + ...(inputs.dbGiven ? ['--db '] : []), + ...(inputs.advanceRef === undefined + ? [] + : [`--advance-ref ${shellQuoted(inputs.advanceRef)}`]), + ...inputs.statements.map(statementFlag), + ].join(' '), ), }; } @@ -373,6 +384,8 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { database: prepared.value.database, to: args.flags.to, dryRun: args.flags.dryRun, + dbGiven: args.flags.db !== undefined, + advanceRef: args.flags.advanceRef, statements: [...renames, ...previewConsents], }), ), 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 3536b0dd7d4a..f9bf2e14f5dd 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 @@ -274,6 +274,30 @@ describe('db update --rename', () => { ]); }); + it('suggests the apply with the database as a placeholder and the ref it advances', async () => { + mocks.dbUpdate.mockResolvedValue(ok(planSuccess())); + const run = await harness().run( + [ + 'db', + 'update', + '--dry-run', + '--db', + 'postgres://user:secret@localhost/appdb', + '--advance-ref', + 'qa', + ], + { cwd: projectDir, isTty: { stdout: true } }, + ); + + expect(run.presented?.presentation.next).toEqual([ + { + kind: 'run-command', + label: 'Apply the planned operations', + command: 'prisma-test db update --db --advance-ref qa', + }, + ]); + }); + it('lists the applied statements on a dry run', async () => { mocks.dbUpdate.mockResolvedValue( ok({ ...planSuccess(), appliedStatements: [{ ...PROFILE_TO_USER, operationIndexes: [] }] }), From 2966304f06ca5d43c3df9aa087e227b55c360354 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:00:40 +0200 Subject: [PATCH 095/109] TML-3476: docs, skills and fragments follow the QA fixes, and record two deferred QA findings The docs say an --allow answers one operation that changes access, name renameStatements and keepDataByHand, and give a field's rename form with its model. The extension fragment says a replaced policy's drop is not listed. The skills say a typo next to an unanswered question is reported in CLI.CONSENT_REQUIRED. The QA script's scenarios 3, 4, 8 and 9 expect the current behaviour. deferred.md records the MongoDB required-field validator and the NOT NULL runner advice. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 6 +++--- packages/1-framework/3-tooling/cli/README.md | 2 +- projects/migration-statements/deferred.md | 12 ++++++++++++ .../migration-statements/slices/refusal/manual-qa.md | 8 ++++---- skills/prisma-8/references/debug.md | 2 +- skills/prisma-8/references/migrations.md | 4 ++-- .../migration-statement-refusal/app/instructions.md | 2 +- .../extension/instructions.md | 10 +++++----- 8 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 406b9dfbffc3..4471a271cec1 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -182,7 +182,7 @@ The origin and destination contracts cannot say whether a model or field that is - `--rename :` keeps a model's or field's data under a new name. Renames are the only statements that reach the planner, which takes them as its third input, beside the destination contract and the origin schema. - `--delete ` lets the plan lose a subject's data. -- `--allow ` lets `db update` widen who can read or write a model's rows. +- `--allow ` lets `db update` change who can read or write a model's rows, one operation at a time. Statements are never part of `contract.json` or its hashes. @@ -191,7 +191,7 @@ prisma migration plan --name tidy-users --rename Profile:User --rename User.name prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` -**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename` (not where the target's migrations capability sets `refusesRenameStatements`, as MongoDB's does); `db update` also asks one per access-widening subject, answered by `allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still has the operation asked about fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; a loss the new plan has on the same subject, such as a type change on the renamed field, is asked next. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. A `db update --dry-run` asks nothing; it checks the same flags against the subjects an apply would ask about and fails an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so a script that checks the code handles both. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename`. Where the target's migrations capability sets `renameStatements: { refused: true, keepDataByHand }`, as MongoDB's does, the question offers no rename and ends with the target's text on keeping the data by hand (renaming the collection in `mongosh`, then running the command again). `db update` also asks one question per operation that changes who can read or write a model's rows, answered by `allow`: the question says the operation widens access when it does, such as disabling row-level security, and that it changes access otherwise, such as dropping a policy, since a permissive policy grants access. Two such operations on one model are two questions, each answered by its own `--allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still has the operation asked about fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; a loss the new plan has on the same subject, such as a type change on the renamed field, is asked next. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. A `db update --dry-run` asks nothing; it checks the same flags against the subjects an apply would ask about and fails an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so a script that checks the code handles both. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. #### Rename statements @@ -222,7 +222,7 @@ Rename operations are `widening`, so neither command asks about them. The `migra **Delete statements.** `--delete ` consents to losing the data of a subject the planner's `dataLoss` names: a model or field of the origin contract written as a statement names it (`Legacy`, `User.name`, with the namespace when the origin contract has several), or the storage name of data no model stores. A field of a model the plan renames is written through the model's new name, as a field rename is written (`--delete User.nickname` after `--rename Profile:User`); the CLI matches answers by subject, not by its text. A delete never reaches a planner: the CLI matches it, by equality, against the subjects of the plan's `dataLoss` entries. It needs no origin contract, so with no snapshot a rename fails with `MIGRATION.STATEMENT_ORIGIN_UNKNOWN` and a delete still works. The CLI writes a delete as its question named the subject (`consentDescription` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts)): `delete model "Legacy"`, `delete field "User.nickname"` after a model rename, or `delete storage "audit_log"`, and `migration plan` and `db update` list it under `Statements applied` with the positions of the operations it consented to; in JSON each `appliedStatements` entry carries its `verb`. -**Allow statements.** `--allow ` consents to an operation the planner's `accessWidening` names: disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. A policy whose generated name changes with its body is created again when the plan creates a policy with the same name prefix on the same table, so editing its body asks nothing, as editing a policy with a stated name does. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; the CLI writes it the same way, as `allow model "User"` or `allow storage "user"`. +**Allow statements.** `--allow ` consents to one operation the planner's `accessWidening` names (a `MigrationAccessChange`, whose `widens` says whether it widens access or only changes it): disabling row-level security, or dropping a policy that no other operation of the plan creates again, on Postgres. A policy whose generated name changes with its body is created again when the plan creates a policy with the same name prefix on the same table, so editing its body asks nothing, as editing a policy with a stated name does. Only `db update` asks about access: `migration plan` writes a package, and the package is reviewed before it is applied. Like a delete, an allow never reaches a planner and is matched by subject; the CLI writes it the same way, as `allow model "User"` or `allow storage "user"`. #### The origin of `db update` diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index fc1eb76eb00f..ce788585d420 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -956,7 +956,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--delete ` lets the update lose that data, for example `--delete Legacy` or `--delete User.nickname`. A field of a model the update renames is named through the model's new name. - `--rename :` keeps the data of a model or field under a new name instead. A field's new name keeps its model: `--rename User.nickname:User.handle`, which the question writes as `User.nickname:User.`. -- `--allow ` lets the update widen who can read or write that model's rows, for example `--allow User`. +- `--allow ` lets the update change who can read or write that model's rows, for example `--allow User`. Each such operation is its own question: dropping a policy and disabling row-level security on one model take two `--allow User` flags. The question says disabling row-level security widens access, and that dropping a policy changes it, since a permissive policy grants access. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given, `--to` and `--advance-ref`, and `--db ` as a placeholder when the dry run named its database (the URL is never printed). The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 887700df817c..49f202fdf023 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -46,3 +46,15 @@ The Postgres planner has a temporary-default recipe for a required column added Project decision 6 lets a namespace delete cover everything in the namespace while the refusal lists each model. The engine answers a question only by a value equal to its subject or starting with it, so a namespace-wide answer has to be expanded by the ORM before the questions are asked. Slice 3 adds the namespace noun to `rename` and `delete` together and does that expansion there. Until then every `delete` names one model, field or storage object. + +## A MongoDB validator that requires a new field leaves existing documents unwritable + +**Found:** slice 2 manual QA (2026-10-08), F9. For slice 4. + +Adding a required field to a populated collection, or making an optional field required, updates the collection's validator with no question and no warning; it is `widening`, since the validator applies only to later writes. Every existing document that lacks the field then fails any update with `Document failed validation`. Nothing is lost, but nothing tells the user that existing documents need the field. A backfill statement would let the user say how to fill them. + +## The Postgres runner's NOT NULL failure gives advice about drift + +**Found:** slice 2 manual QA (2026-10-08), F10. Pre-existing text. + +`db update` making a column NOT NULL on a table with a NULL fails at the precheck (`ensure no NULL values in "note"`) with nothing lost, but the next action says to reconcile schema drift, and the `why` says only "Migration runner failed". The advice should say how many rows hold NULL and how to fix them, or keep the field optional. diff --git a/projects/migration-statements/slices/refusal/manual-qa.md b/projects/migration-statements/slices/refusal/manual-qa.md index 0791f3c8971a..4791e0642f15 100644 --- a/projects/migration-statements/slices/refusal/manual-qa.md +++ b/projects/migration-statements/slices/refusal/manual-qa.md @@ -20,13 +20,13 @@ | - | -------- | -------------- | --------- | | 1 | Postgres, terminal: remove a model and rename another, run `migration plan` with no flags, answer the questions by typing | The question text is understandable; `delete` and `rename X:Y` both work typed; a wrong answer is explained and re-asked; the written plan matches the answers | tmpdir, real TTY | | 2 | The same non-interactively (`< /dev/null` or `--no-interactive`) | The refusal lists every operation and the exact flags; pasting them works; nothing was written by the refused run | tmpdir | -| 3 | SQLite, `db update` interactive, then `--confirm ` only, then `--delete` | The old consent is refused with a message that names the new form; `--delete` applies; a second run with the same statements fails and says why | tmpdir, real TTY | -| 4 | A stray or misspelled `--delete`, and `--delete` on a dry run | `CLI.CONSENT_UNUSED` before anything is written or applied, with wording that helps | tmpdir | +| 3 | SQLite, `db update` interactive, then `--confirm ` only, then `--delete` | `--confirm` alone is refused with `CLI.CONSENT_REQUIRED`, which names the leftover `--confirm` and the flags that answer; `--confirm` with every question answered is `CLI.CONSENT_UNUSED`; `--delete` applies; a second run with the same statements fails and says why | tmpdir, real TTY | +| 4 | A stray or misspelled `--delete`, and `--delete` on a dry run | A stray `--delete` with every question answered is `CLI.CONSENT_UNUSED`; a misspelled one next to an unanswered question is named in the `CLI.CONSENT_REQUIRED` refusal; nothing is written or applied. On a dry run a matching `--delete` is accepted and its question marked `(answered)`, and one that matches nothing is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION` | tmpdir | | 5 | Postgres, drop a policy through `db update` | The dry run lists it under access widening and asks nothing; the apply asks with `allow`; `--allow User` applies | tmpdir | | 6 | Add a required field to a populated table, Postgres and MongoDB; tighten a column to NOT NULL with and without NULLs | No question; the NULL case fails clearly without losing data | tmpdir | | 7 | `db update --db ` with no snapshot, dropping a model | The refusal names the table by its storage name and says the origin is unknown; `--delete ` applies | tmpdir | -| 8 | A field on a renamed model: `--rename Profile:User` with `Profile.nickname` dropped | The refusal suggests `--rename User.nickname:`; that form resolves; the `Statements applied` block names things the way you typed them | tmpdir | -| 9 | MongoDB: drop a collection through both commands; `--rename` | Refused until `--delete Event`; `--rename` refused with advice that loses nothing | tmpdir | +| 8 | A field on a renamed model: `--rename Profile:User` with `Profile.nickname` dropped | The refusal suggests `--rename 'User.nickname:User.'`; that form resolves; the `Statements applied` block names things the way you typed them | tmpdir | +| 9 | MongoDB: drop a collection through both commands; `--rename` | Refused until `--delete Event`, and the question says how to keep the documents by hand (rename the collection in `mongosh`, then run again); `--rename` refused with advice that loses nothing | tmpdir | | 10 | Read the app and extension upgrade fragments as their audiences **(judgement)** | A script author knows what to replace `--confirm` with; a planner author knows every field and type to add | read-only | | 11 | Ask the skill how to delete a field and how to answer a refusal **(judgement)** | `skills/prisma-8/references/migrations.md` leads to `--delete`, and to `--rename` when the user meant a rename | read-only | | 12 | Exploratory: 20 minutes | Two `--delete`s, `--delete` for a model whose field is also dropped, an auto-baseline plan with a drop, `--json` in a TTY (does it still prompt to stderr?), `--yes`, Ctrl-C at a question (exit 3), a placeholder plan with a drop | tmpdir | diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index aa0a5c412022..bad5d318bbdd 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -97,7 +97,7 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `CONTRACT.SCHEMA_VERIFICATION_FAILED` | `db verify`, `db sign` (both exit 4 with the finding) | Live schema does not satisfy the contract; `meta.issues` lists the drifted paths per `meta.space`. Run `db update` to reconcile, or adjust the contract. | | `MIGRATION.RUNNER_FAILED` | `db migrate`, `db update`, `db init` | Wrapper for an unclassified apply failure; `why` and `meta` carry the detail. Reconcile the reported failure, then re-run. Previously applied migrations are preserved. | | `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` when a plan would lose data (or, on `db update`, widen access) and nobody can answer (`--no-interactive`, CI, `--yes`) | `meta.unanswered[]` and `nextActions` list each question with its flags. Re-run with `--delete `, `--rename :` or `--allow ` as each subject means. `--confirm` and `--yes` answer none of them. `db migrate` does not ask. | -| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo or a subject the plan does not touch (on `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`). Fix or drop the flag. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | Every question was answered, and a `--delete` or `--allow` was left over: a typo, a subject the plan does not touch, or a `--confirm` (which consents to nothing). Fix or drop the flag. A typo next to a question nobody answered is reported as `CLI.CONSENT_REQUIRED` instead, which names the flag that matched nothing. On `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. | | `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The rename still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. | | `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.runtime().query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. | diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index ceee4285afe3..61cc355678a9 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -72,7 +72,7 @@ Treat the rendered import lines as framework-managed on both targets: | `MIGRATION.DATA_TRANSFORM_CONTRACT_MISMATCH` | Building a data-transform query plan | The query builder was instantiated with a contract reference different from the `endContract` passed to `this.dataTransform(...)`. Use the `endContract` imported at module scope for both. | | `MIGRATION.HASH_MISMATCH` *Migration package is corrupt* | `db migrate` (or any read of the package) | `ops.json` / `migration.json` were edited without self-emitting. Run `node migrations/app//migration.ts` to re-emit, then re-run `db migrate`. | | `CLI.CONSENT_REQUIRED` | `migration plan` / `db update` run with nobody to answer (`--no-interactive`, CI, `--yes`) | The plan would lose data or (on `db update`) widen access. `meta.unanswered` and `nextActions` list each question with the flags that answer it: `--delete `, `--rename :`, `--allow `. Re-run with the ones you mean. `--confirm` and `--yes` answer none of them. | -| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | A `--delete` or `--allow` answers no question: a typo or a subject the plan does not drop (on `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`). Fix or drop the flag. | +| `CLI.CONSENT_UNUSED` | `migration plan` / `db update` | Every question was answered, and a `--delete` or `--allow` was left over: a typo, a subject the plan does not touch, or a `--confirm` (which consents to nothing). Fix or drop the flag. A typo next to a question nobody answered is reported as `CLI.CONSENT_REQUIRED` instead, which names the flag that matched nothing. On `db update --dry-run` the same mistake is `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`. | | `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS` | A rename typed at the data-loss prompt | The plan was made again with the rename and still loses the subject's data. Answer with `delete`, or give a rename whose old name is the subject. | | `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. | @@ -478,7 +478,7 @@ The concept: the planner cannot tell whether a dropped model or field was meant - `--delete ` lets the data go: `--delete Legacy`, `--delete User.nickname`. A subject no model stores is named by its storage name (`public.audit_log` on Postgres, `audit_log` on SQLite) and can only be deleted. - `--rename :` keeps it under a new name: `--rename Profile:User`, or for a field `--rename User.nickname:User.handle` (the new name keeps its model). A field of a renamed model is named through the new name (`--delete User.nickname` after `--rename Profile:User`). -- `--allow ` (`db update` only) lets the widening run. +- `--allow ` (`db update` only) lets an operation that changes who can read or write the model's rows run: one flag per operation, so a policy drop and disabling row-level security on one model take `--allow User --allow User`. ```bash pnpm prisma migration plan --name drop_legacy --delete Legacy diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index 93563c697195..a6d48a549255 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -73,7 +73,7 @@ Detection finds `--confirm` on the same command line as `db update`, including a - `--delete ` or `--delete ` for each model or field whose data the update may lose, or `--rename :` / `--rename :` if it was renamed. - `--delete ` for data no model stores, such as a table or column added by hand; on Postgres the name is schema-qualified (`public.audit_log`). -- `--allow ` before an operation that widens who can read or write that model's rows, such as dropping its row-level-security policy. +- `--allow ` before each operation that changes who can read or write that model's rows, such as dropping its row-level-security policy or disabling row-level security; each operation is its own question, so both on one model take `--allow User --allow User`. To find the subjects, run the same command with `--no-interactive --json` and read `meta.unanswered[]`, or `--dry-run --json` and read `dataLoss` and `accessWidening`. A `--delete` or `--allow` that answers no question fails the run with `CLI.CONSENT_UNUSED`; on `--dry-run`, which asks nothing, it fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command you will apply can be previewed with `--dry-run` first. diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md index 819f42fba50e..49ee1f2e2fe9 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -24,7 +24,7 @@ changes: - '(? Date: Thu, 8 Oct 2026 05:03:09 +0200 Subject: [PATCH 096/109] TML-3476: engine spec records the narrowed unused --confirm rule Signed-off-by: willbot Signed-off-by: Will Madden --- .../migration-statements/slices/engine-statement-prompt/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/migration-statements/slices/engine-statement-prompt/spec.md b/projects/migration-statements/slices/engine-statement-prompt/spec.md index 3aade72f5510..f8a1677ef86e 100644 --- a/projects/migration-statements/slices/engine-statement-prompt/spec.md +++ b/projects/migration-statements/slices/engine-statement-prompt/spec.md @@ -67,7 +67,7 @@ A question's `subject` may contain `:`. A value answers the question whose subje - A command cannot declare an ordinary flag with the same name as one of its statements, and a `statement()` call naming a verb the command did not declare is a construction error. - `arity` is 1 for every ORM verb today; it exists so the engine never assumes the shape of a statement. - Order across flags is kept: the engine records the position of every verb-flag value in argv, so a product that needs `--rename A:B --delete C` in the order written can read it. This slice only needs to consume values; it exposes the ordered list on the run state so the ORM's statement list can be built from it later. -- At the end of a run that settled successfully, an unconsumed verb-flag value fails the run with a new `CLI.CONSENT_UNUSED`: "`--delete Legacy` was given but nothing in this run asked about `Legacy`". A run that failed for another reason reports that reason only. `--confirm` keeps its current silent behaviour; changing it is not this slice. +- At the end of a run that settled successfully, an unconsumed verb-flag value fails the run with a new `CLI.CONSENT_UNUSED`: "`--delete Legacy` was given but nothing in this run asked about `Legacy`". A run that failed for another reason reports that reason only. On a command that declares statements, an unused `--confirm` value is also `CLI.CONSENT_UNUSED` (the ORM's QA found a defensive `--confirm ` silently ignored); on every other command a leftover `--confirm` stays silent as before, because shipped commands accept the flag on paths that never ask (a follow-up issue in prisma-cli records them). ### What stays From a50d17edd5bcd66e2b730cf197f289a5eba7f238 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:08:04 +0200 Subject: [PATCH 097/109] TML-3476: the engine spec's scope matches the narrowed --confirm rule Signed-off-by: willbot Signed-off-by: Will Madden --- .../migration-statements/slices/engine-statement-prompt/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/migration-statements/slices/engine-statement-prompt/spec.md b/projects/migration-statements/slices/engine-statement-prompt/spec.md index f8a1677ef86e..c2b105039d2d 100644 --- a/projects/migration-statements/slices/engine-statement-prompt/spec.md +++ b/projects/migration-statements/slices/engine-statement-prompt/spec.md @@ -81,7 +81,7 @@ One primitive, its flags and its error codes ship together because a prompt with **In:** `statement` on `PromptSurface` and both renderers (clack, line); verb-flag registration and reservation; consumption and `CLI.CONSENT_UNUSED`; `CLI.CONSENT_REQUIRED` for a statement; tests in `packages/cli-engine/tests/prompts.test.ts` style, with scripted `answers`; the engine `README`, `docs/product/cli-style-guide.md` (consent section: a statement is the second consent form) and `docs/reference/error-reference.md`; `pnpm bump-cli-engine-version minor`. -**Out:** any ORM command; `--confirm` leftovers; `select`-style menus; help text for the ORM's verbs (the ORM owns its briefs). +**Out:** any ORM command; `--confirm` leftovers on commands that declare no statements (prisma/prisma-cli#339); `select`-style menus; help text for the ORM's verbs (the ORM owns its briefs). ## Pre-investigated edge cases From 61d5f236f5e1d34adfc3e332ac072800b43ae16c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:09:43 +0200 Subject: [PATCH 098/109] TML-3476: pin the engine build that names stray flags in a refusal and settles signals at a prompt Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 2 +- pnpm-lock.yaml | 116 ++++++++++++++++++++++++------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index c27115032d5f..1101fbf53795 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@c560ced", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@adb07a8", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5040e56254fa..3b68ef410f4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@c560ced + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@adb07a8 hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1377,8 +1377,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1535,8 +1535,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -3247,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@c560ced - version: https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 + version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,8 +7662,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@c560ced': - resolution: {integrity: sha512-KlnnLWcKKhCOH/1BtfnIEjS9/UxwukevcSWOjShtmQWOvy5KpZADyba2k1bEtDLXmhx7XQjMjlrsj7Md/fSrlQ==, tarball: https://pkg.pr.new/@prisma/cli-engine@c560ced} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@adb07a8': + resolution: {integrity: sha512-5v6UFPyGTNEeSkqFPgp+1mP7KlIFRxmbQ2+ETL/4pQRAWNd6bd2HtYP6ojbsOQ6wclAU8g60sj+IKDBEi2zfjw==, tarball: https://pkg.pr.new/@prisma/cli-engine@adb07a8} version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: @@ -13432,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@c560ced(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From cf0efc5598c686e8bd05140370b7a47d6dc1929f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:16:30 +0200 Subject: [PATCH 099/109] TML-3476: pin engine 1cc84f8 Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 2 +- pnpm-lock.yaml | 116 ++++++++++++++++++++++++------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 1101fbf53795..d5839f0b8635 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@adb07a8", + "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@1cc84f8", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b68ef410f4b..dc4c9a5bda61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@adb07a8 + '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@1cc84f8 hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +202,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +239,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +294,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +346,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +389,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +435,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +472,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +518,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +570,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +634,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +683,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +735,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +799,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +905,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +954,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1377,8 +1377,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1535,8 +1535,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -3247,8 +3247,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3335,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3417,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3793,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5334,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5480,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5498,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5685,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5805,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5880,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@adb07a8 - version: https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 + version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,8 +7662,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@adb07a8': - resolution: {integrity: sha512-5v6UFPyGTNEeSkqFPgp+1mP7KlIFRxmbQ2+ETL/4pQRAWNd6bd2HtYP6ojbsOQ6wclAU8g60sj+IKDBEi2zfjw==, tarball: https://pkg.pr.new/@prisma/cli-engine@adb07a8} + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@1cc84f8': + resolution: {integrity: sha512-xIKRkaCxSou8CU2MgdxmTHUmPnl4FYl35+xOhyyz1ja+4hgKJK6WbLYxxg9NdcHvRQb/TGEtF18Ugdlku5RnNw==, tarball: https://pkg.pr.new/@prisma/cli-engine@1cc84f8} version: 0.7.0 engines: {node: '>=22.12.0'} peerDependencies: @@ -13432,7 +13432,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@adb07a8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From aa34e19a1538c5355950cfaee1f893e1d9b8eced Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:17:11 +0200 Subject: [PATCH 100/109] TML-3476: the spec says a policy drop changes access and disabling RLS widens it, one question each Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/slices/refusal/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md index 393bd9ce3f3e..6932aa86c14e 100644 --- a/projects/migration-statements/slices/refusal/spec.md +++ b/projects/migration-statements/slices/refusal/spec.md @@ -21,7 +21,7 @@ With `--delete Legacy` the plan is written, and `Drop table "Legacy"` is listed (delete, or rename Legacy:) › delete ``` -`prisma db update` behaves the same way against a live database. `--confirm ` no longer consents to data loss on either command, and there is no blanket consent: each operation that loses data has its own statement. Before `db update` drops a row-level-security policy or disables row-level security on a table, it asks the same way with the verb `allow`: `--allow User` consents to widening who can read or write `User`'s rows. +`prisma db update` behaves the same way against a live database. `--confirm ` no longer consents to data loss on either command, and there is no blanket consent: each operation that loses data has its own statement. Before `db update` drops a row-level-security policy or disables row-level security on a table, it asks the same way with the verb `allow`: `--allow User` consents to one operation that changes who can read or write `User`'s rows. ## Chosen design @@ -56,7 +56,7 @@ The CLI turns each entry into one engine question (`ctx.prompt.statements`): que ### Access widening (`allow`) -`db update` asks before applying an operation that widens who can read or write rows: dropping a row-level-security policy and disabling row-level security. Both stay `widening`. The family's success result lists them in `accessWidening: readonly { operationIndex, subject }[]` with the model as subject; the CLI asks with verb `allow` only (`--allow User`). `migration plan` does not ask: the written migration is reviewed before it runs. The transient DROP in a policy replacement (DROP then CREATE of a changed policy) is not listed. +`db update` asks before applying an operation that changes who can read or write rows: disabling row-level security (which widens access) and dropping a row-level-security policy (which changes it; dropping a permissive policy narrows). Both stay `widening` as operation classes, and each operation is its own question, answered by its own `--allow `. The family's success result lists them in `accessWidening: readonly { operationIndex, subject }[]` with the model as subject; the CLI asks with verb `allow` only (`--allow User`). `migration plan` does not ask: the written migration is reviewed before it runs. The transient DROP in a policy replacement (DROP then CREATE of a changed policy) is not listed. ### Output From 13b00407ad1b61d9d28282db394eed19a63a89ff Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:17:19 +0200 Subject: [PATCH 101/109] TML-3476: manual QA report for the refusal slice Signed-off-by: willbot Signed-off-by: Will Madden --- .../2026-10-08-qa-slice-2.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 projects/migration-statements/manual-qa-reports/2026-10-08-qa-slice-2.md diff --git a/projects/migration-statements/manual-qa-reports/2026-10-08-qa-slice-2.md b/projects/migration-statements/manual-qa-reports/2026-10-08-qa-slice-2.md new file mode 100644 index 000000000000..c049c076527f --- /dev/null +++ b/projects/migration-statements/manual-qa-reports/2026-10-08-qa-slice-2.md @@ -0,0 +1,249 @@ +# Manual QA — slice 2, the refusal and `--delete` — 2026-10-08 + +Branch `tml-3476-statement-refusal` at `7cbc9f9e8a`. Script: `projects/migration-statements/slices/refusal/manual-qa.md`, all twelve scenarios. + +## Summary + +| # | Scenario | Verdict | +| - | -------- | ------- | +| 1 | Postgres, terminal: answer the questions by typing | ✅ | +| 2 | The same non-interactively | ✅ (F11) | +| 3 | SQLite `db update`: interactive, `--confirm`, `--delete` | ⚠️ (F3, F4) | +| 4 | Stray or misspelled `--delete`; `--delete` on a dry run | ⚠️ (F5, F6) | +| 5 | Postgres, drop a policy through `db update` | ⚠️ (F7, F8) | +| 6 | Required field and NOT NULL, Postgres and MongoDB | ⚠️ (F9, F10) | +| 7 | `db update --db` with no snapshot | ✅ (F5, F12, F13) | +| 8 | A field on a renamed model | ⚠️ (F2) | +| 9 | MongoDB drop and `--rename` | ⚠️ (F1) | +| 10 | Upgrade fragments (judgement) | ✅ (F15) | +| 11 | Skill replay (judgement) | ✅ (F16) | +| 12 | Exploratory | ⚠️ (F14, F17) | + +No blocker by the script's definition. F1 comes closest: see its note. + +## Findings + +1. ⚠️ **F1 — On MongoDB, a refusal for a dropped collection offers only `--delete`, and says nothing about the hand rename that keeps the documents.** Contract renames `Event` to `Log` (scenario 9). Command: `prisma db update` (also `migration plan`). Output: + ``` + ✘ [CLI.CONSENT_REQUIRED] "Event" needs a statement, and the session is not interactive. + why: Drop collection Event would lose the data of model "Event". + → Pass --delete Event + ``` + In a terminal the question offers only `(delete)` (seen for the reverse rename, `Log` back to `Event`: `Drop collection Log would lose the data of model "Log". (delete)`), and the only way out is Ctrl-C. A user who renamed the model is offered exactly one route, and it drops the documents. The route that keeps them, `renameCollection` by hand and then `db update` with no statement (verified: `(no operations)`, 2 documents kept in `Log`), appears only if the user thinks to try `--rename Event:Log` and reads that error. I expected the refusal to say: "If it was renamed, rename the collection by hand first (see `--rename`'s error)". This is not a blocker, because the `why` says the data is lost and `--delete` is the explicit consent to that. But by the script's literal rule ("an error whose advice loses data if followed") it is close to one. +2. ⚠️ **F2 — The `` placeholder for a field leads to a rename that fails.** Command: `prisma db update --rename Profile:User` with `Profile.nickname` dropped and `User.handle` added (scenario 8). The refusal says `→ Pass --rename 'User.nickname:'`, and the question says `(rename User.nickname: or delete)`. The natural reading is `rename User.nickname:handle`. Typed at the prompt it fails: `Cannot resolve statement "--rename User.nickname:handle". The destination contract has no model "handle" (models: User).` The form that works is `User.nickname:User.handle`. For a field the placeholder should be `User.`, or a bare field name should resolve within the model. The re-ask did work, and the full form planned two renames and kept the rows. +3. ⚠️ **F3 — `--confirm ` is ignored without a word.** Command: `prisma db update --confirm dev.db` and `--confirm ./dev.db --no-interactive` (SQLite, scenario 3), `--confirm template1` (Postgres, scenario 5), `--confirm s9` (MongoDB), and `migration plan --confirm postgres`. Each gives the same `CLI.CONSENT_REQUIRED` as a run without `--confirm`. Nothing was applied, so no data is at risk. But a script author whose run used to pass sees a refusal that never mentions the flag they passed. I expected a line such as "`--confirm` no longer consents to data loss; use the flags below". The app upgrade fragment does say this. +4. ⚠️ **F4 — A second run of `--delete` gives advice that points the wrong way.** Command: `prisma db update --delete Legacy`, run again after it applied (SQLite). Output: `✘ [CLI.CONSENT_UNUSED] --delete Legacy was given but nothing in this run asked about Legacy.` / `→ Remove the flag, or spell the subject the way the command names it.` The spelling is right; the table is already gone. Compare the rename re-run in the same scenario, which is now exemplary: `The origin contract already has "User" and has no "Profile", so this rename has already happened. … → Leave out --rename Profile:User.` +5. ⚠️ **F5 — A misspelled or wrongly shaped `--delete` is not reported while a question is unanswered.** Commands (SQLite, scenario 4): `--delete Legcy`, `--delete legacy`, `--delete Legacy.note`; Postgres: `migration plan … --rename Profile:User --delete Legcy`; scenario 7: `--delete Legacy` and `--delete legacy_rows` where the subject is `public.legacy_rows`. Each gives the plain `CLI.CONSENT_REQUIRED` for `Legacy` and never says that the flag the user passed matched nothing. The user can work it out by comparing the suggested flag with their own, but the script expected `CLI.CONSENT_UNUSED`. `CONSENT_UNUSED` appears only when every question is answered (`--delete Foo` with `--delete Legacy`, or `--delete Foo` on a plan with no loss). In a terminal it is worse: with `--delete Legcy` the CLI asks about `Legacy`, I answered `delete`, and only then did it fail with `CONSENT_UNUSED` (nothing applied, `Legacy` kept). The unused flag should be reported before the question, and next to the refusal. +6. ⚠️ **F6 — `CONSENT_UNUSED` does not name the subjects the plan does ask about; the dry-run equivalent does.** Apply: `--delete Foo` → `→ Remove the flag, or spell the subject the way the command names it.` Dry run: `--dry-run --delete Foo` → `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION … → Leave them out, or name a subject the plan asks about: Legacy.` The dry-run wording is the one to copy. Note: the script expected `--delete` on a dry run to fail with `CONSENT_UNUSED`. It does not: `--dry-run --delete Legacy` succeeds, marks the question `(answered)` and suggests `prisma db update --delete Legacy`. This matches commit `b3d90528c0` and the app fragment, so the script is out of date here, not the CLI. +7. ⚠️ **F7 — Dropping a permissive row-level-security policy narrows access, but the CLI says it widens it.** Command: `prisma db update` after removing `policy_select tenant_read` (permissive, `using (tenant_id = 1)`, role `app_user`) while `@@rls` stays (scenario 5). Question: `Drop RLS policy "tenant_read_f8d5e783" on "user" would widen who can read and write its rows.` Before the apply, `SET ROLE app_user; SELECT count(*) FROM "user"` → `1`; after `--allow User` → `0`. With row-level security on, permissive policies grant access, so dropping one takes access away. Disabling row-level security does widen it (`0` → `2` rows after the `to2` step). Asking before a policy drop is defensible; the stated reason is wrong, and `--allow` reads as "allow wider access". +8. ⚠️ **F8 — When one model has two access questions, the question names only one operation.** Contract removes the policy and `@@rls` together. Dry run lists both under `An apply asks about`. The refusal and the prompt say only `Drop RLS policy … would widen …`, not `Disable row-level security`, which is the operation that actually widens access. One `--allow User` applied both (`allow model "User" (2 operations)`). The `why` should list every operation behind the subject. The interactive question also has no way to say no except Ctrl-C. +9. ⚠️ **F9 — On MongoDB, a new required field leaves every existing document unwritable, and nothing says so.** Command: `prisma db update` adding `size Int` to a populated `Item` (scenario 6). Output: `Update validator on Item (added: size)`, no question, no warning. Afterwards `updateOne({note:'a'},{$set:{note:'aa'}})` fails with `Document failed validation`, because no existing document has `size`. The same happens when `note String?` becomes `note String` with a `null` and a missing `note` in the collection. No data is lost, and the reclassification to `widening` is by design. But the old classification at least warned; now a user gets no hint that existing documents need a backfill. A line such as "3 existing documents do not satisfy the new validator" would help. +10. ⚠️ **F10 — The NOT NULL failure on Postgres is clear about the cause but its advice is about drift.** Command: `prisma db update` with `note String?` → `note String` and one NULL (scenario 6). Output: + ``` + ✘ [MIGRATION.RUNNER_FAILED] Operation alterNullability.setNotNull.Item.note failed during precheck: ensure no NULL values in "note" + why: Migration runner failed + → Inspect the reported conflict, reconcile schema drift if needed, then re-run `prisma db update` + ``` + Nothing changed (column still nullable, both rows intact), and after setting the NULL the same command applied. The `why` says nothing, and there is no drift. I expected: "N rows have NULL in `note`; set a value or keep the field optional." SQLite gives `NOT NULL constraint failed: _prisma_new_Item.note` with the same advice, also without data loss. +11. ⚠️ (minor) **F11 — The refusal's next steps are flags, not commands, and the alternatives are not grouped.** `nextActions` are `user-choice` entries such as `Pass --delete Legacy`, four for two subjects, with nothing marking which ones are alternatives for the same subject. `meta.unanswered[]` groups them correctly. A `run-command` with the user's command plus the flag would be easier to paste. The summary `2 subjects need a statement` uses project words ("subject", "statement") that a first-time user does not know. +12. ⚠️ (minor) **F12 — The no-snapshot refusal is one long paragraph.** Command: `prisma db update --db ` with no snapshot (scenario 7). The `why` runs to about 120 words with steps 1–3 inline. The steps are correct: I followed them (emit the old contract, `--advance-ref qa --dry-run` → `(no operations)`, apply, emit the new contract, `--advance-ref qa --rename Legacy:Old`) and the table was renamed with both rows kept. The `STATEMENT_ORIGIN_UNKNOWN` error gives the same steps as separate `→` lines, which reads far better. +13. ⚠️ (minor) **F13 — A dry run with `--db` suggests an apply without `--db`.** Command: `prisma db update --db --advance-ref qa --dry-run`. Last line: `→ Apply the planned operations: prisma db update`. Followed literally, it runs against the config's database, not the one just previewed, and without the ref. The answered `--delete` flags are carried over (F6 note); `--db` and `--advance-ref` should be too. +14. ⚠️ **F14 — A type change is answered with `delete` and reported as a deleted field, even when it keeps every value.** Postgres `age Int` → `age String`: `Alter type of "User"."age" to text would lose the values of field "User.age"` → `Pass --delete User.age`, reported as `delete field "User.age"`. SQLite `qty Int` → `qty String` with `--delete Item.qty`: values `1`, `2` kept as text, report says `delete field`. The spec defers `--convert` to slice 3, so this is known, but the wording tells the user they are deleting data they are not. +15. ⚠️ (minor) **F15 — Extension fragment: one rule from the spec is missing.** The spec says the transient DROP of a policy replacement (drop then create a changed policy) is not listed in `accessWidening`. The extension fragment does not say this, so a third-party planner author would list it and make every policy edit ask. Every type and import path the fragment names exists in the built packages' `.d.mts` (checked: `MigrationSubject`, `MigrationOperationSubject`, `MigrationPlanSubjects`, `subjectsOfCalls`, `CallSubjects`, `FieldEventCall`, `storageNameOfOperation`, `AnswerPlanQuestions`, `PlanQuestion`, `PlanAnswer`, `AskedSubject`, `AlterColumnTypeCall`, `RecreateTableCall`). +16. ⚠️ (minor) **F16 — The skill says `CONSENT_UNUSED` covers a typo; a typo next to an unanswered question gives `CONSENT_REQUIRED` (F5).** `skills/prisma-8/references/migrations.md` line 75. +17. ⚠️ (minor) **F17 — The CLI ignores SIGTERM while a question is open.** Command: `prisma migration plan --name t --from 20261008T0244_initial` in a pseudo-terminal, left at the question, then SIGTERM. It was still running 5 s later and needed SIGKILL (in the first attempt it was still running after 20 s). Ctrl-C works (exit 3, `CLI.PROMPT_CANCELLED`, nothing written). A CI job with a TTY and a timeout would hang until killed. This may come from the engine's prompt, not this slice. + +## Setup + +- `git branch --show-current` → `tml-3476-statement-refusal`; `git status --short` → clean at the start and at the end. +- `pnpm install --frozen-lockfile` → exit 0. `pnpm build` → `87 successful, 87 total`. `@prisma/cli-engine` resolved from the CLI package is `0.7.0` (`pkg.pr.new/@prisma/cli-engine@c560ced`). +- CLI: `packages/1-framework/3-tooling/cli/dist/bin.mjs` through `wip/qa/prisma` (runs it with the shell's Node v26.8.1). Human output with `--format human` when not in a terminal. +- Projects: one per scenario under `wip/qa/` (`s1`–`s9`, `s6m`, `s7src`, `s12`, `s12b`, `s12c`). Postgres projects from `prisma orm init --target postgres --authoring psl --skip-install`; SQLite and MongoDB configs written by hand from `examples/prisma-8-demo-sqlite` and `examples/mongo-demo`. Packages resolve through `wip/qa/node_modules` symlinks to the workspace facades. +- Postgres: one `@prisma/dev` server per scenario (`wip/qa/pg-server.mjs`). Careful: it is one PGlite backend, so a `SET ROLE` in `psql` carries into the CLI's next connection. One run in scenario 5 was spoiled that way (`permission denied for database template1`) and is not counted. MongoDB: `MongoMemoryReplSet` (`wip/qa/mongo-server.mjs`), queried with `wip/qa/mq.mjs`. +- Terminal: `wip/qa/drive.py`, a Python `pty` driver that waits for a pattern and types an answer. **Renderer: clack.** A wrong answer is explained and asked again; the line reader was never used. +- All five Postgres servers and the replica set were stopped by PID at the end. + +## 1. Postgres, terminal — ✅ + +`from`: `Profile { id; name @unique; handle; posts Post[] }`, `Post` → `Profile`, `Legacy { id; note }`. `to`: `Profile` renamed `User`, `Legacy` removed. `migration plan --name initial`, `db migrate`, 3 profiles, 2 posts, 2 legacy rows. + +`prisma migration plan --name tidy` without `--from` refused with `MIGRATION.PLAN_ORIGIN_UNKNOWN` (existing behaviour, as in slice 1), so I used `--from 20261008T0234_initial`. Typed answers: + +``` +◆ Drop table "Legacy" would lose the data of model "Legacy". (rename Legacy: or delete) +│ remove +◆ Start the answer with rename or delete. +│ delete +◆ Drop table "Profile" would lose the data of model "Profile". (rename Profile: or delete) +│ rename Profile:Nope +◆ Cannot resolve statement "--rename Profile:Nope". The destination contract has no model "Nope" (models: public.Post, public.User). +│ rename Profile:User +✔ Planned 4 operation(s) +├─ Rename table "Profile" to "User" +├─ Rename primary key "Profile_pkey" to "User_pkey" on "User" +├─ Rename unique constraint "Profile_name_key" to "User_name_key" on "User" +└─ ⚠ Drop table "Legacy" +Statements applied +├─ rename model "Profile" to "User" (3 operations) +└─ delete model "Legacy" (1 operation) +``` + +The questions are clear. Both wrong answers were explained and asked again. The second error quotes the flag form (`--rename Profile:Nope`) of what I typed at the prompt (`rename Profile:Nope`), which is a little confusing (minor). `migration.ts` has `...this.renameTable(...)` and `this.dropTable(... 'Legacy')`; `ops.json` classes are `widening` ×3 and `destructive` for the drop. `db migrate` applied it with no question (as specified: a written migration is reviewed before it runs). `User` has alice, bob, carol; `Post` rows kept; `Legacy` and `Profile` gone; a re-plan from `tidy` shows no change. + +Ctrl-C at the first question: `✘ [CLI.PROMPT_CANCELLED] The prompt "Drop table "Legacy" would lose …" was cancelled before it was answered.`, exit 3, nothing written. The nested quotes in the message are cosmetic. + +## 2. Non-interactive — ✅ + +``` +$ prisma migration plan --name tidy --from 20261008T0234_initial < /dev/null +✘ [CLI.CONSENT_REQUIRED] 2 subjects need a statement, and the session is not interactive: "Legacy", "Profile". + why: Drop table "Legacy" would lose the data of model "Legacy". + Drop table "Profile" would lose the data of model "Profile". +→ Pass --rename 'Legacy:' +→ Pass --delete Legacy +→ Pass --rename 'Profile:' +→ Pass --delete Profile +[exit 2] +``` + +The `migrations/` listing was identical before and after. JSON: `meta.unanswered: [{subject: "Legacy", verbs: ["rename","delete"]}, {subject: "Profile", …}]`. Pasting `--rename Profile:User --delete Legacy` planned the same 4 operations as scenario 1; `db migrate` applied it; 3 rows in `User`, `Legacy` gone. With only `--rename Profile:User` the refusal lists only `Legacy`. Minor: F11. + +## 3. SQLite `db update` — ⚠️ + +`from`: `Profile`, `Legacy` (ids without `@default(autoincrement())`, see "Not covered"); `to`: `Legacy` removed; 2 rows each. + +Interactive (copy `s3a`): `yes` → `Start the answer with rename or delete.`; `delete Profile` → `delete names "Legacy": --delete Legacy.` (understandable, but it shows the flag where the prompt wants `delete`); `delete` → applied, `delete model "Legacy" (1 operation)`, `Profile` kept 2 rows. + +`--confirm dev.db` and `--confirm ./dev.db --no-interactive` → the plain `CONSENT_REQUIRED`, `Legacy` still has 2 rows (F3). `--delete Legacy` → `⚠ Drop table Legacy`, `Statements applied └─ delete model "Legacy"`, `Profile` intact. Same command again → `CONSENT_UNUSED … nothing in this run asked about Legacy` (F4). + +DoD check on a copy (`s3b`, `Profile` → `User` and `Legacy` removed): `--rename Profile:User --delete Legacy` applied both; the second run failed on the first statement: `STATEMENT_UNRESOLVED … this rename has already happened. For db update, the database is already at the new name. → Leave out --rename Profile:User.` Good. + +## 4. Stray and misspelled `--delete` — ⚠️ + +| Command (SQLite `db update`, Postgres `migration plan`) | Result | +| --- | --- | +| `--delete Legacy --delete Profile` (Profile kept) | `CONSENT_UNUSED --delete Profile was given but nothing in this run asked about Profile.` nothing applied | +| `--delete Foo` on a plan with no loss | `CONSENT_UNUSED`, same wording | +| `--delete Legcy`, `--delete legacy`, `--delete Legacy.note` | `CONSENT_REQUIRED` for `Legacy`; the unused flag is not mentioned (F5) | +| Same, in a terminal | asks about `Legacy`, then after `delete` fails `CONSENT_UNUSED --delete Legcy`; nothing applied (F5) | +| `migration plan … --delete Legacy --delete Foo` | `CONSENT_UNUSED`; nothing written | +| `migration plan … --delete Legacy --delete Legacy` | `CONSENT_UNUSED … the question about Legacy was already answered by another flag. → Give one flag per question.` Good | +| `--dry-run --delete Legacy` | succeeds; `An apply asks about └─ Drop table Legacy: --delete Legacy (answered)`; `→ Apply …: prisma db update --delete Legacy` (F6 note) | +| `--dry-run --delete Foo` | `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION … name a subject the plan asks about: Legacy.` (F6) | + +Nothing was written or applied in any failing row. + +## 5. Postgres policy drop — ⚠️ + +Project from the `contract-rls-wire.prisma` journey fixture (`User @@map("user") @@rls`, `policy_select tenant_read`), role `app_user`, 2 rows. + +``` +$ prisma db update --dry-run +App space +└─ Drop RLS policy "tenant_read_f8d5e783" on "user" +An apply asks about +└─ Drop RLS policy "tenant_read_f8d5e783" on "user": --allow User +$ prisma db update +✘ [CLI.CONSENT_REQUIRED] "User" needs a statement, and the session is not interactive. + why: Drop RLS policy "tenant_read_f8d5e783" on "user" would widen who can read and write its rows. +→ Pass --allow User +$ prisma db update --confirm template1 → same refusal +``` + +The dry run asks nothing. It shows the operation under `An apply asks about`, not under a heading named "access widening". In a terminal: `delete` → `Start the answer with allow.`; Ctrl-C → exit 3. `--allow User` applied: `Statements applied └─ allow model "User" (1 operation)`. Removing `@@rls` next, the terminal answer `allow` applied `Disable row-level security`. Policy and RLS removed together: one `--allow User` covered both (F8). F7 is about the wording. + +## 6. Required fields and NOT NULL — ⚠️ + +Postgres, `Item { id; note String? }`, 2 rows (one `note` NULL): + +- Adding `size Int`: no question. `ADD COLUMN "size" int4 DEFAULT (0) NOT NULL; … DROP DEFAULT`. Both rows get `0`. The output says only `Add column size to Item`, not that existing rows got `0` (existing behaviour). +- `note String?` → `String` with a NULL: the dry run shows `Set NOT NULL` and no question; the apply fails (F10), no data lost; after `UPDATE … SET note='b'` it applies. + +MongoDB, `Item { id; note String? }`, 3 documents (`note:'a'`, `note:null`, no `note`): + +- Adding `size Int` and then making `note` required: no question either time, validator updated, documents unchanged (F9). + +## 7. `db update --db` with no snapshot — ✅ + +`s7src` (`Keep`, `Legacy @@map("legacy_rows")`, 2 rows) applied with its own `db` ref; `s7` is a separate directory with no `migrations/` and a contract without `Legacy`. + +``` +$ prisma db update --db --dry-run +└─ Drop table "legacy_rows": --delete public.legacy_rows +$ prisma db update --db +✘ [CLI.CONSENT_REQUIRED] "public.legacy_rows" needs a statement, and the session is not interactive. + why: Drop table "legacy_rows" would lose the data in "public.legacy_rows", named by its storage name because the origin contract is unknown; --delete loses its rows. If it was renamed, keep its data instead: … 1. … 2. … 3. … +→ Pass --delete public.legacy_rows +$ prisma db update --db --delete public.legacy_rows +✔ Applied 1 operation(s) … Statements applied └─ delete storage "public.legacy_rows" (1 operation) +``` + +It names the storage name and says the origin is unknown. `--rename Legacy:Old` → `STATEMENT_ORIGIN_UNKNOWN` with the steps as `→` lines. I followed the steps on a recreated table (see F12): the snapshot was stored, then `--advance-ref qa` without statements asked about `Legacy` by model name, and `--rename Legacy:Old` renamed `legacy_rows` to `Old` with rows `1|x`, `2|y` kept. `--delete Legacy` and `--delete legacy_rows` were ignored silently (F5). + +## 8. A field on a renamed model — ⚠️ + +SQLite, `Profile { id; name; nickname }` → `User { id; name; handle }`, 2 rows. + +``` +$ prisma db update --rename Profile:User +✘ [CLI.CONSENT_REQUIRED] "User.nickname" needs a statement … +→ Pass --rename 'User.nickname:' +→ Pass --delete User.nickname +$ prisma db update --rename Profile:User --rename Profile.nickname:User.handle +✘ [MIGRATION.STATEMENT_INVALID] … Name a field's model as the destination contract names it: --rename User.nickname:User.handle. +$ prisma db update --rename Profile:User --rename User.nickname:User.handle +├─ Rename table Profile to User +├─ Rename column nickname on User to handle +Statements applied +├─ rename model "Profile" to "User" (1 operation) +└─ rename field "User.nickname" to "User.handle" (1 operation) +``` + +Rows kept. The `Statements applied` block names things as I typed them. `--delete Profile.nickname` (old model name) is ignored silently (F5). F2 covers the placeholder. In a terminal, after `rename Profile:User` was answered, a question about the field followed, and the full form applied. `--rename Profile:User --delete User.nickname` failed at apply on SQLite with `Cannot add a NOT NULL column with default value NULL` for `handle` and rolled back (`Profile` intact); see "Not covered". + +## 9. MongoDB — ⚠️ + +`Keep`, `Event` (2 documents). With `Event` removed: `db update`, `db update --confirm s9` and `migration plan --name drop-event` are all refused with `→ Pass --delete Event`, and nothing is written (slice 1's F4 auto-baseline leak is fixed). `migration plan --delete Event` wrote the auto-baseline and `drop_event` (`⚠ Drop collection Event`, `delete model "Event"`). `db update --delete Event` dropped it. + +With `Event` renamed to `Log`: `db update --rename Event:Log` and `migration plan … --rename Event:Log` → `PLANNING_FAILED`, with advice that states the loss and gives `renameCollection`. Followed: `(no operations)`, `Database already matches contract`, 2 documents in `Log`. Without `--rename` see F1. + +## 10. Upgrade fragments — ✅ (judgement) + +App (`upgrade-instructions/pending/migration-statement-refusal/app/instructions.md`), read as a script author with `db update --confirm mydb`: it says plainly that `--confirm` answers nothing, gives the replacement per case (`--delete `, `--delete `, `--rename`, the storage name schema-qualified on Postgres, `--allow `), and how to find the subjects (`--no-interactive --json` → `meta.unanswered[]`, or `--dry-run --json`). Each claim I checked holds: the dry-run `STATEMENT_ANSWERS_NO_QUESTION`, the storage-name subject without a snapshot, field-of-renamed-model naming, `--rename` not offered for a type change or on MongoDB. + +Extension, read as a planner author: every field and type to add is named with its import path, and each exists in the built packages (F15 has the list). Detection misses are stated per change. One spec rule is missing (F15). + +## 11. Skill replay — ✅ (judgement) + +"How do I delete a field?" → § *Answer the data-loss questions*: `--delete User.nickname`, with `--delete Legacy` and storage names. "How do I answer a refusal?" → read `meta.unanswered[]` or `nextActions`, add the flags, "Don't reach for `--delete` by reflex: if the model or field was renamed, `--rename` keeps its rows", and Pitfall 8 gives the field form `Person.email:Person.emailAddress`. The code table has `CONSENT_REQUIRED` (with `--confirm` and `--yes` answering nothing), `CONSENT_UNUSED`, `STATEMENT_DID_NOT_RESOLVE_LOSS`. Minor: F16. + +## 12. Exploratory — ⚠️ + +| Probe | Result | +| --- | --- | +| Five subjects in one plan (2 models, 2 fields, a type change), Postgres | All listed; five `--delete`s planned; `Statements applied` lists each ✅ | +| `--delete User` when only `User.bio`/`User.name` drop | `CONSENT_UNUSED` ✅ (a model delete does not cover its fields) | +| Auto-baseline with a drop (MongoDB, empty graph, `db` ref) | Refused, nothing written; with `--delete` baseline + drop written ✅ | +| `--json` in a terminal | Asks (on stderr: with stdout redirected to a file the question still showed); stdout is one clean JSON result; `appliedStatements[].verb` is `delete` ✅ | +| `--yes`, with and without a terminal | `CONSENT_REQUIRED … which --yes cannot give` ✅ | +| Ctrl-C at a question | exit 3, `CLI.PROMPT_CANCELLED`, nothing written ✅ | +| SIGTERM at a question | ignored (F17) | +| Placeholder plan with a drop (type change needs a data transform) | operations and `Statements applied` both shown (slice 1's F9 fixed) ✅ | +| Rollback `--from a --to initial` | refused for the reverse type change; `--delete User.age` plans it ✅ | +| Rename at the prompt that leaves a field drop (SQLite `Legacy{note}` → `Archive{title}`) | after `rename Legacy:Archive` it plans again and asks about `Archive.note`; `rename Archive.note:Archive.title` applied, rows kept ✅ | +| SQLite nullability-only tightening | `Recreate table Item`, no question; with a NULL fails, rows kept ✅ | +| SQLite and Postgres type change Int → String | asked, answered with `--delete`, values kept (F14) | + +## Not covered, and observations outside this slice + +- `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`: I found no answer that reaches it, because the prompt rejects a rename whose old side is not the subject before planning again. +- A destructive operation in an extension contract space; `migration plan` on SQLite; `db migrate` of a plan with a `--delete` on SQLite and MongoDB. +- SQLite: `id Int @id @default(autoincrement())` fails the first `db update` with `MIGRATION.RUNNER_FAILED … Database schema does not satisfy contract (2 failures)` (`column:id default` expected `autoincrement()`). I dropped the default to proceed. It looks like an existing verifier bug, unrelated to this slice; worth checking on `main`. +- SQLite: adding a required `String` to a populated table fails (`Cannot add a NOT NULL column with default value NULL`), where Postgres fills `0` for an `Int`. Existing behaviour; it means scenario 6's "required field, no refusal" passes on SQLite only for an empty table. +- Every Postgres `db update` prints `⚠ Planner warnings - control policy suppressed: namespace "__unbound__" …`, as in slice 1. +- `contract emit` in the scratch projects warns `contract.d.ts imports types from packages that are not installed: @internal/target-postgres`; caused by the symlinked setup. +- `--allow` is offered only by `db update`; `migration plan` writes the policy drop with no question, as specified. + +## Cleanup + +All `@prisma/dev` servers (PIDs 12048, 13122, 13707, 14041, 14271) and the replica set (12050) were stopped by PID. One CLI process left by my driver at a question (12403, started by me) was killed with SIGKILL after SIGTERM failed (F17). `wip/qa/` is left in place with the projects, helper scripts (`prisma`, `pg-server.mjs`, `mongo-server.mjs`, `mq.mjs`, `drive.py`) and the terminal logs (`logs-*`). From 7b662cf43dda82c672a313a0733ef23e82ccf6bb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:22:45 +0200 Subject: [PATCH 102/109] TML-3476: the spec types accessWidening with widens, and the refusal's next-action shape is deferred Signed-off-by: willbot Signed-off-by: Will Madden --- projects/migration-statements/deferred.md | 7 +++++++ projects/migration-statements/slices/refusal/spec.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/projects/migration-statements/deferred.md b/projects/migration-statements/deferred.md index 49f202fdf023..9d0718e7c125 100644 --- a/projects/migration-statements/deferred.md +++ b/projects/migration-statements/deferred.md @@ -58,3 +58,10 @@ Adding a required field to a populated collection, or making an optional field r **Found:** slice 2 manual QA (2026-10-08), F10. Pre-existing text. `db update` making a column NOT NULL on a table with a NULL fails at the precheck (`ensure no NULL values in "note"`) with nothing lost, but the next action says to reconcile schema drift, and the `why` says only "Migration runner failed". The advice should say how many rows hold NULL and how to fix them, or keep the field optional. + +## The refusal's next actions are one line per flag, not grouped by subject + +**Found:** slice 2 manual QA (2026-10-08), F11. Engine side. + +`CLI.CONSENT_REQUIRED` lists one `user-choice` next action per flag form ("Run the command again with --delete Legacy", another for the rename form), not grouped by subject and not a complete runnable command, and its summary says "N subjects need a statement", which is project vocabulary. Grouping the forms per subject and printing a full command would need the engine to know the invocation; raise it on prisma/prisma-cli once the statement prompt has shipped. + diff --git a/projects/migration-statements/slices/refusal/spec.md b/projects/migration-statements/slices/refusal/spec.md index 6932aa86c14e..fa6d056fe390 100644 --- a/projects/migration-statements/slices/refusal/spec.md +++ b/projects/migration-statements/slices/refusal/spec.md @@ -56,7 +56,7 @@ The CLI turns each entry into one engine question (`ctx.prompt.statements`): que ### Access widening (`allow`) -`db update` asks before applying an operation that changes who can read or write rows: disabling row-level security (which widens access) and dropping a row-level-security policy (which changes it; dropping a permissive policy narrows). Both stay `widening` as operation classes, and each operation is its own question, answered by its own `--allow `. The family's success result lists them in `accessWidening: readonly { operationIndex, subject }[]` with the model as subject; the CLI asks with verb `allow` only (`--allow User`). `migration plan` does not ask: the written migration is reviewed before it runs. The transient DROP in a policy replacement (DROP then CREATE of a changed policy) is not listed. +`db update` asks before applying an operation that changes who can read or write rows: disabling row-level security (which widens access) and dropping a row-level-security policy (which changes it; dropping a permissive policy narrows). Both stay `widening` as operation classes, and each operation is its own question, answered by its own `--allow `. The family's success result lists them in `accessWidening: readonly { operationIndex, subject, widens }[]` with the model as subject (`MigrationAccessChange`; `widens` is true for disabling row-level security, false for a policy drop); the CLI asks with verb `allow` only (`--allow User`). `migration plan` does not ask: the written migration is reviewed before it runs. The transient DROP in a policy replacement (DROP then CREATE of a changed policy) is not listed. ### Output From 81d7b90379cc611455acae97c5947420214fcbc3 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:23:32 +0200 Subject: [PATCH 103/109] TML-3476: MongoDB's keep-the-documents advice is right for migration plan as well as db update Rename the collection by hand before a plan that drops it is applied; db update then drops nothing, and a migration written by migration plan still drops it, so remove that operation or do not apply it there. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../1-mongo-target/src/core/migrations/mongo-planner.ts | 6 ++++-- .../1-mongo-target/test/mongo-planner.data-loss.test.ts | 4 ++-- projects/migration-statements/slices/refusal/manual-qa.md | 2 +- skills/prisma-8/references/migrations.md | 2 +- .../pending/migration-statement-refusal/app/instructions.md | 2 +- .../migration-statement-refusal/extension/instructions.md | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 4471a271cec1..231d9e9dbffd 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -191,7 +191,7 @@ prisma migration plan --name tidy-users --rename Profile:User --rename User.name prisma db update --rename Profile:User --rename User.name:User.fullName --delete Legacy --allow User ``` -**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename`. Where the target's migrations capability sets `renameStatements: { refused: true, keepDataByHand }`, as MongoDB's does, the question offers no rename and ends with the target's text on keeping the data by hand (renaming the collection in `mongosh`, then running the command again). `db update` also asks one question per operation that changes who can read or write a model's rows, answered by `allow`: the question says the operation widens access when it does, such as disabling row-level security, and that it changes access otherwise, such as dropping a policy, since a permissive policy grants access. Two such operations on one model are two questions, each answered by its own `--allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still has the operation asked about fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; a loss the new plan has on the same subject, such as a type change on the renamed field, is asked next. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. A `db update --dry-run` asks nothing; it checks the same flags against the subjects an apply would ask about and fails an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so a script that checks the code handles both. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. +**The questions.** The planner's success result lists what the plan would lose (`dataLoss`) and whose access it would widen (`accessWidening`), each entry an operation's position and its subject (a `MigrationSubject`: a model, a field, or a storage name no model of the origin contract stores). Before `migration plan` writes anything, and before `db update` applies anything, the command asks one question per data-loss subject, answered by `delete` or, for a model or field the destination no longer has, `rename`. Where the target's migrations capability sets `renameStatements: { refused: true, keepDataByHand }`, as MongoDB's does, the question offers no rename and ends with the target's text on keeping the data by hand (renaming the collection in `mongosh` on each database before a plan that drops it is applied there; `db update` then drops nothing, and a migration written by `migration plan` still drops it, so remove that operation or do not apply it there). `db update` also asks one question per operation that changes who can read or write a model's rows, answered by `allow`: the question says the operation widens access when it does, such as disabling row-level security, and that it changes access otherwise, such as dropping a policy, since a permissive policy grants access. Two such operations on one model are two questions, each answered by its own `--allow`. The questions go through the CLI engine's statement prompt (`ctx.prompt.statements(questions, { last: true })`), which answers them from the flags first. In a terminal it asks the rest in turn, and the user types `delete`, `allow` or `rename :`; a typed rename is planned again with the other renames, and a plan that still has the operation asked about fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`; a loss the new plan has on the same subject, such as a type change on the renamed field, is asked next. Where nobody can answer (not interactive, or `--yes`), the command fails with `CLI.CONSENT_REQUIRED`, listing every unanswered question with the flags that answer it, and a `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED`; both happen before anything is written or applied. A `db update --dry-run` asks nothing; it checks the same flags against the subjects an apply would ask about and fails an unmatched one with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so a script that checks the code handles both. `--confirm` answers none of these questions. The `--rename` values are taken from the run first (`ctx.statements.take('rename')`) and planned from the start, so the drop a rename replaces is never asked about. The shared question loop is `askPlanQuestions` in [`plan-questions.ts`](../../../packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts); the control API takes the answers through a callback, so it never depends on the engine's prompt. #### Rename statements diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index ce788585d420..b423b4b0ad64 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1045,7 +1045,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Data loss:** a plan that would lose data is written only once you say what each such operation means. The command asks one question per model, field or storage name whose data an operation would lose (dropping a table or a column, or a type change that can change values), before it writes anything, including the baseline package of an auto-baseline. Each question is answered with a statement: - `--delete ` lets the plan lose that data, for example `--delete Legacy` for a model or `--delete User.nickname` for a field. Data that no model of the starting contract stores is named by its storage name, as the question gives it, and can only be deleted. A field of a model the plan renames is named through the model's new name, as a field rename is written: after `--rename Profile:User`, a dropped `nickname` field is answered with `--delete User.nickname` or `--rename User.nickname:User.handle`. -- `--rename :` keeps the data of a model or field under a new name; a field's new name keeps its model (`--rename User.nickname:User.handle`). A rename on the command line is planned from the start, so the drop it replaces is never asked about. The question offers it only for a model or field the destination no longer has, so a field whose type changes is answered with `--delete`, and not on MongoDB, whose planner refuses renames in this release. +- `--rename :` keeps the data of a model or field under a new name; a field's new name keeps its model (`--rename User.nickname:User.handle`). A rename on the command line is planned from the start, so the drop it replaces is never asked about. The question offers it only for a model or field the destination no longer has, so a field whose type changes is answered with `--delete`, and not on MongoDB, whose planner refuses renames in this release. There the question says how to keep the documents instead: rename the collection in `mongosh` on each database before a plan that drops it is applied; `db update` then drops nothing, and a migration written by `migration plan` still drops it. Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn: type `delete` to let the data go, or `rename :`; a rejected answer is asked again. A typed rename is planned again with the other renames, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is written. `--confirm` no longer consents to anything here. The deletes are listed under `Statements applied` after the renames, for example `delete model "Legacy" (1 operation)`, and in `--json` output each `appliedStatements` entry carries its `verb`, `rename` or `delete`; a delete's `statement` is `{ kind: 'delete', subject }`. The `⚠` marker stays on each destructive operation. 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 ffd9c1575103..308f72a53f45 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 @@ -176,7 +176,9 @@ function shellString(name: string): string { /** * How to keep the documents of a subject a plan would drop, since the planner carries out no - * rename: rename the collection by hand, after which the plan finds nothing to drop. + * rename: rename the collection by hand before a plan that drops it is applied. Right for both + * `db update`, which then finds nothing to drop, and `migration plan`, whose written migration + * still drops it. */ export function keepDataByHand( subject: MigrationSubject, @@ -184,7 +186,7 @@ export function keepDataByHand( ): string { const collection = subject.kind === 'storage' ? subject.name : collectionOf(fromContract, subject); - return `If it was renamed, keep its documents instead: rename collection "${collection}" by hand on each database, for example with db.getCollection(${shellString(collection)}).renameCollection("") in mongosh, then run this command again, which then drops nothing.`; + return `If it was renamed, keep its documents instead: rename collection "${collection}" by hand on each database before a plan that drops it is applied there, for example with db.getCollection(${shellString(collection)}).renameCollection("") in mongosh. db update then drops nothing; a migration written by migration plan still drops "${collection}", so remove that operation from its migration.ts, or do not apply it where the collection was renamed.`; } /** diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts index 7d84be59865d..46c548a6ad51 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.data-loss.test.ts @@ -152,13 +152,13 @@ describe('keepDataByHand', () => { contractWith({ Event: 'events' }, 'from'), ), ).toBe( - 'If it was renamed, keep its documents instead: rename collection "events" by hand on each database, for example with db.getCollection("events").renameCollection("") in mongosh, then run this command again, which then drops nothing.', + 'If it was renamed, keep its documents instead: rename collection "events" by hand on each database before a plan that drops it is applied there, for example with db.getCollection("events").renameCollection("") in mongosh. db update then drops nothing; a migration written by migration plan still drops "events", so remove that operation from its migration.ts, or do not apply it where the collection was renamed.', ); }); it('names a collection no model stores by its name', () => { expect(keepDataByHand({ kind: 'storage', name: 'audit' }, contractWith({}, 'from'))).toBe( - 'If it was renamed, keep its documents instead: rename collection "audit" by hand on each database, for example with db.getCollection("audit").renameCollection("") in mongosh, then run this command again, which then drops nothing.', + 'If it was renamed, keep its documents instead: rename collection "audit" by hand on each database before a plan that drops it is applied there, for example with db.getCollection("audit").renameCollection("") in mongosh. db update then drops nothing; a migration written by migration plan still drops "audit", so remove that operation from its migration.ts, or do not apply it where the collection was renamed.', ); }); }); diff --git a/projects/migration-statements/slices/refusal/manual-qa.md b/projects/migration-statements/slices/refusal/manual-qa.md index 4791e0642f15..e322f0d97ea8 100644 --- a/projects/migration-statements/slices/refusal/manual-qa.md +++ b/projects/migration-statements/slices/refusal/manual-qa.md @@ -26,7 +26,7 @@ | 6 | Add a required field to a populated table, Postgres and MongoDB; tighten a column to NOT NULL with and without NULLs | No question; the NULL case fails clearly without losing data | tmpdir | | 7 | `db update --db ` with no snapshot, dropping a model | The refusal names the table by its storage name and says the origin is unknown; `--delete ` applies | tmpdir | | 8 | A field on a renamed model: `--rename Profile:User` with `Profile.nickname` dropped | The refusal suggests `--rename 'User.nickname:User.'`; that form resolves; the `Statements applied` block names things the way you typed them | tmpdir | -| 9 | MongoDB: drop a collection through both commands; `--rename` | Refused until `--delete Event`, and the question says how to keep the documents by hand (rename the collection in `mongosh`, then run again); `--rename` refused with advice that loses nothing | tmpdir | +| 9 | MongoDB: drop a collection through both commands; `--rename` | Refused until `--delete Event`, and the question says how to keep the documents by hand (rename the collection in `mongosh` before a plan that drops it is applied; a migration written by `migration plan` still drops it); `--rename` refused with advice that loses nothing | tmpdir | | 10 | Read the app and extension upgrade fragments as their audiences **(judgement)** | A script author knows what to replace `--confirm` with; a planner author knows every field and type to add | read-only | | 11 | Ask the skill how to delete a field and how to answer a refusal **(judgement)** | `skills/prisma-8/references/migrations.md` leads to `--delete`, and to `--rename` when the user meant a rename | read-only | | 12 | Exploratory: 20 minutes | Two `--delete`s, `--delete` for a model whose field is also dropped, an auto-baseline plan with a drop, `--json` in a TTY (does it still prompt to stderr?), `--yes`, Ctrl-C at a question (exit 3), a placeholder plan with a drop | tmpdir | diff --git a/skills/prisma-8/references/migrations.md b/skills/prisma-8/references/migrations.md index 61cc355678a9..0ff2871ffbb6 100644 --- a/skills/prisma-8/references/migrations.md +++ b/skills/prisma-8/references/migrations.md @@ -508,7 +508,7 @@ In a terminal the command asks each question in turn; type `delete`, `allow`, or - **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. -- **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. +- **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. A data-loss question about a dropped collection offers only `--delete` and says how to keep the documents by hand; a migration written by `migration plan` still drops the collection, so remove that operation or do not apply it where the collection was renamed. 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/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index a6d48a549255..fe5f675bf0e4 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -65,7 +65,7 @@ For each migration package that no database has applied yet and whose `migration `prisma migration plan` used to ask for consent only before an auto-baseline that would lose data. It now asks about every operation of every plan that would lose data: dropping a table, a column or a collection, or a type change that can change values. It asks one question per model, field or storage name, before it writes anything. A rename on the command line is planned from the start, so the drop it replaces is never asked about. -Detection lists every script and CI file that runs `migration plan`; it cannot tell which of them plan a drop. For each one that runs non-interactively, run it once against a contract change that drops something, or read the failure when it next happens. The failure is `CLI.CONSENT_REQUIRED`; its `meta.unanswered[]` names each subject and the verbs that answer it, and its `nextActions` give the flags, for example `--delete Legacy` or `--rename 'Legacy:'` (a value with characters a shell would read is single-quoted). Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. `--rename` is offered only for a model or field the new contract no longer has, so a field whose type changes is answered with `--delete`, and on MongoDB, whose planner refuses renames in this release, only `--delete` is offered. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm ` for this: it answers nothing. +Detection lists every script and CI file that runs `migration plan`; it cannot tell which of them plan a drop. For each one that runs non-interactively, run it once against a contract change that drops something, or read the failure when it next happens. The failure is `CLI.CONSENT_REQUIRED`; its `meta.unanswered[]` names each subject and the verbs that answer it, and its `nextActions` give the flags, for example `--delete Legacy` or `--rename 'Legacy:'` (a value with characters a shell would read is single-quoted). Add the flags you mean to that run. A subject no model of the starting contract stores is named by its storage name and can only be deleted. `--rename` is offered only for a model or field the new contract no longer has, so a field whose type changes is answered with `--delete`, and on MongoDB, whose planner refuses renames in this release, only `--delete` is offered; the question then says how to keep the documents by renaming the collection in `mongosh` before a plan that drops it is applied, and that a migration written by `migration plan` still drops it. A field of a model the plan renames is named through the model's new name: `--rename Profile:User --delete User.nickname`. Do not keep a `--confirm ` for this: it answers nothing. ## `db-update-confirm-no-longer-consents` diff --git a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md index 49ee1f2e2fe9..f0face894f3a 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/extension/instructions.md @@ -76,7 +76,7 @@ Pass `storageNameOf` to each `planMigration({ ... })` and `resolveRecordedPath({ A `ControlFamilyInstance` implementation adds `storageNameOf(operation: MigrationPlanOperation): string`. The aggregate planner calls it to name what a destructive operation of a recorded migration loses, since no planner mapped it to a model. A SQL family returns the name from the operation's target details, `schema.table.column` for a column and `schema.name` for anything else; `storageNameOfOperation` from `@prisma/orm-family-sql/family/control` does that. A test double returns any stable name. -A target whose planner carries out no rename statement sets `renameStatements: { refused: true, keepDataByHand(subject, fromContract) }` on its `migrations` capability. The CLI then offers no `--rename` in a data-loss question, and ends the question with the text `keepDataByHand` returns: how to keep the subject's data by hand before running the command again. MongoDB's target sets it in this release, and its text says to rename the collection in `mongosh`. +A target whose planner carries out no rename statement sets `renameStatements: { refused: true, keepDataByHand(subject, fromContract) }` on its `migrations` capability. The CLI then offers no `--rename` in a data-loss question, and ends the question with the text `keepDataByHand` returns: how to keep the subject's data by hand before running the command again. MongoDB's target sets it in this release, and its text says to rename the collection in `mongosh` before a plan that drops it is applied, and that a migration written by `migration plan` still drops it. ## `operation-classes-and-calls` From b3e50054690f1c353d7c342b52090cab7f65e819 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:24:43 +0200 Subject: [PATCH 104/109] TML-3476: a programmatic allow statement answers one operation, as an --allow flag does Each allow statement goes to the first access question about its subject that no earlier statement took; a leftover one fails with MIGRATION.STATEMENT_ANSWERS_NO_QUESTION, in plan mode too. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../control-api/statements/plan-questions.ts | 38 ++++++++--- .../statements/ask-plan-questions.test.ts | 63 +++++++++++++++++++ .../app/instructions.md | 2 +- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index b423b4b0ad64..c6ef0fd78a92 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -958,7 +958,7 @@ prisma db update [--db ] [--to ] [--advance-ref ] [--config - `--rename :` keeps the data of a model or field under a new name instead. A field's new name keeps its model: `--rename User.nickname:User.handle`, which the question writes as `User.nickname:User.`. - `--allow ` lets the update change who can read or write that model's rows, for example `--allow User`. Each such operation is its own question: dropping a policy and disabling row-level security on one model take two `--allow User` flags. The question says disabling row-level security widens access, and that dropping a policy changes it, since a permissive policy grants access. -Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given, `--to` and `--advance-ref`, and `--db ` as a placeholder when the dry run named its database (the URL is never printed). The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking, and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. +Where nobody can answer (the run is not interactive, or `--yes` is set), the command fails with `CLI.CONSENT_REQUIRED` and lists every unanswered question with the flags that answer it. In a terminal it asks each question in turn; a typed rename is planned again, and when the plan still loses the subject's data the command fails with `MIGRATION.STATEMENT_DID_NOT_RESOLVE_LOSS`. A `--delete` or `--allow` that answers no question fails with `CLI.CONSENT_UNUSED` before anything is applied. `--confirm` no longer consents to anything here. A dry run asks nothing: it lists the questions an apply would ask under `An apply asks about`, each with the flag that answers it, and as `dataLoss` and `accessWidening` in `--json` output, each entry's `text` being the subject as `--delete` or `--allow` takes it. A dry run checks its `--delete` and `--allow` values against those subjects and refuses one that matches none with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, so the exact command an apply will run can be previewed first. A question the dry run's flags answer is marked `(answered)` (`answered: true` in JSON), and the apply it suggests repeats the statement flags it was given, `--to` and `--advance-ref`, and `--db ` as a placeholder when the dry run named its database (the URL is never printed). The answers are listed under `Statements applied` after the renames, for example `delete field "User.nickname" (1 operation)`, and as `appliedStatements` entries with `verb` `delete` or `allow`. Programmatic callers of `dbUpdate` pass `answerQuestions`, which answers every question in order or throws to refuse. It is called at least once per apply, with an empty list when nothing is in question, even under `acceptDataLoss: true`; return `[]` then. `delete` and `allow` statements in `statements` answer their questions without asking (an `allow` answers one operation, so two operations on one model take two `allow` statements, as on the command line), and one that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode, before anything is applied. `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. **Error codes (additional to shared CLI/runtime codes):** - `RUNNER_FAILED`: runner rejected apply (origin mismatch, failed checks, policy failures, or execution errors) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index 3eac03561b9e..a875e2c5765f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -298,6 +298,24 @@ export interface ConsentedSubject { const keyOf = (planned: PlannedSubject) => migrationSubjectKey(planned.subject); +/** + * The pre-answers that answer a question about `text`: every `delete` of the subject, or the + * first `allow` of it no earlier question took, since an `allow` answers one operation. + */ +function givenFor( + verb: ConsentVerb, + text: string, + preAnswers: readonly StatementText[], + taken: ReadonlySet, +): readonly StatementText[] { + const matching = preAnswers.filter( + (statement) => statement.verb === verb && statement.text === text, + ); + if (verb === 'delete') return matching; + const first = matching.find((statement) => !taken.has(statement)); + return first === undefined ? [] : [first]; +} + /** A `delete` answers for its subject; an `allow` for its one operation. */ function questionKey(verb: ConsentVerb, entry: PlannedSubject): string { return verb === 'allow' @@ -411,9 +429,7 @@ export async function askPlanQuestions ] as const) { for (const entry of entries) { const text = subjectText(entry.subject, contracts); - const given = input.preAnswers.filter( - (statement) => statement.verb === verb && statement.text === text, - ); + const given = givenFor(verb, text, input.preAnswers, usedPreAnswers); for (const statement of given) usedPreAnswers.add(statement); if (input.consentAll[verb] || given.length > 0) consent(verb, entry, text); } @@ -491,12 +507,18 @@ export function refuseUnusedConsents(input: { readonly contracts: StatementContracts; }): Result { const subjects = questionSubjects(input.plan, true, input.contracts); - const matches = (statement: StatementText, entries: readonly PlannedSubject[]) => - entries.some((entry) => subjectText(entry.subject, input.contracts) === statement.text); + const used = new Set(); + for (const [verb, entries] of [ + ['delete', input.plan.dataLoss], + ['allow', input.plan.accessWidening], + ] as const) { + for (const entry of entries) { + const text = subjectText(entry.subject, input.contracts); + for (const statement of givenFor(verb, text, input.statements, used)) used.add(statement); + } + } const unused = input.statements.filter( - (statement) => - (statement.verb === 'delete' && !matches(statement, input.plan.dataLoss)) || - (statement.verb === 'allow' && !matches(statement, input.plan.accessWidening)), + (statement) => statement.verb !== 'rename' && !used.has(statement), ); return unused.length === 0 ? ok(undefined) diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index 92540193a314..5ad3a7396025 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -176,3 +176,66 @@ describe('questions about access', () => { ]); }); }); + +describe('allow statements given as pre-answers', () => { + const t = { kind: 'model', namespaceId: asNamespaceId('app'), model: 'T' } as const; + const plan = { + dataLoss: [], + accessWidening: [ + { operationIndex: 0, label: 'Drop RLS policy "readers" on "T"', subject: t, widens: false }, + { operationIndex: 1, label: 'Disable row-level security on "T"', subject: t, widens: true }, + ], + }; + const ask = (preAnswers: readonly { verb: 'allow'; text: string }[]) => { + const asked: string[] = []; + const result = askPlanQuestions({ + plan, + askAccess: true, + renames: [], + preAnswers, + consentAll: { delete: false, allow: false }, + origin, + originKnown: true, + keepDataByHand: undefined, + destination, + answer: async (questions) => { + asked.push(...questions.map(({ question }) => question)); + return questions.map(({ subject }) => ({ verb: 'allow', text: subject })); + }, + replan: async () => ok({ dataLoss: [], accessWidening: [] }), + }); + return { asked, result }; + }; + + it('answer one operation each, in order, and leave the rest to be asked', async () => { + const { asked, result } = ask([{ verb: 'allow', text: 'T' }]); + + expect((await result).ok).toBe(true); + expect(asked).toEqual([ + 'Disable row-level security on "T" would widen who can read and write its rows.', + ]); + }); + + it('answer every operation when there is one per operation', async () => { + const { asked, result } = ask([ + { verb: 'allow', text: 'T' }, + { verb: 'allow', text: 'T' }, + ]); + + expect((await result).ok).toBe(true); + expect(asked).toEqual([]); + }); + + it('refuse an allow left over once each operation has one', async () => { + const { result } = ask([ + { verb: 'allow', text: 'T' }, + { verb: 'allow', text: 'T' }, + { verb: 'allow', text: 'T' }, + ]); + + const outcome = await result; + expect(!outcome.ok && outcome.failure).toMatchObject({ + code: 'MIGRATION.STATEMENT_ANSWERS_NO_QUESTION', + }); + }); +}); diff --git a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md index fe5f675bf0e4..9c404462c6dd 100644 --- a/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md +++ b/upgrade-instructions/pending/migration-statement-refusal/app/instructions.md @@ -87,7 +87,7 @@ These come from `@prisma/orm-toolchain/cli/control-api`. - `executeDbUpdate(options)` and the control client's `dbUpdate(options)` require `answerQuestions`, typed `AnswerPlanQuestions` (with `PlanQuestion`, `PlanAnswer` and `PlanQuestionVerb`, all exported from `@prisma/orm-toolchain/cli/control-api`). Before an apply it is called with every question no statement answered, at least once, with an empty list when nothing is in question, and also under `acceptDataLoss: true`. Return one `{ verb, text }` per question, in order, with a verb from `question.verbs` and `question.subject` as the text (or `:` for a rename); throw to refuse. A callback that returns too few answers, or an answer its question rejects, throws an `InternalError`. To keep a script that refuses every data loss, write `answerQuestions: async (questions) => { if (questions.length > 0) throw new Error('db update would lose data or widen access'); return []; }`. - `acceptDataLoss: true` answers every data-loss question, and `acceptAccessWidening: true` every access-widening question. A caller that passed `acceptDataLoss: true` to apply whatever the plan holds passes both. -- `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. +- `delete` and `allow` statements in `statements` (`{ verb: 'delete', text: 'Legacy' }`) answer their questions without asking. A `delete` answers every loss of its subject; an `allow` answers one operation, in order, so two access changes on one model take two `allow` statements. One that answers no question fails with `MIGRATION.STATEMENT_ANSWERS_NO_QUESTION`, in plan mode as in apply mode. - `consent` is gone from `executeDbUpdate`'s and `dbUpdate`'s options: the plan-hash binding it carried no longer exists. Remove it. - `executeMigrationPlanCommand(options)` requires `answerQuestions`, with the same contract, and loses `consent` and `carryEmittedExtensionDirs`. Remove both. - `DbUpdateSuccess` gains `dataLoss` and `accessWidening`, the operations of the plan that lose data or widen access, by position, each an `AskedSubject` with the subject's `text` as the question writes it. `AskedSubject`, `MigrationSubject`, `MigrationOperationSubject` and `MigrationPlanSubjects` are exported from `@prisma/orm-toolchain/cli/control-api`. From a448bd7953a49cab00a81451d8408d65044c4868 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:41:00 +0200 Subject: [PATCH 105/109] TML-3476: the Prisma 7 handover example answers the drop of User.bio with --delete migration plan now refuses a plan that drops a field until a statement answers it, so the example checks the refusal names --delete User.bio and then plans with it. Signed-off-by: willbot Signed-off-by: Will Madden --- .../prisma7-adoption/test/handover.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/examples/prisma7-adoption/test/handover.test.ts b/examples/prisma7-adoption/test/handover.test.ts index 3b326fd2d688..285d5e7c29a5 100644 --- a/examples/prisma7-adoption/test/handover.test.ts +++ b/examples/prisma7-adoption/test/handover.test.ts @@ -221,7 +221,30 @@ describe('Prisma 8 taking over migrations from the Prisma 7 schema', () => { editSchema('edit-2.prisma'); await v8('contract', 'emit'); const destructiveHash = storageHash(dir); - const destructivePlan = await v8('migration', 'plan', '--name', 'drop-bio'); + const refused = await runAllowingFailure(dir, connectionString, 'prisma', [ + 'migration', + 'plan', + '--name', + 'drop-bio', + ]); + expect(refused.status, refused.output).toBe(2); + expect(resultEnvelope(refused.output)).toMatchObject({ + ok: false, + error: { + code: 'CLI.CONSENT_REQUIRED', + nextActions: expect.arrayContaining([ + expect.objectContaining({ label: 'Run the command again with --delete User.bio' }), + ]), + }, + }); + const destructivePlan = await v8( + 'migration', + 'plan', + '--name', + 'drop-bio', + '--delete', + 'User.bio', + ); expect(destructivePlan).toMatchObject({ from: additiveHash, to: destructiveHash }); expect(destructivePlan['baselineDir']).toBeUndefined(); const destructive = readBundle(dir, String(destructivePlan['dir'])); From 73829a76b7c7c5073200605e2c433133c844324f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:42:58 +0200 Subject: [PATCH 106/109] TML-3476: the command summary's statements paragraph sits below the flags table Signed-off-by: willbot Signed-off-by: Will Madden --- docs/commands/SUMMARY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/commands/SUMMARY.md b/docs/commands/SUMMARY.md index fd0499a0e960..1880946c6ef6 100644 --- a/docs/commands/SUMMARY.md +++ b/docs/commands/SUMMARY.md @@ -122,13 +122,13 @@ Both commands share the same flag surface: | `--config ` | Path to `prisma.config.ts` | | `--dry-run` | Preview planned operations without applying | | `-y, --yes` | Accept the declared default of every prompt. It answers none of `db update`'s questions | - -`db update` also takes statements, each repeatable, which answer the questions it asks before an apply: `--rename :` keeps a model's or field's data under a new name, `--delete ` lets the update lose a model's, field's or storage name's data, and `--allow ` lets it widen who can read or write a model's rows. Without them, an apply that would lose data or widen access asks in a terminal, and fails with `CLI.CONSENT_REQUIRED` elsewhere. `--confirm` answers none of them. See the CLI README's `db update` section. | `--json [format]` | Output as JSON (`object` format only) | | `-q, --quiet` | Quiet mode: errors only | | `-v, --verbose` | Verbose output: debug info, timings | | `--no-color` | Disable color output | +`db update` also takes statements, each repeatable, which answer the questions it asks before an apply: `--rename :` keeps a model's or field's data under a new name, `--delete ` lets the update lose a model's, field's or storage name's data, and `--allow ` lets it change who can read or write a model's rows, one operation per flag. Without them, an apply that would lose data or change access asks in a terminal, and fails with `CLI.CONSENT_REQUIRED` elsewhere. `--confirm` answers none of them. See the CLI README's `db update` section. + ## Programmatic API Both commands are available via the control client: From c132b01cd6d7117a7784255a6e38efb4fc294215 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 05:45:25 +0200 Subject: [PATCH 107/109] TML-3476: an allowed access change survives a re-plan, and MongoDB questions skip the snapshot steps An allow is keyed by its operation's label and subject rather than its position, and each consented allow takes its position from the new plan after a typed rename, so it is asked once and reported where it now is. A question on a target that carries out no rename gives its keep-the-data text without the steps that would make a rename possible. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/statements/plan-questions.ts | 26 +++++-- .../statements/ask-plan-questions.test.ts | 73 +++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts index a875e2c5765f..d25ef495ae6e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/statements/plan-questions.ts @@ -316,11 +316,12 @@ function givenFor( return first === undefined ? [] : [first]; } -/** A `delete` answers for its subject; an `allow` for its one operation. */ +/** + * A `delete` answers for its subject; an `allow` for its one operation, known by its label and + * subject, since a re-plan moves every operation's position. + */ function questionKey(verb: ConsentVerb, entry: PlannedSubject): string { - return verb === 'allow' - ? `allow:${entry.operationIndex}:${entry.label}:${keyOf(entry)}` - : `delete:${keyOf(entry)}`; + return verb === 'allow' ? `allow:${entry.label}:${keyOf(entry)}` : `delete:${keyOf(entry)}`; } function checkAnswers(questions: readonly PlanQuestion[], answers: readonly PlanAnswer[]): void { @@ -353,9 +354,11 @@ function checkAnswers(questions: readonly PlanQuestion[], answers: readonly Plan function withRecoveryOnFirstStorageQuestion( asked: readonly { readonly loss: PlannedSubject; readonly question: PlanQuestion }[], - originKnown: boolean, + recoveryApplies: boolean, ): readonly PlanQuestion[] { - const first = originKnown ? -1 : asked.findIndex(({ loss }) => loss.subject.kind === 'storage'); + const first = recoveryApplies + ? asked.findIndex(({ loss }) => loss.subject.kind === 'storage') + : -1; return asked.map(({ question }, index) => index === first ? { ...question, question: `${question.question} ${ORIGIN_SNAPSHOT_RECOVERY}` } @@ -440,7 +443,7 @@ export async function askPlanQuestions const questions = [ ...withRecoveryOnFirstStorageQuestion( losses.map((loss) => ({ loss, question: dataLossQuestion(loss, contracts) })), - input.originKnown, + !input.originKnown && input.keepDataByHand === undefined, ), ...widenings.map((widening) => accessWideningQuestion(widening, contracts)), ]; @@ -462,6 +465,15 @@ export async function askPlanQuestions const replanned = await input.replan(renames); if (!replanned.ok) return replanned; plan = replanned.value; + for (const entry of plan.accessWidening) { + const allowed = consented.get(questionKey('allow', entry)); + if (allowed !== undefined) { + consented.set(questionKey('allow', entry), { + ...allowed, + operationIndex: entry.operationIndex, + }); + } + } const operationOf = (entry: PlannedSubject) => `${entry.label}:${keyOf(entry)}`; const stillPlanned = new Set(plan.dataLoss.map(operationOf)); const unresolved = typedRenames.find(({ loss }) => stillPlanned.has(operationOf(loss))); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts index 5ad3a7396025..46dda0df1d95 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/statements/ask-plan-questions.test.ts @@ -239,3 +239,76 @@ describe('allow statements given as pre-answers', () => { }); }); }); + +describe('an access change consented before a typed rename', () => { + it('is asked once, and reported at its position in the new plan', async () => { + const t = { kind: 'model', namespaceId: asNamespaceId('app'), model: 'T' } as const; + const policyDrop = { label: 'Drop RLS policy "readers" on "T"', subject: t, widens: false }; + let rounds = 0; + const result = await askPlanQuestions({ + plan: { + dataLoss: [{ ...dropRatio, operationIndex: 0, subject: ratio }], + accessWidening: [{ ...policyDrop, operationIndex: 1 }], + }, + askAccess: true, + renames: [], + preAnswers: [], + consentAll: { delete: false, allow: false }, + origin, + originKnown: true, + keepDataByHand: undefined, + destination, + answer: async (questions) => { + rounds += 1; + return questions.map(({ subject, verbs }) => + verbs.includes('allow') + ? { verb: 'allow', text: subject } + : { verb: 'rename', text: 'T.ratio:T.ratio2' }, + ); + }, + replan: async () => + ok({ dataLoss: [], accessWidening: [{ ...policyDrop, operationIndex: 3 }] }), + }); + + expect(rounds).toBe(1); + expect( + result.ok && + result.value.consented.map(({ verb, operationIndex }) => ({ verb, operationIndex })), + ).toEqual([{ verb: 'allow', operationIndex: 3 }]); + }); +}); + +describe('questions about storage on a target that carries out no rename', () => { + it('say how to keep the data by hand, without the steps that make a rename possible', async () => { + const asked: string[] = []; + await askPlanQuestions({ + plan: { + dataLoss: [ + { + operationIndex: 0, + label: 'Drop collection events', + subject: { kind: 'storage', name: 'events' }, + }, + ], + accessWidening: [], + }, + askAccess: false, + renames: [], + preAnswers: [], + consentAll: { delete: false, allow: false }, + origin, + originKnown: false, + keepDataByHand: () => 'Rename the collection by hand first.', + destination, + answer: async (questions) => { + asked.push(...questions.map(({ question }) => question)); + return questions.map(({ subject }) => ({ verb: 'delete', text: subject })); + }, + replan: async () => ok({ dataLoss: [], accessWidening: [] }), + }); + + expect(asked).toEqual([ + 'Drop collection events would lose the data in "events", named by its storage name because the origin contract is unknown; --delete loses its rows. Rename the collection by hand first.', + ]); + }); +}); From b99bda1a870ce6b3305ac96d79fea55cb4344e38 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 07:42:46 +0200 Subject: [PATCH 108/109] TML-3476: use the published @prisma/cli-engine 0.7.0 instead of the preview build Signed-off-by: willbot Signed-off-by: Will Madden --- package.json | 1 - pnpm-lock.yaml | 120 ++++++++++++++++++++++++------------------------- 2 files changed, 59 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index d5839f0b8635..e3e2b15d1280 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ }, "pnpm": { "overrides": { - "@prisma/cli-engine": "https://pkg.pr.new/@prisma/cli-engine@1cc84f8", "hono": "4.11.4", "js-yaml": "3.14.2", "lodash": "4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc4c9a5bda61..3e37f65012fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,6 @@ catalogs: version: 1.0.12 overrides: - '@prisma/cli-engine': https://pkg.pr.new/@prisma/cli-engine@1cc84f8 hono: 4.11.4 js-yaml: 3.14.2 lodash: 4.17.23 @@ -202,8 +201,8 @@ importers: version: 8.21.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -239,8 +238,8 @@ importers: version: 2.24.0(express@5.2.1)(hono@4.11.4)(next@16.3.0(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/compute-sdk': specifier: ^0.43.0 version: 0.43.0(@prisma/management-api-sdk@1.76.0)(encoding@0.1.13)(rollup@4.59.0) @@ -294,8 +293,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -346,8 +345,8 @@ importers: version: 7.5.0(socks@2.8.9) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -389,8 +388,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -435,8 +434,8 @@ importers: version: link:../../packages/9-public/@prisma/orm-postgres devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -472,8 +471,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -518,8 +517,8 @@ importers: specifier: 5.20260804.1 version: 5.20260804.1 '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -570,8 +569,8 @@ importers: version: 19.2.8(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -634,8 +633,8 @@ importers: version: 17.4.2 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/tsconfig': specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-config/tsconfig @@ -683,8 +682,8 @@ importers: version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -735,8 +734,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -799,8 +798,8 @@ importers: version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/dev': specifier: 'catalog:' version: 0.25.2(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -905,8 +904,8 @@ importers: version: 4.3.3 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -954,8 +953,8 @@ importers: version: 8.22.0 devDependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../test/utils @@ -1376,9 +1375,6 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils - '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1431,6 +1427,9 @@ importers: '@internal/sql-runtime': specifier: workspace:8.0.0-rc.16 version: link:../../../2-sql/5-runtime + '@prisma/cli-engine': + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../../test/utils @@ -1534,9 +1533,6 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.16 version: link:../../0-foundation/utils - '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) arktype: specifier: ^2.2.2 version: 2.2.7 @@ -1550,6 +1546,9 @@ importers: specifier: 'catalog:' version: 0.2.17 devDependencies: + '@prisma/cli-engine': + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../../test/utils @@ -3247,8 +3246,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../3-targets/3-targets/postgres '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3335,8 +3334,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3417,8 +3416,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-ts '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -3793,8 +3792,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../2-sql/2-authoring/contract-psl '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../../../test/utils @@ -5334,8 +5333,8 @@ importers: packages/9-public/@prisma/orm-toolchain: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-framework': specifier: workspace:8.0.0-rc.16 version: link:../orm-framework @@ -5480,8 +5479,8 @@ importers: test/e2e/framework/test/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-arktype-json': specifier: workspace:8.0.0-rc.16 version: link:../../../../../packages/9-public/@prisma/orm-extension-arktype-json @@ -5498,8 +5497,8 @@ importers: test/e2e/framework/test/sqlite/fixtures: dependencies: '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-sqlite': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-sqlite @@ -5685,8 +5684,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@repo/test-utils': specifier: workspace:8.0.0-rc.16 version: link:../utils @@ -5805,8 +5804,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -5880,8 +5879,8 @@ importers: specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/1-framework/0-foundation/utils '@prisma/cli-engine': - specifier: https://pkg.pr.new/@prisma/cli-engine@1cc84f8 - version: https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) + specifier: 0.7.0 + version: 0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4) '@prisma/orm-extension-pgvector': specifier: workspace:8.0.0-rc.16 version: link:../../../../../../packages/9-public/@prisma/orm-extension-pgvector @@ -7662,9 +7661,8 @@ packages: '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@1cc84f8': - resolution: {integrity: sha512-xIKRkaCxSou8CU2MgdxmTHUmPnl4FYl35+xOhyyz1ja+4hgKJK6WbLYxxg9NdcHvRQb/TGEtF18Ugdlku5RnNw==, tarball: https://pkg.pr.new/@prisma/cli-engine@1cc84f8} - version: 0.7.0 + '@prisma/cli-engine@0.7.0': + resolution: {integrity: sha512-CL21NYQQEAR9IEApPfUzMhv/VtbsJs4efRSxlvzHoFT69vynr0Wuu5KNNbZgIlONOEfULqP0djFCD4VaA/HVPg==} engines: {node: '>=22.12.0'} peerDependencies: '@prisma/management-api-sdk': ^1.55.0 @@ -13432,7 +13430,7 @@ snapshots: transitivePeerDependencies: - pg-native - '@prisma/cli-engine@https://pkg.pr.new/@prisma/cli-engine@1cc84f8(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': + '@prisma/cli-engine@0.7.0(@prisma/management-api-sdk@1.76.0)(magicast@0.5.4)': dependencies: '@clack/prompts': 1.5.0 '@prisma/management-api-sdk': 1.76.0 From 0d93ccda17e85bd3eb991e3c21b0be4786b2e549 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 8 Oct 2026 08:35:01 +0200 Subject: [PATCH 109/109] TML-3476: the drop-column and rollback journeys state their drops with --delete Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-journeys/migration-apply-edge-cases.e2e.test.ts | 6 ++++++ .../test/cli-journeys/rollback-cycle.e2e.test.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts b/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts index 836210e8732e..89120faadb30 100644 --- a/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts @@ -184,6 +184,8 @@ withTempDir(({ createTempDir }) => { 'drop-email', '--from', latestMigrationDirName(ctx), + '--delete', + 'User.email', ]); expect(plan1.exitCode, 'plan drop-email').toBe(0); @@ -264,6 +266,10 @@ withTempDir(({ createTempDir }) => { 'drop-email', '--from', latestMigrationDirName(ctx), + '--delete', + 'User.email', + '--delete', + 'User.name', ]); expect(plan2.exitCode, 'plan drop-email').toBe(0); diff --git a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts index 41d3c464e762..d3304a81f048 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -79,6 +79,8 @@ withTempDir(({ createTempDir }) => { addPhoneDir, '--to', rollbackTarget, + '--delete', + 'User.phone', '--json', ]); expect(planRollback.exitCode, 'plan rollback --to ^').toBe(0); @@ -91,7 +93,7 @@ withTempDir(({ createTempDir }) => { expect(rollback.to, 'rollback to predecessor C1').toBe(c1Hash); expect( rollback.operations.some((op) => op.operationClass === 'destructive'), - 'reverse delta drops the added column (destructive), no refusal', + 'reverse delta drops the added column (destructive), answered by --delete', ).toBe(true); const contractSource = readFileSync(join(ctx.testDir, 'contract.ts'), 'utf-8'); expect(contractSource, 'contract source untouched (still phone variant)').toContain(