From 64e111a798bf5b8077b936d6d13ba077f462e24a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 29 Jun 2026 15:09:43 +0200 Subject: [PATCH 1/4] test(query-db-collection): cover persisted row cleanup after reload A row inserted while the collection is mounted should be persisted together with its query owner metadata, so that after a reload the row is removed from both the live collection and the persisted store once the query no longer returns it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../query-db-collection/tests/query.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b9ccb8f55..79fb73547 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4960,6 +4960,101 @@ describe(`QueryCollection`, () => { ).toBe(false) }) + it(`should clean up an inserted row dropped by the query after a reload`, async () => { + // A row inserted while the collection is mounted is persisted. After the + // app reloads, if the query no longer returns that row, it must be removed + // from both the live collection and the persisted store. + const queryKey = [`reload-insert-cleanup`] + const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { staleTime: 0, gcTime: 5 * 60 * 1000, retry: false }, + }, + }) + + // The shared on-device store, surviving across the two sessions. + const adapter = createPersistedQueryAdapter({}) + + // ---- First session: insert an item that the server then returns ---- + let serverRows: Array = [] + const firstQueryClient = makeQueryClient() + const collection1 = createCollection( + persistedCollectionOptions({ + ...(queryCollectionOptions({ + id: `reload-insert-cleanup`, + queryClient: firstQueryClient, + queryKey, + queryFn: async () => serverRows, + getKey: (item: CategorisedItem): string => item.id, + syncMode: `eager`, + startSync: true, + onInsert: async ({ transaction }) => { + // The mutation reaches the server: the item now appears in the + // API response for subsequent fetches. + for (const mutation of transaction.mutations) { + serverRows = [...serverRows, mutation.modified] + } + }, + }) as any), + persistence: { adapter }, + }) as any, + ) + + await collection1.stateWhenReady() + await flushPromises() + + await collection1.insert({ id: `1`, name: `Buy milk`, category: `A` }) + // The query refetches and sees the now-synced row. + await firstQueryClient.invalidateQueries({ queryKey }) + await flushPromises() + await flushPromises() + + expect(adapter.rows.has(`1`)).toBe(true) + + // ---- App closes; the row is removed on the server out of band ---- + serverRows = [] + + // ---- Second session: reload from the persisted store ---- + const secondQueryClient = makeQueryClient() + const adapter2 = createPersistedQueryAdapter({ + rows: adapter.rows, + rowMetadata: adapter.rowMetadata, + collectionMetadata: adapter.collectionMetadata, + }) + const collection2 = createCollection( + persistedCollectionOptions({ + ...(queryCollectionOptions({ + id: `reload-insert-cleanup`, + queryClient: secondQueryClient, + queryKey, + queryFn: async () => serverRows, + getKey: (item: CategorisedItem): string => item.id, + syncMode: `eager`, + startSync: true, + }) as any), + persistence: { adapter: adapter2 }, + }) as any, + ) + + await collection2.stateWhenReady() + const liveQuery = createLiveQueryCollection({ + query: (q) => q.from({ item: collection2 }), + }) + await liveQuery.preload() + await flushPromises() + // The query responds on launch (after persisted rows have hydrated). + await secondQueryClient.invalidateQueries({ queryKey }) + await flushPromises() + await flushPromises() + + expect(collection2.has(`1`)).toBe(false) + expect(adapter2.rows.has(`1`)).toBe(false) + expect(liveQuery.size).toBe(0) + + firstQueryClient.clear() + secondQueryClient.clear() + }) + it(`should expire retained ttl placeholders while the app stays open`, async () => { vi.useFakeTimers() try { From 06c1c7038f81f98f137d564929e0f52aa6f698c2 Mon Sep 17 00:00:00 2001 From: Tom Bryden Date: Thu, 25 Jun 2026 19:39:55 +0100 Subject: [PATCH 2/4] fix: queued metadata reset on insert --- .../src/persisted.ts | 18 ++++- .../tests/persisted.test.ts | 72 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 12cf8319c..ca60a050a 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -2290,9 +2290,21 @@ function createWrappedSyncConfig< message.type === `insert` && normalization.operation.metadata === undefined ) { - openTransaction.rowMetadataWrites.set(normalization.operation.key, { - type: `delete`, - }) + // Reset stale metadata for a fresh insert, but don't clobber an + // explicit metadata write already queued for this key in the same + // transaction (e.g. query reconcile stamps owners, then inserts). + if ( + !openTransaction.rowMetadataWrites.has( + normalization.operation.key, + ) + ) { + openTransaction.rowMetadataWrites.set( + normalization.operation.key, + { + type: `delete`, + }, + ) + } } else if (normalization.operation.metadata !== undefined) { openTransaction.rowMetadataWrites.set(normalization.operation.key, { type: `set`, diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 57c11d844..42bbcf563 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -816,6 +816,78 @@ describe(`persistedCollectionOptions`, () => { ) }) + it(`preserves row metadata set before a metadata-less insert in the same sync transaction`, async () => { + const adapter = createRecordingAdapter() + const ownership = { queryCollection: { owners: [`gc:q1`] } } + const sync: SyncConfig = { + sync: ({ begin, write, commit, markReady, metadata }) => { + begin() + metadata?.row.set(`remote-1`, ownership) + write({ + type: `insert`, + value: { + id: `remote-1`, + title: `From remote`, + }, + }) + commit() + markReady() + }, + } + + const collection = createCollection( + persistedCollectionOptions({ + id: `sync-present`, + getKey: (item: Todo) => item.id, + sync, + persistence: { + adapter, + }, + }), + ) + + await collection.stateWhenReady() + await flushAsyncWork() + + expect(adapter.rowMetadata.get(`remote-1`)).toEqual(ownership) + expect(collection._state.syncedMetadata.get(`remote-1`)).toEqual(ownership) + }) + + it(`resets stale row metadata for a metadata-less insert with no queued metadata`, async () => { + const adapter = createRecordingAdapter() + adapter.rowMetadata.set(`remote-1`, { stale: true }) + const sync: SyncConfig = { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { + id: `remote-1`, + title: `From remote`, + }, + }) + commit() + markReady() + }, + } + + const collection = createCollection( + persistedCollectionOptions({ + id: `sync-present`, + getKey: (item: Todo) => item.id, + sync, + persistence: { + adapter, + }, + }), + ) + + await collection.stateWhenReady() + await flushAsyncWork() + + expect(adapter.rowMetadata.has(`remote-1`)).toBe(false) + }) + it(`uses a stable generated collection id in sync-present mode when id is omitted`, async () => { const adapter = createRecordingAdapter() const options = persistedCollectionOptions({ From f2b9defea5760b10fe6e7d7bb2fb4e494ad2bb7d Mon Sep 17 00:00:00 2001 From: Tom Bryden Date: Thu, 25 Jun 2026 19:50:16 +0100 Subject: [PATCH 3/4] changeset --- .changeset/small-tables-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-tables-listen.md diff --git a/.changeset/small-tables-listen.md b/.changeset/small-tables-listen.md new file mode 100644 index 000000000..fbb62047b --- /dev/null +++ b/.changeset/small-tables-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +--- + +Fixed bug where internal metadata would get reset on insert causing stale items to remain From 998a16c1420725a2f2a3db27c98d3a30623c0627 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 29 Jun 2026 16:20:40 +0200 Subject: [PATCH 4/4] test(query-db-collection): assert persisted hydration before cleanup --- .../query-db-collection/tests/query.test.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 79fb73547..db73648c1 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -5021,13 +5021,20 @@ describe(`QueryCollection`, () => { rowMetadata: adapter.rowMetadata, collectionMetadata: adapter.collectionMetadata, }) + let releaseSecondFetch!: () => void + const secondFetchReleased = new Promise((resolve) => { + releaseSecondFetch = resolve + }) const collection2 = createCollection( persistedCollectionOptions({ ...(queryCollectionOptions({ id: `reload-insert-cleanup`, queryClient: secondQueryClient, queryKey, - queryFn: async () => serverRows, + queryFn: async () => { + await secondFetchReleased + return serverRows + }, getKey: (item: CategorisedItem): string => item.id, syncMode: `eager`, startSync: true, @@ -5036,20 +5043,25 @@ describe(`QueryCollection`, () => { }) as any, ) - await collection2.stateWhenReady() + await vi.waitFor(() => { + expect(collection2.has(`1`)).toBe(true) + expect(adapter2.rows.has(`1`)).toBe(true) + }) + const liveQuery = createLiveQueryCollection({ query: (q) => q.from({ item: collection2 }), }) + releaseSecondFetch() await liveQuery.preload() - await flushPromises() // The query responds on launch (after persisted rows have hydrated). - await secondQueryClient.invalidateQueries({ queryKey }) await flushPromises() await flushPromises() - expect(collection2.has(`1`)).toBe(false) - expect(adapter2.rows.has(`1`)).toBe(false) - expect(liveQuery.size).toBe(0) + await vi.waitFor(() => { + expect(collection2.has(`1`)).toBe(false) + expect(adapter2.rows.has(`1`)).toBe(false) + expect(liveQuery.size).toBe(0) + }) firstQueryClient.clear() secondQueryClient.clear()