feat: initial sketch with AI
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from '../auth'
|
||||
import * as authApi from '@/api/auth'
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('initializes with tokens from localStorage if available', () => {
|
||||
localStorage.setItem('access_token', 'initial-access-token')
|
||||
localStorage.setItem('refresh_token', 'initial-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
expect(authStore.accessToken).toBe('initial-access-token')
|
||||
expect(authStore.refreshToken).toBe('initial-refresh-token')
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes with null tokens if localStorage is empty', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('successfully logs in and stores tokens in state and localStorage', async () => {
|
||||
const mockTokenPair = {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
}
|
||||
|
||||
vi.spyOn(authApi, 'loginApi').mockResolvedValueOnce(mockTokenPair)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const result = await authStore.login({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockTokenPair)
|
||||
expect(authStore.accessToken).toBe('new-access-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('access_token')).toBe('new-access-token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token')
|
||||
expect(authStore.error).toBeNull()
|
||||
})
|
||||
|
||||
it('handles login failure and sets error message', async () => {
|
||||
vi.spyOn(authApi, 'loginApi').mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await expect(
|
||||
authStore.login({
|
||||
email: 'test@example.com',
|
||||
password: 'wrong-password',
|
||||
}),
|
||||
).rejects.toThrow('Invalid credentials')
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(authStore.error).toBe('Invalid credentials')
|
||||
})
|
||||
|
||||
it('refreshes tokens successfully and updates state and localStorage', async () => {
|
||||
localStorage.setItem('access_token', 'old-access-token')
|
||||
localStorage.setItem('refresh_token', 'old-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const refreshedTokenPair = {
|
||||
access_token: 'refreshed-access-token',
|
||||
refresh_token: 'refreshed-refresh-token',
|
||||
}
|
||||
|
||||
const refreshSpy = vi.spyOn(authApi, 'refreshApi').mockResolvedValueOnce(refreshedTokenPair)
|
||||
|
||||
const result = await authStore.refreshTokens()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith({ refresh_token: 'old-refresh-token' })
|
||||
expect(result).toEqual(refreshedTokenPair)
|
||||
expect(authStore.accessToken).toBe('refreshed-access-token')
|
||||
expect(authStore.refreshToken).toBe('refreshed-refresh-token')
|
||||
expect(localStorage.getItem('access_token')).toBe('refreshed-access-token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('refreshed-refresh-token')
|
||||
})
|
||||
|
||||
it('clears tokens and throws if refreshTokens is called without a refresh token', async () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await expect(authStore.refreshTokens()).rejects.toThrow('No refresh token available')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and sets error when refresh API call fails', async () => {
|
||||
localStorage.setItem('access_token', 'old-access-token')
|
||||
localStorage.setItem('refresh_token', 'expired-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
vi.spyOn(authApi, 'refreshApi').mockRejectedValueOnce(new Error('Refresh token expired'))
|
||||
|
||||
await expect(authStore.refreshTokens()).rejects.toThrow('Refresh token expired')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.error).toBe('Refresh token expired')
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and calls logout API on logout', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
|
||||
const logoutSpy = vi.spyOn(authApi, 'logoutApi').mockResolvedValueOnce()
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledWith({ refresh_token: 'sample-refresh' })
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('still clears tokens if logout API fails', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
vi.spyOn(authApi, 'logoutApi').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and calls logoutAll API on logoutAll', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const logoutAllSpy = vi.spyOn(authApi, 'logoutAllApi').mockResolvedValueOnce()
|
||||
|
||||
await authStore.logoutAll()
|
||||
|
||||
expect(logoutAllSpy).toHaveBeenCalledWith('sample-access')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('still clears tokens if logoutAll API fails', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
vi.spyOn(authApi, 'logoutAllApi').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await authStore.logoutAll()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
type Record = { id?: unknown; [key: string]: unknown }
|
||||
|
||||
function createFakeTable(autoIncrement = false) {
|
||||
const store = new Map<unknown, Record>()
|
||||
let nextId = 1
|
||||
|
||||
const table = {
|
||||
async toArray() {
|
||||
return Array.from(store.values()).map((v) => ({ ...v }))
|
||||
},
|
||||
async add(record: Record) {
|
||||
const id = autoIncrement ? nextId++ : record.id
|
||||
const toStore = autoIncrement ? { ...record, id } : record
|
||||
store.set(id, { ...toStore })
|
||||
return id
|
||||
},
|
||||
async get(id: unknown) {
|
||||
const found = store.get(id)
|
||||
return found ? { ...found } : undefined
|
||||
},
|
||||
async put(record: Record) {
|
||||
store.set(record.id, { ...record })
|
||||
return record.id
|
||||
},
|
||||
async delete(id: unknown) {
|
||||
store.delete(id)
|
||||
},
|
||||
async update(id: unknown, changes: Record) {
|
||||
const existing = store.get(id)
|
||||
if (!existing) return 0
|
||||
store.set(id, { ...existing, ...changes })
|
||||
return 1
|
||||
},
|
||||
async count() {
|
||||
return store.size
|
||||
},
|
||||
where(field: string) {
|
||||
return {
|
||||
equals(value: unknown) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.filter((v) => v[field] === value)
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
orderBy(field: string) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.sort((a, b) => Number(a[field]) - Number(b[field]))
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
filter(predicate: (record: Record) => boolean) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.filter(predicate)
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
const fakeDb = {
|
||||
lists: createFakeTable(),
|
||||
listItems: createFakeTable(),
|
||||
syncQueue: createFakeTable(true),
|
||||
}
|
||||
|
||||
vi.mock('@/database/db', () => ({
|
||||
db: fakeDb,
|
||||
}))
|
||||
|
||||
const listsApiMocks = vi.hoisted(() => ({
|
||||
getListsApi: vi.fn(),
|
||||
createListApi: vi.fn(),
|
||||
getListItemsApi: vi.fn(),
|
||||
createListItemApi: vi.fn(),
|
||||
updateListItemApi: vi.fn(),
|
||||
setListItemCompletedApi: vi.fn(),
|
||||
addUserToListApi: vi.fn(),
|
||||
removeUserFromListApi: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/lists', () => listsApiMocks)
|
||||
|
||||
const { useListsStore } = await import('../lists')
|
||||
|
||||
describe('useListsStore', () => {
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
|
||||
|
||||
for (const table of Object.values(fakeDb)) {
|
||||
const all = await table.toArray()
|
||||
for (const record of all) {
|
||||
await table.delete(record.id)
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
|
||||
|
||||
// Default the read endpoints to an empty result so that the pullFromServer()
|
||||
// step chained onto every sync() call doesn't interfere with unrelated tests.
|
||||
listsApiMocks.getListsApi.mockResolvedValue([])
|
||||
listsApiMocks.getListItemsApi.mockResolvedValue([])
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('Groceries', [])
|
||||
|
||||
// wait for the fire-and-forget sync triggered by createList to settle
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
|
||||
name: 'Groceries',
|
||||
user_ids: [],
|
||||
})
|
||||
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
|
||||
const synced = store.lists.find((list) => list.id === 'server-id-1')
|
||||
expect(synced).toBeDefined()
|
||||
expect(synced?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates a list item locally and remaps it to the server-assigned id once synced', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-1',
|
||||
list_id: '',
|
||||
title: 'Milk',
|
||||
is_completed: false,
|
||||
})
|
||||
|
||||
const store = useListsStore()
|
||||
const item = await store.createListItem('list-1', 'Milk')
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListItemApi).toHaveBeenCalledWith({
|
||||
list_id: 'list-1',
|
||||
title: 'Milk',
|
||||
})
|
||||
expect(store.listItems.find((entry) => entry.id === item.id)).toBeUndefined()
|
||||
const synced = store.listItems.find((entry) => entry.id === 'server-item-1')
|
||||
expect(synced).toBeDefined()
|
||||
expect(synced?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the entry in the sync queue and records the error when the API call fails', async () => {
|
||||
listsApiMocks.createListItemApi.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Bread')
|
||||
await store.sync()
|
||||
|
||||
expect(store.pendingCount).toBe(1)
|
||||
expect(store.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('updates a list item locally and pushes the change to the server', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-2',
|
||||
list_id: '',
|
||||
title: 'Eggs',
|
||||
is_completed: false,
|
||||
})
|
||||
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Eggs')
|
||||
await store.sync()
|
||||
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
|
||||
|
||||
await store.updateListItem(created.id, { is_completed: true })
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({
|
||||
list_item_id: created.id,
|
||||
is_completed: true,
|
||||
})
|
||||
const updated = store.listItems.find((entry) => entry.id === created.id)
|
||||
expect(updated?.is_completed).toBe(true)
|
||||
expect(updated?.pendingSync).toBe(false)
|
||||
})
|
||||
|
||||
it('does not attempt to sync while offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createList('Offline list')
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListApi).not.toHaveBeenCalled()
|
||||
expect(store.pendingCount).toBe(1)
|
||||
})
|
||||
|
||||
it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-3',
|
||||
list_id: '',
|
||||
title: 'Eggs',
|
||||
is_completed: false,
|
||||
})
|
||||
listsApiMocks.setListItemCompletedApi.mockResolvedValueOnce(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Eggs')
|
||||
await store.sync()
|
||||
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
|
||||
|
||||
await store.setListItemCompleted(created.id, true)
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.setListItemCompletedApi).toHaveBeenCalledWith(created.id, {
|
||||
is_completed: true,
|
||||
})
|
||||
const updated = store.listItems.find((entry) => entry.id === created.id)
|
||||
expect(updated?.is_completed).toBe(true)
|
||||
expect(updated?.pendingSync).toBe(false)
|
||||
})
|
||||
|
||||
it('pulls lists and items from the server and merges them locally', async () => {
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-list-1', name: 'Groceries' }])
|
||||
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
|
||||
{ id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
await store.pullFromServer()
|
||||
|
||||
expect(store.lists.find((list) => list.id === 'server-list-1')).toBeDefined()
|
||||
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not overwrite a locally pending list with stale server data', async () => {
|
||||
listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('Local only')
|
||||
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: localList.id, name: 'Server version' },
|
||||
])
|
||||
|
||||
await store.pullFromServer()
|
||||
|
||||
const stillLocal = store.lists.find((list) => list.id === localList.id)
|
||||
expect(stillLocal?.name).toBe('Local only')
|
||||
expect(stillLocal?.pendingSync).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { loginApi, refreshApi, logoutApi, logoutAllApi } from '@/api/auth'
|
||||
import type { LoginPayload, TokenPair } from '@/types/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessToken = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
|
||||
function setTokens(tokens: TokenPair) {
|
||||
accessToken.value = tokens.access_token
|
||||
refreshToken.value = tokens.refresh_token
|
||||
localStorage.setItem('access_token', tokens.access_token)
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
async function login(payload: LoginPayload): Promise<TokenPair> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const tokens = await loginApi(payload)
|
||||
setTokens(tokens)
|
||||
return tokens
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<TokenPair> {
|
||||
if (!refreshToken.value) {
|
||||
clearTokens()
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await refreshApi({ refresh_token: refreshToken.value })
|
||||
setTokens(tokens)
|
||||
return tokens
|
||||
} catch (err) {
|
||||
clearTokens()
|
||||
error.value = err instanceof Error ? err.message : 'Token refresh failed'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const currentRefreshToken = refreshToken.value
|
||||
clearTokens()
|
||||
error.value = null
|
||||
if (currentRefreshToken) {
|
||||
try {
|
||||
await logoutApi({ refresh_token: currentRefreshToken })
|
||||
} catch {
|
||||
// Backend logout failure should not prevent local token clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAll() {
|
||||
const currentAccessToken = accessToken.value
|
||||
clearTokens()
|
||||
error.value = null
|
||||
if (currentAccessToken) {
|
||||
try {
|
||||
await logoutAllApi(currentAccessToken)
|
||||
} catch {
|
||||
// Backend logout failure should not prevent local token clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
login,
|
||||
refreshTokens,
|
||||
logout,
|
||||
logoutAll,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { db, type LocalList, type LocalListItem, type SyncQueueEntry } from '@/database/db'
|
||||
import {
|
||||
getListsApi,
|
||||
createListApi,
|
||||
getListItemsApi,
|
||||
createListItemApi,
|
||||
updateListItemApi,
|
||||
setListItemCompletedApi,
|
||||
addUserToListApi,
|
||||
removeUserFromListApi,
|
||||
} from '@/api/lists'
|
||||
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
export const useListsStore = defineStore('lists', () => {
|
||||
const lists = ref<LocalList[]>([])
|
||||
const listItems = ref<LocalListItem[]>([])
|
||||
const isLoaded = ref(false)
|
||||
const isSyncing = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const sortedLists = computed(() =>
|
||||
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
|
||||
)
|
||||
|
||||
function itemsForList(listId: string) {
|
||||
return listItems.value
|
||||
.filter((item) => item.list_id === listId)
|
||||
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
lists.value = await db.lists.toArray()
|
||||
listItems.value = await db.listItems.toArray()
|
||||
pendingCount.value = await db.syncQueue.count()
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
if (!isLoaded.value) {
|
||||
await refresh()
|
||||
}
|
||||
// The server is the source of truth: even though we already have a
|
||||
// local snapshot to render instantly (including while offline), always
|
||||
// kick off a background sync/pull so views reflect the latest server
|
||||
// state on every visit, not just on the first load of the session.
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) {
|
||||
await db.syncQueue.add({
|
||||
...entry,
|
||||
createdAt: Date.now(),
|
||||
attempts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async function createList(name: string, userIds: string[] = []): Promise<LocalList> {
|
||||
const now = new Date().toISOString()
|
||||
const localList: LocalList = {
|
||||
id: generateId(),
|
||||
name,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
pendingSync: true,
|
||||
}
|
||||
|
||||
await db.lists.add(localList)
|
||||
await enqueue({
|
||||
type: 'createList',
|
||||
payload: { name, user_ids: userIds },
|
||||
localListId: localList.id,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
|
||||
return localList
|
||||
}
|
||||
|
||||
async function createListItem(listId: string, title: string): Promise<LocalListItem> {
|
||||
const now = new Date().toISOString()
|
||||
const localItem: LocalListItem = {
|
||||
id: generateId(),
|
||||
list_id: listId,
|
||||
title,
|
||||
is_completed: false,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
pendingSync: true,
|
||||
}
|
||||
|
||||
await db.listItems.add(localItem)
|
||||
await enqueue({
|
||||
type: 'createListItem',
|
||||
payload: { list_id: listId, title },
|
||||
localListItemId: localItem.id,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
|
||||
return localItem
|
||||
}
|
||||
|
||||
async function updateListItem(
|
||||
itemId: string,
|
||||
changes: { title?: string; is_completed?: boolean },
|
||||
) {
|
||||
await db.listItems.update(itemId, {
|
||||
...changes,
|
||||
modified_at: new Date().toISOString(),
|
||||
pendingSync: true,
|
||||
})
|
||||
await enqueue({
|
||||
type: 'updateListItem',
|
||||
payload: { list_item_id: itemId, ...changes },
|
||||
localListItemId: itemId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function setListItemCompleted(itemId: string, isCompleted: boolean) {
|
||||
await db.listItems.update(itemId, {
|
||||
is_completed: isCompleted,
|
||||
modified_at: new Date().toISOString(),
|
||||
pendingSync: true,
|
||||
})
|
||||
await enqueue({
|
||||
type: 'setListItemCompleted',
|
||||
payload: { is_completed: isCompleted },
|
||||
localListItemId: itemId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function addUserToList(listId: string, userId: string) {
|
||||
await enqueue({
|
||||
type: 'addUserToList',
|
||||
payload: { list_id: listId, user_id: userId },
|
||||
localListId: listId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function removeUserFromList(listId: string, userId: string) {
|
||||
await enqueue({
|
||||
type: 'removeUserFromList',
|
||||
payload: { list_id: listId, user_id: userId },
|
||||
localListId: listId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list id to the id assigned by the
|
||||
// server once the "createList" sync operation succeeds. This keeps any
|
||||
// items or queued operations referencing the temporary id consistent.
|
||||
async function remapListId(oldId: string, newId: string) {
|
||||
if (oldId === newId) return
|
||||
|
||||
const existing = await db.lists.get(oldId)
|
||||
if (existing) {
|
||||
await db.lists.delete(oldId)
|
||||
await db.lists.put({ ...existing, id: newId, pendingSync: false })
|
||||
}
|
||||
|
||||
const affectedItems = await db.listItems.where('list_id').equals(oldId).toArray()
|
||||
for (const item of affectedItems) {
|
||||
await db.listItems.update(item.id, { list_id: newId })
|
||||
}
|
||||
|
||||
const affectedQueueEntries = await db.syncQueue
|
||||
.filter((entry) => entry.localListId === oldId)
|
||||
.toArray()
|
||||
for (const entry of affectedQueueEntries) {
|
||||
const payload = entry.payload as { list_id?: string }
|
||||
await db.syncQueue.update(entry.id!, {
|
||||
localListId: newId,
|
||||
payload: payload?.list_id ? { ...payload, list_id: newId } : entry.payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list item id to the id assigned by
|
||||
// the server, keeping any queued operations referencing the temporary id
|
||||
// consistent. This is what lets the server remain the source of truth for
|
||||
// item ids instead of the client-generated placeholder living on forever.
|
||||
async function remapListItemId(oldId: string, newId: string) {
|
||||
if (oldId === newId) return
|
||||
|
||||
const existing = await db.listItems.get(oldId)
|
||||
if (existing) {
|
||||
await db.listItems.delete(oldId)
|
||||
await db.listItems.put({ ...existing, id: newId, pendingSync: false })
|
||||
}
|
||||
|
||||
const affectedQueueEntries = await db.syncQueue
|
||||
.filter((queueEntry) => queueEntry.localListItemId === oldId)
|
||||
.toArray()
|
||||
for (const queueEntry of affectedQueueEntries) {
|
||||
const payload = queueEntry.payload as { list_item_id?: string }
|
||||
await db.syncQueue.update(queueEntry.id!, {
|
||||
localListItemId: newId,
|
||||
payload: payload?.list_item_id
|
||||
? { ...payload, list_item_id: newId }
|
||||
: queueEntry.payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function processSyncEntry(entry: SyncQueueEntry) {
|
||||
switch (entry.type) {
|
||||
case 'createList': {
|
||||
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] })
|
||||
if (entry.localListId) {
|
||||
await remapListId(entry.localListId, created.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'createListItem': {
|
||||
// The server is the source of truth for item ids: it returns the
|
||||
// created item (with its own real id) in the response body, so we
|
||||
// remap our client-generated placeholder id to it instead of keeping
|
||||
// the made-up one around.
|
||||
const created = await createListItemApi(entry.payload as { list_id: string; title: string })
|
||||
if (entry.localListItemId) {
|
||||
await remapListItemId(entry.localListItemId, created.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'updateListItem': {
|
||||
await updateListItemApi(
|
||||
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean },
|
||||
)
|
||||
if (entry.localListItemId) {
|
||||
await db.listItems.update(entry.localListItemId, { pendingSync: false })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'setListItemCompleted': {
|
||||
if (!entry.localListItemId) break
|
||||
await setListItemCompletedApi(
|
||||
entry.localListItemId,
|
||||
entry.payload as { is_completed: boolean },
|
||||
)
|
||||
await db.listItems.update(entry.localListItemId, { pendingSync: false })
|
||||
break
|
||||
}
|
||||
case 'addUserToList': {
|
||||
await addUserToListApi(entry.payload as { list_id: string; user_id: string })
|
||||
break
|
||||
}
|
||||
case 'removeUserFromList': {
|
||||
await removeUserFromListApi(entry.payload as { list_id: string; user_id: string })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ongoingSync: Promise<void> | null = null
|
||||
|
||||
async function runSync() {
|
||||
isSyncing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('createdAt').toArray()
|
||||
|
||||
for (const entry of queue) {
|
||||
try {
|
||||
await processSyncEntry(entry)
|
||||
if (entry.id !== undefined) {
|
||||
await db.syncQueue.delete(entry.id)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed'
|
||||
if (entry.id !== undefined) {
|
||||
await db.syncQueue.update(entry.id, {
|
||||
attempts: entry.attempts + 1,
|
||||
lastError: message,
|
||||
})
|
||||
}
|
||||
error.value = message
|
||||
// Stop processing further entries to preserve ordering; the next
|
||||
// sync attempt (e.g. triggered by the "online" event) will retry.
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures overlapping calls to sync() (e.g. one triggered automatically by
|
||||
// a mutation while another is triggered by the "online" event) share the
|
||||
// same in-flight run instead of silently no-oping.
|
||||
async function sync(): Promise<void> {
|
||||
if (ongoingSync) {
|
||||
return ongoingSync
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return
|
||||
|
||||
ongoingSync = runSync().then(() => pullFromServer())
|
||||
try {
|
||||
await ongoingSync
|
||||
} finally {
|
||||
ongoingSync = null
|
||||
}
|
||||
}
|
||||
|
||||
// Pulls the authoritative lists/items from the server and merges them into
|
||||
// local storage. Entries that still have local unsynced changes
|
||||
// (pendingSync) are left untouched so we never clobber pending edits.
|
||||
async function pullFromServer(): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return
|
||||
|
||||
try {
|
||||
const serverLists = await getListsApi()
|
||||
|
||||
for (const serverList of serverLists) {
|
||||
const existingList = await db.lists.get(serverList.id)
|
||||
if (!existingList || !existingList.pendingSync) {
|
||||
await db.lists.put({ ...serverList, pendingSync: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const serverItems = await getListItemsApi(serverList.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 })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore per-list failures so one broken list doesn't block the rest.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load lists from server'
|
||||
} finally {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lists,
|
||||
listItems,
|
||||
sortedLists,
|
||||
isLoaded,
|
||||
isSyncing,
|
||||
pendingCount,
|
||||
error,
|
||||
itemsForList,
|
||||
loadLists,
|
||||
refresh,
|
||||
createList,
|
||||
createListItem,
|
||||
updateListItem,
|
||||
setListItemCompleted,
|
||||
addUserToList,
|
||||
removeUserFromList,
|
||||
sync,
|
||||
pullFromServer,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user