fix: preserve Drafts when cleanup fails (Closes #625)
This commit is contained in:
parent
89d153d6a5
commit
285fdff869
|
|
@ -57,20 +57,36 @@ function createUnfiledCaptures({
|
||||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||||
};
|
};
|
||||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||||
const finish = () => {
|
const writeItems = () => {
|
||||||
try { write(items); }
|
try { write(items); }
|
||||||
catch (error) {
|
catch (error) {
|
||||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (removed?.hasAttachment) return Promise.resolve(attachmentStore?.delete(removed.id)).then(() => item);
|
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
if (!prepared.hasAttachment) return finish();
|
const stage = prepared.hasAttachment
|
||||||
return Promise.resolve(attachmentStore.put(item.id, {
|
? Promise.resolve(attachmentStore.put(item.id, {
|
||||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||||
})).then(finish);
|
}))
|
||||||
|
: 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) {
|
function save(note) {
|
||||||
|
|
@ -94,10 +110,21 @@ function createUnfiledCaptures({
|
||||||
const items = read();
|
const items = read();
|
||||||
const remaining = items.filter(item => item.id !== id);
|
const remaining = items.filter(item => item.id !== id);
|
||||||
if (remaining.length === items.length) return false;
|
if (remaining.length === items.length) return false;
|
||||||
write(remaining);
|
|
||||||
const removed = items.find(item => item.id === id);
|
const removed = items.find(item => item.id === id);
|
||||||
if (!removed?.hasAttachment) return true;
|
if (!removed?.hasAttachment) {
|
||||||
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
|
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) {
|
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():
|
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user