From 1f099d7381906f39d20a53d54df106bb8320e2cc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 08:47:37 -0600 Subject: [PATCH 1/6] fix(db): drop stale direct optimistic rows after txid sync --- packages/db/src/collection/state.ts | 39 +------- packages/db/tests/collection.test.ts | 90 +++++++++++++++++++ .../tests/electric.test.ts | 43 +++++++++ 3 files changed, 135 insertions(+), 37 deletions(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index af65cb801..2405864a6 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -1,7 +1,6 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' import { enrichRowWithVirtualProps } from '../virtual-props.js' -import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { VirtualOrigin, VirtualRowProps, @@ -81,8 +80,6 @@ export class CollectionStateManager< public optimisticDeletes = new Set() public pendingOptimisticUpserts = new Map() public pendingOptimisticDeletes = new Set() - public pendingOptimisticDirectUpserts = new Set() - public pendingOptimisticDirectDeletes = new Set() /** * Tracks the origin of confirmed changes for each row. @@ -480,8 +477,6 @@ export class CollectionStateManager< // Update pending optimistic state for completed/failed transactions for (const transaction of this.transactions.values()) { - const isDirectTransaction = - transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true if (transaction.state === `completed`) { for (const mutation of transaction.mutations) { if (!this.isThisCollection(mutation.collection)) { @@ -499,24 +494,10 @@ export class CollectionStateManager< mutation.modified as TOutput, ) this.pendingOptimisticDeletes.delete(mutation.key) - if (isDirectTransaction) { - this.pendingOptimisticDirectUpserts.add(mutation.key) - this.pendingOptimisticDirectDeletes.delete(mutation.key) - } else { - this.pendingOptimisticDirectUpserts.delete(mutation.key) - this.pendingOptimisticDirectDeletes.delete(mutation.key) - } break case `delete`: this.pendingOptimisticUpserts.delete(mutation.key) this.pendingOptimisticDeletes.add(mutation.key) - if (isDirectTransaction) { - this.pendingOptimisticDirectUpserts.delete(mutation.key) - this.pendingOptimisticDirectDeletes.add(mutation.key) - } else { - this.pendingOptimisticDirectUpserts.delete(mutation.key) - this.pendingOptimisticDirectDeletes.delete(mutation.key) - } break } } @@ -529,8 +510,6 @@ export class CollectionStateManager< if (mutation.optimistic) { this.pendingOptimisticUpserts.delete(mutation.key) this.pendingOptimisticDeletes.delete(mutation.key) - this.pendingOptimisticDirectUpserts.delete(mutation.key) - this.pendingOptimisticDirectDeletes.delete(mutation.key) } } } @@ -550,10 +529,7 @@ export class CollectionStateManager< } const staleOptimisticUpserts: Array = [] for (const [key, value] of this.pendingOptimisticUpserts) { - if ( - pendingSyncKeys.has(key) || - this.pendingOptimisticDirectUpserts.has(key) - ) { + if (pendingSyncKeys.has(key)) { this.optimisticUpserts.set(key, value) } else { staleOptimisticUpserts.push(key) @@ -565,10 +541,7 @@ export class CollectionStateManager< } const staleOptimisticDeletes: Array = [] for (const key of this.pendingOptimisticDeletes) { - if ( - pendingSyncKeys.has(key) || - this.pendingOptimisticDirectDeletes.has(key) - ) { + if (pendingSyncKeys.has(key)) { this.optimisticDeletes.add(key) } else { staleOptimisticDeletes.push(key) @@ -987,8 +960,6 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) - this.pendingOptimisticDirectUpserts.delete(key) - this.pendingOptimisticDirectDeletes.delete(key) break case `update`: { if (rowUpdateMode === `partial`) { @@ -1007,8 +978,6 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) - this.pendingOptimisticDirectUpserts.delete(key) - this.pendingOptimisticDirectDeletes.delete(key) break } case `delete`: @@ -1020,8 +989,6 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) - this.pendingOptimisticDirectUpserts.delete(key) - this.pendingOptimisticDirectDeletes.delete(key) break } } @@ -1378,8 +1345,6 @@ export class CollectionStateManager< this.optimisticDeletes.clear() this.pendingOptimisticUpserts.clear() this.pendingOptimisticDeletes.clear() - this.pendingOptimisticDirectUpserts.clear() - this.pendingOptimisticDirectDeletes.clear() this.clearOriginTrackingState() this.isLocalOnly = false this.size = 0 diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index c5d09039e..7fc5d67b4 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -11,6 +11,7 @@ import { MissingUpdateHandlerError, } from '../src/errors' import { createTransaction } from '../src/transactions' +import { createLiveQueryCollection, eq } from '../src/query/index.js' import { flushPromises, mockSyncCollectionOptionsNoInitialState, @@ -41,6 +42,95 @@ describe(`Collection`, () => { expect(() => createCollection()).toThrow(CollectionRequiresConfigError) }) + it(`removes optimistic insert when sync confirms with a different server-generated key`, async () => { + const options = mockSyncCollectionOptionsNoInitialState<{ + id: number + text: string + }>({ + id: `server-generated-key-test`, + getKey: (item) => item.id, + startSync: true, + }) + const collection = createCollection(options) + + options.utils.markReady() + await collection.stateWhenReady() + + const tx = collection.insert({ id: 4733, text: `two` }) + + expect(getStateEntries(collection)).toEqual([ + [4733, { id: 4733, text: `two` }], + ]) + + options.utils.begin() + options.utils.write({ type: `insert`, value: { id: 24, text: `two` } }) + options.utils.commit() + + // The sync commit is held while the local insert transaction is persisting. + expect(getStateEntries(collection)).toEqual([ + [4733, { id: 4733, text: `two` }], + ]) + + options.utils.resolveSync() + await tx.isPersisted.promise + await flushPromises() + + expect(getStateEntries(collection)).toEqual([[24, { id: 24, text: `two` }]]) + }) + + it(`updates live queries when an optimistic insert is replaced by a different server key`, async () => { + const options = mockSyncCollectionOptionsNoInitialState<{ + id: number + text: string + project_id: number + }>({ + id: `server-generated-key-live-query-test`, + getKey: (item) => item.id, + startSync: true, + }) + const collection = createCollection(options) + const liveCollection = createLiveQueryCollection((q) => + q + .from({ collection }) + .where(({ collection }) => eq(collection.project_id, 1)) + .select(({ collection }) => ({ + id: collection.id, + text: collection.text, + project_id: collection.project_id, + $synced: collection.$synced, + $origin: collection.$origin, + $key: collection.$key, + })), + ) + + options.utils.markReady() + await liveCollection.preload() + + const tx = collection.insert({ id: 4733, text: `two`, project_id: 1 }) + + expect(liveCollection.toArray.map((todo) => todo.id)).toEqual([4733]) + + options.utils.begin() + options.utils.write({ + type: `insert`, + value: { id: 24, text: `two`, project_id: 1 }, + }) + options.utils.commit() + + options.utils.resolveSync() + await tx.isPersisted.promise + await flushPromises() + + expect( + liveCollection.toArray.map((todo) => ({ + id: todo.id, + synced: todo.$synced, + origin: todo.$origin, + key: todo.$key, + })), + ).toEqual([{ id: 24, synced: true, origin: `remote`, key: 24 }]) + }) + it(`should throw an error when trying to use mutation operations outside of a transaction`, async () => { // Create a collection with sync but no mutationFn const collection = createCollection<{ value: string }>({ diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 30b15887b..868c08521 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -739,6 +739,49 @@ describe(`Electric Integration`, () => { expect(testCollection._state.syncedData.size).toEqual(1) }) + it(`should remove optimistic insert when txid sync confirms a different server-generated key`, async () => { + const txid = 1234 + const onInsert = vi.fn().mockResolvedValue({ txid }) + + const testCollection = createCollection( + electricCollectionOptions({ + id: `test-server-generated-key-txid`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + startSync: true, + getKey: (item: Row) => item.id as number, + onInsert, + }), + ) + + const tx = testCollection.insert({ id: 4733, text: `two` }) + + expect(stripVirtualProps(testCollection.get(4733))).toEqual({ + id: 4733, + text: `two`, + }) + + subscriber([ + { + key: `24`, + value: { id: 24, text: `two` }, + headers: { operation: `insert`, txids: [txid] }, + }, + { headers: { control: `up-to-date` } }, + ]) + + await tx.isPersisted.promise + + expect(testCollection.has(4733)).toBe(false) + expect(stripVirtualProps(testCollection.get(24))).toEqual({ + id: 24, + text: `two`, + }) + expect(Array.from(testCollection.state.keys())).toEqual([24]) + }) + it(`should support void strategy when handler returns nothing`, async () => { const onInsert = vi.fn().mockResolvedValue(undefined) From 1ade6aae84652ace9d17049c19153007bb89b7f4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 08:51:16 -0600 Subject: [PATCH 2/6] ci: run e2e tests on node 22 --- .github/workflows/e2e-tests.yml | 2 +- packages/db/tests/utils.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a7afbaab1..3678cd19f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' + node-version: '22.13' cache: 'pnpm' - name: Install dependencies diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 2b5c5d0c8..41fc65a9d 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -311,6 +311,7 @@ type MockSyncCollectionConfigNoInitialState = { id: string getKey: (item: T) => string | number autoIndex?: `off` | `eager` + startSync?: boolean defaultIndexType?: IndexConstructor } From f3020193205c3f74f968c1ee082325f51c0f6b7a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 09:11:42 -0600 Subject: [PATCH 3/6] refactor(db): remove dead DIRECT_TRANSACTION_METADATA_KEY code The only consumer was in state.ts, which was removed in the prior commit. Clean up the orphaned constant, import, and metadata usage in mutations.ts. Co-Authored-By: Claude Opus 4.6 --- packages/db/src/collection/mutations.ts | 13 +++---------- packages/db/src/collection/transaction-metadata.ts | 1 - 2 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 packages/db/src/collection/transaction-metadata.ts diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 963110801..cc69fe507 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -17,7 +17,6 @@ import { UndefinedKeyError, UpdateKeyNotFoundError, } from '../errors' -import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { Collection, CollectionImpl } from './index.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -231,9 +230,7 @@ export class CollectionMutationsManager< } else { // Create a new transaction with a mutation function that calls the onInsert handler const directOpTransaction = createTransaction({ - metadata: { - [DIRECT_TRANSACTION_METADATA_KEY]: true, - }, + metadata: {}, mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection return await this.config.onInsert!({ @@ -430,9 +427,7 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onUpdate handler const directOpTransaction = createTransaction({ - metadata: { - [DIRECT_TRANSACTION_METADATA_KEY]: true, - }, + metadata: {}, mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection return this.config.onUpdate!({ @@ -536,9 +531,7 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onDelete handler const directOpTransaction = createTransaction({ autoCommit: true, - metadata: { - [DIRECT_TRANSACTION_METADATA_KEY]: true, - }, + metadata: {}, mutationFn: async (params) => { // Call the onDelete handler with the transaction and collection return this.config.onDelete!({ diff --git a/packages/db/src/collection/transaction-metadata.ts b/packages/db/src/collection/transaction-metadata.ts deleted file mode 100644 index c1de2abb9..000000000 --- a/packages/db/src/collection/transaction-metadata.ts +++ /dev/null @@ -1 +0,0 @@ -export const DIRECT_TRANSACTION_METADATA_KEY = `__tanstack_db_direct` From 8613e6526f01a9eaae1661f2a63a226affe8aa4c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 09:12:16 -0600 Subject: [PATCH 4/6] chore: add changeset for stale optimistic row fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-stale-optimistic-server-key.md | 5 +++ packages/db/src/collection/state.ts | 44 ++++++++++--------- 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-stale-optimistic-server-key.md diff --git a/.changeset/fix-stale-optimistic-server-key.md b/.changeset/fix-stale-optimistic-server-key.md new file mode 100644 index 000000000..0d6c43a0f --- /dev/null +++ b/.changeset/fix-stale-optimistic-server-key.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix stale optimistic rows persisting when sync confirms a different server-generated key. Previously, direct transactions (from `collection.insert()` etc.) had their optimistic rows exempted from stale-row cleanup, which prevented temp-key rows from being removed when the server returned a different primary key. diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 2405864a6..7ec89e37d 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -527,29 +527,11 @@ export class CollectionStateManager< pendingSyncKeys.add(operation.key as TKey) } } - const staleOptimisticUpserts: Array = [] for (const [key, value] of this.pendingOptimisticUpserts) { - if (pendingSyncKeys.has(key)) { - this.optimisticUpserts.set(key, value) - } else { - staleOptimisticUpserts.push(key) - } - } - for (const key of staleOptimisticUpserts) { - this.pendingOptimisticUpserts.delete(key) - this.pendingLocalOrigins.delete(key) + this.optimisticUpserts.set(key, value) } - const staleOptimisticDeletes: Array = [] for (const key of this.pendingOptimisticDeletes) { - if (pendingSyncKeys.has(key)) { - this.optimisticDeletes.add(key) - } else { - staleOptimisticDeletes.push(key) - } - } - for (const key of staleOptimisticDeletes) { - this.pendingOptimisticDeletes.delete(key) - this.pendingLocalOrigins.delete(key) + this.optimisticDeletes.add(key) } const activeTransactions: Array> = [] @@ -1127,6 +1109,28 @@ export class CollectionStateManager< } } + // A completed optimistic insert may have used a temporary client key while + // the sync confirmation used a different server-generated key. Once a + // sync commit has been applied, stop retaining completed optimistic keys + // that were not confirmed by this commit so the temporary row is removed. + for (const [key, previousValue] of this.pendingOptimisticUpserts) { + if (!changedKeys.has(key)) { + changedKeys.add(key) + if (!currentVisibleState.has(key)) { + currentVisibleState.set(key, previousValue) + } + this.pendingOptimisticUpserts.delete(key) + this.pendingLocalOrigins.delete(key) + } + } + for (const key of this.pendingOptimisticDeletes) { + if (!changedKeys.has(key)) { + changedKeys.add(key) + } + this.pendingOptimisticDeletes.delete(key) + this.pendingLocalOrigins.delete(key) + } + // Now check what actually changed in the final visible state for (const key of changedKeys) { const previousVisibleValue = currentVisibleState.get(key) From 36b11dea65d91ab9904e64e439245dbd48037a29 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 10:38:40 -0600 Subject: [PATCH 5/6] docs: forbid rewriting published branch history --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6654f89bf..a92ff4676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This guide provides principles and patterns for AI agents contributing to the Ta 8. [Function Design](#function-design) 9. [Modern JavaScript Patterns](#modern-javascript-patterns) 10. [Edge Cases and Corner Cases](#edge-cases-and-corner-cases) +11. [Git and PR Hygiene](#git-and-pr-hygiene) ## Type Safety @@ -563,6 +564,37 @@ const filtered = items.filter((item) => item.value > 0) // Should ignore the stale snapshot ``` +## Git and PR Hygiene + +### Never Rewrite Published Branch History + +**Do not amend, rebase, squash, or force-push a branch after it has been pushed or has an open PR.** This includes `git push --force` and `git push --force-with-lease`. + +Once a branch is visible to others, treat its history as shared. If CI fails or follow-up changes are needed, add a normal follow-up commit and push normally. + +**❌ Bad:** + +```bash +git commit --amend --no-edit +git push --force-with-lease +``` + +**✅ Good:** + +```bash +git add +git commit -m "fix: address CI failure" +git push +``` + +**Key Principles:** + +- Never force-push unless the user explicitly asks for it in that moment. +- Do not assume `--force-with-lease` is acceptable; it still rewrites shared history. +- Prefer small follow-up commits over rewritten history on PR branches. +- If a clean history is desired, let the human maintainer squash or rebase during merge. +- If you think history rewriting is necessary, stop and ask for explicit confirmation before running any command. + ## Package Versioning ### Understand Semantic Versioning From 68992d5cab8e3807c5cf0a7fd61ad300293db7f1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 22 May 2026 10:57:27 -0600 Subject: [PATCH 6/6] fix(db): retain only direct optimistic rows until sync --- packages/db/src/collection/mutations.ts | 7 +- packages/db/src/collection/state.ts | 68 +++++++++++++++++-- .../db/src/collection/transaction-metadata.ts | 1 + 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 packages/db/src/collection/transaction-metadata.ts diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index cc69fe507..765e409ef 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -17,6 +17,7 @@ import { UndefinedKeyError, UpdateKeyNotFoundError, } from '../errors' +import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { Collection, CollectionImpl } from './index.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -230,7 +231,7 @@ export class CollectionMutationsManager< } else { // Create a new transaction with a mutation function that calls the onInsert handler const directOpTransaction = createTransaction({ - metadata: {}, + metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection return await this.config.onInsert!({ @@ -427,7 +428,7 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onUpdate handler const directOpTransaction = createTransaction({ - metadata: {}, + metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection return this.config.onUpdate!({ @@ -531,7 +532,7 @@ export class CollectionMutationsManager< // Create a new transaction with a mutation function that calls the onDelete handler const directOpTransaction = createTransaction({ autoCommit: true, - metadata: {}, + metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { // Call the onDelete handler with the transaction and collection return this.config.onDelete!({ diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 7ec89e37d..9cbdebb23 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -1,6 +1,7 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' import { enrichRowWithVirtualProps } from '../virtual-props.js' +import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { VirtualOrigin, VirtualRowProps, @@ -80,6 +81,8 @@ export class CollectionStateManager< public optimisticDeletes = new Set() public pendingOptimisticUpserts = new Map() public pendingOptimisticDeletes = new Set() + public pendingOptimisticDirectUpserts = new Set() + public pendingOptimisticDirectDeletes = new Set() /** * Tracks the origin of confirmed changes for each row. @@ -477,6 +480,8 @@ export class CollectionStateManager< // Update pending optimistic state for completed/failed transactions for (const transaction of this.transactions.values()) { + const isDirectTransaction = + transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true if (transaction.state === `completed`) { for (const mutation of transaction.mutations) { if (!this.isThisCollection(mutation.collection)) { @@ -494,10 +499,24 @@ export class CollectionStateManager< mutation.modified as TOutput, ) this.pendingOptimisticDeletes.delete(mutation.key) + if (isDirectTransaction) { + this.pendingOptimisticDirectUpserts.add(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } else { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } break case `delete`: this.pendingOptimisticUpserts.delete(mutation.key) this.pendingOptimisticDeletes.add(mutation.key) + if (isDirectTransaction) { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.add(mutation.key) + } else { + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) + } break } } @@ -510,6 +529,8 @@ export class CollectionStateManager< if (mutation.optimistic) { this.pendingOptimisticUpserts.delete(mutation.key) this.pendingOptimisticDeletes.delete(mutation.key) + this.pendingOptimisticDirectUpserts.delete(mutation.key) + this.pendingOptimisticDirectDeletes.delete(mutation.key) } } } @@ -527,11 +548,35 @@ export class CollectionStateManager< pendingSyncKeys.add(operation.key as TKey) } } + const staleOptimisticUpserts: Array = [] for (const [key, value] of this.pendingOptimisticUpserts) { - this.optimisticUpserts.set(key, value) + if ( + pendingSyncKeys.has(key) || + this.pendingOptimisticDirectUpserts.has(key) + ) { + this.optimisticUpserts.set(key, value) + } else { + staleOptimisticUpserts.push(key) + } } + for (const key of staleOptimisticUpserts) { + this.pendingOptimisticUpserts.delete(key) + this.pendingLocalOrigins.delete(key) + } + const staleOptimisticDeletes: Array = [] for (const key of this.pendingOptimisticDeletes) { - this.optimisticDeletes.add(key) + if ( + pendingSyncKeys.has(key) || + this.pendingOptimisticDirectDeletes.has(key) + ) { + this.optimisticDeletes.add(key) + } else { + staleOptimisticDeletes.push(key) + } + } + for (const key of staleOptimisticDeletes) { + this.pendingOptimisticDeletes.delete(key) + this.pendingLocalOrigins.delete(key) } const activeTransactions: Array> = [] @@ -942,6 +987,8 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break case `update`: { if (rowUpdateMode === `partial`) { @@ -960,6 +1007,8 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break } case `delete`: @@ -971,6 +1020,8 @@ export class CollectionStateManager< this.pendingLocalOrigins.delete(key) this.pendingOptimisticUpserts.delete(key) this.pendingOptimisticDeletes.delete(key) + this.pendingOptimisticDirectUpserts.delete(key) + this.pendingOptimisticDirectDeletes.delete(key) break } } @@ -1113,23 +1164,28 @@ export class CollectionStateManager< // the sync confirmation used a different server-generated key. Once a // sync commit has been applied, stop retaining completed optimistic keys // that were not confirmed by this commit so the temporary row is removed. - for (const [key, previousValue] of this.pendingOptimisticUpserts) { + for (const key of this.pendingOptimisticDirectUpserts) { if (!changedKeys.has(key)) { changedKeys.add(key) if (!currentVisibleState.has(key)) { - currentVisibleState.set(key, previousValue) + const previousValue = previousOptimisticUpserts.get(key) + if (previousValue !== undefined) { + currentVisibleState.set(key, previousValue) + } } this.pendingOptimisticUpserts.delete(key) this.pendingLocalOrigins.delete(key) } } - for (const key of this.pendingOptimisticDeletes) { + for (const key of this.pendingOptimisticDirectDeletes) { if (!changedKeys.has(key)) { changedKeys.add(key) } this.pendingOptimisticDeletes.delete(key) this.pendingLocalOrigins.delete(key) } + this.pendingOptimisticDirectUpserts.clear() + this.pendingOptimisticDirectDeletes.clear() // Now check what actually changed in the final visible state for (const key of changedKeys) { @@ -1349,6 +1405,8 @@ export class CollectionStateManager< this.optimisticDeletes.clear() this.pendingOptimisticUpserts.clear() this.pendingOptimisticDeletes.clear() + this.pendingOptimisticDirectUpserts.clear() + this.pendingOptimisticDirectDeletes.clear() this.clearOriginTrackingState() this.isLocalOnly = false this.size = 0 diff --git a/packages/db/src/collection/transaction-metadata.ts b/packages/db/src/collection/transaction-metadata.ts new file mode 100644 index 000000000..c1de2abb9 --- /dev/null +++ b/packages/db/src/collection/transaction-metadata.ts @@ -0,0 +1 @@ +export const DIRECT_TRANSACTION_METADATA_KEY = `__tanstack_db_direct`