learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: sync store with dexie

* indicator UI

+899 -3
+3 -3
docs/todo.md
··· 33 33 - [x] Bi-directional sync infrastructure 34 34 - [x] Conflict resolution strategy 35 35 - [x] API endpoints for sync operations 36 - - [ ] Offline queue for pending publishes 37 - - [ ] Frontend sync store with IndexedDB persistence 38 - - [ ] Sync status UI indicators 36 + - [x] Offline queue for pending publishes 37 + - [x] Frontend sync store with IndexedDB persistence (Dexie.js) 38 + - [x] Sync status UI indicators 39 39 40 40 **Deep Linking:** 41 41
+2
web/package.json
··· 32 32 "d3-force": "^3.0.0", 33 33 "d3-selection": "^3.0.0", 34 34 "d3-zoom": "^3.0.0", 35 + "dexie": "^4.2.1", 35 36 "motion": "^12.23.26", 36 37 "rehype-external-links": "^3.0.0", 37 38 "rehype-sanitize": "^6.0.0", ··· 63 64 "@typescript-eslint/parser": "^8.50.1", 64 65 "eslint": "^9.39.2", 65 66 "eslint-plugin-solid": "^0.14.5", 67 + "fake-indexeddb": "^6.2.5", 66 68 "globals": "^16.5.0", 67 69 "jsdom": "^27.4.0", 68 70 "satori": "^0.18.3",
+17
web/pnpm-lock.yaml
··· 68 68 d3-zoom: 69 69 specifier: ^3.0.0 70 70 version: 3.0.0 71 + dexie: 72 + specifier: ^4.2.1 73 + version: 4.2.1 71 74 motion: 72 75 specifier: ^12.23.26 73 76 version: 12.23.26 ··· 156 159 eslint-plugin-solid: 157 160 specifier: ^0.14.5 158 161 version: 0.14.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 162 + fake-indexeddb: 163 + specifier: ^6.2.5 164 + version: 6.2.5 159 165 globals: 160 166 specifier: ^16.5.0 161 167 version: 16.5.0 ··· 1403 1409 devlop@1.1.0: 1404 1410 resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 1405 1411 1412 + dexie@4.2.1: 1413 + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} 1414 + 1406 1415 dom-accessibility-api@0.5.16: 1407 1416 resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} 1408 1417 ··· 1507 1516 1508 1517 extend@3.0.2: 1509 1518 resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} 1519 + 1520 + fake-indexeddb@6.2.5: 1521 + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} 1522 + engines: {node: '>=18'} 1510 1523 1511 1524 fast-deep-equal@3.1.3: 1512 1525 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} ··· 3632 3645 dependencies: 3633 3646 dequal: 2.0.3 3634 3647 3648 + dexie@4.2.1: {} 3649 + 3635 3650 dom-accessibility-api@0.5.16: {} 3636 3651 3637 3652 dom-accessibility-api@0.6.3: {} ··· 3776 3791 exsolve@1.0.8: {} 3777 3792 3778 3793 extend@3.0.2: {} 3794 + 3795 + fake-indexeddb@6.2.5: {} 3779 3796 3780 3797 fast-deep-equal@3.1.3: {} 3781 3798
+63
web/src/components/SyncIndicator.tsx
··· 1 + import { syncStore } from "$lib/sync-store"; 2 + import { Show } from "solid-js"; 3 + 4 + export function SyncIndicator() { 5 + const stateClasses = () => { 6 + const state = syncStore.syncState(); 7 + switch (state) { 8 + case "syncing": 9 + return "text-blue-500"; 10 + case "error": 11 + return "text-red-500"; 12 + case "offline": 13 + return "text-amber-500"; 14 + default: 15 + return "text-green-500"; 16 + } 17 + }; 18 + 19 + const stateIcon = () => { 20 + const state = syncStore.syncState(); 21 + switch (state) { 22 + case "syncing": 23 + return "i-ri-loader-4-line animate-spin"; 24 + case "error": 25 + return "i-ri-error-warning-line"; 26 + case "offline": 27 + return "i-ri-wifi-off-line"; 28 + default: 29 + return "i-ri-cloud-line"; 30 + } 31 + }; 32 + 33 + const stateLabel = () => { 34 + const state = syncStore.syncState(); 35 + switch (state) { 36 + case "syncing": 37 + return "Syncing..."; 38 + case "error": 39 + return "Sync error"; 40 + case "offline": 41 + return "Offline"; 42 + default: 43 + return "Synced"; 44 + } 45 + }; 46 + 47 + return ( 48 + <div class="flex items-center gap-2 text-sm"> 49 + <span class={`${stateIcon()} ${stateClasses()}`} /> 50 + <span class={stateClasses()}>{stateLabel()}</span> 51 + <Show when={syncStore.pendingCount() > 0}> 52 + <span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700"> 53 + {syncStore.pendingCount()} pending 54 + </span> 55 + </Show> 56 + <Show when={syncStore.conflictCount() > 0}> 57 + <span class="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700"> 58 + {syncStore.conflictCount()} conflicts 59 + </span> 60 + </Show> 61 + </div> 62 + ); 63 + }
+2
web/src/components/layout/Header.tsx
··· 2 2 import { Avatar } from "$ui/Avatar"; 3 3 import { A } from "@solidjs/router"; 4 4 import { type Component, Show } from "solid-js"; 5 + import { SyncIndicator } from "../SyncIndicator"; 5 6 6 7 const Login: Component = () => ( 7 8 <A href="/login" class="px-4 py-2 bg-white text-gray-900 text-sm font-medium hover:bg-gray-100 transition-colors"> ··· 29 30 <div class="flex items-center gap-4"> 30 31 <Show when={authStore.user()} fallback={<Login />}> 31 32 <div class="flex items-center gap-3"> 33 + <SyncIndicator /> 32 34 <span class="text-xs text-gray-400">{authStore.user()?.handle}</span> 33 35 <button 34 36 onClick={() => authStore.logout()}
+76
web/src/components/tests/SyncIndicator.test.tsx
··· 1 + import "fake-indexeddb/auto"; 2 + import { cleanup, render, screen } from "@solidjs/testing-library"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + // Create a controllable mock store 6 + const mockState = { state: "idle" as string, pending: 0, conflicts: 0 }; 7 + 8 + vi.mock( 9 + "$lib/sync-store", 10 + () => ({ 11 + syncStore: { 12 + syncState: () => mockState.state, 13 + pendingCount: () => mockState.pending, 14 + conflictCount: () => mockState.conflicts, 15 + }, 16 + }), 17 + ); 18 + 19 + import { SyncIndicator } from "../SyncIndicator"; 20 + 21 + describe("SyncIndicator", () => { 22 + beforeEach(() => { 23 + mockState.state = "idle"; 24 + mockState.pending = 0; 25 + mockState.conflicts = 0; 26 + }); 27 + 28 + afterEach(cleanup); 29 + 30 + it("renders synced state by default", () => { 31 + render(() => <SyncIndicator />); 32 + expect(screen.getByText("Synced")).toBeInTheDocument(); 33 + }); 34 + 35 + it("renders syncing state with spinner", () => { 36 + mockState.state = "syncing"; 37 + render(() => <SyncIndicator />); 38 + expect(screen.getByText("Syncing...")).toBeInTheDocument(); 39 + }); 40 + 41 + it("renders error state", () => { 42 + mockState.state = "error"; 43 + render(() => <SyncIndicator />); 44 + expect(screen.getByText("Sync error")).toBeInTheDocument(); 45 + }); 46 + 47 + it("renders offline state", () => { 48 + mockState.state = "offline"; 49 + render(() => <SyncIndicator />); 50 + expect(screen.getByText("Offline")).toBeInTheDocument(); 51 + }); 52 + 53 + it("shows pending count when items are pending", () => { 54 + mockState.pending = 3; 55 + render(() => <SyncIndicator />); 56 + expect(screen.getByText("3 pending")).toBeInTheDocument(); 57 + }); 58 + 59 + it("shows conflict count when conflicts exist", () => { 60 + mockState.conflicts = 2; 61 + render(() => <SyncIndicator />); 62 + expect(screen.getByText("2 conflicts")).toBeInTheDocument(); 63 + }); 64 + 65 + it("hides pending badge when count is zero", () => { 66 + mockState.pending = 0; 67 + render(() => <SyncIndicator />); 68 + expect(screen.queryByText(/pending/)).not.toBeInTheDocument(); 69 + }); 70 + 71 + it("hides conflict badge when count is zero", () => { 72 + mockState.conflicts = 0; 73 + render(() => <SyncIndicator />); 74 + expect(screen.queryByText(/conflicts/)).not.toBeInTheDocument(); 75 + }); 76 + });
+10
web/src/lib/api.ts
··· 25 25 return response; 26 26 } 27 27 28 + const syncMethods = { 29 + pushDeck: (id: string) => apiFetch(`/sync/push/deck/${id}`, { method: "POST" }), 30 + pushNote: (id: string) => apiFetch(`/sync/push/note/${id}`, { method: "POST" }), 31 + getSyncStatus: () => apiFetch("/sync/status", { method: "GET" }), 32 + resolveConflict: (entityType: string, id: string, strategy: "last_write_wins" | "keep_local" | "keep_remote") => { 33 + return apiFetch(`/sync/resolve/${entityType}/${id}`, { method: "POST", body: JSON.stringify({ strategy }) }); 34 + }, 35 + }; 36 + 28 37 export const api = { 29 38 get: (path: string) => apiFetch(path, { method: "GET" }), 30 39 post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), ··· 117 126 document.body.removeChild(a); 118 127 URL.revokeObjectURL(url); 119 128 }, 129 + ...syncMethods, 120 130 };
+84
web/src/lib/db.ts
··· 1 + import Dexie, { type EntityTable } from "dexie"; 2 + import type { CardType, Visibility } from "./model"; 3 + 4 + export type SyncStatus = "local_only" | "synced" | "pending_push" | "conflict"; 5 + 6 + type EntityKind = "deck" | "card" | "note"; 7 + 8 + type OperationKind = "push" | "delete"; 9 + 10 + type SyncTracking = { syncStatus: SyncStatus; localVersion: number; pdsCid?: string }; 11 + 12 + export type LocalDeck = SyncTracking & { 13 + id: string; 14 + ownerDid: string; 15 + title: string; 16 + description: string; 17 + tags: string[]; 18 + visibility: Visibility; 19 + publishedAt?: string; 20 + forkOf?: string; 21 + pdsUri?: string; 22 + updatedAt: string; 23 + }; 24 + 25 + export type LocalCard = SyncTracking & { 26 + id: string; 27 + deckId: string; 28 + front: string; 29 + back: string; 30 + mediaUrl?: string; 31 + cardType: CardType; 32 + hints: string[]; 33 + }; 34 + 35 + export type LocalNote = SyncTracking & { 36 + id: string; 37 + ownerDid: string; 38 + title: string; 39 + body: string; 40 + tags: string[]; 41 + visibility: Visibility; 42 + publishedAt?: string; 43 + links: string[]; 44 + pdsUri?: string; 45 + updatedAt: string; 46 + }; 47 + 48 + export type SyncQueueItem = { 49 + id?: number; 50 + entityType: EntityKind; 51 + entityId: string; 52 + operation: OperationKind; 53 + createdAt: string; 54 + retryCount: number; 55 + lastError?: string; 56 + }; 57 + 58 + class MalfestioDatabase extends Dexie { 59 + decks!: EntityTable<LocalDeck, "id">; 60 + cards!: EntityTable<LocalCard, "id">; 61 + notes!: EntityTable<LocalNote, "id">; 62 + syncQueue!: EntityTable<SyncQueueItem, "id">; 63 + 64 + constructor() { 65 + super("malfestio"); 66 + 67 + this.version(1).stores({ 68 + decks: "id, ownerDid, syncStatus, updatedAt", 69 + cards: "id, deckId, syncStatus", 70 + notes: "id, ownerDid, syncStatus, updatedAt", 71 + syncQueue: "++id, entityType, entityId, createdAt", 72 + }); 73 + } 74 + } 75 + 76 + export const db = new MalfestioDatabase(); 77 + 78 + export function generateLocalId(): string { 79 + return `local_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; 80 + } 81 + 82 + export function isLocalId(id: string): boolean { 83 + return id.startsWith("local_"); 84 + }
+221
web/src/lib/sync-store.ts
··· 1 + /** 2 + * Sync store for managing offline-first sync with PDS. 3 + */ 4 + import { createRoot, createSignal } from "solid-js"; 5 + import { api } from "./api"; 6 + import { db, generateLocalId, type LocalDeck, type LocalNote, type SyncStatus } from "./db"; 7 + import { authStore } from "./store"; 8 + 9 + export type SyncState = "idle" | "syncing" | "error" | "offline"; 10 + 11 + function createSyncStore() { 12 + const [syncState, setSyncState] = createSignal<SyncState>("idle"); 13 + const [pendingCount, setPendingCount] = createSignal(0); 14 + const [conflictCount, setConflictCount] = createSignal(0); 15 + const [lastSyncedAt, setLastSyncedAt] = createSignal<string | null>(null); 16 + const [isOnline, setIsOnline] = createSignal(navigator.onLine); 17 + 18 + if (typeof window !== "undefined") { 19 + window.addEventListener("online", () => { 20 + setIsOnline(true); 21 + processQueue(); 22 + }); 23 + window.addEventListener("offline", () => { 24 + setIsOnline(false); 25 + setSyncState("offline"); 26 + }); 27 + } 28 + 29 + async function refreshCounts() { 30 + const pending = await db.syncQueue.count(); 31 + setPendingCount(pending); 32 + 33 + const conflicts = await db.decks.where("syncStatus").equals("conflict").count() 34 + + await db.notes.where("syncStatus").equals("conflict").count(); 35 + setConflictCount(conflicts); 36 + } 37 + 38 + async function saveDeckLocally( 39 + deck: Omit<LocalDeck, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string }, 40 + ): Promise<LocalDeck> { 41 + const now = new Date().toISOString(); 42 + const existing = deck.id ? await db.decks.get(deck.id) : null; 43 + 44 + const localDeck: LocalDeck = { 45 + id: deck.id || generateLocalId(), 46 + ownerDid: deck.ownerDid, 47 + title: deck.title, 48 + description: deck.description, 49 + tags: deck.tags, 50 + visibility: deck.visibility, 51 + publishedAt: deck.publishedAt, 52 + forkOf: deck.forkOf, 53 + syncStatus: existing ? "pending_push" : "local_only", 54 + localVersion: existing ? existing.localVersion + 1 : 1, 55 + pdsCid: existing?.pdsCid, 56 + pdsUri: existing?.pdsUri, 57 + updatedAt: now, 58 + }; 59 + 60 + await db.decks.put(localDeck); 61 + 62 + if (isOnline()) { 63 + await queueForSync("deck", localDeck.id, "push"); 64 + } 65 + 66 + await refreshCounts(); 67 + return localDeck; 68 + } 69 + 70 + async function saveNoteLocally( 71 + note: Omit<LocalNote, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string }, 72 + ): Promise<LocalNote> { 73 + const now = new Date().toISOString(); 74 + const existing = note.id ? await db.notes.get(note.id) : null; 75 + 76 + const localNote: LocalNote = { 77 + id: note.id || generateLocalId(), 78 + ownerDid: note.ownerDid, 79 + title: note.title, 80 + body: note.body, 81 + tags: note.tags, 82 + visibility: note.visibility, 83 + publishedAt: note.publishedAt, 84 + links: note.links, 85 + syncStatus: existing ? "pending_push" : "local_only", 86 + localVersion: existing ? existing.localVersion + 1 : 1, 87 + pdsCid: existing?.pdsCid, 88 + pdsUri: existing?.pdsUri, 89 + updatedAt: now, 90 + }; 91 + 92 + await db.notes.put(localNote); 93 + 94 + if (isOnline()) { 95 + await queueForSync("note", localNote.id, "push"); 96 + } 97 + 98 + await refreshCounts(); 99 + return localNote; 100 + } 101 + 102 + async function queueForSync(entityType: "deck" | "card" | "note", entityId: string, operation: "push" | "delete") { 103 + const existing = await db.syncQueue.where({ entityType, entityId, operation }).first(); 104 + 105 + if (!existing) { 106 + await db.syncQueue.add({ entityType, entityId, operation, createdAt: new Date().toISOString(), retryCount: 0 }); 107 + } 108 + 109 + await refreshCounts(); 110 + } 111 + 112 + async function processQueue() { 113 + if (!isOnline() || !authStore.isAuthenticated()) { 114 + return; 115 + } 116 + 117 + const items = await db.syncQueue.orderBy("createdAt").toArray(); 118 + if (items.length === 0) return; 119 + 120 + setSyncState("syncing"); 121 + 122 + for (const item of items) { 123 + try { 124 + if (item.operation === "push") { 125 + let response: Response; 126 + if (item.entityType === "deck") { 127 + response = await api.pushDeck(item.entityId); 128 + } else if (item.entityType === "note") { 129 + response = await api.pushNote(item.entityId); 130 + } else { 131 + continue; 132 + } 133 + 134 + if (response.ok) { 135 + const result = await response.json(); 136 + if (item.entityType === "deck") { 137 + await db.decks.update(item.entityId, { 138 + syncStatus: "synced" as SyncStatus, 139 + pdsCid: result.pds_cid, 140 + pdsUri: result.pds_uri, 141 + }); 142 + } else if (item.entityType === "note") { 143 + await db.notes.update(item.entityId, { 144 + syncStatus: "synced" as SyncStatus, 145 + pdsCid: result.pds_cid, 146 + pdsUri: result.pds_uri, 147 + }); 148 + } 149 + await db.syncQueue.delete(item.id!); 150 + } else if (response.status === 409) { 151 + if (item.entityType === "deck") { 152 + await db.decks.update(item.entityId, { syncStatus: "conflict" as SyncStatus }); 153 + } else if (item.entityType === "note") { 154 + await db.notes.update(item.entityId, { syncStatus: "conflict" as SyncStatus }); 155 + } 156 + await db.syncQueue.delete(item.id!); 157 + } else { 158 + await db.syncQueue.update(item.id!, { 159 + retryCount: item.retryCount + 1, 160 + lastError: `HTTP ${response.status}`, 161 + }); 162 + } 163 + } 164 + } catch (error) { 165 + console.error(`Sync failed for ${item.entityType}:${item.entityId}`, error); 166 + await db.syncQueue.update(item.id!, { 167 + retryCount: item.retryCount + 1, 168 + lastError: error instanceof Error ? error.message : "Unknown error", 169 + }); 170 + } 171 + } 172 + 173 + setLastSyncedAt(new Date().toISOString()); 174 + setSyncState(isOnline() ? "idle" : "offline"); 175 + await refreshCounts(); 176 + } 177 + 178 + async function getLocalDecks(ownerDid: string): Promise<LocalDeck[]> { 179 + return db.decks.where("ownerDid").equals(ownerDid).toArray(); 180 + } 181 + async function getLocalNotes(ownerDid: string): Promise<LocalNote[]> { 182 + return db.notes.where("ownerDid").equals(ownerDid).toArray(); 183 + } 184 + 185 + async function getLocalDeck(id: string): Promise<LocalDeck | undefined> { 186 + return db.decks.get(id); 187 + } 188 + async function getLocalNote(id: string): Promise<LocalNote | undefined> { 189 + return db.notes.get(id); 190 + } 191 + 192 + async function clearAll() { 193 + await db.decks.clear(); 194 + await db.cards.clear(); 195 + await db.notes.clear(); 196 + await db.syncQueue.clear(); 197 + await refreshCounts(); 198 + } 199 + 200 + refreshCounts(); 201 + 202 + return { 203 + syncState, 204 + pendingCount, 205 + conflictCount, 206 + lastSyncedAt, 207 + isOnline, 208 + saveDeckLocally, 209 + saveNoteLocally, 210 + queueForSync, 211 + processQueue, 212 + refreshCounts, 213 + getLocalDecks, 214 + getLocalNotes, 215 + getLocalDeck, 216 + getLocalNote, 217 + clearAll, 218 + }; 219 + } 220 + 221 + export const syncStore = createRoot(createSyncStore);
+203
web/src/lib/tests/db.test.ts
··· 1 + import "fake-indexeddb/auto"; 2 + import { afterEach, beforeEach, describe, expect, it } from "vitest"; 3 + import { db, generateLocalId, isLocalId, type LocalDeck, type LocalNote } from "../db"; 4 + 5 + describe("db", () => { 6 + beforeEach(async () => { 7 + await db.decks.clear(); 8 + await db.cards.clear(); 9 + await db.notes.clear(); 10 + await db.syncQueue.clear(); 11 + }); 12 + 13 + afterEach(async () => { 14 + await db.decks.clear(); 15 + await db.cards.clear(); 16 + await db.notes.clear(); 17 + await db.syncQueue.clear(); 18 + }); 19 + 20 + describe("generateLocalId", () => { 21 + it("should generate unique IDs with local_ prefix", () => { 22 + const id1 = generateLocalId(); 23 + const id2 = generateLocalId(); 24 + 25 + expect(id1).toMatch(/^local_\d+_[a-z0-9]+$/); 26 + expect(id2).toMatch(/^local_\d+_[a-z0-9]+$/); 27 + expect(id1).not.toBe(id2); 28 + }); 29 + }); 30 + 31 + describe("isLocalId", () => { 32 + it("should return true for local IDs", () => { 33 + expect(isLocalId("local_123_abc")).toBe(true); 34 + expect(isLocalId("local_")).toBe(true); 35 + }); 36 + 37 + it("should return false for server IDs", () => { 38 + expect(isLocalId("deck-123")).toBe(false); 39 + expect(isLocalId("uuid-abc-def")).toBe(false); 40 + }); 41 + }); 42 + 43 + describe("decks table", () => { 44 + it("should insert and retrieve decks", async () => { 45 + const deck: LocalDeck = { 46 + id: "deck-1", 47 + ownerDid: "did:plc:test", 48 + title: "Test Deck", 49 + description: "A test deck", 50 + tags: ["test"], 51 + visibility: { type: "Private" }, 52 + syncStatus: "local_only", 53 + localVersion: 1, 54 + updatedAt: new Date().toISOString(), 55 + }; 56 + 57 + await db.decks.put(deck); 58 + const retrieved = await db.decks.get("deck-1"); 59 + 60 + expect(retrieved).toBeDefined(); 61 + expect(retrieved?.title).toBe("Test Deck"); 62 + expect(retrieved?.syncStatus).toBe("local_only"); 63 + }); 64 + 65 + it("should query decks by ownerDid", async () => { 66 + await db.decks.bulkPut([{ 67 + id: "deck-1", 68 + ownerDid: "did:alice", 69 + title: "Alice Deck 1", 70 + description: "", 71 + tags: [], 72 + visibility: { type: "Private" }, 73 + syncStatus: "synced", 74 + localVersion: 1, 75 + updatedAt: new Date().toISOString(), 76 + }, { 77 + id: "deck-2", 78 + ownerDid: "did:alice", 79 + title: "Alice Deck 2", 80 + description: "", 81 + tags: [], 82 + visibility: { type: "Public" }, 83 + syncStatus: "pending_push", 84 + localVersion: 2, 85 + updatedAt: new Date().toISOString(), 86 + }, { 87 + id: "deck-3", 88 + ownerDid: "did:bob", 89 + title: "Bob Deck", 90 + description: "", 91 + tags: [], 92 + visibility: { type: "Private" }, 93 + syncStatus: "local_only", 94 + localVersion: 1, 95 + updatedAt: new Date().toISOString(), 96 + }]); 97 + 98 + const aliceDecks = await db.decks.where("ownerDid").equals("did:alice").toArray(); 99 + expect(aliceDecks).toHaveLength(2); 100 + expect(aliceDecks.map((d) => d.title)).toContain("Alice Deck 1"); 101 + expect(aliceDecks.map((d) => d.title)).toContain("Alice Deck 2"); 102 + }); 103 + 104 + it("should query decks by syncStatus", async () => { 105 + await db.decks.bulkPut([{ 106 + id: "deck-1", 107 + ownerDid: "did:test", 108 + title: "Synced", 109 + description: "", 110 + tags: [], 111 + visibility: { type: "Private" }, 112 + syncStatus: "synced", 113 + localVersion: 1, 114 + updatedAt: new Date().toISOString(), 115 + }, { 116 + id: "deck-2", 117 + ownerDid: "did:test", 118 + title: "Conflict", 119 + description: "", 120 + tags: [], 121 + visibility: { type: "Private" }, 122 + syncStatus: "conflict", 123 + localVersion: 1, 124 + updatedAt: new Date().toISOString(), 125 + }]); 126 + 127 + const conflicts = await db.decks.where("syncStatus").equals("conflict").toArray(); 128 + expect(conflicts).toHaveLength(1); 129 + expect(conflicts[0].title).toBe("Conflict"); 130 + }); 131 + }); 132 + 133 + describe("notes table", () => { 134 + it("should insert and retrieve notes", async () => { 135 + const note: LocalNote = { 136 + id: "note-1", 137 + ownerDid: "did:plc:test", 138 + title: "Test Note", 139 + body: "This is a test note", 140 + tags: ["test"], 141 + visibility: { type: "Private" }, 142 + links: [], 143 + syncStatus: "local_only", 144 + localVersion: 1, 145 + updatedAt: new Date().toISOString(), 146 + }; 147 + 148 + await db.notes.put(note); 149 + const retrieved = await db.notes.get("note-1"); 150 + 151 + expect(retrieved).toBeDefined(); 152 + expect(retrieved?.title).toBe("Test Note"); 153 + expect(retrieved?.body).toBe("This is a test note"); 154 + }); 155 + }); 156 + 157 + describe("syncQueue table", () => { 158 + it("should auto-increment IDs", async () => { 159 + const id1 = await db.syncQueue.add({ 160 + entityType: "deck", 161 + entityId: "deck-1", 162 + operation: "push", 163 + createdAt: new Date().toISOString(), 164 + retryCount: 0, 165 + }); 166 + 167 + const id2 = await db.syncQueue.add({ 168 + entityType: "note", 169 + entityId: "note-1", 170 + operation: "push", 171 + createdAt: new Date().toISOString(), 172 + retryCount: 0, 173 + }); 174 + 175 + expect(id2).toBeGreaterThan(id1!); 176 + }); 177 + 178 + it("should count pending items", async () => { 179 + await db.syncQueue.bulkAdd([{ 180 + entityType: "deck", 181 + entityId: "deck-1", 182 + operation: "push", 183 + createdAt: new Date().toISOString(), 184 + retryCount: 0, 185 + }, { 186 + entityType: "deck", 187 + entityId: "deck-2", 188 + operation: "push", 189 + createdAt: new Date().toISOString(), 190 + retryCount: 0, 191 + }, { 192 + entityType: "note", 193 + entityId: "note-1", 194 + operation: "push", 195 + createdAt: new Date().toISOString(), 196 + retryCount: 0, 197 + }]); 198 + 199 + const count = await db.syncQueue.count(); 200 + expect(count).toBe(3); 201 + }); 202 + }); 203 + });
+218
web/src/lib/tests/sync-store.test.ts
··· 1 + import "fake-indexeddb/auto"; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { db, type LocalDeck } from "../db"; 4 + 5 + vi.mock( 6 + "../api", 7 + () => ({ 8 + api: { 9 + pushDeck: vi.fn().mockResolvedValue({ 10 + ok: true, 11 + json: async () => ({ pds_cid: "cid123", pds_uri: "at://test" }), 12 + }), 13 + pushNote: vi.fn().mockResolvedValue({ 14 + ok: true, 15 + json: async () => ({ pds_cid: "cid456", pds_uri: "at://test" }), 16 + }), 17 + getSyncStatus: vi.fn().mockResolvedValue({ 18 + ok: true, 19 + json: async () => ({ pending_count: 0, conflict_count: 0 }), 20 + }), 21 + resolveConflict: vi.fn().mockResolvedValue({ ok: true }), 22 + }, 23 + }), 24 + ); 25 + 26 + vi.mock("../store", () => ({ authStore: { isAuthenticated: () => true, accessJwt: () => "test-token" } })); 27 + 28 + import { syncStore } from "../sync-store"; 29 + 30 + describe("syncStore", () => { 31 + beforeEach(async () => { 32 + await db.decks.clear(); 33 + await db.cards.clear(); 34 + await db.notes.clear(); 35 + await db.syncQueue.clear(); 36 + }); 37 + 38 + afterEach(async () => { 39 + await db.decks.clear(); 40 + await db.cards.clear(); 41 + await db.notes.clear(); 42 + await db.syncQueue.clear(); 43 + }); 44 + 45 + describe("state signals", () => { 46 + it("should have initial idle sync state", () => { 47 + expect(["idle", "offline"]).toContain(syncStore.syncState()); 48 + }); 49 + 50 + it("should have online status signal", () => { 51 + expect(typeof syncStore.isOnline()).toBe("boolean"); 52 + }); 53 + }); 54 + 55 + describe("saveDeckLocally", () => { 56 + it("should save a new deck with local_only status", async () => { 57 + const deck = await syncStore.saveDeckLocally({ 58 + ownerDid: "did:plc:test", 59 + title: "New Deck", 60 + description: "Test description", 61 + tags: ["test"], 62 + visibility: { type: "Private" }, 63 + }); 64 + 65 + expect(deck.id).toMatch(/^local_/); 66 + expect(deck.syncStatus).toBe("local_only"); 67 + expect(deck.localVersion).toBe(1); 68 + 69 + const stored = await db.decks.get(deck.id); 70 + expect(stored?.title).toBe("New Deck"); 71 + }); 72 + 73 + it("should update existing deck with pending_push status", async () => { 74 + const deck = await syncStore.saveDeckLocally({ 75 + ownerDid: "did:plc:test", 76 + title: "Original Title", 77 + description: "Test", 78 + tags: [], 79 + visibility: { type: "Private" }, 80 + }); 81 + 82 + const updated = await syncStore.saveDeckLocally({ 83 + id: deck.id, 84 + ownerDid: "did:plc:test", 85 + title: "Updated Title", 86 + description: "Test", 87 + tags: [], 88 + visibility: { type: "Private" }, 89 + }); 90 + 91 + expect(updated.id).toBe(deck.id); 92 + expect(updated.title).toBe("Updated Title"); 93 + expect(updated.syncStatus).toBe("pending_push"); 94 + expect(updated.localVersion).toBe(2); 95 + }); 96 + }); 97 + 98 + describe("saveNoteLocally", () => { 99 + it("should save a new note with local_only status", async () => { 100 + const note = await syncStore.saveNoteLocally({ 101 + ownerDid: "did:plc:test", 102 + title: "New Note", 103 + body: "Note content", 104 + tags: ["test"], 105 + visibility: { type: "Private" }, 106 + links: [], 107 + }); 108 + 109 + expect(note.id).toMatch(/^local_/); 110 + expect(note.syncStatus).toBe("local_only"); 111 + expect(note.localVersion).toBe(1); 112 + }); 113 + }); 114 + 115 + describe("getLocalDecks", () => { 116 + it("should return decks for a specific owner", async () => { 117 + await db.decks.bulkPut([ 118 + { 119 + id: "deck-1", 120 + ownerDid: "did:alice", 121 + title: "Alice Deck", 122 + description: "", 123 + tags: [], 124 + visibility: { type: "Private" }, 125 + syncStatus: "synced", 126 + localVersion: 1, 127 + updatedAt: new Date().toISOString(), 128 + } satisfies LocalDeck, 129 + { 130 + id: "deck-2", 131 + ownerDid: "did:bob", 132 + title: "Bob Deck", 133 + description: "", 134 + tags: [], 135 + visibility: { type: "Private" }, 136 + syncStatus: "synced", 137 + localVersion: 1, 138 + updatedAt: new Date().toISOString(), 139 + } satisfies LocalDeck, 140 + ]); 141 + 142 + const aliceDecks = await syncStore.getLocalDecks("did:alice"); 143 + expect(aliceDecks).toHaveLength(1); 144 + expect(aliceDecks[0].title).toBe("Alice Deck"); 145 + }); 146 + }); 147 + 148 + describe("queueForSync", () => { 149 + it("should add item to sync queue", async () => { 150 + await syncStore.queueForSync("deck", "deck-123", "push"); 151 + 152 + const queue = await db.syncQueue.toArray(); 153 + expect(queue).toHaveLength(1); 154 + expect(queue[0].entityType).toBe("deck"); 155 + expect(queue[0].entityId).toBe("deck-123"); 156 + expect(queue[0].operation).toBe("push"); 157 + }); 158 + 159 + it("should not duplicate queue entries", async () => { 160 + await syncStore.queueForSync("deck", "deck-123", "push"); 161 + await syncStore.queueForSync("deck", "deck-123", "push"); 162 + 163 + const queue = await db.syncQueue.toArray(); 164 + expect(queue).toHaveLength(1); 165 + }); 166 + }); 167 + 168 + describe("refreshCounts", () => { 169 + it("should update pending and conflict counts", async () => { 170 + await db.syncQueue.add({ 171 + entityType: "deck", 172 + entityId: "deck-1", 173 + operation: "push", 174 + createdAt: new Date().toISOString(), 175 + retryCount: 0, 176 + }); 177 + 178 + await db.decks.put({ 179 + id: "deck-2", 180 + ownerDid: "did:test", 181 + title: "Conflict Deck", 182 + description: "", 183 + tags: [], 184 + visibility: { type: "Private" }, 185 + syncStatus: "conflict", 186 + localVersion: 1, 187 + updatedAt: new Date().toISOString(), 188 + }); 189 + 190 + await syncStore.refreshCounts(); 191 + 192 + expect(syncStore.pendingCount()).toBe(1); 193 + expect(syncStore.conflictCount()).toBe(1); 194 + }); 195 + }); 196 + 197 + describe("clearAll", () => { 198 + it("should clear all local data", async () => { 199 + await db.decks.put({ 200 + id: "deck-1", 201 + ownerDid: "did:test", 202 + title: "Test", 203 + description: "", 204 + tags: [], 205 + visibility: { type: "Private" }, 206 + syncStatus: "synced", 207 + localVersion: 1, 208 + updatedAt: new Date().toISOString(), 209 + }); 210 + 211 + await syncStore.clearAll(); 212 + 213 + expect(await db.decks.count()).toBe(0); 214 + expect(await db.notes.count()).toBe(0); 215 + expect(await db.syncQueue.count()).toBe(0); 216 + }); 217 + }); 218 + });