diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:20:04 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:20:04 +0900 |
| commit | 9632d70ea0d326ac0df4e9bffb7fb669013f0755 (patch) | |
| tree | 74b29b896b57c16c3bb64e8ade75566f6a8f0e1c | |
| parent | fe101104cdd50256d4ef5c61e1bf099ed2da68e3 (diff) | |
| download | kioku-9632d70ea0d326ac0df4e9bffb7fb669013f0755.tar.gz kioku-9632d70ea0d326ac0df4e9bffb7fb669013f0755.tar.zst kioku-9632d70ea0d326ac0df4e9bffb7fb669013f0755.zip | |
feat(server): add GET /api/sync/pull endpoint
Implement sync pull endpoint to fetch entities updated since a given
syncVersion. Returns decks, cards, and review logs with their current
sync versions for incremental client synchronization.
🤖 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.md | 2 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 73 | ||||
| -rw-r--r-- | src/server/routes/sync.test.ts | 326 | ||||
| -rw-r--r-- | src/server/routes/sync.ts | 13 |
4 files changed, 413 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 453bd25..97715cc 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -154,7 +154,7 @@ Smaller features first to enable early MVP validation. ### Sync Engine - [x] POST /api/sync/push endpoint -- [ ] GET /api/sync/pull endpoint +- [x] GET /api/sync/pull endpoint - [ ] Client: Sync queue management - [ ] Client: Push implementation - [ ] Client: Pull implementation diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 3051121..87acdb4 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -62,8 +62,20 @@ export interface SyncPushResult { }; } +export interface SyncPullQuery { + lastSyncVersion: number; +} + +export interface SyncPullResult { + decks: Deck[]; + cards: Card[]; + reviewLogs: ReviewLog[]; + currentSyncVersion: number; +} + export interface SyncRepository { pushChanges(userId: string, data: SyncPushData): Promise<SyncPushResult>; + pullChanges(userId: string, query: SyncPullQuery): Promise<SyncPullResult>; } export const syncRepository: SyncRepository = { @@ -276,4 +288,65 @@ export const syncRepository: SyncRepository = { return result; }, + + 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))); + + // Get all cards from user's decks with syncVersion > lastSyncVersion + const userDeckIds = await db + .select({ id: decks.id }) + .from(decks) + .where(eq(decks.userId, userId)); + + const deckIdList = userDeckIds.map((d) => d.id); + + let pulledCards: Card[] = []; + if (deckIdList.length > 0) { + const cardResults = await db + .select() + .from(cards) + .where(gt(cards.syncVersion, lastSyncVersion)); + + // Filter cards that belong to user's decks + pulledCards = cardResults.filter((c) => deckIdList.includes(c.deckId)); + } + + // Get all review logs for user with syncVersion > lastSyncVersion + const pulledReviewLogs = await db + .select() + .from(reviewLogs) + .where(and(eq(reviewLogs.userId, userId), gt(reviewLogs.syncVersion, lastSyncVersion))); + + // Calculate current max sync version across all entities + let currentSyncVersion = lastSyncVersion; + + for (const deck of pulledDecks) { + if (deck.syncVersion > currentSyncVersion) { + currentSyncVersion = deck.syncVersion; + } + } + for (const card of pulledCards) { + if (card.syncVersion > currentSyncVersion) { + currentSyncVersion = card.syncVersion; + } + } + for (const log of pulledReviewLogs) { + if (log.syncVersion > currentSyncVersion) { + currentSyncVersion = log.syncVersion; + } + } + + return { + decks: pulledDecks, + cards: pulledCards, + reviewLogs: pulledReviewLogs, + currentSyncVersion, + }; + }, }; diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index 67f85e8..22efada 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -3,15 +3,18 @@ import { sign } from "hono/jwt"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; import type { + SyncPullResult, SyncPushData, SyncPushResult, SyncRepository, } from "../repositories/sync.js"; +import type { Card, Deck, ReviewLog } from "../repositories/types.js"; import { createSyncRouter } from "./sync.js"; function createMockSyncRepo(): SyncRepository { return { pushChanges: vi.fn(), + pullChanges: vi.fn(), }; } @@ -439,3 +442,326 @@ describe("POST /api/sync/push", () => { expect(body.decks).toHaveLength(1); }); }); + +interface SyncPullResponse { + decks?: Deck[]; + cards?: Card[]; + reviewLogs?: ReviewLog[]; + currentSyncVersion?: number; + error?: { + code: string; + message: string; + }; +} + +describe("GET /api/sync/pull", () => { + 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/pull"); + + expect(res.status).toBe(401); + }); + + it("successfully pulls with default lastSyncVersion (0)", async () => { + const mockDeck: Deck = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId, + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: null, + syncVersion: 1, + }; + + const mockResult: SyncPullResult = { + decks: [mockDeck], + cards: [], + reviewLogs: [], + currentSyncVersion: 1, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.decks).toHaveLength(1); + expect(body.cards).toHaveLength(0); + expect(body.reviewLogs).toHaveLength(0); + expect(body.currentSyncVersion).toBe(1); + expect(mockSyncRepo.pullChanges).toHaveBeenCalledWith(userId, { + lastSyncVersion: 0, + }); + }); + + it("successfully pulls with specified lastSyncVersion", async () => { + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [], + currentSyncVersion: 5, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull?lastSyncVersion=5", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.decks).toEqual([]); + expect(body.currentSyncVersion).toBe(5); + expect(mockSyncRepo.pullChanges).toHaveBeenCalledWith(userId, { + lastSyncVersion: 5, + }); + }); + + it("returns decks with proper fields", async () => { + const mockDeck: Deck = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId, + name: "Test Deck", + description: "A test description", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: null, + syncVersion: 2, + }; + + const mockResult: SyncPullResult = { + decks: [mockDeck], + cards: [], + reviewLogs: [], + currentSyncVersion: 2, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull?lastSyncVersion=1", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.decks?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(body.decks?.[0]?.name).toBe("Test Deck"); + expect(body.decks?.[0]?.description).toBe("A test description"); + expect(body.decks?.[0]?.syncVersion).toBe(2); + }); + + it("returns cards with FSRS fields", async () => { + const mockCard: Card = { + id: "550e8400-e29b-41d4-a716-446655440001", + deckId: "550e8400-e29b-41d4-a716-446655440000", + front: "Question", + back: "Answer", + state: 2, + due: new Date("2024-01-05T00:00:00.000Z"), + stability: 5.5, + difficulty: 0.3, + elapsedDays: 3, + scheduledDays: 4, + reps: 2, + lapses: 0, + lastReview: new Date("2024-01-02T00:00:00.000Z"), + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: null, + syncVersion: 3, + }; + + const mockResult: SyncPullResult = { + decks: [], + cards: [mockCard], + reviewLogs: [], + currentSyncVersion: 3, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull?lastSyncVersion=2", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.cards).toHaveLength(1); + expect(body.cards?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440001"); + expect(body.cards?.[0]?.state).toBe(2); + expect(body.cards?.[0]?.stability).toBe(5.5); + expect(body.cards?.[0]?.difficulty).toBe(0.3); + expect(body.cards?.[0]?.reps).toBe(2); + }); + + it("returns review logs", async () => { + const mockReviewLog: ReviewLog = { + id: "550e8400-e29b-41d4-a716-446655440002", + cardId: "550e8400-e29b-41d4-a716-446655440001", + userId, + rating: 3, + state: 2, + scheduledDays: 4, + elapsedDays: 3, + reviewedAt: new Date("2024-01-02T00:00:00.000Z"), + durationMs: 5000, + syncVersion: 1, + }; + + const mockResult: SyncPullResult = { + decks: [], + cards: [], + reviewLogs: [mockReviewLog], + currentSyncVersion: 1, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + 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]?.rating).toBe(3); + expect(body.reviewLogs?.[0]?.durationMs).toBe(5000); + }); + + it("returns multiple entities", async () => { + const mockDeck: Deck = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId, + name: "Test Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + deletedAt: null, + syncVersion: 1, + }; + + const mockCard: Card = { + id: "550e8400-e29b-41d4-a716-446655440001", + deckId: "550e8400-e29b-41d4-a716-446655440000", + front: "Q", + back: "A", + state: 0, + due: new Date("2024-01-01T00:00:00.000Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + deletedAt: null, + syncVersion: 2, + }; + + const mockReviewLog: ReviewLog = { + id: "550e8400-e29b-41d4-a716-446655440002", + cardId: "550e8400-e29b-41d4-a716-446655440001", + userId, + rating: 3, + state: 0, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-01T00:00:00.000Z"), + durationMs: null, + syncVersion: 3, + }; + + const mockResult: SyncPullResult = { + decks: [mockDeck], + cards: [mockCard], + reviewLogs: [mockReviewLog], + currentSyncVersion: 3, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.decks).toHaveLength(1); + expect(body.cards).toHaveLength(1); + expect(body.reviewLogs).toHaveLength(1); + expect(body.currentSyncVersion).toBe(3); + }); + + it("returns soft-deleted entities", async () => { + const deletedDeck: Deck = { + id: "550e8400-e29b-41d4-a716-446655440000", + userId, + name: "Deleted Deck", + description: null, + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + deletedAt: new Date("2024-01-02T00:00:00.000Z"), + syncVersion: 2, + }; + + const mockResult: SyncPullResult = { + decks: [deletedDeck], + cards: [], + reviewLogs: [], + currentSyncVersion: 2, + }; + vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult); + + const res = await app.request("/api/sync/pull?lastSyncVersion=1", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SyncPullResponse; + expect(body.decks).toHaveLength(1); + expect(body.decks?.[0]?.deletedAt).not.toBeNull(); + }); + + it("validates lastSyncVersion is non-negative", async () => { + const res = await app.request("/api/sync/pull?lastSyncVersion=-1", { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts index 01f9bd0..d61e8d3 100644 --- a/src/server/routes/sync.ts +++ b/src/server/routes/sync.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { authMiddleware, getAuthUser } from "../middleware/index.js"; import { + type SyncPullQuery, type SyncPushData, type SyncRepository, syncRepository, @@ -58,6 +59,10 @@ const syncPushSchema = z.object({ reviewLogs: z.array(syncReviewLogSchema).default([]), }); +const syncPullQuerySchema = z.object({ + lastSyncVersion: z.coerce.number().int().min(0).default(0), +}); + export function createSyncRouter(deps: SyncDependencies) { const { syncRepo } = deps; @@ -70,6 +75,14 @@ export function createSyncRouter(deps: SyncDependencies) { const result = await syncRepo.pushChanges(user.id, data); return c.json(result, 200); + }) + .get("/pull", zValidator("query", syncPullQuerySchema), async (c) => { + const user = getAuthUser(c); + const query = c.req.valid("query") as SyncPullQuery; + + const result = await syncRepo.pullChanges(user.id, query); + + return c.json(result, 200); }); } |
