aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:20:04 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:20:04 +0900
commit9632d70ea0d326ac0df4e9bffb7fb669013f0755 (patch)
tree74b29b896b57c16c3bb64e8ade75566f6a8f0e1c
parentfe101104cdd50256d4ef5c61e1bf099ed2da68e3 (diff)
downloadkioku-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.md2
-rw-r--r--src/server/repositories/sync.ts73
-rw-r--r--src/server/routes/sync.test.ts326
-rw-r--r--src/server/routes/sync.ts13
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);
});
}