aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:26:57 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:26:57 +0900
commit842c74fdc2bf06a020868f5b4e504fec0da8715d (patch)
tree8620a9ddb4211f449faaae98776a62ed8101fae3 /src
parent83be5ccd5d64c64f65c7efbfb9feb94ab0f75ce6 (diff)
downloadkioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.tar.gz
kioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.tar.zst
kioku-842c74fdc2bf06a020868f5b4e504fec0da8715d.zip
feat(client): add push service for sync implementation
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 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/client/sync/index.ts12
-rw-r--r--src/client/sync/push.test.ts545
-rw-r--r--src/client/sync/push.ts211
3 files changed, 768 insertions, 0 deletions
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<SyncPushResult>;
+}
+
+/**
+ * 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<SyncPushResult>;
+
+ 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<SyncPushResult> {
+ 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<boolean> {
+ return this.syncQueue.hasPendingChanges();
+ }
+}
+
+/**
+ * Create a push service with the given options
+ */
+export function createPushService(options: PushServiceOptions): PushService {
+ return new PushService(options);
+}