From 61d1b84ffff97cb0acda8cb766ec4e9fab949447 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 22:53:18 +0200 Subject: [PATCH] fix: lists and list items deleted on another device are now deleted locally --- src/stores/__tests__/lists.spec.ts | 52 ++++++++++++++++++++++++++++++ src/stores/lists.ts | 31 ++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/stores/__tests__/lists.spec.ts b/src/stores/__tests__/lists.spec.ts index c7d14ed..f43dc6d 100644 --- a/src/stores/__tests__/lists.spec.ts +++ b/src/stores/__tests__/lists.spec.ts @@ -123,6 +123,9 @@ describe('useListsStore', () => { it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => { listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' }) + // The chained pullFromServer() call needs to report the just-created list + // back, otherwise it would look like the server deleted it. + listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }]) const store = useListsStore() const localList = await store.createList('Groceries', []) @@ -264,6 +267,55 @@ describe('useListsStore', () => { expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined() }) + it('deletes a previously synced list locally when it is missing from the server', async () => { + const store = useListsStore() + await fakeDb.lists.put({ id: 'server-list-1', name: 'Groceries', pendingSync: false }) + await fakeDb.listItems.put({ + id: 'server-item-1', + list_id: 'server-list-1', + title: 'Milk', + pendingSync: false, + }) + await store.refresh() + + listsApiMocks.getListsApi.mockResolvedValueOnce([]) + + await store.pullFromServer() + + expect(store.lists.find((list) => list.id === 'server-list-1')).toBeUndefined() + expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeUndefined() + }) + + it('deletes a previously synced list item locally when it is missing from the server', async () => { + const store = useListsStore() + await fakeDb.listItems.put({ + id: 'server-item-2', + list_id: 'server-list-1', + title: 'Bread', + pendingSync: false, + }) + await store.refresh() + + listsApiMocks.getListItemsApi.mockResolvedValueOnce([]) + + await store.pullListItems('server-list-1') + + expect(store.listItems.find((item) => item.id === 'server-item-2')).toBeUndefined() + }) + + it('does not delete a locally pending list even when it is missing from the server', async () => { + listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {})) + + const store = useListsStore() + const localList = await store.createList('Local only') + + listsApiMocks.getListsApi.mockResolvedValueOnce([]) + + await store.pullFromServer() + + expect(store.lists.find((list) => list.id === localList.id)).toBeDefined() + }) + it('does not overwrite a locally pending list with stale server data', async () => { listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {})) diff --git a/src/stores/lists.ts b/src/stores/lists.ts index 2f453d0..30d09a8 100644 --- a/src/stores/lists.ts +++ b/src/stores/lists.ts @@ -369,6 +369,7 @@ export const useListsStore = defineStore('lists', () => { try { const serverLists = await getListsApi() + const serverListIds = new Set(serverLists.map((serverList) => serverList.id)) for (const serverList of serverLists) { const existingList = await db.lists.get(serverList.id) @@ -376,6 +377,23 @@ export const useListsStore = defineStore('lists', () => { await db.lists.put({ ...serverList, pendingSync: false }) } } + + // Lists that were already synced but are no longer reported by the + // server have been deleted there (e.g. from another device), so + // remove them locally too, along with their items. Lists that still + // have pending local changes (not yet synced, e.g. a not-yet-pushed + // "createList") are left alone since the server doesn't know about + // them yet. + const localLists = await db.lists.toArray() + for (const localList of localLists) { + if (!localList.pendingSync && !serverListIds.has(localList.id)) { + await db.lists.delete(localList.id) + const orphanedItems = await db.listItems.where('list_id').equals(localList.id).toArray() + for (const item of orphanedItems) { + await db.listItems.delete(item.id) + } + } + } } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load lists from server' } finally { @@ -391,12 +409,25 @@ export const useListsStore = defineStore('lists', () => { try { const serverItems = await getListItemsApi(listId) + const serverItemIds = new Set(serverItems.map((serverItem) => serverItem.id)) + for (const serverItem of serverItems) { const existingItem = await db.listItems.get(serverItem.id) if (!existingItem || !existingItem.pendingSync) { await db.listItems.put({ ...serverItem, pendingSync: false }) } } + + // Items that were already synced but are no longer reported by the + // server for this list have been deleted there, so remove them + // locally too. Items with pending local changes are left alone since + // the server doesn't know about them yet. + const localItems = await db.listItems.where('list_id').equals(listId).toArray() + for (const localItem of localItems) { + if (!localItem.pendingSync && !serverItemIds.has(localItem.id)) { + await db.listItems.delete(localItem.id) + } + } } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load list items from server' } finally {