From 10ca05dcd32d4920f7903518d186b310a92d1735 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 15:58:23 +0000 Subject: [PATCH 1/2] feat: add collection.rollbackOptimisticUpdates() for server-side update conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using paced/debounced mutations alongside server-side updates (e.g., from websockets), pending optimistic mutations can overwrite incoming server state. This adds a `rollbackOptimisticUpdates(keys?)` method on Collection that cancels pending transactions for specific keys (or all keys), allowing the server update to take precedence. Also makes the paced mutations commit callback resilient to externally rolled-back transactions — instead of throwing when the debounce/throttle timer fires after a rollback, it silently clears the reference and allows the next mutation to create a fresh transaction. https://claude.ai/code/session_01FPgcCTxP2SC8NTeWx9xhJV --- packages/db/src/collection/index.ts | 71 +++ packages/db/src/paced-mutations.ts | 17 +- .../tests/rollback-optimistic-updates.test.ts | 521 ++++++++++++++++++ 3 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 packages/db/tests/rollback-optimistic-updates.test.ts diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 39f59ed73..0b5b9dfed 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -916,6 +916,77 @@ export class CollectionImpl< return this._events.waitFor(event, timeout) } + /** + * Rollback optimistic updates for specific keys or all keys in the collection. + * + * When a server-side update arrives for entities that have pending optimistic + * mutations (e.g., from paced/debounced mutations), this method cancels those + * mutations so the server state takes precedence. + * + * Rolled-back transactions cascade: if a transaction touches other keys beyond + * the ones specified, those mutations are also rolled back (same as the normal + * transaction conflict rollback behavior). + * + * @param keys - Optional key or array of keys to rollback transactions for. + * If omitted, all pending transactions for this collection are rolled back. + * @example + * // Rollback all optimistic updates for a specific entity when a server update arrives + * sync: ({ begin, write, commit }) => { + * socket.on('update', (item) => { + * collection.rollbackOptimisticUpdates(item.id) + * begin() + * write({ type: 'update', key: item.id, value: item }) + * commit() + * }) + * } + * + * @example + * // Rollback all optimistic updates in the collection + * collection.rollbackOptimisticUpdates() + */ + public rollbackOptimisticUpdates(keys?: TKey | Array): void { + const globalKeysToMatch = keys !== undefined + ? new Set( + (Array.isArray(keys) ? keys : [keys]).map( + (key) => `KEY::${this.id}/${key}`, + ), + ) + : undefined + + // Collect transactions to rollback first to avoid iterator invalidation + // from cascade rollbacks + const transactionsToRollback: Array> = [] + + for (const transaction of this._state.transactions.values()) { + if ( + transaction.state === `completed` || + transaction.state === `failed` + ) { + continue + } + + const shouldRollback = + !globalKeysToMatch || + transaction.mutations.some((m) => globalKeysToMatch.has(m.globalKey)) + + if (shouldRollback) { + transactionsToRollback.push(transaction) + } + } + + for (const transaction of transactionsToRollback) { + // Re-check state since a cascade rollback from a previous iteration + // may have already failed this transaction + if ( + transaction.state === `completed` || + transaction.state === `failed` + ) { + continue + } + transaction.rollback() + } + } + /** * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection diff --git a/packages/db/src/paced-mutations.ts b/packages/db/src/paced-mutations.ts index d1d6d9a3e..f159f86b4 100644 --- a/packages/db/src/paced-mutations.ts +++ b/packages/db/src/paced-mutations.ts @@ -97,16 +97,13 @@ export function createPacedMutations< // Commit callback that the strategy will call when it's time to persist const commitCallback = () => { - if (!activeTransaction) { - throw new Error( - `Strategy callback called but no active transaction exists. This indicates a bug in the strategy implementation.`, - ) - } - - if (activeTransaction.state !== `pending`) { - throw new Error( - `Strategy callback called but active transaction is in state "${activeTransaction.state}". Expected "pending".`, - ) + if (!activeTransaction || activeTransaction.state !== `pending`) { + // Transaction was externally cancelled (e.g., rolled back due to a + // server-side update via collection.rollbackOptimisticUpdates(), or + // via cascade conflict rollback). Clear the reference so the next + // mutate() call creates a fresh transaction. + activeTransaction = null + return } const txToCommit = activeTransaction diff --git a/packages/db/tests/rollback-optimistic-updates.test.ts b/packages/db/tests/rollback-optimistic-updates.test.ts new file mode 100644 index 000000000..156d77644 --- /dev/null +++ b/packages/db/tests/rollback-optimistic-updates.test.ts @@ -0,0 +1,521 @@ +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '../src/collection' +import { createPacedMutations } from '../src/paced-mutations' +import { debounceStrategy, throttleStrategy } from '../src/strategies' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from './utils' + +type Todo = { + id: number + text: string + revision: number +} + +/** + * Helper to create a collection that's ready for testing. + */ +async function createReadyCollection(opts: { + id: string + getKey: (item: T) => string | number +}) { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState(opts), + ) + + const preloadPromise = collection.preload() + collection.utils.begin() + collection.utils.commit() + collection.utils.markReady() + await preloadPromise + + return collection +} + +/** + * Helper to create a collection with initial data ready for testing. + */ +async function createReadyCollectionWithData(opts: { + id: string + getKey: (item: T) => string | number + initialData: Array +}) { + const collection = createCollection( + mockSyncCollectionOptions(opts), + ) + + const preloadPromise = collection.preload() + await preloadPromise + + return collection +} + +describe(`rollbackOptimisticUpdates`, () => { + describe(`basic rollback`, () => { + it(`should rollback all pending transactions when no keys are specified`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [ + { id: 1, text: `Buy milk`, revision: 1 }, + { id: 2, text: `Walk dog`, revision: 1 }, + ], + }) + + // Create optimistic updates + collection.update(1, (draft) => { + draft.text = `Buy almond milk` + }) + collection.update(2, (draft) => { + draft.text = `Walk the cat` + }) + + // Verify optimistic state + expect(collection.get(1)?.text).toBe(`Buy almond milk`) + expect(collection.get(2)?.text).toBe(`Walk the cat`) + + // Rollback all + collection.rollbackOptimisticUpdates() + + // Should revert to synced data + expect(collection.get(1)?.text).toBe(`Buy milk`) + expect(collection.get(2)?.text).toBe(`Walk dog`) + }) + + it(`should rollback only transactions affecting specified keys`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [ + { id: 1, text: `Buy milk`, revision: 1 }, + { id: 2, text: `Walk dog`, revision: 1 }, + ], + }) + + // Create optimistic updates on different keys + collection.update(1, (draft) => { + draft.text = `Buy almond milk` + }) + collection.update(2, (draft) => { + draft.text = `Walk the cat` + }) + + // Rollback only key 1 + collection.rollbackOptimisticUpdates(1) + + // Key 1 should revert, key 2 should keep optimistic state + expect(collection.get(1)?.text).toBe(`Buy milk`) + expect(collection.get(2)?.text).toBe(`Walk the cat`) + }) + + it(`should rollback transactions affecting multiple specified keys`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [ + { id: 1, text: `Buy milk`, revision: 1 }, + { id: 2, text: `Walk dog`, revision: 1 }, + { id: 3, text: `Clean house`, revision: 1 }, + ], + }) + + collection.update(1, (draft) => { + draft.text = `Updated 1` + }) + collection.update(2, (draft) => { + draft.text = `Updated 2` + }) + collection.update(3, (draft) => { + draft.text = `Updated 3` + }) + + // Rollback keys 1 and 3 + collection.rollbackOptimisticUpdates([1, 3]) + + expect(collection.get(1)?.text).toBe(`Buy milk`) + expect(collection.get(2)?.text).toBe(`Updated 2`) + expect(collection.get(3)?.text).toBe(`Clean house`) + }) + + it(`should be a no-op when there are no pending transactions`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + // No pending transactions - this should not throw + collection.rollbackOptimisticUpdates() + collection.rollbackOptimisticUpdates(1) + collection.rollbackOptimisticUpdates([1, 2]) + + expect(collection.get(1)?.text).toBe(`Buy milk`) + }) + + it(`should be a no-op for keys that have no pending transactions`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [ + { id: 1, text: `Buy milk`, revision: 1 }, + { id: 2, text: `Walk dog`, revision: 1 }, + ], + }) + + // Only update key 1 + collection.update(1, (draft) => { + draft.text = `Updated` + }) + + // Rollback key 2 (which has no pending transaction) - should not throw + collection.rollbackOptimisticUpdates(2) + + // Key 1 should still have optimistic update + expect(collection.get(1)?.text).toBe(`Updated`) + expect(collection.get(2)?.text).toBe(`Walk dog`) + }) + + it(`should handle optimistic inserts`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Create an optimistic insert + collection.insert({ id: 1, text: `New item`, revision: 1 }) + + expect(collection.get(1)?.text).toBe(`New item`) + expect(collection.size).toBe(1) + + // Rollback the insert + collection.rollbackOptimisticUpdates(1) + + expect(collection.get(1)).toBeUndefined() + expect(collection.size).toBe(0) + }) + + it(`should handle optimistic deletes`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + // Optimistic delete + collection.delete(1) + expect(collection.get(1)).toBeUndefined() + + // Rollback + collection.rollbackOptimisticUpdates(1) + + // Item should reappear + expect(collection.get(1)?.text).toBe(`Buy milk`) + }) + }) + + describe(`with paced mutations`, () => { + it(`should rollback a debounced paced mutation before it commits`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 100 }), + }) + + // Apply a debounced mutation + const tx = mutate({ id: 1, text: `Buy almond milk` }) + + // Verify optimistic state is applied + expect(collection.get(1)?.text).toBe(`Buy almond milk`) + expect(tx.state).toBe(`pending`) + + // Rollback before the debounce fires + collection.rollbackOptimisticUpdates(1) + + // Optimistic state should be reverted + expect(collection.get(1)?.text).toBe(`Buy milk`) + expect(tx.state).toBe(`failed`) + + // Wait for the debounce period to pass + await new Promise((resolve) => setTimeout(resolve, 150)) + + // The mutationFn should NOT have been called (transaction was rolled back) + expect(mutationFn).not.toHaveBeenCalled() + + // State should still be reverted + expect(collection.get(1)?.text).toBe(`Buy milk`) + }) + + it(`should allow new paced mutations after rollback`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 50 }), + }) + + // First mutation + mutate({ id: 1, text: `First edit` }) + expect(collection.get(1)?.text).toBe(`First edit`) + + // Rollback + collection.rollbackOptimisticUpdates(1) + expect(collection.get(1)?.text).toBe(`Buy milk`) + + // New mutation after rollback should work + const tx2 = mutate({ id: 1, text: `Second edit` }) + expect(collection.get(1)?.text).toBe(`Second edit`) + expect(tx2.state).toBe(`pending`) + + // Wait for debounce to commit the new mutation + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mutationFn).toHaveBeenCalledTimes(1) + expect(tx2.state).toBe(`completed`) + }) + + it(`should rollback a throttled paced mutation`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: throttleStrategy({ wait: 100, leading: false, trailing: true }), + }) + + // Apply a throttled mutation + const tx = mutate({ id: 1, text: `Throttled edit` }) + expect(collection.get(1)?.text).toBe(`Throttled edit`) + + // Rollback before the throttle fires + collection.rollbackOptimisticUpdates(1) + expect(collection.get(1)?.text).toBe(`Buy milk`) + expect(tx.state).toBe(`failed`) + + // Wait for throttle period + await new Promise((resolve) => setTimeout(resolve, 150)) + + // mutationFn should NOT have been called + expect(mutationFn).not.toHaveBeenCalled() + }) + }) + + describe(`server-side update scenario`, () => { + it(`should allow server update to take precedence over paced mutations`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed initial data via sync + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + }) + collection.utils.commit() + + expect(collection.get(1)?.text).toBe(`Buy milk`) + + // User starts editing with debounced mutations + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 200 }), + }) + + mutate({ id: 1, text: `Buy alm` }) + mutate({ id: 1, text: `Buy almo` }) + mutate({ id: 1, text: `Buy almon` }) + + // Verify optimistic state + expect(collection.get(1)?.text).toBe(`Buy almon`) + + // Server-side update arrives (e.g., another user changed this item) + // First, rollback the optimistic updates for this entity + collection.rollbackOptimisticUpdates(1) + + // Then apply the server update + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Buy eggs`, revision: 2 }, + }) + collection.utils.commit() + + // Server data should be visible + expect(collection.get(1)?.text).toBe(`Buy eggs`) + expect(collection.get(1)?.revision).toBe(2) + + // Wait for the debounce to fire (it should be a no-op) + await new Promise((resolve) => setTimeout(resolve, 250)) + + // mutationFn should NOT have been called + expect(mutationFn).not.toHaveBeenCalled() + + // Server data should still be visible + expect(collection.get(1)?.text).toBe(`Buy eggs`) + }) + + it(`should only affect the relevant entity, leaving other edits intact`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed initial data + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Item 1`, revision: 1 }, + }) + collection.utils.write({ + type: `insert`, + value: { id: 2, text: `Item 2`, revision: 1 }, + }) + collection.utils.commit() + + // User edits both items with separate paced mutations + const mutateItem1 = createPacedMutations<{ text: string }>({ + onMutate: ({ text }) => { + collection.update(1, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 200 }), + }) + + const mutateItem2 = createPacedMutations<{ text: string }>({ + onMutate: ({ text }) => { + collection.update(2, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 200 }), + }) + + mutateItem1({ text: `Editing item 1` }) + mutateItem2({ text: `Editing item 2` }) + + expect(collection.get(1)?.text).toBe(`Editing item 1`) + expect(collection.get(2)?.text).toBe(`Editing item 2`) + + // Server update arrives only for item 1 + collection.rollbackOptimisticUpdates(1) + + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Server updated item 1`, revision: 2 }, + }) + collection.utils.commit() + + // Item 1 should show server data, item 2 should keep optimistic state + expect(collection.get(1)?.text).toBe(`Server updated item 1`) + expect(collection.get(2)?.text).toBe(`Editing item 2`) + }) + }) + + describe(`edge cases`, () => { + it(`should handle cascade rollback of related transactions`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Original`, revision: 1 }], + }) + + // Create two separate updates on the same key + collection.update(1, (draft) => { + draft.text = `First update` + }) + collection.update(1, (draft) => { + draft.text = `Second update` + }) + + expect(collection.get(1)?.text).toBe(`Second update`) + + // Rolling back key 1 should cascade to all transactions on that key + collection.rollbackOptimisticUpdates(1) + + expect(collection.get(1)?.text).toBe(`Original`) + }) + + it(`should handle rollback of transaction with mutations on multiple keys`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed data + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Item 1`, revision: 1 }, + }) + collection.utils.write({ + type: `insert`, + value: { id: 2, text: `Item 2`, revision: 1 }, + }) + collection.utils.commit() + + // Insert a new item (single-key transaction) + collection.insert({ id: 3, text: `Item 3`, revision: 1 }) + + expect(collection.size).toBe(3) + + // Rollback only key 3 + collection.rollbackOptimisticUpdates(3) + + // Only key 3 should be removed + expect(collection.size).toBe(2) + expect(collection.get(1)?.text).toBe(`Item 1`) + expect(collection.get(2)?.text).toBe(`Item 2`) + expect(collection.get(3)).toBeUndefined() + }) + }) +}) From b71ec882be493e9acebb612d83fb3c4f19c3164f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 22:03:33 +0000 Subject: [PATCH 2/2] feat: add getSyncedValue/getSyncedMetadata for conflict detection in paced mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace rollbackOptimisticUpdates with synced state introspection. Rolling back optimistic updates would yank controlled input state out from under the user mid-keystroke. Instead, expose the server-side state so the mutationFn can detect conflicts at persist time and reconcile (merge, skip, or retry) without disrupting the UI. - `collection.getSyncedValue(key)` — returns the authoritative server value for a key, ignoring optimistic overlays - `collection.getSyncedMetadata(key)` — returns sync metadata (revision numbers, ETags, etc.) for conflict detection The paced mutations commit callback is also made resilient to externally rolled-back transactions (e.g. via manual tx.rollback() or cascade) so the debounce/throttle timer doesn't throw. https://claude.ai/code/session_01FPgcCTxP2SC8NTeWx9xhJV --- packages/db/src/collection/index.ts | 105 ++-- .../tests/rollback-optimistic-updates.test.ts | 521 ------------------ .../tests/synced-state-introspection.test.ts | 490 ++++++++++++++++ 3 files changed, 533 insertions(+), 583 deletions(-) delete mode 100644 packages/db/tests/rollback-optimistic-updates.test.ts create mode 100644 packages/db/tests/synced-state-introspection.test.ts diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 0b5b9dfed..6ac4d915e 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -917,74 +917,55 @@ export class CollectionImpl< } /** - * Rollback optimistic updates for specific keys or all keys in the collection. + * Get the synced (server-side) value for a key, without any optimistic overlays. * - * When a server-side update arrives for entities that have pending optimistic - * mutations (e.g., from paced/debounced mutations), this method cancels those - * mutations so the server state takes precedence. + * This returns the authoritative server state for an entity, ignoring any + * pending optimistic mutations. Useful for detecting server-side changes + * during paced mutation persistence — compare `mutation.original` against + * the current synced value to decide whether to persist, merge, or skip. * - * Rolled-back transactions cascade: if a transaction touches other keys beyond - * the ones specified, those mutations are also rolled back (same as the normal - * transaction conflict rollback behavior). - * - * @param keys - Optional key or array of keys to rollback transactions for. - * If omitted, all pending transactions for this collection are rolled back. + * @param key - The key to look up + * @returns The synced value, or undefined if no synced data exists for this key * @example - * // Rollback all optimistic updates for a specific entity when a server update arrives - * sync: ({ begin, write, commit }) => { - * socket.on('update', (item) => { - * collection.rollbackOptimisticUpdates(item.id) - * begin() - * write({ type: 'update', key: item.id, value: item }) - * commit() - * }) + * // In a paced mutation's mutationFn, check for server-side conflicts + * mutationFn: async ({ transaction }) => { + * for (const mutation of transaction.mutations) { + * const serverValue = collection.getSyncedValue(mutation.key) + * if (serverValue && !deepEquals(serverValue, mutation.original)) { + * // Server data changed since this mutation was created — reconcile + * return + * } + * } + * await api.save(transaction.mutations) * } + */ + public getSyncedValue(key: TKey): TOutput | undefined { + return this._state.syncedData.get(key) + } + + /** + * Get the sync metadata for a key. * + * Sync metadata is set by the sync layer via write operations and can + * contain information like revision numbers, timestamps, or ETags that + * are useful for conflict detection. + * + * @param key - The key to look up + * @returns The sync metadata, or undefined if none exists for this key * @example - * // Rollback all optimistic updates in the collection - * collection.rollbackOptimisticUpdates() - */ - public rollbackOptimisticUpdates(keys?: TKey | Array): void { - const globalKeysToMatch = keys !== undefined - ? new Set( - (Array.isArray(keys) ? keys : [keys]).map( - (key) => `KEY::${this.id}/${key}`, - ), - ) - : undefined - - // Collect transactions to rollback first to avoid iterator invalidation - // from cascade rollbacks - const transactionsToRollback: Array> = [] - - for (const transaction of this._state.transactions.values()) { - if ( - transaction.state === `completed` || - transaction.state === `failed` - ) { - continue - } - - const shouldRollback = - !globalKeysToMatch || - transaction.mutations.some((m) => globalKeysToMatch.has(m.globalKey)) - - if (shouldRollback) { - transactionsToRollback.push(transaction) - } - } - - for (const transaction of transactionsToRollback) { - // Re-check state since a cascade rollback from a previous iteration - // may have already failed this transaction - if ( - transaction.state === `completed` || - transaction.state === `failed` - ) { - continue - } - transaction.rollback() - } + * // Check revision number before persisting + * mutationFn: async ({ transaction }) => { + * const mutation = transaction.mutations[0] + * const meta = collection.getSyncedMetadata(mutation.key) + * if (meta?.revision !== mutation.syncMetadata.revision) { + * // Server revision changed — skip or merge + * return + * } + * await api.save(transaction.mutations) + * } + */ + public getSyncedMetadata(key: TKey): unknown { + return this._state.syncedMetadata.get(key) } /** diff --git a/packages/db/tests/rollback-optimistic-updates.test.ts b/packages/db/tests/rollback-optimistic-updates.test.ts deleted file mode 100644 index 156d77644..000000000 --- a/packages/db/tests/rollback-optimistic-updates.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createCollection } from '../src/collection' -import { createPacedMutations } from '../src/paced-mutations' -import { debounceStrategy, throttleStrategy } from '../src/strategies' -import { - mockSyncCollectionOptions, - mockSyncCollectionOptionsNoInitialState, -} from './utils' - -type Todo = { - id: number - text: string - revision: number -} - -/** - * Helper to create a collection that's ready for testing. - */ -async function createReadyCollection(opts: { - id: string - getKey: (item: T) => string | number -}) { - const collection = createCollection( - mockSyncCollectionOptionsNoInitialState(opts), - ) - - const preloadPromise = collection.preload() - collection.utils.begin() - collection.utils.commit() - collection.utils.markReady() - await preloadPromise - - return collection -} - -/** - * Helper to create a collection with initial data ready for testing. - */ -async function createReadyCollectionWithData(opts: { - id: string - getKey: (item: T) => string | number - initialData: Array -}) { - const collection = createCollection( - mockSyncCollectionOptions(opts), - ) - - const preloadPromise = collection.preload() - await preloadPromise - - return collection -} - -describe(`rollbackOptimisticUpdates`, () => { - describe(`basic rollback`, () => { - it(`should rollback all pending transactions when no keys are specified`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [ - { id: 1, text: `Buy milk`, revision: 1 }, - { id: 2, text: `Walk dog`, revision: 1 }, - ], - }) - - // Create optimistic updates - collection.update(1, (draft) => { - draft.text = `Buy almond milk` - }) - collection.update(2, (draft) => { - draft.text = `Walk the cat` - }) - - // Verify optimistic state - expect(collection.get(1)?.text).toBe(`Buy almond milk`) - expect(collection.get(2)?.text).toBe(`Walk the cat`) - - // Rollback all - collection.rollbackOptimisticUpdates() - - // Should revert to synced data - expect(collection.get(1)?.text).toBe(`Buy milk`) - expect(collection.get(2)?.text).toBe(`Walk dog`) - }) - - it(`should rollback only transactions affecting specified keys`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [ - { id: 1, text: `Buy milk`, revision: 1 }, - { id: 2, text: `Walk dog`, revision: 1 }, - ], - }) - - // Create optimistic updates on different keys - collection.update(1, (draft) => { - draft.text = `Buy almond milk` - }) - collection.update(2, (draft) => { - draft.text = `Walk the cat` - }) - - // Rollback only key 1 - collection.rollbackOptimisticUpdates(1) - - // Key 1 should revert, key 2 should keep optimistic state - expect(collection.get(1)?.text).toBe(`Buy milk`) - expect(collection.get(2)?.text).toBe(`Walk the cat`) - }) - - it(`should rollback transactions affecting multiple specified keys`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [ - { id: 1, text: `Buy milk`, revision: 1 }, - { id: 2, text: `Walk dog`, revision: 1 }, - { id: 3, text: `Clean house`, revision: 1 }, - ], - }) - - collection.update(1, (draft) => { - draft.text = `Updated 1` - }) - collection.update(2, (draft) => { - draft.text = `Updated 2` - }) - collection.update(3, (draft) => { - draft.text = `Updated 3` - }) - - // Rollback keys 1 and 3 - collection.rollbackOptimisticUpdates([1, 3]) - - expect(collection.get(1)?.text).toBe(`Buy milk`) - expect(collection.get(2)?.text).toBe(`Updated 2`) - expect(collection.get(3)?.text).toBe(`Clean house`) - }) - - it(`should be a no-op when there are no pending transactions`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], - }) - - // No pending transactions - this should not throw - collection.rollbackOptimisticUpdates() - collection.rollbackOptimisticUpdates(1) - collection.rollbackOptimisticUpdates([1, 2]) - - expect(collection.get(1)?.text).toBe(`Buy milk`) - }) - - it(`should be a no-op for keys that have no pending transactions`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [ - { id: 1, text: `Buy milk`, revision: 1 }, - { id: 2, text: `Walk dog`, revision: 1 }, - ], - }) - - // Only update key 1 - collection.update(1, (draft) => { - draft.text = `Updated` - }) - - // Rollback key 2 (which has no pending transaction) - should not throw - collection.rollbackOptimisticUpdates(2) - - // Key 1 should still have optimistic update - expect(collection.get(1)?.text).toBe(`Updated`) - expect(collection.get(2)?.text).toBe(`Walk dog`) - }) - - it(`should handle optimistic inserts`, async () => { - const collection = await createReadyCollection({ - id: `test`, - getKey: (item) => item.id, - }) - - // Create an optimistic insert - collection.insert({ id: 1, text: `New item`, revision: 1 }) - - expect(collection.get(1)?.text).toBe(`New item`) - expect(collection.size).toBe(1) - - // Rollback the insert - collection.rollbackOptimisticUpdates(1) - - expect(collection.get(1)).toBeUndefined() - expect(collection.size).toBe(0) - }) - - it(`should handle optimistic deletes`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], - }) - - // Optimistic delete - collection.delete(1) - expect(collection.get(1)).toBeUndefined() - - // Rollback - collection.rollbackOptimisticUpdates(1) - - // Item should reappear - expect(collection.get(1)?.text).toBe(`Buy milk`) - }) - }) - - describe(`with paced mutations`, () => { - it(`should rollback a debounced paced mutation before it commits`, async () => { - const mutationFn = vi.fn(async () => {}) - - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], - }) - - const mutate = createPacedMutations<{ id: number; text: string }>({ - onMutate: ({ id, text }) => { - collection.update(id, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: debounceStrategy({ wait: 100 }), - }) - - // Apply a debounced mutation - const tx = mutate({ id: 1, text: `Buy almond milk` }) - - // Verify optimistic state is applied - expect(collection.get(1)?.text).toBe(`Buy almond milk`) - expect(tx.state).toBe(`pending`) - - // Rollback before the debounce fires - collection.rollbackOptimisticUpdates(1) - - // Optimistic state should be reverted - expect(collection.get(1)?.text).toBe(`Buy milk`) - expect(tx.state).toBe(`failed`) - - // Wait for the debounce period to pass - await new Promise((resolve) => setTimeout(resolve, 150)) - - // The mutationFn should NOT have been called (transaction was rolled back) - expect(mutationFn).not.toHaveBeenCalled() - - // State should still be reverted - expect(collection.get(1)?.text).toBe(`Buy milk`) - }) - - it(`should allow new paced mutations after rollback`, async () => { - const mutationFn = vi.fn(async () => {}) - - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], - }) - - const mutate = createPacedMutations<{ id: number; text: string }>({ - onMutate: ({ id, text }) => { - collection.update(id, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: debounceStrategy({ wait: 50 }), - }) - - // First mutation - mutate({ id: 1, text: `First edit` }) - expect(collection.get(1)?.text).toBe(`First edit`) - - // Rollback - collection.rollbackOptimisticUpdates(1) - expect(collection.get(1)?.text).toBe(`Buy milk`) - - // New mutation after rollback should work - const tx2 = mutate({ id: 1, text: `Second edit` }) - expect(collection.get(1)?.text).toBe(`Second edit`) - expect(tx2.state).toBe(`pending`) - - // Wait for debounce to commit the new mutation - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(mutationFn).toHaveBeenCalledTimes(1) - expect(tx2.state).toBe(`completed`) - }) - - it(`should rollback a throttled paced mutation`, async () => { - const mutationFn = vi.fn(async () => {}) - - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], - }) - - const mutate = createPacedMutations<{ id: number; text: string }>({ - onMutate: ({ id, text }) => { - collection.update(id, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: throttleStrategy({ wait: 100, leading: false, trailing: true }), - }) - - // Apply a throttled mutation - const tx = mutate({ id: 1, text: `Throttled edit` }) - expect(collection.get(1)?.text).toBe(`Throttled edit`) - - // Rollback before the throttle fires - collection.rollbackOptimisticUpdates(1) - expect(collection.get(1)?.text).toBe(`Buy milk`) - expect(tx.state).toBe(`failed`) - - // Wait for throttle period - await new Promise((resolve) => setTimeout(resolve, 150)) - - // mutationFn should NOT have been called - expect(mutationFn).not.toHaveBeenCalled() - }) - }) - - describe(`server-side update scenario`, () => { - it(`should allow server update to take precedence over paced mutations`, async () => { - const mutationFn = vi.fn(async () => {}) - - const collection = await createReadyCollection({ - id: `test`, - getKey: (item) => item.id, - }) - - // Seed initial data via sync - collection.utils.begin() - collection.utils.write({ - type: `insert`, - value: { id: 1, text: `Buy milk`, revision: 1 }, - }) - collection.utils.commit() - - expect(collection.get(1)?.text).toBe(`Buy milk`) - - // User starts editing with debounced mutations - const mutate = createPacedMutations<{ id: number; text: string }>({ - onMutate: ({ id, text }) => { - collection.update(id, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: debounceStrategy({ wait: 200 }), - }) - - mutate({ id: 1, text: `Buy alm` }) - mutate({ id: 1, text: `Buy almo` }) - mutate({ id: 1, text: `Buy almon` }) - - // Verify optimistic state - expect(collection.get(1)?.text).toBe(`Buy almon`) - - // Server-side update arrives (e.g., another user changed this item) - // First, rollback the optimistic updates for this entity - collection.rollbackOptimisticUpdates(1) - - // Then apply the server update - collection.utils.begin() - collection.utils.write({ - type: `update`, - key: 1, - value: { id: 1, text: `Buy eggs`, revision: 2 }, - }) - collection.utils.commit() - - // Server data should be visible - expect(collection.get(1)?.text).toBe(`Buy eggs`) - expect(collection.get(1)?.revision).toBe(2) - - // Wait for the debounce to fire (it should be a no-op) - await new Promise((resolve) => setTimeout(resolve, 250)) - - // mutationFn should NOT have been called - expect(mutationFn).not.toHaveBeenCalled() - - // Server data should still be visible - expect(collection.get(1)?.text).toBe(`Buy eggs`) - }) - - it(`should only affect the relevant entity, leaving other edits intact`, async () => { - const mutationFn = vi.fn(async () => {}) - - const collection = await createReadyCollection({ - id: `test`, - getKey: (item) => item.id, - }) - - // Seed initial data - collection.utils.begin() - collection.utils.write({ - type: `insert`, - value: { id: 1, text: `Item 1`, revision: 1 }, - }) - collection.utils.write({ - type: `insert`, - value: { id: 2, text: `Item 2`, revision: 1 }, - }) - collection.utils.commit() - - // User edits both items with separate paced mutations - const mutateItem1 = createPacedMutations<{ text: string }>({ - onMutate: ({ text }) => { - collection.update(1, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: debounceStrategy({ wait: 200 }), - }) - - const mutateItem2 = createPacedMutations<{ text: string }>({ - onMutate: ({ text }) => { - collection.update(2, (draft) => { - draft.text = text - }) - }, - mutationFn, - strategy: debounceStrategy({ wait: 200 }), - }) - - mutateItem1({ text: `Editing item 1` }) - mutateItem2({ text: `Editing item 2` }) - - expect(collection.get(1)?.text).toBe(`Editing item 1`) - expect(collection.get(2)?.text).toBe(`Editing item 2`) - - // Server update arrives only for item 1 - collection.rollbackOptimisticUpdates(1) - - collection.utils.begin() - collection.utils.write({ - type: `update`, - key: 1, - value: { id: 1, text: `Server updated item 1`, revision: 2 }, - }) - collection.utils.commit() - - // Item 1 should show server data, item 2 should keep optimistic state - expect(collection.get(1)?.text).toBe(`Server updated item 1`) - expect(collection.get(2)?.text).toBe(`Editing item 2`) - }) - }) - - describe(`edge cases`, () => { - it(`should handle cascade rollback of related transactions`, async () => { - const collection = await createReadyCollectionWithData({ - id: `test`, - getKey: (item) => item.id, - initialData: [{ id: 1, text: `Original`, revision: 1 }], - }) - - // Create two separate updates on the same key - collection.update(1, (draft) => { - draft.text = `First update` - }) - collection.update(1, (draft) => { - draft.text = `Second update` - }) - - expect(collection.get(1)?.text).toBe(`Second update`) - - // Rolling back key 1 should cascade to all transactions on that key - collection.rollbackOptimisticUpdates(1) - - expect(collection.get(1)?.text).toBe(`Original`) - }) - - it(`should handle rollback of transaction with mutations on multiple keys`, async () => { - const collection = await createReadyCollection({ - id: `test`, - getKey: (item) => item.id, - }) - - // Seed data - collection.utils.begin() - collection.utils.write({ - type: `insert`, - value: { id: 1, text: `Item 1`, revision: 1 }, - }) - collection.utils.write({ - type: `insert`, - value: { id: 2, text: `Item 2`, revision: 1 }, - }) - collection.utils.commit() - - // Insert a new item (single-key transaction) - collection.insert({ id: 3, text: `Item 3`, revision: 1 }) - - expect(collection.size).toBe(3) - - // Rollback only key 3 - collection.rollbackOptimisticUpdates(3) - - // Only key 3 should be removed - expect(collection.size).toBe(2) - expect(collection.get(1)?.text).toBe(`Item 1`) - expect(collection.get(2)?.text).toBe(`Item 2`) - expect(collection.get(3)).toBeUndefined() - }) - }) -}) diff --git a/packages/db/tests/synced-state-introspection.test.ts b/packages/db/tests/synced-state-introspection.test.ts new file mode 100644 index 000000000..425059890 --- /dev/null +++ b/packages/db/tests/synced-state-introspection.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '../src/collection' +import { createPacedMutations } from '../src/paced-mutations' +import { debounceStrategy } from '../src/strategies' +import { deepEquals } from '../src/utils' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from './utils' + +type Todo = { + id: number + text: string + revision: number +} + +/** + * Helper to create a collection that's ready for testing with manual sync control. + */ +async function createReadyCollection(opts: { + id: string + getKey: (item: T) => string | number +}) { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState(opts), + ) + + const preloadPromise = collection.preload() + collection.utils.begin() + collection.utils.commit() + collection.utils.markReady() + await preloadPromise + + return collection +} + +/** + * Helper to create a collection with initial data ready for testing. + */ +async function createReadyCollectionWithData(opts: { + id: string + getKey: (item: T) => string | number + initialData: Array +}) { + const collection = createCollection(mockSyncCollectionOptions(opts)) + + const preloadPromise = collection.preload() + await preloadPromise + + return collection +} + +describe(`synced state introspection`, () => { + describe(`getSyncedValue`, () => { + it(`should return the synced value for a key`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const synced = collection.getSyncedValue(1) + expect(synced).toEqual({ id: 1, text: `Buy milk`, revision: 1 }) + }) + + it(`should return undefined for a key that does not exist in synced data`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + expect(collection.getSyncedValue(999)).toBeUndefined() + }) + + it(`should return the synced value even when there are optimistic updates`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + // Apply an optimistic update + collection.update(1, (draft) => { + draft.text = `Buy almond milk` + }) + + // collection.get returns the optimistic value + expect(collection.get(1)?.text).toBe(`Buy almond milk`) + + // getSyncedValue returns the original server value + expect(collection.getSyncedValue(1)).toEqual({ + id: 1, + text: `Buy milk`, + revision: 1, + }) + }) + + it(`should return undefined for optimistic-only inserts`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Insert optimistically — not yet on the server + collection.insert({ id: 1, text: `New item`, revision: 1 }) + + expect(collection.get(1)?.text).toBe(`New item`) + expect(collection.getSyncedValue(1)).toBeUndefined() + }) + + it(`should still return the synced value when optimistically deleted`, async () => { + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + // Optimistic delete + collection.delete(1) + + // collection.get returns undefined (optimistic view) + expect(collection.get(1)).toBeUndefined() + + // getSyncedValue still returns the server value + expect(collection.getSyncedValue(1)).toEqual({ + id: 1, + text: `Buy milk`, + revision: 1, + }) + }) + + it(`should reflect server-side updates`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Insert via sync + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + }) + collection.utils.commit() + + expect(collection.getSyncedValue(1)).toEqual({ + id: 1, + text: `Buy milk`, + revision: 1, + }) + + // Server-side update arrives + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Buy eggs`, revision: 2 }, + }) + collection.utils.commit() + + expect(collection.getSyncedValue(1)).toEqual({ + id: 1, + text: `Buy eggs`, + revision: 2, + }) + }) + }) + + describe(`getSyncedMetadata`, () => { + it(`should return metadata set by sync operations`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Insert with metadata + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + metadata: { etag: `abc123`, revision: 1 }, + }) + collection.utils.commit() + + const meta = collection.getSyncedMetadata(1) as Record + expect(meta).toEqual({ etag: `abc123`, revision: 1 }) + }) + + it(`should return undefined for keys without metadata`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + expect(collection.getSyncedMetadata(999)).toBeUndefined() + }) + + it(`should merge metadata on update`, async () => { + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + metadata: { etag: `abc`, revision: 1 }, + }) + collection.utils.commit() + + // Update with new metadata + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Buy eggs`, revision: 2 }, + metadata: { etag: `def`, revision: 2 }, + }) + collection.utils.commit() + + const meta = collection.getSyncedMetadata(1) as Record + expect(meta).toEqual({ etag: `def`, revision: 2 }) + }) + }) + + describe(`conflict detection in paced mutations`, () => { + it(`should detect server-side change via getSyncedValue in mutationFn`, async () => { + const persistedMutations: Array = [] + const skippedConflicts: Array = [] + + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed initial data + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + }) + collection.utils.commit() + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn: async ({ transaction }) => { + // Check for conflicts before persisting + for (const mutation of transaction.mutations) { + const currentSynced = collection.getSyncedValue( + mutation.key as number, + ) + if ( + currentSynced && + !deepEquals(currentSynced, mutation.original) + ) { + // Server data changed since this mutation was created + skippedConflicts.push({ + key: mutation.key, + original: mutation.original, + serverValue: currentSynced, + }) + return // Skip persisting + } + } + persistedMutations.push(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 50 }), + }) + + // User starts editing + mutate({ id: 1, text: `Buy almond milk` }) + + // Server update arrives before debounce fires + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Buy eggs`, revision: 2 }, + }) + collection.utils.commit() + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 100)) + + // mutationFn was called but skipped due to conflict + expect(skippedConflicts).toHaveLength(1) + expect(skippedConflicts[0].key).toBe(1) + expect(persistedMutations).toHaveLength(0) + }) + + it(`should persist when no server-side conflict exists`, async () => { + const persistedMutations: Array = [] + + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed initial data + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + }) + collection.utils.commit() + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn: async ({ transaction }) => { + for (const mutation of transaction.mutations) { + const currentSynced = collection.getSyncedValue( + mutation.key as number, + ) + if ( + currentSynced && + !deepEquals(currentSynced, mutation.original) + ) { + return // Skip + } + } + persistedMutations.push(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 50 }), + }) + + // User edits — no server update arrives + mutate({ id: 1, text: `Buy almond milk` }) + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have persisted normally + expect(persistedMutations).toHaveLength(1) + }) + + it(`should allow merge strategy when server data differs`, async () => { + const mergedResults: Array = [] + + const collection = await createReadyCollection({ + id: `test`, + getKey: (item) => item.id, + }) + + // Seed initial data + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: 1, text: `Buy milk`, revision: 1 }, + }) + collection.utils.commit() + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn: async ({ transaction }) => { + for (const mutation of transaction.mutations) { + const currentSynced = collection.getSyncedValue( + mutation.key as number, + ) + const original = mutation.original as Todo + + if (currentSynced && currentSynced.revision !== original.revision) { + // Server changed — merge: keep the user's text change but + // base it on the latest server revision + mergedResults.push({ + key: mutation.key, + mergedValue: { + ...currentSynced, + text: (mutation.modified as Todo).text, + }, + }) + return + } + } + }, + strategy: debounceStrategy({ wait: 50 }), + }) + + // User starts editing + mutate({ id: 1, text: `Buy almond milk` }) + + // Server bumps revision with a different change + collection.utils.begin() + collection.utils.write({ + type: `update`, + key: 1, + value: { id: 1, text: `Buy eggs`, revision: 2 }, + }) + collection.utils.commit() + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have detected conflict and merged + expect(mergedResults).toHaveLength(1) + expect(mergedResults[0].mergedValue).toEqual({ + id: 1, + text: `Buy almond milk`, + revision: 2, + }) + }) + }) + + describe(`paced mutations resilience`, () => { + it(`should not throw when debounce fires after external transaction rollback`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 100 }), + }) + + // Apply a debounced mutation + const tx = mutate({ id: 1, text: `Buy almond milk` }) + expect(tx.state).toBe(`pending`) + + // Externally rollback the transaction (user code or cascade) + tx.rollback() + expect(tx.state).toBe(`failed`) + + // Wait for the debounce period — should not throw + await new Promise((resolve) => setTimeout(resolve, 150)) + + // mutationFn should NOT have been called + expect(mutationFn).not.toHaveBeenCalled() + }) + + it(`should create fresh transaction after external rollback`, async () => { + const mutationFn = vi.fn(async () => {}) + + const collection = await createReadyCollectionWithData({ + id: `test`, + getKey: (item) => item.id, + initialData: [{ id: 1, text: `Buy milk`, revision: 1 }], + }) + + const mutate = createPacedMutations<{ id: number; text: string }>({ + onMutate: ({ id, text }) => { + collection.update(id, (draft) => { + draft.text = text + }) + }, + mutationFn, + strategy: debounceStrategy({ wait: 50 }), + }) + + // First mutation — then rollback + const tx1 = mutate({ id: 1, text: `First edit` }) + tx1.rollback() + + // Wait for debounce to fire (no-op) + await new Promise((resolve) => setTimeout(resolve, 70)) + + // New mutation should create a fresh transaction + const tx2 = mutate({ id: 1, text: `Second edit` }) + expect(tx2).not.toBe(tx1) + expect(tx2.state).toBe(`pending`) + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mutationFn).toHaveBeenCalledTimes(1) + expect(tx2.state).toBe(`completed`) + }) + }) +})