Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-windows-jump.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 82 additions & 4 deletions packages/db/src/local-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
InferSchemaOutput,
InsertMutationFnParams,
OperationType,
PendingMutation,
SyncConfig,
UpdateMutationFnParams,
UtilsRecord,
Expand Down Expand Up @@ -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<PendingMutation<Record<string, unknown>>> },
collection: unknown
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you have to pass the collection in here, that seems unnecessary.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I thought I'd gotten rid of all of those. Will remove

) => void
}

/**
* Creates Local-only collection options for use with a standard Collection
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<PendingMutation<Record<string, unknown>>> },
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,
}
Expand Down
122 changes: 121 additions & 1 deletion packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
DeleteMutationFnParams,
InferSchemaOutput,
InsertMutationFnParams,
PendingMutation,
SyncConfig,
UpdateMutationFnParams,
UtilsRecord,
Expand Down Expand Up @@ -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<PendingMutation<Record<string, unknown>>> },
collection: unknown
) => void
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<PendingMutation<Record<string, unknown>>> },
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<Record<string, unknown>>(
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<Record<string, unknown>> = {
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,
Expand All @@ -407,6 +526,7 @@ export function localStorageCollectionOptions(
utils: {
clearStorage,
getStorageSize,
acceptMutations,
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/db/tests/local-only.test.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There really should be tests for this new functionality

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe(`LocalOnly Collection`, () => {

beforeEach(() => {
// Create collection with LocalOnly configuration
collection = createCollection<TestItem, number>(
collection = createCollection<TestItem, number, LocalOnlyCollectionUtils>(
localOnlyCollectionOptions({
id: `test-local-only`,
getKey: (item: TestItem) => item.id,
Expand Down
Loading