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/brave-coats-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': minor
---

Added writeUpsert util to localstorage collection
69 changes: 69 additions & 0 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,23 @@ export interface LocalStorageCollectionUtils extends UtilsRecord {
acceptMutations: (transaction: {
mutations: Array<PendingMutation<Record<string, unknown>>>
}) => 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: <T extends object>(data: T | Array<T>) => void
}

/**
Expand Down Expand Up @@ -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 = <T extends object>(data: T | Array<T>): 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<PendingMutation<T>> = []

// 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<T> = {
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<T>)
})

// Save to storage
saveToStorage(lastKnownData)

// Confirm mutations through sync interface
sync.confirmOperationsSync(mutations)
}

// Extract standard Collection config properties
const {
storageKey: _storageKey,
Expand Down Expand Up @@ -617,6 +685,7 @@ export function localStorageCollectionOptions(
clearStorage,
getStorageSize,
acceptMutations,
writeUpsert,
},
}
}
Expand Down
162 changes: 162 additions & 0 deletions packages/db/tests/local-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Todo>({
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<Todo>({
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<Todo>({
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<Todo>

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<Todo>({
storageKey: `todos`,
storage: mockStorage,
storageEventApi: mockStorageEventApi,
getKey: (todo) => todo.id,
}),
)

const subscription = collection.subscribeChanges(() => {})

const todos: Array<Todo> = [
{
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()
})
})
})