From d2f5a91d5da4d975484a3ad71c9c863b8459bab2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 3 Oct 2025 17:00:29 -0600 Subject: [PATCH 1/5] Add acceptMutations utility for local collections in manual transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #446 Local-only and local-storage collections now expose `utils.acceptMutations(transaction, collection)` that must be called in manual transaction `mutationFn` to persist mutations. This provides explicit control over when local mutations are persisted, following the pattern established by query-db-collection. Changes: - Add acceptMutations to LocalOnlyCollectionUtils interface - Add acceptMutations to LocalStorageCollectionUtils interface - Include JSON serialization validation in local-storage acceptMutations - Update documentation with manual transaction usage examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/src/local-only.ts | 83 ++++++++++++++++++++-- packages/db/src/local-storage.ts | 116 ++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 5 deletions(-) diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 829ec4d82..2f641abee 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -5,6 +5,7 @@ import type { InferSchemaOutput, InsertMutationFnParams, OperationType, + PendingMutation, SyncConfig, UpdateMutationFnParams, UtilsRecord, @@ -33,9 +34,29 @@ export interface LocalOnlyCollectionConfig< } /** - * Local-only collection utilities type (currently empty but matches the pattern) + * Local-only collection utilities type */ -export interface LocalOnlyCollectionUtils extends UtilsRecord {} +export interface LocalOnlyCollectionUtils extends UtilsRecord { + /** + * Accepts mutations from a transaction that belong to this collection and persists them. + * This should be called in your transaction's mutationFn to persist local-only data. + * + * @param transaction - The transaction containing mutations to accept + * @param collection - The collection instance (pass `this` from within collection context or the collection variable) + * @example + * const localData = createCollection(localOnlyCollectionOptions({...})) + * + * const tx = createTransaction({ + * mutationFn: async ({ transaction }) => { + * // Persist local-only mutations + * localData.utils.acceptMutations(transaction, localData) + * // Then make API call + * await api.save(...) + * } + * }) + */ + acceptMutations: (transaction: { mutations: Array> }, collection: unknown) => void +} /** * Creates Local-only collection options for use with a standard Collection @@ -44,10 +65,16 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {} * that immediately "syncs" all optimistic changes to the collection, making them permanent. * Perfect for local-only data that doesn't need persistence or external synchronization. * + * **Using with Manual Transactions:** + * + * For manual transactions, you must call `utils.acceptMutations()` in your transaction's `mutationFn` + * to persist changes made during `tx.mutate()`. This is necessary because local-only collections + * don't participate in the standard mutation handler flow for manual transactions. + * * @template T - The schema type if a schema is provided, otherwise the type of items in the collection * @template TKey - The type of the key returned by getKey * @param config - Configuration options for the Local-only collection - * @returns Collection options with utilities (currently empty but follows the pattern) + * @returns Collection options with utilities including acceptMutations * * @example * // Basic local-only collection @@ -80,6 +107,32 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {} * }, * }) * ) + * + * @example + * // Using with manual transactions + * const localData = createCollection( + * localOnlyCollectionOptions({ + * getKey: (item) => item.id, + * }) + * ) + * + * const tx = createTransaction({ + * mutationFn: async ({ transaction }) => { + * // Persist local-only mutations + * localData.utils.acceptMutations(transaction, localData) + * + * // Use local data in API call + * const localMutations = transaction.mutations.filter(m => m.collection === localData) + * await api.save({ metadata: localMutations[0]?.modified }) + * } + * }) + * + * tx.mutate(() => { + * localData.insert({ id: 1, data: 'metadata' }) + * apiCollection.insert({ id: 2, data: 'main data' }) + * }) + * + * await tx.commit() */ // Overload for when schema is provided @@ -187,13 +240,35 @@ export function localOnlyCollectionOptions( return handlerResult } + /** + * Accepts mutations from a transaction that belong to this collection and persists them + */ + const acceptMutations = ( + transaction: { mutations: Array> }, + collection: unknown + ) => { + // Filter mutations that belong to this collection + const collectionMutations = transaction.mutations.filter( + (m) => m.collection === collection + ) + + if (collectionMutations.length === 0) { + return + } + + // Persist the mutations through sync + syncResult.confirmOperationsSync(collectionMutations) + } + return { ...restConfig, sync: syncResult.sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, - utils: {} as LocalOnlyCollectionUtils, + utils: { + acceptMutations, + } as LocalOnlyCollectionUtils, startSync: true, gcTime: 0, } diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 7b3da7724..a50081f51 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -12,6 +12,7 @@ import type { DeleteMutationFnParams, InferSchemaOutput, InsertMutationFnParams, + PendingMutation, SyncConfig, UpdateMutationFnParams, UtilsRecord, @@ -90,6 +91,25 @@ export type GetStorageSizeFn = () => number export interface LocalStorageCollectionUtils extends UtilsRecord { clearStorage: ClearStorageFn getStorageSize: GetStorageSizeFn + /** + * Accepts mutations from a transaction that belong to this collection and persists them to localStorage. + * This should be called in your transaction's mutationFn to persist local-storage data. + * + * @param transaction - The transaction containing mutations to accept + * @param collection - The collection instance (pass the collection variable) + * @example + * const localSettings = createCollection(localStorageCollectionOptions({...})) + * + * const tx = createTransaction({ + * mutationFn: async ({ transaction }) => { + * // Persist local-storage mutations + * localSettings.utils.acceptMutations(transaction, localSettings) + * // Then make API call + * await api.save(...) + * } + * }) + */ + acceptMutations: (transaction: { mutations: Array> }, collection: unknown) => void } /** @@ -123,11 +143,17 @@ function generateUuid(): string { * This function creates a collection that persists data to localStorage/sessionStorage * and synchronizes changes across browser tabs using storage events. * + * **Using with Manual Transactions:** + * + * For manual transactions, you must call `utils.acceptMutations()` in your transaction's `mutationFn` + * to persist changes made during `tx.mutate()`. This is necessary because local-storage collections + * don't participate in the standard mutation handler flow for manual transactions. + * * @template TExplicit - The explicit type of items in the collection (highest priority) * @template TSchema - The schema type for validation and type inference (second priority) * @template TFallback - The fallback type if no explicit or schema type is provided * @param config - Configuration options for the localStorage collection - * @returns Collection options with utilities including clearStorage and getStorageSize + * @returns Collection options with utilities including clearStorage, getStorageSize, and acceptMutations * * @example * // Basic localStorage collection @@ -159,6 +185,33 @@ function generateUuid(): string { * }, * }) * ) + * + * @example + * // Using with manual transactions + * const localSettings = createCollection( + * localStorageCollectionOptions({ + * storageKey: 'user-settings', + * getKey: (item) => item.id, + * }) + * ) + * + * const tx = createTransaction({ + * mutationFn: async ({ transaction }) => { + * // Persist local-storage mutations + * localSettings.utils.acceptMutations(transaction, localSettings) + * + * // Use settings data in API call + * const settingsMutations = transaction.mutations.filter(m => m.collection === localSettings) + * await api.updateUserProfile({ settings: settingsMutations[0]?.modified }) + * } + * }) + * + * tx.mutate(() => { + * localSettings.insert({ id: 'theme', value: 'dark' }) + * apiCollection.insert({ id: 2, data: 'profile data' }) + * }) + * + * await tx.commit() */ // Overload for when schema is provided @@ -397,6 +450,66 @@ export function localStorageCollectionOptions( // Default id to a pattern based on storage key if not provided const collectionId = id ?? `local-collection:${config.storageKey}` + /** + * Accepts mutations from a transaction that belong to this collection and persists them to storage + */ + const acceptMutations = ( + transaction: { mutations: Array> }, + collection: unknown + ) => { + // Filter mutations that belong to this collection + const collectionMutations = transaction.mutations.filter( + (m) => m.collection === collection + ) + + if (collectionMutations.length === 0) { + return + } + + // Validate all mutations can be serialized before modifying storage + for (const mutation of collectionMutations) { + switch (mutation.type) { + case `insert`: + case `update`: + validateJsonSerializable(mutation.modified, mutation.type) + break + case `delete`: + validateJsonSerializable(mutation.original, mutation.type) + break + } + } + + // Load current data from storage + const currentData = loadFromStorage(config.storageKey, storage) + + // Apply each mutation + for (const mutation of collectionMutations) { + const key = config.getKey(mutation.modified) + + switch (mutation.type) { + case `insert`: + case `update`: { + const storedItem: StoredItem = { + versionKey: generateUuid(), + data: mutation.modified, + } + currentData.set(key, storedItem) + break + } + case `delete`: { + currentData.delete(key) + break + } + } + } + + // Save to storage + saveToStorage(currentData) + + // Manually trigger local sync since storage events don't fire for current tab + triggerLocalSync() + } + return { ...restConfig, id: collectionId, @@ -407,6 +520,7 @@ export function localStorageCollectionOptions( utils: { clearStorage, getStorageSize, + acceptMutations, }, } } From 2cf74350a94f51150ef8577443fef8ef4e07bbcc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 3 Oct 2025 17:07:04 -0600 Subject: [PATCH 2/5] format --- packages/db/src/local-only.ts | 5 ++++- packages/db/src/local-storage.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 2f641abee..c94fee741 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -55,7 +55,10 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord { * } * }) */ - acceptMutations: (transaction: { mutations: Array> }, collection: unknown) => void + acceptMutations: ( + transaction: { mutations: Array> }, + collection: unknown + ) => void } /** diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index a50081f51..532558ff2 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -109,7 +109,10 @@ export interface LocalStorageCollectionUtils extends UtilsRecord { * } * }) */ - acceptMutations: (transaction: { mutations: Array> }, collection: unknown) => void + acceptMutations: ( + transaction: { mutations: Array> }, + collection: unknown + ) => void } /** From 1151288d108385527d7530675ce3efe4beaf7d37 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 3 Oct 2025 17:08:31 -0600 Subject: [PATCH 3/5] Update changeset for acceptMutations feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/smooth-windows-jump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smooth-windows-jump.md diff --git a/.changeset/smooth-windows-jump.md b/.changeset/smooth-windows-jump.md new file mode 100644 index 000000000..f44ec2b6f --- /dev/null +++ b/.changeset/smooth-windows-jump.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Add acceptMutations utility for local collections in manual transactions. Local-only and local-storage collections now expose `utils.acceptMutations(transaction, collection)` that must be called in manual transaction `mutationFn` to persist mutations. From 494e3741ed95266a7bcc32c347155fac0d404b93 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 6 Oct 2025 15:51:37 -0600 Subject: [PATCH 4/5] Fix type errors in acceptMutations utility functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `unknown` with `Record` in PendingMutation type parameters and related generics to satisfy the `T extends object` constraint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/src/local-only.ts | 4 ++-- packages/db/src/local-storage.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index c94fee741..bf76dcd97 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -56,7 +56,7 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord { * }) */ acceptMutations: ( - transaction: { mutations: Array> }, + transaction: { mutations: Array>> }, collection: unknown ) => void } @@ -247,7 +247,7 @@ export function localOnlyCollectionOptions( * Accepts mutations from a transaction that belong to this collection and persists them */ const acceptMutations = ( - transaction: { mutations: Array> }, + transaction: { mutations: Array>> }, collection: unknown ) => { // Filter mutations that belong to this collection diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 532558ff2..fc63ab7f8 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -110,7 +110,7 @@ export interface LocalStorageCollectionUtils extends UtilsRecord { * }) */ acceptMutations: ( - transaction: { mutations: Array> }, + transaction: { mutations: Array>> }, collection: unknown ) => void } @@ -457,7 +457,7 @@ export function localStorageCollectionOptions( * Accepts mutations from a transaction that belong to this collection and persists them to storage */ const acceptMutations = ( - transaction: { mutations: Array> }, + transaction: { mutations: Array>> }, collection: unknown ) => { // Filter mutations that belong to this collection @@ -483,7 +483,10 @@ export function localStorageCollectionOptions( } // Load current data from storage - const currentData = loadFromStorage(config.storageKey, storage) + const currentData = loadFromStorage>( + config.storageKey, + storage + ) // Apply each mutation for (const mutation of collectionMutations) { @@ -492,7 +495,7 @@ export function localStorageCollectionOptions( switch (mutation.type) { case `insert`: case `update`: { - const storedItem: StoredItem = { + const storedItem: StoredItem> = { versionKey: generateUuid(), data: mutation.modified, } From dbfe4c86f84c46a3318c28c980667206d85cb8d8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 6 Oct 2025 16:46:20 -0600 Subject: [PATCH 5/5] Fix type annotation in local-only test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LocalOnlyCollectionUtils type parameter to createCollection call to satisfy type constraints after merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/tests/local-only.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/tests/local-only.test.ts b/packages/db/tests/local-only.test.ts index 74906f4f5..9acf9653c 100644 --- a/packages/db/tests/local-only.test.ts +++ b/packages/db/tests/local-only.test.ts @@ -17,7 +17,7 @@ describe(`LocalOnly Collection`, () => { beforeEach(() => { // Create collection with LocalOnly configuration - collection = createCollection( + collection = createCollection( localOnlyCollectionOptions({ id: `test-local-only`, getKey: (item: TestItem) => item.id,