diff --git a/server/services/brainSyncLog.js b/server/services/brainSyncLog.js index ff384654c9..83a9694185 100644 --- a/server/services/brainSyncLog.js +++ b/server/services/brainSyncLog.js @@ -303,7 +303,7 @@ export async function compactLog(minSeq = 0) { : 0; const preservedTail = []; - const tailKeys = new Set(); + const tailEntriesByKey = new Map(); const olderEntriesByKey = new Map(); const unindexedOrUntypedOlder = []; @@ -312,7 +312,9 @@ export async function compactLog(minSeq = 0) { if (seq !== null && floor > 0 && seq >= floor) { preservedTail.push(item); if (entry?.type && entry?.id) { - tailKeys.add(`${entry.type}/${entry.id}`); + const key = `${entry.type}/${entry.id}`; + if (!tailEntriesByKey.has(key)) tailEntriesByKey.set(key, []); + tailEntriesByKey.get(key).push(item); } } else if (entry?.type && entry?.id) { const key = `${entry.type}/${entry.id}`; @@ -338,8 +340,8 @@ export async function compactLog(minSeq = 0) { // If the tail carries ONLY stale/losing operations (e.g. olderWinner is a Jan-02 delete // and tail has an echoed Jan-01 create), we MUST retain olderWinner before the verbatim // tail so fresh / delta-only peers do not accept the stale create and resurrect the record. - if (tailKeys.has(key)) { - const tailItems = preservedTail.filter(i => i.entry && `${i.entry.type}/${i.entry.id}` === key); + const tailItems = tailEntriesByKey.get(key); + if (tailItems) { const supersededByTail = tailItems.some(i => { const tailTs = i.entry?.record?.updatedAt; return tailTs != null && olderWinner.record?.updatedAt != null && tailTs > olderWinner.record.updatedAt; diff --git a/server/services/brainSyncLog.test.js b/server/services/brainSyncLog.test.js index 8a65d9c8a8..6a210a69e0 100644 --- a/server/services/brainSyncLog.test.js +++ b/server/services/brainSyncLog.test.js @@ -567,5 +567,25 @@ describe('brainSyncLog', () => { const fromZero = await getChangesSince(0); expect(fromZero.changes.map(c => c.seq)).toEqual([2, 99]); }); + + it('applies mixed LWW outcomes across indexed tail entries', async () => { + writeLog( + '{"seq":1,"op":"create","type":"links","id":"x","record":{"updatedAt":"2026-01-01T00:00:00.000Z"}}\n' + + '{"seq":2,"op":"delete","type":"links","id":"x","record":{"updatedAt":"2026-01-02T00:00:00.000Z"}}\n' + + '{"seq":3,"op":"create","type":"links","id":"y","record":{"updatedAt":"2026-01-01T00:00:00.000Z"}}\n' + + '{"seq":4,"op":"delete","type":"links","id":"y","record":{"updatedAt":"2026-01-03T00:00:00.000Z"}}\n' + + '{"seq":50,"op":"update","type":"links","id":"x","record":{"updatedAt":"2026-01-01T12:00:00.000Z"}}\n' + + '{"seq":51,"op":"create","type":"links","id":"y","record":{"updatedAt":"2026-01-04T00:00:00.000Z"}}\n' + + '{"seq":52,"op":"update","type":"links","id":"x","record":{"updatedAt":"2026-01-01T18:00:00.000Z"}}\n' + ); + await initSyncLog(); + + expect(await compactLog(50)).toBe(3); + + const lines = readFileSync(syncLogPath(), 'utf8').trim().split('\n').map(l => JSON.parse(l)); + expect(lines.map(line => line.seq)).toEqual([2, 50, 51, 52]); + expect(lines[0].id).toBe('x'); + expect(lines[0].op).toBe('delete'); + }); }); });