Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-ghost-delete-sentkeys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/db": patch
---

fix(db): skip deletes for items never sent to D2 in filterDuplicateInserts
4 changes: 3 additions & 1 deletion packages/db/src/query/live/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ export function filterDuplicateInserts(
}
sentKeys.add(change.key)
} else if (change.type === `delete`) {
sentKeys.delete(change.key)
if (!sentKeys.delete(change.key)) {
continue
}
}
filtered.push(change)
}
Expand Down
54 changes: 54 additions & 0 deletions packages/db/tests/collection-subscriber-duplicate-inserts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { createCollection } from '../src/collection/index.js'
import { BTreeIndex } from '../src/indexes/btree-index.js'
import { createLiveQueryCollection, eq } from '../src/query/index.js'
import { filterDuplicateInserts } from '../src/query/live/utils.js'
import { mockSyncCollectionOptions } from './utils.js'
import type { ChangeMessage } from '../src/types.js'

Expand Down Expand Up @@ -452,4 +453,57 @@ describe(`CollectionSubscriber duplicate insert prevention`, () => {

subscription.unsubscribe()
})

describe(`filterDuplicateInserts`, () => {
it(`should skip deletes for keys never sent to D2`, () => {
const sentKeys = new Set<string | number>([`1`, `2`])

const changes: Array<ChangeMessage<TestItem, string>> = [
{ type: `delete`, key: `3`, value: { id: `3`, value: 50 } },
]

const result = filterDuplicateInserts(changes, sentKeys)

expect(result).toHaveLength(0)
expect(sentKeys.has(`1`)).toBe(true)
expect(sentKeys.has(`2`)).toBe(true)
expect(sentKeys.has(`3`)).toBe(false)
})

it(`should forward deletes for keys that were sent to D2`, () => {
const sentKeys = new Set<string | number>([`1`, `2`])

const changes: Array<ChangeMessage<TestItem, string>> = [
{ type: `delete`, key: `2`, value: { id: `2`, value: 90 } },
]

const result = filterDuplicateInserts(changes, sentKeys)

expect(result).toHaveLength(1)
expect(result[0]!.type).toBe(`delete`)
expect(result[0]!.key).toBe(`2`)
expect(sentKeys.has(`2`)).toBe(false)
})

it(`should handle mixed inserts and ghost deletes correctly`, () => {
const sentKeys = new Set<string | number>([`1`])

const changes: Array<ChangeMessage<TestItem, string>> = [
{ type: `insert`, key: `2`, value: { id: `2`, value: 90 } },
{ type: `delete`, key: `3`, value: { id: `3`, value: 50 } },
{ type: `delete`, key: `1`, value: { id: `1`, value: 100 } },
{ type: `insert`, key: `4`, value: { id: `4`, value: 70 } },
]

const result = filterDuplicateInserts(changes, sentKeys)

expect(result).toHaveLength(3)
expect(result.map((c) => ({ type: c.type, key: c.key }))).toEqual([
{ type: `insert`, key: `2` },
{ type: `delete`, key: `1` },
{ type: `insert`, key: `4` },
])
expect(sentKeys).toEqual(new Set([`2`, `4`]))
})
})
})