aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync/conflict.ts
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:32:36 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:32:36 +0900
commit8ef0e4a54986f7e334136d195b7081f176de0282 (patch)
tree956da5f24a4c36055939f183dc68f5a5a55e4aa9 /src/client/sync/conflict.ts
parent864495bd4d7156ee433cbc12adda4bdebd43f6fe (diff)
downloadkioku-8ef0e4a54986f7e334136d195b7081f176de0282.tar.gz
kioku-8ef0e4a54986f7e334136d195b7081f176de0282.tar.zst
kioku-8ef0e4a54986f7e334136d195b7081f176de0282.zip
feat(client): add conflict resolution for offline sync
Implement ConflictResolver class with Last-Write-Wins (LWW) strategy: - Detect conflicts from push results - Support multiple strategies: server_wins, local_wins, newer_wins - Resolve deck and card conflicts using server data from pull - Default to server_wins (LWW) for consistent conflict handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/sync/conflict.ts')
-rw-r--r--src/client/sync/conflict.ts269
1 files changed, 269 insertions, 0 deletions
diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts
new file mode 100644
index 0000000..365ef3c
--- /dev/null
+++ b/src/client/sync/conflict.ts
@@ -0,0 +1,269 @@
+import type { LocalCard, LocalDeck } from "../db/index";
+import {
+ localCardRepository,
+ localDeckRepository,
+} from "../db/repositories";
+import type { ServerCard, ServerDeck, SyncPullResult } from "./pull";
+import type { SyncPushResult } from "./push";
+
+/**
+ * Conflict resolution result for a single item
+ */
+export interface ConflictResolutionItem {
+ id: string;
+ resolution: "server_wins" | "local_wins";
+}
+
+/**
+ * Result of conflict resolution process
+ */
+export interface ConflictResolutionResult {
+ decks: ConflictResolutionItem[];
+ cards: ConflictResolutionItem[];
+}
+
+/**
+ * Options for conflict resolver
+ */
+export interface ConflictResolverOptions {
+ /**
+ * Strategy for resolving conflicts
+ * - "server_wins": Always use server data (default for LWW)
+ * - "local_wins": Always use local data
+ * - "newer_wins": Compare timestamps and use newer data
+ */
+ strategy?: "server_wins" | "local_wins" | "newer_wins";
+}
+
+/**
+ * Compare timestamps for LWW resolution
+ * Returns true if server data is newer or equal
+ */
+function isServerNewer(
+ serverUpdatedAt: Date,
+ localUpdatedAt: Date,
+): boolean {
+ return serverUpdatedAt.getTime() >= localUpdatedAt.getTime();
+}
+
+/**
+ * Convert server deck to local format for storage
+ */
+function serverDeckToLocal(deck: ServerDeck): LocalDeck {
+ return {
+ id: deck.id,
+ userId: deck.userId,
+ name: deck.name,
+ description: deck.description,
+ newCardsPerDay: deck.newCardsPerDay,
+ createdAt: new Date(deck.createdAt),
+ updatedAt: new Date(deck.updatedAt),
+ deletedAt: deck.deletedAt ? new Date(deck.deletedAt) : null,
+ syncVersion: deck.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server card to local format for storage
+ */
+function serverCardToLocal(card: ServerCard): LocalCard {
+ return {
+ id: card.id,
+ deckId: card.deckId,
+ front: card.front,
+ back: card.back,
+ state: card.state as LocalCard["state"],
+ due: new Date(card.due),
+ stability: card.stability,
+ difficulty: card.difficulty,
+ elapsedDays: card.elapsedDays,
+ scheduledDays: card.scheduledDays,
+ reps: card.reps,
+ lapses: card.lapses,
+ lastReview: card.lastReview ? new Date(card.lastReview) : null,
+ createdAt: new Date(card.createdAt),
+ updatedAt: new Date(card.updatedAt),
+ deletedAt: card.deletedAt ? new Date(card.deletedAt) : null,
+ syncVersion: card.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Conflict Resolver
+ *
+ * Handles conflicts reported by the server during push operations.
+ * When a conflict occurs (server has newer data), this resolver:
+ * 1. Identifies conflicting items from push result
+ * 2. Pulls latest server data for those items
+ * 3. Applies conflict resolution strategy (default: server wins / LWW)
+ * 4. Updates local database accordingly
+ */
+export class ConflictResolver {
+ private strategy: "server_wins" | "local_wins" | "newer_wins";
+
+ constructor(options: ConflictResolverOptions = {}) {
+ this.strategy = options.strategy ?? "server_wins";
+ }
+
+ /**
+ * Check if there are conflicts in push result
+ */
+ hasConflicts(pushResult: SyncPushResult): boolean {
+ return (
+ pushResult.conflicts.decks.length > 0 ||
+ pushResult.conflicts.cards.length > 0
+ );
+ }
+
+ /**
+ * Get list of conflicting deck IDs
+ */
+ getConflictingDeckIds(pushResult: SyncPushResult): string[] {
+ return pushResult.conflicts.decks;
+ }
+
+ /**
+ * Get list of conflicting card IDs
+ */
+ getConflictingCardIds(pushResult: SyncPushResult): string[] {
+ return pushResult.conflicts.cards;
+ }
+
+ /**
+ * Resolve deck conflict using configured strategy
+ */
+ async resolveDeckConflict(
+ localDeck: LocalDeck,
+ serverDeck: ServerDeck,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverDeck.updatedAt),
+ localDeck.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ // Update local with server data
+ const localData = serverDeckToLocal(serverDeck);
+ await localDeckRepository.upsertFromServer(localData);
+ }
+ // If local_wins, we keep local data and it will be pushed again next sync
+
+ return { id: localDeck.id, resolution };
+ }
+
+ /**
+ * Resolve card conflict using configured strategy
+ */
+ async resolveCardConflict(
+ localCard: LocalCard,
+ serverCard: ServerCard,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverCard.updatedAt),
+ localCard.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ // Update local with server data
+ const localData = serverCardToLocal(serverCard);
+ await localCardRepository.upsertFromServer(localData);
+ }
+ // If local_wins, we keep local data and it will be pushed again next sync
+
+ return { id: localCard.id, resolution };
+ }
+
+ /**
+ * Resolve all conflicts from a push result
+ * Uses pull result to get server data for conflicting items
+ */
+ async resolveConflicts(
+ pushResult: SyncPushResult,
+ pullResult: SyncPullResult,
+ ): Promise<ConflictResolutionResult> {
+ const result: ConflictResolutionResult = {
+ decks: [],
+ cards: [],
+ };
+
+ // Resolve deck conflicts
+ for (const deckId of pushResult.conflicts.decks) {
+ const localDeck = await localDeckRepository.findById(deckId);
+ const serverDeck = pullResult.decks.find((d) => d.id === deckId);
+
+ if (localDeck && serverDeck) {
+ const resolution = await this.resolveDeckConflict(localDeck, serverDeck);
+ result.decks.push(resolution);
+ } else if (serverDeck) {
+ // Local doesn't exist, apply server data
+ const localData = serverDeckToLocal(serverDeck);
+ await localDeckRepository.upsertFromServer(localData);
+ result.decks.push({ id: deckId, resolution: "server_wins" });
+ }
+ // If server doesn't have it but local does, keep local (will push again)
+ }
+
+ // Resolve card conflicts
+ for (const cardId of pushResult.conflicts.cards) {
+ const localCard = await localCardRepository.findById(cardId);
+ const serverCard = pullResult.cards.find((c) => c.id === cardId);
+
+ if (localCard && serverCard) {
+ const resolution = await this.resolveCardConflict(localCard, serverCard);
+ result.cards.push(resolution);
+ } else if (serverCard) {
+ // Local doesn't exist, apply server data
+ const localData = serverCardToLocal(serverCard);
+ await localCardRepository.upsertFromServer(localData);
+ result.cards.push({ id: cardId, resolution: "server_wins" });
+ }
+ // If server doesn't have it but local does, keep local (will push again)
+ }
+
+ return result;
+ }
+}
+
+/**
+ * Create a conflict resolver with the given options
+ */
+export function createConflictResolver(
+ options: ConflictResolverOptions = {},
+): ConflictResolver {
+ return new ConflictResolver(options);
+}
+
+/**
+ * Default conflict resolver using LWW (server wins) strategy
+ */
+export const conflictResolver = new ConflictResolver({ strategy: "server_wins" });