From 307d50b9f2ef5f20b1b2ab3cf99aca7647b624bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 22:43:55 +0000 Subject: [PATCH 1/6] 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 4f43082d7..d51a901c7 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -79,7 +79,7 @@ The `queryCollectionOptions` function accepts the following options: ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: +You can define handlers that are called when mutations occur. These handlers persist changes to your backend and can manually trigger refetches when needed: ```typescript const todosCollection = createCollection( @@ -89,24 +89,27 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItems = transaction.mutations.map((m) => m.modified) await api.createTodos(newItems) - // Returning nothing or { refetch: true } will trigger a refetch - // Return { refetch: false } to skip automatic refetch + // 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() }, }) ) @@ -114,24 +117,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 @@ -241,7 +253,7 @@ ws.on("todos:update", (changes) => { ### Example: Incremental Updates -When the server returns computed fields (like server-generated IDs or timestamps), you can use the `onInsert` handler with `{ refetch: false }` to avoid unnecessary refetches while still syncing the server response: +When the server returns computed fields (like server-generated IDs or timestamps), you can use direct writes to sync the server response without triggering a full refetch: ```typescript const todosCollection = createCollection( @@ -251,26 +263,25 @@ const todosCollection = createCollection( queryClient, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItems = transaction.mutations.map((m) => m.modified) // Send to server and get back items with server-computed fields const serverItems = await api.createTodos(newItems) // Sync server-computed fields (like server-generated IDs, timestamps, etc.) - // to the collection's synced data store - todosCollection.utils.writeBatch(() => { + // to the collection's synced data store using direct writes + collection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { - todosCollection.utils.writeInsert(serverItem) + collection.utils.writeInsert(serverItem) }) }) - // Skip automatic refetch since we've already synced the server response + // No need to refetch - we've already synced the server response via direct writes // (optimistic state is automatically replaced when handler completes) - return { refetch: false } }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const updates = transaction.mutations.map((m) => ({ id: m.key, changes: m.changes, @@ -278,13 +289,13 @@ const todosCollection = createCollection( const serverItems = await api.updateTodos(updates) // Sync server-computed fields from the update response - todosCollection.utils.writeBatch(() => { + collection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { - todosCollection.utils.writeUpdate(serverItem) + collection.utils.writeUpdate(serverItem) }) }) - return { refetch: false } + // No refetch needed since we used direct writes }, }) ) @@ -384,7 +395,7 @@ Direct writes update the collection immediately and also update the TanStack Que To handle this properly: -1. Use `{ refetch: false }` in your persistence handlers when using direct writes +1. Skip calling `collection.utils.refetch()` in your persistence handlers when using direct writes 2. Set appropriate `staleTime` to prevent unnecessary refetches 3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data) 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 d5e82c41f..e49c50352 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -367,21 +367,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 /** @@ -486,7 +486,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 }) => { @@ -495,10 +499,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 @@ -506,30 +521,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 }) => { @@ -538,11 +546,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 @@ -553,6 +563,7 @@ export interface BaseCollectionConfig< * changes: m.changes * })) * await api.updateTodos(updates) + * await collection.utils.refetch() * } * * @example @@ -572,7 +583,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 }) => { @@ -581,10 +596,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 0b377f672..b4c4d3060 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -120,16 +120,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 @@ -153,10 +163,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' && @@ -168,24 +179,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' && @@ -197,23 +219,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 5c813674737847c04b0b63b5218fb62e17f12493 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:01:54 +0000 Subject: [PATCH 2/6] 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 7a00d2506e30e4469f67778176ffc7e935adbb5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:05:25 +0000 Subject: [PATCH 3/6] 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 e49c50352..d1b3f44c2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -488,8 +488,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 @@ -508,6 +506,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) @@ -535,8 +542,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 @@ -556,6 +561,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 => ({ @@ -585,8 +599,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 @@ -605,6 +617,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 b4c4d3060..b3999b0e1 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -121,45 +121,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 @@ -180,26 +184,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 @@ -220,25 +224,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 116f5345011fa5141d7065da9466913e1d751ada Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:15:46 +0000 Subject: [PATCH 4/6] 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 d51a901c7..0c58f0c5a 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -79,7 +79,7 @@ The `queryCollectionOptions` function accepts the following options: ## Persistence Handlers -You can define handlers that are called when mutations occur. These handlers 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( @@ -92,7 +92,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() }, @@ -102,7 +102,7 @@ const todosCollection = createCollection( changes: m.changes, })) await api.updateTodos(updates) - // Manually refetch after persisting changes + // Refetch after persisting changes await collection.utils.refetch() }, @@ -117,13 +117,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() } ``` @@ -149,7 +149,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 @@ -408,7 +408,7 @@ All direct write methods are available on `collection.utils`: - `writeDelete(keys)`: Delete one or more items directly - `writeUpsert(data)`: Insert or update one or more items directly - `writeBatch(callback)`: Perform multiple operations atomically -- `refetch(opts?)`: Manually trigger a refetch of the query +- `refetch(opts?)`: Trigger a refetch of the query ## QueryFn and Predicate Push-Down diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 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 d1b3f44c2..b0fdc9469 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -487,7 +487,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 @@ -497,20 +497,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) * } * @@ -541,7 +541,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 @@ -551,21 +551,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) * } * @@ -598,7 +598,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 @@ -608,20 +608,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 b3999b0e1..8d609a78a 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -124,21 +124,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) * } * @@ -171,7 +171,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' && @@ -187,22 +187,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) * } * @@ -211,7 +211,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' && @@ -227,21 +227,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) * } * @@ -250,7 +250,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 b44463fa722242560259209434a101129e60f762 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 23:16:46 +0000 Subject: [PATCH 5/6] 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 98bb88b6284fa09a6dd12637e856c8b8a0281ed2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Nov 2025 23:49:41 +0000 Subject: [PATCH 6/6] 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 b0fdc9469..75e792212 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -363,6 +363,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, @@ -370,6 +373,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, @@ -377,6 +383,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, @@ -413,6 +422,11 @@ export type CollectionStatus = export type SyncMode = `eager` | `on-demand` +/** + * @typeParam TReturn - @internal DEPRECATED: This generic parameter exists for backward compatibility only. + * Mutation handlers should not return values. Use collection utilities (refetch, awaitTxId, etc.) for sync coordination. + * This parameter will be removed in v1.0. + */ export interface BaseCollectionConfig< T extends object = Record, TKey extends string | number = string | number, diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 8d609a78a..b0befa70f 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -122,13 +122,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 @@ -154,6 +156,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) @@ -185,13 +207,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 @@ -225,13 +249,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 @@ -584,6 +610,13 @@ export function electricCollectionOptions( ): Promise => { // Only wait if result contains txid if (result && `txid` in result) { + // Warn about deprecated return value pattern + console.warn( + '[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization' + ) + const timeout = result.timeout // Handle both single txid and array of txids if (Array.isArray(result.txid)) { diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index d1d07b664..d64d9ad4e 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1033,6 +1033,16 @@ export function queryCollectionOptions( const wrappedOnInsert = onInsert ? async (params: InsertMutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -1047,6 +1057,16 @@ export function queryCollectionOptions( const wrappedOnUpdate = onUpdate ? async (params: UpdateMutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -1061,6 +1081,16 @@ export function queryCollectionOptions( const wrappedOnDelete = onDelete ? async (params: DeleteMutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} + + // Warn about deprecated return value pattern + if (handlerResult && typeof handlerResult === 'object' && Object.keys(handlerResult).length > 0) { + console.warn( + '[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' + + 'Use `await collection.utils.refetch()` instead of returning { refetch }. ' + + 'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns' + ) + } + const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false