aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:16:48 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:16:48 +0900
commitfe101104cdd50256d4ef5c61e1bf099ed2da68e3 (patch)
tree862d84bd685dcbea6fe1bb2fc02f1cad33049196
parentc086c8b35b6c6f0b0e2623e9b6421713a540941a (diff)
downloadkioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.tar.gz
kioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.tar.zst
kioku-fe101104cdd50256d4ef5c61e1bf099ed2da68e3.zip
feat(server): add POST /api/sync/push endpoint
Implement sync push endpoint with Last-Write-Wins conflict resolution. Includes Zod validation for decks, cards, and review logs. 🤖 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/pages/StudyPage.tsx29
-rw-r--r--src/server/index.ts5
-rw-r--r--src/server/repositories/index.ts1
-rw-r--r--src/server/repositories/sync.ts279
-rw-r--r--src/server/routes/index.ts1
-rw-r--r--src/server/routes/sync.test.ts441
-rw-r--r--src/server/routes/sync.ts78
8 files changed, 821 insertions, 15 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index e98d72f..453bd25 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -153,7 +153,7 @@ Smaller features first to enable early MVP validation.
- [x] Add tests
### Sync Engine
-- [ ] POST /api/sync/push endpoint
+- [x] POST /api/sync/push endpoint
- [ ] GET /api/sync/pull endpoint
- [ ] Client: Sync queue management
- [ ] Client: Push implementation
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index 05e9943..c6f8665 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -144,17 +144,14 @@ export function StudyPage() {
throw new ApiClientError("Not authenticated", 401);
}
- const res = await fetch(
- `/api/decks/${deckId}/study/${currentCard.id}`,
- {
- method: "POST",
- headers: {
- ...authHeader,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ rating, durationMs }),
+ const res = await fetch(`/api/decks/${deckId}/study/${currentCard.id}`, {
+ method: "POST",
+ headers: {
+ ...authHeader,
+ "Content-Type": "application/json",
},
- );
+ body: JSON.stringify({ rating, durationMs }),
+ });
if (!res.ok) {
const errorBody = await res.json().catch(() => ({}));
@@ -312,7 +309,13 @@ export function StudyPage() {
<strong data-testid="completed-count">{completedCount}</strong>{" "}
card{completedCount !== 1 ? "s" : ""}.
</p>
- <div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}>
+ <div
+ style={{
+ display: "flex",
+ gap: "1rem",
+ justifyContent: "center",
+ }}
+ >
<Link href={`/decks/${deckId}`}>
<button type="button">Back to Deck</button>
</Link>
@@ -336,7 +339,9 @@ export function StudyPage() {
}}
role="button"
tabIndex={0}
- aria-label={isFlipped ? "Card showing answer" : "Click to reveal answer"}
+ aria-label={
+ isFlipped ? "Card showing answer" : "Click to reveal answer"
+ }
style={{
border: "1px solid #ccc",
borderRadius: "8px",
diff --git a/src/server/index.ts b/src/server/index.ts
index 0391119..a2a3a77 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { errorHandler } from "./middleware/index.js";
-import { auth, cards, decks, study } from "./routes/index.js";
+import { auth, cards, decks, study, sync } from "./routes/index.js";
const app = new Hono();
@@ -20,7 +20,8 @@ const routes = app
.route("/api/auth", auth)
.route("/api/decks", decks)
.route("/api/decks/:deckId/cards", cards)
- .route("/api/decks/:deckId/study", study);
+ .route("/api/decks/:deckId/study", study)
+ .route("/api/sync", sync);
export type AppType = typeof routes;
diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts
index e909a9b..7770d60 100644
--- a/src/server/repositories/index.ts
+++ b/src/server/repositories/index.ts
@@ -2,5 +2,6 @@ export { cardRepository } from "./card.js";
export { deckRepository } from "./deck.js";
export { refreshTokenRepository } from "./refresh-token.js";
export { reviewLogRepository } from "./review-log.js";
+export { syncRepository } from "./sync.js";
export * from "./types.js";
export { userRepository } from "./user.js";
diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts
new file mode 100644
index 0000000..3051121
--- /dev/null
+++ b/src/server/repositories/sync.ts
@@ -0,0 +1,279 @@
+import { and, eq, gt, sql } from "drizzle-orm";
+import { db } from "../db/index.js";
+import { cards, decks, reviewLogs } from "../db/schema.js";
+import type { Card, Deck, ReviewLog } from "./types.js";
+
+/**
+ * Sync data types for push/pull operations
+ */
+export interface SyncPushData {
+ decks: SyncDeckData[];
+ cards: SyncCardData[];
+ reviewLogs: SyncReviewLogData[];
+}
+
+export interface SyncDeckData {
+ id: string;
+ name: string;
+ description: string | null;
+ newCardsPerDay: number;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncCardData {
+ id: string;
+ deckId: string;
+ front: string;
+ back: string;
+ state: number;
+ due: string;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: string | null;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncReviewLogData {
+ id: string;
+ cardId: string;
+ rating: number;
+ state: number;
+ scheduledDays: number;
+ elapsedDays: number;
+ reviewedAt: string;
+ durationMs: number | null;
+}
+
+export interface SyncPushResult {
+ decks: { id: string; syncVersion: number }[];
+ cards: { id: string; syncVersion: number }[];
+ reviewLogs: { id: string; syncVersion: number }[];
+ conflicts: {
+ decks: string[];
+ cards: string[];
+ };
+}
+
+export interface SyncRepository {
+ pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult>;
+}
+
+export const syncRepository: SyncRepository = {
+ async pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult> {
+ const result: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ },
+ };
+
+ // Process decks with Last-Write-Wins conflict resolution
+ for (const deckData of data.decks) {
+ const clientUpdatedAt = new Date(deckData.updatedAt);
+
+ // Check if deck exists
+ const existing = await db
+ .select({ id: decks.id, updatedAt: decks.updatedAt, syncVersion: decks.syncVersion })
+ .from(decks)
+ .where(and(eq(decks.id, deckData.id), eq(decks.userId, userId)));
+
+ if (existing.length === 0) {
+ // New deck - insert
+ const [inserted] = await db
+ .insert(decks)
+ .values({
+ id: deckData.id,
+ userId,
+ name: deckData.name,
+ description: deckData.description,
+ newCardsPerDay: deckData.newCardsPerDay,
+ createdAt: new Date(deckData.createdAt),
+ updatedAt: clientUpdatedAt,
+ deletedAt: deckData.deletedAt ? new Date(deckData.deletedAt) : null,
+ syncVersion: 1,
+ })
+ .returning({ id: decks.id, syncVersion: decks.syncVersion });
+
+ if (inserted) {
+ result.decks.push({ id: inserted.id, syncVersion: inserted.syncVersion });
+ }
+ } else {
+ const serverDeck = existing[0];
+ // Last-Write-Wins: compare timestamps
+ if (serverDeck && clientUpdatedAt > serverDeck.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(decks)
+ .set({
+ name: deckData.name,
+ description: deckData.description,
+ newCardsPerDay: deckData.newCardsPerDay,
+ updatedAt: clientUpdatedAt,
+ 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 });
+ }
+ } else if (serverDeck) {
+ // Server wins - mark as conflict
+ result.conflicts.decks.push(deckData.id);
+ result.decks.push({ id: serverDeck.id, syncVersion: serverDeck.syncVersion });
+ }
+ }
+ }
+
+ // Process cards with Last-Write-Wins conflict resolution
+ for (const cardData of data.cards) {
+ const clientUpdatedAt = new Date(cardData.updatedAt);
+
+ // Verify deck belongs to user
+ const deckCheck = await db
+ .select({ id: decks.id })
+ .from(decks)
+ .where(and(eq(decks.id, cardData.deckId), eq(decks.userId, userId)));
+
+ if (deckCheck.length === 0) {
+ // Deck doesn't belong to user, skip
+ continue;
+ }
+
+ // Check if card exists
+ const existing = await db
+ .select({ id: cards.id, updatedAt: cards.updatedAt, syncVersion: cards.syncVersion })
+ .from(cards)
+ .where(eq(cards.id, cardData.id));
+
+ if (existing.length === 0) {
+ // New card - insert
+ const [inserted] = await db
+ .insert(cards)
+ .values({
+ id: cardData.id,
+ deckId: cardData.deckId,
+ front: cardData.front,
+ back: cardData.back,
+ state: cardData.state,
+ due: new Date(cardData.due),
+ stability: cardData.stability,
+ difficulty: cardData.difficulty,
+ elapsedDays: cardData.elapsedDays,
+ scheduledDays: cardData.scheduledDays,
+ reps: cardData.reps,
+ lapses: cardData.lapses,
+ lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null,
+ createdAt: new Date(cardData.createdAt),
+ updatedAt: clientUpdatedAt,
+ deletedAt: cardData.deletedAt ? new Date(cardData.deletedAt) : null,
+ syncVersion: 1,
+ })
+ .returning({ id: cards.id, syncVersion: cards.syncVersion });
+
+ if (inserted) {
+ result.cards.push({ id: inserted.id, syncVersion: inserted.syncVersion });
+ }
+ } else {
+ const serverCard = existing[0];
+ // Last-Write-Wins: compare timestamps
+ if (serverCard && clientUpdatedAt > serverCard.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(cards)
+ .set({
+ deckId: cardData.deckId,
+ front: cardData.front,
+ back: cardData.back,
+ state: cardData.state,
+ due: new Date(cardData.due),
+ stability: cardData.stability,
+ difficulty: cardData.difficulty,
+ elapsedDays: cardData.elapsedDays,
+ scheduledDays: cardData.scheduledDays,
+ reps: cardData.reps,
+ lapses: cardData.lapses,
+ lastReview: cardData.lastReview ? new Date(cardData.lastReview) : null,
+ updatedAt: clientUpdatedAt,
+ 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 });
+ }
+ } else if (serverCard) {
+ // Server wins - mark as conflict
+ result.conflicts.cards.push(cardData.id);
+ result.cards.push({ id: serverCard.id, syncVersion: serverCard.syncVersion });
+ }
+ }
+ }
+
+ // Process review logs (append-only, no conflicts)
+ for (const logData of data.reviewLogs) {
+ // Verify the card's deck belongs to user
+ const cardCheck = await db
+ .select({ id: cards.id })
+ .from(cards)
+ .innerJoin(decks, eq(cards.deckId, decks.id))
+ .where(and(eq(cards.id, logData.cardId), eq(decks.userId, userId)));
+
+ if (cardCheck.length === 0) {
+ // Card doesn't belong to user, skip
+ continue;
+ }
+
+ // Check if review log already exists
+ const existing = await db
+ .select({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion })
+ .from(reviewLogs)
+ .where(eq(reviewLogs.id, logData.id));
+
+ if (existing.length === 0) {
+ // New review log - insert
+ const [inserted] = await db
+ .insert(reviewLogs)
+ .values({
+ id: logData.id,
+ cardId: logData.cardId,
+ userId,
+ rating: logData.rating,
+ state: logData.state,
+ scheduledDays: logData.scheduledDays,
+ elapsedDays: logData.elapsedDays,
+ reviewedAt: new Date(logData.reviewedAt),
+ durationMs: logData.durationMs,
+ syncVersion: 1,
+ })
+ .returning({ id: reviewLogs.id, syncVersion: reviewLogs.syncVersion });
+
+ if (inserted) {
+ 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 });
+ }
+ }
+ }
+
+ return result;
+ },
+};
diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts
index e702044..08f42df 100644
--- a/src/server/routes/index.ts
+++ b/src/server/routes/index.ts
@@ -2,3 +2,4 @@ export { auth } from "./auth.js";
export { cards } from "./cards.js";
export { decks } from "./decks.js";
export { study } from "./study.js";
+export { sync } from "./sync.js";
diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts
new file mode 100644
index 0000000..67f85e8
--- /dev/null
+++ b/src/server/routes/sync.test.ts
@@ -0,0 +1,441 @@
+import { Hono } from "hono";
+import { sign } from "hono/jwt";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { errorHandler } from "../middleware/index.js";
+import type {
+ SyncPushData,
+ SyncPushResult,
+ SyncRepository,
+} from "../repositories/sync.js";
+import { createSyncRouter } from "./sync.js";
+
+function createMockSyncRepo(): SyncRepository {
+ return {
+ pushChanges: vi.fn(),
+ };
+}
+
+const JWT_SECRET = process.env.JWT_SECRET || "test-secret";
+
+async function createTestToken(userId: string): Promise<string> {
+ const now = Math.floor(Date.now() / 1000);
+ return sign(
+ {
+ sub: userId,
+ iat: now,
+ exp: now + 900,
+ },
+ JWT_SECRET,
+ );
+}
+
+interface SyncPushResponse {
+ decks?: { id: string; syncVersion: number }[];
+ cards?: { id: string; syncVersion: number }[];
+ reviewLogs?: { id: string; syncVersion: number }[];
+ conflicts?: {
+ decks: string[];
+ cards: string[];
+ };
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("POST /api/sync/push", () => {
+ let app: Hono;
+ let mockSyncRepo: ReturnType<typeof createMockSyncRepo>;
+ let authToken: string;
+ const userId = "user-uuid-123";
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockSyncRepo = createMockSyncRepo();
+ const syncRouter = createSyncRouter({ syncRepo: mockSyncRepo });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/sync", syncRouter);
+ authToken = await createTestToken(userId);
+ });
+
+ it("returns 401 without authentication", async () => {
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ decks: [], cards: [], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it("successfully pushes empty data", async () => {
+ const mockResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [], cards: [], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.decks).toEqual([]);
+ expect(body.cards).toEqual([]);
+ expect(body.reviewLogs).toEqual([]);
+ expect(body.conflicts).toEqual({ decks: [], cards: [] });
+ expect(mockSyncRepo.pushChanges).toHaveBeenCalledWith(userId, {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ });
+ });
+
+ it("successfully pushes decks", async () => {
+ const deckData = {
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ name: "Test Deck",
+ description: "A test deck",
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [{ id: "deck-uuid-123", syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [deckData], cards: [], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.decks).toHaveLength(1);
+ expect(body.decks?.[0]?.id).toBe("deck-uuid-123");
+ expect(body.decks?.[0]?.syncVersion).toBe(1);
+ });
+
+ it("successfully pushes cards", async () => {
+ const cardData = {
+ id: "550e8400-e29b-41d4-a716-446655440001",
+ deckId: "550e8400-e29b-41d4-a716-446655440000",
+ front: "Question",
+ back: "Answer",
+ state: 0,
+ due: "2024-01-01T00:00:00.000Z",
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [],
+ cards: [{ id: "550e8400-e29b-41d4-a716-446655440001", syncVersion: 1 }],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [], cards: [cardData], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.cards).toHaveLength(1);
+ expect(body.cards?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440001");
+ });
+
+ it("successfully pushes review logs", async () => {
+ const reviewLogData = {
+ id: "550e8400-e29b-41d4-a716-446655440002",
+ cardId: "550e8400-e29b-41d4-a716-446655440001",
+ rating: 3,
+ state: 0,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: "2024-01-01T00:00:00.000Z",
+ durationMs: 5000,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [{ id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 }],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [],
+ cards: [],
+ reviewLogs: [reviewLogData],
+ }),
+ });
+
+ 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");
+ });
+
+ it("returns conflicts when server data is newer", async () => {
+ const deckData = {
+ id: "550e8400-e29b-41d4-a716-446655440003",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [{ id: "550e8400-e29b-41d4-a716-446655440003", syncVersion: 5 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: ["550e8400-e29b-41d4-a716-446655440003"], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [deckData], cards: [], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.conflicts?.decks).toContain("550e8400-e29b-41d4-a716-446655440003");
+ });
+
+ it("validates deck schema", async () => {
+ const invalidDeck = {
+ id: "not-a-uuid",
+ name: "",
+ description: null,
+ newCardsPerDay: -1,
+ createdAt: "invalid-date",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [invalidDeck], cards: [], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("validates card schema", async () => {
+ const invalidCard = {
+ id: "card-uuid-123",
+ deckId: "not-a-uuid",
+ front: "",
+ back: "Answer",
+ state: 5, // Invalid state
+ due: "2024-01-01T00:00:00.000Z",
+ stability: -1, // Invalid
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ decks: [], cards: [invalidCard], reviewLogs: [] }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("validates review log schema", async () => {
+ const invalidLog = {
+ id: "log-uuid-123",
+ cardId: "card-uuid-123",
+ rating: 5, // Invalid rating (must be 1-4)
+ state: 0,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: "2024-01-01T00:00:00.000Z",
+ durationMs: 5000,
+ };
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [],
+ cards: [],
+ reviewLogs: [invalidLog],
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("pushes multiple entities at once", async () => {
+ const deckData = {
+ id: "550e8400-e29b-41d4-a716-446655440004",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const cardData = {
+ id: "550e8400-e29b-41d4-a716-446655440005",
+ deckId: "550e8400-e29b-41d4-a716-446655440004",
+ front: "Q",
+ back: "A",
+ state: 0,
+ due: "2024-01-01T00:00:00.000Z",
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const reviewLogData = {
+ id: "550e8400-e29b-41d4-a716-446655440006",
+ cardId: "550e8400-e29b-41d4-a716-446655440005",
+ rating: 3,
+ state: 0,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: "2024-01-01T00:00:00.000Z",
+ durationMs: null,
+ };
+
+ 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 }],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [deckData],
+ cards: [cardData],
+ reviewLogs: [reviewLogData],
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.decks).toHaveLength(1);
+ expect(body.cards).toHaveLength(1);
+ expect(body.reviewLogs).toHaveLength(1);
+ });
+
+ it("handles soft-deleted entities", async () => {
+ const deletedDeck = {
+ id: "550e8400-e29b-41d4-a716-446655440007",
+ name: "Deleted Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: "2024-01-02T00:00:00.000Z",
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [{ id: "550e8400-e29b-41d4-a716-446655440007", syncVersion: 2 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [deletedDeck],
+ cards: [],
+ reviewLogs: [],
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.decks).toHaveLength(1);
+ });
+});
diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts
new file mode 100644
index 0000000..01f9bd0
--- /dev/null
+++ b/src/server/routes/sync.ts
@@ -0,0 +1,78 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+import { authMiddleware, getAuthUser } from "../middleware/index.js";
+import {
+ type SyncPushData,
+ type SyncRepository,
+ syncRepository,
+} from "../repositories/sync.js";
+
+export interface SyncDependencies {
+ syncRepo: SyncRepository;
+}
+
+const syncDeckSchema = z.object({
+ id: z.string().uuid(),
+ name: z.string().min(1).max(255),
+ description: z.string().nullable(),
+ newCardsPerDay: z.number().int().min(0).max(1000),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ deletedAt: z.string().datetime().nullable(),
+});
+
+const syncCardSchema = z.object({
+ id: z.string().uuid(),
+ deckId: z.string().uuid(),
+ front: z.string().min(1),
+ back: z.string().min(1),
+ state: z.number().int().min(0).max(3),
+ due: z.string().datetime(),
+ stability: z.number().min(0),
+ difficulty: z.number().min(0),
+ elapsedDays: z.number().int().min(0),
+ scheduledDays: z.number().int().min(0),
+ reps: z.number().int().min(0),
+ lapses: z.number().int().min(0),
+ lastReview: z.string().datetime().nullable(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ deletedAt: z.string().datetime().nullable(),
+});
+
+const syncReviewLogSchema = z.object({
+ id: z.string().uuid(),
+ cardId: z.string().uuid(),
+ rating: z.number().int().min(1).max(4),
+ state: z.number().int().min(0).max(3),
+ scheduledDays: z.number().int().min(0),
+ elapsedDays: z.number().int().min(0),
+ reviewedAt: z.string().datetime(),
+ durationMs: z.number().int().min(0).nullable(),
+});
+
+const syncPushSchema = z.object({
+ decks: z.array(syncDeckSchema).default([]),
+ cards: z.array(syncCardSchema).default([]),
+ reviewLogs: z.array(syncReviewLogSchema).default([]),
+});
+
+export function createSyncRouter(deps: SyncDependencies) {
+ const { syncRepo } = deps;
+
+ return new Hono()
+ .use("*", authMiddleware)
+ .post("/push", zValidator("json", syncPushSchema), async (c) => {
+ const user = getAuthUser(c);
+ const data = c.req.valid("json") as SyncPushData;
+
+ const result = await syncRepo.pushChanges(user.id, data);
+
+ return c.json(result, 200);
+ });
+}
+
+export const sync = createSyncRouter({
+ syncRepo: syncRepository,
+});