Consolidates 5 name-groups with synthetic null.<8hex> codes (minted by mig 136 from legacy submission_code IS NULL rows) onto a single canonical PE per name. 9 duplicate rows archived (is_active=false, lifecycle_state='archived'), 9 sequencing_rules reparented onto their canonical procedural_event. Worst offender: "Mängelbeseitigung / Zahlung" 6 → 1. Audit-first: per-row RAISE NOTICE before the writes, plus snapshots in paliad.procedural_events_pre_151 and paliad.sequencing_rules_pre_151 (same TX, mirrors precedent pre_091/093/095/098/140). Post-asserts that no name-group still has >1 active+published null.* row and no sr points at an archived PE. Pre-flight schema audit confirmed no audit trigger on procedural_events or sequencing_rules (only INSTEAD OF triggers on deadline_rules_unified, which don't fire on direct table writes), 0 deadlines + 0 draft_of refs to the duplicates, and lifecycle_state has no CHECK constraint blocking 'archived'. .down.sql best-effort restores sr.procedural_event_id and reactivates the archived rows from the snapshot tables. Mig already applied to youpc paliad schema via Supabase MCP within the same TX as the applied_migrations row insert (checksum matches the embedded file); deployed binary will see version 151 as applied.
230 lines
8.6 KiB
SQL
230 lines
8.6 KiB
SQL
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
|
||
--
|
||
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
|
||
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
|
||
-- whose submission_code was NULL) share user-visible names. The
|
||
-- /admin/procedural-events list shows multiple entries for the same legal
|
||
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
|
||
-- migration consolidates every name-group onto a single canonical row,
|
||
-- reparents the sequencing_rules pointing at the duplicates, and archives
|
||
-- the duplicates without deleting them.
|
||
--
|
||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per
|
||
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
|
||
-- * 0 paliad.deadlines reference any duplicate.
|
||
-- * 0 procedural_events.draft_of references any duplicate.
|
||
-- * No audit trigger on procedural_events or sequencing_rules — only
|
||
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
|
||
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
|
||
-- needed.
|
||
--
|
||
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
|
||
-- created_at, id::text). Every duplicate in current data shares the same
|
||
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
|
||
-- the UUID's lexicographic order.
|
||
--
|
||
-- Hard constraints honoured:
|
||
-- * No deletions. Duplicates flip to is_active=false +
|
||
-- lifecycle_state='archived'. The rows stay in the table for audit.
|
||
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
|
||
-- BEFORE archiving, so no FK ever points at an archived PE.
|
||
-- * Snapshot the affected procedural_events + sequencing_rules into
|
||
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
|
||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
|
||
--
|
||
-- Down: best-effort restore from the snapshots. See .down.sql.
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
|
||
-- TEMP table used by every subsequent step.
|
||
-- ----------------------------------------------------------------
|
||
|
||
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
|
||
WITH dupe_names AS (
|
||
SELECT name
|
||
FROM paliad.procedural_events
|
||
WHERE code LIKE 'null.%'
|
||
GROUP BY name
|
||
HAVING COUNT(*) > 1
|
||
),
|
||
ranked AS (
|
||
SELECT pe.id,
|
||
pe.code,
|
||
pe.name,
|
||
pe.created_at,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY pe.name
|
||
ORDER BY pe.created_at, pe.id::text
|
||
) AS rn
|
||
FROM paliad.procedural_events pe
|
||
WHERE pe.code LIKE 'null.%'
|
||
AND pe.name IN (SELECT name FROM dupe_names)
|
||
),
|
||
canonicals AS (
|
||
SELECT name,
|
||
id AS canonical_id,
|
||
code AS canonical_code
|
||
FROM ranked
|
||
WHERE rn = 1
|
||
)
|
||
SELECT r.id AS duplicate_id,
|
||
r.code AS duplicate_code,
|
||
r.name,
|
||
c.canonical_id,
|
||
c.canonical_code
|
||
FROM ranked r
|
||
JOIN canonicals c ON c.name = r.name
|
||
WHERE r.rn > 1;
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 2. Snapshot. Captures the rows that change so .down has a clean
|
||
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
|
||
-- ----------------------------------------------------------------
|
||
|
||
CREATE TABLE paliad.procedural_events_pre_151 AS
|
||
SELECT pe.*
|
||
FROM paliad.procedural_events pe
|
||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||
|
||
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
|
||
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
|
||
'duplicates that were archived in favour of their canonical name-mate. '
|
||
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
|
||
'095/098/140.';
|
||
|
||
CREATE TABLE paliad.sequencing_rules_pre_151 AS
|
||
SELECT sr.id,
|
||
sr.procedural_event_id AS original_procedural_event_id
|
||
FROM paliad.sequencing_rules sr
|
||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||
|
||
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
|
||
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
|
||
'before reparenting from null.* duplicates onto their canonical PE. '
|
||
'Read-only forensic + revert source.';
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 3. Audit log — per-row NOTICE so the migration output captures
|
||
-- exactly which duplicate folded into which canonical, including
|
||
-- the sr_count for the duplicate (always 1 in current data, but
|
||
-- the RAISE keeps the audit honest if the scope grows later).
|
||
-- ----------------------------------------------------------------
|
||
|
||
DO $$
|
||
DECLARE
|
||
rec record;
|
||
v_dup_count int;
|
||
v_grp_count int;
|
||
BEGIN
|
||
SELECT COUNT(*), COUNT(DISTINCT name)
|
||
INTO v_dup_count, v_grp_count
|
||
FROM tmp_pe_dedupe;
|
||
|
||
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
|
||
v_dup_count, v_grp_count;
|
||
|
||
FOR rec IN
|
||
SELECT d.duplicate_id,
|
||
d.duplicate_code,
|
||
d.name,
|
||
d.canonical_id,
|
||
d.canonical_code,
|
||
(SELECT COUNT(*)
|
||
FROM paliad.sequencing_rules sr
|
||
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
|
||
FROM tmp_pe_dedupe d
|
||
ORDER BY d.name, d.duplicate_id
|
||
LOOP
|
||
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
|
||
rec.duplicate_id, rec.duplicate_code,
|
||
rec.canonical_id, rec.canonical_code,
|
||
rec.sr_count;
|
||
RAISE NOTICE '[mig 151] name: %', rec.name;
|
||
END LOOP;
|
||
END $$;
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
|
||
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
|
||
-- multiple sr onto one PE is by design.
|
||
-- ----------------------------------------------------------------
|
||
|
||
UPDATE paliad.sequencing_rules sr
|
||
SET procedural_event_id = d.canonical_id,
|
||
updated_at = now()
|
||
FROM tmp_pe_dedupe d
|
||
WHERE sr.procedural_event_id = d.duplicate_id;
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 5. Archive the duplicates. No deletion — audit trail preserved.
|
||
-- ----------------------------------------------------------------
|
||
|
||
UPDATE paliad.procedural_events pe
|
||
SET is_active = false,
|
||
lifecycle_state = 'archived',
|
||
updated_at = now()
|
||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||
|
||
-- ----------------------------------------------------------------
|
||
-- 6. POST assertions. Any failure rolls the migration back.
|
||
-- ----------------------------------------------------------------
|
||
|
||
DO $$
|
||
DECLARE
|
||
v_surviving_groups int;
|
||
v_expected_count int;
|
||
v_archived_count int;
|
||
v_orphan_sr int;
|
||
BEGIN
|
||
-- (a) Acceptance criterion 2: no name-group still has >1 active+
|
||
-- published null.* row.
|
||
SELECT COUNT(*) INTO v_surviving_groups
|
||
FROM (
|
||
SELECT name
|
||
FROM paliad.procedural_events
|
||
WHERE code LIKE 'null.%'
|
||
AND is_active = true
|
||
AND lifecycle_state = 'published'
|
||
GROUP BY name
|
||
HAVING COUNT(*) > 1
|
||
) s;
|
||
|
||
IF v_surviving_groups > 0 THEN
|
||
RAISE EXCEPTION
|
||
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
|
||
v_surviving_groups;
|
||
END IF;
|
||
|
||
-- (b) Every targeted duplicate is now archived.
|
||
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
|
||
|
||
SELECT COUNT(*) INTO v_archived_count
|
||
FROM paliad.procedural_events pe
|
||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
|
||
AND pe.is_active = false
|
||
AND pe.lifecycle_state = 'archived';
|
||
|
||
IF v_archived_count <> v_expected_count THEN
|
||
RAISE EXCEPTION
|
||
'[mig 151] FAILED POST: archived %/% duplicates',
|
||
v_archived_count, v_expected_count;
|
||
END IF;
|
||
|
||
-- (c) Acceptance criterion 4: no sequencing_rule still points at
|
||
-- an archived duplicate.
|
||
SELECT COUNT(*) INTO v_orphan_sr
|
||
FROM paliad.sequencing_rules sr
|
||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||
|
||
IF v_orphan_sr > 0 THEN
|
||
RAISE EXCEPTION
|
||
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
|
||
v_orphan_sr;
|
||
END IF;
|
||
|
||
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
|
||
v_archived_count,
|
||
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
|
||
END $$;
|