From 639ce911be4d6fee6631750b53cd26843fe17757 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 05:31:14 +0000 Subject: [PATCH] fix: keep planning edits lossless across tabs (#373) --- frontend/dashboard.js | 4 +- frontend/later-sync.js | 80 ++++++++++++++++++----- frontend/outbox-coordinator.js | 2 +- frontend/service-worker.js | 2 +- frontend/today-sync.js | 69 ++++++++++++++----- tests/test_later_sync.py | 38 ++++++++++- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_outbox_coordinator.py | 4 +- tests/test_service_worker.py | 12 ++-- tests/test_today_sync.py | 25 ++++++- 11 files changed, 190 insertions(+), 50 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f455245..de8d9e8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -135,6 +135,7 @@ let activeMyWork = []; let laterMyWork = []; let todayMyWork = []; + const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, @@ -143,6 +144,7 @@ storage: localStorage, getLogin: () => planningOwnerLogin, fetchJson: fetchReviewJson, + coordinator: outboxCoordinator, onRemoteIds: ids => { if (!planningOwnerLogin || !todayWork.replace(ids)) return; refreshMyWorkView(); @@ -177,6 +179,7 @@ storage: localStorage, getLogin: () => planningOwnerLogin, fetchJson: fetchReviewJson, + coordinator: outboxCoordinator, onRemoteRecords: records => { if (!planningOwnerLogin || !laterWork.adopt(records)) return; refreshMyWorkView(); @@ -242,7 +245,6 @@ await registration.sync.register('stackchain-issue-outbox-v1'); }; } - const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, backgroundSync: backgroundIssueSync, diff --git a/frontend/later-sync.js b/frontend/later-sync.js index 3f708bc..d97db23 100644 --- a/frontend/later-sync.js +++ b/frontend/later-sync.js @@ -1,4 +1,4 @@ -function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, +function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { const prefix = 'stackchain.later-sync.v1.'; const migrationPrefix = 'stackchain.later-sync-migrated.v1.'; @@ -8,6 +8,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat let channelKey = ''; let retryTimer = null; let retryAttempt = 0; + const knownOperationKeys = new Set(); function cancelRetry() { if (retryTimer !== null) clearTimer?.(retryTimer); @@ -84,25 +85,52 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat function pending() { const storageKey = key(); if (!storageKey || !storage) return []; + const recordPrefix = storageKey + '.operation.'; try { - const value = JSON.parse(storage.getItem(storageKey) || '[]'); - return Array.isArray(value) ? value.filter(operation => - operation && typeof operation.operation_id === 'string' && - ['defer', 'restore'].includes(operation.action) && - typeof operation.item_id === 'string' && - (operation.action === 'restore' || typeof operation.wake_at === 'string') - ) : []; + const legacy = JSON.parse(storage.getItem(storageKey) || '[]'); + if (Array.isArray(legacy)) { + legacy.forEach((operation, index) => { + if (!operation?.operation_id) return; + const recordKey = recordPrefix + encodeURIComponent(operation.operation_id); + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index })); + knownOperationKeys.add(recordKey); + }); + if (legacy.length) storage.removeItem(storageKey); + } + const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix))); + for (let index = 0; index < Number(storage.length || 0); index += 1) { + const candidate = storage.key?.(index); + if (candidate?.startsWith(recordPrefix)) keys.add(candidate); + } + const records = [...keys].map(recordKey => { + const record = JSON.parse(storage.getItem(recordKey) || 'null'); + return record ? { ...record, recordKey } : null; + }).filter(record => record?.operation) + .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || + left.operation.operation_id.localeCompare(right.operation.operation_id)); + const latestByItem = new Map(); + records.forEach(record => latestByItem.set(record.operation.item_id, record)); + records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => { + storage.removeItem(record.recordKey); + knownOperationKeys.delete(record.recordKey); + }); + return records.filter(record => latestByItem.get(record.operation.item_id) === record) + .map(record => record.operation).filter(operation => + operation && typeof operation.operation_id === 'string' && + ['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' && + (operation.action === 'restore' || typeof operation.wake_at === 'string')); } catch (_error) { return []; } } - function save(operations) { + function removeOperation(operationId) { const storageKey = key(); if (!storageKey || !storage) return false; + const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId); try { - if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); - else storage.removeItem(storageKey); + storage.removeItem(recordKey); + knownOperationKeys.delete(recordKey); return true; } catch (_error) { return false; @@ -118,9 +146,19 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat function enqueue(action, itemId, wakeAt = null) { if (!['defer', 'restore'].includes(action) || !itemId || (action === 'defer' && typeof wakeAt !== 'string')) return false; - const operations = pending().filter(operation => operation.item_id !== itemId); - operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt }); - const saved = save(operations); + pending().filter(operation => operation.item_id === itemId) + .forEach(operation => removeOperation(operation.operation_id)); + const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt }; + const storageKey = key(); + if (!storageKey || !storage) return false; + const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); + let saved = false; + try { + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); + knownOperationKeys.add(recordKey); + saved = true; + coordinator?.notify('later'); + } catch (_error) { /* Report the persistence failure below. */ } onStatus?.(saved ? 'pending' : 'error'); return saved; } @@ -156,9 +194,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); - const remaining = pending(); - const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id); - if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) { + if (pending().some(candidate => candidate.operation_id === operation.operation_id) && + !removeOperation(operation.operation_id)) { throw new Error('Could not persist Later delivery receipt'); } operations = pending(); @@ -176,7 +213,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat } function flush() { - if (!flushing) flushing = run().finally(() => { flushing = null; }); + if (!flushing) { + const delivery = coordinator ? coordinator.runExclusive('later', run) : run(); + flushing = Promise.resolve(delivery).finally(() => { flushing = null; }); + } return flushing; } @@ -187,6 +227,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat ); } + coordinator?.subscribe(change => { + if (change.queue === 'later' && pending().length) flush(); + }); + return { enqueue, migrate, flush, pending, startLifecycle }; } diff --git a/frontend/outbox-coordinator.js b/frontend/outbox-coordinator.js index 557de5c..8ba8e8d 100644 --- a/frontend/outbox-coordinator.js +++ b/frontend/outbox-coordinator.js @@ -15,7 +15,7 @@ function createOutboxCoordinator({ let sequence = 0; function validChange(value) { - return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null; + return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null; } function publish(change) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index d0afc25..50187e3 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v52'; +const CACHE = 'stackchain-dashboard-shell-v53'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/frontend/today-sync.js b/frontend/today-sync.js index 334b16e..24ffeda 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -1,4 +1,4 @@ -function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, +function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { const prefix = 'stackchain.today-sync.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; @@ -8,6 +8,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, let channelKey = ''; let retryTimer = null; let retryAttempt = 0; + const knownOperationKeys = new Set(); function cancelRetry() { if (retryTimer !== null) clearTimer?.(retryTimer); @@ -79,24 +80,42 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, function pending() { const storageKey = key(); if (!storageKey || !storage) return []; + const recordPrefix = storageKey + '.operation.'; try { - const value = JSON.parse(storage.getItem(storageKey) || '[]'); - return Array.isArray(value) ? value.filter(operation => - operation && typeof operation.operation_id === 'string' && - ['add', 'remove', 'move'].includes(operation.action) && - typeof operation.item_id === 'string' - ) : []; + const legacy = JSON.parse(storage.getItem(storageKey) || '[]'); + if (Array.isArray(legacy)) { + legacy.forEach((operation, index) => { + if (!operation?.operation_id) return; + const recordKey = recordPrefix + encodeURIComponent(operation.operation_id); + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index })); + knownOperationKeys.add(recordKey); + }); + if (legacy.length) storage.removeItem(storageKey); + } + const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix))); + for (let index = 0; index < Number(storage.length || 0); index += 1) { + const candidate = storage.key?.(index); + if (candidate?.startsWith(recordPrefix)) keys.add(candidate); + } + return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null')) + .filter(record => record?.operation) + .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || + left.operation.operation_id.localeCompare(right.operation.operation_id)) + .map(record => record.operation) + .filter(operation => operation && typeof operation.operation_id === 'string' && + ['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string'); } catch (_error) { return []; } } - function save(operations) { + function removeOperation(operationId) { const storageKey = key(); if (!storageKey || !storage) return false; + const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId); try { - if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); - else storage.removeItem(storageKey); + storage.removeItem(recordKey); + knownOperationKeys.delete(recordKey); return true; } catch (_error) { return false; @@ -117,8 +136,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, onStatus?.('pending'); return true; } - operations.push({ operation_id: operationId(), action, item_id: itemId, direction }); - const saved = save(operations); + const operation = { operation_id: operationId(), action, item_id: itemId, direction }; + const storageKey = key(); + if (!storageKey || !storage) return false; + const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); + let saved = false; + try { + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() })); + knownOperationKeys.add(recordKey); + saved = true; + coordinator?.notify('today'); + } catch (_error) { /* Report the persistence failure below. */ } onStatus?.(saved ? 'pending' : 'error'); return saved; } @@ -156,17 +184,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, }); } catch (error) { if (error?.status !== 409) throw error; - const rejected = pending(); - if (!save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id))) { + if (!removeOperation(operation.operation_id)) { throw new Error('Could not persist rejected Today operation'); } hadConflict = true; operations = pending(); continue; } - const remaining = pending(); - const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id); - if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) { + if (pending().some(candidate => candidate.operation_id === operation.operation_id) && + !removeOperation(operation.operation_id)) { throw new Error('Could not persist Today delivery receipt'); } operations = pending(); @@ -184,7 +210,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, } function flush() { - if (!flushing) flushing = run().finally(() => { flushing = null; }); + if (!flushing) { + const delivery = coordinator ? coordinator.runExclusive('today', run) : run(); + flushing = Promise.resolve(delivery).finally(() => { flushing = null; }); + } return flushing; } @@ -195,6 +224,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, ); } + coordinator?.subscribe(change => { + if (change.queue === 'today' && pending().length) flush(); + }); + return { enqueue, migrate, flush, pending, startLifecycle }; } diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index c40be84..005e1f8 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js" LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js" +COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js" def run_node(script): @@ -19,6 +20,41 @@ def run_node(script): ) +def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain(): + script = f""" +const createLaterSync=require({json.dumps(str(LATER_SYNC))}); +const createCoordinator=require({json.dumps(str(COORDINATOR))}); +const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}}; +const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}}; +let sequence=0,releaseFirst;const delivered=[]; +const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body);delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}}); +const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z'); +(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})(); +""" + result = run_node(script) + assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"] + assert len(result["delivered"]) == 2 + assert result["pending"] == [] + assert not any(".operation." in key for key in result["keys"]) + + +def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent(): + script = f""" +const createLaterSync=require({json.dumps(str(LATER_SYNC))}); +const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const prefix='stackchain.later-sync.v1.timmy.operation.'; +values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}})); +values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}})); +const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(JSON.parse(options.body));return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}}); +(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})(); +""" + result = run_node(script) + assert [operation["operation_id"] for operation in result["delivered"]] == ["new"] + assert result["pending"] == [] + assert not any(".operation." in key for key in result["keys"]) + + def test_offline_deferral_replays_once_and_adopts_server_records(): script = f""" const createLaterSync = require({json.dumps(str(LATER_SYNC))}); @@ -233,5 +269,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 6e128ab..94d21fb 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v52" in worker + assert "stackchain-dashboard-shell-v53" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 2c0e7f3..8e944d9 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v52" in worker + assert "stackchain-dashboard-shell-v53" in worker diff --git a/tests/test_outbox_coordinator.py b/tests/test_outbox_coordinator.py index 25b9434..272c51b 100644 --- a/tests/test_outbox_coordinator.py +++ b/tests/test_outbox_coordinator.py @@ -110,13 +110,15 @@ const seen=[]; const unsubscribe=second.subscribe(change=>seen.push(change)); first.notify('issue'); first.notify('authored'); +first.notify('today'); +first.notify('later'); unsubscribe(); first.notify('issue'); process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}})); """ output = run_node(script) - assert [change["queue"] for change in output["seen"]] == ["issue", "authored"] + assert [change["queue"] for change in output["seen"]] == ["issue", "authored", "today", "later"] assert output["signal"]["queue"] == "issue" assert output["signal"]["tabId"] == "first" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c4ebfdf..1daf9bd 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 8b275ba..5c09615 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -4,6 +4,29 @@ from pathlib import Path TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js" +COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js" + + +def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const createCoordinator = require({json.dumps(str(COORDINATOR))}); +const values = new Map(); +const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null, + getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const channels=[]; const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}}; +const held=new Set(); const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}}; +let sequence=0,releaseFirst;const delivered=[]; +const make=tab=>createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}), + fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,ids:[]}};const operation=JSON.parse(options.body);delivered.push(operation.operation_id);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,ids:delivered}}}},onRemoteIds:()=>{{}},onStatus:()=>{{}}}}); +const first=make('first'),second=make('second');first.enqueue('add','issue:r:1:'); +(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('add','issue:r:2:');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})(); +""" + result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout) + assert sorted(result["delivered"]) == ["first-1", "second-2"] + assert len(result["delivered"]) == 2 + assert result["pending"] == [] + assert not any(".operation." in key for key in result["keys"]) def test_operations_enqueued_during_an_inflight_flush_are_drained_before_it_settles(): @@ -63,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v52" in source + assert "stackchain-dashboard-shell-v53" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0