diff --git a/.changeset/brave-coats-return.md b/.changeset/brave-coats-return.md new file mode 100644 index 000000000..02bf1c0ea --- /dev/null +++ b/.changeset/brave-coats-return.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': minor +--- + +Added writeUpsert util to localstorage collection diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 3060b7ec6..9591af6fb 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -120,6 +120,23 @@ export interface LocalStorageCollectionUtils extends UtilsRecord { acceptMutations: (transaction: { mutations: Array>> }) => void + /** + * Upsert one or more items directly into the synced data store without triggering optimistic updates. + * If an item with the same key exists, it will be updated; otherwise, it will be inserted. + * + * @param data - The item or array of items to upsert + * @example + * // Upsert a single item + * localSettings.utils.writeUpsert({ id: 'theme', value: 'dark' }) + * + * @example + * // Upsert multiple items + * localSettings.utils.writeUpsert([ + * { id: 'theme', value: 'dark' }, + * { id: 'language', value: 'en' } + * ]) + */ + writeUpsert: (data: T | Array) => void } /** @@ -528,6 +545,57 @@ export function localStorageCollectionOptions( return handlerResult } + /** + * Upsert utility function that directly persists to localStorage + * Inserts if item doesn't exist, updates if it does + */ + const writeUpsert = (data: T | Array): void => { + const items = Array.isArray(data) ? data : [data] + + // Validate that all values can be JSON serialized + items.forEach((item) => { + validateJsonSerializable(parser, item, `upsert`) + }) + + // Prepare change messages for sync confirmation + const mutations: Array> = [] + + // Process each item: check if exists, then insert or update + items.forEach((item) => { + const key = config.getKey(item as any) + const existsInStorage = lastKnownData.has(key) + const operationType = existsInStorage ? `update` : `insert` + + // For updates, merge with existing data; for inserts, use item as-is + const finalData = existsInStorage + ? { ...lastKnownData.get(key)!.data, ...item } + : item + + const storedItem: StoredItem = { + versionKey: generateUuid(), + data: finalData as T, + } + lastKnownData.set(key, storedItem) + + // Track mutation for sync confirmation + mutations.push({ + type: operationType, + key, + modified: finalData as T, + original: existsInStorage + ? (lastKnownData.get(key)?.data as T) + : ({} as T), + collection: sync.collection, + } as PendingMutation) + }) + + // Save to storage + saveToStorage(lastKnownData) + + // Confirm mutations through sync interface + sync.confirmOperationsSync(mutations) + } + // Extract standard Collection config properties const { storageKey: _storageKey, @@ -617,6 +685,7 @@ export function localStorageCollectionOptions( clearStorage, getStorageSize, acceptMutations, + writeUpsert, }, } } diff --git a/packages/db/tests/local-storage.test.ts b/packages/db/tests/local-storage.test.ts index 878e570ea..ee5dffaf9 100644 --- a/packages/db/tests/local-storage.test.ts +++ b/packages/db/tests/local-storage.test.ts @@ -2102,4 +2102,166 @@ describe(`localStorage collection`, () => { subscription.unsubscribe() }) }) + + describe(`writeUpsert utility`, () => { + it(`should insert a single item when it doesn't exist`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }), + ) + + const subscription = collection.subscribeChanges(() => {}) + + const todo: Todo = { + id: `upsert-1`, + title: `Upsert Insert`, + completed: false, + createdAt: new Date(), + } + + collection.utils.writeUpsert(todo) + + expect(collection.has(`upsert-1`)).toBe(true) + expect(collection.get(`upsert-1`)?.title).toBe(`Upsert Insert`) + + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + const parsed = JSON.parse(storedData!) + expect(parsed[`s:upsert-1`].data.title).toBe(`Upsert Insert`) + + subscription.unsubscribe() + }) + + it(`should update a single item when it exists`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }), + ) + + const subscription = collection.subscribeChanges(() => {}) + + const initialTodo: Todo = { + id: `upsert-2`, + title: `Initial`, + completed: false, + createdAt: new Date(), + } + + collection.utils.writeUpsert(initialTodo) + + const updatedTodo: Todo = { + id: `upsert-2`, + title: `Updated`, + completed: true, + createdAt: new Date(), + } + + collection.utils.writeUpsert(updatedTodo) + + expect(collection.has(`upsert-2`)).toBe(true) + expect(collection.get(`upsert-2`)?.title).toBe(`Updated`) + expect(collection.get(`upsert-2`)?.completed).toBe(true) + + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + const parsed = JSON.parse(storedData!) + expect(parsed[`s:upsert-2`].data.title).toBe(`Updated`) + expect(parsed[`s:upsert-2`].data.completed).toBe(true) + + subscription.unsubscribe() + }) + + it(`should merge data when updating existing item`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }), + ) + + const subscription = collection.subscribeChanges(() => {}) + + const initialTodo: Todo = { + id: `upsert-3`, + title: `Initial Title`, + completed: false, + createdAt: new Date(`2024-01-01`), + } + + collection.utils.writeUpsert(initialTodo) + + const partialUpdate = { + id: `upsert-3`, + completed: true, + } as Partial + + collection.utils.writeUpsert(partialUpdate) + + const item = collection.get(`upsert-3`) + expect(item?.title).toBe(`Initial Title`) + expect(item?.completed).toBe(true) + expect(item?.createdAt).toEqual(new Date(`2024-01-01`)) + + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + const parsed = JSON.parse(storedData!) + expect(parsed[`s:upsert-3`].data.title).toBe(`Initial Title`) + expect(parsed[`s:upsert-3`].data.completed).toBe(true) + + subscription.unsubscribe() + }) + + it(`should upsert multiple items from an array`, () => { + const collection = createCollection( + localStorageCollectionOptions({ + storageKey: `todos`, + storage: mockStorage, + storageEventApi: mockStorageEventApi, + getKey: (todo) => todo.id, + }), + ) + + const subscription = collection.subscribeChanges(() => {}) + + const todos: Array = [ + { + id: `multi-1`, + title: `First`, + completed: false, + createdAt: new Date(), + }, + { + id: `multi-2`, + title: `Second`, + completed: false, + createdAt: new Date(), + }, + ] + + collection.utils.writeUpsert(todos) + + expect(collection.has(`multi-1`)).toBe(true) + expect(collection.has(`multi-2`)).toBe(true) + expect(collection.get(`multi-1`)?.title).toBe(`First`) + expect(collection.get(`multi-2`)?.title).toBe(`Second`) + + const storedData = mockStorage.getItem(`todos`) + expect(storedData).toBeDefined() + const parsed = JSON.parse(storedData!) + expect(parsed[`s:multi-1`].data.title).toBe(`First`) + expect(parsed[`s:multi-2`].data.title).toBe(`Second`) + + subscription.unsubscribe() + }) + }) })