1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
|
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",
});
|