diff --git a/.changeset/deprecate-handler-return-values.md b/.changeset/deprecate-handler-return-values.md new file mode 100644 index 000000000..8e25b8cc5 --- /dev/null +++ b/.changeset/deprecate-handler-return-values.md @@ -0,0 +1,50 @@ +--- +'@tanstack/db': minor +'@tanstack/electric-db-collection': minor +'@tanstack/query-db-collection': minor +--- + +**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 +- **Deprecation warnings** are logged when deprecated patterns are used + +**QueryCollection changes:** + +- 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 + +**ElectricCollection changes:** + +- Returning `{ txid }` is deprecated - use `await collection.utils.awaitTxId(txid)` instead + +**Migration guide:** + +```typescript +// QueryCollection - skip refetch (current) +onInsert: async ({ transaction }) => { + await api.create(transaction.mutations[0].modified) + return { refetch: false } // Opt out of auto-refetch +} + +// QueryCollection - with refetch (v1.0 pattern) +onInsert: async ({ transaction, collection }) => { + await api.create(transaction.mutations[0].modified) + await collection.utils.refetch() // Explicit refetch +} + +// ElectricCollection - before +onInsert: async ({ transaction }) => { + const result = await api.create(transaction.mutations[0].modified) + return { txid: result.txid } // Deprecated +} + +// ElectricCollection - after +onInsert: async ({ transaction, collection }) => { + const result = await api.create(transaction.mutations[0].modified) + await collection.utils.awaitTxId(result.txid) // Explicit +} +``` diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 228098762..b9ca9d755 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -60,7 +60,7 @@ Handlers are called before mutations to persist changes to your backend: - `onUpdate`: Handler called before update operations - `onDelete`: Handler called before delete operations -Each handler should return `{ txid }` to wait for synchronization. For cases where your API can not return txids, use the `awaitMatch` utility function. +Each handler should call `await collection.utils.awaitTxId(txid)` to wait for synchronization. For cases where your API cannot return txids, use the `awaitMatch` utility function. ## Persistence Handlers & Synchronization @@ -81,22 +81,23 @@ const todosCollection = createCollection( params: { table: 'todos' }, }, - onInsert: async ({ transaction }) => { + onInsert: async ({ transaction, collection }) => { const newItem = transaction.mutations[0].modified const response = await api.todos.create(newItem) - // Return txid to wait for sync - return { txid: response.txid } + // Wait for txid to sync + await collection.utils.awaitTxId(response.txid) }, - onUpdate: async ({ transaction }) => { + onUpdate: async ({ transaction, collection }) => { const { original, changes } = transaction.mutations[0] const response = await api.todos.update({ where: { id: original.id }, data: changes }) - return { txid: response.txid } + // Wait for txid to sync + await collection.utils.awaitTxId(response.txid) } }) ) @@ -305,13 +306,13 @@ 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 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) ``` @@ -319,7 +320,7 @@ This is useful when you need to ensure a mutation has been synchronized before p ### `awaitMatch(matchFn, timeout?)` -Manually wait for a custom match function to find a matching message: +Wait for a custom match function to find a matching message: ```typescript import { isChangeMessage } from '@tanstack/electric-db-collection' diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index cf13291a8..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 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: ```typescript const todosCollection = createCollection( @@ -212,8 +212,7 @@ const todosCollection = createCollection( onInsert: async ({ transaction }) => { 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 + // Auto-refetch happens after handler completes (pre-1.0 behavior) }, onUpdate: async ({ transaction }) => { @@ -232,32 +231,63 @@ const todosCollection = createCollection( ) ``` +> **Note**: QueryCollection currently auto-refetches after handlers complete. See [Controlling Refetch Behavior](#controlling-refetch-behavior) for details on this transitional behavior. + ### 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. +> **⚠️ 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) -You can control this behavior by returning an object with a `refetch` property: +By default, QueryCollection automatically refetches after each handler completes. To **skip** auto-refetch, return `{ refetch: false }`: ```typescript onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // Skip the automatic refetch + // Skip auto-refetch - use this when server doesn't modify the data return { refetch: false } } ``` -This is useful when: +If you don't return `{ refetch: false }`, auto-refetch happens automatically. + +#### 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() +} +``` + +To skip refetch in v1.0, simply don't call `refetch()`: -- 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) +```typescript +onInsert: async ({ transaction }) => { + await api.createTodos(transaction.mutations.map((m) => m.modified)) + + // 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`: -- `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 @@ -361,7 +391,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 +401,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 +427,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 +533,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) @@ -517,7 +546,7 @@ All direct write methods are available on `collection.utils`: - `writeDelete(keys)`: Delete one or more items directly - `writeUpsert(data)`: Insert or update one or more items directly - `writeBatch(callback)`: Perform multiple operations atomically -- `refetch(opts?)`: Manually trigger a refetch of the query +- `refetch(opts?)`: Trigger a refetch of the query ## QueryFn and Predicate Push-Down diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index d1f4d55c4..b8594334b 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -340,14 +340,16 @@ For more on schemas from a user perspective, see the [Schemas guide](./schemas.m There are two distinct patterns for handling mutations in collection options creators: -#### Pattern A: User-Provided Handlers (ElectricSQL, Query) +#### Pattern A: User-Provided Handlers (Query, Standard) -The user provides mutation handlers in the config. Your collection creator passes them through: +The user provides mutation handlers in the config. Your collection creator passes them through. + +**Note:** Handler return values are deprecated. Users should trigger refetch/sync within their handlers. ```typescript interface MyCollectionConfig { // ... other config - + // User provides these handlers onInsert?: InsertMutationFn onUpdate?: UpdateMutationFn @@ -360,23 +362,23 @@ export function myCollectionOptions( return { // ... other options rowUpdateMode: config.rowUpdateMode || 'partial', - - // Pass through user-provided handlers (possibly with additional logic) - onInsert: config.onInsert ? async (params) => { - const result = await config.onInsert!(params) - // Additional sync coordination logic - return result - } : undefined + + // Pass through user-provided handlers + // Users handle sync coordination in their own handlers + onInsert: config.onInsert, + onUpdate: config.onUpdate, + onDelete: config.onDelete } } ``` + #### Pattern B: Built-in Handlers (Trailbase, WebSocket, Firebase) Your collection creator implements the handlers directly using the sync engine's APIs: ```typescript -interface MyCollectionConfig +interface MyCollectionConfig extends Omit, 'onInsert' | 'onUpdate' | 'onDelete'> { // ... sync engine specific config // Note: onInsert/onUpdate/onDelete are NOT in the config @@ -388,33 +390,34 @@ export function myCollectionOptions( return { // ... other options rowUpdateMode: config.rowUpdateMode || 'partial', - + // Implement handlers using sync engine APIs onInsert: async ({ transaction }) => { // Handle provider-specific batch limits (e.g., Firestore's 500 limit) const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT) - + for (const chunk of chunks) { const ids = await config.recordApi.createBulk( chunk.map(m => serialize(m.modified)) ) + // Wait for these IDs to sync back before completing await awaitIds(ids) } - - return transaction.mutations.map(m => m.key) + // Handler completes after sync coordination }, - + onUpdate: async ({ transaction }) => { const chunks = chunkArray(transaction.mutations, PROVIDER_BATCH_LIMIT) - + for (const chunk of chunks) { await Promise.all( - chunk.map(m => + chunk.map(m => config.recordApi.update(m.key, serialize(m.changes)) ) ) } - + + // Wait for mutations to sync back await awaitIds(transaction.mutations.map(m => String(m.key))) } } @@ -423,6 +426,8 @@ export function myCollectionOptions( Many providers have batch size limits (Firestore: 500, DynamoDB: 25, etc.) so chunk large transactions accordingly. +**Key Principle:** Built-in handlers should coordinate sync internally (using `awaitIds`, `awaitTxId`, or similar) and not rely on return values. The handler completes only after sync coordination is done. + Choose Pattern A when users need to provide their own APIs, and Pattern B when your sync engine handles writes directly. ## Row Update Modes @@ -675,15 +680,17 @@ export function webSocketCollectionOptions( } // All mutation handlers use the same transaction sender - const onInsert = async (params: InsertMutationFnParams) => { + // Handlers wait for server acknowledgment before completing + const onInsert = async (params: InsertMutationFnParams): Promise => { await sendTransaction(params) + // Handler completes after server confirms the transaction } - - const onUpdate = async (params: UpdateMutationFnParams) => { + + const onUpdate = async (params: UpdateMutationFnParams): Promise => { await sendTransaction(params) } - - const onDelete = async (params: DeleteMutationFnParams) => { + + const onDelete = async (params: DeleteMutationFnParams): Promise => { await sendTransaction(params) } @@ -768,16 +775,15 @@ if (message.headers.txids) { }) } -// Mutation handlers return txids and wait for them +// Electric-specific: Wrap user handlers to coordinate txid-based sync const wrappedOnInsert = async (params) => { + // User handler returns { txid } for Electric collections const result = await config.onInsert!(params) - - // Wait for the txid to appear in synced data - if (result.txid) { + + // Electric-specific: Wait for the txid to appear in synced data + if (result?.txid) { await awaitTxId(result.txid) } - - return result } // Utility function to wait for a txid @@ -810,8 +816,8 @@ seenIds.setState(prev => new Map(prev).set(item.id, Date.now())) // Wait for specific IDs after mutations const wrappedOnInsert = async (params) => { const ids = await config.recordApi.createBulk(items) - - // Wait for all IDs to be synced back + + // Wait for all IDs to be synced back before handler completes await awaitIds(ids) } @@ -842,8 +848,8 @@ let lastSyncTime = 0 const wrappedOnUpdate = async (params) => { const mutationTime = Date.now() await config.onUpdate(params) - - // Wait for sync to catch up + + // Wait for sync to catch up before handler completes await waitForSync(mutationTime) } @@ -861,21 +867,40 @@ const waitForSync = (afterTime: number): Promise => { } ``` -### Strategy 5: Full Refetch (Query Collection) +### Strategy 5: Refetch (Query Collection) -The query collection simply refetches all data after mutations: +The query collection pattern has users refetch after mutations: ```typescript -const wrappedOnInsert = async (params) => { - // Perform the mutation - await config.onInsert(params) - - // Refetch the entire collection - await refetch() - - // The refetch will trigger sync with fresh data, - // automatically dropping optimistic state +// Pattern A: User provides handlers and manages refetch +export function queryCollectionOptions(config) { + return { + // ... other options + + // User provides handlers and they handle refetch themselves + onInsert: config.onInsert, // User calls collection.utils.refetch() in their handler + onUpdate: config.onUpdate, + onDelete: config.onDelete, + + utils: { + refetch: () => { + // Refetch implementation that syncs fresh data + // automatically dropping optimistic state + } + } + } } + +// Usage: User refetches in their handler +const collection = createCollection( + queryCollectionOptions({ + onInsert: async ({ transaction, collection }) => { + await api.createTodos(transaction.mutations.map(m => m.modified)) + // User explicitly triggers refetch + await collection.utils.refetch() + } + }) +) ``` ### Choosing a Strategy @@ -902,6 +927,7 @@ const wrappedOnInsert = async (params) => { 5. **Race Conditions** - Start listeners before initial fetch and buffer events 6. **Type safety** - Use TypeScript generics to maintain type safety throughout 7. **Provide utilities** - Export sync-engine-specific utilities for advanced use cases +8. **Handler return values are deprecated** - Mutation handlers should coordinate sync internally (via `await`) rather than returning values. For Electric collections, use `await collection.utils.awaitTxId(txid)` instead of returning the txid ## Testing Your Collection diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 4c1662560..4b6425ca9 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -233,7 +233,7 @@ Use this approach when: - You want to use TanStack DB only for queries and state management How to sync changes back: -- **QueryCollection**: Manually refetch with `collection.utils.refetch()` to reload data from the server +- **QueryCollection**: Refetch with `collection.utils.refetch()` to reload data from the server - **ElectricCollection**: Use `collection.utils.awaitTxId(txid)` to wait for a specific transaction to sync - **Other sync systems**: Wait for your sync mechanism to update the collection @@ -437,28 +437,29 @@ const todoCollection = createCollection({ Different collection types have specific patterns for their handlers: -**QueryCollection** - automatically refetches after handler completes: +**QueryCollection** - refetch after persisting changes: ```typescript -onUpdate: async ({ transaction }) => { +onUpdate: async ({ transaction, collection }) => { await Promise.all( transaction.mutations.map((mutation) => api.todos.update(mutation.original.id, mutation.changes) ) ) - // Automatic refetch happens after handler completes + // Trigger refetch to sync server state + await collection.utils.refetch() } ``` -**ElectricCollection** - return txid(s) to track sync: +**ElectricCollection** - wait for txid(s) to sync: ```typescript -onUpdate: async ({ transaction }) => { - const txids = await Promise.all( +onUpdate: async ({ transaction, collection }) => { + 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) }) ) - return { txid: txids } } ``` 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 }) 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/types.ts b/packages/db/src/types.ts index 29bfce622..94b9674e9 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -441,25 +441,34 @@ export type DeleteMutationFnParams< collection: Collection } +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type InsertMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: InsertMutationFnParams) => Promise +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type UpdateMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: UpdateMutationFnParams) => Promise +/** + * @typeParam TReturn - @internal DEPRECATED: Defaults to void. Only kept for backward compatibility. Will be removed in v1.0. + */ export type DeleteMutationFn< T extends object = Record, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, - TReturn = any, + TReturn = void, > = (params: DeleteMutationFnParams) => Promise /** @@ -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, @@ -563,8 +577,7 @@ export interface BaseCollectionConfig< syncMode?: SyncMode /** * Optional asynchronous handler function called before an insert operation - * @param params Object containing transaction and collection information - * @returns Promise resolving to any value + * * @example * // Basic insert handler * onInsert: async ({ transaction, collection }) => { @@ -573,10 +586,30 @@ export interface BaseCollectionConfig< * } * * @example + * // Insert handler with refetch (Query Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * await api.createTodo(newItem) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Insert handler with sync wait (Electric Collection) + * onInsert: async ({ transaction, collection }) => { + * const newItem = transaction.mutations[0].modified + * const result = await api.createTodo(newItem) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) + * } + * + * @example * // Insert handler with multiple items * onInsert: async ({ transaction, collection }) => { * const items = transaction.mutations.map(m => m.modified) * await api.createTodos(items) + * // Refetch to get updated data from server + * await collection.utils.refetch() * } * * @example @@ -584,30 +617,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 + 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 resolving to any value + * * @example * // Basic update handler * onUpdate: async ({ transaction, collection }) => { @@ -616,11 +642,22 @@ export interface BaseCollectionConfig< * } * * @example - * // Update handler with partial updates + * // Update handler with refetch (Query Collection) * onUpdate: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * const changes = mutation.changes // Only the changed fields * await api.updateTodo(mutation.original.id, changes) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Update handler with sync wait (Electric Collection) + * onUpdate: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.updateTodo(mutation.original.id, mutation.changes) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) * } * * @example @@ -631,6 +668,7 @@ export interface BaseCollectionConfig< * changes: m.changes * })) * await api.updateTodos(updates) + * await collection.utils.refetch() * } * * @example @@ -646,11 +684,15 @@ 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 resolving to any value + * * @example * // Basic delete handler * onDelete: async ({ transaction, collection }) => { @@ -659,10 +701,21 @@ export interface BaseCollectionConfig< * } * * @example - * // Delete handler with multiple items + * // Delete handler with refetch (Query Collection) * onDelete: async ({ transaction, collection }) => { * const keysToDelete = transaction.mutations.map(m => m.key) * await api.deleteTodos(keysToDelete) + * // Trigger refetch to sync server state + * await collection.utils.refetch() + * } + * + * @example + * // Delete handler with sync wait (Electric Collection) + * onDelete: async ({ transaction, collection }) => { + * const mutation = transaction.mutations[0] + * const result = await api.deleteTodo(mutation.original.id) + * // Wait for txid to sync + * await collection.utils.awaitTxId(result.txid) * } * * @example @@ -689,7 +742,12 @@ 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. 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 01484838e..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, @@ -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 @@ -164,43 +164,80 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an insert operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric insert handler with txid (recommended) - * onInsert: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example * // Insert handler with custom timeout - * onInsert: async ({ transaction }) => { + * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * const result = await api.todos.create({ * data: newItem * }) - * 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 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 - return array of txids - * onInsert: async ({ transaction }) => { + * // Insert handler with multiple items + * onInsert: async ({ transaction, collection }) => { * const items = transaction.mutations.map(m => m.modified) * const results = await Promise.all( * items.map(item => api.todos.create({ data: item })) * ) - * return { txid: results.map(r => r.txid) } + * // Wait for all txids to sync + * await Promise.all( + * results.map(r => collection.utils.awaitTxId(r.txid)) + * ) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onInsert: async ({ transaction, collection }) => { * const newItem = transaction.mutations[0].modified * await api.todos.create({ data: newItem }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'insert' && @@ -218,24 +255,37 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before an update operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric update handler with txid (recommended) - * onUpdate: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * const result = await api.todos.update({ * where: { id: original.id }, * data: changes * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onUpdate: async ({ transaction, collection }) => { * const { original, changes } = transaction.mutations[0] * await api.todos.update({ where: { id: original.id }, data: changes }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'update' && @@ -253,23 +303,36 @@ export interface ElectricCollectionConfig< /** * Optional asynchronous handler function called before a delete operation + * + * **IMPORTANT - Electric Synchronization:** + * This handler returns `Promise`, but **must not resolve** until synchronization is confirmed. + * You must await one of these synchronization utilities before the handler completes: + * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases) + * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic + * + * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches. + * * @param params Object containing transaction and collection information - * @returns Promise resolving to { txid, timeout? } or void + * @returns Promise - Must not resolve until synchronization is complete + * @deprecated Returning { txid } from handlers is deprecated. Use `await collection.utils.awaitTxId(txid)` instead. + * * @example - * // Basic Electric delete handler with txid (recommended) - * onDelete: async ({ transaction }) => { + * // Recommended: Wait for txid to sync + * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * const result = await api.todos.delete({ * id: mutation.original.id * }) - * return { txid: result.txid } + * // Wait for txid to sync before handler completes + * await collection.utils.awaitTxId(result.txid) * } * * @example - * // Use awaitMatch utility for custom matching + * // Alternative: Use awaitMatch utility for custom matching logic * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.todos.delete({ id: mutation.original.id }) + * // Wait for specific change to appear in sync stream * await collection.utils.awaitMatch( * (message) => isChangeMessage(message) && * message.headers.operation === 'delete' && @@ -599,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`, @@ -666,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 ( @@ -773,6 +836,14 @@ export function electricCollectionOptions>( ): Promise => { // Only wait if result contains txid if (result && `txid` in result) { + // Warn about deprecated return value pattern + 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', + ) + 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..07d76f402 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, @@ -1250,10 +1250,32 @@ export function queryCollectionOptions( const wrappedOnInsert = onInsert ? async (params: InsertMutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - if (shouldRefetch) { + const explicitRefetchFalse = + handlerResult && + typeof handlerResult === 'object' && + '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', + ) await refetch() } @@ -1264,10 +1286,30 @@ export function queryCollectionOptions( const wrappedOnUpdate = onUpdate ? async (params: UpdateMutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - if (shouldRefetch) { + const explicitRefetchFalse = + handlerResult && + typeof handlerResult === 'object' && + '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', + ) await refetch() } @@ -1278,10 +1320,30 @@ export function queryCollectionOptions( const wrappedOnDelete = onDelete ? async (params: DeleteMutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} - const shouldRefetch = - (handlerResult as { refetch?: boolean }).refetch !== false - if (shouldRefetch) { + const explicitRefetchFalse = + handlerResult && + typeof handlerResult === 'object' && + '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', + ) await refetch() }