tangled
alpha
login
or
join now
desertthunder.dev
/
malfestio
5
fork
atom
learn and share notes on atproto (wip) 🦉
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
5
fork
atom
overview
issues
pulls
pipelines
feat: sync store with dexie
* indicator UI
desertthunder.dev
2 months ago
8f589d8c
e0dfde3a
+899
-3
11 changed files
expand all
collapse all
unified
split
docs
todo.md
web
package.json
pnpm-lock.yaml
src
components
SyncIndicator.tsx
layout
Header.tsx
tests
SyncIndicator.test.tsx
lib
api.ts
db.ts
sync-store.ts
tests
db.test.ts
sync-store.test.ts
+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
36
-
- [ ] Offline queue for pending publishes
37
37
-
- [ ] Frontend sync store with IndexedDB persistence
38
38
-
- [ ] Sync status UI indicators
36
36
+
- [x] Offline queue for pending publishes
37
37
+
- [x] Frontend sync store with IndexedDB persistence (Dexie.js)
38
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
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
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
71
+
dexie:
72
72
+
specifier: ^4.2.1
73
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
162
+
fake-indexeddb:
163
163
+
specifier: ^6.2.5
164
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
1412
+
dexie@4.2.1:
1413
1413
+
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
1414
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
1519
+
1520
1520
+
fake-indexeddb@6.2.5:
1521
1521
+
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
1522
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
3648
+
dexie@4.2.1: {}
3649
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
3794
+
3795
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
1
+
import { syncStore } from "$lib/sync-store";
2
2
+
import { Show } from "solid-js";
3
3
+
4
4
+
export function SyncIndicator() {
5
5
+
const stateClasses = () => {
6
6
+
const state = syncStore.syncState();
7
7
+
switch (state) {
8
8
+
case "syncing":
9
9
+
return "text-blue-500";
10
10
+
case "error":
11
11
+
return "text-red-500";
12
12
+
case "offline":
13
13
+
return "text-amber-500";
14
14
+
default:
15
15
+
return "text-green-500";
16
16
+
}
17
17
+
};
18
18
+
19
19
+
const stateIcon = () => {
20
20
+
const state = syncStore.syncState();
21
21
+
switch (state) {
22
22
+
case "syncing":
23
23
+
return "i-ri-loader-4-line animate-spin";
24
24
+
case "error":
25
25
+
return "i-ri-error-warning-line";
26
26
+
case "offline":
27
27
+
return "i-ri-wifi-off-line";
28
28
+
default:
29
29
+
return "i-ri-cloud-line";
30
30
+
}
31
31
+
};
32
32
+
33
33
+
const stateLabel = () => {
34
34
+
const state = syncStore.syncState();
35
35
+
switch (state) {
36
36
+
case "syncing":
37
37
+
return "Syncing...";
38
38
+
case "error":
39
39
+
return "Sync error";
40
40
+
case "offline":
41
41
+
return "Offline";
42
42
+
default:
43
43
+
return "Synced";
44
44
+
}
45
45
+
};
46
46
+
47
47
+
return (
48
48
+
<div class="flex items-center gap-2 text-sm">
49
49
+
<span class={`${stateIcon()} ${stateClasses()}`} />
50
50
+
<span class={stateClasses()}>{stateLabel()}</span>
51
51
+
<Show when={syncStore.pendingCount() > 0}>
52
52
+
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
53
53
+
{syncStore.pendingCount()} pending
54
54
+
</span>
55
55
+
</Show>
56
56
+
<Show when={syncStore.conflictCount() > 0}>
57
57
+
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
58
58
+
{syncStore.conflictCount()} conflicts
59
59
+
</span>
60
60
+
</Show>
61
61
+
</div>
62
62
+
);
63
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
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
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
1
+
import "fake-indexeddb/auto";
2
2
+
import { cleanup, render, screen } from "@solidjs/testing-library";
3
3
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
+
5
5
+
// Create a controllable mock store
6
6
+
const mockState = { state: "idle" as string, pending: 0, conflicts: 0 };
7
7
+
8
8
+
vi.mock(
9
9
+
"$lib/sync-store",
10
10
+
() => ({
11
11
+
syncStore: {
12
12
+
syncState: () => mockState.state,
13
13
+
pendingCount: () => mockState.pending,
14
14
+
conflictCount: () => mockState.conflicts,
15
15
+
},
16
16
+
}),
17
17
+
);
18
18
+
19
19
+
import { SyncIndicator } from "../SyncIndicator";
20
20
+
21
21
+
describe("SyncIndicator", () => {
22
22
+
beforeEach(() => {
23
23
+
mockState.state = "idle";
24
24
+
mockState.pending = 0;
25
25
+
mockState.conflicts = 0;
26
26
+
});
27
27
+
28
28
+
afterEach(cleanup);
29
29
+
30
30
+
it("renders synced state by default", () => {
31
31
+
render(() => <SyncIndicator />);
32
32
+
expect(screen.getByText("Synced")).toBeInTheDocument();
33
33
+
});
34
34
+
35
35
+
it("renders syncing state with spinner", () => {
36
36
+
mockState.state = "syncing";
37
37
+
render(() => <SyncIndicator />);
38
38
+
expect(screen.getByText("Syncing...")).toBeInTheDocument();
39
39
+
});
40
40
+
41
41
+
it("renders error state", () => {
42
42
+
mockState.state = "error";
43
43
+
render(() => <SyncIndicator />);
44
44
+
expect(screen.getByText("Sync error")).toBeInTheDocument();
45
45
+
});
46
46
+
47
47
+
it("renders offline state", () => {
48
48
+
mockState.state = "offline";
49
49
+
render(() => <SyncIndicator />);
50
50
+
expect(screen.getByText("Offline")).toBeInTheDocument();
51
51
+
});
52
52
+
53
53
+
it("shows pending count when items are pending", () => {
54
54
+
mockState.pending = 3;
55
55
+
render(() => <SyncIndicator />);
56
56
+
expect(screen.getByText("3 pending")).toBeInTheDocument();
57
57
+
});
58
58
+
59
59
+
it("shows conflict count when conflicts exist", () => {
60
60
+
mockState.conflicts = 2;
61
61
+
render(() => <SyncIndicator />);
62
62
+
expect(screen.getByText("2 conflicts")).toBeInTheDocument();
63
63
+
});
64
64
+
65
65
+
it("hides pending badge when count is zero", () => {
66
66
+
mockState.pending = 0;
67
67
+
render(() => <SyncIndicator />);
68
68
+
expect(screen.queryByText(/pending/)).not.toBeInTheDocument();
69
69
+
});
70
70
+
71
71
+
it("hides conflict badge when count is zero", () => {
72
72
+
mockState.conflicts = 0;
73
73
+
render(() => <SyncIndicator />);
74
74
+
expect(screen.queryByText(/conflicts/)).not.toBeInTheDocument();
75
75
+
});
76
76
+
});
+10
web/src/lib/api.ts
···
25
25
return response;
26
26
}
27
27
28
28
+
const syncMethods = {
29
29
+
pushDeck: (id: string) => apiFetch(`/sync/push/deck/${id}`, { method: "POST" }),
30
30
+
pushNote: (id: string) => apiFetch(`/sync/push/note/${id}`, { method: "POST" }),
31
31
+
getSyncStatus: () => apiFetch("/sync/status", { method: "GET" }),
32
32
+
resolveConflict: (entityType: string, id: string, strategy: "last_write_wins" | "keep_local" | "keep_remote") => {
33
33
+
return apiFetch(`/sync/resolve/${entityType}/${id}`, { method: "POST", body: JSON.stringify({ strategy }) });
34
34
+
},
35
35
+
};
36
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
129
+
...syncMethods,
120
130
};
+84
web/src/lib/db.ts
···
1
1
+
import Dexie, { type EntityTable } from "dexie";
2
2
+
import type { CardType, Visibility } from "./model";
3
3
+
4
4
+
export type SyncStatus = "local_only" | "synced" | "pending_push" | "conflict";
5
5
+
6
6
+
type EntityKind = "deck" | "card" | "note";
7
7
+
8
8
+
type OperationKind = "push" | "delete";
9
9
+
10
10
+
type SyncTracking = { syncStatus: SyncStatus; localVersion: number; pdsCid?: string };
11
11
+
12
12
+
export type LocalDeck = SyncTracking & {
13
13
+
id: string;
14
14
+
ownerDid: string;
15
15
+
title: string;
16
16
+
description: string;
17
17
+
tags: string[];
18
18
+
visibility: Visibility;
19
19
+
publishedAt?: string;
20
20
+
forkOf?: string;
21
21
+
pdsUri?: string;
22
22
+
updatedAt: string;
23
23
+
};
24
24
+
25
25
+
export type LocalCard = SyncTracking & {
26
26
+
id: string;
27
27
+
deckId: string;
28
28
+
front: string;
29
29
+
back: string;
30
30
+
mediaUrl?: string;
31
31
+
cardType: CardType;
32
32
+
hints: string[];
33
33
+
};
34
34
+
35
35
+
export type LocalNote = SyncTracking & {
36
36
+
id: string;
37
37
+
ownerDid: string;
38
38
+
title: string;
39
39
+
body: string;
40
40
+
tags: string[];
41
41
+
visibility: Visibility;
42
42
+
publishedAt?: string;
43
43
+
links: string[];
44
44
+
pdsUri?: string;
45
45
+
updatedAt: string;
46
46
+
};
47
47
+
48
48
+
export type SyncQueueItem = {
49
49
+
id?: number;
50
50
+
entityType: EntityKind;
51
51
+
entityId: string;
52
52
+
operation: OperationKind;
53
53
+
createdAt: string;
54
54
+
retryCount: number;
55
55
+
lastError?: string;
56
56
+
};
57
57
+
58
58
+
class MalfestioDatabase extends Dexie {
59
59
+
decks!: EntityTable<LocalDeck, "id">;
60
60
+
cards!: EntityTable<LocalCard, "id">;
61
61
+
notes!: EntityTable<LocalNote, "id">;
62
62
+
syncQueue!: EntityTable<SyncQueueItem, "id">;
63
63
+
64
64
+
constructor() {
65
65
+
super("malfestio");
66
66
+
67
67
+
this.version(1).stores({
68
68
+
decks: "id, ownerDid, syncStatus, updatedAt",
69
69
+
cards: "id, deckId, syncStatus",
70
70
+
notes: "id, ownerDid, syncStatus, updatedAt",
71
71
+
syncQueue: "++id, entityType, entityId, createdAt",
72
72
+
});
73
73
+
}
74
74
+
}
75
75
+
76
76
+
export const db = new MalfestioDatabase();
77
77
+
78
78
+
export function generateLocalId(): string {
79
79
+
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
80
80
+
}
81
81
+
82
82
+
export function isLocalId(id: string): boolean {
83
83
+
return id.startsWith("local_");
84
84
+
}
+221
web/src/lib/sync-store.ts
···
1
1
+
/**
2
2
+
* Sync store for managing offline-first sync with PDS.
3
3
+
*/
4
4
+
import { createRoot, createSignal } from "solid-js";
5
5
+
import { api } from "./api";
6
6
+
import { db, generateLocalId, type LocalDeck, type LocalNote, type SyncStatus } from "./db";
7
7
+
import { authStore } from "./store";
8
8
+
9
9
+
export type SyncState = "idle" | "syncing" | "error" | "offline";
10
10
+
11
11
+
function createSyncStore() {
12
12
+
const [syncState, setSyncState] = createSignal<SyncState>("idle");
13
13
+
const [pendingCount, setPendingCount] = createSignal(0);
14
14
+
const [conflictCount, setConflictCount] = createSignal(0);
15
15
+
const [lastSyncedAt, setLastSyncedAt] = createSignal<string | null>(null);
16
16
+
const [isOnline, setIsOnline] = createSignal(navigator.onLine);
17
17
+
18
18
+
if (typeof window !== "undefined") {
19
19
+
window.addEventListener("online", () => {
20
20
+
setIsOnline(true);
21
21
+
processQueue();
22
22
+
});
23
23
+
window.addEventListener("offline", () => {
24
24
+
setIsOnline(false);
25
25
+
setSyncState("offline");
26
26
+
});
27
27
+
}
28
28
+
29
29
+
async function refreshCounts() {
30
30
+
const pending = await db.syncQueue.count();
31
31
+
setPendingCount(pending);
32
32
+
33
33
+
const conflicts = await db.decks.where("syncStatus").equals("conflict").count()
34
34
+
+ await db.notes.where("syncStatus").equals("conflict").count();
35
35
+
setConflictCount(conflicts);
36
36
+
}
37
37
+
38
38
+
async function saveDeckLocally(
39
39
+
deck: Omit<LocalDeck, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string },
40
40
+
): Promise<LocalDeck> {
41
41
+
const now = new Date().toISOString();
42
42
+
const existing = deck.id ? await db.decks.get(deck.id) : null;
43
43
+
44
44
+
const localDeck: LocalDeck = {
45
45
+
id: deck.id || generateLocalId(),
46
46
+
ownerDid: deck.ownerDid,
47
47
+
title: deck.title,
48
48
+
description: deck.description,
49
49
+
tags: deck.tags,
50
50
+
visibility: deck.visibility,
51
51
+
publishedAt: deck.publishedAt,
52
52
+
forkOf: deck.forkOf,
53
53
+
syncStatus: existing ? "pending_push" : "local_only",
54
54
+
localVersion: existing ? existing.localVersion + 1 : 1,
55
55
+
pdsCid: existing?.pdsCid,
56
56
+
pdsUri: existing?.pdsUri,
57
57
+
updatedAt: now,
58
58
+
};
59
59
+
60
60
+
await db.decks.put(localDeck);
61
61
+
62
62
+
if (isOnline()) {
63
63
+
await queueForSync("deck", localDeck.id, "push");
64
64
+
}
65
65
+
66
66
+
await refreshCounts();
67
67
+
return localDeck;
68
68
+
}
69
69
+
70
70
+
async function saveNoteLocally(
71
71
+
note: Omit<LocalNote, "id" | "syncStatus" | "localVersion" | "updatedAt"> & { id?: string },
72
72
+
): Promise<LocalNote> {
73
73
+
const now = new Date().toISOString();
74
74
+
const existing = note.id ? await db.notes.get(note.id) : null;
75
75
+
76
76
+
const localNote: LocalNote = {
77
77
+
id: note.id || generateLocalId(),
78
78
+
ownerDid: note.ownerDid,
79
79
+
title: note.title,
80
80
+
body: note.body,
81
81
+
tags: note.tags,
82
82
+
visibility: note.visibility,
83
83
+
publishedAt: note.publishedAt,
84
84
+
links: note.links,
85
85
+
syncStatus: existing ? "pending_push" : "local_only",
86
86
+
localVersion: existing ? existing.localVersion + 1 : 1,
87
87
+
pdsCid: existing?.pdsCid,
88
88
+
pdsUri: existing?.pdsUri,
89
89
+
updatedAt: now,
90
90
+
};
91
91
+
92
92
+
await db.notes.put(localNote);
93
93
+
94
94
+
if (isOnline()) {
95
95
+
await queueForSync("note", localNote.id, "push");
96
96
+
}
97
97
+
98
98
+
await refreshCounts();
99
99
+
return localNote;
100
100
+
}
101
101
+
102
102
+
async function queueForSync(entityType: "deck" | "card" | "note", entityId: string, operation: "push" | "delete") {
103
103
+
const existing = await db.syncQueue.where({ entityType, entityId, operation }).first();
104
104
+
105
105
+
if (!existing) {
106
106
+
await db.syncQueue.add({ entityType, entityId, operation, createdAt: new Date().toISOString(), retryCount: 0 });
107
107
+
}
108
108
+
109
109
+
await refreshCounts();
110
110
+
}
111
111
+
112
112
+
async function processQueue() {
113
113
+
if (!isOnline() || !authStore.isAuthenticated()) {
114
114
+
return;
115
115
+
}
116
116
+
117
117
+
const items = await db.syncQueue.orderBy("createdAt").toArray();
118
118
+
if (items.length === 0) return;
119
119
+
120
120
+
setSyncState("syncing");
121
121
+
122
122
+
for (const item of items) {
123
123
+
try {
124
124
+
if (item.operation === "push") {
125
125
+
let response: Response;
126
126
+
if (item.entityType === "deck") {
127
127
+
response = await api.pushDeck(item.entityId);
128
128
+
} else if (item.entityType === "note") {
129
129
+
response = await api.pushNote(item.entityId);
130
130
+
} else {
131
131
+
continue;
132
132
+
}
133
133
+
134
134
+
if (response.ok) {
135
135
+
const result = await response.json();
136
136
+
if (item.entityType === "deck") {
137
137
+
await db.decks.update(item.entityId, {
138
138
+
syncStatus: "synced" as SyncStatus,
139
139
+
pdsCid: result.pds_cid,
140
140
+
pdsUri: result.pds_uri,
141
141
+
});
142
142
+
} else if (item.entityType === "note") {
143
143
+
await db.notes.update(item.entityId, {
144
144
+
syncStatus: "synced" as SyncStatus,
145
145
+
pdsCid: result.pds_cid,
146
146
+
pdsUri: result.pds_uri,
147
147
+
});
148
148
+
}
149
149
+
await db.syncQueue.delete(item.id!);
150
150
+
} else if (response.status === 409) {
151
151
+
if (item.entityType === "deck") {
152
152
+
await db.decks.update(item.entityId, { syncStatus: "conflict" as SyncStatus });
153
153
+
} else if (item.entityType === "note") {
154
154
+
await db.notes.update(item.entityId, { syncStatus: "conflict" as SyncStatus });
155
155
+
}
156
156
+
await db.syncQueue.delete(item.id!);
157
157
+
} else {
158
158
+
await db.syncQueue.update(item.id!, {
159
159
+
retryCount: item.retryCount + 1,
160
160
+
lastError: `HTTP ${response.status}`,
161
161
+
});
162
162
+
}
163
163
+
}
164
164
+
} catch (error) {
165
165
+
console.error(`Sync failed for ${item.entityType}:${item.entityId}`, error);
166
166
+
await db.syncQueue.update(item.id!, {
167
167
+
retryCount: item.retryCount + 1,
168
168
+
lastError: error instanceof Error ? error.message : "Unknown error",
169
169
+
});
170
170
+
}
171
171
+
}
172
172
+
173
173
+
setLastSyncedAt(new Date().toISOString());
174
174
+
setSyncState(isOnline() ? "idle" : "offline");
175
175
+
await refreshCounts();
176
176
+
}
177
177
+
178
178
+
async function getLocalDecks(ownerDid: string): Promise<LocalDeck[]> {
179
179
+
return db.decks.where("ownerDid").equals(ownerDid).toArray();
180
180
+
}
181
181
+
async function getLocalNotes(ownerDid: string): Promise<LocalNote[]> {
182
182
+
return db.notes.where("ownerDid").equals(ownerDid).toArray();
183
183
+
}
184
184
+
185
185
+
async function getLocalDeck(id: string): Promise<LocalDeck | undefined> {
186
186
+
return db.decks.get(id);
187
187
+
}
188
188
+
async function getLocalNote(id: string): Promise<LocalNote | undefined> {
189
189
+
return db.notes.get(id);
190
190
+
}
191
191
+
192
192
+
async function clearAll() {
193
193
+
await db.decks.clear();
194
194
+
await db.cards.clear();
195
195
+
await db.notes.clear();
196
196
+
await db.syncQueue.clear();
197
197
+
await refreshCounts();
198
198
+
}
199
199
+
200
200
+
refreshCounts();
201
201
+
202
202
+
return {
203
203
+
syncState,
204
204
+
pendingCount,
205
205
+
conflictCount,
206
206
+
lastSyncedAt,
207
207
+
isOnline,
208
208
+
saveDeckLocally,
209
209
+
saveNoteLocally,
210
210
+
queueForSync,
211
211
+
processQueue,
212
212
+
refreshCounts,
213
213
+
getLocalDecks,
214
214
+
getLocalNotes,
215
215
+
getLocalDeck,
216
216
+
getLocalNote,
217
217
+
clearAll,
218
218
+
};
219
219
+
}
220
220
+
221
221
+
export const syncStore = createRoot(createSyncStore);
+203
web/src/lib/tests/db.test.ts
···
1
1
+
import "fake-indexeddb/auto";
2
2
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
+
import { db, generateLocalId, isLocalId, type LocalDeck, type LocalNote } from "../db";
4
4
+
5
5
+
describe("db", () => {
6
6
+
beforeEach(async () => {
7
7
+
await db.decks.clear();
8
8
+
await db.cards.clear();
9
9
+
await db.notes.clear();
10
10
+
await db.syncQueue.clear();
11
11
+
});
12
12
+
13
13
+
afterEach(async () => {
14
14
+
await db.decks.clear();
15
15
+
await db.cards.clear();
16
16
+
await db.notes.clear();
17
17
+
await db.syncQueue.clear();
18
18
+
});
19
19
+
20
20
+
describe("generateLocalId", () => {
21
21
+
it("should generate unique IDs with local_ prefix", () => {
22
22
+
const id1 = generateLocalId();
23
23
+
const id2 = generateLocalId();
24
24
+
25
25
+
expect(id1).toMatch(/^local_\d+_[a-z0-9]+$/);
26
26
+
expect(id2).toMatch(/^local_\d+_[a-z0-9]+$/);
27
27
+
expect(id1).not.toBe(id2);
28
28
+
});
29
29
+
});
30
30
+
31
31
+
describe("isLocalId", () => {
32
32
+
it("should return true for local IDs", () => {
33
33
+
expect(isLocalId("local_123_abc")).toBe(true);
34
34
+
expect(isLocalId("local_")).toBe(true);
35
35
+
});
36
36
+
37
37
+
it("should return false for server IDs", () => {
38
38
+
expect(isLocalId("deck-123")).toBe(false);
39
39
+
expect(isLocalId("uuid-abc-def")).toBe(false);
40
40
+
});
41
41
+
});
42
42
+
43
43
+
describe("decks table", () => {
44
44
+
it("should insert and retrieve decks", async () => {
45
45
+
const deck: LocalDeck = {
46
46
+
id: "deck-1",
47
47
+
ownerDid: "did:plc:test",
48
48
+
title: "Test Deck",
49
49
+
description: "A test deck",
50
50
+
tags: ["test"],
51
51
+
visibility: { type: "Private" },
52
52
+
syncStatus: "local_only",
53
53
+
localVersion: 1,
54
54
+
updatedAt: new Date().toISOString(),
55
55
+
};
56
56
+
57
57
+
await db.decks.put(deck);
58
58
+
const retrieved = await db.decks.get("deck-1");
59
59
+
60
60
+
expect(retrieved).toBeDefined();
61
61
+
expect(retrieved?.title).toBe("Test Deck");
62
62
+
expect(retrieved?.syncStatus).toBe("local_only");
63
63
+
});
64
64
+
65
65
+
it("should query decks by ownerDid", async () => {
66
66
+
await db.decks.bulkPut([{
67
67
+
id: "deck-1",
68
68
+
ownerDid: "did:alice",
69
69
+
title: "Alice Deck 1",
70
70
+
description: "",
71
71
+
tags: [],
72
72
+
visibility: { type: "Private" },
73
73
+
syncStatus: "synced",
74
74
+
localVersion: 1,
75
75
+
updatedAt: new Date().toISOString(),
76
76
+
}, {
77
77
+
id: "deck-2",
78
78
+
ownerDid: "did:alice",
79
79
+
title: "Alice Deck 2",
80
80
+
description: "",
81
81
+
tags: [],
82
82
+
visibility: { type: "Public" },
83
83
+
syncStatus: "pending_push",
84
84
+
localVersion: 2,
85
85
+
updatedAt: new Date().toISOString(),
86
86
+
}, {
87
87
+
id: "deck-3",
88
88
+
ownerDid: "did:bob",
89
89
+
title: "Bob Deck",
90
90
+
description: "",
91
91
+
tags: [],
92
92
+
visibility: { type: "Private" },
93
93
+
syncStatus: "local_only",
94
94
+
localVersion: 1,
95
95
+
updatedAt: new Date().toISOString(),
96
96
+
}]);
97
97
+
98
98
+
const aliceDecks = await db.decks.where("ownerDid").equals("did:alice").toArray();
99
99
+
expect(aliceDecks).toHaveLength(2);
100
100
+
expect(aliceDecks.map((d) => d.title)).toContain("Alice Deck 1");
101
101
+
expect(aliceDecks.map((d) => d.title)).toContain("Alice Deck 2");
102
102
+
});
103
103
+
104
104
+
it("should query decks by syncStatus", async () => {
105
105
+
await db.decks.bulkPut([{
106
106
+
id: "deck-1",
107
107
+
ownerDid: "did:test",
108
108
+
title: "Synced",
109
109
+
description: "",
110
110
+
tags: [],
111
111
+
visibility: { type: "Private" },
112
112
+
syncStatus: "synced",
113
113
+
localVersion: 1,
114
114
+
updatedAt: new Date().toISOString(),
115
115
+
}, {
116
116
+
id: "deck-2",
117
117
+
ownerDid: "did:test",
118
118
+
title: "Conflict",
119
119
+
description: "",
120
120
+
tags: [],
121
121
+
visibility: { type: "Private" },
122
122
+
syncStatus: "conflict",
123
123
+
localVersion: 1,
124
124
+
updatedAt: new Date().toISOString(),
125
125
+
}]);
126
126
+
127
127
+
const conflicts = await db.decks.where("syncStatus").equals("conflict").toArray();
128
128
+
expect(conflicts).toHaveLength(1);
129
129
+
expect(conflicts[0].title).toBe("Conflict");
130
130
+
});
131
131
+
});
132
132
+
133
133
+
describe("notes table", () => {
134
134
+
it("should insert and retrieve notes", async () => {
135
135
+
const note: LocalNote = {
136
136
+
id: "note-1",
137
137
+
ownerDid: "did:plc:test",
138
138
+
title: "Test Note",
139
139
+
body: "This is a test note",
140
140
+
tags: ["test"],
141
141
+
visibility: { type: "Private" },
142
142
+
links: [],
143
143
+
syncStatus: "local_only",
144
144
+
localVersion: 1,
145
145
+
updatedAt: new Date().toISOString(),
146
146
+
};
147
147
+
148
148
+
await db.notes.put(note);
149
149
+
const retrieved = await db.notes.get("note-1");
150
150
+
151
151
+
expect(retrieved).toBeDefined();
152
152
+
expect(retrieved?.title).toBe("Test Note");
153
153
+
expect(retrieved?.body).toBe("This is a test note");
154
154
+
});
155
155
+
});
156
156
+
157
157
+
describe("syncQueue table", () => {
158
158
+
it("should auto-increment IDs", async () => {
159
159
+
const id1 = await db.syncQueue.add({
160
160
+
entityType: "deck",
161
161
+
entityId: "deck-1",
162
162
+
operation: "push",
163
163
+
createdAt: new Date().toISOString(),
164
164
+
retryCount: 0,
165
165
+
});
166
166
+
167
167
+
const id2 = await db.syncQueue.add({
168
168
+
entityType: "note",
169
169
+
entityId: "note-1",
170
170
+
operation: "push",
171
171
+
createdAt: new Date().toISOString(),
172
172
+
retryCount: 0,
173
173
+
});
174
174
+
175
175
+
expect(id2).toBeGreaterThan(id1!);
176
176
+
});
177
177
+
178
178
+
it("should count pending items", async () => {
179
179
+
await db.syncQueue.bulkAdd([{
180
180
+
entityType: "deck",
181
181
+
entityId: "deck-1",
182
182
+
operation: "push",
183
183
+
createdAt: new Date().toISOString(),
184
184
+
retryCount: 0,
185
185
+
}, {
186
186
+
entityType: "deck",
187
187
+
entityId: "deck-2",
188
188
+
operation: "push",
189
189
+
createdAt: new Date().toISOString(),
190
190
+
retryCount: 0,
191
191
+
}, {
192
192
+
entityType: "note",
193
193
+
entityId: "note-1",
194
194
+
operation: "push",
195
195
+
createdAt: new Date().toISOString(),
196
196
+
retryCount: 0,
197
197
+
}]);
198
198
+
199
199
+
const count = await db.syncQueue.count();
200
200
+
expect(count).toBe(3);
201
201
+
});
202
202
+
});
203
203
+
});
+218
web/src/lib/tests/sync-store.test.ts
···
1
1
+
import "fake-indexeddb/auto";
2
2
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
+
import { db, type LocalDeck } from "../db";
4
4
+
5
5
+
vi.mock(
6
6
+
"../api",
7
7
+
() => ({
8
8
+
api: {
9
9
+
pushDeck: vi.fn().mockResolvedValue({
10
10
+
ok: true,
11
11
+
json: async () => ({ pds_cid: "cid123", pds_uri: "at://test" }),
12
12
+
}),
13
13
+
pushNote: vi.fn().mockResolvedValue({
14
14
+
ok: true,
15
15
+
json: async () => ({ pds_cid: "cid456", pds_uri: "at://test" }),
16
16
+
}),
17
17
+
getSyncStatus: vi.fn().mockResolvedValue({
18
18
+
ok: true,
19
19
+
json: async () => ({ pending_count: 0, conflict_count: 0 }),
20
20
+
}),
21
21
+
resolveConflict: vi.fn().mockResolvedValue({ ok: true }),
22
22
+
},
23
23
+
}),
24
24
+
);
25
25
+
26
26
+
vi.mock("../store", () => ({ authStore: { isAuthenticated: () => true, accessJwt: () => "test-token" } }));
27
27
+
28
28
+
import { syncStore } from "../sync-store";
29
29
+
30
30
+
describe("syncStore", () => {
31
31
+
beforeEach(async () => {
32
32
+
await db.decks.clear();
33
33
+
await db.cards.clear();
34
34
+
await db.notes.clear();
35
35
+
await db.syncQueue.clear();
36
36
+
});
37
37
+
38
38
+
afterEach(async () => {
39
39
+
await db.decks.clear();
40
40
+
await db.cards.clear();
41
41
+
await db.notes.clear();
42
42
+
await db.syncQueue.clear();
43
43
+
});
44
44
+
45
45
+
describe("state signals", () => {
46
46
+
it("should have initial idle sync state", () => {
47
47
+
expect(["idle", "offline"]).toContain(syncStore.syncState());
48
48
+
});
49
49
+
50
50
+
it("should have online status signal", () => {
51
51
+
expect(typeof syncStore.isOnline()).toBe("boolean");
52
52
+
});
53
53
+
});
54
54
+
55
55
+
describe("saveDeckLocally", () => {
56
56
+
it("should save a new deck with local_only status", async () => {
57
57
+
const deck = await syncStore.saveDeckLocally({
58
58
+
ownerDid: "did:plc:test",
59
59
+
title: "New Deck",
60
60
+
description: "Test description",
61
61
+
tags: ["test"],
62
62
+
visibility: { type: "Private" },
63
63
+
});
64
64
+
65
65
+
expect(deck.id).toMatch(/^local_/);
66
66
+
expect(deck.syncStatus).toBe("local_only");
67
67
+
expect(deck.localVersion).toBe(1);
68
68
+
69
69
+
const stored = await db.decks.get(deck.id);
70
70
+
expect(stored?.title).toBe("New Deck");
71
71
+
});
72
72
+
73
73
+
it("should update existing deck with pending_push status", async () => {
74
74
+
const deck = await syncStore.saveDeckLocally({
75
75
+
ownerDid: "did:plc:test",
76
76
+
title: "Original Title",
77
77
+
description: "Test",
78
78
+
tags: [],
79
79
+
visibility: { type: "Private" },
80
80
+
});
81
81
+
82
82
+
const updated = await syncStore.saveDeckLocally({
83
83
+
id: deck.id,
84
84
+
ownerDid: "did:plc:test",
85
85
+
title: "Updated Title",
86
86
+
description: "Test",
87
87
+
tags: [],
88
88
+
visibility: { type: "Private" },
89
89
+
});
90
90
+
91
91
+
expect(updated.id).toBe(deck.id);
92
92
+
expect(updated.title).toBe("Updated Title");
93
93
+
expect(updated.syncStatus).toBe("pending_push");
94
94
+
expect(updated.localVersion).toBe(2);
95
95
+
});
96
96
+
});
97
97
+
98
98
+
describe("saveNoteLocally", () => {
99
99
+
it("should save a new note with local_only status", async () => {
100
100
+
const note = await syncStore.saveNoteLocally({
101
101
+
ownerDid: "did:plc:test",
102
102
+
title: "New Note",
103
103
+
body: "Note content",
104
104
+
tags: ["test"],
105
105
+
visibility: { type: "Private" },
106
106
+
links: [],
107
107
+
});
108
108
+
109
109
+
expect(note.id).toMatch(/^local_/);
110
110
+
expect(note.syncStatus).toBe("local_only");
111
111
+
expect(note.localVersion).toBe(1);
112
112
+
});
113
113
+
});
114
114
+
115
115
+
describe("getLocalDecks", () => {
116
116
+
it("should return decks for a specific owner", async () => {
117
117
+
await db.decks.bulkPut([
118
118
+
{
119
119
+
id: "deck-1",
120
120
+
ownerDid: "did:alice",
121
121
+
title: "Alice Deck",
122
122
+
description: "",
123
123
+
tags: [],
124
124
+
visibility: { type: "Private" },
125
125
+
syncStatus: "synced",
126
126
+
localVersion: 1,
127
127
+
updatedAt: new Date().toISOString(),
128
128
+
} satisfies LocalDeck,
129
129
+
{
130
130
+
id: "deck-2",
131
131
+
ownerDid: "did:bob",
132
132
+
title: "Bob Deck",
133
133
+
description: "",
134
134
+
tags: [],
135
135
+
visibility: { type: "Private" },
136
136
+
syncStatus: "synced",
137
137
+
localVersion: 1,
138
138
+
updatedAt: new Date().toISOString(),
139
139
+
} satisfies LocalDeck,
140
140
+
]);
141
141
+
142
142
+
const aliceDecks = await syncStore.getLocalDecks("did:alice");
143
143
+
expect(aliceDecks).toHaveLength(1);
144
144
+
expect(aliceDecks[0].title).toBe("Alice Deck");
145
145
+
});
146
146
+
});
147
147
+
148
148
+
describe("queueForSync", () => {
149
149
+
it("should add item to sync queue", async () => {
150
150
+
await syncStore.queueForSync("deck", "deck-123", "push");
151
151
+
152
152
+
const queue = await db.syncQueue.toArray();
153
153
+
expect(queue).toHaveLength(1);
154
154
+
expect(queue[0].entityType).toBe("deck");
155
155
+
expect(queue[0].entityId).toBe("deck-123");
156
156
+
expect(queue[0].operation).toBe("push");
157
157
+
});
158
158
+
159
159
+
it("should not duplicate queue entries", async () => {
160
160
+
await syncStore.queueForSync("deck", "deck-123", "push");
161
161
+
await syncStore.queueForSync("deck", "deck-123", "push");
162
162
+
163
163
+
const queue = await db.syncQueue.toArray();
164
164
+
expect(queue).toHaveLength(1);
165
165
+
});
166
166
+
});
167
167
+
168
168
+
describe("refreshCounts", () => {
169
169
+
it("should update pending and conflict counts", async () => {
170
170
+
await db.syncQueue.add({
171
171
+
entityType: "deck",
172
172
+
entityId: "deck-1",
173
173
+
operation: "push",
174
174
+
createdAt: new Date().toISOString(),
175
175
+
retryCount: 0,
176
176
+
});
177
177
+
178
178
+
await db.decks.put({
179
179
+
id: "deck-2",
180
180
+
ownerDid: "did:test",
181
181
+
title: "Conflict Deck",
182
182
+
description: "",
183
183
+
tags: [],
184
184
+
visibility: { type: "Private" },
185
185
+
syncStatus: "conflict",
186
186
+
localVersion: 1,
187
187
+
updatedAt: new Date().toISOString(),
188
188
+
});
189
189
+
190
190
+
await syncStore.refreshCounts();
191
191
+
192
192
+
expect(syncStore.pendingCount()).toBe(1);
193
193
+
expect(syncStore.conflictCount()).toBe(1);
194
194
+
});
195
195
+
});
196
196
+
197
197
+
describe("clearAll", () => {
198
198
+
it("should clear all local data", async () => {
199
199
+
await db.decks.put({
200
200
+
id: "deck-1",
201
201
+
ownerDid: "did:test",
202
202
+
title: "Test",
203
203
+
description: "",
204
204
+
tags: [],
205
205
+
visibility: { type: "Private" },
206
206
+
syncStatus: "synced",
207
207
+
localVersion: 1,
208
208
+
updatedAt: new Date().toISOString(),
209
209
+
});
210
210
+
211
211
+
await syncStore.clearAll();
212
212
+
213
213
+
expect(await db.decks.count()).toBe(0);
214
214
+
expect(await db.notes.count()).toBe(0);
215
215
+
expect(await db.syncQueue.count()).toBe(0);
216
216
+
});
217
217
+
});
218
218
+
});