From 842c74fdc2bf06a020868f5b4e504fec0da8715d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 19:26:57 +0900 Subject: feat(client): add push service for sync implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement PushService class to push local changes to server: - Convert local decks, cards, and review logs to API format - Push pending changes to server endpoint - Mark items as synced after successful push - Return conflicts reported by server (LWW resolution) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/sync/index.ts | 12 + src/client/sync/push.test.ts | 545 +++++++++++++++++++++++++++++++++++++++++++ src/client/sync/push.ts | 211 +++++++++++++++++ 3 files changed, 768 insertions(+) create mode 100644 src/client/sync/push.test.ts create mode 100644 src/client/sync/push.ts (limited to 'src') diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts index 6f75a29..76f5081 100644 --- a/src/client/sync/index.ts +++ b/src/client/sync/index.ts @@ -7,3 +7,15 @@ export { type SyncQueueState, type SyncStatusType, } from "./queue"; + +export { + createPushService, + pendingChangesToPushData, + PushService, + type PushServiceOptions, + type SyncCardData, + type SyncDeckData, + type SyncPushData, + type SyncPushResult, + type SyncReviewLogData, +} from "./push"; diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts new file mode 100644 index 0000000..79a9d4a --- /dev/null +++ b/src/client/sync/push.test.ts @@ -0,0 +1,545 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CardState, db, Rating } from "../db/index"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "../db/repositories"; +import { pendingChangesToPushData, PushService } from "./push"; +import { SyncQueue } from "./queue"; + +describe("pendingChangesToPushData", () => { + it("should convert decks to sync format", () => { + const decks = [ + { + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T15:30:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks, + cards: [], + reviewLogs: [], + }); + + expect(result.decks).toHaveLength(1); + expect(result.decks[0]).toEqual({ + id: "deck-1", + name: "Test Deck", + description: "A description", + newCardsPerDay: 20, + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T15:30:00.000Z", + deletedAt: null, + }); + }); + + it("should convert deleted decks with deletedAt timestamp", () => { + const decks = [ + { + id: "deck-1", + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 10, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-03T12:00:00Z"), + deletedAt: new Date("2024-01-03T12:00:00Z"), + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks, + cards: [], + reviewLogs: [], + }); + + expect(result.decks[0]?.deletedAt).toBe("2024-01-03T12:00:00.000Z"); + }); + + it("should convert cards to sync format", () => { + const cards = [ + { + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.Review, + due: new Date("2024-01-05T09:00:00Z"), + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: new Date("2024-01-02T10:00:00Z"), + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-02T10:00:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards, + reviewLogs: [], + }); + + expect(result.cards).toHaveLength(1); + expect(result.cards[0]).toEqual({ + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.Review, + due: "2024-01-05T09:00:00.000Z", + stability: 10.5, + difficulty: 5.2, + elapsedDays: 3, + scheduledDays: 5, + reps: 4, + lapses: 1, + lastReview: "2024-01-02T10:00:00.000Z", + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-01-02T10:00:00.000Z", + deletedAt: null, + }); + }); + + it("should convert cards with null lastReview", () => { + const cards = [ + { + id: "card-1", + deckId: "deck-1", + front: "New Card", + back: "Answer", + state: CardState.New, + due: new Date("2024-01-01T10:00:00Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T10:00:00Z"), + updatedAt: new Date("2024-01-01T10:00:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards, + reviewLogs: [], + }); + + expect(result.cards[0]?.lastReview).toBeNull(); + }); + + it("should convert review logs to sync format", () => { + const reviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: 5000, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs, + }); + + expect(result.reviewLogs).toHaveLength(1); + expect(result.reviewLogs[0]).toEqual({ + id: "log-1", + cardId: "card-1", + rating: Rating.Good, + state: CardState.Learning, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: "2024-01-02T10:00:00.000Z", + durationMs: 5000, + }); + }); + + it("should convert review logs with null durationMs", () => { + const reviewLogs = [ + { + id: "log-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Easy, + state: CardState.New, + scheduledDays: 3, + elapsedDays: 0, + reviewedAt: new Date("2024-01-02T10:00:00Z"), + durationMs: null, + syncVersion: 0, + _synced: false, + }, + ]; + + const result = pendingChangesToPushData({ + decks: [], + cards: [], + reviewLogs, + }); + + expect(result.reviewLogs[0]?.durationMs).toBeNull(); + }); +}); + +describe("PushService", () => { + let syncQueue: SyncQueue; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + syncQueue = new SyncQueue(); + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + localStorage.clear(); + }); + + describe("push", () => { + it("should return empty result when no pending changes", async () => { + const pushToServer = vi.fn(); + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result).toEqual({ + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + expect(pushToServer).not.toHaveBeenCalled(); + }); + + it("should push pending decks to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 1 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledTimes(1); + expect(pushToServer).toHaveBeenCalledWith({ + decks: [ + expect.objectContaining({ + id: deck.id, + name: "Test Deck", + }), + ], + cards: [], + reviewLogs: [], + }); + expect(result.decks).toHaveLength(1); + expect(result.decks[0]?.id).toBe(deck.id); + }); + + it("should push pending cards to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Question", + back: "Answer", + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [{ id: card.id, syncVersion: 1 }], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledWith({ + decks: [], + cards: [ + expect.objectContaining({ + id: card.id, + front: "Question", + back: "Answer", + }), + ], + reviewLogs: [], + }); + expect(result.cards).toHaveLength(1); + }); + + it("should push pending review logs to server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck.id, 1); + + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + await localCardRepository.markSynced(card.id, 1); + + const log = await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [], + cards: [], + reviewLogs: [{ id: log.id, syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(pushToServer).toHaveBeenCalledWith({ + decks: [], + cards: [], + reviewLogs: [ + expect.objectContaining({ + id: log.id, + rating: Rating.Good, + }), + ], + }); + expect(result.reviewLogs).toHaveLength(1); + }); + + it("should mark items as synced after successful push", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 5 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + await pushService.push(); + + const updatedDeck = await localDeckRepository.findById(deck.id); + expect(updatedDeck?._synced).toBe(true); + expect(updatedDeck?.syncVersion).toBe(5); + }); + + it("should return conflicts from server", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 3 }], + cards: [], + reviewLogs: [], + conflicts: { decks: [deck.id], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.conflicts.decks).toContain(deck.id); + }); + + it("should throw error if push fails", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushToServer = vi.fn().mockRejectedValue(new Error("Network error")); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + await expect(pushService.push()).rejects.toThrow("Network error"); + }); + + it("should push all types of changes together", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const card = await localCardRepository.create({ + deckId: deck.id, + front: "Q", + back: "A", + }); + + const log = await localReviewLogRepository.create({ + cardId: card.id, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + + const pushToServer = vi.fn().mockResolvedValue({ + decks: [{ id: deck.id, syncVersion: 1 }], + cards: [{ id: card.id, syncVersion: 1 }], + reviewLogs: [{ id: log.id, syncVersion: 1 }], + conflicts: { decks: [], cards: [] }, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer, + }); + + const result = await pushService.push(); + + expect(result.decks).toHaveLength(1); + expect(result.cards).toHaveLength(1); + expect(result.reviewLogs).toHaveLength(1); + + // Verify all items are marked as synced + const updatedDeck = await localDeckRepository.findById(deck.id); + const updatedCard = await localCardRepository.findById(card.id); + const updatedLog = await localReviewLogRepository.findById(log.id); + + expect(updatedDeck?._synced).toBe(true); + expect(updatedCard?._synced).toBe(true); + expect(updatedLog?._synced).toBe(true); + }); + }); + + describe("hasPendingChanges", () => { + it("should return false when no pending changes", async () => { + const pushService = new PushService({ + syncQueue, + pushToServer: vi.fn(), + }); + + const result = await pushService.hasPendingChanges(); + expect(result).toBe(false); + }); + + it("should return true when there are pending changes", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const pushService = new PushService({ + syncQueue, + pushToServer: vi.fn(), + }); + + const result = await pushService.hasPendingChanges(); + expect(result).toBe(true); + }); + }); +}); diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts new file mode 100644 index 0000000..7702583 --- /dev/null +++ b/src/client/sync/push.ts @@ -0,0 +1,211 @@ +import type { LocalCard, LocalDeck, LocalReviewLog } from "../db/index"; +import type { PendingChanges, SyncQueue } from "./queue"; + +/** + * Data format for push request to server + */ +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; +} + +/** + * Response from push endpoint + */ +export interface SyncPushResult { + decks: { id: string; syncVersion: number }[]; + cards: { id: string; syncVersion: number }[]; + reviewLogs: { id: string; syncVersion: number }[]; + conflicts: { + decks: string[]; + cards: string[]; + }; +} + +/** + * Options for creating a push service + */ +export interface PushServiceOptions { + syncQueue: SyncQueue; + pushToServer: (data: SyncPushData) => Promise; +} + +/** + * Convert local deck to sync format + */ +function deckToSyncData(deck: LocalDeck): SyncDeckData { + return { + id: deck.id, + name: deck.name, + description: deck.description, + newCardsPerDay: deck.newCardsPerDay, + createdAt: deck.createdAt.toISOString(), + updatedAt: deck.updatedAt.toISOString(), + deletedAt: deck.deletedAt?.toISOString() ?? null, + }; +} + +/** + * Convert local card to sync format + */ +function cardToSyncData(card: LocalCard): SyncCardData { + return { + id: card.id, + deckId: card.deckId, + front: card.front, + back: card.back, + state: card.state, + due: card.due.toISOString(), + stability: card.stability, + difficulty: card.difficulty, + elapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays, + reps: card.reps, + lapses: card.lapses, + lastReview: card.lastReview?.toISOString() ?? null, + createdAt: card.createdAt.toISOString(), + updatedAt: card.updatedAt.toISOString(), + deletedAt: card.deletedAt?.toISOString() ?? null, + }; +} + +/** + * Convert local review log to sync format + */ +function reviewLogToSyncData(log: LocalReviewLog): SyncReviewLogData { + return { + id: log.id, + cardId: log.cardId, + rating: log.rating, + state: log.state, + scheduledDays: log.scheduledDays, + elapsedDays: log.elapsedDays, + reviewedAt: log.reviewedAt.toISOString(), + durationMs: log.durationMs, + }; +} + +/** + * Convert pending changes to sync push data format + */ +export function pendingChangesToPushData(changes: PendingChanges): SyncPushData { + return { + decks: changes.decks.map(deckToSyncData), + cards: changes.cards.map(cardToSyncData), + reviewLogs: changes.reviewLogs.map(reviewLogToSyncData), + }; +} + +/** + * Push sync service + * + * Handles pushing local changes to the server: + * 1. Get pending changes from sync queue + * 2. Convert to API format + * 3. Send to server + * 4. Mark items as synced on success + * 5. Handle conflicts (server wins for LWW) + */ +export class PushService { + private syncQueue: SyncQueue; + private pushToServer: (data: SyncPushData) => Promise; + + constructor(options: PushServiceOptions) { + this.syncQueue = options.syncQueue; + this.pushToServer = options.pushToServer; + } + + /** + * Push all pending changes to the server + * + * @returns Result containing synced items and conflicts + * @throws Error if push fails + */ + async push(): Promise { + const pendingChanges = await this.syncQueue.getPendingChanges(); + + // If no pending changes, return empty result + if ( + pendingChanges.decks.length === 0 && + pendingChanges.cards.length === 0 && + pendingChanges.reviewLogs.length === 0 + ) { + return { + decks: [], + cards: [], + reviewLogs: [], + conflicts: { decks: [], cards: [] }, + }; + } + + // Convert to API format + const pushData = pendingChangesToPushData(pendingChanges); + + // Push to server + const result = await this.pushToServer(pushData); + + // Mark successfully synced items + await this.syncQueue.markSynced({ + decks: result.decks, + cards: result.cards, + reviewLogs: result.reviewLogs, + }); + + return result; + } + + /** + * Check if there are pending changes to push + */ + async hasPendingChanges(): Promise { + return this.syncQueue.hasPendingChanges(); + } +} + +/** + * Create a push service with the given options + */ +export function createPushService(options: PushServiceOptions): PushService { + return new PushService(options); +} -- cgit v1.2.3-70-g09d2