diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:16:48 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:16:48 +0900 |
| commit | fe101104cdd50256d4ef5c61e1bf099ed2da68e3 (patch) | |
| tree | 862d84bd685dcbea6fe1bb2fc02f1cad33049196 /src/server/repositories/sync.ts | |
| parent | c086c8b35b6c6f0b0e2623e9b6421713a540941a (diff) | |
| download | kioku-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>
Diffstat (limited to 'src/server/repositories/sync.ts')
| -rw-r--r-- | src/server/repositories/sync.ts | 279 |
1 files changed, 279 insertions, 0 deletions
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; + }, +}; |
