sync_tombstones logs every owner-scoped delete on the synced tables so an offline device can apply it later; filtered Realtime channels never deliver DELETE, so the tombstone INSERT is also the live deletion signal. history.revision now follows content changes made without a revision (the desktop mirror), meeting_memos gains updated_at, and cursor indexes back keyset pulls. Templates get client-keyed upsert/delete wrappers over the revision-checked mobile RPCs. export_account_portability serialised whole rows while the v1 archive is an exact key set; columns added later made every account with meetings fail export. Each dataset is projected back onto its v1 keys.
512 lines
18 KiB
PL/PgSQL
512 lines
18 KiB
PL/PgSQL
-- Desktop ↔ mobile sync parity.
|
|
--
|
|
-- The desktop app keeps a local SQLite copy and mirrors it through
|
|
-- CloudSyncService. Mobile and web read and write Supabase directly. Before this
|
|
-- migration the mirror could not see deletions made on another device, could
|
|
-- not tell whether a desktop write changed a history row that mobile holds
|
|
-- with an expected revision, and could not pull edited meeting memos at all.
|
|
--
|
|
-- 1. sync_tombstones: every owner-scoped delete on a synced table leaves a
|
|
-- tombstone the owner can read, so an offline device applies it later.
|
|
-- 2. history.revision moves on any content change, including writes that do
|
|
-- not send a revision (desktop upserts), so mobile's optimistic
|
|
-- concurrency sees desktop edits.
|
|
-- 3. meeting_memos.updated_at so memo edits are pullable by cursor.
|
|
-- 4. (user_id, updated_at, id) cursor indexes for keyset pagination.
|
|
-- 5. Desktop-facing template upsert/delete RPCs keyed by the client id, built
|
|
-- on the existing revision-checked RPCs.
|
|
-- 6. Realtime for tombstones and devices (remote sign-out of a revoked
|
|
-- desktop).
|
|
|
|
BEGIN;
|
|
|
|
-- 1) Tombstones ----------------------------------------------------------------
|
|
-- No foreign key to auth.users on purpose: an account deletion cascades into
|
|
-- the synced tables and fires the tombstone trigger for a user row that is
|
|
-- already gone in the same statement. A foreign key would abort the account
|
|
-- deletion. Orphaned tombstones only carry ids and are pruned below.
|
|
CREATE TABLE IF NOT EXISTS public.sync_tombstones (
|
|
id bigserial PRIMARY KEY,
|
|
user_id uuid NOT NULL,
|
|
table_name text NOT NULL CHECK (table_name IN (
|
|
'history',
|
|
'dictionary',
|
|
'meetings',
|
|
'meeting_memos',
|
|
'meeting_documents',
|
|
'memo_tags',
|
|
'custom_instructions',
|
|
'user_templates'
|
|
)),
|
|
row_id uuid NOT NULL,
|
|
deleted_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_deleted
|
|
ON public.sync_tombstones(user_id, deleted_at, id);
|
|
|
|
ALTER TABLE public.sync_tombstones ENABLE ROW LEVEL SECURITY;
|
|
|
|
DROP POLICY IF EXISTS sync_tombstones_read_own ON public.sync_tombstones;
|
|
CREATE POLICY sync_tombstones_read_own
|
|
ON public.sync_tombstones FOR SELECT TO authenticated
|
|
USING (user_id = auth.uid());
|
|
|
|
REVOKE ALL ON TABLE public.sync_tombstones FROM PUBLIC, anon, authenticated;
|
|
GRANT SELECT ON TABLE public.sync_tombstones TO authenticated;
|
|
REVOKE ALL ON SEQUENCE public.sync_tombstones_id_seq FROM PUBLIC, anon, authenticated;
|
|
|
|
CREATE OR REPLACE FUNCTION public.record_sync_tombstone_v1()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public
|
|
AS $$
|
|
BEGIN
|
|
INSERT INTO public.sync_tombstones(user_id, table_name, row_id)
|
|
VALUES (OLD.user_id, TG_TABLE_NAME, OLD.id);
|
|
RETURN OLD;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.record_sync_tombstone_v1() FROM PUBLIC, anon, authenticated;
|
|
|
|
DO $$
|
|
DECLARE
|
|
t text;
|
|
BEGIN
|
|
FOREACH t IN ARRAY ARRAY[
|
|
'history',
|
|
'dictionary',
|
|
'meetings',
|
|
'meeting_memos',
|
|
'meeting_documents',
|
|
'memo_tags',
|
|
'custom_instructions',
|
|
'user_templates'
|
|
]
|
|
LOOP
|
|
EXECUTE format('DROP TRIGGER IF EXISTS record_sync_tombstone_v1 ON public.%I', t);
|
|
EXECUTE format(
|
|
'CREATE TRIGGER record_sync_tombstone_v1 AFTER DELETE ON public.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION public.record_sync_tombstone_v1()',
|
|
t
|
|
);
|
|
END LOOP;
|
|
END $$;
|
|
|
|
-- Tombstones older than the retention window are useless to a client whose
|
|
-- cursor is older still: such a client must do a full resync anyway. Rows of
|
|
-- deleted accounts are removed at the same time.
|
|
CREATE OR REPLACE FUNCTION public.prune_sync_tombstones_v1(p_retention interval DEFAULT interval '180 days')
|
|
RETURNS bigint
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
removed bigint;
|
|
BEGIN
|
|
DELETE FROM public.sync_tombstones AS tombstone
|
|
WHERE tombstone.deleted_at < clock_timestamp() - p_retention
|
|
OR NOT EXISTS (SELECT 1 FROM auth.users AS account WHERE account.id = tombstone.user_id);
|
|
GET DIAGNOSTICS removed = ROW_COUNT;
|
|
RETURN removed;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.prune_sync_tombstones_v1(interval) FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.prune_sync_tombstones_v1(interval) TO service_role;
|
|
|
|
-- 2) history.revision follows content changes -----------------------------------
|
|
-- Mobile updates with `revision = expected + 1` and a revision predicate, so a
|
|
-- write that already moved the revision is left alone. A write that did not
|
|
-- (the desktop mirror upserts full rows) bumps it only when user-visible
|
|
-- content changed, so a re-push of identical content never forces a false
|
|
-- conflict on another device.
|
|
CREATE OR REPLACE FUNCTION public.bump_history_revision_v1()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.revision IS NOT DISTINCT FROM OLD.revision
|
|
AND (
|
|
NEW.title,
|
|
NEW.original_text,
|
|
NEW.polished_text,
|
|
NEW.summary_text,
|
|
NEW.is_favorite,
|
|
NEW.status,
|
|
NEW.mode
|
|
) IS DISTINCT FROM (
|
|
OLD.title,
|
|
OLD.original_text,
|
|
OLD.polished_text,
|
|
OLD.summary_text,
|
|
OLD.is_favorite,
|
|
OLD.status,
|
|
OLD.mode
|
|
) THEN
|
|
NEW.revision := OLD.revision + 1;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS bump_history_revision_v1 ON public.history;
|
|
CREATE TRIGGER bump_history_revision_v1
|
|
BEFORE UPDATE ON public.history
|
|
FOR EACH ROW EXECUTE FUNCTION public.bump_history_revision_v1();
|
|
|
|
-- 3) meeting_memos.updated_at ----------------------------------------------------
|
|
ALTER TABLE public.meeting_memos ADD COLUMN IF NOT EXISTS updated_at timestamptz;
|
|
UPDATE public.meeting_memos SET updated_at = created_at WHERE updated_at IS NULL;
|
|
ALTER TABLE public.meeting_memos
|
|
ALTER COLUMN updated_at SET DEFAULT now(),
|
|
ALTER COLUMN updated_at SET NOT NULL;
|
|
|
|
DROP TRIGGER IF EXISTS set_updated_at_meeting_memos ON public.meeting_memos;
|
|
CREATE TRIGGER set_updated_at_meeting_memos
|
|
BEFORE UPDATE ON public.meeting_memos
|
|
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
|
|
|
|
-- 4) Cursor indexes --------------------------------------------------------------
|
|
CREATE INDEX IF NOT EXISTS idx_history_sync_cursor
|
|
ON public.history(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_dictionary_sync_cursor
|
|
ON public.dictionary(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_meetings_sync_cursor
|
|
ON public.meetings(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_meeting_memos_sync_cursor
|
|
ON public.meeting_memos(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_meeting_documents_sync_cursor
|
|
ON public.meeting_documents(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_custom_instructions_sync_cursor
|
|
ON public.custom_instructions(user_id, updated_at, id);
|
|
CREATE INDEX IF NOT EXISTS idx_user_templates_sync_cursor
|
|
ON public.user_templates(user_id, updated_at, id);
|
|
|
|
-- 5) Client-keyed template sync ---------------------------------------------------
|
|
-- The desktop creates templates offline under its own uuid. These wrappers keep
|
|
-- that id and reuse the revision-checked mobile RPCs for every update/delete
|
|
-- rule (builtin immutability, selection fallback), so there is one mutation
|
|
-- contract per template.
|
|
CREATE OR REPLACE FUNCTION public.sync_upsert_user_template_v1(
|
|
p_id uuid,
|
|
p_template_kind text,
|
|
p_name text,
|
|
p_description text DEFAULT NULL,
|
|
p_fields jsonb DEFAULT '[]'::jsonb,
|
|
p_output_format text DEFAULT NULL,
|
|
p_system_prompt text DEFAULT NULL
|
|
)
|
|
RETURNS public.user_templates
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
actor_id uuid := auth.uid();
|
|
current_template public.user_templates;
|
|
saved public.user_templates;
|
|
BEGIN
|
|
IF actor_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF p_id IS NULL THEN
|
|
RAISE EXCEPTION 'template_id_required' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_template_kind NOT IN ('dictation', 'meeting_document') THEN
|
|
RAISE EXCEPTION 'invalid_template_kind' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT * INTO current_template
|
|
FROM public.user_templates
|
|
WHERE id = p_id
|
|
FOR UPDATE;
|
|
|
|
IF current_template.id IS NOT NULL THEN
|
|
IF current_template.user_id <> actor_id THEN
|
|
RAISE EXCEPTION 'template_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
IF current_template.template_kind <> p_template_kind THEN
|
|
RAISE EXCEPTION 'template_kind_immutable' USING ERRCODE = '22023';
|
|
END IF;
|
|
saved := public.update_user_template_v1(
|
|
p_id,
|
|
current_template.revision,
|
|
p_name,
|
|
p_description,
|
|
p_fields,
|
|
p_output_format,
|
|
p_system_prompt
|
|
);
|
|
RETURN saved;
|
|
END IF;
|
|
|
|
INSERT INTO public.user_templates(
|
|
id, user_id, template_kind, name, description, fields, output_format,
|
|
template_type, system_prompt, is_builtin
|
|
)
|
|
VALUES (
|
|
p_id,
|
|
actor_id,
|
|
p_template_kind,
|
|
trim(p_name),
|
|
nullif(trim(p_description), ''),
|
|
CASE WHEN p_template_kind = 'dictation' THEN p_fields ELSE '[]'::jsonb END,
|
|
CASE WHEN p_template_kind = 'dictation' THEN p_output_format ELSE NULL END,
|
|
CASE WHEN p_template_kind = 'meeting_document' THEN 'custom' ELSE NULL END,
|
|
CASE WHEN p_template_kind = 'meeting_document' THEN p_system_prompt ELSE NULL END,
|
|
false
|
|
)
|
|
RETURNING * INTO saved;
|
|
|
|
RETURN saved;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.sync_upsert_user_template_v1(uuid, text, text, text, jsonb, text, text)
|
|
FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.sync_upsert_user_template_v1(uuid, text, text, text, jsonb, text, text)
|
|
TO authenticated;
|
|
|
|
-- Deleting a template another device already deleted is success.
|
|
CREATE OR REPLACE FUNCTION public.sync_delete_user_template_v1(p_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public, auth
|
|
AS $$
|
|
DECLARE
|
|
actor_id uuid := auth.uid();
|
|
current_revision bigint;
|
|
BEGIN
|
|
IF actor_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT revision INTO current_revision
|
|
FROM public.user_templates
|
|
WHERE id = p_id AND user_id = actor_id
|
|
FOR UPDATE;
|
|
|
|
IF current_revision IS NULL THEN
|
|
RETURN false;
|
|
END IF;
|
|
|
|
RETURN public.delete_user_template_v1(p_id, current_revision);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.sync_delete_user_template_v1(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.sync_delete_user_template_v1(uuid) TO authenticated;
|
|
|
|
-- 6) Realtime -----------------------------------------------------------------------
|
|
DO $$
|
|
DECLARE
|
|
t text;
|
|
BEGIN
|
|
FOREACH t IN ARRAY ARRAY['sync_tombstones', 'devices', 'memo_tags', 'custom_instructions', 'user_templates']
|
|
LOOP
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_publication_tables
|
|
WHERE pubname = 'supabase_realtime'
|
|
AND schemaname = 'public'
|
|
AND tablename = t
|
|
) THEN
|
|
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t);
|
|
END IF;
|
|
END LOOP;
|
|
END $$;
|
|
|
|
-- 7) Portable archive keeps its v1 shape ---------------------------------------
|
|
-- export_account_portability serialised whole rows with to_jsonb, while the
|
|
-- v1 archive contract (server restore and the mobile parser) accepts an exact
|
|
-- key set. Columns added after the archive was frozen (meetings creation
|
|
-- metadata, meeting_documents template/idempotency, meeting_memos.updated_at
|
|
-- above) made every archive with such rows fail validation. Project each
|
|
-- dataset back onto its 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(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) - ARRAY['audio_storage_key', 'language', 'attendees', 'template_id', 'creation_idempotency_key', 'creation_request_hash']::text[] 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) - 'updated_at' 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) - ARRAY['template_id', 'generation_idempotency_key']::text[] 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;
|
|
$$;
|
|
|
|
COMMIT;
|