aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 23:34:03 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 23:34:03 +0900
commit0c042ac89fc0822fcbe09c48702857faa5494ae1 (patch)
treeea1f1d180f747613343040d441a07f92b2760840
parentae5a0bb97fbf013417a6962f7e077f0408b2a951 (diff)
downloadkioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.gz
kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.tar.zst
kioku-0c042ac89fc0822fcbe09c48702857faa5494ae1.zip
feat(client): add sync status indicator component
Add SyncStatusIndicator component to display current sync state in the UI header. The component shows online/offline status, syncing progress, pending changes count, and sync errors. - Create SyncProvider context to wrap SyncManager for React components - Add SyncStatusIndicator component with visual status indicators - Integrate indicator into HomePage header - Add comprehensive tests for SyncStatusIndicator and SyncProvider - Update existing tests to include SyncProvider wrapper 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/App.test.tsx7
-rw-r--r--src/client/components/SyncStatusIndicator.test.tsx160
-rw-r--r--src/client/components/SyncStatusIndicator.tsx82
-rw-r--r--src/client/components/index.ts1
-rw-r--r--src/client/main.tsx6
-rw-r--r--src/client/pages/HomePage.test.tsx7
-rw-r--r--src/client/pages/HomePage.tsx10
-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
-rw-r--r--src/client/sync/conflict.test.ts3
-rw-r--r--src/client/sync/conflict.ts24
-rw-r--r--src/client/sync/index.ts69
-rw-r--r--src/client/sync/manager.test.ts23
-rw-r--r--src/client/sync/manager.ts3
-rw-r--r--src/client/sync/pull.test.ts10
-rw-r--r--src/client/sync/push.test.ts6
-rw-r--r--src/client/sync/push.ts4
-rw-r--r--src/client/sync/queue.test.ts8
-rw-r--r--src/client/sync/queue.ts71
-rw-r--r--src/server/repositories/sync.ts94
-rw-r--r--src/server/routes/sync.test.ts20
23 files changed, 957 insertions, 130 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 2a1e600..e775ae2 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -163,7 +163,7 @@ Smaller features first to enable early MVP validation.
- [x] Add tests
### Sync UI
-- [ ] Sync status indicator
+- [x] Sync status indicator
- [ ] Manual sync button
- [ ] Offline mode indicator
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index c11eb88..8359e67 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -1,13 +1,14 @@
/**
* @vitest-environment jsdom
*/
+import "fake-indexeddb/auto";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "./App";
import { apiClient } from "./api/client";
-import { AuthProvider } from "./stores";
+import { AuthProvider, SyncProvider } from "./stores";
vi.mock("./api/client", () => ({
apiClient: {
@@ -53,7 +54,9 @@ function renderWithRouter(path: string) {
return render(
<Router hook={hook}>
<AuthProvider>
- <App />
+ <SyncProvider>
+ <App />
+ </SyncProvider>
</AuthProvider>
</Router>,
);
diff --git a/src/client/components/SyncStatusIndicator.test.tsx b/src/client/components/SyncStatusIndicator.test.tsx
new file mode 100644
index 0000000..a607e11
--- /dev/null
+++ b/src/client/components/SyncStatusIndicator.test.tsx
@@ -0,0 +1,160 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SyncStatusIndicator } from "./SyncStatusIndicator";
+
+// Mock the useSync hook
+const mockUseSync = vi.fn();
+vi.mock("../stores", () => ({
+ useSync: () => mockUseSync(),
+}));
+
+// Mock the SyncStatus constant
+vi.mock("../sync", () => ({
+ SyncStatus: {
+ Idle: "idle",
+ Syncing: "syncing",
+ Error: "error",
+ },
+}));
+
+describe("SyncStatusIndicator", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("displays 'Synced' when online with no pending changes", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: false,
+ pendingCount: 0,
+ lastError: null,
+ status: "idle",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Synced")).toBeDefined();
+ expect(screen.getByTestId("sync-status-indicator")).toBeDefined();
+ });
+
+ it("displays 'Offline' when not online", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: false,
+ isSyncing: false,
+ pendingCount: 0,
+ lastError: null,
+ status: "idle",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Offline")).toBeDefined();
+ });
+
+ it("displays 'Syncing...' when syncing", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: true,
+ pendingCount: 0,
+ lastError: null,
+ status: "syncing",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Syncing...")).toBeDefined();
+ });
+
+ it("displays pending count when there are pending changes", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: false,
+ pendingCount: 5,
+ lastError: null,
+ status: "idle",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("5 pending")).toBeDefined();
+ });
+
+ it("displays 'Sync error' when there is an error", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: false,
+ pendingCount: 0,
+ lastError: "Network error",
+ status: "error",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Sync error")).toBeDefined();
+ });
+
+ it("shows error message in title when there is an error", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: false,
+ pendingCount: 0,
+ lastError: "Network error",
+ status: "error",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ const indicator = screen.getByTestId("sync-status-indicator");
+ expect(indicator.getAttribute("title")).toBe("Network error");
+ });
+
+ it("prioritizes offline status over other states", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: false,
+ isSyncing: true,
+ pendingCount: 5,
+ lastError: "Error",
+ status: "error",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Offline")).toBeDefined();
+ });
+
+ it("prioritizes syncing status over pending and error", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: true,
+ pendingCount: 5,
+ lastError: null,
+ status: "syncing",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Syncing...")).toBeDefined();
+ });
+
+ it("prioritizes error status over pending", () => {
+ mockUseSync.mockReturnValue({
+ isOnline: true,
+ isSyncing: false,
+ pendingCount: 5,
+ lastError: "Network error",
+ status: "error",
+ });
+
+ render(<SyncStatusIndicator />);
+
+ expect(screen.getByText("Sync error")).toBeDefined();
+ });
+});
diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx
new file mode 100644
index 0000000..23e3ec6
--- /dev/null
+++ b/src/client/components/SyncStatusIndicator.tsx
@@ -0,0 +1,82 @@
+import { useSync } from "../stores";
+import { SyncStatus } from "../sync";
+
+export function SyncStatusIndicator() {
+ const { isOnline, isSyncing, pendingCount, lastError, status } = useSync();
+
+ const getStatusText = (): string => {
+ if (!isOnline) {
+ return "Offline";
+ }
+ if (isSyncing) {
+ return "Syncing...";
+ }
+ if (status === SyncStatus.Error && lastError) {
+ return "Sync error";
+ }
+ if (pendingCount > 0) {
+ return `${pendingCount} pending`;
+ }
+ return "Synced";
+ };
+
+ const getStatusColor = (): string => {
+ if (!isOnline) {
+ return "#6c757d"; // gray
+ }
+ if (isSyncing) {
+ return "#007bff"; // blue
+ }
+ if (status === SyncStatus.Error) {
+ return "#dc3545"; // red
+ }
+ if (pendingCount > 0) {
+ return "#ffc107"; // yellow
+ }
+ return "#28a745"; // green
+ };
+
+ const getStatusIcon = (): string => {
+ if (!isOnline) {
+ return "\u25CB"; // hollow circle
+ }
+ if (isSyncing) {
+ return "\u21BB"; // rotating arrows
+ }
+ if (status === SyncStatus.Error) {
+ return "\u2717"; // cross mark
+ }
+ if (pendingCount > 0) {
+ return "\u25D4"; // partial circle
+ }
+ return "\u2713"; // check mark
+ };
+
+ return (
+ <div
+ data-testid="sync-status-indicator"
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "0.25rem",
+ padding: "0.25rem 0.5rem",
+ borderRadius: "4px",
+ backgroundColor: "#f8f9fa",
+ border: "1px solid #dee2e6",
+ fontSize: "0.875rem",
+ }}
+ title={lastError || undefined}
+ >
+ <span
+ style={{
+ color: getStatusColor(),
+ fontWeight: "bold",
+ }}
+ aria-hidden="true"
+ >
+ {getStatusIcon()}
+ </span>
+ <span style={{ color: getStatusColor() }}>{getStatusText()}</span>
+ </div>
+ );
+}
diff --git a/src/client/components/index.ts b/src/client/components/index.ts
index 9b97620..12a09a9 100644
--- a/src/client/components/index.ts
+++ b/src/client/components/index.ts
@@ -1 +1,2 @@
export { ProtectedRoute } from "./ProtectedRoute";
+export { SyncStatusIndicator } from "./SyncStatusIndicator";
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 5a12f68..bff0889 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
-import { AuthProvider } from "./stores";
+import { AuthProvider, SyncProvider } from "./stores";
const rootElement = document.getElementById("root");
if (!rootElement) {
@@ -11,7 +11,9 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
<AuthProvider>
- <App />
+ <SyncProvider>
+ <App />
+ </SyncProvider>
</AuthProvider>
</StrictMode>,
);
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index 4dd4a81..f75f88d 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -1,13 +1,14 @@
/**
* @vitest-environment jsdom
*/
+import "fake-indexeddb/auto";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { apiClient } from "../api/client";
-import { AuthProvider } from "../stores";
+import { AuthProvider, SyncProvider } from "../stores";
import { HomePage } from "./HomePage";
vi.mock("../api/client", () => ({
@@ -89,7 +90,9 @@ function renderWithProviders(path = "/") {
return render(
<Router hook={hook}>
<AuthProvider>
- <HomePage />
+ <SyncProvider>
+ <HomePage />
+ </SyncProvider>
</AuthProvider>
</Router>,
);
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index fb13422..debc935 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -4,6 +4,7 @@ import { ApiClientError, apiClient } from "../api";
import { CreateDeckModal } from "../components/CreateDeckModal";
import { DeleteDeckModal } from "../components/DeleteDeckModal";
import { EditDeckModal } from "../components/EditDeckModal";
+import { SyncStatusIndicator } from "../components/SyncStatusIndicator";
import { useAuth } from "../stores";
interface Deck {
@@ -70,9 +71,12 @@ export function HomePage() {
}}
>
<h1>Kioku</h1>
- <button type="button" onClick={logout}>
- Logout
- </button>
+ <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
+ <SyncStatusIndicator />
+ <button type="button" onClick={logout}>
+ Logout
+ </button>
+ </div>
</header>
<main>
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;
+}
diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts
index 7f86953..211f410 100644
--- a/src/client/sync/conflict.test.ts
+++ b/src/client/sync/conflict.test.ts
@@ -466,7 +466,8 @@ describe("ConflictResolver", () => {
expect(result.decks).toHaveLength(1);
expect(result.decks[0]?.resolution).toBe("server_wins");
- const insertedDeck = await localDeckRepository.findById("non-existent-deck");
+ const insertedDeck =
+ await localDeckRepository.findById("non-existent-deck");
expect(insertedDeck?.name).toBe("Server Deck");
});
diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts
index 365ef3c..4e0e3ef 100644
--- a/src/client/sync/conflict.ts
+++ b/src/client/sync/conflict.ts
@@ -1,8 +1,5 @@
import type { LocalCard, LocalDeck } from "../db/index";
-import {
- localCardRepository,
- localDeckRepository,
-} from "../db/repositories";
+import { localCardRepository, localDeckRepository } from "../db/repositories";
import type { ServerCard, ServerDeck, SyncPullResult } from "./pull";
import type { SyncPushResult } from "./push";
@@ -39,10 +36,7 @@ export interface ConflictResolverOptions {
* Compare timestamps for LWW resolution
* Returns true if server data is newer or equal
*/
-function isServerNewer(
- serverUpdatedAt: Date,
- localUpdatedAt: Date,
-): boolean {
+function isServerNewer(serverUpdatedAt: Date, localUpdatedAt: Date): boolean {
return serverUpdatedAt.getTime() >= localUpdatedAt.getTime();
}
@@ -222,7 +216,10 @@ export class ConflictResolver {
const serverDeck = pullResult.decks.find((d) => d.id === deckId);
if (localDeck && serverDeck) {
- const resolution = await this.resolveDeckConflict(localDeck, serverDeck);
+ const resolution = await this.resolveDeckConflict(
+ localDeck,
+ serverDeck,
+ );
result.decks.push(resolution);
} else if (serverDeck) {
// Local doesn't exist, apply server data
@@ -239,7 +236,10 @@ export class ConflictResolver {
const serverCard = pullResult.cards.find((c) => c.id === cardId);
if (localCard && serverCard) {
- const resolution = await this.resolveCardConflict(localCard, serverCard);
+ const resolution = await this.resolveCardConflict(
+ localCard,
+ serverCard,
+ );
result.cards.push(resolution);
} else if (serverCard) {
// Local doesn't exist, apply server data
@@ -266,4 +266,6 @@ export function createConflictResolver(
/**
* Default conflict resolver using LWW (server wins) strategy
*/
-export const conflictResolver = new ConflictResolver({ strategy: "server_wins" });
+export const conflictResolver = new ConflictResolver({
+ strategy: "server_wins",
+});
diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts
index a602753..c3ddab4 100644
--- a/src/client/sync/index.ts
+++ b/src/client/sync/index.ts
@@ -1,50 +1,47 @@
export {
- SyncQueue,
- SyncStatus,
- syncQueue,
- type PendingChanges,
- type SyncQueueListener,
- type SyncQueueState,
- type SyncStatusType,
-} from "./queue";
-
+ type ConflictResolutionItem,
+ type ConflictResolutionResult,
+ ConflictResolver,
+ type ConflictResolverOptions,
+ conflictResolver,
+ createConflictResolver,
+} from "./conflict";
export {
- createPushService,
- pendingChangesToPushData,
- PushService,
- type PushServiceOptions,
- type SyncCardData,
- type SyncDeckData,
- type SyncPushData,
- type SyncPushResult,
- type SyncReviewLogData,
-} from "./push";
+ createSyncManager,
+ SyncManager,
+ type SyncManagerEvent,
+ type SyncManagerListener,
+ type SyncManagerOptions,
+ type SyncResult,
+} from "./manager";
export {
createPullService,
- pullResultToLocalData,
PullService,
type PullServiceOptions,
+ pullResultToLocalData,
type ServerCard,
type ServerDeck,
type ServerReviewLog,
type SyncPullResult,
} from "./pull";
-
export {
- ConflictResolver,
- conflictResolver,
- createConflictResolver,
- type ConflictResolutionItem,
- type ConflictResolutionResult,
- type ConflictResolverOptions,
-} from "./conflict";
-
+ createPushService,
+ PushService,
+ type PushServiceOptions,
+ pendingChangesToPushData,
+ type SyncCardData,
+ type SyncDeckData,
+ type SyncPushData,
+ type SyncPushResult,
+ type SyncReviewLogData,
+} from "./push";
export {
- createSyncManager,
- SyncManager,
- type SyncManagerEvent,
- type SyncManagerListener,
- type SyncManagerOptions,
- type SyncResult,
-} from "./manager";
+ type PendingChanges,
+ SyncQueue,
+ type SyncQueueListener,
+ type SyncQueueState,
+ SyncStatus,
+ type SyncStatusType,
+ syncQueue,
+} from "./queue";
diff --git a/src/client/sync/manager.test.ts b/src/client/sync/manager.test.ts
index 1e53bd4..96fb97d 100644
--- a/src/client/sync/manager.test.ts
+++ b/src/client/sync/manager.test.ts
@@ -8,8 +8,8 @@ import {
describe,
expect,
it,
- vi,
type Mock,
+ vi,
} from "vitest";
import { db } from "../db/index";
import { localDeckRepository } from "../db/repositories";
@@ -42,9 +42,8 @@ describe("SyncManager", () => {
/**
* Create a pending deck in the database that will need to be synced
*/
- async function createPendingDeck(id = "deck-1") {
+ async function createPendingDeck() {
return localDeckRepository.create({
- id,
userId: "user-1",
name: "Test Deck",
description: null,
@@ -169,12 +168,10 @@ describe("SyncManager", () => {
manager.start();
// Should only be called once for each event type
- expect(
- addSpy.mock.calls.filter((c) => c[0] === "online").length,
- ).toBe(1);
- expect(
- addSpy.mock.calls.filter((c) => c[0] === "offline").length,
- ).toBe(1);
+ expect(addSpy.mock.calls.filter((c) => c[0] === "online").length).toBe(1);
+ expect(addSpy.mock.calls.filter((c) => c[0] === "offline").length).toBe(
+ 1,
+ );
manager.stop();
addSpy.mockRestore();
@@ -354,20 +351,20 @@ describe("SyncManager", () => {
it("should resolve conflicts when present", async () => {
// Create pending data so pushToServer will be called
- await createPendingDeck();
+ const deck = await createPendingDeck();
const pushResult: SyncPushResult = {
- decks: [{ id: "deck-1", syncVersion: 1 }],
+ decks: [{ id: deck.id, syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: ["deck-1"], cards: [] },
+ conflicts: { decks: [deck.id], cards: [] },
};
pushToServer.mockResolvedValue(pushResult);
const pullResult: SyncPullResult = {
decks: [
{
- id: "deck-1",
+ id: deck.id,
userId: "user-1",
name: "Server Deck",
description: null,
diff --git a/src/client/sync/manager.ts b/src/client/sync/manager.ts
index d24fda4..d935a3b 100644
--- a/src/client/sync/manager.ts
+++ b/src/client/sync/manager.ts
@@ -239,8 +239,7 @@ export class SyncManager {
pushResult,
pullResult,
);
- conflictsResolved =
- resolution.decks.length + resolution.cards.length;
+ conflictsResolved = resolution.decks.length + resolution.cards.length;
}
const result: SyncResult = {
diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts
index 1aaac84..23c64ef 100644
--- a/src/client/sync/pull.test.ts
+++ b/src/client/sync/pull.test.ts
@@ -5,7 +5,7 @@ import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CardState, db, Rating } from "../db/index";
import { localCardRepository, localDeckRepository } from "../db/repositories";
-import { pullResultToLocalData, PullService } from "./pull";
+import { PullService, pullResultToLocalData } from "./pull";
import { SyncQueue } from "./queue";
describe("pullResultToLocalData", () => {
@@ -68,7 +68,9 @@ describe("pullResultToLocalData", () => {
currentSyncVersion: 3,
});
- expect(result.decks[0]?.deletedAt).toEqual(new Date("2024-01-03T12:00:00Z"));
+ expect(result.decks[0]?.deletedAt).toEqual(
+ new Date("2024-01-03T12:00:00Z"),
+ );
});
it("should convert server cards to local format", () => {
@@ -410,7 +412,9 @@ describe("PullService", () => {
});
it("should throw error if pull fails", async () => {
- const pullFromServer = vi.fn().mockRejectedValue(new Error("Network error"));
+ const pullFromServer = vi
+ .fn()
+ .mockRejectedValue(new Error("Network error"));
const pullService = new PullService({
syncQueue,
diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts
index 79a9d4a..911a8d3 100644
--- a/src/client/sync/push.test.ts
+++ b/src/client/sync/push.test.ts
@@ -9,7 +9,7 @@ import {
localDeckRepository,
localReviewLogRepository,
} from "../db/repositories";
-import { pendingChangesToPushData, PushService } from "./push";
+import { PushService, pendingChangesToPushData } from "./push";
import { SyncQueue } from "./queue";
describe("pendingChangesToPushData", () => {
@@ -450,7 +450,9 @@ describe("PushService", () => {
newCardsPerDay: 20,
});
- const pushToServer = vi.fn().mockRejectedValue(new Error("Network error"));
+ const pushToServer = vi
+ .fn()
+ .mockRejectedValue(new Error("Network error"));
const pushService = new PushService({
syncQueue,
diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts
index 7702583..2493e4e 100644
--- a/src/client/sync/push.ts
+++ b/src/client/sync/push.ts
@@ -129,7 +129,9 @@ function reviewLogToSyncData(log: LocalReviewLog): SyncReviewLogData {
/**
* Convert pending changes to sync push data format
*/
-export function pendingChangesToPushData(changes: PendingChanges): SyncPushData {
+export function pendingChangesToPushData(
+ changes: PendingChanges,
+): SyncPushData {
return {
decks: changes.decks.map(deckToSyncData),
cards: changes.cards.map(cardToSyncData),
diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts
index d35ae32..f6a3019 100644
--- a/src/client/sync/queue.test.ts
+++ b/src/client/sync/queue.test.ts
@@ -230,13 +230,17 @@ describe("SyncQueue", () => {
const state = await syncQueue.getState();
expect(state.lastSyncAt).not.toBeNull();
- expect(state.lastSyncAt?.getTime()).toBeGreaterThanOrEqual(before.getTime());
+ expect(state.lastSyncAt?.getTime()).toBeGreaterThanOrEqual(
+ before.getTime(),
+ );
});
it("should persist state to localStorage", async () => {
await syncQueue.completeSync(10);
- const stored = JSON.parse(localStorage.getItem("kioku_sync_state") ?? "{}");
+ const stored = JSON.parse(
+ localStorage.getItem("kioku_sync_state") ?? "{}",
+ );
expect(stored.lastSyncVersion).toBe(10);
expect(stored.lastSyncAt).toBeDefined();
});
diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts
index f0b112a..01c62cc 100644
--- a/src/client/sync/queue.ts
+++ b/src/client/sync/queue.ts
@@ -1,4 +1,9 @@
-import { db, type LocalCard, type LocalDeck, type LocalReviewLog } from "../db/index";
+import {
+ db,
+ type LocalCard,
+ type LocalDeck,
+ type LocalReviewLog,
+} from "../db/index";
import {
localCardRepository,
localDeckRepository,
@@ -41,7 +46,10 @@ const SYNC_STATE_KEY = "kioku_sync_state";
/**
* Load sync state from localStorage
*/
-function loadSyncState(): Pick<SyncQueueState, "lastSyncVersion" | "lastSyncAt"> {
+function loadSyncState(): Pick<
+ SyncQueueState,
+ "lastSyncVersion" | "lastSyncAt"
+> {
const stored = localStorage.getItem(SYNC_STATE_KEY);
if (!stored) {
return { lastSyncVersion: 0, lastSyncAt: null };
@@ -137,7 +145,9 @@ export class SyncQueue {
*/
async getPendingCount(): Promise<number> {
const changes = await this.getPendingChanges();
- return changes.decks.length + changes.cards.length + changes.reviewLogs.length;
+ return (
+ changes.decks.length + changes.cards.length + changes.reviewLogs.length
+ );
}
/**
@@ -205,17 +215,24 @@ export class SyncQueue {
cards: { id: string; syncVersion: number }[];
reviewLogs: { id: string; syncVersion: number }[];
}): Promise<void> {
- await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => {
- for (const deck of results.decks) {
- await localDeckRepository.markSynced(deck.id, deck.syncVersion);
- }
- for (const card of results.cards) {
- await localCardRepository.markSynced(card.id, card.syncVersion);
- }
- for (const reviewLog of results.reviewLogs) {
- await localReviewLogRepository.markSynced(reviewLog.id, reviewLog.syncVersion);
- }
- });
+ await db.transaction(
+ "rw",
+ [db.decks, db.cards, db.reviewLogs],
+ async () => {
+ for (const deck of results.decks) {
+ await localDeckRepository.markSynced(deck.id, deck.syncVersion);
+ }
+ for (const card of results.cards) {
+ await localCardRepository.markSynced(card.id, card.syncVersion);
+ }
+ for (const reviewLog of results.reviewLogs) {
+ await localReviewLogRepository.markSynced(
+ reviewLog.id,
+ reviewLog.syncVersion,
+ );
+ }
+ },
+ );
await this.notifyListeners();
}
@@ -227,17 +244,21 @@ export class SyncQueue {
cards: LocalCard[];
reviewLogs: LocalReviewLog[];
}): Promise<void> {
- await db.transaction("rw", [db.decks, db.cards, db.reviewLogs], async () => {
- for (const deck of data.decks) {
- await localDeckRepository.upsertFromServer(deck);
- }
- for (const card of data.cards) {
- await localCardRepository.upsertFromServer(card);
- }
- for (const reviewLog of data.reviewLogs) {
- await localReviewLogRepository.upsertFromServer(reviewLog);
- }
- });
+ await db.transaction(
+ "rw",
+ [db.decks, db.cards, db.reviewLogs],
+ async () => {
+ for (const deck of data.decks) {
+ await localDeckRepository.upsertFromServer(deck);
+ }
+ for (const card of data.cards) {
+ await localCardRepository.upsertFromServer(card);
+ }
+ for (const reviewLog of data.reviewLogs) {
+ await localReviewLogRepository.upsertFromServer(reviewLog);
+ }
+ },
+ );
await this.notifyListeners();
}
diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts
index 87acdb4..a1b6648 100644
--- a/src/server/repositories/sync.ts
+++ b/src/server/repositories/sync.ts
@@ -79,7 +79,10 @@ export interface SyncRepository {
}
export const syncRepository: SyncRepository = {
- async pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult> {
+ async pushChanges(
+ userId: string,
+ data: SyncPushData,
+ ): Promise<SyncPushResult> {
const result: SyncPushResult = {
decks: [],
cards: [],
@@ -96,7 +99,11 @@ export const syncRepository: SyncRepository = {
// Check if deck exists
const existing = await db
- .select({ id: decks.id, updatedAt: decks.updatedAt, syncVersion: decks.syncVersion })
+ .select({
+ id: decks.id,
+ updatedAt: decks.updatedAt,
+ syncVersion: decks.syncVersion,
+ })
.from(decks)
.where(and(eq(decks.id, deckData.id), eq(decks.userId, userId)));
@@ -118,7 +125,10 @@ export const syncRepository: SyncRepository = {
.returning({ id: decks.id, syncVersion: decks.syncVersion });
if (inserted) {
- result.decks.push({ id: inserted.id, syncVersion: inserted.syncVersion });
+ result.decks.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
}
} else {
const serverDeck = existing[0];
@@ -132,19 +142,27 @@ export const syncRepository: SyncRepository = {
description: deckData.description,
newCardsPerDay: deckData.newCardsPerDay,
updatedAt: clientUpdatedAt,
- deletedAt: deckData.deletedAt ? new Date(deckData.deletedAt) : null,
+ deletedAt: deckData.deletedAt
+ ? new Date(deckData.deletedAt)
+ : null,
syncVersion: sql`${decks.syncVersion} + 1`,
})
.where(eq(decks.id, deckData.id))
.returning({ id: decks.id, syncVersion: decks.syncVersion });
if (updated) {
- result.decks.push({ id: updated.id, syncVersion: updated.syncVersion });
+ result.decks.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
}
} else if (serverDeck) {
// Server wins - mark as conflict
result.conflicts.decks.push(deckData.id);
- result.decks.push({ id: serverDeck.id, syncVersion: serverDeck.syncVersion });
+ result.decks.push({
+ id: serverDeck.id,
+ syncVersion: serverDeck.syncVersion,
+ });
}
}
}
@@ -166,7 +184,11 @@ export const syncRepository: SyncRepository = {
// Check if card exists
const existing = await db
- .select({ id: cards.id, updatedAt: cards.updatedAt, syncVersion: cards.syncVersion })
+ .select({
+ id: cards.id,
+ updatedAt: cards.updatedAt,
+ syncVersion: cards.syncVersion,
+ })
.from(cards)
.where(eq(cards.id, cardData.id));
@@ -187,7 +209,9 @@ export const syncRepository: SyncRepository = {
scheduledDays: cardData.scheduledDays,
reps: cardData.reps,
lapses: cardData.lapses,
- lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null,
+ lastReview: cardData.lastReview
+ ? new Date(cardData.lastReview)
+ : null,
createdAt: new Date(cardData.createdAt),
updatedAt: clientUpdatedAt,
deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null,
@@ -196,7 +220,10 @@ export const syncRepository: SyncRepository = {
.returning({ id: cards.id, syncVersion: cards.syncVersion });
if (inserted) {
- result.cards.push({ id: inserted.id, syncVersion: inserted.syncVersion });
+ result.cards.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
}
} else {
const serverCard = existing[0];
@@ -217,21 +244,31 @@ export const syncRepository: SyncRepository = {
scheduledDays: cardData.scheduledDays,
reps: cardData.reps,
lapses: cardData.lapses,
- lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null,
+ lastReview: cardData.lastReview
+ ? new Date(cardData.lastReview)
+ : null,
updatedAt: clientUpdatedAt,
- deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null,
+ deletedAt: cardData.deletedAt
+ ? new Date(cardData.deletedAt)
+ : null,
syncVersion: sql`${cards.syncVersion} + 1`,
})
.where(eq(cards.id, cardData.id))
.returning({ id: cards.id, syncVersion: cards.syncVersion });
if (updated) {
- result.cards.push({ id: updated.id, syncVersion: updated.syncVersion });
+ result.cards.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
}
} else if (serverCard) {
// Server wins - mark as conflict
result.conflicts.cards.push(cardData.id);
- result.cards.push({ id: serverCard.id, syncVersion: serverCard.syncVersion });
+ result.cards.push({
+ id: serverCard.id,
+ syncVersion: serverCard.syncVersion,
+ });
}
}
}
@@ -272,16 +309,25 @@ export const syncRepository: SyncRepository = {
durationMs: logData.durationMs,
syncVersion: 1,
})
- .returning({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion });
+ .returning({
+ id: reviewLogs.id,
+ syncVersion: reviewLogs.syncVersion,
+ });
if (inserted) {
- result.reviewLogs.push({ id: inserted.id, syncVersion: inserted.syncVersion });
+ result.reviewLogs.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
}
} else {
// Already exists, return current version
const existingLog = existing[0];
if (existingLog) {
- result.reviewLogs.push({ id: existingLog.id, syncVersion: existingLog.syncVersion });
+ result.reviewLogs.push({
+ id: existingLog.id,
+ syncVersion: existingLog.syncVersion,
+ });
}
}
}
@@ -289,14 +335,19 @@ export const syncRepository: SyncRepository = {
return result;
},
- async pullChanges(userId: string, query: SyncPullQuery): Promise<SyncPullResult> {
+ async pullChanges(
+ userId: string,
+ query: SyncPullQuery,
+ ): Promise<SyncPullResult> {
const { lastSyncVersion } = query;
// Get all decks with syncVersion > lastSyncVersion
const pulledDecks = await db
.select()
.from(decks)
- .where(and(eq(decks.userId, userId), gt(decks.syncVersion, lastSyncVersion)));
+ .where(
+ and(eq(decks.userId, userId), gt(decks.syncVersion, lastSyncVersion)),
+ );
// Get all cards from user's decks with syncVersion > lastSyncVersion
const userDeckIds = await db
@@ -321,7 +372,12 @@ export const syncRepository: SyncRepository = {
const pulledReviewLogs = await db
.select()
.from(reviewLogs)
- .where(and(eq(reviewLogs.userId, userId), gt(reviewLogs.syncVersion, lastSyncVersion)));
+ .where(
+ and(
+ eq(reviewLogs.userId, userId),
+ gt(reviewLogs.syncVersion, lastSyncVersion),
+ ),
+ );
// Calculate current max sync version across all entities
let currentSyncVersion = lastSyncVersion;
diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts
index 22efada..9deb5ac 100644
--- a/src/server/routes/sync.test.ts
+++ b/src/server/routes/sync.test.ts
@@ -196,7 +196,9 @@ describe("POST /api/sync/push", () => {
const mockResult: SyncPushResult = {
decks: [],
cards: [],
- reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }],
+ reviewLogs: [
+ { id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 },
+ ],
conflicts: { decks: [], cards: [] },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -217,7 +219,9 @@ describe("POST /api/sync/push", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as SyncPushResponse;
expect(body.reviewLogs).toHaveLength(1);
- expect(body.reviewLogs?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440002");
+ expect(body.reviewLogs?.[0]?.id).toBe(
+ "550e8400-e29b-41d4-a716-446655440002",
+ );
});
it("returns conflicts when server data is newer", async () => {
@@ -250,7 +254,9 @@ describe("POST /api/sync/push", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as SyncPushResponse;
- expect(body.conflicts?.decks).toContain("550e8400-e29b-41d4-a716-446655440003");
+ expect(body.conflicts?.decks).toContain(
+ "550e8400-e29b-41d4-a716-446655440003",
+ );
});
it("validates deck schema", async () => {
@@ -380,7 +386,9 @@ describe("POST /api/sync/push", () => {
const mockResult: SyncPushResult = {
decks: [{ id: "550e8400-e29b-41d4-a716-446655440004", syncVersion: 1 }],
cards: [{ id: "550e8400-e29b-41d4-a716-446655440005", syncVersion: 1 }],
- reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 }],
+ reviewLogs: [
+ { id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 },
+ ],
conflicts: { decks: [], cards: [] },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -649,7 +657,9 @@ describe("GET /api/sync/pull", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as SyncPullResponse;
expect(body.reviewLogs).toHaveLength(1);
- expect(body.reviewLogs?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440002");
+ expect(body.reviewLogs?.[0]?.id).toBe(
+ "550e8400-e29b-41d4-a716-446655440002",
+ );
expect(body.reviewLogs?.[0]?.rating).toBe(3);
expect(body.reviewLogs?.[0]?.durationMs).toBe(5000);
});