Preserve mobile Drafts when attachment cleanup fails #626
|
|
@ -57,20 +57,36 @@ function createUnfiledCaptures({
|
|||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const finish = () => {
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (removed?.hasAttachment) return Promise.resolve(attachmentStore?.delete(removed.id)).then(() => item);
|
||||
return item;
|
||||
};
|
||||
if (!prepared.hasAttachment) return finish();
|
||||
return Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
})).then(finish);
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
|
|
@ -94,10 +110,21 @@ function createUnfiledCaptures({
|
|||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) return true;
|
||||
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,131 @@ const attachmentStore = {{
|
|||
}
|
||||
|
||||
|
||||
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => {{
|
||||
if (id === '1') throw new Error('IndexedDB delete failed');
|
||||
blobs.delete(id);
|
||||
}},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>String(++id), now:()=>id,
|
||||
}});
|
||||
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
|
||||
let error = '';
|
||||
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
|
||||
catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume(original.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
|
||||
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Original"],
|
||||
"blobs": ["1"],
|
||||
"resumedTitle": "Original",
|
||||
"resumedFilename": "original.png",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Keep this"],
|
||||
"resumedTitle": "Keep this",
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_discard_metadata_write_failure_restores_attachment_and_draft():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
let failWrites = false;
|
||||
const storage = {{
|
||||
getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
|
||||
}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => blobs.delete(id),
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
failWrites = true;
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
failWrites = false;
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "Storage write failed",
|
||||
"titles": ["Keep this"],
|
||||
"blobIds": ["capture-1"],
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user