-- v1.0.7 item B (day 3): CHECK constraint guaranteeing that every -- row in status='reversal_pending' has next_retry_at set. -- -- Failure mode this prevents: a reversal_pending row with -- next_retry_at=NULL is invisible to the worker's select -- WHERE status='reversal_pending' AND (next_retry_at IS NULL OR next_retry_at <= NOW()) -- (actually the `IS NULL` branch IS included in the worker query, -- so the row would be picked up — but the pattern is fragile: -- anyone who later writes a stricter WHERE clause expecting -- next_retry_at NOT NULL would silently orphan these rows). -- -- Belt-and-suspenders: the constraint makes the invariant structural. -- reverseSellerAccounting always sets next_retry_at=NOW() on -- transition; the worker always sets a retry timestamp on -- same-state retry; terminal transitions (reversed, -- permanently_failed) clear next_retry_at back to NULL. No code -- path produces NULL in combination with reversal_pending — this -- constraint codifies that. -- -- The constraint is a CHECK not a NOT NULL because the column is -- legitimately NULL in the other statuses (pending, completed, -- failed without pending retry, reversed, permanently_failed). -- ADD CONSTRAINT is not natively IF NOT EXISTS in Postgres — the -- DO block catches `duplicate_object` so re-running the migration -- (test runs, manual psql apply, re-deploys) is a no-op. Same shape -- the runner expects from other idempotent migrations in this -- directory. DO $$ BEGIN ALTER TABLE seller_transfers ADD CONSTRAINT chk_reversal_pending_has_next_retry_at CHECK (status <> 'reversal_pending' OR next_retry_at IS NOT NULL) NOT VALID; EXCEPTION WHEN duplicate_object THEN -- constraint already present (prior manual apply or prior run) NULL; END $$; -- NOT VALID creates the constraint without scanning existing rows. -- Pre-v1.0.7.2 rows are grandfathered — item A + day 2 of B don't -- produce any reversal_pending rows with NULL next_retry_at, but -- if any slipped in via tests or manual ops writes, validation -- would fail the migration. Use VALIDATE CONSTRAINT in a follow-up -- once the table is known to be clean: -- -- ALTER TABLE seller_transfers VALIDATE CONSTRAINT chk_reversal_pending_has_next_retry_at; -- -- Running VALIDATE requires a SHARE UPDATE EXCLUSIVE lock (not -- AccessExclusive), which is cheap and non-blocking for readers. -- Left as a separate one-shot so the deploy is independent of -- table cleanup timing. -- -- Post-validate, future INSERTs / UPDATEs that violate the -- invariant will fail synchronously — the constraint is enforced -- regardless of NOT VALID / VALIDATED state.