json-patch-to-crdt 0.0.0 → 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.
@@ -194,6 +194,19 @@ const ROOT_KEY = "@@crdt/root";
194
194
 
195
195
  //#endregion
196
196
  //#region src/patch.ts
197
+ /** Structured compile error used to map patch validation failures to typed reasons. */
198
+ var PatchCompileError = class extends Error {
199
+ reason;
200
+ path;
201
+ opIndex;
202
+ constructor(reason, message, path, opIndex) {
203
+ super(message);
204
+ this.name = "PatchCompileError";
205
+ this.reason = reason;
206
+ this.path = path;
207
+ this.opIndex = opIndex;
208
+ }
209
+ };
197
210
  /**
198
211
  * Parse an RFC 6901 JSON Pointer into a path array, unescaping `~1` and `~0`.
199
212
  * @param ptr - A JSON Pointer string (e.g. `"/a/b"` or `""`).
@@ -234,94 +247,15 @@ function getAtJson(base, path) {
234
247
  * @param patch - Array of JSON Patch operations.
235
248
  * @returns An array of `IntentOp` ready for `applyIntentsToCrdt`.
236
249
  */
237
- function compileJsonPatchToIntent(baseJson, patch) {
250
+ function compileJsonPatchToIntent(baseJson, patch, options = {}) {
251
+ const semantics = options.semantics ?? "sequential";
252
+ let workingBase = semantics === "sequential" ? structuredClone(baseJson) : baseJson;
238
253
  const intents = [];
239
- for (const op of patch) {
240
- if (op.op === "test") {
241
- intents.push({
242
- t: "Test",
243
- path: parseJsonPointer(op.path),
244
- value: op.value
245
- });
246
- continue;
247
- }
248
- if (op.op === "copy" || op.op === "move") {
249
- const val = getAtJson(baseJson, parseJsonPointer(op.from));
250
- intents.push(...compileJsonPatchToIntent(baseJson, [{
251
- op: "add",
252
- path: op.path,
253
- value: val
254
- }]));
255
- if (op.op === "move") intents.push(...compileJsonPatchToIntent(baseJson, [{
256
- op: "remove",
257
- path: op.from
258
- }]));
259
- continue;
260
- }
261
- const path = parseJsonPointer(op.path);
262
- const parent = path.slice(0, -1);
263
- const last = path[path.length - 1];
264
- if (path.length === 0) {
265
- if (op.op === "replace" || op.op === "add") intents.push({
266
- t: "ObjSet",
267
- path: [],
268
- key: ROOT_KEY,
269
- value: op.value
270
- });
271
- else if (op.op === "remove") intents.push({
272
- t: "ObjSet",
273
- path: [],
274
- key: ROOT_KEY,
275
- value: null
276
- });
277
- continue;
278
- }
279
- const isIndexLike = (s) => s === "-" || /^[0-9]+$/.test(s);
280
- if (isIndexLike(last)) {
281
- const index = last === "-" ? Number.POSITIVE_INFINITY : Number(last);
282
- if (op.op === "add") intents.push({
283
- t: "ArrInsert",
284
- path: parent,
285
- index,
286
- value: op.value
287
- });
288
- else if (op.op === "remove") intents.push({
289
- t: "ArrDelete",
290
- path: parent,
291
- index
292
- });
293
- else if (op.op === "replace") intents.push({
294
- t: "ArrReplace",
295
- path: parent,
296
- index,
297
- value: op.value
298
- });
299
- else assertNever$1(op, "Unsupported op at array index path");
300
- } else {
301
- const parentValue = pathValueAt(baseJson, parent);
302
- if (!isPlainObject(parentValue)) throw new Error(`Expected object parent at ${stringifyJsonPointer(parent)}`);
303
- if ((op.op === "replace" || op.op === "remove") && !hasOwn(parentValue, last)) throw new Error(`Missing key ${last} at ${stringifyJsonPointer(parent)}`);
304
- if (op.op === "add") intents.push({
305
- t: "ObjSet",
306
- path: parent,
307
- key: last,
308
- value: op.value,
309
- mode: "add"
310
- });
311
- else if (op.op === "replace") intents.push({
312
- t: "ObjSet",
313
- path: parent,
314
- key: last,
315
- value: op.value,
316
- mode: "replace"
317
- });
318
- else if (op.op === "remove") intents.push({
319
- t: "ObjRemove",
320
- path: parent,
321
- key: last
322
- });
323
- else assertNever$1(op, "Unsupported op");
324
- }
254
+ for (let opIndex = 0; opIndex < patch.length; opIndex++) {
255
+ const op = patch[opIndex];
256
+ const compileBase = semantics === "sequential" ? workingBase : baseJson;
257
+ intents.push(...compileSingleOp(compileBase, op, opIndex));
258
+ if (semantics === "sequential") workingBase = applyPatchOpToJson(workingBase, op, opIndex);
325
259
  }
326
260
  return intents;
327
261
  }
@@ -473,6 +407,185 @@ function pathValueAt(base, path) {
473
407
  function assertNever$1(_value, message) {
474
408
  throw new Error(message);
475
409
  }
410
+ function compileSingleOp(baseJson, op, opIndex) {
411
+ if (op.op === "test") return [{
412
+ t: "Test",
413
+ path: parsePointerOrThrow(op.path, op.path, opIndex),
414
+ value: op.value
415
+ }];
416
+ if (op.op === "copy" || op.op === "move") {
417
+ const fromPath = parsePointerOrThrow(op.from, op.from, opIndex);
418
+ const toPath = parsePointerOrThrow(op.path, op.path, opIndex);
419
+ if (op.op === "move" && isStrictDescendantPath(fromPath, toPath)) throw compileError("INVALID_MOVE", `cannot move a value into one of its descendants at ${op.path}`, op.path, opIndex);
420
+ const val = lookupValueOrThrow(baseJson, fromPath, op.from, opIndex);
421
+ const out = compileSingleOp(baseJson, {
422
+ op: "add",
423
+ path: op.path,
424
+ value: val
425
+ }, opIndex);
426
+ if (op.op === "move") out.push(...compileSingleOp(baseJson, {
427
+ op: "remove",
428
+ path: op.from
429
+ }, opIndex));
430
+ return out;
431
+ }
432
+ const path = parsePointerOrThrow(op.path, op.path, opIndex);
433
+ if (path.length === 0) {
434
+ if (op.op === "replace" || op.op === "add") return [{
435
+ t: "ObjSet",
436
+ path: [],
437
+ key: ROOT_KEY,
438
+ value: op.value
439
+ }];
440
+ throw compileError("INVALID_TARGET", "remove at root path is not supported in RFC-compliant mode", op.path, opIndex);
441
+ }
442
+ const parent = path.slice(0, -1);
443
+ const token = path[path.length - 1];
444
+ const parentPath = stringifyJsonPointer(parent);
445
+ const parentValue = getParentValue(baseJson, parent, opIndex);
446
+ if (Array.isArray(parentValue)) {
447
+ const index = parseArrayIndexToken(token, op.op, parentValue.length, op.path, opIndex);
448
+ if (op.op === "add") return [{
449
+ t: "ArrInsert",
450
+ path: parent,
451
+ index,
452
+ value: op.value
453
+ }];
454
+ if (op.op === "remove") return [{
455
+ t: "ArrDelete",
456
+ path: parent,
457
+ index
458
+ }];
459
+ if (op.op === "replace") return [{
460
+ t: "ArrReplace",
461
+ path: parent,
462
+ index,
463
+ value: op.value
464
+ }];
465
+ return assertNever$1(op, "Unsupported op at array path");
466
+ }
467
+ if (!isPlainObject(parentValue)) throw compileError("INVALID_TARGET", `expected object or array parent at ${parentPath}`, parentPath, opIndex);
468
+ if ((op.op === "replace" || op.op === "remove") && !hasOwn(parentValue, token)) throw compileError("MISSING_TARGET", `missing key ${token} at ${parentPath}`, op.path, opIndex);
469
+ if (op.op === "add") return [{
470
+ t: "ObjSet",
471
+ path: parent,
472
+ key: token,
473
+ value: op.value,
474
+ mode: "add"
475
+ }];
476
+ if (op.op === "replace") return [{
477
+ t: "ObjSet",
478
+ path: parent,
479
+ key: token,
480
+ value: op.value,
481
+ mode: "replace"
482
+ }];
483
+ if (op.op === "remove") return [{
484
+ t: "ObjRemove",
485
+ path: parent,
486
+ key: token
487
+ }];
488
+ return assertNever$1(op, "Unsupported op");
489
+ }
490
+ function applyPatchOpToJson(baseJson, op, opIndex) {
491
+ let doc = structuredClone(baseJson);
492
+ if (op.op === "test") return doc;
493
+ if (op.op === "copy" || op.op === "move") {
494
+ const fromPath = parsePointerOrThrow(op.from, op.from, opIndex);
495
+ const value = structuredClone(lookupValueOrThrow(doc, fromPath, op.from, opIndex));
496
+ if (op.op === "move") doc = applyPatchOpToJson(doc, {
497
+ op: "remove",
498
+ path: op.from
499
+ }, opIndex);
500
+ return applyPatchOpToJson(doc, {
501
+ op: "add",
502
+ path: op.path,
503
+ value
504
+ }, opIndex);
505
+ }
506
+ const path = parsePointerOrThrow(op.path, op.path, opIndex);
507
+ if (path.length === 0) {
508
+ if (op.op === "add" || op.op === "replace") return structuredClone(op.value);
509
+ throw compileError("INVALID_TARGET", "remove at root path is not supported in RFC-compliant mode", op.path, opIndex);
510
+ }
511
+ const parentPath = path.slice(0, -1);
512
+ const token = path[path.length - 1];
513
+ let parent;
514
+ if (parentPath.length === 0) parent = doc;
515
+ else parent = lookupValueOrThrow(doc, parentPath, op.path, opIndex);
516
+ if (Array.isArray(parent)) {
517
+ const index = parseArrayIndexToken(token, op.op, parent.length, op.path, opIndex);
518
+ if (op.op === "add") {
519
+ const insertAt = index === Number.POSITIVE_INFINITY ? parent.length : index;
520
+ parent.splice(insertAt, 0, structuredClone(op.value));
521
+ return doc;
522
+ }
523
+ if (op.op === "replace") {
524
+ parent[index] = structuredClone(op.value);
525
+ return doc;
526
+ }
527
+ parent.splice(index, 1);
528
+ return doc;
529
+ }
530
+ if (!isPlainObject(parent)) throw compileError("INVALID_TARGET", `expected object or array parent at ${stringifyJsonPointer(parentPath)}`, op.path, opIndex);
531
+ if (op.op === "add" || op.op === "replace") {
532
+ parent[token] = structuredClone(op.value);
533
+ return doc;
534
+ }
535
+ delete parent[token];
536
+ return doc;
537
+ }
538
+ function parsePointerOrThrow(ptr, path, opIndex) {
539
+ try {
540
+ return parseJsonPointer(ptr);
541
+ } catch (error) {
542
+ throw compileError("INVALID_POINTER", error instanceof Error ? error.message : "invalid pointer", path, opIndex);
543
+ }
544
+ }
545
+ function lookupValueOrThrow(baseJson, path, pointer, opIndex) {
546
+ try {
547
+ return getAtJson(baseJson, path);
548
+ } catch (error) {
549
+ throw compileErrorFromLookup(error, pointer, opIndex);
550
+ }
551
+ }
552
+ function getParentValue(baseJson, parent, opIndex) {
553
+ if (parent.length === 0) return baseJson;
554
+ try {
555
+ return pathValueAt(baseJson, parent);
556
+ } catch (error) {
557
+ throw compileErrorFromLookup(error, stringifyJsonPointer(parent), opIndex);
558
+ }
559
+ }
560
+ function parseArrayIndexToken(token, op, arrLength, path, opIndex) {
561
+ if (token === "-") {
562
+ if (op !== "add") throw compileError("INVALID_POINTER", `'-' index is only valid for add at ${path}`, path, opIndex);
563
+ return Number.POSITIVE_INFINITY;
564
+ }
565
+ if (!/^[0-9]+$/.test(token)) throw compileError("INVALID_POINTER", `expected array index at ${path}`, path, opIndex);
566
+ const index = Number(token);
567
+ if (!Number.isSafeInteger(index)) throw compileError("OUT_OF_BOUNDS", `array index is too large at ${path}`, path, opIndex);
568
+ if (op === "add") {
569
+ if (index > arrLength) throw compileError("OUT_OF_BOUNDS", `index out of bounds at ${path}; expected 0..${arrLength}`, path, opIndex);
570
+ } else if (index >= arrLength) throw compileError("OUT_OF_BOUNDS", `index out of bounds at ${path}; expected 0..${Math.max(arrLength - 1, 0)}`, path, opIndex);
571
+ return index;
572
+ }
573
+ function compileErrorFromLookup(error, path, opIndex) {
574
+ const message = error instanceof Error ? error.message : "invalid path";
575
+ if (message.includes("Expected array index")) return compileError("INVALID_POINTER", message, path, opIndex);
576
+ if (message.includes("Index out of bounds")) return compileError("OUT_OF_BOUNDS", message, path, opIndex);
577
+ if (message.includes("Missing key")) return compileError("MISSING_PARENT", message, path, opIndex);
578
+ if (message.includes("Cannot traverse into non-container")) return compileError("INVALID_TARGET", message, path, opIndex);
579
+ return compileError("INVALID_PATCH", message, path, opIndex);
580
+ }
581
+ function compileError(reason, message, path, opIndex) {
582
+ return new PatchCompileError(reason, message, path, opIndex);
583
+ }
584
+ function isStrictDescendantPath(from, to) {
585
+ if (to.length <= from.length) return false;
586
+ for (let i = 0; i < from.length; i++) if (from[i] !== to[i]) return false;
587
+ return true;
588
+ }
476
589
 
477
590
  //#endregion
478
591
  //#region src/doc.ts
@@ -673,13 +786,17 @@ function applyTest(base, head, it, evalTestAgainst) {
673
786
  return {
674
787
  ok: false,
675
788
  code: 409,
676
- message: `test path missing at /${it.path.join("/")}`
789
+ reason: "MISSING_TARGET",
790
+ message: `test path missing at /${it.path.join("/")}`,
791
+ path: `/${it.path.join("/")}`
677
792
  };
678
793
  }
679
794
  if (!jsonEquals(got, it.value)) return {
680
795
  ok: false,
681
796
  code: 409,
682
- message: `test failed at /${it.path.join("/")}`
797
+ reason: "TEST_FAILED",
798
+ message: `test failed at /${it.path.join("/")}`,
799
+ path: `/${it.path.join("/")}`
683
800
  };
684
801
  return null;
685
802
  }
@@ -692,12 +809,16 @@ function applyObjSet(head, it, newDot) {
692
809
  if (!parentRes.ok) return {
693
810
  ok: false,
694
811
  code: 409,
695
- message: parentRes.message
812
+ reason: "MISSING_PARENT",
813
+ message: parentRes.message,
814
+ path: `/${it.path.join("/")}`
696
815
  };
697
816
  if (it.mode === "replace" && !parentRes.obj.entries.has(it.key)) return {
698
817
  ok: false,
699
818
  code: 409,
700
- message: `no value at /${[...it.path, it.key].join("/")}`
819
+ reason: "MISSING_TARGET",
820
+ message: `no value at /${[...it.path, it.key].join("/")}`,
821
+ path: `/${[...it.path, it.key].join("/")}`
701
822
  };
702
823
  const d = newDot();
703
824
  const parentObj = parentRes.obj;
@@ -709,12 +830,16 @@ function applyObjRemove(head, it, newDot) {
709
830
  if (!parentRes.ok) return {
710
831
  ok: false,
711
832
  code: 409,
712
- message: parentRes.message
833
+ reason: "MISSING_PARENT",
834
+ message: parentRes.message,
835
+ path: `/${it.path.join("/")}`
713
836
  };
714
837
  if (!parentRes.obj.entries.has(it.key)) return {
715
838
  ok: false,
716
839
  code: 409,
717
- message: `no value at /${[...it.path, it.key].join("/")}`
840
+ reason: "MISSING_TARGET",
841
+ message: `no value at /${[...it.path, it.key].join("/")}`,
842
+ path: `/${[...it.path, it.key].join("/")}`
718
843
  };
719
844
  const d = newDot();
720
845
  const parentObj = parentRes.obj;
@@ -734,7 +859,9 @@ function applyArrInsert(base, head, it, newDot, bumpCounterAbove) {
734
859
  return {
735
860
  ok: false,
736
861
  code: 409,
737
- message: `base array missing at /${it.path.join("/")}`
862
+ reason: "MISSING_PARENT",
863
+ message: `base array missing at /${it.path.join("/")}`,
864
+ path: `/${it.path.join("/")}`
738
865
  };
739
866
  }
740
867
  const headSeq = ensureSeqAtPath(head, it.path, newDot());
@@ -743,7 +870,9 @@ function applyArrInsert(base, head, it, newDot, bumpCounterAbove) {
743
870
  if (idx < 0 || idx > baseLen) return {
744
871
  ok: false,
745
872
  code: 409,
746
- message: `index out of bounds at /${it.path.join("/")}/${it.index}`
873
+ reason: "OUT_OF_BOUNDS",
874
+ message: `index out of bounds at /${it.path.join("/")}/${it.index}`,
875
+ path: `/${it.path.join("/")}/${it.index}`
747
876
  };
748
877
  const prev = idx === 0 ? HEAD : rgaIdAtIndex(baseSeq, idx - 1) ?? HEAD;
749
878
  const d = nextInsertDotForPrev(headSeq, prev, newDot, bumpCounterAbove);
@@ -767,14 +896,18 @@ function applyArrDelete(base, head, it, newDot) {
767
896
  if (!baseSeq) return {
768
897
  ok: false,
769
898
  code: 409,
770
- message: `base array missing at /${it.path.join("/")}`
899
+ reason: "MISSING_PARENT",
900
+ message: `base array missing at /${it.path.join("/")}`,
901
+ path: `/${it.path.join("/")}`
771
902
  };
772
903
  const headSeq = ensureSeqAtPath(head, it.path, d);
773
904
  const baseId = rgaIdAtIndex(baseSeq, it.index);
774
905
  if (!baseId) return {
775
906
  ok: false,
776
907
  code: 409,
777
- message: `no base element at index ${it.index}`
908
+ reason: "MISSING_TARGET",
909
+ message: `no base element at index ${it.index}`,
910
+ path: `/${it.path.join("/")}/${it.index}`
778
911
  };
779
912
  rgaDelete(headSeq, baseId);
780
913
  return null;
@@ -785,20 +918,26 @@ function applyArrReplace(base, head, it, newDot) {
785
918
  if (!baseSeq) return {
786
919
  ok: false,
787
920
  code: 409,
788
- message: `base array missing at /${it.path.join("/")}`
921
+ reason: "MISSING_PARENT",
922
+ message: `base array missing at /${it.path.join("/")}`,
923
+ path: `/${it.path.join("/")}`
789
924
  };
790
925
  const headSeq = ensureSeqAtPath(head, it.path, d);
791
926
  const baseId = rgaIdAtIndex(baseSeq, it.index);
792
927
  if (!baseId) return {
793
928
  ok: false,
794
929
  code: 409,
795
- message: `no base element at index ${it.index}`
930
+ reason: "MISSING_TARGET",
931
+ message: `no base element at index ${it.index}`,
932
+ path: `/${it.path.join("/")}/${it.index}`
796
933
  };
797
934
  const e = headSeq.elems.get(baseId);
798
935
  if (!e || e.tombstone) return {
799
936
  ok: false,
800
937
  code: 409,
801
- message: `element already deleted at index ${it.index}`
938
+ reason: "MISSING_TARGET",
939
+ message: `element already deleted at index ${it.index}`,
940
+ path: `/${it.path.join("/")}/${it.index}`
802
941
  };
803
942
  e.value = nodeFromJson(it.value, newDot);
804
943
  return null;
@@ -842,34 +981,39 @@ function applyIntentsToCrdt(base, head, intents, newDot, evalTestAgainst = "head
842
981
  }
843
982
  return { ok: true };
844
983
  }
845
- /**
846
- * Convenience wrapper: compile a JSON Patch and apply it to a CRDT document.
847
- * @param base - The base document for index resolution.
848
- * @param head - The target document to mutate.
849
- * @param patch - Array of RFC 6902 JSON Patch operations.
850
- * @param newDot - A function that generates a unique `Dot` per mutation.
851
- * @param evalTestAgainst - Whether `test` ops evaluate against `"head"` or `"base"`.
852
- * @param bumpCounterAbove - Optional hook that can fast-forward the underlying counter before inserts.
853
- * @returns `{ ok: true }` on success, or `{ ok: false, code: 409, message }` on conflict.
854
- */
855
- function jsonPatchToCrdt(base, head, patch, newDot, evalTestAgainst = "head", bumpCounterAbove) {
856
- return applyIntentsToCrdt(base, head, compileJsonPatchToIntent(materialize(base.root), patch), newDot, evalTestAgainst, bumpCounterAbove);
984
+ function jsonPatchToCrdt(baseOrOptions, head, patch, newDot, evalTestAgainst = "head", bumpCounterAbove) {
985
+ if (isJsonPatchToCrdtOptions(baseOrOptions)) return jsonPatchToCrdtInternal(baseOrOptions);
986
+ if (!head || !patch || !newDot) return {
987
+ ok: false,
988
+ code: 409,
989
+ reason: "INVALID_PATCH",
990
+ message: "invalid jsonPatchToCrdt call signature"
991
+ };
992
+ return jsonPatchToCrdtInternal({
993
+ base: baseOrOptions,
994
+ head,
995
+ patch,
996
+ newDot,
997
+ evalTestAgainst,
998
+ bumpCounterAbove
999
+ });
857
1000
  }
858
- /**
859
- * Safe wrapper around `jsonPatchToCrdt` that converts compile-time errors into `409` results.
860
- * This function never throws for malformed/invalid patch paths.
861
- */
862
- function jsonPatchToCrdtSafe(base, head, patch, newDot, evalTestAgainst = "head", bumpCounterAbove) {
1001
+ function jsonPatchToCrdtSafe(baseOrOptions, head, patch, newDot, evalTestAgainst = "head", bumpCounterAbove) {
863
1002
  try {
864
- return jsonPatchToCrdt(base, head, patch, newDot, evalTestAgainst, bumpCounterAbove);
865
- } catch (error) {
866
- return {
1003
+ if (isJsonPatchToCrdtOptions(baseOrOptions)) return jsonPatchToCrdt(baseOrOptions);
1004
+ if (!head || !patch || !newDot) return {
867
1005
  ok: false,
868
1006
  code: 409,
869
- message: error instanceof Error ? error.message : "failed to compile patch"
1007
+ reason: "INVALID_PATCH",
1008
+ message: "invalid jsonPatchToCrdtSafe call signature"
870
1009
  };
1010
+ return jsonPatchToCrdt(baseOrOptions, head, patch, newDot, evalTestAgainst, bumpCounterAbove);
1011
+ } catch (error) {
1012
+ return toApplyError$1(error);
871
1013
  }
872
1014
  }
1015
+ /** Alias for codebases that prefer `try*` naming for non-throwing APIs. */
1016
+ const tryJsonPatchToCrdt = jsonPatchToCrdtSafe;
873
1017
  /**
874
1018
  * Generate a JSON Patch delta between two CRDT documents.
875
1019
  * @param base - The base document snapshot.
@@ -891,19 +1035,95 @@ function crdtToFullReplace(doc) {
891
1035
  value: materialize(doc.root)
892
1036
  }];
893
1037
  }
1038
+ function jsonPatchToCrdtInternal(options) {
1039
+ const evalTestAgainst = options.evalTestAgainst ?? "head";
1040
+ if ((options.semantics ?? "sequential") === "base") {
1041
+ const baseJson = materialize(options.base.root);
1042
+ let intents;
1043
+ try {
1044
+ intents = compileJsonPatchToIntent(baseJson, options.patch, { semantics: "base" });
1045
+ } catch (error) {
1046
+ return toApplyError$1(error);
1047
+ }
1048
+ return applyIntentsToCrdt(options.base, options.head, intents, options.newDot, evalTestAgainst, options.bumpCounterAbove);
1049
+ }
1050
+ let shadowBase = cloneDoc(evalTestAgainst === "base" ? options.base : options.head);
1051
+ let shadowCtr = 0;
1052
+ const shadowDot = () => ({
1053
+ actor: "__shadow__",
1054
+ ctr: ++shadowCtr
1055
+ });
1056
+ const shadowBump = (ctr) => {
1057
+ if (shadowCtr < ctr) shadowCtr = ctr;
1058
+ };
1059
+ for (let opIndex = 0; opIndex < options.patch.length; opIndex++) {
1060
+ const op = options.patch[opIndex];
1061
+ const baseJson = materialize(shadowBase.root);
1062
+ let intents;
1063
+ try {
1064
+ intents = compileJsonPatchToIntent(baseJson, [op], { semantics: "sequential" });
1065
+ } catch (error) {
1066
+ return withOpIndex(toApplyError$1(error), opIndex);
1067
+ }
1068
+ const headStep = applyIntentsToCrdt(shadowBase, options.head, intents, options.newDot, evalTestAgainst, options.bumpCounterAbove);
1069
+ if (!headStep.ok) return withOpIndex(headStep, opIndex);
1070
+ if (evalTestAgainst === "base") {
1071
+ const shadowStep = applyIntentsToCrdt(shadowBase, shadowBase, intents, shadowDot, "base", shadowBump);
1072
+ if (!shadowStep.ok) return withOpIndex(shadowStep, opIndex);
1073
+ } else shadowBase = cloneDoc(options.head);
1074
+ }
1075
+ return { ok: true };
1076
+ }
1077
+ function withOpIndex(error, opIndex) {
1078
+ if (error.opIndex !== void 0) return error;
1079
+ return {
1080
+ ...error,
1081
+ opIndex
1082
+ };
1083
+ }
1084
+ function isJsonPatchToCrdtOptions(value) {
1085
+ return typeof value === "object" && value !== null && "base" in value && "head" in value && "patch" in value && "newDot" in value;
1086
+ }
1087
+ function toApplyError$1(error) {
1088
+ if (error instanceof PatchCompileError) return {
1089
+ ok: false,
1090
+ code: 409,
1091
+ reason: error.reason,
1092
+ message: error.message,
1093
+ path: error.path,
1094
+ opIndex: error.opIndex
1095
+ };
1096
+ return {
1097
+ ok: false,
1098
+ code: 409,
1099
+ reason: "INVALID_PATCH",
1100
+ message: error instanceof Error ? error.message : "failed to compile/apply patch"
1101
+ };
1102
+ }
894
1103
  function assertNever(_value, message) {
895
1104
  throw new Error(message);
896
1105
  }
897
1106
 
898
1107
  //#endregion
899
1108
  //#region src/state.ts
900
- /** Error thrown when a JSON Patch cannot be applied. Includes a numeric `.code` (409 for conflicts). */
1109
+ /** Error thrown when a JSON Patch cannot be applied. Includes structured conflict metadata. */
901
1110
  var PatchError = class extends Error {
902
1111
  code;
903
- constructor(message, code = 409) {
904
- super(message);
1112
+ reason;
1113
+ path;
1114
+ opIndex;
1115
+ constructor(errorOrMessage, code = 409, reason = "INVALID_PATCH") {
1116
+ super(typeof errorOrMessage === "string" ? errorOrMessage : errorOrMessage.message);
905
1117
  this.name = "PatchError";
906
- this.code = code;
1118
+ if (typeof errorOrMessage === "string") {
1119
+ this.code = code;
1120
+ this.reason = reason;
1121
+ return;
1122
+ }
1123
+ this.code = errorOrMessage.code;
1124
+ this.reason = errorOrMessage.reason;
1125
+ this.path = errorOrMessage.path;
1126
+ this.opIndex = errorOrMessage.opIndex;
907
1127
  }
908
1128
  };
909
1129
  /**
@@ -920,6 +1140,16 @@ function createState(initial, options) {
920
1140
  };
921
1141
  }
922
1142
  /**
1143
+ * Fork a replica from a shared origin state while assigning a new local actor ID.
1144
+ * The forked state has an independent document clone and clock.
1145
+ */
1146
+ function forkState(origin, actor) {
1147
+ return {
1148
+ doc: cloneDoc(origin.doc),
1149
+ clock: createClock(actor, origin.clock.ctr)
1150
+ };
1151
+ }
1152
+ /**
923
1153
  * Materialize a CRDT document or state back to a plain JSON value.
924
1154
  * @param target - A `Doc` or `CrdtState` to materialize.
925
1155
  * @returns The JSON representation of the current document.
@@ -933,34 +1163,69 @@ function toJson(target) {
933
1163
  * Throws `PatchError` on conflict (e.g. out-of-bounds index, failed test op).
934
1164
  * @param state - The current CRDT state.
935
1165
  * @param patch - Array of RFC 6902 JSON Patch operations.
936
- * @param options - Optional base document and test evaluation mode.
1166
+ * @param options - Optional base state snapshot and patch semantics.
937
1167
  * @returns A new `CrdtState` with the patch applied.
938
1168
  */
939
1169
  function applyPatch(state, patch, options = {}) {
940
- const nextState = {
941
- doc: cloneDoc(state.doc),
942
- clock: cloneClock(state.clock)
943
- };
944
- const result = applyPatchInternal(nextState, patch, options);
945
- if (!result.ok) throw new PatchError(result.message, result.code);
946
- return nextState;
1170
+ const result = tryApplyPatch(state, patch, options);
1171
+ if (!result.ok) throw new PatchError(result.error);
1172
+ return result.state;
947
1173
  }
948
1174
  /**
949
1175
  * Apply a JSON Patch to the state in place, mutating the existing state.
950
1176
  * Throws `PatchError` on conflict.
951
1177
  * @param state - The CRDT state to mutate.
952
1178
  * @param patch - Array of RFC 6902 JSON Patch operations.
953
- * @param options - Optional base document and test evaluation mode.
1179
+ * @param options - Optional base state snapshot, patch semantics, and atomicity.
954
1180
  */
955
1181
  function applyPatchInPlace(state, patch, options = {}) {
956
- if (options.atomic ?? true) {
957
- const next = applyPatch(state, patch, options);
958
- state.doc = next.doc;
959
- state.clock = next.clock;
960
- return;
1182
+ const result = tryApplyPatchInPlace(state, patch, options);
1183
+ if (!result.ok) throw new PatchError(result.error);
1184
+ }
1185
+ /** Non-throwing immutable patch application variant. */
1186
+ function tryApplyPatch(state, patch, options = {}) {
1187
+ const nextState = {
1188
+ doc: cloneDoc(state.doc),
1189
+ clock: cloneClock(state.clock)
1190
+ };
1191
+ const result = applyPatchInternal(nextState, patch, options);
1192
+ if (!result.ok) return {
1193
+ ok: false,
1194
+ error: result
1195
+ };
1196
+ return {
1197
+ ok: true,
1198
+ state: nextState
1199
+ };
1200
+ }
1201
+ /** Non-throwing in-place patch application variant. */
1202
+ function tryApplyPatchInPlace(state, patch, options = {}) {
1203
+ const { atomic = true, ...applyOptions } = options;
1204
+ if (atomic) {
1205
+ const next = tryApplyPatch(state, patch, applyOptions);
1206
+ if (!next.ok) return next;
1207
+ state.doc = next.state.doc;
1208
+ state.clock = next.state.clock;
1209
+ return { ok: true };
961
1210
  }
962
- const result = applyPatchInternal(state, patch, options);
963
- if (!result.ok) throw new PatchError(result.message, result.code);
1211
+ const result = applyPatchInternal(state, patch, applyOptions);
1212
+ if (!result.ok) return {
1213
+ ok: false,
1214
+ error: result
1215
+ };
1216
+ return { ok: true };
1217
+ }
1218
+ /**
1219
+ * Validate whether a patch is applicable against a JSON base value under the chosen options.
1220
+ * Does not mutate caller-provided values.
1221
+ */
1222
+ function validateJsonPatch(base, patch, options = {}) {
1223
+ const result = tryApplyPatch(createState(base, { actor: "__validate__" }), patch, options);
1224
+ if (!result.ok) return {
1225
+ ok: false,
1226
+ error: result.error
1227
+ };
1228
+ return { ok: true };
964
1229
  }
965
1230
  /**
966
1231
  * Apply a JSON Patch as a specific actor while maintaining an external version vector.
@@ -971,7 +1236,7 @@ function applyPatchAsActor(doc, vv, actor, patch, options = {}) {
971
1236
  const state = applyPatch({
972
1237
  doc,
973
1238
  clock: createClock(actor, Math.max(vv[actor] ?? 0, observedCtr))
974
- }, patch, options);
1239
+ }, patch, toApplyPatchOptionsForActor(options));
975
1240
  return {
976
1241
  state,
977
1242
  vv: {
@@ -980,27 +1245,37 @@ function applyPatchAsActor(doc, vv, actor, patch, options = {}) {
980
1245
  }
981
1246
  };
982
1247
  }
1248
+ function toApplyPatchOptionsForActor(options) {
1249
+ return {
1250
+ semantics: options.semantics,
1251
+ testAgainst: options.testAgainst,
1252
+ base: options.base ? {
1253
+ doc: options.base,
1254
+ clock: createClock("__base__", 0)
1255
+ } : void 0
1256
+ };
1257
+ }
983
1258
  function applyPatchInternal(state, patch, options) {
984
- if ((options.semantics ?? "base") === "sequential") {
1259
+ if ((options.semantics ?? "sequential") === "sequential") {
985
1260
  const explicitBaseState = options.base ? {
986
- doc: cloneDoc(options.base),
1261
+ doc: cloneDoc(options.base.doc),
987
1262
  clock: createClock("__base__", 0)
988
1263
  } : null;
989
1264
  for (const op of patch) {
990
1265
  const step = applyPatchOpSequential(state, op, options, explicitBaseState ? explicitBaseState.doc : cloneDoc(state.doc));
991
1266
  if (!step.ok) return step;
992
- if (explicitBaseState) {
1267
+ if (explicitBaseState && op.op !== "test") {
993
1268
  const baseStep = applyPatchInternal(explicitBaseState, [op], {
994
1269
  semantics: "sequential",
995
- testAgainst: options.testAgainst
1270
+ testAgainst: "base"
996
1271
  });
997
1272
  if (!baseStep.ok) return baseStep;
998
1273
  }
999
1274
  }
1000
1275
  return { ok: true };
1001
1276
  }
1002
- const baseDoc = options.base ? options.base : cloneDoc(state.doc);
1003
- const compiled = compileIntents(materialize(baseDoc.root), patch);
1277
+ const baseDoc = options.base ? options.base.doc : cloneDoc(state.doc);
1278
+ const compiled = compileIntents(materialize(baseDoc.root), patch, "base");
1004
1279
  if (!compiled.ok) return compiled;
1005
1280
  return applyIntentsToCrdt(baseDoc, state.doc, compiled.intents, () => state.clock.next(), options.testAgainst ?? "head", (ctr) => bumpClockCounter(state, ctr));
1006
1281
  }
@@ -1030,25 +1305,21 @@ function applyPatchOpSequential(state, op, options, baseDoc) {
1030
1305
  return applySinglePatchOp(state, baseDoc, op, options);
1031
1306
  }
1032
1307
  function applySinglePatchOp(state, baseDoc, op, options) {
1033
- const compiled = compileIntents(materialize(baseDoc.root), [op]);
1308
+ const compiled = compileIntents(materialize(baseDoc.root), [op], "sequential");
1034
1309
  if (!compiled.ok) return compiled;
1035
1310
  return applyIntentsToCrdt(baseDoc, state.doc, compiled.intents, () => state.clock.next(), options.testAgainst ?? "head", (ctr) => bumpClockCounter(state, ctr));
1036
1311
  }
1037
1312
  function bumpClockCounter(state, ctr) {
1038
1313
  if (state.clock.ctr < ctr) state.clock.ctr = ctr;
1039
1314
  }
1040
- function compileIntents(baseJson, patch) {
1315
+ function compileIntents(baseJson, patch, semantics = "sequential") {
1041
1316
  try {
1042
1317
  return {
1043
1318
  ok: true,
1044
- intents: compileJsonPatchToIntent(baseJson, patch)
1319
+ intents: compileJsonPatchToIntent(baseJson, patch, { semantics })
1045
1320
  };
1046
1321
  } catch (error) {
1047
- return {
1048
- ok: false,
1049
- code: 409,
1050
- message: error instanceof Error ? error.message : "failed to compile patch"
1051
- };
1322
+ return toApplyError(error);
1052
1323
  }
1053
1324
  }
1054
1325
  function maxCtrInNodeForActor$1(node, actor) {
@@ -1075,6 +1346,22 @@ function maxCtrInNodeForActor$1(node, actor) {
1075
1346
  }
1076
1347
  }
1077
1348
  }
1349
+ function toApplyError(error) {
1350
+ if (error instanceof PatchCompileError) return {
1351
+ ok: false,
1352
+ code: 409,
1353
+ reason: error.reason,
1354
+ message: error.message,
1355
+ path: error.path,
1356
+ opIndex: error.opIndex
1357
+ };
1358
+ return {
1359
+ ok: false,
1360
+ code: 409,
1361
+ reason: "INVALID_PATCH",
1362
+ message: error instanceof Error ? error.message : "failed to compile patch"
1363
+ };
1364
+ }
1078
1365
 
1079
1366
  //#endregion
1080
1367
  //#region src/serialize.ts
@@ -1197,6 +1484,19 @@ function deserializeNode(node) {
1197
1484
 
1198
1485
  //#endregion
1199
1486
  //#region src/merge.ts
1487
+ /** Error thrown by throwing merge helpers (`mergeDoc` / `mergeState`). */
1488
+ var MergeError = class extends Error {
1489
+ code;
1490
+ reason;
1491
+ path;
1492
+ constructor(error) {
1493
+ super(error.message);
1494
+ this.name = "MergeError";
1495
+ this.code = error.code;
1496
+ this.reason = "LINEAGE_MISMATCH";
1497
+ this.path = error.path;
1498
+ }
1499
+ };
1200
1500
  /**
1201
1501
  * Merge two CRDT documents from different peers into one.
1202
1502
  * By default this requires shared array lineage for non-empty sequences.
@@ -1211,9 +1511,27 @@ function deserializeNode(node) {
1211
1511
  * - **Kind mismatch**: the node with the higher "representative dot" wins and replaces the other entirely.
1212
1512
  */
1213
1513
  function mergeDoc(a, b, options = {}) {
1514
+ const result = tryMergeDoc(a, b, options);
1515
+ if (!result.ok) throw new MergeError(result.error);
1516
+ return result.doc;
1517
+ }
1518
+ /** Non-throwing `mergeDoc` variant with structured conflict details. */
1519
+ function tryMergeDoc(a, b, options = {}) {
1214
1520
  const mismatchPath = options.requireSharedOrigin ?? true ? findSeqLineageMismatch(a.root, b.root, []) : null;
1215
- if (mismatchPath) throw new Error(`merge requires shared array origin at ${mismatchPath}`);
1216
- return { root: mergeNode(a.root, b.root) };
1521
+ if (mismatchPath) return {
1522
+ ok: false,
1523
+ error: {
1524
+ ok: false,
1525
+ code: 409,
1526
+ reason: "LINEAGE_MISMATCH",
1527
+ message: `merge requires shared array origin at ${mismatchPath}`,
1528
+ path: mismatchPath
1529
+ }
1530
+ };
1531
+ return {
1532
+ ok: true,
1533
+ doc: { root: mergeNode(a.root, b.root) }
1534
+ };
1217
1535
  }
1218
1536
  /**
1219
1537
  * Merge two CRDT states.
@@ -1227,11 +1545,22 @@ function mergeDoc(a, b, options = {}) {
1227
1545
  * that actor across both input clocks and the merged document dots.
1228
1546
  */
1229
1547
  function mergeState(a, b, options = {}) {
1230
- const doc = mergeDoc(a.doc, b.doc, { requireSharedOrigin: options.requireSharedOrigin });
1548
+ const result = tryMergeState(a, b, options);
1549
+ if (!result.ok) throw new MergeError(result.error);
1550
+ return result.state;
1551
+ }
1552
+ /** Non-throwing `mergeState` variant with structured conflict details. */
1553
+ function tryMergeState(a, b, options = {}) {
1554
+ const mergedDoc = tryMergeDoc(a.doc, b.doc, { requireSharedOrigin: options.requireSharedOrigin });
1555
+ if (!mergedDoc.ok) return mergedDoc;
1556
+ const doc = mergedDoc.doc;
1231
1557
  const actor = options.actor ?? a.clock.actor;
1232
1558
  return {
1233
- doc,
1234
- clock: createClock(actor, maxObservedCtrForActor(doc, actor, a, b))
1559
+ ok: true,
1560
+ state: {
1561
+ doc,
1562
+ clock: createClock(actor, maxObservedCtrForActor(doc, actor, a, b))
1563
+ }
1235
1564
  };
1236
1565
  }
1237
1566
  function findSeqLineageMismatch(a, b, path) {
@@ -1432,4 +1761,4 @@ function cloneNodeShallow(node) {
1432
1761
  }
1433
1762
 
1434
1763
  //#endregion
1435
- export { newReg as A, rgaPrevForInsertAtIndex as B, getAtJson as C, ROOT_KEY as D, stringifyJsonPointer as E, HEAD as F, cloneClock as G, dotToElemId as H, rgaDelete as I, observeDot as J, createClock as K, rgaIdAtIndex as L, objRemove as M, objSet as N, lwwSet as O, materialize as P, rgaInsertAfter as R, diffJsonPatch as S, parseJsonPointer as T, vvHasDot as U, compareDot as V, vvMerge as W, docFromJson as _, serializeDoc as a, jsonPatchToCrdtSafe as b, applyPatch as c, createState as d, toJson as f, crdtToJsonPatch as g, crdtToFullReplace as h, deserializeState as i, newSeq as j, newObj as k, applyPatchAsActor as l, cloneDoc as m, mergeState as n, serializeState as o, applyIntentsToCrdt as p, nextDotForActor as q, deserializeDoc as r, PatchError as s, mergeDoc as t, applyPatchInPlace as u, docFromJsonWithDot as v, jsonEquals as w, compileJsonPatchToIntent as x, jsonPatchToCrdt as y, rgaLinearizeIds as z };
1764
+ export { vvMerge as $, compileJsonPatchToIntent as A, newSeq as B, crdtToJsonPatch as C, jsonPatchToCrdtSafe as D, jsonPatchToCrdt as E, stringifyJsonPointer as F, rgaDelete as G, objSet as H, ROOT_KEY as I, rgaLinearizeIds as J, rgaIdAtIndex as K, lwwSet as L, getAtJson as M, jsonEquals as N, tryJsonPatchToCrdt as O, parseJsonPointer as P, vvHasDot as Q, newObj as R, crdtToFullReplace as S, docFromJsonWithDot as T, materialize as U, objRemove as V, HEAD as W, compareDot as X, rgaPrevForInsertAtIndex as Y, dotToElemId as Z, tryApplyPatch as _, tryMergeState as a, applyIntentsToCrdt as b, serializeDoc as c, applyPatch as d, cloneClock as et, applyPatchAsActor as f, toJson as g, forkState as h, tryMergeDoc as i, diffJsonPatch as j, PatchCompileError as k, serializeState as l, createState as m, mergeDoc as n, nextDotForActor as nt, deserializeDoc as o, applyPatchInPlace as p, rgaInsertAfter as q, mergeState as r, observeDot as rt, deserializeState as s, MergeError as t, createClock as tt, PatchError as u, tryApplyPatchInPlace as v, docFromJson as w, cloneDoc as x, validateJsonPatch as y, newReg as z };