d3ro-voice/server/supabase/migrations/20260929000002_portability_restore_projection.sql

1025 lines
45 KiB
PL/PgSQL

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