Preserve both versions when synchronized Drafts diverge #854
|
|
@ -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))};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user