aboutsummaryrefslogtreecommitdiffhomepage
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
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>
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/sync/conflict.test.ts550
-rw-r--r--src/client/sync/conflict.ts269
-rw-r--r--src/client/sync/index.ts9
4 files changed, 829 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 276e6c5..e4b2151 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -158,7 +158,7 @@ Smaller features first to enable early MVP validation.
- [x] Client: Sync queue management
- [x] Client: Push implementation
- [x] Client: Pull implementation
-- [ ] Conflict resolution (Last-Write-Wins)
+- [x] Conflict resolution (Last-Write-Wins)
- [ ] Auto-sync on reconnect
- [ ] Add tests
diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts
new file mode 100644
index 0000000..7f86953
--- /dev/null
+++ b/src/client/sync/conflict.test.ts
@@ -0,0 +1,550 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { CardState, db } from "../db/index";
+import { localCardRepository, localDeckRepository } from "../db/repositories";
+import { ConflictResolver } from "./conflict";
+import type { SyncPullResult } from "./pull";
+import type { SyncPushResult } from "./push";
+
+describe("ConflictResolver", () => {
+ beforeEach(async () => {
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ localStorage.clear();
+ });
+
+ afterEach(async () => {
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ localStorage.clear();
+ });
+
+ describe("hasConflicts", () => {
+ it("should return false when no conflicts", () => {
+ const resolver = new ConflictResolver();
+ const pushResult: SyncPushResult = {
+ decks: [{ id: "deck-1", syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [] },
+ };
+
+ expect(resolver.hasConflicts(pushResult)).toBe(false);
+ });
+
+ it("should return true when deck conflicts exist", () => {
+ const resolver = new ConflictResolver();
+ const pushResult: SyncPushResult = {
+ decks: [{ id: "deck-1", syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: ["deck-1"], cards: [] },
+ };
+
+ expect(resolver.hasConflicts(pushResult)).toBe(true);
+ });
+
+ it("should return true when card conflicts exist", () => {
+ const resolver = new ConflictResolver();
+ const pushResult: SyncPushResult = {
+ decks: [],
+ cards: [{ id: "card-1", syncVersion: 1 }],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: ["card-1"] },
+ };
+
+ expect(resolver.hasConflicts(pushResult)).toBe(true);
+ });
+ });
+
+ describe("getConflictingDeckIds", () => {
+ it("should return conflicting deck IDs", () => {
+ const resolver = new ConflictResolver();
+ const pushResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: ["deck-1", "deck-2"], cards: [] },
+ };
+
+ expect(resolver.getConflictingDeckIds(pushResult)).toEqual([
+ "deck-1",
+ "deck-2",
+ ]);
+ });
+ });
+
+ describe("getConflictingCardIds", () => {
+ it("should return conflicting card IDs", () => {
+ const resolver = new ConflictResolver();
+ const pushResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: ["card-1", "card-2"] },
+ };
+
+ expect(resolver.getConflictingCardIds(pushResult)).toEqual([
+ "card-1",
+ "card-2",
+ ]);
+ });
+ });
+
+ describe("resolveDeckConflict", () => {
+ it("should use server data with server_wins strategy", async () => {
+ const localDeck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Name",
+ description: "Local description",
+ newCardsPerDay: 10,
+ });
+
+ const serverDeck = {
+ id: localDeck.id,
+ userId: "user-1",
+ name: "Server Name",
+ description: "Server description",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-03"),
+ deletedAt: null,
+ syncVersion: 5,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveDeckConflict(localDeck, serverDeck);
+
+ expect(result.resolution).toBe("server_wins");
+
+ const updatedDeck = await localDeckRepository.findById(localDeck.id);
+ expect(updatedDeck?.name).toBe("Server Name");
+ expect(updatedDeck?.description).toBe("Server description");
+ expect(updatedDeck?.newCardsPerDay).toBe(20);
+ expect(updatedDeck?._synced).toBe(true);
+ });
+
+ it("should keep local data with local_wins strategy", async () => {
+ const localDeck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Name",
+ description: "Local description",
+ newCardsPerDay: 10,
+ });
+
+ const serverDeck = {
+ id: localDeck.id,
+ userId: "user-1",
+ name: "Server Name",
+ description: "Server description",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-03"),
+ deletedAt: null,
+ syncVersion: 5,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "local_wins" });
+ const result = await resolver.resolveDeckConflict(localDeck, serverDeck);
+
+ expect(result.resolution).toBe("local_wins");
+
+ const updatedDeck = await localDeckRepository.findById(localDeck.id);
+ expect(updatedDeck?.name).toBe("Local Name");
+ });
+
+ it("should use server data when server is newer with newer_wins strategy", async () => {
+ const localDeck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Name",
+ description: null,
+ newCardsPerDay: 10,
+ });
+
+ const serverDeck = {
+ id: localDeck.id,
+ userId: "user-1",
+ name: "Server Name",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date(Date.now() + 10000), // Server is newer
+ deletedAt: null,
+ syncVersion: 5,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "newer_wins" });
+ const result = await resolver.resolveDeckConflict(localDeck, serverDeck);
+
+ expect(result.resolution).toBe("server_wins");
+
+ const updatedDeck = await localDeckRepository.findById(localDeck.id);
+ expect(updatedDeck?.name).toBe("Server Name");
+ });
+
+ it("should use local data when local is newer with newer_wins strategy", async () => {
+ const localDeck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Name",
+ description: null,
+ newCardsPerDay: 10,
+ });
+
+ const serverDeck = {
+ id: localDeck.id,
+ userId: "user-1",
+ name: "Server Name",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"), // Server is older
+ deletedAt: null,
+ syncVersion: 5,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "newer_wins" });
+ const result = await resolver.resolveDeckConflict(localDeck, serverDeck);
+
+ expect(result.resolution).toBe("local_wins");
+
+ const updatedDeck = await localDeckRepository.findById(localDeck.id);
+ expect(updatedDeck?.name).toBe("Local Name");
+ });
+ });
+
+ describe("resolveCardConflict", () => {
+ it("should use server data with server_wins strategy", async () => {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ });
+
+ const localCard = await localCardRepository.create({
+ deckId: deck.id,
+ front: "Local Question",
+ back: "Local Answer",
+ });
+
+ const serverCard = {
+ id: localCard.id,
+ deckId: deck.id,
+ front: "Server Question",
+ back: "Server Answer",
+ state: CardState.Review,
+ due: new Date("2024-01-05"),
+ stability: 10,
+ difficulty: 5,
+ elapsedDays: 3,
+ scheduledDays: 5,
+ reps: 4,
+ lapses: 1,
+ lastReview: new Date("2024-01-02"),
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-03"),
+ deletedAt: null,
+ syncVersion: 3,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveCardConflict(localCard, serverCard);
+
+ expect(result.resolution).toBe("server_wins");
+
+ const updatedCard = await localCardRepository.findById(localCard.id);
+ expect(updatedCard?.front).toBe("Server Question");
+ expect(updatedCard?.back).toBe("Server Answer");
+ expect(updatedCard?._synced).toBe(true);
+ });
+
+ it("should keep local data with local_wins strategy", async () => {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ });
+
+ const localCard = await localCardRepository.create({
+ deckId: deck.id,
+ front: "Local Question",
+ back: "Local Answer",
+ });
+
+ const serverCard = {
+ id: localCard.id,
+ deckId: deck.id,
+ front: "Server Question",
+ back: "Server Answer",
+ state: CardState.New,
+ due: new Date(),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 3,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "local_wins" });
+ const result = await resolver.resolveCardConflict(localCard, serverCard);
+
+ expect(result.resolution).toBe("local_wins");
+
+ const updatedCard = await localCardRepository.findById(localCard.id);
+ expect(updatedCard?.front).toBe("Local Question");
+ });
+ });
+
+ describe("resolveConflicts", () => {
+ it("should resolve multiple deck conflicts", async () => {
+ const deck1 = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Deck 1",
+ description: null,
+ newCardsPerDay: 10,
+ });
+ const deck2 = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Deck 2",
+ description: null,
+ newCardsPerDay: 10,
+ });
+
+ const pushResult: SyncPushResult = {
+ decks: [
+ { id: deck1.id, syncVersion: 1 },
+ { id: deck2.id, syncVersion: 1 },
+ ],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [deck1.id, deck2.id], cards: [] },
+ };
+
+ const pullResult: SyncPullResult = {
+ decks: [
+ {
+ id: deck1.id,
+ userId: "user-1",
+ name: "Server Deck 1",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 5,
+ },
+ {
+ id: deck2.id,
+ userId: "user-1",
+ name: "Server Deck 2",
+ description: null,
+ newCardsPerDay: 25,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 6,
+ },
+ ],
+ cards: [],
+ reviewLogs: [],
+ currentSyncVersion: 6,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveConflicts(pushResult, pullResult);
+
+ expect(result.decks).toHaveLength(2);
+ expect(result.decks[0]?.resolution).toBe("server_wins");
+ expect(result.decks[1]?.resolution).toBe("server_wins");
+
+ const updatedDeck1 = await localDeckRepository.findById(deck1.id);
+ const updatedDeck2 = await localDeckRepository.findById(deck2.id);
+ expect(updatedDeck1?.name).toBe("Server Deck 1");
+ expect(updatedDeck2?.name).toBe("Server Deck 2");
+ });
+
+ it("should resolve card conflicts", 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: "Local Question",
+ back: "Local Answer",
+ });
+
+ const pushResult: SyncPushResult = {
+ decks: [],
+ cards: [{ id: card.id, syncVersion: 1 }],
+ reviewLogs: [],
+ conflicts: { decks: [], cards: [card.id] },
+ };
+
+ const pullResult: SyncPullResult = {
+ decks: [],
+ cards: [
+ {
+ id: card.id,
+ deckId: deck.id,
+ front: "Server Question",
+ back: "Server Answer",
+ state: CardState.New,
+ due: new Date(),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 3,
+ },
+ ],
+ reviewLogs: [],
+ currentSyncVersion: 3,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveConflicts(pushResult, pullResult);
+
+ expect(result.cards).toHaveLength(1);
+ expect(result.cards[0]?.resolution).toBe("server_wins");
+
+ const updatedCard = await localCardRepository.findById(card.id);
+ expect(updatedCard?.front).toBe("Server Question");
+ });
+
+ it("should handle conflicts when local item does not exist", async () => {
+ const pushResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: ["non-existent-deck"], cards: [] },
+ };
+
+ const pullResult: SyncPullResult = {
+ decks: [
+ {
+ id: "non-existent-deck",
+ userId: "user-1",
+ name: "Server Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 1,
+ },
+ ],
+ cards: [],
+ reviewLogs: [],
+ currentSyncVersion: 1,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveConflicts(pushResult, pullResult);
+
+ expect(result.decks).toHaveLength(1);
+ expect(result.decks[0]?.resolution).toBe("server_wins");
+
+ const insertedDeck = await localDeckRepository.findById("non-existent-deck");
+ expect(insertedDeck?.name).toBe("Server Deck");
+ });
+
+ it("should handle conflicts when server item does not exist", async () => {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Only Deck",
+ description: null,
+ newCardsPerDay: 10,
+ });
+
+ const pushResult: SyncPushResult = {
+ decks: [{ id: deck.id, syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [deck.id], cards: [] },
+ };
+
+ const pullResult: SyncPullResult = {
+ decks: [], // Server doesn't have this deck
+ cards: [],
+ reviewLogs: [],
+ currentSyncVersion: 0,
+ };
+
+ const resolver = new ConflictResolver({ strategy: "server_wins" });
+ const result = await resolver.resolveConflicts(pushResult, pullResult);
+
+ // No resolution since server doesn't have the item
+ expect(result.decks).toHaveLength(0);
+
+ // Local deck should still exist
+ const localDeck = await localDeckRepository.findById(deck.id);
+ expect(localDeck?.name).toBe("Local Only Deck");
+ });
+
+ it("should default to server_wins strategy", async () => {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Local Name",
+ description: null,
+ newCardsPerDay: 10,
+ });
+
+ const pushResult: SyncPushResult = {
+ decks: [{ id: deck.id, syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ conflicts: { decks: [deck.id], cards: [] },
+ };
+
+ const pullResult: SyncPullResult = {
+ decks: [
+ {
+ id: deck.id,
+ userId: "user-1",
+ name: "Server Name",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ syncVersion: 5,
+ },
+ ],
+ cards: [],
+ reviewLogs: [],
+ currentSyncVersion: 5,
+ };
+
+ // Create resolver without explicit strategy
+ const resolver = new ConflictResolver();
+ const result = await resolver.resolveConflicts(pushResult, pullResult);
+
+ expect(result.decks[0]?.resolution).toBe("server_wins");
+
+ const updatedDeck = await localDeckRepository.findById(deck.id);
+ expect(updatedDeck?.name).toBe("Server Name");
+ });
+ });
+});
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" });
diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts
index 80d0cc1..2472871 100644
--- a/src/client/sync/index.ts
+++ b/src/client/sync/index.ts
@@ -30,3 +30,12 @@ export {
type ServerReviewLog,
type SyncPullResult,
} from "./pull";
+
+export {
+ ConflictResolver,
+ conflictResolver,
+ createConflictResolver,
+ type ConflictResolutionItem,
+ type ConflictResolutionResult,
+ type ConflictResolverOptions,
+} from "./conflict";