aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/stores
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/stores')
-rw-r--r--src/client/stores/index.ts8
-rw-r--r--src/client/stores/sync.test.tsx209
-rw-r--r--src/client/stores/sync.tsx260
3 files changed, 477 insertions, 0 deletions
diff --git a/src/client/stores/index.ts b/src/client/stores/index.ts
index 7117bd6..c7f6241 100644
--- a/src/client/stores/index.ts
+++ b/src/client/stores/index.ts
@@ -5,3 +5,11 @@ export type {
AuthState,
} from "./auth";
export { ApiClientError, AuthProvider, useAuth } from "./auth";
+
+export type {
+ SyncActions,
+ SyncContextValue,
+ SyncProviderProps,
+ SyncState,
+} from "./sync";
+export { SyncProvider, useSync } from "./sync";
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx
new file mode 100644
index 0000000..9c4e5c2
--- /dev/null
+++ b/src/client/stores/sync.test.tsx
@@ -0,0 +1,209 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { db } from "../db/index";
+import { SyncProvider, useSync } from "./sync";
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+// Mock apiClient
+vi.mock("../api/client", () => ({
+ apiClient: {
+ getAuthHeader: vi.fn(() => ({ Authorization: "Bearer token" })),
+ },
+}));
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ <SyncProvider>{children}</SyncProvider>
+);
+
+describe("useSync", () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+
+ // Default mock for fetch
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ currentSyncVersion: 0,
+ }),
+ });
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ localStorage.clear();
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ });
+
+ it("throws error when used outside SyncProvider", () => {
+ // Suppress console.error for this test
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => useSync());
+ }).toThrow("useSync must be used within a SyncProvider");
+
+ consoleSpy.mockRestore();
+ });
+
+ it("returns initial state", async () => {
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isOnline).toBe(true);
+ expect(result.current.isSyncing).toBe(false);
+ expect(result.current.pendingCount).toBe(0);
+ expect(result.current.lastSyncAt).toBeNull();
+ expect(result.current.lastError).toBeNull();
+ expect(result.current.status).toBe("idle");
+ });
+ });
+
+ it("provides sync function", async () => {
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(typeof result.current.sync).toBe("function");
+ });
+ });
+
+ it("updates isSyncing during sync", async () => {
+ // Make the sync take some time
+ mockFetch.mockImplementation(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ ok: true,
+ json: async () => ({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ currentSyncVersion: 0,
+ }),
+ }),
+ 50,
+ ),
+ ),
+ );
+
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSyncing).toBe(false);
+ });
+
+ // Start sync
+ let syncPromise: Promise<unknown>;
+ act(() => {
+ syncPromise = result.current.sync();
+ });
+
+ // Check that isSyncing becomes true
+ await waitFor(() => {
+ expect(result.current.isSyncing).toBe(true);
+ });
+
+ // Wait for sync to complete
+ await act(async () => {
+ await syncPromise;
+ });
+
+ expect(result.current.isSyncing).toBe(false);
+ });
+
+ it("updates lastSyncAt after successful sync", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ currentSyncVersion: 1,
+ }),
+ });
+
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.lastSyncAt).toBeNull();
+ });
+
+ await act(async () => {
+ await result.current.sync();
+ });
+
+ await waitFor(() => {
+ expect(result.current.lastSyncAt).not.toBeNull();
+ });
+ });
+
+ it("updates lastError on sync failure", async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Server error" }),
+ });
+
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.lastError).toBeNull();
+ });
+
+ await act(async () => {
+ await result.current.sync();
+ });
+
+ await waitFor(() => {
+ expect(result.current.lastError).toBe("Server error");
+ expect(result.current.status).toBe("error");
+ });
+ });
+
+ it("responds to online/offline events", async () => {
+ const { result } = renderHook(() => useSync(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isOnline).toBe(true);
+ });
+
+ // Simulate going offline
+ act(() => {
+ window.dispatchEvent(new Event("offline"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.isOnline).toBe(false);
+ });
+
+ // Simulate going online
+ act(() => {
+ window.dispatchEvent(new Event("online"));
+ });
+
+ await waitFor(() => {
+ expect(result.current.isOnline).toBe(true);
+ });
+ });
+});
diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx
new file mode 100644
index 0000000..29c6c4f
--- /dev/null
+++ b/src/client/stores/sync.tsx
@@ -0,0 +1,260 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { apiClient } from "../api/client";
+import {
+ conflictResolver,
+ createPullService,
+ createPushService,
+ createSyncManager,
+ type SyncManagerEvent,
+ type SyncQueueState,
+ type SyncResult,
+ SyncStatus,
+ syncQueue,
+} from "../sync";
+import type {
+ ServerCard,
+ ServerDeck,
+ ServerReviewLog,
+ SyncPullResult,
+} from "../sync/pull";
+import type { SyncPushData, SyncPushResult } from "../sync/push";
+
+export interface SyncState {
+ isOnline: boolean;
+ isSyncing: boolean;
+ pendingCount: number;
+ lastSyncAt: Date | null;
+ lastError: string | null;
+ status: SyncQueueState["status"];
+}
+
+export interface SyncActions {
+ sync: () => Promise<SyncResult>;
+}
+
+export type SyncContextValue = SyncState & SyncActions;
+
+const SyncContext = createContext<SyncContextValue | null>(null);
+
+export interface SyncProviderProps {
+ children: ReactNode;
+}
+
+interface PullResponse {
+ decks: Array<
+ Omit<ServerDeck, "createdAt" | "updatedAt" | "deletedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+ }
+ >;
+ cards: Array<
+ Omit<
+ ServerCard,
+ "due" | "lastReview" | "createdAt" | "updatedAt" | "deletedAt"
+ > & {
+ due: string;
+ lastReview: string | null;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+ }
+ >;
+ reviewLogs: Array<
+ Omit<ServerReviewLog, "reviewedAt"> & {
+ reviewedAt: string;
+ }
+ >;
+ currentSyncVersion: number;
+}
+
+async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new Error("Not authenticated");
+ }
+
+ const res = await fetch("/api/sync/push", {
+ method: "POST",
+ headers: {
+ ...authHeader,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!res.ok) {
+ const errorBody = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ throw new Error(errorBody.error || `Push failed with status ${res.status}`);
+ }
+
+ return res.json() as Promise<SyncPushResult>;
+}
+
+async function pullFromServer(
+ lastSyncVersion: number,
+): Promise<SyncPullResult> {
+ const authHeader = apiClient.getAuthHeader();
+ if (!authHeader) {
+ throw new Error("Not authenticated");
+ }
+
+ const res = await fetch(`/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, {
+ headers: authHeader,
+ });
+
+ if (!res.ok) {
+ const errorBody = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ throw new Error(errorBody.error || `Pull failed with status ${res.status}`);
+ }
+
+ const data = (await res.json()) as PullResponse;
+
+ return {
+ decks: data.decks.map((d) => ({
+ ...d,
+ createdAt: new Date(d.createdAt),
+ updatedAt: new Date(d.updatedAt),
+ deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
+ })),
+ cards: data.cards.map((c) => ({
+ ...c,
+ due: new Date(c.due),
+ lastReview: c.lastReview ? new Date(c.lastReview) : null,
+ createdAt: new Date(c.createdAt),
+ updatedAt: new Date(c.updatedAt),
+ deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
+ })),
+ reviewLogs: data.reviewLogs.map((r) => ({
+ ...r,
+ reviewedAt: new Date(r.reviewedAt),
+ })),
+ currentSyncVersion: data.currentSyncVersion,
+ };
+}
+
+const pushService = createPushService({
+ syncQueue,
+ pushToServer,
+});
+
+const pullService = createPullService({
+ syncQueue,
+ pullFromServer,
+});
+
+const syncManager = createSyncManager({
+ syncQueue,
+ pushService,
+ pullService,
+ conflictResolver,
+});
+
+export function SyncProvider({ children }: SyncProviderProps) {
+ const [isOnline, setIsOnline] = useState(
+ typeof navigator !== "undefined" ? navigator.onLine : true,
+ );
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [pendingCount, setPendingCount] = useState(0);
+ const [lastSyncAt, setLastSyncAt] = useState<Date | null>(null);
+ const [lastError, setLastError] = useState<string | null>(null);
+ const [status, setStatus] = useState<SyncQueueState["status"]>(
+ SyncStatus.Idle,
+ );
+
+ useEffect(() => {
+ syncManager.start();
+
+ const unsubscribeManager = syncManager.subscribe(
+ (event: SyncManagerEvent) => {
+ switch (event.type) {
+ case "online":
+ setIsOnline(true);
+ break;
+ case "offline":
+ setIsOnline(false);
+ break;
+ case "sync_start":
+ setIsSyncing(true);
+ setLastError(null);
+ setStatus(SyncStatus.Syncing);
+ break;
+ case "sync_complete":
+ setIsSyncing(false);
+ setLastSyncAt(new Date());
+ setStatus(SyncStatus.Idle);
+ break;
+ case "sync_error":
+ setIsSyncing(false);
+ setLastError(event.error);
+ setStatus(SyncStatus.Error);
+ break;
+ }
+ },
+ );
+
+ const unsubscribeQueue = syncQueue.subscribe((state: SyncQueueState) => {
+ setPendingCount(state.pendingCount);
+ if (state.lastSyncAt) {
+ setLastSyncAt(state.lastSyncAt);
+ }
+ if (state.lastError) {
+ setLastError(state.lastError);
+ }
+ setStatus(state.status);
+ });
+
+ // Initialize state from queue
+ syncQueue.getState().then((state) => {
+ setPendingCount(state.pendingCount);
+ setLastSyncAt(state.lastSyncAt);
+ setLastError(state.lastError);
+ setStatus(state.status);
+ });
+
+ return () => {
+ unsubscribeManager();
+ unsubscribeQueue();
+ syncManager.stop();
+ };
+ }, []);
+
+ const sync = useCallback(async () => {
+ return syncManager.sync();
+ }, []);
+
+ const value = useMemo<SyncContextValue>(
+ () => ({
+ isOnline,
+ isSyncing,
+ pendingCount,
+ lastSyncAt,
+ lastError,
+ status,
+ sync,
+ }),
+ [isOnline, isSyncing, pendingCount, lastSyncAt, lastError, status, sync],
+ );
+
+ return <SyncContext.Provider value={value}>{children}</SyncContext.Provider>;
+}
+
+export function useSync(): SyncContextValue {
+ const context = useContext(SyncContext);
+ if (!context) {
+ throw new Error("useSync must be used within a SyncProvider");
+ }
+ return context;
+}