@wildwinter/toolkit 0.2.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @wildwinter/toolkit
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Added
6
+
7
+ - **Comment threads merge message by message** (`./extensions`): `mergeComments(base, ours, theirs)`, and the
8
+ `comments` strategy (`COMMENTS`) for `mergeTree`, lifted from Patter's merge so both families take one rule.
9
+ Two replies to one thread both land, oldest first; a withdrawn message stays withdrawn, and one a side removed
10
+ outright, or lost with its thread, counts as withdrawn by that side; one message edited two ways is a clash.
11
+ A thread's own fields (anchor, marker, `resolved`) merge one by one, and a thread with nothing readable left
12
+ goes. Threads come back oldest first.
13
+
3
14
  ## 0.2.1
4
15
 
5
16
  ### Changed
@@ -49,7 +49,52 @@
49
49
  "base": { "k": { "a": 1 } }, "ours": { "k": { "a": 2 } }, "theirs": { "k": { "a": 1, "b": 1 } },
50
50
  "merged": { "k": { "a": 2, "b": 1 } }, "conflicts": [] },
51
51
  { "name": "a value whose shape no longer fits its strategy merges as one value", "strategy": { "kind": "map", "of": { "kind": "atomic" } },
52
- "base": { "a": 1 }, "ours": "gone", "theirs": { "a": 2 }, "merged": "gone", "conflicts": [{ "path": "", "kind": "both-changed" }] }
52
+ "base": { "a": 1 }, "ours": "gone", "theirs": { "a": 2 }, "merged": "gone", "conflicts": [{ "path": "", "kind": "both-changed" }] },
53
+ { "name": "comments: two replies to one thread both land, in time order", "strategy": { "kind": "comments" },
54
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
55
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
56
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Cy", "ts": "2026-01-01T11:00", "body": "Ask Bo." }] }],
57
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Cy", "ts": "2026-01-01T11:00", "body": "Ask Bo." }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
58
+ "conflicts": [] },
59
+ { "name": "comments: a message withdrawn on one side stays withdrawn", "strategy": { "kind": "comments" },
60
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
61
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
62
+ "theirs": [{ "id": "t1", "anchor": "c1", "resolved": true, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
63
+ "merged": [{ "id": "t1", "anchor": "c1", "resolved": true, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
64
+ "conflicts": [] },
65
+ { "name": "comments: a message one side removed outright is withdrawn, not brought back", "strategy": { "kind": "comments" },
66
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
67
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
68
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
69
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
70
+ "conflicts": [] },
71
+ { "name": "comments: a thread one side removed goes, unless the other side replied", "strategy": { "kind": "comments" },
72
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] },
73
+ { "id": "t2", "anchor": "c2", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:30", "body": "Typo" }] }],
74
+ "ours": [],
75
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] },
76
+ { "id": "t2", "anchor": "c2", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:30", "body": "Typo" }] }],
77
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "", "deleted": true }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
78
+ "conflicts": [] },
79
+ { "name": "comments: one message edited two ways clashes and keeps ours", "strategy": { "kind": "comments" },
80
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
81
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why this?" }] }],
82
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why that?" }] }],
83
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why this?" }] }],
84
+ "conflicts": [{ "path": "[t1].messages[Ann|2026-01-01T10:00]", "kind": "both-changed" }] },
85
+ { "name": "comments: a thread's own fields merge one by one, a marker moved two ways clashes", "strategy": { "kind": "comments" },
86
+ "base": [{ "id": "t1", "anchor": "c1", "mark": { "canvas": "d1", "x": 0, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
87
+ "ours": [{ "id": "t1", "anchor": "c1", "mark": { "canvas": "d1", "x": 5, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
88
+ "theirs": [{ "id": "t1", "anchor": "c1", "resolved": true, "mark": { "canvas": "d1", "x": 9, "y": 9 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
89
+ "merged": [{ "id": "t1", "anchor": "c1", "resolved": true, "mark": { "canvas": "d1", "x": 5, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
90
+ "conflicts": [{ "path": "[t1].mark", "kind": "both-changed" }] },
91
+ { "name": "comments: threads come back oldest first, whatever their ids", "strategy": { "kind": "comments" },
92
+ "base": [],
93
+ "ours": [{ "id": "zz", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T09:00", "body": "First" }] }],
94
+ "theirs": [{ "id": "aa", "anchor": "c2", "messages": [{ "author": "Bo", "ts": "2026-01-02T09:00", "body": "Second" }] }],
95
+ "merged": [{ "id": "zz", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T09:00", "body": "First" }] },
96
+ { "id": "aa", "anchor": "c2", "messages": [{ "author": "Bo", "ts": "2026-01-02T09:00", "body": "Second" }] }],
97
+ "conflicts": [] }
53
98
  ],
54
99
  "manifest": {
55
100
  "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
@@ -641,6 +641,7 @@ function effectiveClaim(claim, hostDecl) {
641
641
  // src/extensions/merge.ts
642
642
  var ATOMIC = { kind: "atomic" };
643
643
  var SET = { kind: "set" };
644
+ var COMMENTS = { kind: "comments" };
644
645
  var mapOf = (of) => ({ kind: "map", of });
645
646
  var keyedBy = (key, of, opts) => ({
646
647
  kind: "keyed",
@@ -683,6 +684,7 @@ function hasShape(spec, v) {
683
684
  return true;
684
685
  case "set":
685
686
  case "keyed":
687
+ case "comments":
686
688
  return Array.isArray(v);
687
689
  case "map":
688
690
  case "object":
@@ -722,6 +724,8 @@ function mergeValue(spec, b, o, t, id, path, ctx) {
722
724
  }
723
725
  case "keyed":
724
726
  return mergeKeyed(spec, asArr(b), o, t, path, ctx);
727
+ case "comments":
728
+ return mergeThreads(asArr(b), o, t, path, ctx);
725
729
  }
726
730
  }
727
731
  function mergeKeyed(spec, B, O, T, path, ctx) {
@@ -800,6 +804,70 @@ function preservedOrder(B, O, T, survivors, path, ctx) {
800
804
  for (const k of [...survivors.keys()].sort()) if (!result.includes(k)) result.push(k);
801
805
  return result;
802
806
  }
807
+ function mergeComments(base, ours, theirs, opts) {
808
+ return mergeTree(COMMENTS, base, ours, theirs, opts);
809
+ }
810
+ function mergeThreads(B, O, T, path, ctx) {
811
+ const index = (arr) => {
812
+ const m = /* @__PURE__ */ new Map();
813
+ for (const c of arr) if (isObject(c) && typeof c["id"] === "string") m.set(c["id"], c);
814
+ return m;
815
+ };
816
+ const Bm = index(B), Om = index(O), Tm = index(T);
817
+ const out = [];
818
+ for (const id of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
819
+ const b = Bm.get(id), o = Om.get(id), t = Tm.get(id);
820
+ const here = `${path}[${id}]`;
821
+ if (o === void 0 && t === void 0) continue;
822
+ if (![b, o, t].some((c) => Array.isArray(c?.["messages"]))) {
823
+ const v = o !== void 0 && t !== void 0 ? merge3(b, o, t, id, here, ctx) : b !== void 0 && ctx.eq(o ?? t, b) ? void 0 : o ?? t;
824
+ if (isObject(v)) out.push(v);
825
+ continue;
826
+ }
827
+ const messages = mergeMessages(b, o, t, id, here, ctx);
828
+ if (messages.length === 0 || messages.every((m) => m["deleted"] === true)) continue;
829
+ const fields = {};
830
+ if (o !== void 0 && t !== void 0) {
831
+ const B0 = b ?? {};
832
+ const flag = (c, k) => k === "resolved" ? c[k] === true ? true : void 0 : c[k];
833
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(o), ...Object.keys(t), ...Object.keys(B0)])) {
834
+ if (k === "messages") continue;
835
+ const v = merge3(flag(B0, k), flag(o, k), flag(t, k), id, `${here}.${k}`, ctx);
836
+ if (v !== void 0) fields[k] = v;
837
+ }
838
+ } else {
839
+ for (const [k, v] of Object.entries(o ?? t)) if (k !== "messages") fields[k] = v;
840
+ }
841
+ out.push({ ...fields, messages });
842
+ }
843
+ const first = (c) => {
844
+ const m = asArr(c["messages"])[0];
845
+ return isObject(m) && typeof m["ts"] === "string" ? m["ts"] : "";
846
+ };
847
+ return out.sort((x, y) => first(x).localeCompare(first(y)) || String(x["id"]).localeCompare(String(y["id"])));
848
+ }
849
+ function mergeMessages(b, o, t, id, here, ctx) {
850
+ const keyOf = (m) => `${String(m["author"] ?? "")}|${String(m["ts"] ?? "")}`;
851
+ const index = (c) => {
852
+ const m = /* @__PURE__ */ new Map();
853
+ for (const x of asArr(c?.["messages"])) if (isObject(x)) m.set(keyOf(x), x);
854
+ return m;
855
+ };
856
+ const Bm = index(b), Om = index(o), Tm = index(t);
857
+ const out = [];
858
+ for (const k of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
859
+ const mb = Bm.get(k), mo = Om.get(k), mt = Tm.get(k);
860
+ if ([mb, mo, mt].some((m) => m?.["deleted"] === true) || mb !== void 0 && (mo === void 0 || mt === void 0)) {
861
+ out.push({ ...mo ?? mt ?? mb, body: "", deleted: true });
862
+ } else if (mo !== void 0 && mt !== void 0) {
863
+ out.push(merge3(mb, mo, mt, id, `${here}.messages[${k}]`, ctx));
864
+ } else {
865
+ out.push(mo ?? mt);
866
+ }
867
+ }
868
+ const ts = (m) => String(m["ts"] ?? "");
869
+ return out.sort((x, y) => ts(x).localeCompare(ts(y)) || String(x["author"] ?? "").localeCompare(String(y["author"] ?? "")));
870
+ }
803
871
  function strategyForType(decl) {
804
872
  switch (decl.type) {
805
873
  case "flags":
@@ -908,6 +976,7 @@ function orphanedEntries(data, exists) {
908
976
  }
909
977
 
910
978
  exports.ATOMIC = ATOMIC;
979
+ exports.COMMENTS = COMMENTS;
911
980
  exports.HOSTS = HOSTS;
912
981
  exports.MANIFEST_FILE_SUFFIX = MANIFEST_FILE_SUFFIX;
913
982
  exports.MANIFEST_SCHEMA = MANIFEST_SCHEMA;
@@ -928,6 +997,7 @@ exports.keyedBy = keyedBy;
928
997
  exports.manifestProblems = manifestProblems;
929
998
  exports.mapOf = mapOf;
930
999
  exports.markUnresolved = markUnresolved;
1000
+ exports.mergeComments = mergeComments;
931
1001
  exports.mergeExtensionData = mergeExtensionData;
932
1002
  exports.mergeTree = mergeTree;
933
1003
  exports.objectOf = objectOf;