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. diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 829ec4d82..bf76dcd97 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,32 @@ 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 +68,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 +110,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 +243,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..fc63ab7f8 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,28 @@ 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 +146,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 +188,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 +453,69 @@ 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 +526,7 @@ export function localStorageCollectionOptions( utils: { clearStorage, getStorageSize, + acceptMutations, }, } } 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,