fix(sync): surface attachment/folder restore failures (audit 2.3)

applyRemoteSnapshot swallowed attachment + folder restore errors in silent
catch blocks. Count them (attFailed/folderFailed) and warn in a toast after
the sync summary. These don't abort the push (the entry synced, only its
attachment/folder didn't) unlike a failed entry import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-11 18:32:15 +01:00
parent b9eee0e15e
commit 92ed153bc0
2 changed files with 16 additions and 6 deletions
+15 -5
View File
@@ -273,8 +273,8 @@ async function buildSyncSnapshot() {
}
async function applyRemoteSnapshot(remote) {
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 };
let added = 0, updated = 0, deleted = 0, failed = 0;
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0, attFailed:0, folderFailed:0 };
let added = 0, updated = 0, deleted = 0, failed = 0, attFailed = 0, folderFailed = 0;
// Ensure state.entries reflects the live DB before we read updated_at
// for the resurrection arbitration below — a restore-then-sync must
@@ -356,7 +356,7 @@ async function applyRemoteSnapshot(remote) {
name: f.name, color: f.color || '', icon: f.icon || '',
}),
});
} catch (_) {}
} catch (_) { folderFailed++; }
}
await loadFolders();
}
@@ -410,7 +410,7 @@ async function applyRemoteSnapshot(remote) {
size_bytes: a.size_bytes || bytes.length,
}),
});
} catch (_) {}
} catch (_) { attFailed++; }
}
}
} catch (_) { failed++; }
@@ -432,7 +432,7 @@ async function applyRemoteSnapshot(remote) {
} catch (_) { failed++; }
}
}
return { added, updated, deleted, failed };
return { added, updated, deleted, failed, attFailed, folderFailed };
}
// Update the inline sync-status label next to the button. Empty string
@@ -623,5 +623,15 @@ async function runSyncNow(_attempt) {
'pushed ' + pushedCount + ' ' +
(pushedCount === 1 ? 'entry' : 'entries');
toast('Sync complete — ' + summary);
// Surface additive-restore gaps that used to be swallowed silently
// (§2.3). These don't risk overwriting the remote — the entry synced,
// only its attachment/folder didn't come down — so they warn, not abort.
if (merged.attFailed || merged.folderFailed) {
const parts = [];
if (merged.attFailed) parts.push(merged.attFailed + ' attachment(s)');
if (merged.folderFailed) parts.push(merged.folderFailed + ' folder(s)');
toast(parts.join(' · ') + ' failed to sync — retry sync', 'warning');
}
}
+1 -1
View File
@@ -268,7 +268,7 @@ test('merge: empty/invalid remote snapshot is a no-op', async () => {
const { T, db } = await freshMerge();
db.seedEntry({ uuid: 'u1' });
const res = await T.applyRemoteSnapshot(null);
assert.deepEqual({ ...res }, { added: 0, updated: 0, deleted: 0, failed: 0 });
assert.deepEqual({ ...res }, { added: 0, updated: 0, deleted: 0, failed: 0, attFailed: 0, folderFailed: 0 });
assert.equal(db.entries.length, 1);
});