diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:32:36 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:32:36 +0900 |
| commit | 8ef0e4a54986f7e334136d195b7081f176de0282 (patch) | |
| tree | 956da5f24a4c36055939f183dc68f5a5a55e4aa9 | |
| parent | 864495bd4d7156ee433cbc12adda4bdebd43f6fe (diff) | |
| download | kioku-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.md | 2 | ||||
| -rw-r--r-- | src/client/sync/conflict.test.ts | 550 | ||||
| -rw-r--r-- | src/client/sync/conflict.ts | 269 | ||||
| -rw-r--r-- | src/client/sync/index.ts | 9 |
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"; |
