Skip to content

Commit 05776f5

Browse files
authored
Fix a bug that could result in a duplicate delete event for a row (#621)
* Add tests that repo the bug * ensure that redundent sync delete events are skipped * changeset * typo * remove console logs
1 parent b479f8e commit 05776f5

File tree

4 files changed

+211
-5
lines changed

4 files changed

+211
-5
lines changed

.changeset/olive-boxes-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Fixed a bug that could result in a duplicate delete event for a row

packages/db/src/collection/state.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -708,10 +708,23 @@ export class CollectionStateManager<
708708

709709
// Check if this sync operation is redundant with a completed optimistic operation
710710
const completedOp = completedOptimisticOps.get(key)
711-
const isRedundantSync =
712-
completedOp &&
713-
newVisibleValue !== undefined &&
714-
deepEquals(completedOp.value, newVisibleValue)
711+
let isRedundantSync = false
712+
713+
if (completedOp) {
714+
if (
715+
completedOp.type === `delete` &&
716+
previousVisibleValue !== undefined &&
717+
newVisibleValue === undefined &&
718+
deepEquals(completedOp.value, previousVisibleValue)
719+
) {
720+
isRedundantSync = true
721+
} else if (
722+
newVisibleValue !== undefined &&
723+
deepEquals(completedOp.value, newVisibleValue)
724+
) {
725+
isRedundantSync = true
726+
}
727+
}
715728

716729
if (!isRedundantSync) {
717730
if (

packages/db/tests/collection-subscribe-changes.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,4 +1379,141 @@ describe(`Collection.subscribeChanges`, () => {
13791379
expect(changeEvents.length).toBe(0)
13801380
expect(collection.state.has(1)).toBe(false)
13811381
})
1382+
1383+
it(`only emit a single event when a sync mutation is triggered from inside a mutation handler callback`, async () => {
1384+
const callback = vi.fn()
1385+
1386+
interface TestItem extends Record<string, unknown> {
1387+
id: number
1388+
number: number
1389+
}
1390+
1391+
let callBegin!: () => void
1392+
let callWrite!: (message: Omit<ChangeMessage<TestItem>, `key`>) => void
1393+
let callCommit!: () => void
1394+
1395+
// Create collection with pre-populated data
1396+
const collection = createCollection<TestItem>({
1397+
id: `test`,
1398+
getKey: (item) => item.id,
1399+
sync: {
1400+
sync: ({ begin, write, commit, markReady }) => {
1401+
callBegin = begin
1402+
callWrite = write
1403+
callCommit = commit
1404+
// Immediately populate with initial data
1405+
begin()
1406+
write({
1407+
type: `insert`,
1408+
value: { id: 0, number: 15 },
1409+
})
1410+
commit()
1411+
markReady()
1412+
},
1413+
},
1414+
onDelete: ({ transaction }) => {
1415+
const { original } = transaction.mutations[0]
1416+
1417+
// IMMEDIATELY synchronously trigger the sync inside the onDelete callback promise
1418+
callBegin()
1419+
callWrite({ type: `delete`, value: original })
1420+
callCommit()
1421+
1422+
return Promise.resolve()
1423+
},
1424+
})
1425+
1426+
// Subscribe to changes
1427+
const subscription = collection.subscribeChanges(callback, {
1428+
includeInitialState: true,
1429+
})
1430+
1431+
callback.mockReset()
1432+
1433+
// Delete item 0
1434+
collection.delete(0)
1435+
1436+
await new Promise((resolve) => setTimeout(resolve, 10))
1437+
1438+
expect(callback.mock.calls.length).toBe(1)
1439+
expect(callback.mock.calls[0]![0]).toEqual([
1440+
{
1441+
type: `delete`,
1442+
key: 0,
1443+
value: { id: 0, number: 15 },
1444+
},
1445+
])
1446+
1447+
subscription.unsubscribe()
1448+
})
1449+
1450+
it(`only emit a single event when a sync mutation is triggered from inside a mutation handler callback after a short delay`, async () => {
1451+
const callback = vi.fn()
1452+
1453+
interface TestItem extends Record<string, unknown> {
1454+
id: number
1455+
number: number
1456+
}
1457+
1458+
let callBegin!: () => void
1459+
let callWrite!: (message: Omit<ChangeMessage<TestItem>, `key`>) => void
1460+
let callCommit!: () => void
1461+
1462+
// Create collection with pre-populated data
1463+
const collection = createCollection<TestItem>({
1464+
id: `test`,
1465+
getKey: (item) => item.id,
1466+
sync: {
1467+
sync: ({ begin, write, commit, markReady }) => {
1468+
callBegin = begin
1469+
callWrite = write
1470+
callCommit = commit
1471+
// Immediately populate with initial data
1472+
begin()
1473+
write({
1474+
type: `insert`,
1475+
value: { id: 0, number: 15 },
1476+
})
1477+
commit()
1478+
markReady()
1479+
},
1480+
},
1481+
onDelete: async ({ transaction }) => {
1482+
const { original } = transaction.mutations[0]
1483+
1484+
// Simulate waiting for some async operation
1485+
await new Promise((resolve) => setTimeout(resolve, 0))
1486+
1487+
// Synchronously trigger the sync inside the onDelete callback promise,
1488+
// but after a short delay.
1489+
// Ordering here is important to test for a race condition!
1490+
callBegin()
1491+
callWrite({ type: `delete`, value: original })
1492+
callCommit()
1493+
},
1494+
})
1495+
1496+
// Subscribe to changes
1497+
const subscription = collection.subscribeChanges(callback, {
1498+
includeInitialState: true,
1499+
})
1500+
1501+
callback.mockReset()
1502+
1503+
// Delete item 0
1504+
collection.delete(0)
1505+
1506+
await new Promise((resolve) => setTimeout(resolve, 10))
1507+
1508+
expect(callback.mock.calls.length).toBe(1)
1509+
expect(callback.mock.calls[0]![0]).toEqual([
1510+
{
1511+
type: `delete`,
1512+
key: 0,
1513+
value: { id: 0, number: 15 },
1514+
},
1515+
])
1516+
1517+
subscription.unsubscribe()
1518+
})
13821519
})

packages/db/tests/local-only.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest"
2-
import { createCollection } from "../src/index"
2+
import { createCollection, liveQueryCollectionOptions } from "../src/index"
3+
import { sum } from "../src/query/builder/functions"
34
import { localOnlyCollectionOptions } from "../src/local-only"
45
import type { LocalOnlyCollectionUtils } from "../src/local-only"
56
import type { Collection } from "../src/index"
@@ -8,6 +9,7 @@ interface TestItem extends Record<string, unknown> {
89
id: number
910
name: string
1011
completed?: boolean
12+
number?: number
1113
}
1214

1315
describe(`LocalOnly Collection`, () => {
@@ -435,4 +437,53 @@ describe(`LocalOnly Collection`, () => {
435437
expect(testCollection.get(200)).toEqual({ id: 200, name: `Added Item` })
436438
})
437439
})
440+
441+
describe(`Live Query integration`, () => {
442+
it(`aggregation should work when there is a onDelete callback`, async () => {
443+
// This is a reproduction of this issue: https://github.com/TanStack/db/issues/609
444+
// The underlying bug is covered by the "only emit a single event when a sync
445+
// mutation is triggered from inside an mutation handler callback after a short
446+
// delay" test in collection-subscribe-changes.test.ts
447+
448+
const testCollection = createCollection<TestItem, number>(
449+
localOnlyCollectionOptions({
450+
id: `numbers`,
451+
getKey: (item) => item.id,
452+
initialData: [
453+
{ id: 0, number: 15 },
454+
{ id: 1, number: 15 },
455+
{ id: 2, number: 15 },
456+
] as Array<TestItem>,
457+
onDelete: () => {
458+
return Promise.resolve()
459+
},
460+
autoIndex: `off`,
461+
})
462+
)
463+
464+
testCollection.subscribeChanges((changes) => {
465+
console.log({ testCollectionChanges: changes })
466+
})
467+
468+
const query = createCollection(
469+
liveQueryCollectionOptions({
470+
startSync: true,
471+
query: (q) =>
472+
q.from({ numbers: testCollection }).select(({ numbers }) => ({
473+
totalNumber: sum(numbers.number),
474+
})),
475+
})
476+
)
477+
478+
query.subscribeChanges((changes) => {
479+
console.log({ queryChanges: changes })
480+
})
481+
482+
testCollection.delete(0)
483+
484+
await new Promise((resolve) => setTimeout(resolve, 10))
485+
486+
expect(query.toArray).toEqual([{ totalNumber: 30 }])
487+
})
488+
})
438489
})

0 commit comments

Comments
 (0)