diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md new file mode 100644 index 000000000..5e57f430d --- /dev/null +++ b/.changeset/deprecate-handler-return-values.md @@ -0,0 +1,49 @@ +--- +"@tanstack/db": major +"@tanstack/electric-db-collection": major +"@tanstack/query-db-collection": major +--- + +**BREAKING (TypeScript only)**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`). + +**What's changed:** +- Handler types now default to `Promise` instead of `Promise` +- TypeScript will error on `return { refetch: false }` or `return { txid }` +- Runtime still supports old return patterns for backward compatibility +- **Deprecation warnings** are now logged when handlers return values +- Old patterns will be fully removed in v1.0 RC + +**New pattern (explicit sync coordination):** +- **Query Collections**: Call `await collection.utils.refetch()` to sync server state +- **Electric Collections**: Call `await collection.utils.awaitTxId(txid)` or `await collection.utils.awaitMatch(fn)` to wait for synchronization +- **Other Collections**: Use appropriate sync utilities for your collection type + +This change makes the API more explicit and consistent across all collection types. All handlers should coordinate sync explicitly within the handler function using `await`, rather than relying on magic return values. + +Migration guide: + +```typescript +// Before (Query Collection) +onInsert: async ({ transaction }) => { + await api.create(transaction.mutations[0].modified) + // Implicitly refetches +} + +// After (Query Collection) +onInsert: async ({ transaction, collection }) => { + await api.create(transaction.mutations[0].modified) + await collection.utils.refetch() +} + +// Before (Electric Collection) +onInsert: async ({ transaction }) => { + const result = await api.create(transaction.mutations[0].modified) + return { txid: result.txid } +} + +// After (Electric Collection) +onInsert: async ({ transaction, collection }) => { + const result = await api.create(transaction.mutations[0].modified) + await collection.utils.awaitTxId(result.txid) +} +``` diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 228098762..5ff3461ec 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -60,7 +60,7 @@ Handlers are called before mutations to persist changes to your backend: - `onUpdate`: Handler called before update operations - `onDelete`: Handler called before delete operations -Each handler should return `{ txid }` to wait for synchronization. For cases where your API can not return txids, use the `awaitMatch` utility function. +Each handler should call `await collection.utils.awaitTxId(txid)` to wait for synchronization. For cases where your API cannot return txids, use the `awaitMatch` utility function. ## Persistence Handlers & Synchronization @@ -81,22 +81,23 @@ const todosCollection = createCollection( params: { table: 'todos' }, }, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItem = transaction.mutations[0].modified const response = await api.todos.create(newItem) - // Return txid to wait for sync - return { txid: response.txid } + // Wait for txid to sync + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { original, changes } = transaction.mutations[0] const response = await api.todos.update({ where: { id: original.id }, data: changes }) - return { txid: response.txid } + // Wait for txid to sync + await collection.utils.awaitTxId(response.txid) } }) ) @@ -305,7 +306,7 @@ The collection provides these utility methods via `collection.utils`: ### `awaitTxId(txid, timeout?)` -Manually wait for a specific transaction ID to be synchronized: +Wait for a specific transaction ID to be synchronized: ```typescript // Wait for specific txid @@ -319,7 +320,7 @@ This is useful when you need to ensure a mutation has been synchronized before p ### `awaitMatch(matchFn, timeout?)` -Manually wait for a custom match function to find a matching message: +Wait for a custom match function to find a matching message: ```typescript import { isChangeMessage } from '@tanstack/electric-db-collection' diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 4f43082d7..0c58f0c5a 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -79,7 +79,7 @@ The `queryCollectionOptions` function accepts the following options: ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: +You can define handlers that are called when mutations occur. These handlers persist changes to your backend and trigger refetches when needed: ```typescript const todosCollection = createCollection( @@ -89,24 +89,27 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItems = transaction.mutations.map((m) => m.modified) await api.createTodos(newItems) - // Returning nothing or { refetch: true } will trigger a refetch - // Return { refetch: false } to skip automatic refetch + // Trigger refetch to sync server state + await collection.utils.refetch() }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const updates = transaction.mutations.map((m) => ({ id: m.key, changes: m.changes, })) await api.updateTodos(updates) + // Refetch after persisting changes + await collection.utils.refetch() }, - onDelete: async ({ transaction }) => { + onDelete: async ({ transaction, collection }) => { const ids = transaction.mutations.map((m) => m.key) await api.deleteTodos(ids) + await collection.utils.refetch() }, }) ) @@ -114,30 +117,39 @@ const todosCollection = createCollection( ### Controlling Refetch Behavior -By default, after any persistence handler (`onInsert`, `onUpdate`, or `onDelete`) completes successfully, the query will automatically refetch to ensure the local state matches the server state. - -You can control this behavior by returning an object with a `refetch` property: +After persisting mutations to your backend, call `collection.utils.refetch()` to sync the server state back to your collection. This ensures the local state matches the server state after server-side processing. ```typescript -onInsert: async ({ transaction }) => { +onInsert: async ({ transaction, collection }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Skip the automatic refetch - return { refetch: false } + // Trigger refetch to sync server state + await collection.utils.refetch() } ``` -This is useful when: +You can skip the refetch when: + +- You're confident the server state exactly matches what you sent (no server-side processing) +- You're handling state updates through other mechanisms (like WebSockets or direct writes) +- You want to optimize for fewer network requests + +**When to skip refetch:** + +```typescript +onInsert: async ({ transaction }) => { + await api.createTodos(transaction.mutations.map((m) => m.modified)) -- You're confident the server state matches what you sent -- You want to avoid unnecessary network requests -- You're handling state updates through other mechanisms (like WebSockets) + // Skip refetch - only do this if server doesn't modify the data + // The optimistic state will remain as-is +} +``` ## Utility Methods The collection provides these utility methods via `collection.utils`: -- `refetch(opts?)`: Manually trigger a refetch of the query +- `refetch(opts?)`: Trigger a refetch of the query - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns `QueryObserverResult` for inspecting the result @@ -241,7 +253,7 @@ ws.on("todos:update", (changes) => { ### Example: Incremental Updates -When the server returns computed fields (like server-generated IDs or timestamps), you can use the `onInsert` handler with `{ refetch: false }` to avoid unnecessary refetches while still syncing the server response: +When the server returns computed fields (like server-generated IDs or timestamps), you can use direct writes to sync the server response without triggering a full refetch: ```typescript const todosCollection = createCollection( @@ -251,26 +263,25 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItems = transaction.mutations.map((m) => m.modified) // Send to server and get back items with server-computed fields const serverItems = await api.createTodos(newItems) // Sync server-computed fields (like server-generated IDs, timestamps, etc.) - // to the collection's synced data store - todosCollection.utils.writeBatch(() => { + // to the collection's synced data store using direct writes + collection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { - todosCollection.utils.writeInsert(serverItem) + collection.utils.writeInsert(serverItem) }) }) - // Skip automatic refetch since we've already synced the server response + // No need to refetch - we've already synced the server response via direct writes // (optimistic state is automatically replaced when handler completes) - return { refetch: false } }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const updates = transaction.mutations.map((m) => ({ id: m.key, changes: m.changes, @@ -278,13 +289,13 @@ const todosCollection = createCollection( const serverItems = await api.updateTodos(updates) // Sync server-computed fields from the update response - todosCollection.utils.writeBatch(() => { + collection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { - todosCollection.utils.writeUpdate(serverItem) + collection.utils.writeUpdate(serverItem) }) }) - return { refetch: false } + // No refetch needed since we used direct writes }, }) ) @@ -384,7 +395,7 @@ Direct writes update the collection immediately and also update the TanStack Que To handle this properly: -1. Use `{ refetch: false }` in your persistence handlers when using direct writes +1. Skip calling `collection.utils.refetch()` in your persistence handlers when using direct writes 2. Set appropriate `staleTime` to prevent unnecessary refetches 3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data) @@ -397,7 +408,7 @@ All direct write methods are available on `collection.utils`: - `writeDelete(keys)`: Delete one or more items directly - `writeUpsert(data)`: Insert or update one or more items directly - `writeBatch(callback)`: Perform multiple operations atomically -- `refetch(opts?)`: Manually trigger a refetch of the query +- `refetch(opts?)`: Trigger a refetch of the query ## QueryFn and Predicate Push-Down diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index d1f4d55c4..b8594334b 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -340,14 +340,16 @@ For more on schemas from a user perspective, see the [Schemas guide](./schemas.m There are two distinct patterns for handling mutations in collection options creators: -#### Pattern A: User-Provided Handlers (ElectricSQL, Query) +#### Pattern A: User-Provided Handlers (Query, Standard) -The user provides mutation handlers in the config. Your collection creator passes them through: +The user provides mutation handlers in the config. Your collection creator passes them through. + +**Note:** Handler return values are deprecated. Users should trigger refetch/sync within their handlers. ```typescript interface MyCollectionConfig { // ... other config - + // User provides these handlers onInsert?: InsertMutationFn onUpdate?: UpdateMutationFn @@ -360,23 +362,23 @@ export function myCollectionOptions( return { // ... other options rowUpdateMode: config.rowUpdateMode || 'partial', - - // Pass through user-provided handlers (possibly with additional logic) - onInsert: config.onInsert ? async (params) => { - const result = await config.onInsert!(params) - // Additional sync coordination logic - return result - } : undefined + + // Pass through user-provided handlers + // Users handle sync coordination in their own handlers + onInsert: config.onInsert, + onUpdate: config.onUpdate, + onDelete: config.onDelete } } ``` + #### Pattern B: Built-in Handlers (Trailbase, WebSocket, Firebase) Your collection creator implements the handlers directly using the sync engine's APIs: ```typescript -interface MyCollectionConfig +interface MyCollectionConfig extends Omit, 'onInsert' | 'onUpdate' | 'onDelete'> { // ... sync engine specific config // Note: onInsert/onUpdate/onDelete are NOT in the config @@ -388,33 +390,34 @@ export function myCollectionOptions( return { // ... other options rowUpdateMode: config.rowUpdateMode || 'partial', - + // Implement handlers using sync engine APIs onInsert: async ({ transaction }) => { // Handle provider-specific batch limits (e.g., Firestore's 500 limit) const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT) - + for (const chunk of chunks) { const ids = await config.recordApi.createBulk( chunk.map(m => serialize(m.modified)) ) + // Wait for these IDs to sync back before completing await awaitIds(ids) } - - return transaction.mutations.map(m => m.key) + // Handler completes after sync coordination }, - + onUpdate: async ({ transaction }) => { const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT) - + for (const chunk of chunks) { await Promise.all( - chunk.map(m => + chunk.map(m => config.recordApi.update(m.key, serialize(m.changes)) ) ) } - + + // Wait for mutations to sync back await awaitIds(transaction.mutations.map(m => String(m.key))) } } @@ -423,6 +426,8 @@ export function myCollectionOptions( Many providers have batch size limits (Firestore: 500, DynamoDB: 25, etc.) so chunk large transactions accordingly. +**Key Principle:** Built-in handlers should coordinate sync internally (using `awaitIds`, `awaitTxId`, or similar) and not rely on return values. The handler completes only after sync coordination is done. + Choose Pattern A when users need to provide their own APIs, and Pattern B when your sync engine handles writes directly. ## Row Update Modes @@ -675,15 +680,17 @@ export function webSocketCollectionOptions( } // All mutation handlers use the same transaction sender - const onInsert = async (params: InsertMutationFnParams) => { + // Handlers wait for server acknowledgment before completing + const onInsert = async (params: InsertMutationFnParams): Promise => { await sendTransaction(params) + // Handler completes after server confirms the transaction } - - const onUpdate = async (params: UpdateMutationFnParams) => { + + const onUpdate = async (params: UpdateMutationFnParams): Promise => { await sendTransaction(params) } - - const onDelete = async (params: DeleteMutationFnParams) => { + + const onDelete = async (params: DeleteMutationFnParams): Promise => { await sendTransaction(params) } @@ -768,16 +775,15 @@ if (message.headers.txids) { }) } -// Mutation handlers return txids and wait for them +// Electric-specific: Wrap user handlers to coordinate txid-based sync const wrappedOnInsert = async (params) => { + // User handler returns { txid } for Electric collections const result = await config.onInsert!(params) - - // Wait for the txid to appear in synced data - if (result.txid) { + + // Electric-specific: Wait for the txid to appear in synced data + if (result?.txid) { await awaitTxId(result.txid) } - - return result } // Utility function to wait for a txid @@ -810,8 +816,8 @@ seenIds.setState(prev => new Map(prev).set(item.id, Date.now())) // Wait for specific IDs after mutations const wrappedOnInsert = async (params) => { const ids = await config.recordApi.createBulk(items) - - // Wait for all IDs to be synced back + + // Wait for all IDs to be synced back before handler completes await awaitIds(ids) } @@ -842,8 +848,8 @@ let lastSyncTime = 0 const wrappedOnUpdate = async (params) => { const mutationTime = Date.now() await config.onUpdate(params) - - // Wait for sync to catch up + + // Wait for sync to catch up before handler completes await waitForSync(mutationTime) } @@ -861,21 +867,40 @@ const waitForSync = (afterTime: number): Promise => { } ``` -### Strategy 5: Full Refetch (Query Collection) +### Strategy 5: Refetch (Query Collection) -The query collection simply refetches all data after mutations: +The query collection pattern has users refetch after mutations: ```typescript -const wrappedOnInsert = async (params) => { - // Perform the mutation - await config.onInsert(params) - - // Refetch the entire collection - await refetch() - - // The refetch will trigger sync with fresh data, - // automatically dropping optimistic state +// Pattern A: User provides handlers and manages refetch +export function queryCollectionOptions(config) { + return { + // ... other options + + // User provides handlers and they handle refetch themselves + onInsert: config.onInsert, // User calls collection.utils.refetch() in their handler + onUpdate: config.onUpdate, + onDelete: config.onDelete, + + utils: { + refetch: () => { + // Refetch implementation that syncs fresh data + // automatically dropping optimistic state + } + } + } } + +// Usage: User refetches in their handler +const collection = createCollection( + queryCollectionOptions({ + onInsert: async ({ transaction, collection }) => { + await api.createTodos(transaction.mutations.map(m => m.modified)) + // User explicitly triggers refetch + await collection.utils.refetch() + } + }) +) ``` ### Choosing a Strategy @@ -902,6 +927,7 @@ const wrappedOnInsert = async (params) => { 5. **Race Conditions** - Start listeners before initial fetch and buffer events 6. **Type safety** - Use TypeScript generics to maintain type safety throughout 7. **Provide utilities** - Export sync-engine-specific utilities for advanced use cases +8. **Handler return values are deprecated** - Mutation handlers should coordinate sync internally (via `await`) rather than returning values. For Electric collections, use `await collection.utils.awaitTxId(txid)` instead of returning the txid ## Testing Your Collection diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 4c1662560..fe427d5be 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -233,7 +233,7 @@ Use this approach when: - You want to use TanStack DB only for queries and state management How to sync changes back: -- **QueryCollection**: Manually refetch with `collection.utils.refetch()` to reload data from the server +- **QueryCollection**: Refetch with `collection.utils.refetch()` to reload data from the server - **ElectricCollection**: Use `collection.utils.awaitTxId(txid)` to wait for a specific transaction to sync - **Other sync systems**: Wait for your sync mechanism to update the collection @@ -437,28 +437,30 @@ const todoCollection = createCollection({ Different collection types have specific patterns for their handlers: -**QueryCollection** - automatically refetches after handler completes: +**QueryCollection** - refetch after persisting changes: ```typescript -onUpdate: async ({ transaction }) => { +onUpdate: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((mutation) => api.todos.update(mutation.original.id, mutation.changes) ) ) - // Automatic refetch happens after handler completes + // Trigger refetch to sync server state + await collection.utils.refetch() } ``` -**ElectricCollection** - return txid(s) to track sync: +**ElectricCollection** - wait for txid(s) to sync: ```typescript -onUpdate: async ({ transaction }) => { +onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( transaction.mutations.map(async (mutation) => { const response = await api.todos.update(mutation.original.id, mutation.changes) return response.txid }) ) - return { txid: txids } + // Wait for all txids to sync + await Promise.all(txids.map(txid => collection.utils.awaitTxId(txid))) } ``` diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index d5e82c41f..75e792212 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -363,25 +363,34 @@ export type DeleteMutationFnParams< collection: Collection } +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type InsertMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: InsertMutationFnParams) => Promise +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type UpdateMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: UpdateMutationFnParams) => Promise +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type DeleteMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: DeleteMutationFnParams) => Promise /** @@ -413,6 +422,11 @@ export type CollectionStatus = export type SyncMode = `eager` | `on-demand` +/** + * @typeParam TReturn - @internal DEPRECATED: This generic parameter exists for backward compatibility only. + * Mutation handlers should not return values. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. + * This parameter will be removed in v1.0. + */ export interface BaseCollectionConfig< T extends object = Record, TKey extends string | number = string | number, @@ -486,7 +500,9 @@ export interface BaseCollectionConfig< /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information - * @returns Promise resolving to any value + * @returns Promise that should resolve to void + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. + * * @example * // Basic insert handler * onInsert: async ({ transaction, collection }) => { @@ -495,10 +511,30 @@ export interface BaseCollectionConfig< * } * * @example + * // Insert handler with refetch (Query Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * await api.createTodo(newItem) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Insert handler with sync wait (Electric Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * const result = await api.createTodo(newItem) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) + * } + * + * @example * // Insert handler with multiple items * onInsert: async ({ transaction, collection }) => { * const items = transaction.mutations.map(m => m.modified) * await api.createTodos(items) + * // Refetch to get updated data from server + * await collection.utils.refetch() * } * * @example @@ -506,30 +542,21 @@ export interface BaseCollectionConfig< * onInsert: async ({ transaction, collection }) => { * try { * const newItem = transaction.mutations[0].modified - * const result = await api.createTodo(newItem) - * return result + * await api.createTodo(newItem) * } catch (error) { * console.error('Insert failed:', error) - * throw error // This will cause the transaction to fail + * throw error // This will cause the transaction to rollback * } * } - * - * @example - * // Insert handler with metadata - * onInsert: async ({ transaction, collection }) => { - * const mutation = transaction.mutations[0] - * await api.createTodo(mutation.modified, { - * source: mutation.metadata?.source, - * timestamp: mutation.createdAt - * }) - * } */ onInsert?: InsertMutationFn /** * Optional asynchronous handler function called before an update operation * @param params Object containing transaction and collection information - * @returns Promise resolving to any value + * @returns Promise that should resolve to void + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. + * * @example * // Basic update handler * onUpdate: async ({ transaction, collection }) => { @@ -538,11 +565,22 @@ export interface BaseCollectionConfig< * } * * @example - * // Update handler with partial updates + * // Update handler with refetch (Query Collection) * onUpdate: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * const changes = mutation.changes // Only the changed fields * await api.updateTodo(mutation.original.id, changes) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Update handler with sync wait (Electric Collection) + * onUpdate: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.updateTodo(mutation.original.id, mutation.changes) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) * } * * @example @@ -553,6 +591,7 @@ export interface BaseCollectionConfig< * changes: m.changes * })) * await api.updateTodos(updates) + * await collection.utils.refetch() * } * * @example @@ -572,7 +611,9 @@ export interface BaseCollectionConfig< /** * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and collection information - * @returns Promise resolving to any value + * @returns Promise that should resolve to void + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. + * * @example * // Basic delete handler * onDelete: async ({ transaction, collection }) => { @@ -581,10 +622,21 @@ export interface BaseCollectionConfig< * } * * @example - * // Delete handler with multiple items + * // Delete handler with refetch (Query Collection) * onDelete: async ({ transaction, collection }) => { * const keysToDelete = transaction.mutations.map(m => m.key) * await api.deleteTodos(keysToDelete) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Delete handler with sync wait (Electric Collection) + * onDelete: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.deleteTodo(mutation.original.id) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) * } * * @example diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 0b377f672..b0befa70f 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -120,43 +120,80 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an insert operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric insert handler with txid (recommended) - * onInsert: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example * // Insert handler with custom timeout - * onInsert: async ({ transaction }) => { + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * const result = await api.todos.create({ + * data: newItem + * }) + * // Wait up to 10 seconds for txid + * await collection.utils.awaitTxId(result.txid, 10000) + * } + * + * @example + * // Insert handler with timeout error handling + * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds + * + * try { + * await collection.utils.awaitTxId(result.txid, 5000) + * } catch (error) { + * // Decide sync timeout policy: + * // - Throw to rollback optimistic state + * // - Catch to keep optimistic state (eventual consistency) + * // - Schedule background retry + * console.warn('Sync timeout, keeping optimistic state:', error) + * // Don't throw - allow optimistic state to persist + * } * } * * @example - * // Insert handler with multiple items - return array of txids - * onInsert: async ({ transaction }) => { + * // Insert handler with multiple items + * onInsert: async ({ transaction, collection }) => { * const items = transaction.mutations.map(m => m.modified) * const results = await Promise.all( * items.map(item => api.todos.create({ data: item })) * ) - * return { txid: results.map(r => r.txid) } + * // Wait for all txids to sync + * await Promise.all( + * results.map(r => collection.utils.awaitTxId(r.txid)) + * ) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * await api.todos.create({ data: newItem }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'insert' && @@ -168,24 +205,37 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an update operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric update handler with txid (recommended) - * onUpdate: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * const result = await api.todos.update({ * where: { id: original.id }, * data: changes * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * await api.todos.update({ where: { id: original.id }, data: changes }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'update' && @@ -197,23 +247,36 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before a delete operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric delete handler with txid (recommended) - * onDelete: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * const result = await api.todos.delete({ * id: mutation.original.id * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.todos.delete({ id: mutation.original.id }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'delete' && @@ -547,6 +610,13 @@ export function electricCollectionOptions( ): Promise => { // Only wait if result contains txid if (result && `txid` in result) { + // Warn about deprecated return value pattern + console.warn( + '[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization' + ) + const timeout = result.timeout // Handle both single txid and array of txids if (Array.isArray(result.txid)) { diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index d1d07b664..d64d9ad4e 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1033,6 +1033,16 @@ export function queryCollectionOptions( const wrappedOnInsert = onInsert ? async (params: InsertMutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -1047,6 +1057,16 @@ export function queryCollectionOptions( const wrappedOnUpdate = onUpdate ? async (params: UpdateMutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -1061,6 +1081,16 @@ export function queryCollectionOptions( const wrappedOnDelete = onDelete ? async (params: DeleteMutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false