From f517eedffc1eee431969ee203423df470c33d11c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:00 +0900 Subject: [PATCH] fix(sync): let the server own updated_at on insert for cursor-pulled tables --- ...927000037_server_owned_sync_timestamps.sql | 68 ++++++ ...rver-owned-sync-timestamps.integration.sql | 197 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 server/supabase/migrations/20260927000037_server_owned_sync_timestamps.sql create mode 100644 server/supabase/tests/server-owned-sync-timestamps.integration.sql diff --git a/server/supabase/migrations/20260927000037_server_owned_sync_timestamps.sql b/server/supabase/migrations/20260927000037_server_owned_sync_timestamps.sql new file mode 100644 index 0000000..65670f4 --- /dev/null +++ b/server/supabase/migrations/20260927000037_server_owned_sync_timestamps.sql @@ -0,0 +1,68 @@ +-- Server-owned sync timestamps. +-- +-- The desktop mirror (SyncEngine.pullEntity) and the audio sync read every +-- synced table with a (updated_at, id) keyset cursor and rewind it by only a +-- minute. That cursor is only sound when updated_at is a server clock value +-- for every write. moddatetime already owns it on UPDATE, but an INSERT kept +-- whatever updated_at the client sent: +-- +-- a) a row recorded offline at T0 and pushed a day later landed behind every +-- other desktop's cursor and was never pulled there; +-- b) a desktop whose clock ran ahead inserted rows in the future, pushed the +-- other desktops' cursors ahead with them, and every server-stamped edit in +-- that window was then skipped for good; +-- c) restore_account_portability re-inserted archived updated_at values, so a +-- desktop that already applied the delete tombstones never got the rows +-- back. +-- +-- From here on the server stamps updated_at on INSERT as well. user_templates +-- had no UPDATE stamp at all (only its RPC set it), so it gets one too. The +-- value is now(), the same clock moddatetime uses, so INSERT and UPDATE share +-- one cursor timeline and the existing overlap window still covers late +-- commits. + +BEGIN; + +CREATE OR REPLACE FUNCTION public.stamp_sync_timestamp_v1() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + NEW.updated_at := pg_catalog.now(); + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.stamp_sync_timestamp_v1() FROM PUBLIC, anon, authenticated; + +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'custom_instructions', + 'user_templates', + 'knowledge_documents' + ] + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS stamp_sync_insert_v1 ON public.%I', t); + EXECUTE format( + 'CREATE TRIGGER stamp_sync_insert_v1 BEFORE INSERT ON public.%I ' + 'FOR EACH ROW EXECUTE FUNCTION public.stamp_sync_timestamp_v1()', + t + ); + END LOOP; +END $$; + +DROP TRIGGER IF EXISTS stamp_sync_update_v1 ON public.user_templates; +CREATE TRIGGER stamp_sync_update_v1 + BEFORE UPDATE ON public.user_templates + FOR EACH ROW EXECUTE FUNCTION public.stamp_sync_timestamp_v1(); + +COMMIT; diff --git a/server/supabase/tests/server-owned-sync-timestamps.integration.sql b/server/supabase/tests/server-owned-sync-timestamps.integration.sql new file mode 100644 index 0000000..09701bf --- /dev/null +++ b/server/supabase/tests/server-owned-sync-timestamps.integration.sql @@ -0,0 +1,197 @@ +\set ON_ERROR_STOP on + +-- Regression for 20260927000037_server_owned_sync_timestamps.sql. +-- Every synced table is pulled with a (updated_at, id) keyset cursor, so an +-- INSERT must not keep a client-chosen updated_at: a stale value (offline +-- recording, archive restore) lands behind other desktops' cursors, and a +-- future value (fast clock) pushes their cursors ahead. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +-- Catalog: every cursor-pulled table stamps updated_at on INSERT. +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'custom_instructions', + 'user_templates', + 'knowledge_documents' + ] + LOOP + PERFORM pg_temp.assert_true( + EXISTS ( + SELECT 1 + FROM pg_trigger AS trg + WHERE trg.tgrelid = format('public.%I', t)::regclass + AND trg.tgname = 'stamp_sync_insert_v1' + AND NOT trg.tgisinternal + AND trg.tgenabled <> 'D' + -- ROW (1) | BEFORE (2) | INSERT (4) + AND (trg.tgtype & 7) = 7 + ), + format('%s stamps updated_at before insert', t) + ); + END LOOP; +END $$; + +SELECT pg_temp.assert_true( + EXISTS ( + SELECT 1 + FROM pg_trigger AS trg + WHERE trg.tgrelid = 'public.user_templates'::regclass + AND trg.tgname = 'stamp_sync_update_v1' + -- ROW (1) | BEFORE (2) | UPDATE (16) + AND (trg.tgtype & 19) = 19 + ), + 'user_templates stamps updated_at before update' +); + +SELECT pg_temp.assert_true( + NOT has_function_privilege('authenticated', 'public.stamp_sync_timestamp_v1()', 'EXECUTE'), + 'clients cannot call the stamp function directly' +); + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES ( + '37000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', + 'sync-timestamps@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() +); + +-- Behaviour: client-supplied past and future timestamps are replaced by the +-- transaction clock on every table. +INSERT INTO public.history (id, user_id, original_text, duration, updated_at) +VALUES + ('37000000-0000-4000-8000-0000000000a1', '37000000-0000-4000-8000-000000000001', + 'recorded offline yesterday', 1.5, now() - interval '1 day'), + ('37000000-0000-4000-8000-0000000000a2', '37000000-0000-4000-8000-000000000001', + 'recorded on a fast clock', 1.5, now() + interval '2 hours'); + +INSERT INTO public.dictionary (id, user_id, word, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000b1', '37000000-0000-4000-8000-000000000001', + 'cursor', now() - interval '3 days'); + +INSERT INTO public.meetings (id, user_id, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000c1', '37000000-0000-4000-8000-000000000001', + now() + interval '5 hours'); + +INSERT INTO public.meeting_memos (id, meeting_id, user_id, content, timestamp_ms, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000d1', '37000000-0000-4000-8000-0000000000c1', + '37000000-0000-4000-8000-000000000001', 'memo', 1000, now() - interval '10 days'); + +INSERT INTO public.meeting_documents (id, meeting_id, user_id, template_type, title, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000e1', '37000000-0000-4000-8000-0000000000c1', + '37000000-0000-4000-8000-000000000001', 'minutes', 'minutes', now() - interval '1 year'); + +INSERT INTO public.custom_instructions (id, user_id, name, prompt, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000f1', '37000000-0000-4000-8000-000000000001', + 'sync timestamp fixture', 'prompt', now() + interval '1 day'); + +INSERT INTO public.user_templates ( + id, user_id, template_kind, name, template_type, system_prompt, updated_at +) VALUES ( + '37000000-0000-4000-8000-000000000101', '37000000-0000-4000-8000-000000000001', + 'meeting_document', 'sync timestamp fixture', 'custom', 'prompt', now() - interval '30 days' +); + +INSERT INTO public.knowledge_documents (id, user_id, title, updated_at) +VALUES ('37000000-0000-4000-8000-000000000111', '37000000-0000-4000-8000-000000000001', + 'knowledge', now() - interval '2 days'); + +DO $$ +DECLARE + owner uuid := '37000000-0000-4000-8000-000000000001'; + t text; + stale integer; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'history', + 'dictionary', + 'meetings', + 'meeting_memos', + 'meeting_documents', + 'custom_instructions', + 'user_templates', + 'knowledge_documents' + ] + LOOP + EXECUTE format( + 'SELECT count(*) FROM public.%I WHERE user_id = $1 AND id::text LIKE ''37000000-%%'' ' + 'AND updated_at IS DISTINCT FROM now()', + t + ) + INTO stale + USING owner; + PERFORM pg_temp.assert_true(stale = 0, format('%s insert keeps the server clock, not the client value', t)); + END LOOP; +END $$; + +-- The desktop pushes with PostgREST upsert (INSERT ... ON CONFLICT DO UPDATE). +-- A fresh row goes through the INSERT stamp, an existing row through the +-- UPDATE stamp; neither keeps the client timestamp. +INSERT INTO public.history (id, user_id, original_text, duration, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000a3', '37000000-0000-4000-8000-000000000001', + 'upserted while offline', 2.0, now() - interval '6 hours') +ON CONFLICT (id) DO UPDATE SET original_text = EXCLUDED.original_text, updated_at = EXCLUDED.updated_at; + +INSERT INTO public.history (id, user_id, original_text, duration, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000a1', '37000000-0000-4000-8000-000000000001', + 'edited offline', 1.5, now() - interval '6 hours') +ON CONFLICT (id) DO UPDATE SET original_text = EXCLUDED.original_text, updated_at = EXCLUDED.updated_at; + +SELECT pg_temp.assert_true( + (SELECT count(*) FROM public.history + WHERE id IN ('37000000-0000-4000-8000-0000000000a1', '37000000-0000-4000-8000-0000000000a3') + AND updated_at = now()) = 2, + 'upsert insert and upsert update both carry the server clock' +); + +-- Same stamp for a client write through RLS, as PostgREST performs it. +SET LOCAL ROLE authenticated; +SELECT set_config( + 'request.jwt.claims', + '{"sub":"37000000-0000-4000-8000-000000000001","role":"authenticated"}', + true +); + +INSERT INTO public.dictionary (id, user_id, word, updated_at) +VALUES ('37000000-0000-4000-8000-0000000000b2', '37000000-0000-4000-8000-000000000001', + 'skewed', now() + interval '2 hours'); + +SELECT pg_temp.assert_true( + (SELECT updated_at FROM public.dictionary WHERE id = '37000000-0000-4000-8000-0000000000b2') = now(), + 'an authenticated client cannot choose updated_at on insert' +); + +RESET ROLE; + +-- user_templates: a direct UPDATE (no RPC) also moves the cursor column. +UPDATE public.user_templates + SET name = 'renamed fixture', updated_at = now() - interval '9 days' + WHERE id = '37000000-0000-4000-8000-000000000101'; + +SELECT pg_temp.assert_true( + (SELECT updated_at FROM public.user_templates WHERE id = '37000000-0000-4000-8000-000000000101') = now(), + 'user_templates update carries the server clock' +); + +ROLLBACK;