@korajs/merge 1.0.0-beta.0 → 1.0.0-beta.2

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.d.ts CHANGED
@@ -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.
@@ -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
@@ -74,6 +74,109 @@ function addWinsSet(localArray, remoteArray, baseArray) {
74
74
  return result;
75
75
  }
76
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
+
77
180
  // src/strategies/atomic-merge.ts
78
181
  function mergeAtomicOps(localAtomicOp, remoteAtomicOp, baseValue) {
79
182
  if (localAtomicOp.type !== remoteAtomicOp.type) {
@@ -267,6 +370,38 @@ function applySchemaStrategy(strategy, localValue, remoteValue, baseValue, local
267
370
 
268
371
  // src/engine/field-merger.ts
269
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) {
270
405
  const startTime = Date.now();
271
406
  const localData = localOp.data ?? {};
272
407
  const remoteData = remoteOp.data ?? {};
@@ -398,7 +533,14 @@ function autoMerge(fieldName, localOp, remoteOp, localValue, remoteValue, baseVa
398
533
  case "number":
399
534
  case "boolean":
400
535
  case "enum":
401
- 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": {
402
544
  const lwwResult = lastWriteWins(
403
545
  localValue,
404
546
  remoteValue,
@@ -455,6 +597,28 @@ function autoMerge(fieldName, localOp, remoteOp, localValue, remoteValue, baseVa
455
597
  startTime
456
598
  );
457
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
+ }
458
622
  }
459
623
  }
460
624
  function createResult(value, field, operationA, operationB, inputA, inputB, base, strategy, tier, startTime) {
@@ -1358,6 +1522,7 @@ export {
1358
1522
  maxMerge,
1359
1523
  mergeAtomicOps,
1360
1524
  mergeField,
1525
+ mergeObject,
1361
1526
  mergeRichtext,
1362
1527
  minMerge,
1363
1528
  resolveConstraintViolation,