\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;