fix(parties): skip companies frozen by a migration reset in the party backfill (#2176)
* fix(parties): skip companies frozen by a migration reset in the party backfill Second prod failure of the substrate backfill: suppliers and customers in a company archived by a migration reset are immutable (block_migration_reset_source_mutation), so even setting party_id is refused. Nine suppliers and eleven customers on prod. They are skipped; the archive stays untouched by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(parties): pin why the party backfill skips migration-reset archives A supplier in a company archived by a migration reset cannot take a party_id: block_migration_reset_source_mutation refuses the UPDATE. The backfill skip rule exists because of this trigger. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(pg-upgrade): seed the rows that broke the party backfill on prod A supplier and a customer with an empty name, and a company archived by a migration reset whose rows are immutable. The substrate migration passed the upgrade job and then failed twice on prod for exactly these shapes; any migration that updates every supplier or customer now meets them in CI first. The archived company is guarded on the table existing at the merge-base. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
a24982463b
commit
c40e63e3f7
@@ -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
|
-- 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
|
-- prod: one supplier, two customers) keep party_id NULL: ensure_party
|
||||||
-- refuses a nameless party, and the suggestion pipeline names them later.
|
-- 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 $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
r record;
|
r record;
|
||||||
@@ -315,6 +318,7 @@ BEGIN
|
|||||||
FROM public.suppliers
|
FROM public.suppliers
|
||||||
WHERE party_id IS NULL AND company_id IS NOT NULL
|
WHERE party_id IS NULL AND company_id IS NOT NULL
|
||||||
AND nullif(btrim(name), '') 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
|
ORDER BY created_at, id
|
||||||
LOOP
|
LOOP
|
||||||
UPDATE public.suppliers
|
UPDATE public.suppliers
|
||||||
@@ -327,6 +331,7 @@ BEGIN
|
|||||||
FROM public.customers
|
FROM public.customers
|
||||||
WHERE party_id IS NULL AND company_id IS NOT NULL
|
WHERE party_id IS NULL AND company_id IS NOT NULL
|
||||||
AND nullif(btrim(name), '') 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
|
ORDER BY created_at, id
|
||||||
LOOP
|
LOOP
|
||||||
UPDATE public.customers
|
UPDATE public.customers
|
||||||
|
|||||||
@@ -139,6 +139,29 @@ describe('parties substrate (pg)', () => {
|
|||||||
expect(visibleToStranger).toBe(0)
|
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 () => {
|
it('ensure_party refuses a p_user_id that is not the authenticated caller', async () => {
|
||||||
const mine = await seedCompany()
|
const mine = await seedCompany()
|
||||||
const other = await seedCompany()
|
const other = await seedCompany()
|
||||||
|
|||||||
@@ -105,4 +105,74 @@ VALUES
|
|||||||
('44444444-4444-4444-4444-444444444403', '6570', 123.45, 0),
|
('44444444-4444-4444-4444-444444444403', '6570', 123.45, 0),
|
||||||
('44444444-4444-4444-4444-444444444403', '1930', 0, 123.45);
|
('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;
|
COMMIT;
|
||||||
|
|||||||
Reference in New Issue
Block a user