@korajs/merge 0.6.0 → 1.0.0-beta.1
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/LICENSE +21 -0
- package/dist/index.cjs +197 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -3
- package/dist/index.d.ts +39 -3
- package/dist/index.js +188 -14
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OnDeleteAction, MergeTrace, SchemaDefinition, Operation, Constraint, CollectionDefinition, HLCTimestamp, AtomicOp, FieldMergeStrategy, FieldDescriptor, CustomResolver } from '@korajs/core';
|
|
1
|
+
import { OnDeleteAction, MergeTrace, SchemaDefinition, Operation, Constraint, CollectionDefinition, HLCTimestamp, AtomicOp, KoraBytesValue, FieldMergeStrategy, FieldDescriptor, CustomResolver } from '@korajs/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Describes an incoming relation to a target collection.
|
|
@@ -233,6 +233,42 @@ declare function lastWriteWins(localValue: unknown, remoteValue: unknown, localT
|
|
|
233
233
|
*/
|
|
234
234
|
declare function addWinsSet(localArray: unknown[], remoteArray: unknown[], baseArray: unknown[]): unknown[];
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Convergent merge for `object` and `json` fields.
|
|
238
|
+
*
|
|
239
|
+
* Structured values are merged as a 3-way LWW map with add-wins key presence:
|
|
240
|
+
* two devices that edit different keys of the same object offline both keep
|
|
241
|
+
* their edits on reconnect, instead of one write clobbering the whole object.
|
|
242
|
+
*
|
|
243
|
+
* Per key, each side's action is classified against the base (the value before
|
|
244
|
+
* either operation):
|
|
245
|
+
* - wrote (added the key, or changed its value)
|
|
246
|
+
* - removed (present in base, absent now)
|
|
247
|
+
* - unchanged
|
|
248
|
+
*
|
|
249
|
+
* Resolution:
|
|
250
|
+
* - unchanged + unchanged → base value
|
|
251
|
+
* - wrote + unchanged → the written value
|
|
252
|
+
* - removed + unchanged → absent
|
|
253
|
+
* - removed + removed → absent
|
|
254
|
+
* - wrote + removed → the write wins (add-wins: never silently drop an edit)
|
|
255
|
+
* - wrote + wrote → recurse if both are plain objects; add-wins if both
|
|
256
|
+
* arrays; otherwise last-write-wins by the operations'
|
|
257
|
+
* HLC timestamps
|
|
258
|
+
*
|
|
259
|
+
* The result is commutative (swapping local/remote also swaps the HLCs, and HLC
|
|
260
|
+
* compare is a symmetric total order), idempotent (merging a value with itself
|
|
261
|
+
* returns it), and deterministic. Absent keys are omitted from the result.
|
|
262
|
+
*
|
|
263
|
+
* @param localValue - Local value after the local operation
|
|
264
|
+
* @param remoteValue - Remote value after the remote operation
|
|
265
|
+
* @param baseValue - Value before either operation (for 3-way diffing)
|
|
266
|
+
* @param localTs - HLC timestamp of the local operation
|
|
267
|
+
* @param remoteTs - HLC timestamp of the remote operation
|
|
268
|
+
* @returns The merged object
|
|
269
|
+
*/
|
|
270
|
+
declare function mergeObject(localValue: unknown, remoteValue: unknown, baseValue: unknown, localTs: HLCTimestamp, remoteTs: HLCTimestamp): Record<string, unknown>;
|
|
271
|
+
|
|
236
272
|
/**
|
|
237
273
|
* Result of merging two atomic operations on the same field.
|
|
238
274
|
* If `merged` is false, the caller should fall back to LWW on the resolved values.
|
|
@@ -270,7 +306,7 @@ interface AtomicMergeFallback {
|
|
|
270
306
|
*/
|
|
271
307
|
declare function mergeAtomicOps(localAtomicOp: AtomicOp, remoteAtomicOp: AtomicOp, baseValue: unknown): AtomicMergeResult | AtomicMergeFallback;
|
|
272
308
|
|
|
273
|
-
type RichtextValue = string | Uint8Array | ArrayBuffer | null | undefined;
|
|
309
|
+
type RichtextValue = string | Uint8Array | ArrayBuffer | KoraBytesValue | null | undefined;
|
|
274
310
|
/**
|
|
275
311
|
* Merges richtext values using Yjs CRDT updates.
|
|
276
312
|
*/
|
|
@@ -458,4 +494,4 @@ declare class MergeEngine {
|
|
|
458
494
|
private mergeWithDelete;
|
|
459
495
|
}
|
|
460
496
|
|
|
461
|
-
export { type AtomicMergeFallback, type AtomicMergeResult, type ConstraintContext, type ConstraintResolution, type ConstraintViolation, type DeleteVsInsertResolution, type FieldMergeResult, type LWWResult, MergeEngine, type MergeIncomingRelation, type MergeInput, type MergeResult, type ReferentialCheckResult, type ReferentialMergeContext, type SideEffectOp, addWinsSet, appendOnlyMerge, applySchemaStrategy, buildMergeRelationLookup, checkConstraints, checkReferentialIntegrityOnDelete, counterMerge, lastWriteWins, maxMerge, mergeAtomicOps, mergeField, mergeRichtext, minMerge, resolveConstraintViolation, resolveDeleteVsInsertConflict, richtextToString, serverAuthoritativeMerge, stringToRichtextUpdate };
|
|
497
|
+
export { type AtomicMergeFallback, type AtomicMergeResult, type ConstraintContext, type ConstraintResolution, type ConstraintViolation, type DeleteVsInsertResolution, type FieldMergeResult, type LWWResult, MergeEngine, type MergeIncomingRelation, type MergeInput, type MergeResult, type ReferentialCheckResult, type ReferentialMergeContext, type SideEffectOp, addWinsSet, appendOnlyMerge, applySchemaStrategy, buildMergeRelationLookup, checkConstraints, checkReferentialIntegrityOnDelete, counterMerge, lastWriteWins, maxMerge, mergeAtomicOps, mergeField, mergeObject, mergeRichtext, minMerge, resolveConstraintViolation, resolveDeleteVsInsertConflict, richtextToString, serverAuthoritativeMerge, stringToRichtextUpdate };
|
package/dist/index.js
CHANGED
|
@@ -55,21 +55,128 @@ function addWinsSet(localArray, remoteArray, baseArray) {
|
|
|
55
55
|
for (const item of baseArray) {
|
|
56
56
|
addIfNew(serialize(item), item);
|
|
57
57
|
}
|
|
58
|
+
const additions = /* @__PURE__ */ new Map();
|
|
58
59
|
for (const item of localArray) {
|
|
59
60
|
const s = serialize(item);
|
|
60
|
-
if (addedLocal.has(s)) {
|
|
61
|
-
|
|
61
|
+
if (addedLocal.has(s) && !additions.has(s)) {
|
|
62
|
+
additions.set(s, item);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
for (const item of remoteArray) {
|
|
65
66
|
const s = serialize(item);
|
|
66
|
-
if (addedRemote.has(s)) {
|
|
67
|
-
|
|
67
|
+
if (addedRemote.has(s) && !additions.has(s)) {
|
|
68
|
+
additions.set(s, item);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
for (const s of [...additions.keys()].sort()) {
|
|
72
|
+
addIfNew(s, additions.get(s));
|
|
73
|
+
}
|
|
70
74
|
return result;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// src/strategies/object-merge.ts
|
|
78
|
+
function mergeObject(localValue, remoteValue, baseValue, localTs, remoteTs) {
|
|
79
|
+
const local = asPlainObject(localValue);
|
|
80
|
+
const remote = asPlainObject(remoteValue);
|
|
81
|
+
const base = asPlainObject(baseValue);
|
|
82
|
+
if (local === null || remote === null) {
|
|
83
|
+
const winner = lastWriteWins(localValue, remoteValue, localTs, remoteTs).value;
|
|
84
|
+
return asPlainObject(winner) ?? {};
|
|
85
|
+
}
|
|
86
|
+
const baseObj = base ?? {};
|
|
87
|
+
const result = {};
|
|
88
|
+
const keys = /* @__PURE__ */ new Set([
|
|
89
|
+
...Object.keys(local),
|
|
90
|
+
...Object.keys(remote),
|
|
91
|
+
...Object.keys(baseObj)
|
|
92
|
+
]);
|
|
93
|
+
for (const key of keys) {
|
|
94
|
+
const inLocal = key in local;
|
|
95
|
+
const inRemote = key in remote;
|
|
96
|
+
const inBase = key in baseObj;
|
|
97
|
+
const localVal = local[key];
|
|
98
|
+
const remoteVal = remote[key];
|
|
99
|
+
const baseVal = baseObj[key];
|
|
100
|
+
const localWrote = inLocal && (!inBase || !valuesEqual(localVal, baseVal));
|
|
101
|
+
const remoteWrote = inRemote && (!inBase || !valuesEqual(remoteVal, baseVal));
|
|
102
|
+
const localRemoved = !inLocal && inBase;
|
|
103
|
+
const remoteRemoved = !inRemote && inBase;
|
|
104
|
+
if (localWrote && remoteWrote) {
|
|
105
|
+
result[key] = mergeWrittenValues(localVal, remoteVal, baseVal, localTs, remoteTs);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (localWrote && !remoteRemoved) {
|
|
109
|
+
result[key] = localVal;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (remoteWrote && !localRemoved) {
|
|
113
|
+
result[key] = remoteVal;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (localWrote && remoteRemoved) {
|
|
117
|
+
result[key] = localVal;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (remoteWrote && localRemoved) {
|
|
121
|
+
result[key] = remoteVal;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (localRemoved || remoteRemoved) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (inBase) {
|
|
128
|
+
result[key] = baseVal;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function mergeWrittenValues(localVal, remoteVal, baseVal, localTs, remoteTs) {
|
|
134
|
+
if (asPlainObject(localVal) !== null && asPlainObject(remoteVal) !== null) {
|
|
135
|
+
return mergeObject(localVal, remoteVal, baseVal, localTs, remoteTs);
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(localVal) && Array.isArray(remoteVal)) {
|
|
138
|
+
const baseArr = Array.isArray(baseVal) ? baseVal : [];
|
|
139
|
+
return addWinsSet(localVal, remoteVal, baseArr);
|
|
140
|
+
}
|
|
141
|
+
return lastWriteWins(localVal, remoteVal, localTs, remoteTs).value;
|
|
142
|
+
}
|
|
143
|
+
function asPlainObject(value) {
|
|
144
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const proto = Object.getPrototypeOf(value);
|
|
148
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
function valuesEqual(a, b) {
|
|
154
|
+
if (a === b) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return stableStringify(a) === stableStringify(b);
|
|
158
|
+
}
|
|
159
|
+
function stableStringify(value) {
|
|
160
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
161
|
+
}
|
|
162
|
+
function sortKeysDeep(value) {
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map(sortKeysDeep);
|
|
165
|
+
}
|
|
166
|
+
if (typeof value === "object" && value !== null) {
|
|
167
|
+
const proto = Object.getPrototypeOf(value);
|
|
168
|
+
if (proto === null || proto === Object.prototype) {
|
|
169
|
+
const record = value;
|
|
170
|
+
const sorted = {};
|
|
171
|
+
for (const key of Object.keys(record).sort()) {
|
|
172
|
+
sorted[key] = sortKeysDeep(record[key]);
|
|
173
|
+
}
|
|
174
|
+
return sorted;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
73
180
|
// src/strategies/atomic-merge.ts
|
|
74
181
|
function mergeAtomicOps(localAtomicOp, remoteAtomicOp, baseValue) {
|
|
75
182
|
if (localAtomicOp.type !== remoteAtomicOp.type) {
|
|
@@ -133,6 +240,11 @@ function mergeAtomicOps(localAtomicOp, remoteAtomicOp, baseValue) {
|
|
|
133
240
|
}
|
|
134
241
|
|
|
135
242
|
// src/strategies/yjs-richtext.ts
|
|
243
|
+
import {
|
|
244
|
+
decodeBytesFromOpData,
|
|
245
|
+
isKoraBytesValue,
|
|
246
|
+
isLegacyNumericByteObject
|
|
247
|
+
} from "@korajs/core";
|
|
136
248
|
import * as Y from "yjs";
|
|
137
249
|
var TEXT_KEY = "content";
|
|
138
250
|
function mergeRichtext(localValue, remoteValue, baseValue) {
|
|
@@ -165,7 +277,13 @@ function toYjsUpdate(value) {
|
|
|
165
277
|
if (value instanceof ArrayBuffer) {
|
|
166
278
|
return new Uint8Array(value);
|
|
167
279
|
}
|
|
168
|
-
|
|
280
|
+
if (isKoraBytesValue(value) || isLegacyNumericByteObject(value)) {
|
|
281
|
+
const decoded = decodeBytesFromOpData(value);
|
|
282
|
+
return decoded instanceof Uint8Array ? decoded : stringToRichtextUpdate(decoded);
|
|
283
|
+
}
|
|
284
|
+
throw new Error(
|
|
285
|
+
"Richtext value must be a string, Uint8Array, ArrayBuffer, tagged { $koraBytes } object, null, or undefined."
|
|
286
|
+
);
|
|
169
287
|
}
|
|
170
288
|
|
|
171
289
|
// src/strategies/schema-strategies.ts
|
|
@@ -252,6 +370,38 @@ function applySchemaStrategy(strategy, localValue, remoteValue, baseValue, local
|
|
|
252
370
|
|
|
253
371
|
// src/engine/field-merger.ts
|
|
254
372
|
function mergeField(fieldName, localOp, remoteOp, baseState, fieldDescriptor, resolver) {
|
|
373
|
+
const result = mergeFieldInner(fieldName, localOp, remoteOp, baseState, fieldDescriptor, resolver);
|
|
374
|
+
if (fieldDescriptor.kind === "secret") {
|
|
375
|
+
return {
|
|
376
|
+
value: result.value,
|
|
377
|
+
trace: {
|
|
378
|
+
...result.trace,
|
|
379
|
+
operationA: redactOperationField(result.trace.operationA, fieldName),
|
|
380
|
+
operationB: redactOperationField(result.trace.operationB, fieldName),
|
|
381
|
+
inputA: SECRET_REDACTED,
|
|
382
|
+
inputB: SECRET_REDACTED,
|
|
383
|
+
base: result.trace.base == null ? result.trace.base : SECRET_REDACTED,
|
|
384
|
+
output: SECRET_REDACTED
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
var SECRET_REDACTED = "[secret]";
|
|
391
|
+
function redactOperationField(operation, field) {
|
|
392
|
+
const redactRecord = (record) => {
|
|
393
|
+
if (record === null || !(field in record)) {
|
|
394
|
+
return record;
|
|
395
|
+
}
|
|
396
|
+
return { ...record, [field]: SECRET_REDACTED };
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
...operation,
|
|
400
|
+
data: redactRecord(operation.data),
|
|
401
|
+
previousData: redactRecord(operation.previousData)
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function mergeFieldInner(fieldName, localOp, remoteOp, baseState, fieldDescriptor, resolver) {
|
|
255
405
|
const startTime = Date.now();
|
|
256
406
|
const localData = localOp.data ?? {};
|
|
257
407
|
const remoteData = remoteOp.data ?? {};
|
|
@@ -383,7 +533,14 @@ function autoMerge(fieldName, localOp, remoteOp, localValue, remoteValue, baseVa
|
|
|
383
533
|
case "number":
|
|
384
534
|
case "boolean":
|
|
385
535
|
case "enum":
|
|
386
|
-
case "timestamp":
|
|
536
|
+
case "timestamp":
|
|
537
|
+
// A blob field's value is a small content-addressed reference; concurrent
|
|
538
|
+
// replacements of the whole reference resolve by last-write-wins (the bytes
|
|
539
|
+
// themselves are immutable and deduplicated by hash in the blob store).
|
|
540
|
+
case "blob":
|
|
541
|
+
// A secret field's stored value (ciphertext or hash) resolves by
|
|
542
|
+
// last-write-wins; the merge never sees or emits plaintext (redacted below).
|
|
543
|
+
case "secret": {
|
|
387
544
|
const lwwResult = lastWriteWins(
|
|
388
545
|
localValue,
|
|
389
546
|
remoteValue,
|
|
@@ -440,6 +597,28 @@ function autoMerge(fieldName, localOp, remoteOp, localValue, remoteValue, baseVa
|
|
|
440
597
|
startTime
|
|
441
598
|
);
|
|
442
599
|
}
|
|
600
|
+
case "object":
|
|
601
|
+
case "json": {
|
|
602
|
+
const merged = mergeObject(
|
|
603
|
+
localValue,
|
|
604
|
+
remoteValue,
|
|
605
|
+
baseValue,
|
|
606
|
+
localOp.timestamp,
|
|
607
|
+
remoteOp.timestamp
|
|
608
|
+
);
|
|
609
|
+
return createResult(
|
|
610
|
+
merged,
|
|
611
|
+
fieldName,
|
|
612
|
+
localOp,
|
|
613
|
+
remoteOp,
|
|
614
|
+
localValue,
|
|
615
|
+
remoteValue,
|
|
616
|
+
baseValue,
|
|
617
|
+
"object-lww-map",
|
|
618
|
+
1,
|
|
619
|
+
startTime
|
|
620
|
+
);
|
|
621
|
+
}
|
|
443
622
|
}
|
|
444
623
|
}
|
|
445
624
|
function createResult(value, field, operationA, operationB, inputA, inputB, base, strategy, tier, startTime) {
|
|
@@ -1226,7 +1405,7 @@ var MergeEngine = class {
|
|
|
1226
1405
|
*/
|
|
1227
1406
|
mergeFields(input) {
|
|
1228
1407
|
const { local, remote, baseState, collectionDef } = input;
|
|
1229
|
-
const allFields = collectAffectedFields(local, remote
|
|
1408
|
+
const allFields = collectAffectedFields(local, remote);
|
|
1230
1409
|
const mergedData = {};
|
|
1231
1410
|
const traces = [];
|
|
1232
1411
|
for (const fieldName of allFields) {
|
|
@@ -1291,11 +1470,8 @@ var MergeEngine = class {
|
|
|
1291
1470
|
};
|
|
1292
1471
|
}
|
|
1293
1472
|
};
|
|
1294
|
-
function collectAffectedFields(local, remote
|
|
1473
|
+
function collectAffectedFields(local, remote) {
|
|
1295
1474
|
const fields = /* @__PURE__ */ new Set();
|
|
1296
|
-
for (const fieldName of Object.keys(collectionDef.fields)) {
|
|
1297
|
-
fields.add(fieldName);
|
|
1298
|
-
}
|
|
1299
1475
|
if (local.data !== null) {
|
|
1300
1476
|
for (const fieldName of Object.keys(local.data)) {
|
|
1301
1477
|
fields.add(fieldName);
|
|
@@ -1306,9 +1482,6 @@ function collectAffectedFields(local, remote, baseState, collectionDef) {
|
|
|
1306
1482
|
fields.add(fieldName);
|
|
1307
1483
|
}
|
|
1308
1484
|
}
|
|
1309
|
-
for (const fieldName of Object.keys(baseState)) {
|
|
1310
|
-
fields.add(fieldName);
|
|
1311
|
-
}
|
|
1312
1485
|
return fields;
|
|
1313
1486
|
}
|
|
1314
1487
|
function determineAppliedOperation(traces) {
|
|
@@ -1349,6 +1522,7 @@ export {
|
|
|
1349
1522
|
maxMerge,
|
|
1350
1523
|
mergeAtomicOps,
|
|
1351
1524
|
mergeField,
|
|
1525
|
+
mergeObject,
|
|
1352
1526
|
mergeRichtext,
|
|
1353
1527
|
minMerge,
|
|
1354
1528
|
resolveConstraintViolation,
|