d3ro-voice/server/supabase/migrations/20260821000021_atomic_data_portability.sql
2026-08-29 18:33:45 +09:00

1070 lines
46 KiB
PL/PgSQL

BEGIN;
CREATE TABLE public.data_portability_imports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
checksum text NOT NULL CHECK (checksum ~ '^[0-9a-f]{64}$'),
schema_version integer NOT NULL CHECK (schema_version = 1),
source text NOT NULL CHECK (source IN ('cloud', 'mobile', 'desktop-legacy', 'web-legacy')),
imported_rows integer NOT NULL CHECK (imported_rows >= 0),
skipped_rows integer NOT NULL CHECK (skipped_rows >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, checksum)
);
CREATE INDEX idx_data_portability_imports_user_created
ON public.data_portability_imports(user_id, created_at DESC);
ALTER TABLE public.data_portability_imports ENABLE ROW LEVEL SECURITY;
CREATE POLICY "data_portability_imports_read_own"
ON public.data_portability_imports
FOR SELECT
USING (user_id = auth.uid());
REVOKE ALL ON public.data_portability_imports FROM anon, authenticated;
GRANT SELECT ON public.data_portability_imports TO authenticated;
CREATE OR REPLACE FUNCTION public.portability_assert_exact_keys(
value jsonb,
allowed_keys text[],
field_label text
)
RETURNS void
LANGUAGE plpgsql
IMMUTABLE
SET search_path = ''
AS $$
DECLARE
actual_count integer;
invalid_key text;
BEGIN
IF jsonb_typeof(value) IS DISTINCT FROM 'object' THEN
RAISE EXCEPTION 'invalid_portability_object:%', field_label USING ERRCODE = '22023';
END IF;
SELECT count(*)::integer
INTO actual_count
FROM jsonb_object_keys(value);
IF actual_count <> cardinality(allowed_keys) THEN
RAISE EXCEPTION 'invalid_portability_fields:%', field_label USING ERRCODE = '22023';
END IF;
SELECT key
INTO invalid_key
FROM jsonb_object_keys(value) AS key
WHERE NOT (key = ANY (allowed_keys))
LIMIT 1;
IF invalid_key IS NOT NULL THEN
RAISE EXCEPTION 'unsupported_portability_field:%.%', field_label, invalid_key USING ERRCODE = '22023';
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.portability_assert_field_types(
value jsonb,
required_text_keys text[],
nullable_text_keys text[],
required_number_keys text[],
nullable_number_keys text[],
required_boolean_keys text[],
nullable_boolean_keys text[],
nullable_object_keys text[],
field_label text
)
RETURNS void
LANGUAGE plpgsql
IMMUTABLE
SET search_path = ''
AS $$
DECLARE
field_key text;
field_type text;
BEGIN
FOREACH field_key IN ARRAY required_text_keys LOOP
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'string' THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY nullable_text_keys LOOP
field_type := jsonb_typeof(value->field_key);
IF field_type IS NULL OR field_type <> ALL (ARRAY['string', 'null']) THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY required_number_keys LOOP
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'number' THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY nullable_number_keys LOOP
field_type := jsonb_typeof(value->field_key);
IF field_type IS NULL OR field_type <> ALL (ARRAY['number', 'null']) THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY required_boolean_keys LOOP
IF jsonb_typeof(value->field_key) IS DISTINCT FROM 'boolean' THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY nullable_boolean_keys LOOP
field_type := jsonb_typeof(value->field_key);
IF field_type IS NULL OR field_type <> ALL (ARRAY['boolean', 'null']) THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
FOREACH field_key IN ARRAY nullable_object_keys LOOP
field_type := jsonb_typeof(value->field_key);
IF field_type IS NULL OR field_type <> ALL (ARRAY['object', 'null']) THEN
RAISE EXCEPTION 'invalid_portability_type:%.%', field_label, field_key USING ERRCODE = '22023';
END IF;
END LOOP;
END;
$$;
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(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(to_jsonb(history) - 'audio_storage_key' 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(to_jsonb(meeting) - 'audio_storage_key' 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(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(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(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(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, ARRAY[
'id', 'user_id', 'word', 'pronunciation', 'category', 'usage_count',
'last_used_at', 'created_at', 'updated_at'
], '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, 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'
], '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, 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'
], '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, ARRAY[
'id', 'meeting_id', 'segment_index', 'timestamp_ms', 'duration_ms', 'text',
'speaker', 'edited', 'created_at', 'updated_at'
], '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, ARRAY[
'id', 'meeting_id', 'user_id', 'content', 'timestamp_ms', 'created_at'
], '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, ARRAY[
'id', 'meeting_id', 'user_id', 'template_type', 'title', 'content',
'prompt_used', 'llm_model', 'llm_latency_ms', 'created_at', 'updated_at'
], '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, ARRAY[
'id', 'user_id', 'builtin_key', 'name', 'description', 'prompt', 'icon',
'sort_order', 'revision', 'created_at', 'updated_at'
], '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.
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(datasets->'dictionary') AS incoming
JOIN public.dictionary AS existing ON existing.id = (incoming->>'id')::uuid
WHERE 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 to_jsonb(existing) - 'audio_storage_key' 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 to_jsonb(existing) - 'audio_storage_key' 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 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 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 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 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_assert_exact_keys(jsonb, text[], text) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.portability_assert_field_types(
jsonb, text[], text[], text[], text[], text[], text[], text[], text
) 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;