Skip to content

Commit 392294e

Browse files
authored
fix: prevent bookmark additions/deletions from reverting on next sync (#7)
Four interrelated bugs caused local changes (adds/deletes) to be reverted by the next auto-sync after the first successful sync: 1. applyTombstonesToLocal did not set isSyncDrivenChange, causing cloud-driven deletions to be tracked as locally modified and triggering unnecessary follow-up syncs via pendingSyncNeeded. 2. forcePush/forcePull did not update lastSyncTime (tombstone safeguard), lastCloudChecksum (baseline for next sync), or clear locallyModifiedBookmarkIds. If the first sync was a force push/pull, all subsequent syncs had stale metadata. forcePush also did not send local tombstones to the cloud. 3. Server-side dateAdded normalization fell back to Date.now() for falsy values, making bookmarks appear brand-new and causing the tombstone filter (bookmarkDate > tombstoneDate) to incorrectly keep bookmarks that should have been deleted. 4. Server-side replace mode redundantly re-applied tombstone filtering on data the extension had already filtered. Combined with the Date.now() fallback, this could silently strip or preserve bookmarks incorrectly.
1 parent 31463af commit 392294e

3 files changed

Lines changed: 144 additions & 52 deletions

File tree

apps/extension/src/background/index.js

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,27 +2046,36 @@ async function applyTombstonesToLocal(tombstones, localBookmarks) {
20462046
}
20472047
console.log(`[MarkSyncr] Found ${matchCount} local bookmarks that match tombstones`);
20482048

2049-
for (const bookmark of localBookmarks) {
2050-
if (tombstonedUrls.has(bookmark.url)) {
2051-
// Skip if this bookmark was locally modified (e.g., user re-added it after deletion)
2052-
// The user's intent to have this bookmark takes priority over the cloud tombstone
2053-
if (locallyModifiedBookmarkIds.has(bookmark.id)) {
2054-
console.log(
2055-
`[MarkSyncr] 🏠 Skipping tombstone for locally modified bookmark: ${bookmark.url}`
2056-
);
2057-
continue;
2058-
}
2049+
// Set isSyncDrivenChange so that onRemoved doesn't track these cloud-driven
2050+
// deletions as locally modified. Without this flag, each tombstone-driven
2051+
// removal was incorrectly added to locallyModifiedBookmarkIds and triggered
2052+
// an unnecessary follow-up sync via pendingSyncNeeded.
2053+
isSyncDrivenChange = true;
2054+
try {
2055+
for (const bookmark of localBookmarks) {
2056+
if (tombstonedUrls.has(bookmark.url)) {
2057+
// Skip if this bookmark was locally modified (e.g., user re-added it after deletion)
2058+
// The user's intent to have this bookmark takes priority over the cloud tombstone
2059+
if (locallyModifiedBookmarkIds.has(bookmark.id)) {
2060+
console.log(
2061+
`[MarkSyncr] 🏠 Skipping tombstone for locally modified bookmark: ${bookmark.url}`
2062+
);
2063+
continue;
2064+
}
20592065

2060-
console.log(`[MarkSyncr] Tombstone match found for: ${bookmark.url}`);
2066+
console.log(`[MarkSyncr] Tombstone match found for: ${bookmark.url}`);
20612067

2062-
try {
2063-
await browser.bookmarks.remove(bookmark.id);
2064-
deletedCount++;
2065-
console.log(`[MarkSyncr] ✓ Deleted local bookmark (tombstoned): ${bookmark.url}`);
2066-
} catch (err) {
2067-
console.warn(`[MarkSyncr] ✗ Failed to delete tombstoned bookmark: ${bookmark.url}`, err);
2068+
try {
2069+
await browser.bookmarks.remove(bookmark.id);
2070+
deletedCount++;
2071+
console.log(`[MarkSyncr] ✓ Deleted local bookmark (tombstoned): ${bookmark.url}`);
2072+
} catch (err) {
2073+
console.warn(`[MarkSyncr] ✗ Failed to delete tombstoned bookmark: ${bookmark.url}`, err);
2074+
}
20682075
}
20692076
}
2077+
} finally {
2078+
isSyncDrivenChange = false;
20702079
}
20712080

20722081
console.log(`[MarkSyncr] applyTombstonesToLocal complete: deleted ${deletedCount} bookmarks`);
@@ -2911,9 +2920,13 @@ async function forcePush() {
29112920
// Pass the bookmark count as synced count since we're pushing all to cloud
29122921
const stats = countBookmarks(bookmarkTree, flatBookmarks.filter((b) => b.url).length);
29132922

2923+
// Get current local tombstones to push alongside bookmarks
2924+
// Force push should include tombstones so the cloud knows about deletions
2925+
const localTombstones = await getTombstones();
2926+
29142927
// Force push to cloud (overwrite)
29152928
console.log(`[MarkSyncr] Force pushing ${flatBookmarks.length} bookmarks to cloud...`);
2916-
const syncResult = await syncBookmarksToCloud(flatBookmarks, detectBrowser());
2929+
const syncResult = await syncBookmarksToCloud(flatBookmarks, detectBrowser(), localTombstones);
29172930
console.log('[MarkSyncr] Force push sync result:', syncResult);
29182931

29192932
// Save version with force push marker (non-blocking)
@@ -2930,10 +2943,25 @@ async function forcePush() {
29302943
console.warn('[MarkSyncr] Force push version save failed (continuing):', versionErr.message);
29312944
}
29322945

2933-
// Update last sync time
2946+
// Update last sync time (both ISO string for display and timestamp for tombstone safeguard)
2947+
const syncTimestamp = Date.now();
29342948
await browser.storage.local.set({
2935-
lastSync: new Date().toISOString(),
2949+
lastSync: new Date(syncTimestamp).toISOString(),
29362950
});
2951+
await storeLastSyncTime(syncTimestamp);
2952+
2953+
// Store the cloud checksum so the next performSync knows the baseline
2954+
if (syncResult?.checksum) {
2955+
await storeLastCloudChecksum(syncResult.checksum);
2956+
}
2957+
2958+
// Clear locally modified tracking — force push overwrites cloud, so everything is in sync
2959+
if (saveModifiedIdsTimeout) {
2960+
clearTimeout(saveModifiedIdsTimeout);
2961+
saveModifiedIdsTimeout = null;
2962+
}
2963+
locallyModifiedBookmarkIds.clear();
2964+
await saveLocallyModifiedIds();
29372965

29382966
return { success: true, stats, message: 'Successfully force pushed bookmarks to cloud' };
29392967
} catch (err) {
@@ -3096,17 +3124,27 @@ async function forcePull() {
30963124
`[MarkSyncr] Force Pull: Created ${importedCount} bookmarks, ${foldersCreated} folders`
30973125
);
30983126

3099-
// Update last sync time
3127+
// Update last sync time (both ISO string for display and timestamp for tombstone safeguard)
3128+
const syncTimestamp = Date.now();
31003129
await browser.storage.local.set({
3101-
lastSync: new Date().toISOString(),
3130+
lastSync: new Date(syncTimestamp).toISOString(),
31023131
});
3132+
await storeLastSyncTime(syncTimestamp);
31033133

31043134
const stats = { total: importedCount, folders: foldersCreated, synced: importedCount };
31053135

31063136
console.log(
31073137
`[MarkSyncr] Force Pull complete: ${importedCount} bookmarks, ${foldersCreated} folders`
31083138
);
31093139

3140+
// Clear locally modified tracking — force pull overwrites local, so everything is in sync
3141+
if (saveModifiedIdsTimeout) {
3142+
clearTimeout(saveModifiedIdsTimeout);
3143+
saveModifiedIdsTimeout = null;
3144+
}
3145+
locallyModifiedBookmarkIds.clear();
3146+
await saveLocallyModifiedIds();
3147+
31103148
// CRITICAL: After Force Pull, sync the pulled bookmarks back to cloud_bookmarks
31113149
// This ensures the next regular sync sees matching data and doesn't revert changes.
31123150
// Without this, the next sync would compare local bookmarks (from version history)

apps/web/__tests__/bookmarks.test.js

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3023,9 +3023,36 @@ describe('Server-Side Tombstone Cleanup', () => {
30233023

30243024
// Existing cloud data has a DIFFERENT order
30253025
const existingCloudBookmarks = [
3026-
{ id: '1', url: 'https://first.com', title: 'First', index: 0, type: 'bookmark', folderPath: '', dateAdded: 1000, source: 'browser' },
3027-
{ id: '2', url: 'https://second.com', title: 'Second', index: 1, type: 'bookmark', folderPath: '', dateAdded: 1000, source: 'browser' },
3028-
{ id: '3', url: 'https://third.com', title: 'Third', index: 2, type: 'bookmark', folderPath: '', dateAdded: 1000, source: 'browser' },
3026+
{
3027+
id: '1',
3028+
url: 'https://first.com',
3029+
title: 'First',
3030+
index: 0,
3031+
type: 'bookmark',
3032+
folderPath: '',
3033+
dateAdded: 1000,
3034+
source: 'browser',
3035+
},
3036+
{
3037+
id: '2',
3038+
url: 'https://second.com',
3039+
title: 'Second',
3040+
index: 1,
3041+
type: 'bookmark',
3042+
folderPath: '',
3043+
dateAdded: 1000,
3044+
source: 'browser',
3045+
},
3046+
{
3047+
id: '3',
3048+
url: 'https://third.com',
3049+
title: 'Third',
3050+
index: 2,
3051+
type: 'bookmark',
3052+
folderPath: '',
3053+
dateAdded: 1000,
3054+
source: 'browser',
3055+
},
30293056
];
30303057

30313058
let upsertedData = null;
@@ -3084,16 +3111,16 @@ describe('Server-Side Tombstone Cleanup', () => {
30843111
expect(storedBookmarks[2].url).toBe('https://second.com');
30853112
});
30863113

3087-
it('should still apply tombstone filtering when replace=true', async () => {
3114+
it('should trust incoming bookmarks as-is when replace=true (extension handles tombstone filtering)', async () => {
30883115
const now = Date.now();
3116+
// In replace mode, the extension has already performed tombstone filtering
3117+
// client-side. The server should store whatever the extension sends.
30893118
const bookmarksToSync = [
30903119
{ id: '1', url: 'https://keep.com', title: 'Keep', dateAdded: now - 10000 },
3091-
{ id: '2', url: 'https://deleted.com', title: 'Deleted', dateAdded: now - 10000 },
3120+
{ id: '2', url: 'https://also-keep.com', title: 'Also Keep', dateAdded: now - 10000 },
30923121
];
30933122

3094-
const incomingTombstones = [
3095-
{ url: 'https://deleted.com', deletedAt: now - 5000 },
3096-
];
3123+
const incomingTombstones = [{ url: 'https://already-deleted.com', deletedAt: now - 5000 }];
30973124

30983125
let upsertedData = null;
30993126
const upsertMock = vi.fn().mockImplementation((data) => {
@@ -3139,10 +3166,13 @@ describe('Server-Side Tombstone Cleanup', () => {
31393166
const response = await POST(request);
31403167
expect(response.status).toBe(200);
31413168

3142-
// Only the non-tombstoned bookmark should be stored
3169+
// Both bookmarks should be stored — server trusts the extension in replace mode
31433170
const storedBookmarks = upsertedData.bookmark_data;
3144-
expect(storedBookmarks).toHaveLength(1);
3145-
expect(storedBookmarks[0].url).toBe('https://keep.com');
3171+
expect(storedBookmarks).toHaveLength(2);
3172+
expect(storedBookmarks.map((b) => b.url).sort()).toEqual([
3173+
'https://also-keep.com',
3174+
'https://keep.com',
3175+
]);
31463176
});
31473177

31483178
it('should use legacy merge behavior when replace is not set', async () => {
@@ -3151,7 +3181,16 @@ describe('Server-Side Tombstone Cleanup', () => {
31513181
];
31523182

31533183
const existingCloudBookmarks = [
3154-
{ id: '1', url: 'https://existing.com', title: 'Existing', index: 0, type: 'bookmark', folderPath: '', dateAdded: 1000, source: 'browser' },
3184+
{
3185+
id: '1',
3186+
url: 'https://existing.com',
3187+
title: 'Existing',
3188+
index: 0,
3189+
type: 'bookmark',
3190+
folderPath: '',
3191+
dateAdded: 1000,
3192+
source: 'browser',
3193+
},
31553194
];
31563195

31573196
let upsertedData = null;

apps/web/app/api/bookmarks/route.js

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,9 @@ async function tryRefreshDropboxToken(supabase, source) {
673673
const clientSecret = process.env.DROPBOX_CLIENT_SECRET;
674674

675675
if (!clientId || !clientSecret) {
676-
console.error('[Dropbox Sync] Cannot refresh token: DROPBOX_CLIENT_ID or DROPBOX_CLIENT_SECRET not configured');
676+
console.error(
677+
'[Dropbox Sync] Cannot refresh token: DROPBOX_CLIENT_ID or DROPBOX_CLIENT_SECRET not configured'
678+
);
677679
return null;
678680
}
679681

@@ -754,7 +756,8 @@ async function syncToDropbox(supabase, source, bookmarks, tombstones, checksum)
754756
});
755757
} catch (error) {
756758
// If the error indicates an expired token, try refreshing and retrying once
757-
const isExpiredToken = error.message?.includes('expired_access_token') ||
759+
const isExpiredToken =
760+
error.message?.includes('expired_access_token') ||
758761
error.message?.includes('invalid_access_token');
759762

760763
if (isExpiredToken) {
@@ -829,13 +832,23 @@ export async function POST(request) {
829832
// Normalize bookmarks structure
830833
// Preserve empty titles - don't replace with URL
831834
// IMPORTANT: Preserve type and index fields for proper checksum comparison
835+
//
836+
// NOTE on dateAdded: We preserve the original value (or 0 if falsy) instead
837+
// of falling back to Date.now(). The old Date.now() fallback was dangerous:
838+
// when dateAdded was falsy, the server would assign the current timestamp,
839+
// making the bookmark appear "brand new." This caused the tombstone filter
840+
// (bookmarkDate > tombstoneDate) to incorrectly keep bookmarks that should
841+
// have been deleted — the bookmark looked newer than its tombstone.
832842
const normalizedBookmarks = bookmarks.map((bookmark) => ({
833843
id: bookmark.id,
834844
type: bookmark.type || 'bookmark', // Preserve type (bookmark or folder)
835845
url: bookmark.url,
836846
title: bookmark.title ?? '',
837847
folderPath: bookmark.folderPath || bookmark.folder_path || '',
838-
dateAdded: (typeof bookmark.dateAdded === 'string' ? new Date(bookmark.dateAdded).getTime() : bookmark.dateAdded) || Date.now(),
848+
dateAdded:
849+
(typeof bookmark.dateAdded === 'string'
850+
? new Date(bookmark.dateAdded).getTime()
851+
: bookmark.dateAdded) || 0,
839852
index: bookmark.index ?? 0, // Preserve index for ordering
840853
source,
841854
}));
@@ -903,17 +916,20 @@ export async function POST(request) {
903916
let deleted = 0;
904917

905918
if (replace) {
906-
console.log(`[Bookmarks API] Replace mode: using incoming bookmarks directly (skipping merge)`);
907-
// Apply tombstones to filter deleted bookmarks from incoming data
908-
const tombstoneMap = new Map(mergedTombstones.map((t) => [t.url, t.deletedAt || 0]));
909-
finalBookmarks = normalizedBookmarks.filter((b) => {
910-
if (!b.url) return true; // Keep folders
911-
const tombstoneDate = tombstoneMap.get(b.url);
912-
if (tombstoneDate === undefined) return true; // No tombstone
913-
const bookmarkDate = typeof b.dateAdded === 'string' ? new Date(b.dateAdded).getTime() : (b.dateAdded || 0);
914-
return bookmarkDate > tombstoneDate;
915-
});
916-
deleted = normalizedBookmarks.length - finalBookmarks.length;
919+
console.log(
920+
`[Bookmarks API] Replace mode: using incoming bookmarks directly (skipping merge and tombstone filter)`
921+
);
922+
// In replace mode, the extension has already performed a full two-way merge
923+
// INCLUDING tombstone filtering on the client side. The incoming bookmarks
924+
// are the authoritative state — we trust them as-is.
925+
//
926+
// IMPORTANT: We no longer re-apply tombstone filtering here because:
927+
// 1. The extension already filtered tombstoned bookmarks before pushing
928+
// 2. Re-filtering with dateAdded comparisons was causing bookmarks with
929+
// falsy/stale dateAdded to be incorrectly removed from the cloud
930+
// 3. The extension is the source of truth in replace mode
931+
finalBookmarks = normalizedBookmarks;
932+
deleted = 0;
917933
added = finalBookmarks.length;
918934
} else {
919935
// Legacy merge behavior
@@ -928,7 +944,8 @@ export async function POST(request) {
928944
if (!b.url) return true; // Keep folders
929945
const tombstoneDate = tombstoneMap.get(b.url);
930946
if (tombstoneDate === undefined) return true; // No tombstone
931-
const bookmarkDate = typeof b.dateAdded === 'string' ? new Date(b.dateAdded).getTime() : (b.dateAdded || 0);
947+
const bookmarkDate =
948+
typeof b.dateAdded === 'string' ? new Date(b.dateAdded).getTime() : b.dateAdded || 0;
932949
return bookmarkDate > tombstoneDate;
933950
});
934951
deleted = merged.length - finalBookmarks.length;
@@ -937,9 +954,7 @@ export async function POST(request) {
937954
console.log(
938955
`[Bookmarks API] After ${replace ? 'replace' : 'merge'}: ${finalBookmarks.length + deleted} total, ${added} added, ${updated} updated, ${deleted} removed by tombstones`
939956
);
940-
console.log(
941-
`[Bookmarks API] Tombstones: ${mergedTombstones.length} stored`
942-
);
957+
console.log(`[Bookmarks API] Tombstones: ${mergedTombstones.length} stored`);
943958

944959
// Validate total bookmark count after merge to prevent data explosion
945960
if (finalBookmarks.length > MAX_BOOKMARKS_PER_USER) {

0 commit comments

Comments
 (0)