learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1/**
2 * Sync store for managing offline-first sync with PDS.
3 */
4import { createRoot, createSignal } from "solid-js";
5import { api } from "./api";
6import {
7 db,
8 generateLocalId,
9 type LocalCard,
10 type LocalDeck,
11 type LocalNote,
12 type SyncQueueItem,
13 type SyncStatus,
14} from "./db";
15import { authStore } from "./store";
16
17export type SyncState = "idle" | "syncing" | "error" | "offline";
18
19function createSyncStore() {
20 const [syncState, setSyncState] = createSignal<SyncState>("idle");
21 const [pendingCount, setPendingCount] = createSignal(0);
22 const [conflictCount, setConflictCount] = createSignal(0);
23 const [lastSyncedAt, setLastSyncedAt] = createSignal<string | null>(null);
24 const [isOnline, setIsOnline] = createSignal(navigator.onLine);
25
26 if (typeof window !== "undefined") {
27 window.addEventListener("online", () => {
28 setIsOnline(true);
29 processQueue();
30 });
31 window.addEventListener("offline", () => {
32 setIsOnline(false);
33 setSyncState("offline");
34 });
35 }
36
37 async function refreshCounts() {
38 const pending = await db.syncQueue.count();
39 setPendingCount(pending);
40
41 const conflicts = await db.decks.where("syncStatus").equals("conflict").count()
42 + await db.notes.where("syncStatus").equals("conflict").count();
43 setConflictCount(conflicts);
44 }
45
46 async function saveDeckLocally(
47 deck: Omit<LocalDeck, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string },
48 ): Promise<LocalDeck> {
49 const now = new Date().toISOString();
50 const existing = deck.id ? await db.decks.get(deck.id) : null;
51
52 const localDeck: LocalDeck = {
53 id: deck.id || generateLocalId(),
54 ownerDid: deck.ownerDid,
55 title: deck.title,
56 description: deck.description,
57 tags: deck.tags,
58 visibility: deck.visibility,
59 publishedAt: deck.publishedAt,
60 forkOf: deck.forkOf,
61 syncStatus: existing ? "pending_push" : "local_only",
62 localVersion: existing ? existing.localVersion + 1 : 1,
63 pdsCid: existing?.pdsCid,
64 pdsUri: existing?.pdsUri,
65 updatedAt: now,
66 };
67
68 await db.decks.put(localDeck);
69
70 if (isOnline()) {
71 await queueForSync("deck", localDeck.id, "push");
72 }
73
74 await refreshCounts();
75 return localDeck;
76 }
77
78 async function saveNoteLocally(
79 note: Omit<LocalNote, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string },
80 ): Promise<LocalNote> {
81 const now = new Date().toISOString();
82 const existing = note.id ? await db.notes.get(note.id) : null;
83
84 const localNote: LocalNote = {
85 id: note.id || generateLocalId(),
86 ownerDid: note.ownerDid,
87 title: note.title,
88 body: note.body,
89 tags: note.tags,
90 visibility: note.visibility,
91 publishedAt: note.publishedAt,
92 links: note.links,
93 syncStatus: existing ? "pending_push" : "local_only",
94 localVersion: existing ? existing.localVersion + 1 : 1,
95 pdsCid: existing?.pdsCid,
96 pdsUri: existing?.pdsUri,
97 updatedAt: now,
98 };
99
100 await db.notes.put(localNote);
101
102 if (isOnline()) {
103 await queueForSync("note", localNote.id, "push");
104 }
105
106 await refreshCounts();
107 return localNote;
108 }
109
110 async function saveCardLocally(
111 card: Omit<LocalCard, "id" | "syncStatus" | "localVersion"> & { id?: string },
112 ): Promise<LocalCard> {
113 const existing = card.id ? await db.cards.get(card.id) : null;
114
115 const localCard: LocalCard = {
116 id: card.id || generateLocalId(),
117 deckId: card.deckId,
118 front: card.front,
119 back: card.back,
120 mediaUrl: card.mediaUrl,
121 cardType: card.cardType,
122 hints: card.hints,
123 syncStatus: existing ? "pending_push" : "local_only",
124 localVersion: existing ? existing.localVersion + 1 : 1,
125 pdsCid: existing?.pdsCid,
126 };
127
128 await db.cards.put(localCard);
129 return localCard;
130 }
131
132 async function getLocalCards(deckId: string): Promise<LocalCard[]> {
133 return db.cards.where("deckId").equals(deckId).toArray();
134 }
135
136 async function deleteLocalCard(id: string): Promise<void> {
137 await db.cards.delete(id);
138 }
139
140 async function queueForSync(entityType: "deck" | "card" | "note", entityId: string, operation: "push" | "delete") {
141 const existing = await db.syncQueue.where({ entityType, entityId, operation }).first();
142
143 if (!existing) {
144 await db.syncQueue.add({ entityType, entityId, operation, createdAt: new Date().toISOString(), retryCount: 0 });
145 }
146
147 await refreshCounts();
148 }
149
150 async function processQueue() {
151 if (!isOnline() || !authStore.isAuthenticated()) {
152 return;
153 }
154
155 const items = await db.syncQueue.orderBy("createdAt").toArray();
156 if (items.length === 0) return;
157
158 setSyncState("syncing");
159
160 for (const item of items) {
161 try {
162 if (item.operation === "push") {
163 let response: Response;
164 if (item.entityType === "deck") {
165 response = await api.pushDeck(item.entityId);
166 } else if (item.entityType === "note") {
167 response = await api.pushNote(item.entityId);
168 } else {
169 continue;
170 }
171
172 if (response.ok) {
173 const result = await response.json();
174 if (item.entityType === "deck") {
175 await db.decks.update(item.entityId, {
176 syncStatus: "synced" as SyncStatus,
177 pdsCid: result.pds_cid,
178 pdsUri: result.pds_uri,
179 });
180 } else if (item.entityType === "note") {
181 await db.notes.update(item.entityId, {
182 syncStatus: "synced" as SyncStatus,
183 pdsCid: result.pds_cid,
184 pdsUri: result.pds_uri,
185 });
186 }
187 await db.syncQueue.delete(item.id!);
188 } else if (response.status === 409) {
189 if (item.entityType === "deck") {
190 await db.decks.update(item.entityId, { syncStatus: "conflict" as SyncStatus });
191 } else if (item.entityType === "note") {
192 await db.notes.update(item.entityId, { syncStatus: "conflict" as SyncStatus });
193 }
194 await db.syncQueue.delete(item.id!);
195 } else {
196 await db.syncQueue.update(item.id!, {
197 retryCount: item.retryCount + 1,
198 lastError: `HTTP ${response.status}`,
199 });
200 }
201 }
202 } catch (error) {
203 console.error(`Sync failed for ${item.entityType}:${item.entityId}`, error);
204 await db.syncQueue.update(item.id!, {
205 retryCount: item.retryCount + 1,
206 lastError: error instanceof Error ? error.message : "Unknown error",
207 });
208 }
209 }
210
211 setLastSyncedAt(new Date().toISOString());
212 setSyncState(isOnline() ? "idle" : "offline");
213 await refreshCounts();
214 }
215
216 async function getLocalDecks(ownerDid: string): Promise<LocalDeck[]> {
217 return db.decks.where("ownerDid").equals(ownerDid).toArray();
218 }
219 async function getLocalNotes(ownerDid: string): Promise<LocalNote[]> {
220 return db.notes.where("ownerDid").equals(ownerDid).toArray();
221 }
222
223 async function getLocalDeck(id: string): Promise<LocalDeck | undefined> {
224 return db.decks.get(id);
225 }
226 async function getLocalNote(id: string): Promise<LocalNote | undefined> {
227 return db.notes.get(id);
228 }
229
230 async function getAllLocalData(): Promise<
231 { decks: LocalDeck[]; notes: LocalNote[]; cards: LocalCard[]; queue: SyncQueueItem[] }
232 > {
233 const [decks, notes, cards, queue] = await Promise.all([
234 db.decks.toArray(),
235 db.notes.toArray(),
236 db.cards.toArray(),
237 db.syncQueue.toArray(),
238 ]);
239 return { decks, notes, cards, queue };
240 }
241
242 async function clearAll() {
243 await db.decks.clear();
244 await db.cards.clear();
245 await db.notes.clear();
246 await db.syncQueue.clear();
247 await refreshCounts();
248 }
249
250 refreshCounts();
251
252 return {
253 syncState,
254 pendingCount,
255 conflictCount,
256 lastSyncedAt,
257 isOnline,
258 saveDeckLocally,
259 saveNoteLocally,
260 saveCardLocally,
261 getLocalCards,
262 deleteLocalCard,
263 queueForSync,
264 processQueue,
265 refreshCounts,
266 getLocalDecks,
267 getLocalNotes,
268 getLocalDeck,
269 getLocalNote,
270 getAllLocalData,
271 clearAll,
272 };
273}
274
275export const syncStore = createRoot(createSyncStore);