/** * RED PHASE Tests for useMutation Hook (postgres-dsvj.4) * * Tests optimistic updates, rollback, onSuccess/onError callbacks, * and cache invalidation patterns for the usePostgresMutation hook. * * These tests should FAIL because the features are not fully implemented yet. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createUsePostgresMutation, createUseCollectionInsert, createUseCollectionUpdate, createUseCollectionDelete, } from '../hooks' import { createQueryAdapter, QueryAdapter, createCollectionMutationOptions, } from '../adapter' import { createQueryCollection, QueryCollection } from '../query/query-collection' import { createSyncCollection, SyncCollection } from '../sync/sync-collection' import { createOptimisticStore, OptimisticStore } from '../optimistic/optimistic-store' import type { BaseRecord, Collection, SyncCollectionOptions } from '../types' import type { MutationParams, MutationContext, QueryResult, PostgresMutationOptions } from '../adapter' // ============================================================================ // Test Types // ============================================================================ interface Todo extends BaseRecord { id: number title: string completed: boolean order: number createdAt: number } interface User extends BaseRecord { id: number name: string email: string active: boolean } // ============================================================================ // Mock Helpers // ============================================================================ function createMockFetch(response: unknown = { rows: [], rowCount: 1, command: 'INSERT' }) { return vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(response), }) } function createFailingFetch(error: string = 'Mutation failed') { return vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: error }), }) } function createMockAdapter(fetch = createMockFetch()) { return createQueryAdapter({ database: 'testdb', fetch }) } function createMockCollection(items: T[] = []): Collection { let _items = [...items] const subscribers = new Set<(items: T[]) => void>() return { id: 'test-collection', getAll: () => _items, get: (id) => _items.find(item => item.id === id), insert: vi.fn().mockImplementation(async (data) => { const newItem = { ...data, id: data.id ?? Date.now() } as T _items = [..._items, newItem] subscribers.forEach(cb => cb(_items)) return newItem }), update: vi.fn().mockImplementation(async (id, data) => { const existing = _items.find(item => item.id === id) if (!existing) throw new Error(`Item ${id} not found`) const updated = { ...existing, ...data } as T _items = _items.map(item => item.id === id ? updated : item) subscribers.forEach(cb => cb(_items)) return updated }), delete: vi.fn().mockImplementation(async (id) => { if (!_items.find(item => item.id === id)) throw new Error(`Item ${id} not found`) _items = _items.filter(item => item.id !== id) subscribers.forEach(cb => cb(_items)) }), subscribe: (callback: (items: T[]) => void) => { subscribers.add(callback) return () => subscribers.delete(callback) }, getSyncState: () => ({ connected: true, initialized: true, pendingCount: 0, }), } } // ============================================================================ // Section 1: Optimistic Updates // ============================================================================ describe('useMutation Hook: Optimistic Updates', () => { it('should apply optimistic insert immediately', async () => { const store = createOptimisticStore({ collectionId: 'todos' }) const mutationId = store.applyOptimistic( 'insert', 'temp-1', { title: 'New Todo', completed: false, order: 1, createdAt: Date.now() } ) const state = store.getState() expect(state.applied).toHaveLength(1) expect(state.applied[0].type).toBe('insert') expect(state.applied[0].data.title).toBe('New Todo') expect(mutationId).toBeDefined() }) it('should apply optimistic update with previous data', async () => { const existingTodo: Todo = { id: 1, title: 'Old Title', completed: false, order: 1, createdAt: 1000 } const store = createOptimisticStore({ collectionId: 'todos' }) store.applyOptimistic( 'update', 1, { title: 'Updated Title' }, existingTodo ) const state = store.getState() expect(state.applied[0].previousData).toEqual(existingTodo) expect(state.applied[0].data.title).toBe('Updated Title') }) it('should apply optimistic delete', async () => { const store = createOptimisticStore({ collectionId: 'todos' }) store.applyOptimistic('delete', 1, { id: 1 } as Partial) const state = store.getState() expect(state.applied[0].type).toBe('delete') expect(state.applied[0].recordId).toBe(1) }) it('should confirm mutation and remove from applied list', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const mutationId = store.applyOptimistic('insert', 'temp-1', { title: 'New' }) expect(store.getState().applied).toHaveLength(1) store.confirmMutation(mutationId) expect(store.getState().applied).toHaveLength(0) }) it('RED: should apply optimistic update to collection data view', async () => { const collection = createQueryCollection({ id: 'todos', table: 'todos', queryFn: async () => [ { id: 1, title: 'Existing', completed: false, order: 1, createdAt: 1000 }, ], }) await collection.fetch() const store = createOptimisticStore({ collectionId: 'todos' }) // Apply optimistic insert store.applyOptimistic('insert', 'temp-1', { id: 999 as unknown as number, title: 'Optimistic Todo', completed: false, order: 2, createdAt: Date.now(), }) // The collection should reflect the optimistic data // @ts-expect-error - getOptimisticData not yet implemented const optimisticView = collection.getOptimisticData(store) expect(optimisticView).toHaveLength(2) // original + optimistic expect(optimisticView.find((t: Todo) => t.title === 'Optimistic Todo')).toBeDefined() }) it('RED: should merge optimistic updates with server data', async () => { const collection = createQueryCollection({ id: 'todos', table: 'todos', queryFn: async () => [ { id: 1, title: 'Server Title', completed: false, order: 1, createdAt: 1000 }, ], }) await collection.fetch() const store = createOptimisticStore({ collectionId: 'todos' }) // Apply optimistic update to existing item store.applyOptimistic('update', 1, { title: 'Optimistic Title' }) // The merged view should show the optimistic update // @ts-expect-error - getOptimisticData not yet implemented const view = collection.getOptimisticData(store) expect(view[0].title).toBe('Optimistic Title') }) it('RED: should handle multiple concurrent optimistic updates', async () => { const store = createOptimisticStore({ collectionId: 'todos' }) // Apply multiple optimistic updates to the same record store.applyOptimistic('update', 1, { title: 'First Update' }) store.applyOptimistic('update', 1, { completed: true }) store.applyOptimistic('update', 1, { order: 5 }) const state = store.getState() expect(state.applied).toHaveLength(3) // The merged optimistic view should combine all updates // @ts-expect-error - getMergedOptimisticState not yet implemented const merged = store.getMergedOptimisticState(1) expect(merged).toEqual(expect.objectContaining({ title: 'First Update', completed: true, order: 5, })) }) it('RED: should track optimistic mutation ordering for conflict resolution', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const id1 = store.applyOptimistic('update', 1, { title: 'A' }) const id2 = store.applyOptimistic('update', 1, { title: 'B' }) const id3 = store.applyOptimistic('update', 1, { title: 'C' }) const state = store.getState() // Mutations should be ordered by timestamp expect(state.applied[0].id).toBe(id1) expect(state.applied[1].id).toBe(id2) expect(state.applied[2].id).toBe(id3) // Confirming middle mutation should preserve ordering of remaining store.confirmMutation(id2) const updatedState = store.getState() expect(updatedState.applied).toHaveLength(2) expect(updatedState.applied[0].id).toBe(id1) expect(updatedState.applied[1].id).toBe(id3) }) }) // ============================================================================ // Section 2: Rollback // ============================================================================ describe('useMutation Hook: Rollback', () => { it('should rollback a specific mutation', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const previousData: Todo = { id: 1, title: 'Original', completed: false, order: 1, createdAt: 1000 } const mutationId = store.applyOptimistic('update', 1, { title: 'Updated' }, previousData) expect(store.getState().applied).toHaveLength(1) store.rollbackMutation(mutationId) expect(store.getState().applied).toHaveLength(0) }) it('should return previous data on rollback', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const previousData: Todo = { id: 1, title: 'Original', completed: false, order: 1, createdAt: 1000 } const mutationId = store.applyOptimistic('update', 1, { title: 'Updated' }, previousData) const rolledBack = store.rollbackMutation(mutationId) expect(rolledBack?.previousData).toEqual(previousData) }) it('should rollback all pending mutations', () => { const store = createOptimisticStore({ collectionId: 'todos' }) store.applyOptimistic('insert', 'temp-1', { title: 'A' }) store.applyOptimistic('insert', 'temp-2', { title: 'B' }) store.applyOptimistic('update', 1, { title: 'C' }) expect(store.getState().applied).toHaveLength(3) store.rollbackAll() expect(store.getState().applied).toHaveLength(0) }) it('RED: should rollback in reverse order to preserve consistency', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const original: Todo = { id: 1, title: 'Original', completed: false, order: 1, createdAt: 1000 } store.applyOptimistic('update', 1, { title: 'First Change' }, original) store.applyOptimistic('update', 1, { title: 'Second Change' }) store.applyOptimistic('update', 1, { title: 'Third Change' }) // Rolling back all should restore to original state // @ts-expect-error - rollbackAllWithRestore not yet implemented const restoredState = store.rollbackAllWithRestore() // Should return data in reverse order of application expect(restoredState).toHaveLength(3) expect(restoredState[0].data.title).toBe('Third Change') expect(restoredState[2].data.title).toBe('First Change') }) it('RED: should support partial rollback (rollback mutations after a given ID)', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const id1 = store.applyOptimistic('insert', 'temp-1', { title: 'A' }) const id2 = store.applyOptimistic('insert', 'temp-2', { title: 'B' }) const id3 = store.applyOptimistic('insert', 'temp-3', { title: 'C' }) expect(store.getState().applied).toHaveLength(3) // Rollback everything after id1 (i.e., id2 and id3) // @ts-expect-error - rollbackAfter not yet implemented store.rollbackAfter(id1) expect(store.getState().applied).toHaveLength(1) expect(store.getState().applied[0].id).toBe(id1) }) it('RED: should emit rollback events for UI feedback', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const rollbackHandler = vi.fn() // @ts-expect-error - onRollback event not yet implemented store.onRollback(rollbackHandler) const mutationId = store.applyOptimistic('update', 1, { title: 'Temp' }) store.rollbackMutation(mutationId) expect(rollbackHandler).toHaveBeenCalledWith( expect.objectContaining({ mutationId, type: 'update', recordId: 1, }) ) }) it('RED: should automatically rollback on mutation error when rollbackOnError is true', async () => { const mockFetch = createFailingFetch('Constraint violation') const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const store = createOptimisticStore({ collectionId: 'todos' }) // Apply optimistic update first const mutationId = store.applyOptimistic('insert', 'temp-1', { title: 'New Todo', completed: false, order: 1, createdAt: Date.now(), }) // Execute the actual mutation const mutationOptions = adapter.mutationOptions({ onMutate: () => ({ previousData: store.getState() }), onError: (_error, _vars, context) => { // Should automatically rollback the optimistic update if (context) { store.rollbackMutation(mutationId) } }, }) try { await mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['New Todo'], }) } catch {} // After error, the optimistic update should be rolled back // This test will fail if the rollback mechanism isn't properly wired await mutationOptions.onError?.( new Error('Constraint violation'), { sql: 'INSERT...', params: [] }, { previousData: store.getState() } ) expect(store.getState().applied).toHaveLength(0) }) it('RED: should support rollback with custom merge strategy', () => { const store = createOptimisticStore({ collectionId: 'todos' }) const serverData: Todo = { id: 1, title: 'Server Version', completed: true, order: 1, createdAt: 1000 } const localData: Todo = { id: 1, title: 'Local Version', completed: false, order: 1, createdAt: 1000 } store.applyOptimistic('update', 1, { title: 'Local Version' }, serverData) // Rollback with custom merge: keep some local changes // @ts-expect-error - rollbackWithMerge not yet implemented store.rollbackWithMerge(store.getState().applied[0].id, (local, server) => { // Keep local title but accept server's completed state return { ...server, title: local.title } }) // @ts-expect-error - getMergedState not yet implemented const result = store.getMergedState(1) expect(result.title).toBe('Local Version') expect(result.completed).toBe(true) // from server }) }) // ============================================================================ // Section 3: onSuccess/onError Callbacks // ============================================================================ describe('useMutation Hook: onSuccess/onError Callbacks', () => { it('should call onSuccess with data, variables, and context', async () => { const onSuccess = vi.fn() const mockFetch = createMockFetch({ rows: [{ id: 1, title: 'Created' }], rowCount: 1, command: 'INSERT' }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onSuccess }) const result = await mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Created'], }) // Simulate TanStack Query calling onSuccess mutationOptions.onSuccess?.( result, { sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Created'] }, undefined ) expect(onSuccess).toHaveBeenCalledWith( expect.objectContaining({ rows: [{ id: 1, title: 'Created' }] }), expect.objectContaining({ sql: expect.any(String) }), undefined ) }) it('should call onError with error, variables, and context', async () => { const onError = vi.fn() const mockFetch = createFailingFetch('Unique constraint violation') const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onError }) try { await mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Duplicate'], }) } catch (error) { mutationOptions.onError?.( error as Error, { sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Duplicate'] }, undefined ) } expect(onError).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ sql: expect.any(String) }), undefined ) }) it('should call onMutate before mutation for optimistic context', async () => { const onMutate = vi.fn().mockReturnValue({ previousData: [{ id: 1, title: 'Before' }] }) const mockFetch = createMockFetch({ rows: [{ id: 1, title: 'After' }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onMutate }) const variables = { sql: 'UPDATE todos SET title = $1 WHERE id = $2', params: ['After', 1] } // onMutate should be called and return context const context = await mutationOptions.onMutate?.(variables) expect(context?.previousData).toEqual([{ id: 1, title: 'Before' }]) expect(onMutate).toHaveBeenCalledWith(variables) }) it('should call onSettled on both success and error', async () => { const onSettled = vi.fn() const mockFetch = createMockFetch({ rows: [] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onSettled }) const result = await mutationOptions.mutationFn({ sql: 'DELETE FROM todos WHERE id = $1', params: [1] }) // Simulate settled call mutationOptions.onSettled?.( result, null, { sql: 'DELETE FROM todos WHERE id = $1', params: [1] }, undefined ) expect(onSettled).toHaveBeenCalledWith( expect.any(Object), null, expect.objectContaining({ sql: expect.any(String) }), undefined ) }) it('RED: should support onProgress callback for long-running mutations', async () => { const onProgress = vi.fn() const mockFetch = createMockFetch({ rows: [], rowCount: 1000, command: 'UPDATE' }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ // @ts-expect-error - onProgress not yet supported onProgress, }) await mutationOptions.mutationFn({ sql: 'UPDATE todos SET completed = true WHERE order > $1', params: [0], }) // For bulk operations, onProgress should be called with progress info expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ processed: expect.any(Number), total: expect.any(Number), }) ) }) it('RED: should support onOptimisticUpdate callback for UI synchronization', async () => { const onOptimisticUpdate = vi.fn() const mockFetch = createMockFetch({ rows: [{ id: 1, title: 'Confirmed' }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onMutate: (variables) => { // Apply optimistic update return { previousData: [{ id: 1, title: 'Before' }] } }, // @ts-expect-error - onOptimisticUpdate not yet supported onOptimisticUpdate: onOptimisticUpdate, }) const variables = { sql: 'UPDATE todos SET title = $1 WHERE id = $2', params: ['After', 1] } await mutationOptions.onMutate?.(variables) expect(onOptimisticUpdate).toHaveBeenCalledWith( expect.objectContaining({ type: 'update', variables, }) ) }) it('RED: should support typed error handling with PostgreSQL error codes', async () => { const onError = vi.fn() const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: 'duplicate key value violates unique constraint', code: '23505', constraint: 'todos_title_unique', detail: 'Key (title)=(Test) already exists.', }), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ onError }) try { await mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) } catch (error) { mutationOptions.onError?.(error as Error, { sql: '', params: [] }, undefined) } expect(onError).toHaveBeenCalled() const errorArg = onError.mock.calls[0][0] // Error should include PostgreSQL-specific fields // @ts-expect-error - PostgreSQL error fields not yet on Error type expect(errorArg.code).toBe('23505') // @ts-expect-error - PostgreSQL error fields not yet on Error type expect(errorArg.constraint).toBe('todos_title_unique') }) it('RED: should support mutation lifecycle timing', async () => { const mockFetch = createMockFetch({ rows: [{ id: 1 }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const onSettled = vi.fn() const mutationOptions = adapter.mutationOptions({ onSettled }) await mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) // Simulate TanStack Query calling onSettled mutationOptions.onSettled?.( { rows: [{ id: 1 }], rowCount: 1, command: 'INSERT' }, null, { sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }, undefined ) // onSettled should receive timing metadata // @ts-expect-error - timing metadata not yet passed to onSettled expect(onSettled.mock.calls[0][4]?.timing).toBeDefined() // @ts-expect-error - timing metadata not yet passed expect(onSettled.mock.calls[0][4]?.timing?.durationMs).toBeGreaterThanOrEqual(0) }) }) // ============================================================================ // Section 4: Cache Invalidation // ============================================================================ describe('useMutation Hook: Invalidation', () => { it('should invalidate all queries when invalidate.all is true', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { options.onSuccess?.({ rows: [] }, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutate } = usePostgresMutation(adapter, { invalidate: { all: true }, }) as { mutate: (v: MutationParams) => void } mutate({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['postgres', 'testdb'], }) }) it('should invalidate table-specific queries', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { options.onSuccess?.({ rows: [] }, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutate } = usePostgresMutation(adapter, { invalidate: { tables: ['todos'] }, }) as { mutate: (v: MutationParams) => void } mutate({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) expect(mockInvalidateQueries).toHaveBeenCalled() }) it('should invalidate specific query keys', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { options.onSuccess?.({ rows: [] }, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const queryKeys = [ ['postgres', 'testdb', 'SELECT * FROM todos'] as const, ] const { mutate } = usePostgresMutation(adapter, { invalidate: { queryKeys }, }) as { mutate: (v: MutationParams) => void } mutate({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['postgres', 'testdb', 'SELECT * FROM todos'], }) }) it('RED: should auto-detect tables from SQL and invalidate related queries', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { options.onSuccess?.({ rows: [] }, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutate } = usePostgresMutation(adapter, { invalidate: { // @ts-expect-error - autoDetect not yet supported autoDetect: true, // Automatically detect tables from mutation SQL }, }) as { mutate: (v: MutationParams) => void } mutate({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Test'] }) // Should have auto-detected "todos" table and invalidated related queries expect(mockInvalidateQueries).toHaveBeenCalledWith( expect.objectContaining({ queryKey: expect.arrayContaining(['postgres', 'testdb']), // Should use predicate to match queries involving 'todos' table predicate: expect.any(Function), }) ) }) it('RED: should support delayed invalidation (await mutation completion)', async () => { const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined) const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutateAsync: vi.fn(async (variables: MutationParams) => { const result = await options.mutationFn(variables) await options.onSuccess?.(result, variables, undefined) return result }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutateAsync } = usePostgresMutation(adapter, { invalidate: { all: true, // @ts-expect-error - awaitInvalidation not yet supported awaitInvalidation: true, // Wait for invalidation before resolving }, }) as { mutateAsync: (v: MutationParams) => Promise } await mutateAsync({ sql: 'DELETE FROM todos WHERE id = $1', params: [1] }) // Invalidation should have been awaited before mutateAsync resolves expect(mockInvalidateQueries).toHaveBeenCalled() }) it('RED: should support conditional invalidation based on mutation result', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { const result = { rows: [], rowCount: 0, command: 'UPDATE' } options.onSuccess?.(result, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutate } = usePostgresMutation(adapter, { invalidate: { // @ts-expect-error - shouldInvalidate not yet supported shouldInvalidate: (result: QueryResult) => (result.rowCount ?? 0) > 0, tables: ['todos'], }, }) as { mutate: (v: MutationParams) => void } // Mutation that updates 0 rows should NOT invalidate mutate({ sql: 'UPDATE todos SET completed = true WHERE id = $1', params: [999] }) expect(mockInvalidateQueries).not.toHaveBeenCalled() }) it('RED: should support cascading invalidation for related tables', () => { const mockInvalidateQueries = vi.fn() const mockUseMutation = vi.fn().mockImplementation((options) => ({ mutate: vi.fn((variables: MutationParams) => { options.onSuccess?.({ rows: [], rowCount: 1, command: 'DELETE' }, variables, undefined) }), })) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries, }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const { mutate } = usePostgresMutation(adapter, { invalidate: { tables: ['users'], // @ts-expect-error - cascade not yet supported cascade: { users: ['todos', 'comments'], // When users changes, also invalidate todos and comments }, }, }) as { mutate: (v: MutationParams) => void } mutate({ sql: 'DELETE FROM users WHERE id = $1', params: [1] }) // Should invalidate users, todos, and comments expect(mockInvalidateQueries).toHaveBeenCalledTimes(3) }) }) // ============================================================================ // Section 5: Collection Mutation Integration // ============================================================================ describe('useMutation Hook: Collection Mutations', () => { it('should create insert mutation options from collection', async () => { const collection = createMockCollection([]) const mutationOptions = createCollectionMutationOptions(collection) const result = await mutationOptions.mutationFn({ type: 'insert', data: { title: 'New Todo', completed: false, order: 1, createdAt: Date.now() }, }) expect(collection.insert).toHaveBeenCalled() expect(result).toBeDefined() }) it('should create update mutation options from collection', async () => { const collection = createMockCollection([ { id: 1, title: 'Existing', completed: false, order: 1, createdAt: 1000 }, ]) const mutationOptions = createCollectionMutationOptions(collection) await mutationOptions.mutationFn({ type: 'update', id: 1, data: { title: 'Updated' }, }) expect(collection.update).toHaveBeenCalledWith(1, { title: 'Updated' }) }) it('should create delete mutation options from collection', async () => { const collection = createMockCollection([ { id: 1, title: 'To Delete', completed: false, order: 1, createdAt: 1000 }, ]) const mutationOptions = createCollectionMutationOptions(collection) await mutationOptions.mutationFn({ type: 'delete', id: 1, data: {}, }) expect(collection.delete).toHaveBeenCalledWith(1) }) it('should throw on update without ID', async () => { const collection = createMockCollection([]) const mutationOptions = createCollectionMutationOptions(collection) await expect(mutationOptions.mutationFn({ type: 'update', data: { title: 'No ID' }, })).rejects.toThrow(/ID is required/i) }) it('should throw on delete without ID', async () => { const collection = createMockCollection([]) const mutationOptions = createCollectionMutationOptions(collection) await expect(mutationOptions.mutationFn({ type: 'delete', data: {}, })).rejects.toThrow(/ID is required/i) }) it('RED: should support batch mutations (multiple operations in one call)', async () => { const collection = createMockCollection([ { id: 1, title: 'A', completed: false, order: 1, createdAt: 1000 }, { id: 2, title: 'B', completed: false, order: 2, createdAt: 2000 }, ]) const mutationOptions = createCollectionMutationOptions(collection) // @ts-expect-error - batch mutations not yet supported await mutationOptions.mutationFn({ type: 'batch', operations: [ { type: 'update', id: 1, data: { completed: true } }, { type: 'update', id: 2, data: { completed: true } }, { type: 'insert', data: { title: 'C', completed: false, order: 3, createdAt: Date.now() } }, ], }) // All operations should have been applied expect(collection.update).toHaveBeenCalledTimes(2) expect(collection.insert).toHaveBeenCalledTimes(1) }) it('RED: should support transactional mutations (all-or-nothing)', async () => { const collection = createMockCollection([ { id: 1, title: 'A', completed: false, order: 1, createdAt: 1000 }, ]) // Make the second operation fail ;(collection.update as ReturnType).mockImplementationOnce(async () => { return { id: 1, title: 'Updated A', completed: false, order: 1, createdAt: 1000 } }).mockRejectedValueOnce(new Error('Constraint violation')) const mutationOptions = createCollectionMutationOptions(collection, { // @ts-expect-error - transactional not yet supported transactional: true, }) // This should roll back the first update when the second fails await expect(mutationOptions.mutationFn({ // @ts-expect-error - batch not yet supported type: 'batch', operations: [ { type: 'update', id: 1, data: { title: 'Updated A' } }, { type: 'update', id: 999, data: { title: 'Should Fail' } }, // Non-existent ], })).rejects.toThrow() // The first update should have been rolled back expect(collection.getAll()[0].title).toBe('A') }) it('RED: should support upsert operation (insert or update)', async () => { const collection = createMockCollection([ { id: 1, title: 'Existing', completed: false, order: 1, createdAt: 1000 }, ]) const mutationOptions = createCollectionMutationOptions(collection) // @ts-expect-error - upsert not yet supported await mutationOptions.mutationFn({ type: 'upsert', data: { id: 1, title: 'Updated via Upsert', completed: true, order: 1, createdAt: 1000 }, }) // Should have called update since item exists expect(collection.update).toHaveBeenCalledWith(1, expect.objectContaining({ title: 'Updated via Upsert' })) // Upsert with new ID should insert // @ts-expect-error - upsert not yet supported await mutationOptions.mutationFn({ type: 'upsert', data: { id: 999, title: 'New via Upsert', completed: false, order: 2, createdAt: Date.now() }, }) expect(collection.insert).toHaveBeenCalled() }) }) // ============================================================================ // Section 6: Mutation State Management // ============================================================================ describe('useMutation Hook: State Management', () => { it('should track mutation as pending during execution', () => { const mockUseMutation = vi.fn().mockReturnValue({ isPending: true, isIdle: false, isError: false, isSuccess: false, data: undefined, error: null, mutate: vi.fn(), mutateAsync: vi.fn(), reset: vi.fn(), }) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: vi.fn() }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const result = usePostgresMutation(adapter) as { isPending: boolean } expect(result.isPending).toBe(true) }) it('should provide reset function to clear mutation state', () => { const resetFn = vi.fn() const mockUseMutation = vi.fn().mockReturnValue({ isPending: false, isSuccess: true, data: { rows: [{ id: 1 }] }, reset: resetFn, mutate: vi.fn(), mutateAsync: vi.fn(), }) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: vi.fn() }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const result = usePostgresMutation(adapter) as { reset: () => void } result.reset() expect(resetFn).toHaveBeenCalled() }) it('RED: should provide mutation history for debugging', () => { const mockUseMutation = vi.fn().mockReturnValue({ mutate: vi.fn(), mutateAsync: vi.fn(), reset: vi.fn(), }) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: vi.fn() }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const result = usePostgresMutation(adapter) // @ts-expect-error - getMutationHistory not yet implemented expect(result.getMutationHistory).toBeDefined() // @ts-expect-error - getMutationHistory not yet implemented expect(result.getMutationHistory()).toEqual([]) }) it('RED: should track mutation count and timing statistics', () => { const mockUseMutation = vi.fn().mockReturnValue({ mutate: vi.fn(), mutateAsync: vi.fn(), reset: vi.fn(), }) const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: vi.fn() }) const usePostgresMutation = createUsePostgresMutation(mockUseMutation, mockUseQueryClient) const adapter = createMockAdapter() const result = usePostgresMutation(adapter) // @ts-expect-error - getStats not yet implemented const stats = result.getStats() expect(stats).toEqual(expect.objectContaining({ totalMutations: expect.any(Number), successCount: expect.any(Number), errorCount: expect.any(Number), averageDurationMs: expect.any(Number), })) }) it('RED: should support mutation queue for sequential execution', async () => { const executionOrder: string[] = [] const mockFetch = vi.fn().mockImplementation(async (_url: string, init: { body: string }) => { const body = JSON.parse(init.body) as { sql: string } executionOrder.push(body.sql) await new Promise(resolve => setTimeout(resolve, 10)) return { ok: true, json: () => Promise.resolve({ rows: [] }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) // @ts-expect-error - mutationQueue not yet supported const queue = adapter.createMutationQueue({ concurrency: 1 }) await Promise.all([ queue.add({ sql: 'INSERT INTO todos VALUES (1)', params: [] }), queue.add({ sql: 'INSERT INTO todos VALUES (2)', params: [] }), queue.add({ sql: 'INSERT INTO todos VALUES (3)', params: [] }), ]) // With concurrency: 1, mutations should execute sequentially expect(executionOrder).toEqual([ 'INSERT INTO todos VALUES (1)', 'INSERT INTO todos VALUES (2)', 'INSERT INTO todos VALUES (3)', ]) }) it('RED: should support mutation deduplication (prevent duplicate submissions)', async () => { let callCount = 0 const mockFetch = vi.fn().mockImplementation(async () => { callCount++ await new Promise(resolve => setTimeout(resolve, 50)) return { ok: true, json: () => Promise.resolve({ rows: [] }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const mutationOptions = adapter.mutationOptions({ // @ts-expect-error - deduplicate not yet supported deduplicate: true, }) // Fire the same mutation multiple times rapidly const results = await Promise.all([ mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Same'] }), mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Same'] }), mutationOptions.mutationFn({ sql: 'INSERT INTO todos (title) VALUES ($1)', params: ['Same'] }), ]) // Should only execute once due to deduplication expect(callCount).toBe(1) // All promises should resolve with the same result expect(results[0]).toEqual(results[1]) expect(results[1]).toEqual(results[2]) }) }) // ============================================================================ // Section 7: SyncCollection Mutation Integration // ============================================================================ describe('useMutation Hook: SyncCollection Integration', () => { let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]), }) vi.stubGlobal('fetch', mockFetch) }) afterEach(() => { vi.unstubAllGlobals() }) it('should track pending mutations in sync collection', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await collection.insert({ title: 'Pending', completed: false, order: 1, createdAt: Date.now() }) const syncState = collection.getSyncState() expect(syncState.pendingCount).toBe(1) }) it('RED: should retry failed mutations on reconnect', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', // @ts-expect-error - retryOnReconnect not yet supported retryOnReconnect: true, }) await collection.connect() await collection.insert({ title: 'Will Retry', completed: false, order: 1, createdAt: Date.now() }) // Disconnect collection.disconnect() // Reconnect should retry pending mutations mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([{ id: 'confirmed-1', title: 'Will Retry', completed: false, order: 1, createdAt: 1000 }]), }) await collection.connect() // After successful sync, pending count should be 0 expect(collection.getSyncState().pendingCount).toBe(0) }) it('RED: should support conflict resolution on mutation sync', async () => { const conflictResolver = vi.fn().mockReturnValue({ id: 1, title: 'Resolved', completed: true, order: 1, createdAt: 3000 }) const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', // @ts-expect-error - onConflict not yet supported onConflict: conflictResolver, }) await collection.connect() // Local mutation await collection.insert({ id: 1, title: 'Local Version', completed: false, order: 1, createdAt: 2000 }) // Server returns conflicting data mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([{ id: 1, title: 'Server Version', completed: true, order: 1, createdAt: 1500 }]), }) // Trigger sync // @ts-expect-error - sync() not publicly exposed await collection.fetchData?.() expect(conflictResolver).toHaveBeenCalledWith( expect.objectContaining({ type: 'insert-insert', localValue: expect.objectContaining({ title: 'Local Version' }), remoteValue: expect.objectContaining({ title: 'Server Version' }), }) ) }) it('RED: should support mutation batching for network efficiency', async () => { vi.useFakeTimers() const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', // @ts-expect-error - batchMutations not yet supported batchMutations: { maxBatchSize: 10, batchDelayMs: 100, }, }) await collection.connect() // Rapid mutations should be batched await collection.insert({ title: 'A', completed: false, order: 1, createdAt: Date.now() }) await collection.insert({ title: 'B', completed: false, order: 2, createdAt: Date.now() }) await collection.insert({ title: 'C', completed: false, order: 3, createdAt: Date.now() }) // Before batch delay, no sync request for mutations expect(mockFetch).toHaveBeenCalledTimes(1) // Only the initial connect fetch // After batch delay, all mutations should be sent in one request await vi.advanceTimersByTimeAsync(150) // Should have made one batch request with all mutations const batchCalls = mockFetch.mock.calls.filter(call => typeof call[1]?.body === 'string' && call[1].body.includes('batch') ) expect(batchCalls.length).toBeGreaterThanOrEqual(1) vi.useRealTimers() collection.disconnect() }) })