experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(sync): record lastSyncTime after push to prevent echo on next pull

Pushed items were echoing back on the next sync because lastSyncTime was
captured before push started, but the server sets updatedAt=now() during
push — so pushed items had updatedAt > lastSyncTime. Recording the
timestamp after push ensures the high-water mark is after server-side
timestamps. Includes regression test and pre-existing test fixes.

+91 -13
+6 -4
backend/electron/sync.ts
··· 719 719 } 720 720 721 721 const config = getSyncConfig(); 722 - const startTime = Date.now(); 723 722 724 723 DEBUG && console.log('[sync] Starting full sync...'); 725 724 ··· 737 736 const pushResult = await pushToServer(serverUrl, apiKey, config.lastSyncTime); 738 737 pushed = pushResult.pushed; 739 738 740 - // Update last sync time and remember current server config 739 + // Update last sync time AFTER push completes — using Date.now() ensures 740 + // the high-water mark is after any server-side timestamps set during push, 741 + // preventing the next pull from echoing back items we just pushed. 742 + const syncTime = Date.now(); 741 743 const activeProfile = getActiveProfile(); 742 - updateLastSyncTime(activeProfile.id, startTime); 744 + updateLastSyncTime(activeProfile.id, syncTime); 743 745 saveSyncServerConfig(serverUrl); 744 746 745 747 DEBUG && console.log(`[sync] Sync complete: ${pulled} pulled, ${pushed} pushed, ${conflicts} conflicts`); ··· 748 750 pulled, 749 751 pushed, 750 752 conflicts, 751 - lastSyncTime: startTime, 753 + lastSyncTime: syncTime, 752 754 }; 753 755 } catch (error) { 754 756 DEBUG && console.error('[sync] Sync failed:', error);
+78 -6
backend/extension/tests/sync.test.js
··· 257 257 const postIdx = requestLog.findIndex(r => r.method === 'POST'); 258 258 assert.ok(getIdx < postIdx); 259 259 }); 260 + 261 + it('should not echo back pushed items on next sync', async () => { 262 + // Simulate the scenario: 263 + // 1. Sync 1: push local items to server 264 + // 2. Sync 2: pull should NOT return those same items 265 + // 266 + // Bug: if lastSyncTime is captured BEFORE push, the server's updatedAt 267 + // on pushed items is AFTER lastSyncTime, so they echo back on next pull. 268 + 269 + // Create local items that need pushing 270 + await data.addItem('url', { content: 'https://echo-1.com' }); 271 + await data.addItem('url', { content: 'https://echo-2.com' }); 272 + await data.addItem('text', { content: 'Echo test note' }); 273 + 274 + // Track server-side state: items pushed to server get updatedAt = now 275 + const serverItems = []; 276 + let syncCount = 0; 277 + 278 + mockFetchHandler = async (url, opts) => { 279 + const urlStr = url.toString(); 280 + if (opts && opts.method === 'POST') { 281 + // Server saves item with updatedAt = now (like real server does) 282 + const body = JSON.parse(opts.body); 283 + const serverUpdatedAt = Date.now(); 284 + serverItems.push({ 285 + id: `server-${serverItems.length}`, 286 + type: body.type, 287 + content: body.content, 288 + tags: body.tags || [], 289 + metadata: body.metadata || null, 290 + createdAt: serverUpdatedAt, 291 + updatedAt: serverUpdatedAt, 292 + }); 293 + return jsonResponse({ id: `server-${serverItems.length - 1}`, created: true }); 294 + } 295 + 296 + // GET /items/since/:timestamp — return items with updatedAt > since 297 + // URL uses ISO timestamps like /items/since/2026-02-19T12:34:56.789Z 298 + const sinceMatch = urlStr.match(/\/items\/since\/([^?&]+)/); 299 + if (sinceMatch) { 300 + const sinceStr = decodeURIComponent(sinceMatch[1]); 301 + const since = /^\d+$/.test(sinceStr) 302 + ? parseInt(sinceStr, 10) 303 + : new Date(sinceStr).getTime(); 304 + const matching = serverItems.filter(i => i.updatedAt > since); 305 + syncCount++; 306 + return jsonResponse({ items: matching }); 307 + } 308 + 309 + return jsonResponse({ items: [] }); 310 + }; 311 + 312 + // Sync 1: pull (nothing), then push 3 items 313 + const result1 = await sync.syncAll(); 314 + assert.equal(result1.pushed, 3, 'Sync 1 should push 3 items'); 315 + 316 + // Sync 2: pull should NOT echo back items we just pushed 317 + // Reset mock to not accept more pushes (items already synced) 318 + const origHandler = mockFetchHandler; 319 + mockFetchHandler = async (url, opts) => { 320 + if (opts && opts.method === 'POST') { 321 + return jsonResponse({ id: 'no-push', created: false }); 322 + } 323 + return origHandler(url, opts); 324 + }; 325 + 326 + const result2 = await sync.syncAll(); 327 + assert.equal(result2.pulled, 0, 328 + `Sync 2 pulled ${result2.pulled} items — expected 0. ` + 329 + `Pushed items are echoing back because lastSyncTime is set before push.` 330 + ); 331 + }); 260 332 }); 261 333 262 334 // ==================== Status ==================== ··· 328 400 329 401 // Verify sync markers are intact 330 402 const items = await data.queryItems(); 331 - const item = items.find(i => i.content === 'Stable item'); 332 - assert.equal(item.syncId, 'server-sc-2'); 333 - assert.ok(item.syncedAt > 0, 'Should still have syncedAt timestamp'); 403 + const stableItem = items.find(i => i.content === 'Stable item'); 404 + assert.equal(stableItem.syncId, 'server-sc-2'); 405 + assert.ok(stableItem.syncedAt > 0, 'Should still have syncedAt timestamp'); 334 406 }); 335 407 336 408 it('should not reset on first run (no stored config)', async () => { ··· 350 422 351 423 // Sync markers should be intact 352 424 const items = await data.queryItems(); 353 - const item = items.find(i => i.content === 'Legacy item'); 354 - assert.equal(item.syncId, 'server-legacy-1'); 355 - assert.ok(item.syncedAt > 0, 'Should still have syncedAt timestamp'); 425 + const legacyItem = items.find(i => i.content === 'Legacy item'); 426 + assert.equal(legacyItem.syncId, 'server-legacy-1'); 427 + assert.ok(legacyItem.syncedAt > 0, 'Should still have syncedAt timestamp'); 356 428 }); 357 429 }); 358 430 });
+7 -3
sync/sync.js
··· 188 188 } 189 189 190 190 await this.resetSyncStateIfServerChanged(config.serverUrl); 191 - const startTime = Date.now(); 192 191 193 192 const pullResult = await this.pullFromServer(); 194 193 await this.saveSyncServerConfig(config.serverUrl); 195 194 const pushResult = await this.pushToServer(); 196 - await this.setConfig({ lastSyncTime: startTime }); 195 + 196 + // Record sync time AFTER push completes so the high-water mark is after 197 + // any server-side timestamps set during push — prevents echoing back 198 + // items we just pushed on the next pull. 199 + const syncTime = Date.now(); 200 + await this.setConfig({ lastSyncTime: syncTime }); 197 201 198 202 return { 199 203 pulled: pullResult.pulled, 200 204 pushed: pushResult.pushed, 201 205 conflicts: pullResult.conflicts, 202 - lastSyncTime: startTime, 206 + lastSyncTime: syncTime, 203 207 }; 204 208 } 205 209