Skip to content

Commit 7095ffd

Browse files
committed
fix(query-db-collection): prevent Direct Write items from being purged
Fixes issue where items inserted via Direct Writes (writeInsert/writeUpsert) were incorrectly purged when query results changed and didn't include those items. **Root Cause:** The reference counting system (queryToRows/rowToQueries) tracks which items are in each query result. When a query result doesn't include an item and the item's reference count is 0, it gets deleted. Direct Write items were never added to this tracking system, so they always had a reference count of 0 and were purged when not in query results. **Solution:** - Added `directWriteKeys` Set to track items inserted via Direct Writes - Modified purging logic in `handleQueryResult()` and `cleanupQuery()` to skip items in the `directWriteKeys` Set - Updated `performWriteOperations()` to add/remove keys from `directWriteKeys`: - Insert operations add the key - Delete operations remove the key - Upsert operations add the key if inserting new item **Changes:** - query.ts: Added directWriteKeys Set and modified purging checks - manual-sync.ts: Track direct write keys in performWriteOperations - query.test.ts: Added test case to verify Direct Writes are protected This ensures directly-written items persist until explicitly deleted via writeDelete(), regardless of query result changes.
1 parent 3e3504a commit 7095ffd

File tree

3 files changed

+105
-2
lines changed

3 files changed

+105
-2
lines changed

packages/query-db-collection/src/manual-sync.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface SyncContext<
3838
begin: () => void
3939
write: (message: Omit<ChangeMessage<TRow>, `key`>) => void
4040
commit: () => void
41+
directWriteKeys?: Set<TKey>
4142
}
4243

4344
interface NormalizedOperation<
@@ -148,6 +149,8 @@ export function performWriteOperations<
148149
type: `insert`,
149150
value: resolved,
150151
})
152+
// Track this key as a direct write
153+
ctx.directWriteKeys?.add(op.key)
151154
break
152155
}
153156
case `update`: {
@@ -175,6 +178,8 @@ export function performWriteOperations<
175178
type: `delete`,
176179
value: currentItem,
177180
})
181+
// Remove from direct write tracking when explicitly deleted
182+
ctx.directWriteKeys?.delete(op.key)
178183
break
179184
}
180185
case `upsert`: {
@@ -195,6 +200,8 @@ export function performWriteOperations<
195200
type: `insert`,
196201
value: resolved,
197202
})
203+
// Track this key as a direct write (only for new inserts)
204+
ctx.directWriteKeys?.add(op.key)
198205
}
199206
break
200207
}

packages/query-db-collection/src/query.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,10 @@ export function queryCollectionOptions(
592592
// queryKey → QueryObserver's unsubscribe function
593593
const unsubscribes = new Map<string, () => void>()
594594

595+
// Track row keys that were inserted via direct writes to prevent them from being purged
596+
// when they don't appear in query results
597+
const directWriteKeys = new Set<string | number>()
598+
595599
// Helper function to add a row to the internal state
596600
const addRow = (rowKey: string | number, hashedQueryKey: string) => {
597601
const rowToQueriesSet = rowToQueries.get(rowKey) || new Set()
@@ -780,7 +784,9 @@ export function queryCollectionOptions(
780784
const newItem = newItemsMap.get(key)
781785
if (!newItem) {
782786
const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries
783-
if (needToRemove) {
787+
// Don't remove items that were inserted via direct writes
788+
// They should only be removed via explicit writeDelete calls
789+
if (needToRemove && !directWriteKeys.has(key)) {
784790
write({ type: `delete`, value: oldItem })
785791
}
786792
} else if (
@@ -932,7 +938,8 @@ export function queryCollectionOptions(
932938
// Reference count dropped to 0, we can GC the row
933939
rowToQueries.delete(rowKey)
934940

935-
if (collection.has(rowKey)) {
941+
// Don't remove items that were inserted via direct writes
942+
if (collection.has(rowKey) && !directWriteKeys.has(rowKey)) {
936943
begin()
937944
write({ type: `delete`, value: collection.get(rowKey) })
938945
commit()
@@ -957,6 +964,7 @@ export function queryCollectionOptions(
957964
hashToQueryKey.clear()
958965
queryToRows.clear()
959966
rowToQueries.clear()
967+
directWriteKeys.clear()
960968
state.observers.clear()
961969
unsubscribeQueryCache()
962970

@@ -1024,6 +1032,7 @@ export function queryCollectionOptions(
10241032
begin: () => void
10251033
write: (message: Omit<ChangeMessage<any>, `key`>) => void
10261034
commit: () => void
1035+
directWriteKeys?: Set<string | number>
10271036
} | null = null
10281037

10291038
// Enhanced internalSync that captures write functions for manual use
@@ -1039,6 +1048,7 @@ export function queryCollectionOptions(
10391048
begin,
10401049
write,
10411050
commit,
1051+
directWriteKeys,
10421052
}
10431053

10441054
// Call the original internalSync logic

packages/query-db-collection/tests/query.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3538,4 +3538,90 @@ describe(`QueryCollection`, () => {
35383538
expect(collection.size).toBe(0)
35393539
})
35403540
})
3541+
3542+
describe(`Direct Writes with dynamic query keys`, () => {
3543+
it(`should not purge Direct Write items when query results change`, async () => {
3544+
// Reproduces the issue reported on Discord:
3545+
// When using Direct Writes, items inserted via writeInsert get purged
3546+
// when a query result comes in that doesn't include those items
3547+
3548+
interface MachineEvent {
3549+
id: string
3550+
timestamp: string
3551+
message: string
3552+
}
3553+
3554+
const getEventKey = (item: MachineEvent) => item.id
3555+
3556+
// Simulate initial data - first response returns 2 items
3557+
let queryCallCount = 0
3558+
const initialEvents: Array<MachineEvent> = [
3559+
{ id: `1`, timestamp: `2025-11-12T22:08:00Z`, message: `Event 1` },
3560+
{ id: `2`, timestamp: `2025-11-12T22:08:30Z`, message: `Event 2` },
3561+
]
3562+
3563+
const queryFn = vi.fn(async (): Promise<Array<MachineEvent>> => {
3564+
queryCallCount++
3565+
// First call returns 2 items
3566+
// Second call returns only 1 item (simulating filtered data)
3567+
if (queryCallCount === 1) {
3568+
return initialEvents
3569+
} else {
3570+
return [initialEvents[0]!] // Only return first item
3571+
}
3572+
})
3573+
3574+
const config: QueryCollectionConfig<MachineEvent> = {
3575+
id: `machine-events-test`,
3576+
queryClient,
3577+
queryKey: [`machineEvents`, `test`],
3578+
queryFn,
3579+
getKey: getEventKey,
3580+
}
3581+
3582+
const options = queryCollectionOptions(config)
3583+
const collection = createCollection(options)
3584+
3585+
await collection.preload()
3586+
3587+
await vi.waitFor(() => {
3588+
expect(collection.size).toBe(2)
3589+
})
3590+
3591+
expect(queryFn).toHaveBeenCalledTimes(1)
3592+
expect(collection.has(`1`)).toBe(true)
3593+
expect(collection.has(`2`)).toBe(true)
3594+
3595+
// Now perform a Direct Write to add a new event
3596+
// This simulates receiving an event from EventSource
3597+
const newEvent: MachineEvent = {
3598+
id: `4`,
3599+
timestamp: `2025-11-12T22:09:30Z`,
3600+
message: `Event 4`,
3601+
}
3602+
3603+
collection.utils.writeInsert(newEvent)
3604+
3605+
// Verify the new event was added
3606+
expect(collection.size).toBe(3)
3607+
expect(collection.has(`4`)).toBe(true)
3608+
3609+
// Now refetch - the query returns different data (only item 1)
3610+
// This simulates a situation where the query result changes
3611+
// (e.g., server-side filtering, pagination, time-based filters)
3612+
await collection.utils.refetch()
3613+
3614+
await vi.waitFor(() => {
3615+
expect(queryFn).toHaveBeenCalledTimes(2)
3616+
})
3617+
3618+
// After refetch, the query returns only item 1
3619+
// Item 2 should be removed (it's not in the result and not a direct write)
3620+
// BUT item 4 (the directly-written item) should still exist
3621+
expect(collection.has(`1`)).toBe(true) // In query result
3622+
expect(collection.has(`2`)).toBe(false) // Not in query result, should be removed
3623+
expect(collection.has(`4`)).toBe(true) // Direct write, should be protected
3624+
expect(collection.size).toBe(2) // Item 1 + Item 4
3625+
})
3626+
})
35413627
})

0 commit comments

Comments
 (0)