Preserve both versions when synchronized Drafts diverge #854

Merged
timmy merged 1 commits from timmy/853-divergent-unfiled-drafts into main 2026-08-14 22:36:00 +00:00
4 changed files with 260 additions and 20 deletions

View File

@ -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))};
}

View File

@ -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) {

View File

@ -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))});

View File

@ -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.",
}