From cee3d938649c2374d4914bd12fa8feeb6a6031c2 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 22:29:37 +0000 Subject: [PATCH] fix: preserve divergent synchronized Drafts (Closes #853) --- frontend/unfiled-captures.js | 55 ++++++++++++++++++- frontend/unfiled-draft-sync.js | 90 +++++++++++++++++++++++++------- tests/test_unfiled_captures.py | 75 ++++++++++++++++++++++++++ tests/test_unfiled_draft_sync.py | 60 +++++++++++++++++++++ 4 files changed, 260 insertions(+), 20 deletions(-) diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index da3ff34..451ca0d 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -273,8 +273,61 @@ function createUnfiledCaptures({ return imported.length; } + async function reconcileRemote(drafts, login) { + const ownerLogin = String(login || '').trim(); + if (!ownerLogin || !Array.isArray(drafts)) return 0; + const existing = read(); + const retained = existing.filter(item => item.ownerLogin !== ownerLogin); + const reconciled = []; + const stagedEvidence = new Map(); + for (const remote of drafts) { + if (!remote || reconciled.length + retained.length >= maxItems) continue; + const evidence = Array.isArray(remote.evidence) ? remote.evidence : []; + if (evidence.length && !attachmentStore) { + throw new Error('The synchronized screenshots cannot be stored on this device.'); + } + const item = { + id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), + savedAt:Number(remote.saved_at), + ...(Array.isArray(remote.blockers) && remote.blockers.length ? { + blockers:remote.blockers, blockerCount:remote.blockers.length, + } : {}), + ...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}), + }; + if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue; + if (evidence.length) { + stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({ + filename:String(entry.filename), contentType:String(entry.content_type), + blob:decodeBytes(String(entry.data), String(entry.content_type)), + ...(entry.note ? {note:String(entry.note)} : {}), + }))}); + } + reconciled.push(item); + } + const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id)); + const affectedIds = new Set(stagedEvidence.keys()); + for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id); + const previousEvidence = new Map(); + for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id)); + try { + for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value); + for (const item of existing) { + if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) { + await attachmentStore?.delete(item.id); + } + } + write(reconciled.concat(retained)); + } catch (error) { + for (const [id, value] of previousEvidence) { + await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {}); + } + throw error; + } + return reconciled.length; + } + return {list, capacity, save, replaceOldest, discard, resume, completeResume, - exportOwned, mergeRemote, currentLogin:getCurrentLogin, + exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin, subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))}; } diff --git a/frontend/unfiled-draft-sync.js b/frontend/unfiled-draft-sync.js index 6e0dde2..eb63c7f 100644 --- a/frontend/unfiled-draft-sync.js +++ b/frontend/unfiled-draft-sync.js @@ -5,7 +5,10 @@ })(typeof globalThis !== 'undefined' ? globalThis : this, function () { function createUnfiledDraftSync(options, mountedFetch) { if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch); - const {captures, fetchJson, storage, getLogin = () => '', onState = () => {}} = options; + const { + captures, fetchJson, storage, getLogin = () => '', onState = () => {}, + createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)), + } = options; const key = 'stackchain.unfiled-draft-sync.v1'; let inFlight = null; let rerun = false; @@ -18,14 +21,15 @@ all:all && typeof all === 'object' ? all : {}, known:new Set(Array.isArray(state?.known) ? state.known : []), deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []), + base:state?.base && typeof state.base === 'object' ? state.base : {}, }; } catch (_error) { - return {all:{}, known:new Set(), deleted:new Set()}; + return {all:{}, known:new Set(), deleted:new Set(), base:{}}; } } function write(login, state) { - state.all[login] = {known:[...state.known], deleted:[...state.deleted]}; + state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base}; storage?.setItem(key, JSON.stringify(state.all)); } @@ -39,6 +43,33 @@ return JSON.stringify(left) === JSON.stringify(right); } + async function fingerprint(draft) { + if (!draft) return null; + const text = JSON.stringify(draft); + if (globalThis.crypto?.subtle && globalThis.TextEncoder) { + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); + } + let first = 2166136261, second = 3335557771; + for (let index = 0; index < text.length; index += 1) { + first = Math.imul(first ^ text.charCodeAt(index), 16777619); + second = Math.imul(second ^ text.charCodeAt(index), 2246822519); + } + return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16); + } + + async function reconcile(captures, drafts, owner) { + if (typeof captures.reconcileRemote === 'function') { + await captures.reconcileRemote(drafts, owner); + return; + } + const finalIds = new Set(drafts.map(item => item.id)); + for (const item of await captures.exportOwned(owner)) { + if (!finalIds.has(item.id)) await captures.discard(item.id); + } + await captures.mergeRemote(drafts, owner); + } + async function runSync(login) { const owner = String(login || '').trim(); if (!owner) return false; @@ -47,23 +78,39 @@ try { const remote = await fetchJson('api/v1/unfiled-drafts'); const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : []; - const remoteIds = new Set(remoteDrafts.map(item => item.id)); - const before = await captures.exportOwned(owner); - - for (const local of before) { - if (state.known.has(local.id) && !remoteIds.has(local.id) && !state.deleted.has(local.id)) { - await captures.discard(local.id); - } - } - await captures.mergeRemote( - remoteDrafts.filter(item => !state.deleted.has(item.id)), owner - ); const local = await captures.exportOwned(owner); const merged = []; - const byId = new Map(); - for (const item of remoteDrafts) if (!state.deleted.has(item.id)) byId.set(item.id, item); - for (const item of local) if (!state.deleted.has(item.id)) byId.set(item.id, item); - for (const item of byId.values()) merged.push(item); + const localById = new Map(local.map(item => [item.id, item])); + const remoteById = new Map(remoteDrafts.map(item => [item.id, item])); + const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]); + let preservedConflict = false; + const usedIds = new Set(ids); + for (const id of ids) { + const localDraft = state.deleted.has(id) ? null : localById.get(id); + const remoteDraft = remoteById.get(id); + const localHash = await fingerprint(localDraft); + const remoteHash = await fingerprint(remoteDraft); + const baseHash = state.base[id] || null; + const localChanged = localHash !== baseHash; + const remoteChanged = remoteHash !== baseHash; + if (localChanged && remoteChanged && localHash !== remoteHash) { + preservedConflict = true; + if (remoteDraft) merged.push(remoteDraft); + if (localDraft) { + let conflictId = String(createConflictId()).slice(0, 100); + for (let suffix = 2; usedIds.has(conflictId); suffix += 1) { + conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100); + } + usedIds.add(conflictId); + const suffix = ' (conflict copy)'; + merged.push({...localDraft, id:conflictId, + title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)}); + } + continue; + } + const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft)); + if (selected) merged.push(selected); + } merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at)); let finalSnapshot = remote; @@ -73,10 +120,15 @@ body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}), }); } + await reconcile(captures, finalSnapshot.drafts || [], owner); state.known = new Set((finalSnapshot.drafts || []).map(item => item.id)); state.deleted.clear(); + state.base = {}; + for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item); write(owner, state); - publish('ready', 'Drafts synced across devices.'); + publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict + ? 'Draft changed on two devices · both versions were saved.' + : 'Drafts synced across devices.'); return true; } catch (error) { if (error?.status === 409) { diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 8f33fb9..fd78590 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -145,6 +145,81 @@ function device() {{ assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}] +def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +const values=new Map(),blobs=new Map(); +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}}; +const captures=createUnfiledCaptures({{ + storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1, +}}); +(async()=>{{ + await captures.save({{title:'Field failure',body:'phone edit',attachment:{{ + filename:'phone.png',contentType:'image/png',blob:new Blob(['phone']) + }}}}); + await captures.reconcileRemote([{{ + id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{ + filename:'desktop.png',content_type:'image/png',note:'newer',data:'ZGVza3RvcA==' + }}] + }}], 'timmy'); + const resumed=await captures.resume('field','timmy'); + process.stdout.write(JSON.stringify({{ + body:resumed.body,filename:resumed.attachments[0].filename,note:resumed.attachments[0].note, + bytes:await resumed.attachments[0].blob.text(),count:captures.list().length, + }})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output == { + "body": "desktop edit", + "filename": "desktop.png", + "note": "newer", + "bytes": "desktop", + "count": 1, + } + + +def test_unfiled_captures_reconcile_rolls_back_evidence_when_metadata_write_fails(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +const values=new Map(),blobs=new Map();let failWrite=false; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>{{if(failWrite)throw new Error('quota');values.set(k,v)}}}}; +const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}}; +const captures=createUnfiledCaptures({{ + storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1, +}}); +(async()=>{{ + await captures.save({{title:'Field failure',body:'phone edit',attachment:{{ + filename:'phone.png',contentType:'image/png',blob:new Blob(['phone']) + }}}}); + failWrite=true;let error=''; + try {{ + await captures.reconcileRemote([{{ + id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{ + filename:'desktop.png',content_type:'image/png',data:'ZGVza3RvcA==' + }}] + }}], 'timmy'); + }} catch (caught) {{ error=caught.message; }} + const resumed=await captures.resume('field','timmy'); + const proof=resumed.attachments?.[0] || resumed.attachment; + process.stdout.write(JSON.stringify({{ + error,body:resumed.body,filename:proof.filename, + bytes:await proof.blob.text(), + }})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output == { + "error": "quota", + "body": "phone edit", + "filename": "phone.png", + "bytes": "phone", + } + + def test_unfiled_capture_restores_selected_blockers(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); diff --git a/tests/test_unfiled_draft_sync.py b/tests/test_unfiled_draft_sync.py index 876bf83..ab2d551 100644 --- a/tests/test_unfiled_draft_sync.py +++ b/tests/test_unfiled_draft_sync.py @@ -102,3 +102,63 @@ const sync=createSync({{ assert output["puts"] == 1 assert output["remote"]["drafts"][0]["id"] == "local" assert output["results"] == [True, True] + + +def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let remote={{revision:0,drafts:[]}}; +async function api(_url,init) {{ + if(!init) return JSON.parse(JSON.stringify(remote)); + const body=JSON.parse(init.body); + if(body.revision!==remote.revision) {{ + const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error; + }} + remote={{revision:remote.revision+1,drafts:body.drafts}}; + return JSON.parse(JSON.stringify(remote)); +}} +function device(initial=[]) {{ + let drafts=JSON.parse(JSON.stringify(initial)); + const values=new Map(), states=[]; + const captures={{ + exportOwned:async()=>JSON.parse(JSON.stringify(drafts)), + mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }}, + reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, + discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}}, + }}; + const sync=createSync({{ + captures,fetchJson:api, + storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}, + createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state), + }}); + return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}}; +}} +(async()=>{{ + const original={{id:'field',title:'Field failure',body:'original',saved_at:1}}; + const phone=device([original]); + await phone.sync.sync('timmy'); + const desktop=device(); + await desktop.sync.sync('timmy'); + desktop.edit('desktop edit',2); + await desktop.sync.sync('timmy'); + await phone.sync.sync('timmy'); + phone.edit('phone edit',3); + desktop.edit('desktop second edit',4); + await desktop.sync.sync('timmy'); + await phone.sync.sync('timmy'); + process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["remote"]["revision"] == 4 + assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [ + ("field", "desktop second edit"), + ("field-conflict-phone", "phone edit"), + ] + assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)" + assert output["phone"] == output["remote"]["drafts"] + assert output["states"][-1] == { + "status": "conflict-preserved", + "message": "Draft changed on two devices · both versions were saved.", + } -- 2.43.0