fix(portability): restore compares existing rows through the v1 archive projection
This commit is contained in:
parent
63bc0ebe69
commit
f04a7f9944
2 changed files with 1271 additions and 0 deletions
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue