diff --git a/frontend/index.html b/frontend/index.html index d832088..087d474 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -252,6 +252,7 @@
+
Today is saved on this device.
Later is saved on this device.
diff --git a/frontend/unfiled-draft-sync.js b/frontend/unfiled-draft-sync.js index eb63c7f..e8b1b05 100644 --- a/frontend/unfiled-draft-sync.js +++ b/frontend/unfiled-draft-sync.js @@ -6,12 +6,13 @@ function createUnfiledDraftSync(options, mountedFetch) { if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch); const { - captures, fetchJson, storage, getLogin = () => '', onState = () => {}, + captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2, createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)), } = options; const key = 'stackchain.unfiled-draft-sync.v1'; let inFlight = null; let rerun = false; + let queuedOwner = ''; function read(login) { try { @@ -33,8 +34,8 @@ storage?.setItem(key, JSON.stringify(state.all)); } - function publish(status, message) { - onState({status, ...(message ? {message} : {})}); + function publish(status, message, details = {}) { + onState({status, ...(message ? {message} : {}), ...details}); const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status'); if (message && visibleStatus) visibleStatus.textContent = message; } @@ -131,9 +132,7 @@ : 'Drafts synced across devices.'); return true; } catch (error) { - if (error?.status === 409) { - publish('conflict', 'Drafts changed on another device. Sync again to combine them.'); - } else { + if (error?.status !== 409) { publish('pending', 'Saved on this device ยท sync pending'); } throw error; @@ -145,13 +144,32 @@ if (!owner) return Promise.resolve(false); if (inFlight) { rerun = true; + queuedOwner = owner; return inFlight; } inFlight = (async () => { let result = false; + let conflictRetries = 0; + let activeOwner = owner; do { rerun = false; - result = await runSync(owner); + try { + result = await runSync(activeOwner); + } catch (error) { + if (error?.status !== 409) throw error; + if (conflictRetries >= maxConflictRetries) { + publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true}); + throw error; + } + conflictRetries += 1; + rerun = true; + } + if (queuedOwner) { + if (queuedOwner !== activeOwner) conflictRetries = 0; + activeOwner = queuedOwner; + queuedOwner = ''; + rerun = true; + } } while (rerun); return result; })().finally(() => { inFlight = null; }); @@ -168,16 +186,29 @@ return sync(owner); } + function retry() { + return sync(getLogin()); + } + captures.subscribe?.((id, removed) => { const login = getLogin(); if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {}); }); - return {sync, remove}; + return {sync, remove, retry}; } createUnfiledDraftSync.mount = (captures, fetchJson) => { const getLogin = captures.currentLogin; - const controller = createUnfiledDraftSync({captures, fetchJson, storage:globalThis.localStorage, getLogin}); + const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync'); + const controller = createUnfiledDraftSync({ + captures, fetchJson, storage:globalThis.localStorage, getLogin, + onState:state => { if (retryButton) retryButton.hidden = !state.retryable; }, + }); + retryButton?.addEventListener?.('click', async () => { + retryButton.disabled = true; + try { await controller.retry(); } catch (_error) { /* state remains actionable */ } + finally { retryButton.disabled = false; } + }); const connect = () => { const login = getLogin(); if (login) void controller.sync(login).catch(() => {}); diff --git a/tests/test_unfiled_draft_sync.py b/tests/test_unfiled_draft_sync.py index ab2d551..a434e5e 100644 --- a/tests/test_unfiled_draft_sync.py +++ b/tests/test_unfiled_draft_sync.py @@ -4,6 +4,7 @@ from pathlib import Path SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js" +HTML = Path(__file__).parents[1] / "frontend" / "index.html" def run_node(script: str): @@ -58,25 +59,138 @@ function device(initial=[]) {{ assert output["desktopStates"][-1] == "ready" -def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft(): +def test_unfiled_draft_sync_rebases_and_retries_a_revision_race_without_user_action(): script = f""" const createSync=require({json.dumps(str(SYNC))}); -let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}]; -const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}}; +let calls=0, puts=0;const states=[]; +let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}]; +let remote={{revision:1,drafts:[]}}; const sync=createSync({{ - captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}}, + captures:{{ + exportOwned:async()=>drafts, + reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, + discard:async()=>true, + }}, storage:{{getItem:()=>null,setItem:()=>{{}}}}, - fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}}, + fetchJson:async(_url,init)=>{{ + calls++; + if(!init)return JSON.parse(JSON.stringify(remote)); + puts++; + const body=JSON.parse(init.body); + if(puts===1){{ + remote={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}}; + const error=new Error('conflict');error.status=409; + error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(remote))}}}}; + throw error; + }} + if(body.revision!==remote.revision)throw new Error('stale retry'); + remote={{revision:remote.revision+1,drafts:body.drafts}}; + return JSON.parse(JSON.stringify(remote)); + }}, onState:state=>states.push(state), }}); -(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})(); +(async()=>{{ + const result=await sync.sync('timmy'); + process.stdout.write(JSON.stringify({{calls,puts,states,result,drafts,remote}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) - assert output["calls"] == 2 - assert output["states"][-1]["status"] == "conflict" - assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them." - assert output["drafts"][0]["id"] == "local" + assert output["result"] is True + assert output["puts"] == 2 + assert output["calls"] == 4 + assert [item["id"] for item in output["remote"]["drafts"]] == ["remote", "local"] + assert output["drafts"] == output["remote"]["drafts"] + assert output["states"][-1]["status"] == "ready" + + +def test_unfiled_draft_sync_bounds_conflicts_and_exposes_a_retry_path(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let contended=true, puts=0; +let drafts=[{{id:'local',title:'Local',body:'kept',saved_at:1}}]; +let remote={{revision:1,drafts:[]}};const states=[]; +const controller=createSync({{ + captures:{{ + exportOwned:async()=>JSON.parse(JSON.stringify(drafts)), + reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, + discard:async()=>true, + }}, + storage:{{getItem:()=>null,setItem:()=>{{}}}},getLogin:()=> 'timmy',maxConflictRetries:1, + fetchJson:async(_url,init)=>{{ + if(!init)return JSON.parse(JSON.stringify(remote)); + puts++;const body=JSON.parse(init.body); + if(contended){{ + remote={{revision:remote.revision+1,drafts:remote.drafts}}; + const error=new Error('conflict');error.status=409;throw error; + }} + remote={{revision:remote.revision+1,drafts:body.drafts}}; + return JSON.parse(JSON.stringify(remote)); + }}, + onState:state=>states.push(state), +}}); +(async()=>{{ + let rejected=false; + try{{await controller.sync('timmy')}}catch(error){{rejected=error.status===409}} + const afterExhaustion={{puts,rejected,state:states.at(-1),drafts:JSON.parse(JSON.stringify(drafts))}}; + contended=false; + const retried=await controller.retry(); + process.stdout.write(JSON.stringify({{afterExhaustion,retried,puts,state:states.at(-1),drafts,remote}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["afterExhaustion"]["puts"] == 2 + assert output["afterExhaustion"]["rejected"] is True + assert output["afterExhaustion"]["state"] == { + "status": "conflict", + "message": "Draft sync needs attention. Retry when your devices finish saving.", + "retryable": True, + } + assert output["afterExhaustion"]["drafts"][0]["body"] == "kept" + assert output["retried"] is True + assert output["state"]["status"] == "ready" + assert output["drafts"] == output["remote"]["drafts"] + + +def test_mounted_unfiled_draft_sync_shows_a_working_retry_action_after_exhaustion(): + assert 'id="retry-unfiled-draft-sync"' in HTML.read_text() + script = f""" +let clickHandler;const status={{textContent:''}}; +const button={{hidden:true,disabled:false,textContent:'Retry Draft sync',addEventListener:(_name,handler)=>clickHandler=handler}}; +global.document={{querySelector:selector=>selector==='#retry-unfiled-draft-sync'?button:(selector==='#my-work-action-status'?status:null)}}; +global.localStorage={{getItem:()=>null,setItem:()=>{{}}}}; +global.setTimeout=()=>0;global.addEventListener=()=>{{}}; +const createSync=require({json.dumps(str(SYNC))}); +let contended=true;let remote={{revision:0,drafts:[]}}; +const captures={{ + currentLogin:()=> 'timmy',exportOwned:async()=>[{{id:'local',title:'Local',body:'',saved_at:1}}], + reconcileRemote:async()=>{{}},discard:async()=>true, +}}; +const api=async(_url,init)=>{{ + if(!init)return remote; + if(contended){{remote={{revision:remote.revision+1,drafts:[]}};const error=new Error('conflict');error.status=409;throw error}} + const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return remote; +}}; +(async()=>{{ + const controller=createSync.mount(captures,api); + try{{await controller.sync('timmy')}}catch(_error){{}} + const exhausted={{hidden:button.hidden,label:button.textContent,status:status.textContent}}; + contended=false; + await clickHandler(); + process.stdout.write(JSON.stringify({{exhausted,after:{{hidden:button.hidden,disabled:button.disabled,status:status.textContent}},remote}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["exhausted"] == { + "hidden": False, + "label": "Retry Draft sync", + "status": "Draft sync needs attention. Retry when your devices finish saving.", + } + assert output["after"]["hidden"] is True + assert output["after"]["disabled"] is False + assert [draft["id"] for draft in output["remote"]["drafts"]] == ["local"] def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict(): @@ -104,6 +218,33 @@ const sync=createSync({{ assert output["results"] == [True, True] +def test_unfiled_draft_sync_does_not_drop_an_account_transition_during_an_active_sync(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let remote={{revision:0,drafts:[]}}, release; +const gate=new Promise(resolve=>release=resolve);let firstGet=true;const exported=[]; +const api=async(_url,init)=>{{ + if(!init){{if(firstGet){{firstGet=false;await gate}}return JSON.parse(JSON.stringify(remote))}} + const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return JSON.parse(JSON.stringify(remote)); +}}; +const captures={{ + exportOwned:async owner=>{{exported.push(owner);return [{{id:owner,title:owner,body:'',saved_at:owner==='alice'?1:2}}]}}, + reconcileRemote:async()=>{{}},discard:async()=>true, +}}; +const sync=createSync({{captures,fetchJson:api,storage:{{getItem:()=>null,setItem:()=>{{}}}}}}); +(async()=>{{ + const alice=sync.sync('alice');const bob=sync.sync('bob');release(); + const results=await Promise.all([alice,bob]); + process.stdout.write(JSON.stringify({{exported,results,remote}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["exported"] == ["alice", "bob"] + assert output["results"] == [True, True] + assert [draft["id"] for draft in output["remote"]["drafts"]] == ["bob", "alice"] + + def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy(): script = f""" const createSync=require({json.dumps(str(SYNC))});