fix(portability): restore compares existing rows through the v1 archive projection

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent 63bc0ebe69
commit f04a7f9944
2 changed files with 1271 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,246 @@
\set ON_ERROR_STOP on
-- Regression for 20260929000002_portability_restore_projection.sql.
-- restore_account_portability compared whole current rows against the frozen
-- v1 archive rows, so any meeting, memo or document still on the server made
-- the restore fail with portability_restore_conflict_existing_revision, even
-- for an archive exported a moment earlier. Export and restore now share one
-- v1 projection (portability_v1_keys / portability_v1_project).
BEGIN;
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF condition IS NOT TRUE THEN
RAISE EXCEPTION 'assertion_failed: %', message;
END IF;
END;
$$;
-- Catalog: the projection helpers are internal.
SELECT pg_temp.assert_true(
NOT has_function_privilege('authenticated', 'public.portability_v1_keys(text)', 'EXECUTE')
AND NOT has_function_privilege('authenticated', 'public.portability_v1_project(text, jsonb)', 'EXECUTE')
AND NOT has_function_privilege('anon', 'public.portability_v1_project(text, jsonb)', 'EXECUTE'),
'clients cannot call the projection helpers directly'
);
-- Helper contract: allow-list projection onto the frozen v1 keys.
SELECT pg_temp.assert_true(
public.portability_v1_project(
'meeting_memos',
'{"id":"a","meeting_id":"b","user_id":"c","content":"x","timestamp_ms":1,"created_at":"t","updated_at":"u","future_column":1}'::jsonb
) = '{"id":"a","meeting_id":"b","user_id":"c","content":"x","timestamp_ms":1,"created_at":"t"}'::jsonb,
'memo projection drops updated_at and any later column'
);
SELECT pg_temp.assert_true(
public.portability_v1_project('meetings', NULL) IS NULL,
'projection of NULL is NULL'
);
SELECT pg_temp.assert_true(
cardinality(public.portability_v1_keys('dictionary')) = 9
AND cardinality(public.portability_v1_keys('history')) = 25
AND cardinality(public.portability_v1_keys('meetings')) = 19
AND cardinality(public.portability_v1_keys('transcripts')) = 10
AND cardinality(public.portability_v1_keys('meeting_memos')) = 6
AND cardinality(public.portability_v1_keys('meeting_documents')) = 11
AND cardinality(public.portability_v1_keys('custom_instructions')) = 11,
'v1 key sets keep their frozen sizes'
);
DO $$
BEGIN
PERFORM public.portability_v1_keys('profiles');
RAISE EXCEPTION 'assertion_failed: unknown dataset must be rejected';
EXCEPTION WHEN SQLSTATE '22023' THEN
NULL;
END $$;
-- Every column of a synced table is either a v1 key or a known post-freeze
-- column; a new column must be classified here on purpose.
DO $$
DECLARE
dataset text;
unclassified text;
BEGIN
FOREACH dataset IN ARRAY ARRAY[
'dictionary', 'history', 'meetings', 'transcripts', 'meeting_memos',
'meeting_documents', 'custom_instructions'
]
LOOP
SELECT string_agg(col.column_name, ',')
INTO unclassified
FROM information_schema.columns AS col
WHERE col.table_schema = 'public'
AND col.table_name = dataset
AND NOT (col.column_name = ANY (public.portability_v1_keys(dataset)))
AND NOT (col.column_name = ANY (ARRAY[
'audio_storage_key', 'language', 'attendees', 'template_id',
'creation_idempotency_key', 'creation_request_hash',
'generation_idempotency_key', 'updated_at'
]));
PERFORM pg_temp.assert_true(unclassified IS NULL, format('%s has unclassified columns: %s', dataset, unclassified));
END LOOP;
END $$;
-- Fixtures: one row per dataset, with every post-freeze column populated.
INSERT INTO auth.users (
id, aud, role, email, encrypted_password, email_confirmed_at,
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
) VALUES (
'29000002-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
'portability-restore@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
);
SELECT set_config(
'request.jwt.claims',
'{"sub":"29000002-0000-4000-8000-000000000001","role":"authenticated"}',
true
);
SELECT set_config('d3ro.meeting_creation_actor', '29000002-0000-4000-8000-000000000001', true);
INSERT INTO public.dictionary (id, user_id, word)
VALUES ('29000002-0000-4000-8000-0000000000b1', '29000002-0000-4000-8000-000000000001', 'portable');
INSERT INTO public.history (id, user_id, original_text, duration, audio_storage_key)
VALUES ('29000002-0000-4000-8000-0000000000a1', '29000002-0000-4000-8000-000000000001',
'deleted then restored', 1.5, 'audio/history-a1.m4a');
INSERT INTO public.meetings (
id, user_id, title, language, attendees, creation_idempotency_key,
creation_request_hash, audio_storage_key
) VALUES (
'29000002-0000-4000-8000-0000000000c1', '29000002-0000-4000-8000-000000000001',
'weekly sync', 'en', '["Alice","Bob"]'::jsonb, '29000002-0000-4000-8000-0000000000c9',
repeat('ab', 32), 'audio/meeting-c1.m4a'
);
INSERT INTO public.transcripts (id, meeting_id, segment_index, timestamp_ms, text)
VALUES ('29000002-0000-4000-8000-000000000011', '29000002-0000-4000-8000-0000000000c1',
0, 0, 'hello');
INSERT INTO public.meeting_memos (id, meeting_id, user_id, content, timestamp_ms)
VALUES ('29000002-0000-4000-8000-0000000000d1', '29000002-0000-4000-8000-0000000000c1',
'29000002-0000-4000-8000-000000000001', 'follow up', 1000);
INSERT INTO public.meeting_documents (
id, meeting_id, user_id, template_type, title, content, generation_idempotency_key
) VALUES (
'29000002-0000-4000-8000-0000000000e1', '29000002-0000-4000-8000-0000000000c1',
'29000002-0000-4000-8000-000000000001', 'minutes', 'minutes', 'body',
'29000002-0000-4000-8000-0000000000e9'
);
INSERT INTO public.custom_instructions (id, user_id, name, prompt)
VALUES ('29000002-0000-4000-8000-0000000000f1', '29000002-0000-4000-8000-000000000001',
'portability fixture', 'prompt');
SELECT pg_temp.assert_true(
(SELECT attendees FROM public.meetings WHERE id = '29000002-0000-4000-8000-0000000000c1') = '["Alice","Bob"]'::jsonb
AND (SELECT generation_idempotency_key FROM public.meeting_documents WHERE id = '29000002-0000-4000-8000-0000000000e1') IS NOT NULL,
'fixtures carry post-freeze columns'
);
SET LOCAL ROLE authenticated;
-- 1) Export keeps the v1 bytes, and restoring it right away is a no-op.
DO $$
DECLARE
owner uuid := '29000002-0000-4000-8000-000000000001';
archive record;
archive_b record;
archive_c record;
datasets jsonb;
legacy_datasets jsonb;
result jsonb;
BEGIN
SELECT * INTO archive FROM public.export_account_portability();
datasets := (archive.canonical_payload::jsonb)->'datasets';
-- The same projection 0034 hard-coded; the archive must not move a byte.
legacy_datasets := jsonb_build_object(
'dictionary', (SELECT jsonb_agg(to_jsonb(d) ORDER BY d.created_at, d.id) FROM public.dictionary AS d WHERE d.user_id = owner),
'history', (SELECT jsonb_agg(to_jsonb(h) - 'audio_storage_key' ORDER BY h.created_at, h.id) FROM public.history AS h WHERE h.user_id = owner),
'meetings', (SELECT jsonb_agg(to_jsonb(m) - ARRAY['audio_storage_key', 'language', 'attendees', 'template_id', 'creation_idempotency_key', 'creation_request_hash']::text[] ORDER BY m.created_at, m.id) FROM public.meetings AS m WHERE m.user_id = owner AND m.team_id IS NULL),
'transcripts', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.meeting_id, t.segment_index, t.id) FROM public.transcripts AS t JOIN public.meetings AS m ON m.id = t.meeting_id WHERE m.user_id = owner AND m.team_id IS NULL),
'meeting_memos', (SELECT jsonb_agg(to_jsonb(x) - 'updated_at' ORDER BY x.meeting_id, x.timestamp_ms, x.id) FROM public.meeting_memos AS x WHERE x.user_id = owner),
'meeting_documents', (SELECT jsonb_agg(to_jsonb(x) - ARRAY['template_id', 'generation_idempotency_key']::text[] ORDER BY x.meeting_id, x.created_at, x.id) FROM public.meeting_documents AS x WHERE x.user_id = owner),
'custom_instructions', (SELECT jsonb_agg(to_jsonb(x) ORDER BY x.sort_order, x.created_at, x.id) FROM public.custom_instructions AS x WHERE x.user_id = owner AND x.builtin_key IS NULL)
);
PERFORM pg_temp.assert_true(datasets::text = legacy_datasets::text, 'export datasets are byte-identical to the 0034 projection');
PERFORM pg_temp.assert_true(archive.row_count = 7, 'fixture exports seven rows');
PERFORM pg_temp.assert_true(NOT (datasets->'meetings'->0 ? 'attendees'), 'meetings stay v1');
-- Before the fix this raised portability_restore_conflict_existing_revision.
result := public.restore_account_portability(archive.canonical_payload, archive.checksum);
PERFORM pg_temp.assert_true(result->>'status' = 'imported', 'round-trip restore is accepted: ' || result::text);
PERFORM pg_temp.assert_true((result->>'imported_rows')::integer = 0, 'round-trip restore imports nothing');
PERFORM pg_temp.assert_true((result->>'skipped_rows')::integer = 7, 'round-trip restore skips every existing row');
-- Post-freeze columns of existing rows survive the restore untouched.
PERFORM pg_temp.assert_true(
(SELECT language = 'en' AND attendees = '["Alice","Bob"]'::jsonb AND audio_storage_key = 'audio/meeting-c1.m4a'
FROM public.meetings WHERE id = '29000002-0000-4000-8000-0000000000c1'),
'existing meeting keeps its post-freeze metadata'
);
-- Archives for the next two steps (distinct exported_at => distinct checksum).
SELECT * INTO archive_b FROM public.export_account_portability();
SELECT * INTO archive_c FROM public.export_account_portability();
PERFORM pg_temp.assert_true(archive_b.checksum <> archive_c.checksum, 'fresh exports have distinct checksums');
PERFORM set_config('d3ro_test.archive_b', archive_b.canonical_payload, true);
PERFORM set_config('d3ro_test.checksum_b', archive_b.checksum, true);
PERFORM set_config('d3ro_test.archive_c', archive_c.canonical_payload, true);
PERFORM set_config('d3ro_test.checksum_c', archive_c.checksum, true);
END $$;
-- 2) Delete a history entry and a memo, then restore: only those come back,
-- while the surviving meeting, transcript and document are skipped.
RESET ROLE;
DELETE FROM public.history WHERE id = '29000002-0000-4000-8000-0000000000a1';
DELETE FROM public.meeting_memos WHERE id = '29000002-0000-4000-8000-0000000000d1';
SET LOCAL ROLE authenticated;
DO $$
DECLARE
result jsonb;
BEGIN
result := public.restore_account_portability(
current_setting('d3ro_test.archive_b'),
current_setting('d3ro_test.checksum_b')
);
PERFORM pg_temp.assert_true(result->>'status' = 'imported', 'restore after deletes is accepted: ' || result::text);
PERFORM pg_temp.assert_true((result->>'imported_rows')::integer = 2, 'deleted history and memo are restored');
PERFORM pg_temp.assert_true((result->>'skipped_rows')::integer = 5, 'surviving rows are skipped');
PERFORM pg_temp.assert_true(
EXISTS (SELECT 1 FROM public.history WHERE id = '29000002-0000-4000-8000-0000000000a1' AND original_text = 'deleted then restored')
AND EXISTS (SELECT 1 FROM public.meeting_memos WHERE id = '29000002-0000-4000-8000-0000000000d1' AND content = 'follow up'),
'restored rows carry the archived content'
);
END $$;
-- 3) A v1 field that changed since the export is still a conflict.
RESET ROLE;
UPDATE public.meetings SET title = 'renamed after export'
WHERE id = '29000002-0000-4000-8000-0000000000c1';
SET LOCAL ROLE authenticated;
DO $$
BEGIN
PERFORM public.restore_account_portability(
current_setting('d3ro_test.archive_c'),
current_setting('d3ro_test.checksum_c')
);
RAISE EXCEPTION 'assertion_failed: a changed v1 field must conflict';
EXCEPTION WHEN SQLSTATE 'P0001' THEN
IF SQLERRM <> 'portability_restore_conflict_existing_revision' THEN
RAISE;
END IF;
END $$;
RESET ROLE;
ROLLBACK;