ultisuite-client/lib/api/offline-queue.ts
R3D347HR4Y c87670e90f
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
feat(api): offline-first mail sync w/ TanStack Query
Move mail, compose, contacts, and accounts off mocks onto REST + WS.
Add client, auth store, IDB-backed query cache, offline queue, and
sync bar; hybrid Zustand for UI-only state. Settings still local until
backend has preferences API.
2026-05-23 00:04:28 +02:00

107 lines
3.0 KiB
TypeScript

import { openDB, type IDBPDatabase } from "idb"
import { apiClient } from "./client"
export interface PendingMutation {
id: string
timestamp: number
type:
| "send_message"
| "update_flags"
| "update_labels"
| "delete_message"
| "create_draft"
| "update_draft"
| "schedule_send"
| "create_contact"
| "update_contact"
| "delete_draft"
| "delete_contact"
payload: unknown
retries: number
}
const DB_NAME = "ultimail-offline-queue"
const STORE_NAME = "mutations"
let dbPromise: Promise<IDBPDatabase> | null = null
function getDb() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE_NAME, { keyPath: "id" })
},
})
}
return dbPromise
}
export async function enqueue(mutation: PendingMutation): Promise<void> {
const db = await getDb()
await db.put(STORE_NAME, mutation)
}
export async function getAll(): Promise<PendingMutation[]> {
const db = await getDb()
return db.getAll(STORE_NAME)
}
export async function remove(id: string): Promise<void> {
const db = await getDb()
await db.delete(STORE_NAME, id)
}
export async function getPendingCount(): Promise<number> {
const db = await getDb()
return db.count(STORE_NAME)
}
const MUTATION_ENDPOINTS: Record<PendingMutation["type"], { method: "post" | "put" | "delete"; path: (p: any) => string }> = {
send_message: { method: "post", path: () => "/outbox" },
update_flags: { method: "put", path: (p) => `/messages/${p.message_id}/flags` },
update_labels: { method: "put", path: (p) => `/messages/${p.message_id}/labels` },
delete_message: { method: "delete", path: (p) => `/messages/${p.message_id}` },
create_draft: { method: "post", path: () => "/drafts" },
update_draft: { method: "put", path: (p) => `/drafts/${p.draft_id}` },
schedule_send: { method: "post", path: () => "/outbox/schedule" },
delete_draft: { method: "delete", path: (p) => `/drafts/${p.draft_id}` },
create_contact: { method: "post", path: () => "/contacts" },
update_contact: { method: "put", path: (p) => `/contacts/${p.uid}` },
delete_contact: { method: "delete", path: (p) => `/contacts/${p.uid}` },
}
export async function flush(): Promise<void> {
const mutations = await getAll()
const sorted = mutations.sort((a, b) => a.timestamp - b.timestamp)
for (const mutation of sorted) {
try {
const endpoint = MUTATION_ENDPOINTS[mutation.type]
const path = endpoint.path(mutation.payload)
switch (endpoint.method) {
case "post":
await apiClient.post(path, mutation.payload)
break
case "put":
await apiClient.put(path, mutation.payload)
break
case "delete":
await apiClient.delete(path)
break
}
await remove(mutation.id)
} catch {
const db = await getDb()
await db.put(STORE_NAME, { ...mutation, retries: mutation.retries + 1 })
}
}
}
if (typeof window !== "undefined") {
window.addEventListener("online", () => {
flush()
})
}