diff --git a/supabase/migrations/20260902160000_parties_substrate.sql b/supabase/migrations/20260902160000_parties_substrate.sql index dd1640fd..b6656ac5 100644 --- a/supabase/migrations/20260902160000_parties_substrate.sql +++ b/supabase/migrations/20260902160000_parties_substrate.sql @@ -306,6 +306,9 @@ GRANT EXECUTE ON FUNCTION public.ensure_party(uuid, uuid, text, text, text, text -- party; nothing is merged on name. Rows with an empty name (three exist on -- prod: one supplier, two customers) keep party_id NULL: ensure_party -- refuses a nameless party, and the suggestion pipeline names them later. +-- Rows in a company archived by a migration reset (company_migration_resets +-- .source_company_id) are frozen by block_migration_reset_source_mutation +-- and are skipped too: that archive is immutable by design. DO $$ DECLARE r record; @@ -315,6 +318,7 @@ BEGIN FROM public.suppliers WHERE party_id IS NULL AND company_id IS NOT NULL AND nullif(btrim(name), '') IS NOT NULL + AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets) ORDER BY created_at, id LOOP UPDATE public.suppliers @@ -327,6 +331,7 @@ BEGIN FROM public.customers WHERE party_id IS NULL AND company_id IS NOT NULL AND nullif(btrim(name), '') IS NOT NULL + AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets) ORDER BY created_at, id LOOP UPDATE public.customers diff --git a/tests/pg/parties-substrate.pg.test.ts b/tests/pg/parties-substrate.pg.test.ts index 1527b427..564abe14 100644 --- a/tests/pg/parties-substrate.pg.test.ts +++ b/tests/pg/parties-substrate.pg.test.ts @@ -139,6 +139,29 @@ describe('parties substrate (pg)', () => { expect(visibleToStranger).toBe(0) }) + it('a supplier in a company archived by a migration reset cannot take a party_id (why the backfill skips it)', async () => { + const frozen = await seedCompany() + const replacement = await seedCompany() + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, 'Frozen Supplier') RETURNING id`, + [frozen.companyId, frozen.userId], + ) + await getPool().query( + `INSERT INTO public.company_migration_resets (source_company_id, replacement_company_id, actor_id, reason, confirmation_snapshot, source_counts) + VALUES ($1, $2, $3, 'pg test: the party backfill must skip archived companies', '{}'::jsonb, '{}'::jsonb)`, + [frozen.companyId, replacement.companyId, frozen.userId], + ) + const partyId = await getPool().query<{ id: string }>( + `SELECT public.ensure_party($1, $2, 'Frozen Supplier', NULL, 'company', 'backfill') AS id`, + [replacement.companyId, replacement.userId], + ) + await expect( + getPool().query(`UPDATE public.suppliers SET party_id = $1 WHERE id = $2`, [partyId.rows[0]!.id, rows[0]!.id]), + ).rejects.toMatchObject({ code: 'P0001' }) + const still = await getPool().query<{ party_id: string | null }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [rows[0]!.id]) + expect(still.rows[0]!.party_id).toBeNull() + }) + it('ensure_party refuses a p_user_id that is not the authenticated caller', async () => { const mine = await seedCompany() const other = await seedCompany() diff --git a/tests/pg/upgrade/seed.sql b/tests/pg/upgrade/seed.sql index b3b481a9..19db9b21 100644 --- a/tests/pg/upgrade/seed.sql +++ b/tests/pg/upgrade/seed.sql @@ -105,4 +105,74 @@ VALUES ('44444444-4444-4444-4444-444444444403', '6570', 123.45, 0), ('44444444-4444-4444-4444-444444444403', '1930', 0, 123.45); +-- Edge rows that broke real backfills on production (2026-09-02, the party +-- substrate migration failed twice on prod after passing here): a supplier +-- and a customer with an empty name, and a company archived by a migration +-- reset whose rows block_migration_reset_source_mutation makes immutable. +-- Any migration that UPDATEs every supplier or customer must survive both. +INSERT INTO public.suppliers (id, company_id, user_id, name) +VALUES ( + '77777777-7777-7777-7777-777777777701', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + '' +); + +INSERT INTO public.customers (id, company_id, user_id, name) +VALUES ( + '77777777-7777-7777-7777-777777777702', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + '' +); + +-- Guarded: company_migration_resets arrived 2026-08-18 and a merge-base older +-- than that lacks the table. Then the archived company simply is not seeded. +DO $$ +BEGIN + IF to_regclass('public.company_migration_resets') IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.companies (id, name, entity_type, created_by) + VALUES ( + '88888888-8888-8888-8888-888888888801', + 'Arkiverade Bolaget AB', + 'aktiebolag', + '11111111-1111-1111-1111-111111111111' + ); + INSERT INTO public.companies (id, name, entity_type, created_by) + VALUES ( + '88888888-8888-8888-8888-888888888802', + 'Ersattningsbolaget AB', + 'aktiebolag', + '11111111-1111-1111-1111-111111111111' + ); + INSERT INTO public.suppliers (id, company_id, user_id, name) + VALUES ( + '88888888-8888-8888-8888-888888888811', + '88888888-8888-8888-8888-888888888801', + '11111111-1111-1111-1111-111111111111', + 'Frusen Leverantor AB' + ); + INSERT INTO public.customers (id, company_id, user_id, name) + VALUES ( + '88888888-8888-8888-8888-888888888812', + '88888888-8888-8888-8888-888888888801', + '11111111-1111-1111-1111-111111111111', + 'Frusen Kund AB' + ); + -- Last, so the rows above are written before the archive freezes them. + INSERT INTO public.company_migration_resets + (source_company_id, replacement_company_id, actor_id, reason, confirmation_snapshot, source_counts) + VALUES ( + '88888888-8888-8888-8888-888888888801', + '88888888-8888-8888-8888-888888888802', + '11111111-1111-1111-1111-111111111111', + 'pg-upgrade fixture: archived source company for migration edge cases', + '{}'::jsonb, + '{}'::jsonb + ); +END $$; + COMMIT;