diff --git a/server/supabase/migrations/20260929000002_portability_restore_projection.sql b/server/supabase/migrations/20260929000002_portability_restore_projection.sql new file mode 100644 index 0000000..f97ae0e --- /dev/null +++ b/server/supabase/migrations/20260929000002_portability_restore_projection.sql @@ -0,0 +1,1025 @@ +-- Portable archive v1: one owner for the frozen row shape. +-- +-- restore_account_portability (0021) refused every archive that still had a +-- live meeting, memo or document on the server. Its "existing IDs must match +-- the archive" check compared whole current rows, to_jsonb(existing), against +-- the v1 archive rows. Columns added after the archive froze (meetings +-- language/attendees/template_id/creation_* in 0025, meeting_documents +-- template_id/generation_idempotency_key in 0024, meeting_memos.updated_at in +-- 0034) are always present in to_jsonb, so the comparison never matched and +-- the RPC raised portability_restore_conflict_existing_revision, even when an +-- archive was restored right after it was exported. 0034 had already taught +-- export_account_portability to strip those keys, but restore was never +-- redefined. +-- +-- The v1 key sets lived in three places (restore validators, export +-- projection, restore comparison) and drifted apart. From here on: +-- * portability_v1_keys(dataset) is the single owner of each dataset's +-- frozen v1 key set (copied verbatim from the 0021 restore validators); +-- * portability_v1_project(dataset, row) keeps only those keys +-- (allow-list), so a column added to a synced table later cannot leak into +-- the archive or break the restore comparison; +-- * export, the restore validators and the restore comparison all go +-- through them. +-- +-- Export bytes do not change: the allow-list is exactly the column set export +-- emitted after 0034, and jsonb serialises keys in its own canonical order. +-- The restore bodies below are 0021 verbatim except for the validator key +-- arrays and the existing-row comparison. + +BEGIN; + +CREATE OR REPLACE FUNCTION public.portability_v1_keys(p_dataset text) +RETURNS text[] +LANGUAGE plpgsql +IMMUTABLE +SET search_path = '' +AS $$ +DECLARE + dataset_keys text[]; +BEGIN + dataset_keys := CASE p_dataset + WHEN 'dictionary' THEN ARRAY[ + 'id', 'user_id', 'word', 'pronunciation', 'category', 'usage_count', + 'last_used_at', 'created_at', 'updated_at' + ] + WHEN 'history' THEN ARRAY[ + 'id', 'user_id', 'title', 'original_text', 'polished_text', 'focused_app', + 'focused_app_name', 'focused_app_window_title', 'mode', 'status', + 'error_code', 'duration', 'detected_language', 'mic_device', 'word_count', + 'stt_model', 'llm_model', 'stt_latency_ms', 'llm_latency_ms', 'app_version', + 'summary_text', 'is_favorite', 'revision', 'created_at', 'updated_at' + ] + WHEN 'meetings' THEN ARRAY[ + 'id', 'user_id', 'team_id', 'title', 'status', 'started_at', 'ended_at', + 'duration_ms', 'raw_transcript', 'edited_transcript', 'minutes_markdown', + 'minutes_json', 'stt_model', 'llm_model', 'stt_latency_ms', 'llm_latency_ms', + 'error_message', 'created_at', 'updated_at' + ] + WHEN 'transcripts' THEN ARRAY[ + 'id', 'meeting_id', 'segment_index', 'timestamp_ms', 'duration_ms', 'text', + 'speaker', 'edited', 'created_at', 'updated_at' + ] + WHEN 'meeting_memos' THEN ARRAY[ + 'id', 'meeting_id', 'user_id', 'content', 'timestamp_ms', 'created_at' + ] + WHEN 'meeting_documents' THEN ARRAY[ + 'id', 'meeting_id', 'user_id', 'template_type', 'title', 'content', + 'prompt_used', 'llm_model', 'llm_latency_ms', 'created_at', 'updated_at' + ] + WHEN 'custom_instructions' THEN ARRAY[ + 'id', 'user_id', 'builtin_key', 'name', 'description', 'prompt', 'icon', + 'sort_order', 'revision', 'created_at', 'updated_at' + ] + END; + IF dataset_keys IS NULL THEN + RAISE EXCEPTION 'unknown_portability_dataset:%', p_dataset USING ERRCODE = '22023'; + END IF; + RETURN dataset_keys; +END; +$$; + +CREATE OR REPLACE FUNCTION public.portability_v1_project(p_dataset text, p_row jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE +STRICT +SET search_path = '' +AS $$ + SELECT coalesce(pg_catalog.jsonb_object_agg(entry.key, entry.value), '{}'::jsonb) + FROM (SELECT public.portability_v1_keys(p_dataset) AS keys) AS v1 + CROSS JOIN LATERAL pg_catalog.jsonb_each(p_row) AS entry + WHERE entry.key = ANY (v1.keys) +$$; + +CREATE OR REPLACE FUNCTION public.export_account_portability() +RETURNS TABLE ( + canonical_payload text, + checksum text, + exported_at timestamptz, + row_count integer +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + payload jsonb; + payload_text text; + dataset_rows integer; + exported_timestamp timestamptz := clock_timestamp(); +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + + SELECT + (SELECT count(*) FROM public.dictionary WHERE user_id = current_user_id) + + (SELECT count(*) FROM public.history WHERE user_id = current_user_id) + + (SELECT count(*) FROM public.meetings WHERE user_id = current_user_id AND team_id IS NULL) + + ( + SELECT count(*) + FROM public.transcripts AS transcript + JOIN public.meetings AS meeting ON meeting.id = transcript.meeting_id + WHERE meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ) + + ( + SELECT count(*) + FROM public.meeting_memos AS memo + JOIN public.meetings AS meeting ON meeting.id = memo.meeting_id + WHERE memo.user_id = current_user_id + AND meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ) + + ( + SELECT count(*) + FROM public.meeting_documents AS document + JOIN public.meetings AS meeting ON meeting.id = document.meeting_id + WHERE document.user_id = current_user_id + AND meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ) + + ( + SELECT count(*) + FROM public.custom_instructions + WHERE user_id = current_user_id + AND builtin_key IS NULL + ) + INTO dataset_rows; + + IF dataset_rows > 10000 THEN + RAISE EXCEPTION 'export_row_limit_exceeded' USING ERRCODE = '54000'; + END IF; + + payload := jsonb_build_object( + 'format', 'd3ro-account-portability', + 'schema_version', 1, + 'exported_at', exported_timestamp, + 'owner_id', current_user_id, + 'source', 'cloud', + 'account', jsonb_build_object( + 'profile', coalesce(( + SELECT jsonb_build_object( + 'id', profile.id, + 'name', profile.name, + 'avatar_url', profile.avatar_url, + 'locale', profile.locale, + 'tier', profile.tier, + 'created_at', profile.created_at, + 'updated_at', profile.updated_at + ) + FROM public.profiles AS profile + WHERE profile.id = current_user_id + ), 'null'::jsonb), + 'settings', coalesce(( + SELECT jsonb_build_object( + 'theme_mode', settings.theme_mode, + 'locale', settings.locale, + 'haptic_enabled', settings.haptic_enabled, + 'auto_polish_enabled', settings.auto_polish_enabled, + 'preferred_stt_model', settings.preferred_stt_model, + 'preferred_llm_model', settings.preferred_llm_model, + 'onboarding_version', settings.onboarding_version, + 'tutorial_completed_at', settings.tutorial_completed_at, + 'revision', settings.revision, + 'updated_at', settings.updated_at + ) + FROM public.user_settings AS settings + WHERE settings.user_id = current_user_id + ), 'null'::jsonb), + 'subscription', coalesce(( + SELECT jsonb_build_object( + 'tier', subscription.tier, + 'provider', subscription.provider, + 'status', subscription.status, + 'current_period_start', subscription.current_period_start, + 'current_period_end', subscription.current_period_end, + 'cancel_at', subscription.cancel_at, + 'auto_renewing', subscription.auto_renewing, + 'updated_at', subscription.updated_at + ) + FROM public.subscriptions AS subscription + WHERE subscription.user_id = current_user_id + ), 'null'::jsonb) + ), + 'datasets', jsonb_build_object( + 'dictionary', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('dictionary', to_jsonb(dictionary)) ORDER BY dictionary.created_at, dictionary.id) + FROM public.dictionary AS dictionary + WHERE dictionary.user_id = current_user_id + ), '[]'::jsonb), + 'history', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('history', to_jsonb(history)) ORDER BY history.created_at, history.id) + FROM public.history AS history + WHERE history.user_id = current_user_id + ), '[]'::jsonb), + 'meetings', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('meetings', to_jsonb(meeting)) ORDER BY meeting.created_at, meeting.id) + FROM public.meetings AS meeting + WHERE meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ), '[]'::jsonb), + 'transcripts', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('transcripts', to_jsonb(transcript)) ORDER BY transcript.meeting_id, transcript.segment_index, transcript.id) + FROM public.transcripts AS transcript + JOIN public.meetings AS meeting ON meeting.id = transcript.meeting_id + WHERE meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ), '[]'::jsonb), + 'meeting_memos', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('meeting_memos', to_jsonb(memo)) ORDER BY memo.meeting_id, memo.timestamp_ms, memo.id) + FROM public.meeting_memos AS memo + JOIN public.meetings AS meeting ON meeting.id = memo.meeting_id + WHERE memo.user_id = current_user_id + AND meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ), '[]'::jsonb), + 'meeting_documents', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('meeting_documents', to_jsonb(document)) ORDER BY document.meeting_id, document.created_at, document.id) + FROM public.meeting_documents AS document + JOIN public.meetings AS meeting ON meeting.id = document.meeting_id + WHERE document.user_id = current_user_id + AND meeting.user_id = current_user_id + AND meeting.team_id IS NULL + ), '[]'::jsonb), + 'custom_instructions', coalesce(( + SELECT jsonb_agg(public.portability_v1_project('custom_instructions', to_jsonb(instruction)) ORDER BY instruction.sort_order, instruction.created_at, instruction.id) + FROM public.custom_instructions AS instruction + WHERE instruction.user_id = current_user_id + AND instruction.builtin_key IS NULL + ), '[]'::jsonb) + ), + 'exclusions', jsonb_build_array( + 'raw_audio', + 'storage_objects', + 'payment_credentials', + 'push_tokens' + ) + ); + + payload_text := payload::text; + IF octet_length(payload_text) > 5242880 THEN + RAISE EXCEPTION 'export_payload_too_large' USING ERRCODE = '54000'; + END IF; + + RETURN QUERY SELECT + payload_text, + encode(extensions.digest(convert_to(payload_text, 'UTF8'), 'sha256'), 'hex'), + exported_timestamp, + dataset_rows; +END; +$$; + +CREATE OR REPLACE FUNCTION public.restore_account_portability( + canonical_payload text, + supplied_checksum text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + current_user_id uuid := auth.uid(); + payload jsonb; + datasets jsonb; + account_snapshot jsonb; + payload_checksum text; + payload_source text; + payload_row_count integer; + imported_count integer := 0; + skipped_count integer := 0; + affected integer; + existing_import public.data_portability_imports%ROWTYPE; + imported_at timestamptz; + item jsonb; + item_id uuid; + item_owner uuid; + parent_id uuid; +BEGIN + IF current_user_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + IF canonical_payload IS NULL OR octet_length(canonical_payload) NOT BETWEEN 1 AND 5242880 THEN + RAISE EXCEPTION 'invalid_or_oversized_portability_payload' USING ERRCODE = '22023'; + END IF; + IF supplied_checksum IS NULL OR supplied_checksum !~ '^[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'invalid_portability_checksum' USING ERRCODE = '22023'; + END IF; + + payload_checksum := encode( + extensions.digest(convert_to(canonical_payload, 'UTF8'), 'sha256'), + 'hex' + ); + IF payload_checksum <> supplied_checksum THEN + RAISE EXCEPTION 'portability_checksum_mismatch' USING ERRCODE = '22023'; + END IF; + + BEGIN + payload := canonical_payload::jsonb; + EXCEPTION WHEN invalid_text_representation THEN + RAISE EXCEPTION 'invalid_portability_json' USING ERRCODE = '22023'; + END; + + PERFORM public.portability_assert_exact_keys( + payload, + ARRAY[ + 'format', 'schema_version', 'exported_at', 'owner_id', 'source', 'account', + 'datasets', 'exclusions' + ], + 'payload' + ); + IF jsonb_typeof(payload->'format') IS DISTINCT FROM 'string' + OR jsonb_typeof(payload->'schema_version') IS DISTINCT FROM 'number' + OR jsonb_typeof(payload->'exported_at') IS DISTINCT FROM 'string' + OR jsonb_typeof(payload->'owner_id') IS DISTINCT FROM 'string' + OR jsonb_typeof(payload->'source') IS DISTINCT FROM 'string' + OR jsonb_typeof(payload->'account') IS DISTINCT FROM 'object' + OR jsonb_typeof(payload->'datasets') IS DISTINCT FROM 'object' + OR jsonb_typeof(payload->'exclusions') IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'invalid_portability_root_types' USING ERRCODE = '22023'; + END IF; + + IF payload->>'format' IS DISTINCT FROM 'd3ro-account-portability' + OR payload->>'schema_version' IS DISTINCT FROM '1' THEN + RAISE EXCEPTION 'unsupported_portability_version' USING ERRCODE = '22023'; + END IF; + IF (payload->>'owner_id')::uuid <> current_user_id THEN + RAISE EXCEPTION 'portability_owner_mismatch' USING ERRCODE = '42501'; + END IF; + IF jsonb_typeof(payload->'exported_at') IS DISTINCT FROM 'string' THEN + RAISE EXCEPTION 'invalid_portability_exported_at' USING ERRCODE = '22023'; + END IF; + PERFORM (payload->>'exported_at')::timestamptz; + payload_source := payload->>'source'; + IF payload_source IS NULL OR payload_source <> ALL ( + ARRAY['cloud', 'mobile', 'desktop-legacy', 'web-legacy'] + ) THEN + RAISE EXCEPTION 'invalid_portability_source' USING ERRCODE = '22023'; + END IF; + IF payload->'exclusions' IS DISTINCT FROM jsonb_build_array( + 'raw_audio', 'storage_objects', 'payment_credentials', 'push_tokens' + ) THEN + RAISE EXCEPTION 'invalid_portability_exclusions' USING ERRCODE = '22023'; + END IF; + + account_snapshot := payload->'account'; + PERFORM public.portability_assert_exact_keys( + account_snapshot, + ARRAY['profile', 'settings', 'subscription'], + 'account' + ); + IF account_snapshot->'profile' <> 'null'::jsonb THEN + PERFORM public.portability_assert_exact_keys( + account_snapshot->'profile', + ARRAY['id', 'name', 'avatar_url', 'locale', 'tier', 'created_at', 'updated_at'], + 'account.profile' + ); + PERFORM public.portability_assert_field_types( + account_snapshot->'profile', + ARRAY['id', 'locale', 'tier', 'created_at', 'updated_at'], + ARRAY['name', 'avatar_url'], + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], + 'account.profile' + ); + IF (account_snapshot->'profile'->>'id')::uuid <> current_user_id THEN + RAISE EXCEPTION 'portability_profile_owner_mismatch' USING ERRCODE = '42501'; + END IF; + IF coalesce(length(account_snapshot->'profile'->>'name'), 0) > 80 + OR coalesce(length(account_snapshot->'profile'->>'avatar_url'), 0) > 2048 + OR length(account_snapshot->'profile'->>'locale') NOT BETWEEN 1 AND 16 + OR length(account_snapshot->'profile'->>'tier') NOT BETWEEN 1 AND 32 THEN + RAISE EXCEPTION 'invalid_portability_profile' USING ERRCODE = '22023'; + END IF; + PERFORM + (account_snapshot->'profile'->>'created_at')::timestamptz, + (account_snapshot->'profile'->>'updated_at')::timestamptz; + END IF; + IF account_snapshot->'settings' <> 'null'::jsonb THEN + PERFORM public.portability_assert_exact_keys( + account_snapshot->'settings', + ARRAY[ + 'theme_mode', 'locale', 'haptic_enabled', 'auto_polish_enabled', + 'preferred_stt_model', 'preferred_llm_model', 'onboarding_version', + 'tutorial_completed_at', 'revision', 'updated_at' + ], + 'account.settings' + ); + IF account_snapshot->'settings'->>'theme_mode' <> ALL (ARRAY['system', 'light', 'dark']) + OR length(account_snapshot->'settings'->>'locale') NOT BETWEEN 1 AND 16 + OR (account_snapshot->'settings'->>'onboarding_version')::integer < 0 + OR (account_snapshot->'settings'->>'revision')::bigint < 1 THEN + RAISE EXCEPTION 'invalid_portability_settings' USING ERRCODE = '22023'; + END IF; + PERFORM (account_snapshot->'settings'->>'updated_at')::timestamptz; + IF account_snapshot->'settings'->'tutorial_completed_at' <> 'null'::jsonb THEN + PERFORM (account_snapshot->'settings'->>'tutorial_completed_at')::timestamptz; + END IF; + PERFORM public.portability_assert_field_types( + account_snapshot->'settings', + ARRAY['theme_mode', 'locale', 'updated_at'], + ARRAY['preferred_stt_model', 'preferred_llm_model', 'tutorial_completed_at'], + ARRAY['onboarding_version', 'revision'], ARRAY[]::text[], + ARRAY['haptic_enabled', 'auto_polish_enabled'], ARRAY[]::text[], + ARRAY[]::text[], + 'account.settings' + ); + END IF; + IF account_snapshot->'subscription' <> 'null'::jsonb THEN + PERFORM public.portability_assert_exact_keys( + account_snapshot->'subscription', + ARRAY[ + 'tier', 'provider', 'status', 'current_period_start', 'current_period_end', + 'cancel_at', 'auto_renewing', 'updated_at' + ], + 'account.subscription' + ); + IF length(account_snapshot->'subscription'->>'tier') NOT BETWEEN 1 AND 32 + OR length(account_snapshot->'subscription'->>'provider') NOT BETWEEN 1 AND 32 + OR coalesce(length(account_snapshot->'subscription'->>'status'), 0) > 64 THEN + RAISE EXCEPTION 'invalid_portability_subscription' USING ERRCODE = '22023'; + END IF; + PERFORM (account_snapshot->'subscription'->>'updated_at')::timestamptz; + IF account_snapshot->'subscription'->'current_period_start' <> 'null'::jsonb THEN + PERFORM (account_snapshot->'subscription'->>'current_period_start')::timestamptz; + END IF; + IF account_snapshot->'subscription'->'current_period_end' <> 'null'::jsonb THEN + PERFORM (account_snapshot->'subscription'->>'current_period_end')::timestamptz; + END IF; + IF account_snapshot->'subscription'->'cancel_at' <> 'null'::jsonb THEN + PERFORM (account_snapshot->'subscription'->>'cancel_at')::timestamptz; + END IF; + PERFORM public.portability_assert_field_types( + account_snapshot->'subscription', + ARRAY['tier', 'provider', 'updated_at'], + ARRAY['status', 'current_period_start', 'current_period_end', 'cancel_at'], + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], ARRAY['auto_renewing'], + ARRAY[]::text[], + 'account.subscription' + ); + END IF; + + datasets := payload->'datasets'; + PERFORM public.portability_assert_exact_keys( + datasets, + ARRAY[ + 'dictionary', 'history', 'meetings', 'transcripts', 'meeting_memos', + 'meeting_documents', 'custom_instructions' + ], + 'datasets' + ); + + IF EXISTS ( + SELECT 1 + FROM unnest(ARRAY[ + 'dictionary', 'history', 'meetings', 'transcripts', 'meeting_memos', + 'meeting_documents', 'custom_instructions' + ]) AS dataset_name + WHERE jsonb_typeof(datasets->dataset_name) IS DISTINCT FROM 'array' + ) THEN + RAISE EXCEPTION 'invalid_portability_dataset_type' USING ERRCODE = '22023'; + END IF; + + IF jsonb_array_length(datasets->'dictionary') > 5000 + OR jsonb_array_length(datasets->'history') > 5000 + OR jsonb_array_length(datasets->'meetings') > 1000 + OR jsonb_array_length(datasets->'transcripts') > 10000 + OR jsonb_array_length(datasets->'meeting_memos') > 5000 + OR jsonb_array_length(datasets->'meeting_documents') > 2000 + OR jsonb_array_length(datasets->'custom_instructions') > 1000 THEN + RAISE EXCEPTION 'portability_dataset_row_limit_exceeded' USING ERRCODE = '54000'; + END IF; + + payload_row_count := + jsonb_array_length(datasets->'dictionary') + + jsonb_array_length(datasets->'history') + + jsonb_array_length(datasets->'meetings') + + jsonb_array_length(datasets->'transcripts') + + jsonb_array_length(datasets->'meeting_memos') + + jsonb_array_length(datasets->'meeting_documents') + + jsonb_array_length(datasets->'custom_instructions'); + IF payload_row_count > 10000 THEN + RAISE EXCEPTION 'portability_total_row_limit_exceeded' USING ERRCODE = '54000'; + END IF; + + IF EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'dictionary') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'history') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'meetings') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'transcripts') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'meeting_memos') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'meeting_documents') + GROUP BY value->>'id' HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'id' FROM jsonb_array_elements(datasets->'custom_instructions') + GROUP BY value->>'id' HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'duplicate_portability_row_id' USING ERRCODE = '22023'; + END IF; + + IF EXISTS ( + SELECT lower(btrim(value->>'word')), value->>'category' + FROM jsonb_array_elements(datasets->'dictionary') + GROUP BY lower(btrim(value->>'word')), value->>'category' + HAVING count(*) > 1 + ) OR EXISTS ( + SELECT value->>'meeting_id', value->>'segment_index' + FROM jsonb_array_elements(datasets->'transcripts') + GROUP BY value->>'meeting_id', value->>'segment_index' + HAVING count(*) > 1 + ) OR EXISTS ( + SELECT lower(btrim(value->>'name')) + FROM jsonb_array_elements(datasets->'custom_instructions') + GROUP BY lower(btrim(value->>'name')) + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'duplicate_portability_natural_key' USING ERRCODE = '22023'; + END IF; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'dictionary') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('dictionary'), 'datasets.dictionary[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'user_id', 'word', 'category', 'created_at', 'updated_at'], + ARRAY['pronunciation', 'last_used_at'], + ARRAY['usage_count'], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], + 'datasets.dictionary[]' + ); + item_id := (item->>'id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id THEN + RAISE EXCEPTION 'cross_user_dictionary_row' USING ERRCODE = '42501'; + END IF; + IF length(btrim(item->>'word')) NOT BETWEEN 1 AND 120 + OR coalesce(length(item->>'pronunciation'), 0) > 200 + OR item->>'category' <> ALL (ARRAY['user', 'auto', 'technical']) + OR (item->>'usage_count')::integer < 0 THEN + RAISE EXCEPTION 'invalid_dictionary_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF item->'last_used_at' <> 'null'::jsonb THEN PERFORM (item->>'last_used_at')::timestamptz; END IF; + IF EXISTS (SELECT 1 FROM public.dictionary WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_dictionary_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'history') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('history'), 'datasets.history[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'user_id', 'original_text', 'mode', 'status', 'app_version', 'created_at', 'updated_at'], + ARRAY[ + 'title', 'polished_text', 'focused_app', 'focused_app_name', + 'focused_app_window_title', 'error_code', 'detected_language', 'mic_device', + 'stt_model', 'llm_model', 'summary_text' + ], + ARRAY['duration', 'word_count', 'revision'], + ARRAY['stt_latency_ms', 'llm_latency_ms'], + ARRAY['is_favorite'], ARRAY[]::text[], ARRAY[]::text[], + 'datasets.history[]' + ); + item_id := (item->>'id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_history_row' USING ERRCODE = '42501'; END IF; + IF item->>'mode' <> ALL (ARRAY['dictation', 'translate', 'command', 'caption', 'file-transcription']) + OR item->>'status' <> ALL (ARRAY['completed', 'cancelled', 'error']) + OR (item->>'duration')::double precision < 0 + OR (item->>'word_count')::integer < 0 + OR (item->>'revision')::bigint < 1 + OR length(item->>'app_version') NOT BETWEEN 1 AND 64 THEN + RAISE EXCEPTION 'invalid_history_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF EXISTS (SELECT 1 FROM public.history WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_history_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meetings') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('meetings'), 'datasets.meetings[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'user_id', 'status', 'started_at', 'created_at', 'updated_at'], + ARRAY[ + 'team_id', 'title', 'ended_at', 'raw_transcript', 'edited_transcript', + 'minutes_markdown', 'stt_model', 'llm_model', 'error_message' + ], + ARRAY[]::text[], + ARRAY['duration_ms', 'stt_latency_ms', 'llm_latency_ms'], + ARRAY[]::text[], ARRAY[]::text[], ARRAY['minutes_json'], + 'datasets.meetings[]' + ); + item_id := (item->>'id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id OR item->'team_id' <> 'null'::jsonb THEN + RAISE EXCEPTION 'cross_user_or_team_meeting_row' USING ERRCODE = '42501'; + END IF; + IF item->>'status' <> ALL (ARRAY['recording', 'processing', 'completed', 'error']) THEN + RAISE EXCEPTION 'invalid_meeting_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'started_at')::timestamptz, (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF item->'ended_at' <> 'null'::jsonb THEN PERFORM (item->>'ended_at')::timestamptz; END IF; + IF EXISTS (SELECT 1 FROM public.meetings WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_meeting_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'transcripts') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('transcripts'), 'datasets.transcripts[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'meeting_id', 'text', 'created_at', 'updated_at'], + ARRAY['speaker'], + ARRAY['segment_index', 'timestamp_ms'], ARRAY['duration_ms'], + ARRAY['edited'], ARRAY[]::text[], ARRAY[]::text[], + 'datasets.transcripts[]' + ); + item_id := (item->>'id')::uuid; + parent_id := (item->>'meeting_id')::uuid; + IF NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent + WHERE (parent->>'id')::uuid = parent_id + AND (parent->>'user_id')::uuid = current_user_id + ) THEN RAISE EXCEPTION 'orphan_transcript_row' USING ERRCODE = '23503'; END IF; + IF (item->>'segment_index')::integer < 0 OR (item->>'timestamp_ms')::bigint < 0 THEN + RAISE EXCEPTION 'invalid_transcript_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF EXISTS ( + SELECT 1 FROM public.transcripts AS existing + JOIN public.meetings AS meeting ON meeting.id = existing.meeting_id + WHERE existing.id = item_id AND meeting.user_id <> current_user_id + ) THEN RAISE EXCEPTION 'cross_user_transcript_id' USING ERRCODE = '42501'; END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meeting_memos') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('meeting_memos'), 'datasets.meeting_memos[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'meeting_id', 'user_id', 'content', 'created_at'], ARRAY[]::text[], + ARRAY['timestamp_ms'], ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], + 'datasets.meeting_memos[]' + ); + item_id := (item->>'id')::uuid; + parent_id := (item->>'meeting_id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_memo_row' USING ERRCODE = '42501'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent + WHERE (parent->>'id')::uuid = parent_id + AND (parent->>'user_id')::uuid = current_user_id + ) THEN RAISE EXCEPTION 'orphan_memo_row' USING ERRCODE = '23503'; END IF; + IF length(item->>'content') NOT BETWEEN 1 AND 4000 OR (item->>'timestamp_ms')::bigint < 0 THEN + RAISE EXCEPTION 'invalid_memo_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz; + IF EXISTS (SELECT 1 FROM public.meeting_memos WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_memo_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'meeting_documents') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('meeting_documents'), 'datasets.meeting_documents[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY['id', 'meeting_id', 'user_id', 'template_type', 'title', 'content', 'created_at', 'updated_at'], + ARRAY['prompt_used', 'llm_model'], + ARRAY[]::text[], ARRAY['llm_latency_ms'], ARRAY[]::text[], ARRAY[]::text[], + ARRAY[]::text[], + 'datasets.meeting_documents[]' + ); + item_id := (item->>'id')::uuid; + parent_id := (item->>'meeting_id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id THEN RAISE EXCEPTION 'cross_user_document_row' USING ERRCODE = '42501'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS parent + WHERE (parent->>'id')::uuid = parent_id + AND (parent->>'user_id')::uuid = current_user_id + ) THEN RAISE EXCEPTION 'orphan_document_row' USING ERRCODE = '23503'; END IF; + IF item->>'template_type' <> ALL (ARRAY['minutes', 'report', 'idea-note', 'custom', 'mindmap']) + OR length(item->>'title') NOT BETWEEN 1 AND 300 THEN + RAISE EXCEPTION 'invalid_document_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF EXISTS (SELECT 1 FROM public.meeting_documents WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_document_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + FOR item IN SELECT value FROM jsonb_array_elements(datasets->'custom_instructions') LOOP + PERFORM public.portability_assert_exact_keys( + item, public.portability_v1_keys('custom_instructions'), 'datasets.custom_instructions[]' + ); + PERFORM public.portability_assert_field_types( + item, + ARRAY[ + 'id', 'user_id', 'name', 'description', 'prompt', 'icon', 'created_at', + 'updated_at' + ], + ARRAY['builtin_key'], ARRAY['sort_order', 'revision'], ARRAY[]::text[], + ARRAY[]::text[], ARRAY[]::text[], ARRAY[]::text[], + 'datasets.custom_instructions[]' + ); + item_id := (item->>'id')::uuid; + item_owner := (item->>'user_id')::uuid; + IF item_owner <> current_user_id OR item->'builtin_key' <> 'null'::jsonb THEN + RAISE EXCEPTION 'cross_user_or_builtin_instruction_row' USING ERRCODE = '42501'; + END IF; + IF length(btrim(item->>'name')) NOT BETWEEN 1 AND 80 + OR length(item->>'description') > 240 + OR length(btrim(item->>'prompt')) NOT BETWEEN 1 AND 4000 + OR length(item->>'icon') NOT BETWEEN 1 AND 32 + OR (item->>'sort_order')::integer < 0 + OR (item->>'revision')::bigint < 1 THEN + RAISE EXCEPTION 'invalid_instruction_import_row' USING ERRCODE = '22023'; + END IF; + PERFORM (item->>'created_at')::timestamptz, (item->>'updated_at')::timestamptz; + IF EXISTS (SELECT 1 FROM public.custom_instructions WHERE id = item_id AND user_id <> current_user_id) THEN + RAISE EXCEPTION 'cross_user_instruction_id' USING ERRCODE = '42501'; + END IF; + END LOOP; + + -- Serialize imports for one account. This also closes the ledger check/insert race. + IF NOT pg_catalog.pg_try_advisory_xact_lock( + pg_catalog.hashtextextended('d3ro-portability:' || current_user_id::text, 0) + ) THEN + RAISE EXCEPTION 'portability_import_busy' USING ERRCODE = 'P0001'; + END IF; + + SELECT * INTO existing_import + FROM public.data_portability_imports + WHERE user_id = current_user_id + AND checksum = payload_checksum; + IF FOUND THEN + RETURN jsonb_build_object( + 'status', 'duplicate', + 'checksum', existing_import.checksum, + 'imported_rows', 0, + 'skipped_rows', existing_import.imported_rows + existing_import.skipped_rows, + 'imported_at', existing_import.created_at + ); + END IF; + + -- Existing IDs must be byte-for-byte equivalent to the portable projection, + -- the same v1 projection export_account_portability emits. + IF EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'dictionary') AS incoming + JOIN public.dictionary AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('dictionary', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'history') AS incoming + JOIN public.history AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('history', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meetings') AS incoming + JOIN public.meetings AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('meetings', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'transcripts') AS incoming + JOIN public.transcripts AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('transcripts', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meeting_memos') AS incoming + JOIN public.meeting_memos AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('meeting_memos', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'meeting_documents') AS incoming + JOIN public.meeting_documents AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('meeting_documents', to_jsonb(existing)) IS DISTINCT FROM incoming + ) OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(datasets->'custom_instructions') AS incoming + JOIN public.custom_instructions AS existing ON existing.id = (incoming->>'id')::uuid + WHERE public.portability_v1_project('custom_instructions', to_jsonb(existing)) IS DISTINCT FROM incoming + ) THEN + RAISE EXCEPTION 'portability_restore_conflict_existing_revision' USING ERRCODE = 'P0001'; + END IF; + + -- Natural-key duplicates are idempotent only when their user data agrees. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(datasets->'dictionary') AS incoming + JOIN public.dictionary AS existing + ON existing.user_id = current_user_id + AND lower(btrim(existing.word)) = lower(btrim(incoming->>'word')) + AND existing.category = incoming->>'category' + AND existing.id <> (incoming->>'id')::uuid + WHERE existing.pronunciation IS DISTINCT FROM nullif(incoming->>'pronunciation', '') + OR existing.usage_count IS DISTINCT FROM (incoming->>'usage_count')::integer + OR existing.last_used_at IS DISTINCT FROM (incoming->>'last_used_at')::timestamptz + ) OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(datasets->'transcripts') AS incoming + JOIN public.transcripts AS existing + ON existing.meeting_id = (incoming->>'meeting_id')::uuid + AND existing.segment_index = (incoming->>'segment_index')::integer + AND existing.id <> (incoming->>'id')::uuid + WHERE existing.timestamp_ms IS DISTINCT FROM (incoming->>'timestamp_ms')::bigint + OR existing.duration_ms IS DISTINCT FROM (incoming->>'duration_ms')::integer + OR existing.text IS DISTINCT FROM incoming->>'text' + OR existing.speaker IS DISTINCT FROM nullif(incoming->>'speaker', '') + OR existing.edited IS DISTINCT FROM (incoming->>'edited')::boolean + ) OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements(datasets->'custom_instructions') AS incoming + JOIN public.custom_instructions AS existing + ON existing.user_id = current_user_id + AND lower(btrim(existing.name)) = lower(btrim(incoming->>'name')) + AND existing.id <> (incoming->>'id')::uuid + WHERE existing.description IS DISTINCT FROM incoming->>'description' + OR existing.prompt IS DISTINCT FROM incoming->>'prompt' + OR existing.icon IS DISTINCT FROM incoming->>'icon' + OR existing.sort_order IS DISTINCT FROM (incoming->>'sort_order')::integer + OR existing.revision IS DISTINCT FROM (incoming->>'revision')::bigint + ) THEN + RAISE EXCEPTION 'portability_restore_conflict_natural_key' USING ERRCODE = 'P0001'; + END IF; + + INSERT INTO public.dictionary ( + id, user_id, word, pronunciation, category, usage_count, last_used_at, + created_at, updated_at + ) + SELECT + row.id, row.user_id, row.word, row.pronunciation, row.category, + row.usage_count, row.last_used_at, row.created_at, row.updated_at + FROM jsonb_to_recordset(datasets->'dictionary') AS row( + id uuid, user_id uuid, word text, pronunciation text, category text, + usage_count integer, last_used_at timestamptz, created_at timestamptz, + updated_at timestamptz + ) + ON CONFLICT DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'dictionary') - affected; + + INSERT INTO public.history ( + id, user_id, title, original_text, polished_text, focused_app, + focused_app_name, focused_app_window_title, mode, status, error_code, + audio_storage_key, duration, detected_language, mic_device, word_count, + stt_model, llm_model, stt_latency_ms, llm_latency_ms, app_version, + summary_text, is_favorite, revision, created_at, updated_at + ) + SELECT + row.id, row.user_id, row.title, row.original_text, row.polished_text, + row.focused_app, row.focused_app_name, row.focused_app_window_title, + row.mode, row.status, row.error_code, NULL, row.duration, + row.detected_language, row.mic_device, row.word_count, row.stt_model, + row.llm_model, row.stt_latency_ms, row.llm_latency_ms, row.app_version, + row.summary_text, row.is_favorite, row.revision, row.created_at, row.updated_at + FROM jsonb_to_recordset(datasets->'history') AS row( + id uuid, user_id uuid, title text, original_text text, polished_text text, + focused_app text, focused_app_name text, focused_app_window_title text, + mode text, status text, error_code text, duration double precision, + detected_language text, mic_device text, word_count integer, stt_model text, + llm_model text, stt_latency_ms integer, llm_latency_ms integer, + app_version text, summary_text text, is_favorite boolean, revision bigint, + created_at timestamptz, updated_at timestamptz + ) + ON CONFLICT (id) DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'history') - affected; + + INSERT INTO public.meetings ( + id, user_id, team_id, title, status, started_at, ended_at, duration_ms, + raw_transcript, edited_transcript, minutes_markdown, minutes_json, + stt_model, llm_model, stt_latency_ms, llm_latency_ms, error_message, + audio_storage_key, created_at, updated_at + ) + SELECT + row.id, row.user_id, NULL, row.title, row.status, row.started_at, + row.ended_at, row.duration_ms, row.raw_transcript, row.edited_transcript, + row.minutes_markdown, row.minutes_json, row.stt_model, row.llm_model, + row.stt_latency_ms, row.llm_latency_ms, row.error_message, NULL, + row.created_at, row.updated_at + FROM jsonb_to_recordset(datasets->'meetings') AS row( + id uuid, user_id uuid, team_id uuid, title text, status text, + started_at timestamptz, ended_at timestamptz, duration_ms bigint, + raw_transcript text, edited_transcript text, minutes_markdown text, + minutes_json jsonb, stt_model text, llm_model text, stt_latency_ms integer, + llm_latency_ms integer, error_message text, created_at timestamptz, + updated_at timestamptz + ) + ON CONFLICT (id) DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'meetings') - affected; + + INSERT INTO public.transcripts ( + id, meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker, + edited, created_at, updated_at + ) + SELECT + row.id, row.meeting_id, row.segment_index, row.timestamp_ms, + row.duration_ms, row.text, row.speaker, row.edited, row.created_at, + row.updated_at + FROM jsonb_to_recordset(datasets->'transcripts') AS row( + id uuid, meeting_id uuid, segment_index integer, timestamp_ms bigint, + duration_ms integer, text text, speaker text, edited boolean, + created_at timestamptz, updated_at timestamptz + ) + ON CONFLICT DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'transcripts') - affected; + + INSERT INTO public.meeting_memos ( + id, meeting_id, user_id, content, timestamp_ms, created_at + ) + SELECT row.id, row.meeting_id, row.user_id, row.content, row.timestamp_ms, row.created_at + FROM jsonb_to_recordset(datasets->'meeting_memos') AS row( + id uuid, meeting_id uuid, user_id uuid, content text, timestamp_ms bigint, + created_at timestamptz + ) + ON CONFLICT (id) DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'meeting_memos') - affected; + + INSERT INTO public.meeting_documents ( + id, meeting_id, user_id, template_type, title, content, prompt_used, + llm_model, llm_latency_ms, created_at, updated_at + ) + SELECT + row.id, row.meeting_id, row.user_id, row.template_type, row.title, + row.content, row.prompt_used, row.llm_model, row.llm_latency_ms, + row.created_at, row.updated_at + FROM jsonb_to_recordset(datasets->'meeting_documents') AS row( + id uuid, meeting_id uuid, user_id uuid, template_type text, title text, + content text, prompt_used text, llm_model text, llm_latency_ms integer, + created_at timestamptz, updated_at timestamptz + ) + ON CONFLICT (id) DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'meeting_documents') - affected; + + INSERT INTO public.custom_instructions ( + id, user_id, builtin_key, name, description, prompt, icon, sort_order, + revision, created_at, updated_at + ) + SELECT + row.id, row.user_id, NULL, row.name, row.description, row.prompt, + row.icon, row.sort_order, row.revision, row.created_at, row.updated_at + FROM jsonb_to_recordset(datasets->'custom_instructions') AS row( + id uuid, user_id uuid, builtin_key text, name text, description text, + prompt text, icon text, sort_order integer, revision bigint, + created_at timestamptz, updated_at timestamptz + ) + ON CONFLICT DO NOTHING; + GET DIAGNOSTICS affected = ROW_COUNT; + imported_count := imported_count + affected; + skipped_count := skipped_count + jsonb_array_length(datasets->'custom_instructions') - affected; + + INSERT INTO public.data_portability_imports ( + user_id, checksum, schema_version, source, imported_rows, skipped_rows + ) VALUES ( + current_user_id, payload_checksum, 1, payload_source, imported_count, skipped_count + ) + RETURNING created_at INTO imported_at; + + RETURN jsonb_build_object( + 'status', 'imported', + 'checksum', payload_checksum, + 'imported_rows', imported_count, + 'skipped_rows', skipped_count, + 'imported_at', imported_at + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.portability_v1_keys(text) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.portability_v1_project(text, jsonb) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.export_account_portability() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.export_account_portability() TO authenticated; +REVOKE ALL ON FUNCTION public.restore_account_portability(text, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.restore_account_portability(text, text) TO authenticated; + +COMMIT; diff --git a/server/supabase/tests/portability-restore-projection.integration.sql b/server/supabase/tests/portability-restore-projection.integration.sql new file mode 100644 index 0000000..fc91c28 --- /dev/null +++ b/server/supabase/tests/portability-restore-projection.integration.sql @@ -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;