Context
PR LeanerCloud/cloud-commitments-cli#1277 lands the EXPAND half of a cancelled->canceled rename (expand-contract pattern). The EXPAND migration (000078) is intentionally additive and non-normalizing:
- it widens both status CHECK constraints to accept BOTH spellings,
- it adds the
canceled_by column and does a best-effort copy of existing cancelled_by values,
- it does NOT normalize
status='cancelled' rows and does NOT authoritatively drain cancelled_by (a one-time backfill during EXPAND would be a false guarantee, since old code still running keeps writing fresh 'cancelled' / cancelled_by-only rows right after the backfill).
Throughout EXPAND the application reads BOTH spellings (status filter + KPI/cancel switches accept both; every column read projects COALESCE(canceled_by, cancelled_by)), so any row written by old or new code at any time reads correctly.
This CONTRACT step runs only after PR LeanerCloud/cloud-commitments-cli#1277 is deployed and every old code instance is gone — at which point the set of legacy rows is final and can be normalized completely.
Work
Create migration 000079 (next after 000078) that, in this order:
- Normalize late legacy data FIRST (this is the authoritative pass the EXPAND migration deliberately skipped):
UPDATE purchase_executions SET status = 'canceled' WHERE status = 'cancelled';
UPDATE ri_exchange_history SET status = 'canceled' WHERE status = 'cancelled';
- Drain attribution before dropping the column:
UPDATE purchase_executions SET canceled_by = cancelled_by WHERE canceled_by IS NULL AND cancelled_by IS NOT NULL;
- Drop
'cancelled' from the purchase_executions_status_check constraint (leaving only 'canceled' and the other statuses).
- Drop
'cancelled' from the ri_exchange_history_status_check constraint.
ALTER TABLE purchase_executions DROP COLUMN IF EXISTS cancelled_by;
Ordering matters: normalize/drain (step 1) must run BEFORE narrowing the constraints (steps 2-3) and dropping the column (step 4), or the narrowed constraint rejects surviving 'cancelled' rows / the drop loses un-drained attribution. (Same ordering lesson as 000078's down.sql.)
Pre-conditions before applying
Code cleanup (same PR or follow-up)
Once the data is normalized and the column dropped, remove the EXPAND-window dual-spelling scaffolding added by LeanerCloud/cloud-commitments-cli#1277:
config.LegacyStatusCanceled and every reference to it (history status filter, the cancel/KPI switches, the cancel-recovery check).
- The
COALESCE(canceled_by, cancelled_by) in the 10 read projections in internal/config/store_postgres.go -> plain canceled_by.
- The legacy fixture in the KPI regression test and the file-level
//nolint:misspell on the 000078 integration test.
Down migration
The down migration should:
- Re-add
'cancelled' to both CHECK constraints.
ALTER TABLE purchase_executions ADD COLUMN IF NOT EXISTS cancelled_by TEXT;
- Backfill
cancelled_by from canceled_by for any rows that need it.
(Order: re-add column/constraints widening before any data move; converting canceled->cancelled is optional on the down path since the widened-again constraint accepts both.)
Context
PR LeanerCloud/cloud-commitments-cli#1277 lands the EXPAND half of a cancelled->canceled rename (expand-contract pattern). The EXPAND migration (000078) is intentionally additive and non-normalizing:
canceled_bycolumn and does a best-effort copy of existingcancelled_byvalues,status='cancelled'rows and does NOT authoritatively draincancelled_by(a one-time backfill during EXPAND would be a false guarantee, since old code still running keeps writing fresh'cancelled'/cancelled_by-only rows right after the backfill).Throughout EXPAND the application reads BOTH spellings (status filter + KPI/cancel switches accept both; every column read projects
COALESCE(canceled_by, cancelled_by)), so any row written by old or new code at any time reads correctly.This CONTRACT step runs only after PR LeanerCloud/cloud-commitments-cli#1277 is deployed and every old code instance is gone — at which point the set of legacy rows is final and can be normalized completely.
Work
Create migration 000079 (next after 000078) that, in this order:
UPDATE purchase_executions SET status = 'canceled' WHERE status = 'cancelled';UPDATE ri_exchange_history SET status = 'canceled' WHERE status = 'cancelled';UPDATE purchase_executions SET canceled_by = cancelled_by WHERE canceled_by IS NULL AND cancelled_by IS NOT NULL;'cancelled'from thepurchase_executions_status_checkconstraint (leaving only'canceled'and the other statuses).'cancelled'from theri_exchange_history_status_checkconstraint.ALTER TABLE purchase_executions DROP COLUMN IF EXISTS cancelled_by;Ordering matters: normalize/drain (step 1) must run BEFORE narrowing the constraints (steps 2-3) and dropping the column (step 4), or the narrowed constraint rejects surviving
'cancelled'rows / the drop loses un-drained attribution. (Same ordering lesson as 000078's down.sql.)Pre-conditions before applying
'cancelled'orcancelled_by). This is the gate that makes step 1's normalization complete and final — it cannot be guaranteed during the EXPAND rolling deploy, which is exactly why normalization was deferred to here.Code cleanup (same PR or follow-up)
Once the data is normalized and the column dropped, remove the EXPAND-window dual-spelling scaffolding added by LeanerCloud/cloud-commitments-cli#1277:
config.LegacyStatusCanceledand every reference to it (history status filter, the cancel/KPI switches, the cancel-recovery check).COALESCE(canceled_by, cancelled_by)in the 10 read projections ininternal/config/store_postgres.go-> plaincanceled_by.//nolint:misspellon the 000078 integration test.Down migration
The down migration should:
'cancelled'to both CHECK constraints.ALTER TABLE purchase_executions ADD COLUMN IF NOT EXISTS cancelled_by TEXT;cancelled_byfromcanceled_byfor any rows that need it.(Order: re-add column/constraints widening before any data move; converting
canceled->cancelledis optional on the down path since the widened-again constraint accepts both.)