@korajs/merge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +763 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +245 -0
- package/dist/index.d.ts +245 -0
- package/dist/index.js +718 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
// src/strategies/lww.ts
|
|
2
|
+
import { HybridLogicalClock } from "@korajs/core";
|
|
3
|
+
function lastWriteWins(localValue, remoteValue, localTimestamp, remoteTimestamp) {
|
|
4
|
+
const comparison = HybridLogicalClock.compare(localTimestamp, remoteTimestamp);
|
|
5
|
+
if (comparison >= 0) {
|
|
6
|
+
return { value: localValue, winner: "local" };
|
|
7
|
+
}
|
|
8
|
+
return { value: remoteValue, winner: "remote" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/strategies/add-wins-set.ts
|
|
12
|
+
function addWinsSet(localArray, remoteArray, baseArray) {
|
|
13
|
+
const serialize = (v) => JSON.stringify(v);
|
|
14
|
+
const baseSet = new Set(baseArray.map(serialize));
|
|
15
|
+
const localSet = new Set(localArray.map(serialize));
|
|
16
|
+
const remoteSet = new Set(remoteArray.map(serialize));
|
|
17
|
+
const addedLocal = /* @__PURE__ */ new Set();
|
|
18
|
+
for (const s of localSet) {
|
|
19
|
+
if (!baseSet.has(s)) {
|
|
20
|
+
addedLocal.add(s);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const addedRemote = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const s of remoteSet) {
|
|
25
|
+
if (!baseSet.has(s)) {
|
|
26
|
+
addedRemote.add(s);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const removedLocal = /* @__PURE__ */ new Set();
|
|
30
|
+
for (const s of baseSet) {
|
|
31
|
+
if (!localSet.has(s)) {
|
|
32
|
+
removedLocal.add(s);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const removedRemote = /* @__PURE__ */ new Set();
|
|
36
|
+
for (const s of baseSet) {
|
|
37
|
+
if (!remoteSet.has(s)) {
|
|
38
|
+
removedRemote.add(s);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const removedByBoth = /* @__PURE__ */ new Set();
|
|
42
|
+
for (const s of removedLocal) {
|
|
43
|
+
if (removedRemote.has(s)) {
|
|
44
|
+
removedByBoth.add(s);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const resultSerialized = /* @__PURE__ */ new Set();
|
|
48
|
+
const result = [];
|
|
49
|
+
const addIfNew = (serialized, value) => {
|
|
50
|
+
if (!resultSerialized.has(serialized) && !removedByBoth.has(serialized)) {
|
|
51
|
+
resultSerialized.add(serialized);
|
|
52
|
+
result.push(value);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
for (const item of baseArray) {
|
|
56
|
+
addIfNew(serialize(item), item);
|
|
57
|
+
}
|
|
58
|
+
for (const item of localArray) {
|
|
59
|
+
const s = serialize(item);
|
|
60
|
+
if (addedLocal.has(s)) {
|
|
61
|
+
addIfNew(s, item);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const item of remoteArray) {
|
|
65
|
+
const s = serialize(item);
|
|
66
|
+
if (addedRemote.has(s)) {
|
|
67
|
+
addIfNew(s, item);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/strategies/yjs-richtext.ts
|
|
74
|
+
import * as Y from "yjs";
|
|
75
|
+
var TEXT_KEY = "content";
|
|
76
|
+
function mergeRichtext(localValue, remoteValue, baseValue) {
|
|
77
|
+
const mergedDoc = new Y.Doc();
|
|
78
|
+
Y.applyUpdate(mergedDoc, toYjsUpdate(baseValue));
|
|
79
|
+
Y.applyUpdate(mergedDoc, toYjsUpdate(localValue));
|
|
80
|
+
Y.applyUpdate(mergedDoc, toYjsUpdate(remoteValue));
|
|
81
|
+
return Y.encodeStateAsUpdate(mergedDoc);
|
|
82
|
+
}
|
|
83
|
+
function richtextToString(value) {
|
|
84
|
+
const doc = new Y.Doc();
|
|
85
|
+
Y.applyUpdate(doc, toYjsUpdate(value));
|
|
86
|
+
return doc.getText(TEXT_KEY).toString();
|
|
87
|
+
}
|
|
88
|
+
function stringToRichtextUpdate(value) {
|
|
89
|
+
const doc = new Y.Doc();
|
|
90
|
+
doc.getText(TEXT_KEY).insert(0, value);
|
|
91
|
+
return Y.encodeStateAsUpdate(doc);
|
|
92
|
+
}
|
|
93
|
+
function toYjsUpdate(value) {
|
|
94
|
+
if (value === null || value === void 0) {
|
|
95
|
+
return Y.encodeStateAsUpdate(new Y.Doc());
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "string") {
|
|
98
|
+
return stringToRichtextUpdate(value);
|
|
99
|
+
}
|
|
100
|
+
if (value instanceof Uint8Array) {
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
if (value instanceof ArrayBuffer) {
|
|
104
|
+
return new Uint8Array(value);
|
|
105
|
+
}
|
|
106
|
+
throw new Error("Richtext value must be a string, Uint8Array, ArrayBuffer, null, or undefined.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/engine/field-merger.ts
|
|
110
|
+
function mergeField(fieldName, localOp, remoteOp, baseState, fieldDescriptor, resolver) {
|
|
111
|
+
const startTime = Date.now();
|
|
112
|
+
const localData = localOp.data ?? {};
|
|
113
|
+
const remoteData = remoteOp.data ?? {};
|
|
114
|
+
const localPrevious = localOp.previousData ?? {};
|
|
115
|
+
const remotePrevious = remoteOp.previousData ?? {};
|
|
116
|
+
const localChanged = fieldName in localData;
|
|
117
|
+
const remoteChanged = fieldName in remoteData;
|
|
118
|
+
const baseValue = baseState[fieldName];
|
|
119
|
+
if (localChanged && !remoteChanged) {
|
|
120
|
+
return createResult(
|
|
121
|
+
localData[fieldName],
|
|
122
|
+
fieldName,
|
|
123
|
+
localOp,
|
|
124
|
+
remoteOp,
|
|
125
|
+
localData[fieldName],
|
|
126
|
+
baseValue,
|
|
127
|
+
baseValue,
|
|
128
|
+
"no-conflict-local",
|
|
129
|
+
1,
|
|
130
|
+
startTime
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!localChanged && remoteChanged) {
|
|
134
|
+
return createResult(
|
|
135
|
+
remoteData[fieldName],
|
|
136
|
+
fieldName,
|
|
137
|
+
localOp,
|
|
138
|
+
remoteOp,
|
|
139
|
+
baseValue,
|
|
140
|
+
remoteData[fieldName],
|
|
141
|
+
baseValue,
|
|
142
|
+
"no-conflict-remote",
|
|
143
|
+
1,
|
|
144
|
+
startTime
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!localChanged && !remoteChanged) {
|
|
148
|
+
return createResult(
|
|
149
|
+
baseValue,
|
|
150
|
+
fieldName,
|
|
151
|
+
localOp,
|
|
152
|
+
remoteOp,
|
|
153
|
+
baseValue,
|
|
154
|
+
baseValue,
|
|
155
|
+
baseValue,
|
|
156
|
+
"no-conflict-unchanged",
|
|
157
|
+
1,
|
|
158
|
+
startTime
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const localValue = localData[fieldName];
|
|
162
|
+
const remoteValue = remoteData[fieldName];
|
|
163
|
+
if (resolver !== void 0) {
|
|
164
|
+
const resolved = resolver(localValue, remoteValue, baseValue);
|
|
165
|
+
return createResult(
|
|
166
|
+
resolved,
|
|
167
|
+
fieldName,
|
|
168
|
+
localOp,
|
|
169
|
+
remoteOp,
|
|
170
|
+
localValue,
|
|
171
|
+
remoteValue,
|
|
172
|
+
baseValue,
|
|
173
|
+
"custom",
|
|
174
|
+
3,
|
|
175
|
+
startTime
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return autoMerge(
|
|
179
|
+
fieldName,
|
|
180
|
+
localOp,
|
|
181
|
+
remoteOp,
|
|
182
|
+
localValue,
|
|
183
|
+
remoteValue,
|
|
184
|
+
baseValue,
|
|
185
|
+
fieldDescriptor,
|
|
186
|
+
startTime
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
function autoMerge(fieldName, localOp, remoteOp, localValue, remoteValue, baseValue, fieldDescriptor, startTime) {
|
|
190
|
+
switch (fieldDescriptor.kind) {
|
|
191
|
+
case "string":
|
|
192
|
+
case "number":
|
|
193
|
+
case "boolean":
|
|
194
|
+
case "enum":
|
|
195
|
+
case "timestamp": {
|
|
196
|
+
const lwwResult = lastWriteWins(
|
|
197
|
+
localValue,
|
|
198
|
+
remoteValue,
|
|
199
|
+
localOp.timestamp,
|
|
200
|
+
remoteOp.timestamp
|
|
201
|
+
);
|
|
202
|
+
return createResult(
|
|
203
|
+
lwwResult.value,
|
|
204
|
+
fieldName,
|
|
205
|
+
localOp,
|
|
206
|
+
remoteOp,
|
|
207
|
+
localValue,
|
|
208
|
+
remoteValue,
|
|
209
|
+
baseValue,
|
|
210
|
+
"lww",
|
|
211
|
+
1,
|
|
212
|
+
startTime
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
case "array": {
|
|
216
|
+
const baseArr = Array.isArray(baseValue) ? baseValue : [];
|
|
217
|
+
const localArr = Array.isArray(localValue) ? localValue : [];
|
|
218
|
+
const remoteArr = Array.isArray(remoteValue) ? remoteValue : [];
|
|
219
|
+
const merged = addWinsSet(localArr, remoteArr, baseArr);
|
|
220
|
+
return createResult(
|
|
221
|
+
merged,
|
|
222
|
+
fieldName,
|
|
223
|
+
localOp,
|
|
224
|
+
remoteOp,
|
|
225
|
+
localValue,
|
|
226
|
+
remoteValue,
|
|
227
|
+
baseValue,
|
|
228
|
+
"add-wins-set",
|
|
229
|
+
1,
|
|
230
|
+
startTime
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
case "richtext": {
|
|
234
|
+
const merged = mergeRichtext(
|
|
235
|
+
localValue,
|
|
236
|
+
remoteValue,
|
|
237
|
+
baseValue
|
|
238
|
+
);
|
|
239
|
+
return createResult(
|
|
240
|
+
merged,
|
|
241
|
+
fieldName,
|
|
242
|
+
localOp,
|
|
243
|
+
remoteOp,
|
|
244
|
+
localValue,
|
|
245
|
+
remoteValue,
|
|
246
|
+
baseValue,
|
|
247
|
+
"crdt-text",
|
|
248
|
+
1,
|
|
249
|
+
startTime
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function createResult(value, field, operationA, operationB, inputA, inputB, base, strategy, tier, startTime) {
|
|
255
|
+
const trace = {
|
|
256
|
+
operationA,
|
|
257
|
+
operationB,
|
|
258
|
+
field,
|
|
259
|
+
strategy,
|
|
260
|
+
inputA,
|
|
261
|
+
inputB,
|
|
262
|
+
base,
|
|
263
|
+
output: value,
|
|
264
|
+
tier,
|
|
265
|
+
constraintViolated: null,
|
|
266
|
+
duration: Date.now() - startTime
|
|
267
|
+
};
|
|
268
|
+
return { value, trace };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/constraints/constraint-checker.ts
|
|
272
|
+
async function checkConstraints(mergedRecord, recordId, collection, collectionDef, constraintContext) {
|
|
273
|
+
const violations = [];
|
|
274
|
+
for (const constraint of collectionDef.constraints) {
|
|
275
|
+
if (constraint.type !== "referential" && constraint.where !== void 0 && !matchesWhere(mergedRecord, constraint.where)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const violation = await checkSingleConstraint(
|
|
279
|
+
constraint,
|
|
280
|
+
mergedRecord,
|
|
281
|
+
recordId,
|
|
282
|
+
collection,
|
|
283
|
+
constraintContext
|
|
284
|
+
);
|
|
285
|
+
if (violation !== null) {
|
|
286
|
+
violations.push(violation);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return violations;
|
|
290
|
+
}
|
|
291
|
+
async function checkSingleConstraint(constraint, mergedRecord, recordId, collection, ctx) {
|
|
292
|
+
switch (constraint.type) {
|
|
293
|
+
case "unique":
|
|
294
|
+
return checkUniqueConstraint(constraint, mergedRecord, recordId, collection, ctx);
|
|
295
|
+
case "capacity":
|
|
296
|
+
return checkCapacityConstraint(constraint, mergedRecord, collection, ctx);
|
|
297
|
+
case "referential":
|
|
298
|
+
return checkReferentialConstraint(constraint, mergedRecord, collection, ctx);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function checkUniqueConstraint(constraint, mergedRecord, recordId, collection, ctx) {
|
|
302
|
+
const where = {};
|
|
303
|
+
for (const field of constraint.fields) {
|
|
304
|
+
where[field] = mergedRecord[field];
|
|
305
|
+
}
|
|
306
|
+
const existing = await ctx.queryRecords(collection, where);
|
|
307
|
+
const duplicates = existing.filter((r) => r.id !== recordId);
|
|
308
|
+
if (duplicates.length > 0) {
|
|
309
|
+
return {
|
|
310
|
+
constraint,
|
|
311
|
+
fields: constraint.fields,
|
|
312
|
+
message: `Unique constraint violated on fields [${constraint.fields.join(", ")}] in collection "${collection}": duplicate value(s) found`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
async function checkCapacityConstraint(constraint, mergedRecord, collection, ctx) {
|
|
318
|
+
const where = constraint.where ?? {};
|
|
319
|
+
const count = await ctx.countRecords(collection, where);
|
|
320
|
+
if (count > 0 && constraint.fields.length > 0) {
|
|
321
|
+
const groupWhere = { ...where };
|
|
322
|
+
for (const field of constraint.fields) {
|
|
323
|
+
groupWhere[field] = mergedRecord[field];
|
|
324
|
+
}
|
|
325
|
+
const groupCount = await ctx.countRecords(collection, groupWhere);
|
|
326
|
+
if (groupCount > 1) {
|
|
327
|
+
return {
|
|
328
|
+
constraint,
|
|
329
|
+
fields: constraint.fields,
|
|
330
|
+
message: `Capacity constraint violated on fields [${constraint.fields.join(", ")}] in collection "${collection}": group count ${groupCount} exceeds limit`
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
async function checkReferentialConstraint(constraint, mergedRecord, collection, ctx) {
|
|
337
|
+
if (constraint.fields.length === 0) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const fkField = constraint.fields[0];
|
|
341
|
+
if (fkField === void 0) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const fkValue = mergedRecord[fkField];
|
|
345
|
+
if (fkValue === null || fkValue === void 0) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const referencedCollection = constraint.where !== void 0 ? constraint.where.collection : void 0;
|
|
349
|
+
if (referencedCollection === void 0) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const referenced = await ctx.queryRecords(referencedCollection, { id: fkValue });
|
|
353
|
+
if (referenced.length === 0) {
|
|
354
|
+
return {
|
|
355
|
+
constraint,
|
|
356
|
+
fields: constraint.fields,
|
|
357
|
+
message: `Referential constraint violated on field "${fkField}" in collection "${collection}": referenced record not found in "${referencedCollection}" with id "${String(fkValue)}"`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
function matchesWhere(record, where) {
|
|
363
|
+
for (const [key, value] of Object.entries(where)) {
|
|
364
|
+
if (record[key] !== value) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/constraints/resolvers.ts
|
|
372
|
+
import { HybridLogicalClock as HybridLogicalClock2 } from "@korajs/core";
|
|
373
|
+
function resolveConstraintViolation(violation, mergedRecord, localOp, remoteOp, baseState) {
|
|
374
|
+
const startTime = Date.now();
|
|
375
|
+
const { constraint } = violation;
|
|
376
|
+
switch (constraint.onConflict) {
|
|
377
|
+
case "last-write-wins": {
|
|
378
|
+
const comparison = HybridLogicalClock2.compare(localOp.timestamp, remoteOp.timestamp);
|
|
379
|
+
const winner = comparison >= 0 ? localOp : remoteOp;
|
|
380
|
+
const resolvedRecord = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
381
|
+
return createResolution(
|
|
382
|
+
resolvedRecord,
|
|
383
|
+
violation,
|
|
384
|
+
localOp,
|
|
385
|
+
remoteOp,
|
|
386
|
+
baseState,
|
|
387
|
+
"constraint-lww",
|
|
388
|
+
startTime
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
case "first-write-wins": {
|
|
392
|
+
const comparison = HybridLogicalClock2.compare(localOp.timestamp, remoteOp.timestamp);
|
|
393
|
+
const winner = comparison <= 0 ? localOp : remoteOp;
|
|
394
|
+
const resolvedRecord = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
395
|
+
return createResolution(
|
|
396
|
+
resolvedRecord,
|
|
397
|
+
violation,
|
|
398
|
+
localOp,
|
|
399
|
+
remoteOp,
|
|
400
|
+
baseState,
|
|
401
|
+
"constraint-fww",
|
|
402
|
+
startTime
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
case "priority-field": {
|
|
406
|
+
const priorityField = constraint.priorityField;
|
|
407
|
+
if (priorityField === void 0) {
|
|
408
|
+
const comparison = HybridLogicalClock2.compare(localOp.timestamp, remoteOp.timestamp);
|
|
409
|
+
const winner2 = comparison >= 0 ? localOp : remoteOp;
|
|
410
|
+
const resolvedRecord2 = applyWinnerFields(mergedRecord, winner2, violation.fields);
|
|
411
|
+
return createResolution(
|
|
412
|
+
resolvedRecord2,
|
|
413
|
+
violation,
|
|
414
|
+
localOp,
|
|
415
|
+
remoteOp,
|
|
416
|
+
baseState,
|
|
417
|
+
"constraint-priority-fallback-lww",
|
|
418
|
+
startTime
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const localPriority = getFieldValue(localOp, priorityField, mergedRecord);
|
|
422
|
+
const remotePriority = getFieldValue(remoteOp, priorityField, mergedRecord);
|
|
423
|
+
const winner = comparePriority(localPriority, remotePriority) >= 0 ? localOp : remoteOp;
|
|
424
|
+
const resolvedRecord = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
425
|
+
return createResolution(
|
|
426
|
+
resolvedRecord,
|
|
427
|
+
violation,
|
|
428
|
+
localOp,
|
|
429
|
+
remoteOp,
|
|
430
|
+
baseState,
|
|
431
|
+
"constraint-priority",
|
|
432
|
+
startTime
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
case "server-decides": {
|
|
436
|
+
const resolvedRecord = {
|
|
437
|
+
...mergedRecord,
|
|
438
|
+
_pendingServerResolution: true
|
|
439
|
+
};
|
|
440
|
+
return createResolution(
|
|
441
|
+
resolvedRecord,
|
|
442
|
+
violation,
|
|
443
|
+
localOp,
|
|
444
|
+
remoteOp,
|
|
445
|
+
baseState,
|
|
446
|
+
"constraint-server-decides",
|
|
447
|
+
startTime
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
case "custom": {
|
|
451
|
+
if (constraint.resolve === void 0) {
|
|
452
|
+
const comparison = HybridLogicalClock2.compare(localOp.timestamp, remoteOp.timestamp);
|
|
453
|
+
const winner = comparison >= 0 ? localOp : remoteOp;
|
|
454
|
+
const resolvedRecord2 = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
455
|
+
return createResolution(
|
|
456
|
+
resolvedRecord2,
|
|
457
|
+
violation,
|
|
458
|
+
localOp,
|
|
459
|
+
remoteOp,
|
|
460
|
+
baseState,
|
|
461
|
+
"constraint-custom-fallback-lww",
|
|
462
|
+
startTime
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const resolvedRecord = { ...mergedRecord };
|
|
466
|
+
for (const field of violation.fields) {
|
|
467
|
+
const localVal = getFieldValue(localOp, field, mergedRecord);
|
|
468
|
+
const remoteVal = getFieldValue(remoteOp, field, mergedRecord);
|
|
469
|
+
const baseVal = baseState[field];
|
|
470
|
+
resolvedRecord[field] = constraint.resolve(localVal, remoteVal, baseVal);
|
|
471
|
+
}
|
|
472
|
+
return createResolution(
|
|
473
|
+
resolvedRecord,
|
|
474
|
+
violation,
|
|
475
|
+
localOp,
|
|
476
|
+
remoteOp,
|
|
477
|
+
baseState,
|
|
478
|
+
"constraint-custom",
|
|
479
|
+
startTime
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function applyWinnerFields(mergedRecord, winner, fields) {
|
|
485
|
+
const result = { ...mergedRecord };
|
|
486
|
+
const winnerData = winner.data ?? {};
|
|
487
|
+
for (const field of fields) {
|
|
488
|
+
if (field in winnerData) {
|
|
489
|
+
result[field] = winnerData[field];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
function getFieldValue(op, field, mergedRecord) {
|
|
495
|
+
const data = op.data ?? {};
|
|
496
|
+
if (field in data) {
|
|
497
|
+
return data[field];
|
|
498
|
+
}
|
|
499
|
+
return mergedRecord[field];
|
|
500
|
+
}
|
|
501
|
+
function comparePriority(a, b) {
|
|
502
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
503
|
+
return a - b;
|
|
504
|
+
}
|
|
505
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
506
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
507
|
+
}
|
|
508
|
+
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
509
|
+
}
|
|
510
|
+
function createResolution(resolvedRecord, violation, localOp, remoteOp, baseState, strategy, startTime) {
|
|
511
|
+
const field = violation.fields.join(", ");
|
|
512
|
+
const trace = {
|
|
513
|
+
operationA: localOp,
|
|
514
|
+
operationB: remoteOp,
|
|
515
|
+
field,
|
|
516
|
+
strategy,
|
|
517
|
+
inputA: extractFieldValues(localOp, violation.fields),
|
|
518
|
+
inputB: extractFieldValues(remoteOp, violation.fields),
|
|
519
|
+
base: extractFields(baseState, violation.fields),
|
|
520
|
+
output: extractFields(resolvedRecord, violation.fields),
|
|
521
|
+
tier: 2,
|
|
522
|
+
constraintViolated: violation.message,
|
|
523
|
+
duration: Date.now() - startTime
|
|
524
|
+
};
|
|
525
|
+
return { resolvedRecord, trace };
|
|
526
|
+
}
|
|
527
|
+
function extractFieldValues(op, fields) {
|
|
528
|
+
const data = op.data ?? {};
|
|
529
|
+
const result = {};
|
|
530
|
+
for (const field of fields) {
|
|
531
|
+
result[field] = data[field];
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
function extractFields(record, fields) {
|
|
536
|
+
const result = {};
|
|
537
|
+
for (const field of fields) {
|
|
538
|
+
result[field] = record[field];
|
|
539
|
+
}
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/engine/merge-engine.ts
|
|
544
|
+
import { HybridLogicalClock as HybridLogicalClock3 } from "@korajs/core";
|
|
545
|
+
var MergeEngine = class {
|
|
546
|
+
/**
|
|
547
|
+
* Merge two concurrent operations with all three tiers.
|
|
548
|
+
*
|
|
549
|
+
* Flow:
|
|
550
|
+
* 1. Determine which fields conflict (both ops modified the same field)
|
|
551
|
+
* 2. For non-conflicting fields: take the changed value from whichever op modified it
|
|
552
|
+
* 3. For conflicting fields: Tier 3 custom resolver if exists, else Tier 1 auto-merge
|
|
553
|
+
* 4. Assemble candidate merged record
|
|
554
|
+
* 5. If constraintContext provided: run Tier 2 constraint checks and resolve violations
|
|
555
|
+
* 6. Return MergeResult with all traces
|
|
556
|
+
*
|
|
557
|
+
* @param input - The two operations, base state, and collection definition
|
|
558
|
+
* @param constraintContext - Optional DB lookup interface for Tier 2 constraints
|
|
559
|
+
* @returns The merged data and traces for DevTools
|
|
560
|
+
*/
|
|
561
|
+
async merge(input, constraintContext) {
|
|
562
|
+
if (input.local.type === "delete" && input.remote.type === "delete") {
|
|
563
|
+
return {
|
|
564
|
+
mergedData: {},
|
|
565
|
+
traces: [],
|
|
566
|
+
appliedOperation: "merged"
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (input.local.type === "delete" || input.remote.type === "delete") {
|
|
570
|
+
return this.mergeWithDelete(input);
|
|
571
|
+
}
|
|
572
|
+
const fieldResult = this.mergeFields(input);
|
|
573
|
+
if (constraintContext !== void 0 && input.collectionDef.constraints.length > 0) {
|
|
574
|
+
const recordWithId = { id: input.local.recordId, ...fieldResult.mergedData };
|
|
575
|
+
const violations = await checkConstraints(
|
|
576
|
+
recordWithId,
|
|
577
|
+
input.local.recordId,
|
|
578
|
+
input.local.collection,
|
|
579
|
+
input.collectionDef,
|
|
580
|
+
constraintContext
|
|
581
|
+
);
|
|
582
|
+
let mergedData = fieldResult.mergedData;
|
|
583
|
+
const allTraces = [...fieldResult.traces];
|
|
584
|
+
for (const violation of violations) {
|
|
585
|
+
const resolution = resolveConstraintViolation(
|
|
586
|
+
violation,
|
|
587
|
+
mergedData,
|
|
588
|
+
input.local,
|
|
589
|
+
input.remote,
|
|
590
|
+
input.baseState
|
|
591
|
+
);
|
|
592
|
+
mergedData = resolution.resolvedRecord;
|
|
593
|
+
allTraces.push(resolution.trace);
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
mergedData,
|
|
597
|
+
traces: allTraces,
|
|
598
|
+
appliedOperation: determineAppliedOperation(allTraces)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return fieldResult;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Synchronous field-level merge (Tier 1 + Tier 3 only).
|
|
605
|
+
*
|
|
606
|
+
* Useful when constraint context is unavailable or not needed.
|
|
607
|
+
* Skips Tier 2 constraint checking entirely.
|
|
608
|
+
*
|
|
609
|
+
* @param input - The two operations, base state, and collection definition
|
|
610
|
+
* @returns The merged data and traces for DevTools
|
|
611
|
+
*/
|
|
612
|
+
mergeFields(input) {
|
|
613
|
+
const { local, remote, baseState, collectionDef } = input;
|
|
614
|
+
const allFields = collectAffectedFields(local, remote, baseState, collectionDef);
|
|
615
|
+
const mergedData = {};
|
|
616
|
+
const traces = [];
|
|
617
|
+
for (const fieldName of allFields) {
|
|
618
|
+
const fieldDef = collectionDef.fields[fieldName];
|
|
619
|
+
if (fieldDef === void 0) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const resolver = collectionDef.resolvers[fieldName];
|
|
623
|
+
const result = mergeField(fieldName, local, remote, baseState, fieldDef, resolver);
|
|
624
|
+
mergedData[fieldName] = result.value;
|
|
625
|
+
if (result.trace.strategy !== "no-conflict-local" && result.trace.strategy !== "no-conflict-remote" && result.trace.strategy !== "no-conflict-unchanged") {
|
|
626
|
+
traces.push(result.trace);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
mergedData,
|
|
631
|
+
traces,
|
|
632
|
+
appliedOperation: determineAppliedOperation(traces)
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Handle merge when one operation is a delete.
|
|
637
|
+
* Default: delete wins (LWW on the record level).
|
|
638
|
+
*/
|
|
639
|
+
mergeWithDelete(input) {
|
|
640
|
+
const { local, remote } = input;
|
|
641
|
+
const comparison = HybridLogicalClock3.compare(local.timestamp, remote.timestamp);
|
|
642
|
+
if (comparison >= 0) {
|
|
643
|
+
if (local.type === "delete") {
|
|
644
|
+
return { mergedData: {}, traces: [], appliedOperation: "local" };
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
mergedData: { ...input.baseState, ...local.data ?? {} },
|
|
648
|
+
traces: [],
|
|
649
|
+
appliedOperation: "local"
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (remote.type === "delete") {
|
|
653
|
+
return { mergedData: {}, traces: [], appliedOperation: "remote" };
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
mergedData: { ...input.baseState, ...remote.data ?? {} },
|
|
657
|
+
traces: [],
|
|
658
|
+
appliedOperation: "remote"
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
function collectAffectedFields(local, remote, baseState, collectionDef) {
|
|
663
|
+
const fields = /* @__PURE__ */ new Set();
|
|
664
|
+
for (const fieldName of Object.keys(collectionDef.fields)) {
|
|
665
|
+
fields.add(fieldName);
|
|
666
|
+
}
|
|
667
|
+
if (local.data !== null) {
|
|
668
|
+
for (const fieldName of Object.keys(local.data)) {
|
|
669
|
+
fields.add(fieldName);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (remote.data !== null) {
|
|
673
|
+
for (const fieldName of Object.keys(remote.data)) {
|
|
674
|
+
fields.add(fieldName);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
for (const fieldName of Object.keys(baseState)) {
|
|
678
|
+
fields.add(fieldName);
|
|
679
|
+
}
|
|
680
|
+
return fields;
|
|
681
|
+
}
|
|
682
|
+
function determineAppliedOperation(traces) {
|
|
683
|
+
if (traces.length === 0) {
|
|
684
|
+
return "merged";
|
|
685
|
+
}
|
|
686
|
+
let allLocal = true;
|
|
687
|
+
let allRemote = true;
|
|
688
|
+
for (const trace of traces) {
|
|
689
|
+
if (trace.strategy === "lww" || trace.strategy === "constraint-lww") {
|
|
690
|
+
if (trace.output === trace.inputA) {
|
|
691
|
+
allRemote = false;
|
|
692
|
+
} else if (trace.output === trace.inputB) {
|
|
693
|
+
allLocal = false;
|
|
694
|
+
} else {
|
|
695
|
+
allLocal = false;
|
|
696
|
+
allRemote = false;
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
allLocal = false;
|
|
700
|
+
allRemote = false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (allLocal) return "local";
|
|
704
|
+
if (allRemote) return "remote";
|
|
705
|
+
return "merged";
|
|
706
|
+
}
|
|
707
|
+
export {
|
|
708
|
+
MergeEngine,
|
|
709
|
+
addWinsSet,
|
|
710
|
+
checkConstraints,
|
|
711
|
+
lastWriteWins,
|
|
712
|
+
mergeField,
|
|
713
|
+
mergeRichtext,
|
|
714
|
+
resolveConstraintViolation,
|
|
715
|
+
richtextToString,
|
|
716
|
+
stringToRichtextUpdate
|
|
717
|
+
};
|
|
718
|
+
//# sourceMappingURL=index.js.map
|