Skip to content

fix(query-db-collection): preserve in-memory row ownership when hydrating from persisted metadata#1483

Open
goatrenterguy wants to merge 2 commits intoTanStack:mainfrom
goatrenterguy:fix/hydration-row-ownership-desync
Open

fix(query-db-collection): preserve in-memory row ownership when hydrating from persisted metadata#1483
goatrenterguy wants to merge 2 commits intoTanStack:mainfrom
goatrenterguy:fix/hydration-row-ownership-desync

Conversation

@goatrenterguy
Copy link
Copy Markdown
Contributor

Summary

Fixes a row-ownership desync in @tanstack/query-db-collection that causes rows to be incorrectly deleted from syncedData when multiple overlapping on-demand live queries are active and one of them unmounts.

Observed in production as: after a mutation inserts a row through an onInsert handler, opening and then closing a detail panel (which unmounts a sibling live query) causes rows still covered by a subscribed broader live query to disappear from the UI. The broader query's cache is intact but the collection's syncedData has been truncated.

Root cause

getHydratedOwnedRowsForQueryBaseline (and the scanPersisted branch in loadPersistedBaselineForQuery) seed ownership for a brand-new query by doing:

rowToQueries.set(rowKey, new Set(owners))

where owners comes from getPersistedOwners(rowKey). This overwrites the in-memory entry with whatever persisted metadata currently holds, while leaving queryToRows for other queries untouched. When in-memory ownership and persisted ownership are out of sync, any active query whose entry is only in-memory gets silently evicted from rowToQueries, while its queryToRows[…] entry still claims the row. Later, cleanupQueryInternal (which consults rowToQueries as truth) sees nextOwners.size === 0 for the shared row and deletes it, even though another subscribed query still covers it.

Why in-memory and persisted can diverge

applySuccessfulResult's newItemsMap loop for a fresh row runs this sequence inside a single begin() / commit():

  1. setPersistedOwners(key, { A }) — queues { type: 'set', value: { queryCollection: { owners: { A: true } } } } on the pending transaction's rowMetadataWrites.
  2. addRow(key, A) — updates the in-memory maps.
  3. ctx.write({ type: 'insert', value: newItem }) — the insert branch in packages/db/src/collection/sync.ts (~L176-186) unconditionally sets rowMetadataWrites[key] = { type: 'delete' }, overwriting step 1 on the same key in the same transaction.

On commit, the delete wins. Net state after the first query:

  • rowToQueries[key] = { A }
  • syncedMetadata[key] = undefined

When a second query subsequently touches the same row via the update path (existing row → no insert clobber), its setPersistedOwners survives but writes only { B } because getPersistedOwners returned empty. Net state:

  • rowToQueries[key] = { A, B }
  • syncedMetadata[key].queryCollection.owners = { B }

A third query entering getHydratedOwnedRowsForQueryBaseline then overwrites rowToQueries[key] back to { B }, dropping A, and the teardown path deletes the row.

Fix

Merge persisted owners into the existing in-memory rowToQueries entry instead of overwriting it, in both hydration sites:

const existingOwners = rowToQueries.get(rowKey)
if (existingOwners) {
  persistedOwners.forEach((owner) => existingOwners.add(owner))
} else {
  rowToQueries.set(rowKey, new Set(persistedOwners))
}

Narrowest intervention: hydration now tolerates an out-of-date persisted record without dropping in-memory ownership that active queries registered via addRow.

Test

Added a regression test under Query Garbage Collection:

  • hydration must not strip active in-memory query owners when persisted metadata is out of date

It builds the scenario deterministically:

  1. Query A fetches a fresh row (triggers the insert-clobber — persisted metadata ends empty, in-memory owns the row).
  2. Query B fetches the same row under a different predicate. Because the row already exists, B's applySuccessfulResult takes the update path and persists { B } (A is never persisted).
  3. Query C subscribes with an empty-result predicate, forcing the hydration path.
  4. Query B is torn down.

Asserts the row is still in syncedData afterward. Fails without the patch (row is deleted), passes with it.

Full suite: 192/192 passing.

Out of scope

This PR fixes the hydration side. The insert-clobber in sync.ts that creates the divergence in the first place is a broader change with framework-wide implications (it would alter how manual sync backends interact with row metadata across transactions). Happy to open a separate discussion on whether the insert branch should preserve a pending { type: 'set' } in the same transaction rather than unconditionally writing { type: 'delete' } — but it's not required for this bug.

Test plan

  • New regression test fails on main, passes with this change
  • pnpm --filter @tanstack/query-db-collection test passes (192/192)
  • Changeset included

goatrenterguy and others added 2 commits April 16, 2026 16:50
…ting from persisted metadata

`getHydratedOwnedRowsForQueryBaseline` and the `scanPersisted` path in
`loadPersistedBaselineForQuery` used to overwrite `rowToQueries[rowKey]`
with whatever the persisted metadata held, silently evicting any query
that had registered ownership in-memory only. `queryToRows` was left
untouched, so the two maps desynced and `cleanupQueryInternal` could
later delete rows still needed by an active overlapping query.

In-memory and persisted ownership can legitimately diverge: when
`applySuccessfulResult` writes a brand-new row via `ctx.write({ type:
'insert', value })` without a `metadata` field, the insert branch in
`packages/db/src/collection/sync.ts` unconditionally queues
`{ type: 'delete' }` on the same transaction's `rowMetadataWrites`,
clobbering the earlier `setPersistedOwners({A})` call in the same
transaction. After commit, the in-memory map has `{A}` but persisted
metadata is empty. The next query that hits the hydration path then
stomps `rowToQueries[rowKey]` with the stale persisted owners.

Fix: merge persisted owners into the existing in-memory
`rowToQueries[rowKey]` instead of overwriting it, in both hydration
paths.

Added a regression test under `Query Garbage Collection` that
reproduces the scenario end-to-end (fresh-row insert by A, overlap
on the existing row by B, a new empty-result query C forcing the
hydration path, then B teardown) and asserts the shared row survives
when A is still subscribed. Fails without the fix, passes with it.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 16, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1483

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1483

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1483

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1483

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1483

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1483

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1483

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1483

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1483

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1483

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1483

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1483

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1483

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1483

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1483

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1483

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1483

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1483

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1483

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1483

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1483

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1483

commit: d7f7618

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant