diff --git a/.changeset/small-tables-listen.md b/.changeset/small-tables-listen.md new file mode 100644 index 0000000000..fbb62047b7 --- /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 diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 12cf8319c3..ca60a050ab 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 57c11d8442..42bbcf5633 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({ diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b9ccb8f550..db73648c19 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4960,6 +4960,113 @@ 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, + }) + let releaseSecondFetch!: () => void + const secondFetchReleased = new Promise((resolve) => { + releaseSecondFetch = resolve + }) + const collection2 = createCollection( + persistedCollectionOptions({ + ...(queryCollectionOptions({ + id: `reload-insert-cleanup`, + queryClient: secondQueryClient, + queryKey, + queryFn: async () => { + await secondFetchReleased + return serverRows + }, + getKey: (item: CategorisedItem): string => item.id, + syncMode: `eager`, + startSync: true, + }) as any), + persistence: { adapter: adapter2 }, + }) as any, + ) + + 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() + // The query responds on launch (after persisted rows have hydrated). + await flushPromises() + await flushPromises() + + 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() + }) + it(`should expire retained ttl placeholders while the app stays open`, async () => { vi.useFakeTimers() try {