From 5ac7f3121bd3ce45574e60b65bbee638a75c9770 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 22:43:55 +0000 Subject: [PATCH 01/14] deprecate: handler return values in favor of manual refetch/sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Mutation handler return values are now deprecated **Problem:** Users were confused about the difference between collection handlers (onInsert, onUpdate, onDelete) and manual actions/transactions. The magic of returning values like `{ refetch: false }` from handlers was specific to Query Collections but users expected it to work everywhere, leading to incorrect mental models. **Solution:** 1. Deprecate returning values from mutation handlers in TypeScript - Changed `TReturn = any` to `TReturn = void` in base handler types - Added @deprecated JSDoc tags with migration guidance - Updated all handler examples to show manual refetch pattern 2. Update documentation to teach manual patterns: - Query Collections: Use `await collection.utils.refetch()` - Electric Collections: Return `{ txid }` or use `collection.utils.awaitMatch()` 3. Clarify Electric Collection's special pattern: - Electric handlers REQUIRE returning txid or calling awaitMatch - This is Electric-specific, not a general pattern - Updated JSDoc to emphasize this distinction **Migration Guide:** Before (deprecated): ```typescript onInsert: async ({ transaction }) => { await api.create(data) return { refetch: false } // ❌ Deprecated } ``` After (recommended): ```typescript onInsert: async ({ transaction, collection }) => { await api.create(data) await collection.utils.refetch() // ✅ Explicit and clear } ``` For Electric Collections (unchanged): ```typescript onInsert: async ({ transaction }) => { const result = await api.create(data) return { txid: result.txid } // ✅ Electric-specific pattern } ``` **Files Changed:** - packages/db/src/types.ts: Deprecated return types, updated JSDoc - packages/electric-db-collection/src/electric.ts: Clarified Electric-specific pattern - docs/collections/query-collection.md: Removed refetch control examples, added manual patterns - docs/guides/mutations.md: Updated collection-specific handler patterns This change reduces confusion and makes the API more explicit and easier to understand. --- docs/collections/query-collection.md | 67 +++++++++++-------- docs/guides/mutations.md | 8 ++- packages/db/src/types.ts | 59 ++++++++++------ .../electric-db-collection/src/electric.ts | 51 +++++++++++--- 4 files changed, 124 insertions(+), 61 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index cf13291a8..2fe37d973 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -199,7 +199,7 @@ const productsCollection = createCollection( ## 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 can manually trigger refetches when needed: ```typescript const todosCollection = createCollection( @@ -209,24 +209,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 + // Manually 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) + // Manually 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() }, }) ) @@ -234,24 +237,33 @@ 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, you should manually 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 } + // Manually 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 @@ -361,7 +373,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( @@ -371,26 +383,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, @@ -398,13 +409,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 }, }) ) @@ -504,7 +515,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) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 4c1662560..b52f6b62f 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -437,15 +437,16 @@ const todoCollection = createCollection({ Different collection types have specific patterns for their handlers: -**QueryCollection** - automatically refetches after handler completes: +**QueryCollection** - manually 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 + // Manually trigger refetch to sync server state + await collection.utils.refetch() } ``` @@ -458,6 +459,7 @@ onUpdate: async ({ transaction }) => { return response.txid }) ) + // Return txid to wait for Electric sync return { txid: txids } } ``` diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 29bfce622..f6628b71f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -445,21 +445,21 @@ export type InsertMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: InsertMutationFnParams) => Promise export type UpdateMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: UpdateMutationFnParams) => Promise export type DeleteMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: DeleteMutationFnParams) => Promise /** @@ -564,7 +564,11 @@ 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 for manual refetch/sync. + * For Query Collections, use `await collection.utils.refetch()` after your operation. + * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. + * * @example * // Basic insert handler * onInsert: async ({ transaction, collection }) => { @@ -573,10 +577,21 @@ export interface BaseCollectionConfig< * } * * @example + * // Insert handler with manual refetch (Query Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * await api.createTodo(newItem) + * // Manually trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @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 @@ -584,30 +599,23 @@ 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 for manual refetch/sync. + * For Query Collections, use `await collection.utils.refetch()` after your operation. + * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. + * * @example * // Basic update handler * onUpdate: async ({ transaction, collection }) => { @@ -616,11 +624,13 @@ export interface BaseCollectionConfig< * } * * @example - * // Update handler with partial updates + * // Update handler with manual 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) + * // Manually trigger refetch to sync server state + * await collection.utils.refetch() * } * * @example @@ -631,6 +641,7 @@ export interface BaseCollectionConfig< * changes: m.changes * })) * await api.updateTodos(updates) + * await collection.utils.refetch() * } * * @example @@ -650,7 +661,11 @@ 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 for manual refetch/sync. + * For Query Collections, use `await collection.utils.refetch()` after your operation. + * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. + * * @example * // Basic delete handler * onDelete: async ({ transaction, collection }) => { @@ -659,10 +674,12 @@ export interface BaseCollectionConfig< * } * * @example - * // Delete handler with multiple items + * // Delete handler with manual refetch (Query Collection) * onDelete: async ({ transaction, collection }) => { * const keysToDelete = transaction.mutations.map(m => m.key) * await api.deleteTodos(keysToDelete) + * // Manually trigger refetch to sync server state + * await collection.utils.refetch() * } * * @example diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 01484838e..f49cddf9f 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -164,16 +164,26 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an insert operation + * + * **IMPORTANT - Electric-Specific Pattern:** + * Electric collections require explicit synchronization coordination. You have two options: + * 1. Return `{ txid }` from the handler (recommended for most cases) + * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * + * Unlike standard collections, Electric handlers should NOT just return void, as this would + * complete the mutation without ensuring the change has synced from the server. + * * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void + * * @example - * // Basic Electric insert handler with txid (recommended) + * // Recommended: Return txid for automatic sync matching * onInsert: async ({ transaction }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * return { txid: result.txid } + * return { txid: result.txid } // Handler waits for this txid to sync * } * * @example @@ -197,10 +207,11 @@ export interface ElectricCollectionConfig< * } * * @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 }) + * // Manually wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'insert' && @@ -218,24 +229,35 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an update operation + * + * **IMPORTANT - Electric-Specific Pattern:** + * Electric collections require explicit synchronization coordination. You have two options: + * 1. Return `{ txid }` from the handler (recommended for most cases) + * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * + * Unlike standard collections, Electric handlers should NOT just return void, as this would + * complete the mutation without ensuring the change has synced from the server. + * * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void + * * @example - * // Basic Electric update handler with txid (recommended) + * // Recommended: Return txid for automatic sync matching * onUpdate: async ({ transaction }) => { * const { original, changes } = transaction.mutations[0] * const result = await api.todos.update({ * where: { id: original.id }, * data: changes * }) - * return { txid: result.txid } + * return { txid: result.txid } // Handler waits for this txid to sync * } * * @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 }) + * // Manually wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'update' && @@ -253,23 +275,34 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before a delete operation + * + * **IMPORTANT - Electric-Specific Pattern:** + * Electric collections require explicit synchronization coordination. You have two options: + * 1. Return `{ txid }` from the handler (recommended for most cases) + * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * + * Unlike standard collections, Electric handlers should NOT just return void, as this would + * complete the mutation without ensuring the change has synced from the server. + * * @param params Object containing transaction and collection information * @returns Promise resolving to { txid, timeout? } or void + * * @example - * // Basic Electric delete handler with txid (recommended) + * // Recommended: Return txid for automatic sync matching * onDelete: async ({ transaction }) => { * const mutation = transaction.mutations[0] * const result = await api.todos.delete({ * id: mutation.original.id * }) - * return { txid: result.txid } + * return { txid: result.txid } // Handler waits for this txid to sync * } * * @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 }) + * // Manually wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'delete' && From e5239fe4929920d6fc3029ee56a2edab4e5de6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:01:54 +0000 Subject: [PATCH 02/14] docs: update collection options creator guide for deprecated return values Update the guide for creating custom collection options creators to reflect the deprecation of mutation handler return values. **Changes:** 1. **Pattern A (User-Provided Handlers):** - Clarified that handler return values are deprecated - Users should manually trigger refetch/sync within their handlers - Added note about Electric-specific exception (txid returns) - Simplified example to just pass through handlers 2. **Pattern B (Built-in Handlers):** - Updated examples to show handlers completing after sync coordination - Removed misleading return statements - Added key principle: coordinate sync internally via `await` 3. **Strategy 5 (Query Collection):** - Renamed from "Full Refetch" to "Manual Refetch" - Updated to show user explicitly calling `collection.utils.refetch()` - Clarified that users manage refetch in their own handlers 4. **WebSocket Example:** - Added explicit `Promise` return types to handlers - Added comments about handlers completing after server confirmation 5. **Electric Example:** - Clarified that txid returns are Electric-specific - Updated wrapper example to not return the result 6. **Best Practices:** - Added new guideline about deprecated handler return values - Emphasized Electric is the only exception These changes align the guide with the new pattern where mutation handlers don't return values, and sync coordination happens explicitly within handlers via await or manual refetch calls. --- docs/guides/collection-options-creator.md | 117 +++++++++++++--------- 1 file changed, 72 insertions(+), 45 deletions(-) diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index d1f4d55c4..edb1b69af 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 manually trigger refetch/sync within their handlers. ```typescript interface MyCollectionConfig { // ... other config - + // User provides these handlers onInsert?: InsertMutationFn onUpdate?: UpdateMutationFn @@ -360,23 +362,24 @@ 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 } } ``` +**Electric-Specific Exception:** Electric collections have a special pattern where handlers should return `{ txid }` for sync coordination. This is Electric-specific and not the general pattern. + #### 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 +391,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 +427,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 +681,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 +776,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 +817,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 +849,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 +868,40 @@ const waitForSync = (afterTime: number): Promise => { } ``` -### Strategy 5: Full Refetch (Query Collection) +### Strategy 5: Manual Refetch (Query Collection) -The query collection simply refetches all data after mutations: +The query collection pattern has users manually 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 manually 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 +928,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. The only exception is Electric collections which return `{ txid }` for their specific sync protocol ## Testing Your Collection From b0e3dd439e8e2aa5b9717ccdd25136f3cb58b432 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:05:25 +0000 Subject: [PATCH 03/14] deprecate: remove ALL magic return values, including Electric's txid pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Electric collection handlers no longer support returning { txid } **Problem:** The previous commit deprecated return values for most collections but kept Electric's `return { txid }` pattern as an exception. This creates confusion because it's still "magic" - users don't understand why Electric is different and it maintains the same conceptual problem of implicit behavior. **Solution:** Deprecate ALL return value patterns, including Electric's. Electric handlers should now use `await collection.utils.awaitTxId(txid)` explicitly. **Changes:** 1. **Base Collection Types** (`packages/db/src/types.ts`): - Added Electric example showing manual `await collection.utils.awaitTxId()` - Removed mentions of Electric-specific return pattern - All handlers consistently show void return with manual sync 2. **Electric Collection** (`packages/electric-db-collection/src/electric.ts`): - Deprecated `return { txid }` pattern in all three handlers - Updated JSDoc to show manual `await collection.utils.awaitTxId(txid)` - Added `@deprecated` tags explaining the migration - Updated all examples to use manual await pattern 3. **Electric Documentation** (`docs/collections/electric-collection.md`): - Updated "Using Txid" section to show manual awaitTxId - Changed "return txid to wait for sync" to "manually wait for txid" - All code examples now use `await collection.utils.awaitTxId()` 4. **Collection Options Creator Guide** (`docs/guides/collection-options-creator.md`): - Removed "Electric-Specific Exception" note - Updated best practices to mention Electric should use awaitTxId - No more special cases - all handlers work the same way 5. **Mutations Guide** (`docs/guides/mutations.md`): - Updated Electric example to show manual awaitTxId pattern - Changed from "return { txid }" to manual await pattern **Migration Guide:** Before (deprecated): ```typescript // Electric Collection onInsert: async ({ transaction }) => { const result = await api.create(data) return { txid: result.txid } // ❌ Deprecated } ``` After (recommended): ```typescript // Electric Collection onInsert: async ({ transaction, collection }) => { const result = await api.create(data) await collection.utils.awaitTxId(result.txid) // ✅ Explicit and clear } ``` **Benefits:** 1. **No more magic**: All collections follow the same explicit pattern 2. **Clearer mental model**: Users understand they're waiting for sync 3. **Consistent API**: No special cases to remember 4. **Better visibility**: `await` makes the async nature explicit 5. **Same control**: Users have same power, just more explicit All collections now have a consistent pattern: handlers coordinate sync explicitly via await, not implicitly via return values. --- docs/collections/electric-collection.md | 15 ++-- docs/guides/collection-options-creator.md | 3 +- docs/guides/mutations.md | 8 +- packages/db/src/types.ts | 33 +++++++-- .../electric-db-collection/src/electric.ts | 74 ++++++++++--------- 5 files changed, 79 insertions(+), 54 deletions(-) diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 228098762..eac1f6e0a 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 manually 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 @@ -68,7 +68,7 @@ Handlers persist mutations to the backend and wait for Electric to sync the chan ### 1. Using Txid (Recommended) -The recommended approach uses PostgreSQL transaction IDs (txids) for precise matching. The backend returns a txid, and the client waits for that specific txid to appear in the Electric stream. +The recommended approach uses PostgreSQL transaction IDs (txids) for precise matching. The backend returns a txid, and the client manually waits for that specific txid to appear in the Electric stream. ```typescript const todosCollection = createCollection( @@ -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 } + // Manually 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 } + // Manually wait for txid to sync + await collection.utils.awaitTxId(response.txid) } }) ) diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index edb1b69af..c05e6a2e7 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -372,7 +372,6 @@ export function myCollectionOptions( } ``` -**Electric-Specific Exception:** Electric collections have a special pattern where handlers should return `{ txid }` for sync coordination. This is Electric-specific and not the general pattern. #### Pattern B: Built-in Handlers (Trailbase, WebSocket, Firebase) @@ -928,7 +927,7 @@ const collection = createCollection( 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. The only exception is Electric collections which return `{ txid }` for their specific sync protocol +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 b52f6b62f..7f04a6119 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -450,17 +450,17 @@ onUpdate: async ({ transaction, collection }) => { } ``` -**ElectricCollection** - return txid(s) to track sync: +**ElectricCollection** - manually 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 to wait for Electric sync - return { txid: txids } + // Manually 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 f6628b71f..2c45910ca 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -566,8 +566,6 @@ export interface BaseCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. - * For Query Collections, use `await collection.utils.refetch()` after your operation. - * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. * * @example * // Basic insert handler @@ -586,6 +584,15 @@ export interface BaseCollectionConfig< * } * * @example + * // Insert handler with manual sync wait (Electric Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * const result = await api.createTodo(newItem) + * // Manually 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) @@ -613,8 +620,6 @@ export interface BaseCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. - * For Query Collections, use `await collection.utils.refetch()` after your operation. - * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. * * @example * // Basic update handler @@ -634,6 +639,15 @@ export interface BaseCollectionConfig< * } * * @example + * // Update handler with manual sync wait (Electric Collection) + * onUpdate: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.updateTodo(mutation.original.id, mutation.changes) + * // Manually wait for txid to sync + * await collection.utils.awaitTxId(result.txid) + * } + * + * @example * // Update handler with multiple items * onUpdate: async ({ transaction, collection }) => { * const updates = transaction.mutations.map(m => ({ @@ -663,8 +677,6 @@ export interface BaseCollectionConfig< * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. - * For Query Collections, use `await collection.utils.refetch()` after your operation. - * For Electric Collections, use the txid-based matching with `collection.utils.awaitTxId()`. * * @example * // Basic delete handler @@ -683,6 +695,15 @@ export interface BaseCollectionConfig< * } * * @example + * // Delete handler with manual sync wait (Electric Collection) + * onDelete: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.deleteTodo(mutation.original.id) + * // Manually wait for txid to sync + * await collection.utils.awaitTxId(result.txid) + * } + * + * @example * // Delete handler with confirmation * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index f49cddf9f..c0cfadc98 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -165,45 +165,49 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an insert operation * - * **IMPORTANT - Electric-Specific Pattern:** - * Electric collections require explicit synchronization coordination. You have two options: - * 1. Return `{ txid }` from the handler (recommended for most cases) + * **IMPORTANT - Electric Synchronization:** + * Electric collections require explicit synchronization coordination to ensure changes have synced + * from the server before dropping optimistic state. Use one of these patterns: + * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic * - * Unlike standard collections, Electric handlers should NOT just return void, as this would - * complete the mutation without ensuring the change has synced from the server. - * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise that should resolve to void + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Return txid for automatic sync matching - * onInsert: async ({ transaction }) => { + * // Recommended: Manually 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 } // Handler waits for this txid to sync + * // Manually 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 * }) - * return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds + * // Wait up to 10 seconds for txid + * await collection.utils.awaitTxId(result.txid, 10000) * } * * @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 @@ -230,26 +234,26 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an update operation * - * **IMPORTANT - Electric-Specific Pattern:** - * Electric collections require explicit synchronization coordination. You have two options: - * 1. Return `{ txid }` from the handler (recommended for most cases) + * **IMPORTANT - Electric Synchronization:** + * Electric collections require explicit synchronization coordination to ensure changes have synced + * from the server before dropping optimistic state. Use one of these patterns: + * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic * - * Unlike standard collections, Electric handlers should NOT just return void, as this would - * complete the mutation without ensuring the change has synced from the server. - * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise that should resolve to void + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Return txid for automatic sync matching - * onUpdate: async ({ transaction }) => { + * // Recommended: Manually 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 } // Handler waits for this txid to sync + * // Manually wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example @@ -276,25 +280,25 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before a delete operation * - * **IMPORTANT - Electric-Specific Pattern:** - * Electric collections require explicit synchronization coordination. You have two options: - * 1. Return `{ txid }` from the handler (recommended for most cases) + * **IMPORTANT - Electric Synchronization:** + * Electric collections require explicit synchronization coordination to ensure changes have synced + * from the server before dropping optimistic state. Use one of these patterns: + * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic * - * Unlike standard collections, Electric handlers should NOT just return void, as this would - * complete the mutation without ensuring the change has synced from the server. - * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise that should resolve to void + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Return txid for automatic sync matching - * onDelete: async ({ transaction }) => { + * // Recommended: Manually 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 } // Handler waits for this txid to sync + * // Manually wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example From b5f7e38231d430abd19926908164d4b6981d19cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:15:46 +0000 Subject: [PATCH 04/14] docs: remove redundant 'manually' wording from mutation handler documentation Cleaned up language throughout mutation handler docs and JSDoc to be more direct. Changed phrases like "manually wait for" to "wait for" since there's only one way to do it. --- docs/collections/electric-collection.md | 12 ++++---- docs/collections/query-collection.md | 14 ++++----- docs/guides/collection-options-creator.md | 8 ++--- docs/guides/mutations.md | 10 +++---- packages/db/src/types.ts | 30 +++++++++---------- .../electric-db-collection/src/electric.ts | 30 +++++++++---------- 6 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index eac1f6e0a..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 manually call `await collection.utils.awaitTxId(txid)` to wait for synchronization. For cases where your API cannot 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 @@ -68,7 +68,7 @@ Handlers persist mutations to the backend and wait for Electric to sync the chan ### 1. Using Txid (Recommended) -The recommended approach uses PostgreSQL transaction IDs (txids) for precise matching. The backend returns a txid, and the client manually waits for that specific txid to appear in the Electric stream. +The recommended approach uses PostgreSQL transaction IDs (txids) for precise matching. The backend returns a txid, and the client waits for that specific txid to appear in the Electric stream. ```typescript const todosCollection = createCollection( @@ -85,7 +85,7 @@ const todosCollection = createCollection( const newItem = transaction.mutations[0].modified const response = await api.todos.create(newItem) - // Manually wait for txid to sync + // Wait for txid to sync await collection.utils.awaitTxId(response.txid) }, @@ -96,7 +96,7 @@ const todosCollection = createCollection( data: changes }) - // Manually wait for txid to sync + // Wait for txid to sync await collection.utils.awaitTxId(response.txid) } }) @@ -306,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 @@ -320,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 2fe37d973..557b36a2e 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -199,7 +199,7 @@ const productsCollection = createCollection( ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers persist changes to your backend and can manually trigger refetches when needed: +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( @@ -212,7 +212,7 @@ const todosCollection = createCollection( onInsert: async ({ transaction, collection }) => { const newItems = transaction.mutations.map((m) => m.modified) await api.createTodos(newItems) - // Manually trigger refetch to sync server state + // Trigger refetch to sync server state await collection.utils.refetch() }, @@ -222,7 +222,7 @@ const todosCollection = createCollection( changes: m.changes, })) await api.updateTodos(updates) - // Manually refetch after persisting changes + // Refetch after persisting changes await collection.utils.refetch() }, @@ -237,13 +237,13 @@ const todosCollection = createCollection( ### Controlling Refetch Behavior -After persisting mutations to your backend, you should manually 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. +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, collection }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Manually trigger refetch to sync server state + // Trigger refetch to sync server state await collection.utils.refetch() } ``` @@ -269,7 +269,7 @@ onInsert: async ({ transaction }) => { 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 @@ -528,7 +528,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 c05e6a2e7..b8594334b 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -344,7 +344,7 @@ There are two distinct patterns for handling mutations in collection options cre The user provides mutation handlers in the config. Your collection creator passes them through. -**Note:** Handler return values are deprecated. Users should manually trigger refetch/sync within their handlers. +**Note:** Handler return values are deprecated. Users should trigger refetch/sync within their handlers. ```typescript interface MyCollectionConfig { @@ -867,9 +867,9 @@ const waitForSync = (afterTime: number): Promise => { } ``` -### Strategy 5: Manual Refetch (Query Collection) +### Strategy 5: Refetch (Query Collection) -The query collection pattern has users manually refetch after mutations: +The query collection pattern has users refetch after mutations: ```typescript // Pattern A: User provides handlers and manages refetch @@ -891,7 +891,7 @@ export function queryCollectionOptions(config) { } } -// Usage: User manually refetches in their handler +// Usage: User refetches in their handler const collection = createCollection( queryCollectionOptions({ onInsert: async ({ transaction, collection }) => { diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 7f04a6119..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,7 +437,7 @@ const todoCollection = createCollection({ Different collection types have specific patterns for their handlers: -**QueryCollection** - manually refetch after persisting changes: +**QueryCollection** - refetch after persisting changes: ```typescript onUpdate: async ({ transaction, collection }) => { await Promise.all( @@ -445,12 +445,12 @@ onUpdate: async ({ transaction, collection }) => { api.todos.update(mutation.original.id, mutation.changes) ) ) - // Manually trigger refetch to sync server state + // Trigger refetch to sync server state await collection.utils.refetch() } ``` -**ElectricCollection** - manually wait for txid(s) to sync: +**ElectricCollection** - wait for txid(s) to sync: ```typescript onUpdate: async ({ transaction, collection }) => { const txids = await Promise.all( @@ -459,7 +459,7 @@ onUpdate: async ({ transaction, collection }) => { return response.txid }) ) - // Manually wait for all txids to sync + // 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 2c45910ca..5ad42037c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -565,7 +565,7 @@ export interface BaseCollectionConfig< * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information * @returns Promise that should resolve to void - * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. * * @example * // Basic insert handler @@ -575,20 +575,20 @@ export interface BaseCollectionConfig< * } * * @example - * // Insert handler with manual refetch (Query Collection) + * // Insert handler with refetch (Query Collection) * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * await api.createTodo(newItem) - * // Manually trigger refetch to sync server state + * // Trigger refetch to sync server state * await collection.utils.refetch() * } * * @example - * // Insert handler with manual sync wait (Electric Collection) + * // Insert handler with sync wait (Electric Collection) * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.createTodo(newItem) - * // Manually wait for txid to sync + * // Wait for txid to sync * await collection.utils.awaitTxId(result.txid) * } * @@ -619,7 +619,7 @@ export interface BaseCollectionConfig< * Optional asynchronous handler function called before an update operation * @param params Object containing transaction and collection information * @returns Promise that should resolve to void - * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. * * @example * // Basic update handler @@ -629,21 +629,21 @@ export interface BaseCollectionConfig< * } * * @example - * // Update handler with manual refetch (Query Collection) + * // 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) - * // Manually trigger refetch to sync server state + * // Trigger refetch to sync server state * await collection.utils.refetch() * } * * @example - * // Update handler with manual sync wait (Electric Collection) + * // 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) - * // Manually wait for txid to sync + * // Wait for txid to sync * await collection.utils.awaitTxId(result.txid) * } * @@ -676,7 +676,7 @@ export interface BaseCollectionConfig< * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and collection information * @returns Promise that should resolve to void - * @deprecated Returning values from this handler is deprecated. Use collection utilities for manual refetch/sync. + * @deprecated Returning values from this handler is deprecated. Use collection utilities to refetch/sync. * * @example * // Basic delete handler @@ -686,20 +686,20 @@ export interface BaseCollectionConfig< * } * * @example - * // Delete handler with manual refetch (Query Collection) + * // Delete handler with refetch (Query Collection) * onDelete: async ({ transaction, collection }) => { * const keysToDelete = transaction.mutations.map(m => m.key) * await api.deleteTodos(keysToDelete) - * // Manually trigger refetch to sync server state + * // Trigger refetch to sync server state * await collection.utils.refetch() * } * * @example - * // Delete handler with manual sync wait (Electric Collection) + * // Delete handler with sync wait (Electric Collection) * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * const result = await api.deleteTodo(mutation.original.id) - * // Manually wait for txid to sync + * // Wait for txid to sync * await collection.utils.awaitTxId(result.txid) * } * diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index c0cfadc98..a9731b07b 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -168,21 +168,21 @@ export interface ElectricCollectionConfig< * **IMPORTANT - Electric Synchronization:** * Electric collections require explicit synchronization coordination to ensure changes have synced * from the server before dropping optimistic state. Use one of these patterns: - * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. Call `await collection.utils.awaitMatch()` for custom matching logic * * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Manually wait for txid to sync + * // Recommended: Wait for txid to sync * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * // Manually wait for txid to sync before handler completes + * // Wait for txid to sync before handler completes * await collection.utils.awaitTxId(result.txid) * } * @@ -215,7 +215,7 @@ export interface ElectricCollectionConfig< * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * await api.todos.create({ data: newItem }) - * // Manually wait for specific change to appear in sync stream + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'insert' && @@ -237,22 +237,22 @@ export interface ElectricCollectionConfig< * **IMPORTANT - Electric Synchronization:** * Electric collections require explicit synchronization coordination to ensure changes have synced * from the server before dropping optimistic state. Use one of these patterns: - * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. Call `await collection.utils.awaitMatch()` for custom matching logic * * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Manually wait for txid to sync + * // 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 * }) - * // Manually wait for txid to sync before handler completes + * // Wait for txid to sync before handler completes * await collection.utils.awaitTxId(result.txid) * } * @@ -261,7 +261,7 @@ export interface ElectricCollectionConfig< * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * await api.todos.update({ where: { id: original.id }, data: changes }) - * // Manually wait for specific change to appear in sync stream + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'update' && @@ -283,21 +283,21 @@ export interface ElectricCollectionConfig< * **IMPORTANT - Electric Synchronization:** * Electric collections require explicit synchronization coordination to ensure changes have synced * from the server before dropping optimistic state. Use one of these patterns: - * 1. Manually call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Manually call `await collection.utils.awaitMatch()` for custom matching logic + * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. Call `await collection.utils.awaitMatch()` for custom matching logic * * @param params Object containing transaction and collection information * @returns Promise that should resolve to void * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. * * @example - * // Recommended: Manually wait for txid to sync + * // 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 * }) - * // Manually wait for txid to sync before handler completes + * // Wait for txid to sync before handler completes * await collection.utils.awaitTxId(result.txid) * } * @@ -306,7 +306,7 @@ export interface ElectricCollectionConfig< * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.todos.delete({ id: mutation.original.id }) - * // Manually wait for specific change to appear in sync stream + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'delete' && From 9eaee4a2fb391918f900e0a6d2740d9fbdec2558 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:16:46 +0000 Subject: [PATCH 05/14] chore: add changeset for mutation handler deprecation --- .changeset/deprecate-handler-return-values.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/deprecate-handler-return-values.md diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md new file mode 100644 index 000000000..6a9463ed9 --- /dev/null +++ b/.changeset/deprecate-handler-return-values.md @@ -0,0 +1,41 @@ +--- +"@tanstack/db": major +"@tanstack/electric-db-collection": major +"@tanstack/query-db-collection": major +--- + +**BREAKING**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`). Instead, use 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. Magic return values like `{ refetch: false }` in Query Collections and `{ txid }` in Electric Collections are now deprecated. All handlers should coordinate sync explicitly within the handler function. + +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) +} +``` From 7f36cbc8a810cb68c74fb8412e5d496c24e6faca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Nov 2025 23:49:41 +0000 Subject: [PATCH 06/14] feat: add runtime deprecation warnings for handler return values Based on external code review feedback, this commit implements a soft deprecation strategy for mutation handler return values: Runtime changes: - Add console warnings when QueryCollection handlers return { refetch } - Add console warnings when Electric handlers return { txid } - Keep runtime functionality intact for backward compatibility - Runtime support will be removed in v1.0 RC Type improvements: - Mark TReturn generic as @internal and deprecated across all mutation types - Clarify that it exists only for backward compatibility Documentation improvements: - Clarify Electric JSDoc: handlers return Promise but must not RESOLVE until synchronization is complete (avoiding "void but not void" confusion) - Add timeout error handling example showing policy choices (rollback vs eventual consistency) - Update changeset to clearly communicate this is a soft deprecation This aligns with the review recommendation for a gradual migration path with clear runtime feedback to help users migrate to the new explicit patterns. --- .changeset/deprecate-handler-return-values.md | 12 +++- packages/db/src/types.ts | 14 +++++ .../electric-db-collection/src/electric.ts | 63 ++++++++++++++----- packages/query-db-collection/src/query.ts | 30 +++++++++ 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md index 6a9463ed9..5e57f430d 100644 --- a/.changeset/deprecate-handler-return-values.md +++ b/.changeset/deprecate-handler-return-values.md @@ -4,13 +4,21 @@ "@tanstack/query-db-collection": major --- -**BREAKING**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`). Instead, use explicit sync coordination: +**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. Magic return values like `{ refetch: false }` in Query Collections and `{ txid }` in Electric Collections are now deprecated. All handlers should coordinate sync explicitly within the handler function. +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: diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 5ad42037c..6b38a6911 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -441,6 +441,9 @@ 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, @@ -448,6 +451,9 @@ export type InsertMutationFn< 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, @@ -455,6 +461,9 @@ export type UpdateMutationFn< 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, @@ -491,6 +500,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, diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index a9731b07b..8adc89d0f 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -166,13 +166,15 @@ export interface ElectricCollectionConfig< * Optional asynchronous handler function called before an insert operation * * **IMPORTANT - Electric Synchronization:** - * Electric collections require explicit synchronization coordination to ensure changes have synced - * from the server before dropping optimistic state. Use one of these patterns: - * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Call `await collection.utils.awaitMatch()` for custom matching logic + * 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 that should resolve to 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 @@ -198,6 +200,26 @@ export interface ElectricCollectionConfig< * } * * @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 + * }) + * + * 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 * onInsert: async ({ transaction, collection }) => { * const items = transaction.mutations.map(m => m.modified) @@ -235,13 +257,15 @@ export interface ElectricCollectionConfig< * Optional asynchronous handler function called before an update operation * * **IMPORTANT - Electric Synchronization:** - * Electric collections require explicit synchronization coordination to ensure changes have synced - * from the server before dropping optimistic state. Use one of these patterns: - * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Call `await collection.utils.awaitMatch()` for custom matching logic + * 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 that should resolve to 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 @@ -281,13 +305,15 @@ export interface ElectricCollectionConfig< * Optional asynchronous handler function called before a delete operation * * **IMPORTANT - Electric Synchronization:** - * Electric collections require explicit synchronization coordination to ensure changes have synced - * from the server before dropping optimistic state. Use one of these patterns: - * 1. Call `await collection.utils.awaitTxId(txid)` (recommended for most cases) - * 2. Call `await collection.utils.awaitMatch()` for custom matching logic + * 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 that should resolve to 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 @@ -810,6 +836,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 47e07be63..4a6dd9d3a 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1250,6 +1250,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 @@ -1264,6 +1274,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 @@ -1278,6 +1298,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 From 8d9f691e5c5e486e73ab05788e9f2efe6ea63b96 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 12 Jan 2026 15:12:42 +0100 Subject: [PATCH 07/14] Prettier --- .changeset/deprecate-handler-return-values.md | 2 ++ .../electric-db-collection/src/electric.ts | 6 ++-- packages/query-db-collection/src/query.ts | 36 ++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md index 5e57f430d..324d6cd81 100644 --- a/.changeset/deprecate-handler-return-values.md +++ b/.changeset/deprecate-handler-return-values.md @@ -7,6 +7,7 @@ **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 @@ -14,6 +15,7 @@ - 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 diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 8adc89d0f..de6132746 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -838,9 +838,9 @@ export function electricCollectionOptions>( 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' + "[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 diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 4a6dd9d3a..d0066f98b 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1252,11 +1252,15 @@ export function queryCollectionOptions( const handlerResult = (await onInsert(params)) ?? {} // Warn about deprecated return value pattern - if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + 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' + "[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" ) } @@ -1276,11 +1280,15 @@ export function queryCollectionOptions( const handlerResult = (await onUpdate(params)) ?? {} // Warn about deprecated return value pattern - if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + 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' + "[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" ) } @@ -1300,11 +1308,15 @@ export function queryCollectionOptions( const handlerResult = (await onDelete(params)) ?? {} // Warn about deprecated return value pattern - if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + 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' + "[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" ) } From c53c23255cb713b977c51e553213dbf2d70baa3d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:24:46 +0000 Subject: [PATCH 08/14] ci: apply automated fixes --- .changeset/deprecate-handler-return-values.md | 6 ++--- .../electric-db-collection/src/electric.ts | 6 ++--- packages/query-db-collection/src/query.ts | 24 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md index 324d6cd81..891d34467 100644 --- a/.changeset/deprecate-handler-return-values.md +++ b/.changeset/deprecate-handler-return-values.md @@ -1,7 +1,7 @@ --- -"@tanstack/db": major -"@tanstack/electric-db-collection": major -"@tanstack/query-db-collection": major +'@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`). diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index de6132746..f308349ed 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -838,9 +838,9 @@ export function electricCollectionOptions>( 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" + '[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 diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index d0066f98b..d7afc46c0 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1254,13 +1254,13 @@ export function queryCollectionOptions( // Warn about deprecated return value pattern if ( handlerResult && - typeof handlerResult === "object" && + 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" + '[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', ) } @@ -1282,13 +1282,13 @@ export function queryCollectionOptions( // Warn about deprecated return value pattern if ( handlerResult && - typeof handlerResult === "object" && + 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" + '[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', ) } @@ -1310,13 +1310,13 @@ export function queryCollectionOptions( // Warn about deprecated return value pattern if ( handlerResult && - typeof handlerResult === "object" && + 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" + '[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', ) } From 2716d592c11698e1cd69ba272dde744267a578d8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:48:52 +0000 Subject: [PATCH 09/14] feat: improve deprecation annotations for mutation handlers Use union types to create overloads that deprecate only the non-void return signatures, not the entire handler functions. This provides more precise deprecation warnings to users. Co-authored-by: Kevin --- packages/db/src/types.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6b38a6911..f8b642ebf 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -577,9 +577,6 @@ export interface BaseCollectionConfig< syncMode?: SyncMode /** * Optional asynchronous handler function called before an insert operation - * @param params Object containing transaction and collection information - * @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 @@ -627,13 +624,17 @@ export interface BaseCollectionConfig< * } * } */ - onInsert?: InsertMutationFn + onInsert?: + | InsertMutationFn + | ( + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + InsertMutationFn + ) /** * Optional asynchronous handler function called before an update operation - * @param params Object containing transaction and collection information - * @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 @@ -685,12 +686,16 @@ export interface BaseCollectionConfig< * } * } */ - onUpdate?: UpdateMutationFn + onUpdate?: + | UpdateMutationFn + | ( + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + UpdateMutationFn + ) /** * Optional asynchronous handler function called before a delete operation - * @param params Object containing transaction and collection information - * @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 @@ -741,7 +746,14 @@ export interface BaseCollectionConfig< * } * } */ - onDelete?: DeleteMutationFn + onDelete?: + | DeleteMutationFn + | ( + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + DeleteMutationFn + ) /** * Specifies how to compare data in the collection. From 46b052fc3c1d74e0f59276064c9a1a5715922cff Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:52:10 +0000 Subject: [PATCH 10/14] docs: update examples to use new explicit sync pattern instead of deprecated return values - Update ElectricCollectionConfig examples to use await collection.utils.awaitTxId() - Update overview.md example to use explicit sync awaiting - Update mutations.md to show cleaner pattern for awaiting txids - Update examples/react/projects/README.md to use collection.utils.refetch() Co-authored-by: Kyle Mathews --- docs/guides/mutations.md | 7 ++--- docs/overview.md | 5 ++-- .../interfaces/ElectricCollectionConfig.md | 29 ++++++++++++------- examples/react/projects/README.md | 7 +++-- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index fe427d5be..4b6425ca9 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -453,14 +453,13 @@ onUpdate: async ({ transaction, collection }) => { **ElectricCollection** - wait for txid(s) to sync: ```typescript onUpdate: async ({ transaction, collection }) => { - const txids = await Promise.all( + await Promise.all( transaction.mutations.map(async (mutation) => { const response = await api.todos.update(mutation.original.id, mutation.changes) - return response.txid + // Wait for this txid to sync + await collection.utils.awaitTxId(response.txid) }) ) - // Wait for all txids to sync - await Promise.all(txids.map(txid => collection.utils.awaitTxId(txid))) } ``` diff --git a/docs/overview.md b/docs/overview.md index 8af0254c3..bcbb5f33f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -498,10 +498,11 @@ export const todoCollection = createCollection( }, getKey: (item) => item.id, schema: todoSchema, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const response = await api.todos.create(transaction.mutations[0].modified) - return { txid: response.txid } + // Wait for txid to sync + await collection.utils.awaitTxId(response.txid) }, // You can also implement onUpdate, onDelete as needed. }) diff --git a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md index 46a6dc547..087a3dee6 100644 --- a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md +++ b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md @@ -70,12 +70,13 @@ Promise resolving to { txid, timeout? } or void ```ts // Basic Electric delete handler with txid (recommended) -onDelete: async ({ transaction }) => { +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 + await collection.utils.awaitTxId(result.txid) } ``` @@ -122,34 +123,39 @@ Promise resolving to { txid, timeout? } or void ```ts // Basic Electric insert handler with txid (recommended) -onInsert: async ({ transaction }) => { +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 + await collection.utils.awaitTxId(result.txid) } ``` ```ts // 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 }) - return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds + // Wait for txid to sync with custom timeout (10 seconds) + await collection.utils.awaitTxId(result.txid, 10000) } ``` ```ts -// Insert handler with multiple items - return array of txids -onInsert: async ({ transaction }) => { +// Insert handler with multiple items - wait for all txids +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)) + ) } ``` @@ -196,13 +202,14 @@ Promise resolving to { txid, timeout? } or void ```ts // Basic Electric update handler with txid (recommended) -onUpdate: async ({ transaction }) => { +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 + await collection.utils.awaitTxId(result.txid) } ``` diff --git a/examples/react/projects/README.md b/examples/react/projects/README.md index 7a122431d..05b0f0b4e 100644 --- a/examples/react/projects/README.md +++ b/examples/react/projects/README.md @@ -201,14 +201,15 @@ export const todoCollection = createCollection( queryClient, schema: todoSchema, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const { modified: newTodo } = transaction.mutations[0] - const result = await trpc.todos.create.mutate({ + await trpc.todos.create.mutate({ text: newTodo.text, completed: newTodo.completed, project_id: newTodo.project_id, }) - return { txid: result.txid } + // Trigger refetch to sync server state + await collection.utils.refetch() }, // You can also implement onUpdate, onDelete as needed }) From 64ba5018178d4116897993e73384d9cd5b1ff922 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 20 Jan 2026 10:43:57 -0700 Subject: [PATCH 11/14] docs: fix timeout documentation and increase awaitTxId default to 15s - Fix changeset to remove incorrect claim that TypeScript would error on old return patterns (types are generic/overridable, only runtime warns) - Update awaitTxId default timeout from 5s to 15s for more reliable sync - Fix documentation that incorrectly stated 30s default Co-Authored-By: Claude Opus 4.5 --- .changeset/deprecate-handler-return-values.md | 13 ++++++------- docs/collections/electric-collection.md | 2 +- packages/electric-db-collection/src/electric.ts | 8 ++++---- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md index 891d34467..b8e8b4aab 100644 --- a/.changeset/deprecate-handler-return-values.md +++ b/.changeset/deprecate-handler-return-values.md @@ -1,18 +1,17 @@ --- -'@tanstack/db': major -'@tanstack/electric-db-collection': major -'@tanstack/query-db-collection': major +'@tanstack/db': minor +'@tanstack/electric-db-collection': minor +'@tanstack/query-db-collection': minor --- **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 +- Handler types now default to `Promise` instead of `Promise`, indicating the new expected pattern +- Old return patterns (`return { refetch }`, `return { txid }`) still work at runtime with deprecation warnings - **Deprecation warnings** are now logged when handlers return values -- Old patterns will be fully removed in v1.0 RC +- Old patterns will be fully removed in v1.0 **New pattern (explicit sync coordination):** diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 5ff3461ec..b9ca9d755 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -312,7 +312,7 @@ Wait for a specific transaction ID to be synchronized: // Wait for specific txid await todosCollection.utils.awaitTxId(12345) -// With custom timeout (default is 30 seconds) +// With custom timeout (default is 15 seconds) await todosCollection.utils.awaitTxId(12345, 10000) ``` diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index f308349ed..304aee66c 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -92,7 +92,7 @@ export type MatchFunction> = ( * - Void (no return value) - mutation completes without waiting * * The optional timeout property specifies how long to wait for the txid(s) in milliseconds. - * If not specified, defaults to 5000ms. + * If not specified, defaults to 15000ms. */ export type MatchingStrategy = { txid: Txid | Array @@ -662,12 +662,12 @@ export function electricCollectionOptions>( /** * Wait for a specific transaction ID to be synced * @param txId The transaction ID to wait for as a number - * @param timeout Optional timeout in milliseconds (defaults to 5000ms) + * @param timeout Optional timeout in milliseconds (defaults to 15000ms) * @returns Promise that resolves when the txId is synced */ const awaitTxId: AwaitTxIdFn = async ( txId: Txid, - timeout: number = 5000, + timeout: number = 15000, ): Promise => { debug( `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`, @@ -729,7 +729,7 @@ export function electricCollectionOptions>( /** * Wait for a custom match function to find a matching message * @param matchFn Function that returns true when a message matches - * @param timeout Optional timeout in milliseconds (defaults to 5000ms) + * @param timeout Optional timeout in milliseconds (defaults to 15000ms) * @returns Promise that resolves when a matching message is found */ const awaitMatch: AwaitMatchFn = async ( From bbd00ac623386b448002913b540a1d003269f6e1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:45:07 +0000 Subject: [PATCH 12/14] ci: apply automated fixes --- packages/db/src/types.ts | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index f8b642ebf..94b9674e9 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -626,12 +626,10 @@ export interface BaseCollectionConfig< */ onInsert?: | InsertMutationFn - | ( - /** - * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. - */ - InsertMutationFn - ) + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + | InsertMutationFn /** * Optional asynchronous handler function called before an update operation @@ -688,12 +686,10 @@ export interface BaseCollectionConfig< */ onUpdate?: | UpdateMutationFn - | ( - /** - * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. - */ - UpdateMutationFn - ) + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + | UpdateMutationFn /** * Optional asynchronous handler function called before a delete operation * @@ -748,12 +744,10 @@ export interface BaseCollectionConfig< */ onDelete?: | DeleteMutationFn - | ( - /** - * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. - */ - DeleteMutationFn - ) + /** + * @deprecated Returning values from mutation handlers is deprecated. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. This signature will be removed in v1.0. + */ + | DeleteMutationFn /** * Specifies how to compare data in the collection. From 5d564f7edf96beed2409f0e9d89d005b5f12a48c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 20 Jan 2026 12:50:40 -0700 Subject: [PATCH 13/14] feat: add warnOnce utility and improve deprecation warnings - Add warnOnce utility to prevent console spam (logs each warning once) - QueryCollection: warn about deprecated auto-refetch behavior - QueryCollection: clarify { refetch: false } is correct pattern for now - ElectricCollection: use warnOnce for { txid } return deprecation - Update docs to accurately describe current vs v1.0 behavior - Update changeset with clearer migration guidance Co-Authored-By: Claude Opus 4.5 --- .changeset/deprecate-handler-return-values.md | 36 +++--- docs/collections/query-collection.md | 58 ++++++---- packages/db/src/index.ts | 2 +- packages/db/src/utils.ts | 37 ++++++ .../electric-db-collection/src/electric.ts | 5 +- packages/query-db-collection/src/query.ts | 106 +++++++++++------- 6 files changed, 160 insertions(+), 84 deletions(-) diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md index b8e8b4aab..8e25b8cc5 100644 --- a/.changeset/deprecate-handler-return-values.md +++ b/.changeset/deprecate-handler-return-values.md @@ -4,47 +4,47 @@ '@tanstack/query-db-collection': minor --- -**BREAKING (TypeScript only)**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`). +**Deprecation**: Mutation handler return values and QueryCollection auto-refetch behavior. **What's changed:** - Handler types now default to `Promise` instead of `Promise`, indicating the new expected pattern -- Old return patterns (`return { refetch }`, `return { txid }`) still work at runtime with deprecation warnings -- **Deprecation warnings** are now logged when handlers return values -- Old patterns will be fully removed in v1.0 +- **Deprecation warnings** are logged when deprecated patterns are used -**New pattern (explicit sync coordination):** +**QueryCollection changes:** -- **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 +- Auto-refetch after handlers is **deprecated** and will be removed in v1.0 +- To skip auto-refetch now, return `{ refetch: false }` from your handler +- In v1.0: call `await collection.utils.refetch()` explicitly when needed, or omit to skip -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. +**ElectricCollection changes:** -Migration guide: +- Returning `{ txid }` is deprecated - use `await collection.utils.awaitTxId(txid)` instead + +**Migration guide:** ```typescript -// Before (Query Collection) +// QueryCollection - skip refetch (current) onInsert: async ({ transaction }) => { await api.create(transaction.mutations[0].modified) - // Implicitly refetches + return { refetch: false } // Opt out of auto-refetch } -// After (Query Collection) +// QueryCollection - with refetch (v1.0 pattern) onInsert: async ({ transaction, collection }) => { await api.create(transaction.mutations[0].modified) - await collection.utils.refetch() + await collection.utils.refetch() // Explicit refetch } -// Before (Electric Collection) +// ElectricCollection - before onInsert: async ({ transaction }) => { const result = await api.create(transaction.mutations[0].modified) - return { txid: result.txid } + return { txid: result.txid } // Deprecated } -// After (Electric Collection) +// ElectricCollection - after onInsert: async ({ transaction, collection }) => { const result = await api.create(transaction.mutations[0].modified) - await collection.utils.awaitTxId(result.txid) + await collection.utils.awaitTxId(result.txid) // Explicit } ``` diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 557b36a2e..646492025 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -199,7 +199,7 @@ const productsCollection = createCollection( ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers persist changes to your backend and trigger refetches when needed: +You can define handlers that are called when mutations occur. These handlers persist changes to your backend: ```typescript const todosCollection = createCollection( @@ -209,62 +209,80 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, - onInsert: async ({ transaction, collection }) => { + onInsert: async ({ transaction }) => { const newItems = transaction.mutations.map((m) => m.modified) await api.createTodos(newItems) - // Trigger refetch to sync server state - await collection.utils.refetch() + // Auto-refetch happens after handler completes (pre-1.0 behavior) }, - onUpdate: async ({ transaction, collection }) => { + onUpdate: async ({ transaction }) => { 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, collection }) => { + onDelete: async ({ transaction }) => { const ids = transaction.mutations.map((m) => m.key) await api.deleteTodos(ids) - await collection.utils.refetch() }, }) ) ``` +> **Note**: QueryCollection currently auto-refetches after handlers complete. See [Controlling Refetch Behavior](#controlling-refetch-behavior) for details on this transitional behavior. + ### Controlling Refetch Behavior -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. +> **⚠️ Transitional API**: QueryCollection currently auto-refetches after handlers complete. This behavior is deprecated and will be removed in v1.0. See the migration notes below. + +#### Current Behavior (Pre-1.0) + +By default, QueryCollection automatically refetches after each handler completes. To **skip** auto-refetch, return `{ refetch: false }`: ```typescript -onInsert: async ({ transaction, collection }) => { +onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Trigger refetch to sync server state - await collection.utils.refetch() + // Skip auto-refetch - use this when server doesn't modify the data + return { refetch: false } } ``` -You can skip the refetch when: +If you don't return `{ refetch: false }`, auto-refetch happens automatically. -- 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 +#### v1.0 Behavior (Future) + +In v1.0, auto-refetch will be **removed**. Handlers will need to explicitly call `collection.utils.refetch()` when refetching is needed: + +```typescript +onInsert: async ({ transaction, collection }) => { + await api.createTodos(transaction.mutations.map((m) => m.modified)) + + // Explicitly trigger refetch when you need server state + await collection.utils.refetch() +} +``` -**When to skip refetch:** +To skip refetch in v1.0, simply don't call `refetch()`: ```typescript onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Skip refetch - only do this if server doesn't modify the data - // The optimistic state will remain as-is + // No refetch call = no refetch (v1.0 behavior) } ``` +#### When to Skip Refetch + +Skip refetching 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 + ## Utility Methods The collection provides these utility methods via `collection.utils`: diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ccf7cbb6e..0ce303f82 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -13,7 +13,7 @@ export * from './optimistic-action' export * from './local-only' export * from './local-storage' export * from './errors' -export { deepEquals } from './utils' +export { deepEquals, warnOnce, resetWarnings } from './utils' export * from './paced-mutations' export * from './strategies/index.js' diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 00292e37a..0c4b88c8e 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -237,3 +237,40 @@ export const DEFAULT_COMPARE_OPTIONS: CompareOptions = { nulls: `first`, stringSort: `locale`, } + +/** + * Set of warning keys that have already been shown. + * Used to prevent duplicate warnings from spamming the console. + */ +const warnedKeys = new Set() + +/** + * Log a warning message only once per unique key. + * Subsequent calls with the same key will be silently ignored. + * + * @param key - Unique identifier for this warning + * @param message - The warning message to display + * + * @example + * ```typescript + * // First call logs the warning + * warnOnce('deprecated-api', 'This API is deprecated') + * + * // Subsequent calls with same key are ignored + * warnOnce('deprecated-api', 'This API is deprecated') // silent + * ``` + */ +export function warnOnce(key: string, message: string): void { + if (warnedKeys.has(key)) { + return + } + warnedKeys.add(key) + console.warn(message) +} + +/** + * Reset all warning states. Primarily useful for testing. + */ +export function resetWarnings(): void { + warnedKeys.clear() +} diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 304aee66c..aa832a11f 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -6,7 +6,7 @@ import { } from '@electric-sql/client' import { Store } from '@tanstack/store' import DebugModule from 'debug' -import { DeduplicatedLoadSubset, and } from '@tanstack/db' +import { DeduplicatedLoadSubset, and, warnOnce } from '@tanstack/db' import { ExpectedNumberInAwaitTxIdError, StreamAbortedError, @@ -837,7 +837,8 @@ export function electricCollectionOptions>( // Only wait if result contains txid if (result && `txid` in result) { // Warn about deprecated return value pattern - console.warn( + warnOnce( + 'electric-collection-txid-return', '[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', diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index d7afc46c0..0fa20144b 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1,5 +1,5 @@ import { QueryObserver, hashKey } from '@tanstack/query-core' -import { deepEquals } from '@tanstack/db' +import { deepEquals, warnOnce } from '@tanstack/db' import { GetKeyRequiredError, QueryClientRequiredError, @@ -1251,23 +1251,31 @@ export function queryCollectionOptions( ? async (params: InsertMutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} - // Warn about deprecated return value pattern - if ( + const explicitRefetchFalse = 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', + 'refetch' in handlerResult && + handlerResult.refetch === false + + if (explicitRefetchFalse) { + // User is correctly opting out of auto-refetch - warn about upcoming change + warnOnce( + 'query-collection-refetch-false', + '[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' + + 'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' + + 'or omit it to skip refetching. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', + ) + } else { + // Auto-refetch is happening - warn about upcoming removal + warnOnce( + 'query-collection-auto-refetch', + '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + + 'This behavior will be removed in v1.0. To prepare: ' + + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + + '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) - } - - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - - if (shouldRefetch) { await refetch() } @@ -1279,23 +1287,29 @@ export function queryCollectionOptions( ? async (params: UpdateMutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} - // Warn about deprecated return value pattern - if ( + const explicitRefetchFalse = 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', + 'refetch' in handlerResult && + handlerResult.refetch === false + + if (explicitRefetchFalse) { + warnOnce( + 'query-collection-refetch-false', + '[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' + + 'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' + + 'or omit it to skip refetching. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', + ) + } else { + warnOnce( + 'query-collection-auto-refetch', + '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + + 'This behavior will be removed in v1.0. To prepare: ' + + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + + '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) - } - - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - - if (shouldRefetch) { await refetch() } @@ -1307,23 +1321,29 @@ export function queryCollectionOptions( ? async (params: DeleteMutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} - // Warn about deprecated return value pattern - if ( + const explicitRefetchFalse = 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', + 'refetch' in handlerResult && + handlerResult.refetch === false + + if (explicitRefetchFalse) { + warnOnce( + 'query-collection-refetch-false', + '[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' + + 'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' + + 'or omit it to skip refetching. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', + ) + } else { + warnOnce( + 'query-collection-auto-refetch', + '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + + 'This behavior will be removed in v1.0. To prepare: ' + + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + + '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) - } - - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - - if (shouldRefetch) { await refetch() } From 7b1aa37b25b24cc9aee3be657835f22cbde313c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:55:10 +0000 Subject: [PATCH 14/14] ci: apply automated fixes --- packages/query-db-collection/src/query.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 0fa20144b..07d76f402 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1273,7 +1273,7 @@ export function queryCollectionOptions( '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + 'This behavior will be removed in v1.0. To prepare: ' + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + - '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + "(2) Return `{ refetch: false }` to opt out now if you don't need it. " + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) await refetch() @@ -1307,7 +1307,7 @@ export function queryCollectionOptions( '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + 'This behavior will be removed in v1.0. To prepare: ' + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + - '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + "(2) Return `{ refetch: false }` to opt out now if you don't need it. " + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) await refetch() @@ -1341,7 +1341,7 @@ export function queryCollectionOptions( '[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' + 'This behavior will be removed in v1.0. To prepare: ' + '(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' + - '(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' + + "(2) Return `{ refetch: false }` to opt out now if you don't need it. " + 'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior', ) await refetch()