@storacha/md-merge 0.5.0 → 0.7.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.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { RGAChangeSet, RGATreeRoot } from "./types.js";
8
8
  import type { RGAEvent, EventComparator } from "./crdt/rga.js";
9
9
  export { parse, stringify, stringifyNode, fingerprint } from "./parse.js";
10
10
  export { diff, applyChangeSet } from "./diff.js";
11
- export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, } from "./rga-tree.js";
11
+ export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, mergeRGATrees, } from "./rga-tree.js";
12
12
  export { encodeTree, decodeTree, encodeChangeSet, decodeChangeSet, encodeRGA, decodeRGA, } from "./codec.js";
13
13
  export type { ChangeSet, Change, RGAChangeSet, RGAChange, RGATreeRoot, RGATreeNode, RGAParentNode, RGALeafNode, } from "./types.js";
14
14
  export { RGA, type RGAEvent, type RGANodeId, type EventComparator, } from "./types.js";
@@ -31,3 +31,7 @@ export declare function applyToMarkdown<E extends RGAEvent>(tree: RGATreeRoot<E>
31
31
  * Bootstrap: create an RGA tree from a markdown string.
32
32
  */
33
33
  export declare function fromMarkdown<E extends RGAEvent>(markdown: string, event: E, compareEvents: EventComparator<E>): RGATreeRoot<E>;
34
+ /**
35
+ * Convert an RGA tree back to a markdown string.
36
+ */
37
+ export declare function toMarkdown<E extends RGAEvent>(tree: RGATreeRoot<E>): string;
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { toRGATree, toMdast, generateRGAChangeSet, applyRGAChangeSet, } from "./
9
9
  // Re-exports
10
10
  export { parse, stringify, stringifyNode, fingerprint } from "./parse.js";
11
11
  export { diff, applyChangeSet } from "./diff.js";
12
- export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, } from "./rga-tree.js";
12
+ export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, mergeRGATrees, } from "./rga-tree.js";
13
13
  export { encodeTree, decodeTree, encodeChangeSet, decodeChangeSet, encodeRGA, decodeRGA, } from "./codec.js";
14
14
  export { RGA, } from "./types.js";
15
15
  /**
@@ -39,3 +39,9 @@ export function fromMarkdown(markdown, event, compareEvents) {
39
39
  const root = parse(markdown);
40
40
  return toRGATree(root, event, compareEvents);
41
41
  }
42
+ /**
43
+ * Convert an RGA tree back to a markdown string.
44
+ */
45
+ export function toMarkdown(tree) {
46
+ return stringify(toMdast(tree));
47
+ }
@@ -30,3 +30,14 @@ export declare function generateRGAChangeSet<E extends RGAEvent>(existing: RGATr
30
30
  * Navigates by node IDs at every level — no index dependency.
31
31
  */
32
32
  export declare function applyRGAChangeSet<E extends RGAEvent>(root: RGATreeRoot<E>, changeset: RGAChangeSet<E>, compareEvents: EventComparator<E>): RGATreeRoot<E>;
33
+ /**
34
+ * Merge two RGA trees, returning a new tree with nodes from both.
35
+ *
36
+ * Clones the target first (non-destructive), merges all source nodes in,
37
+ * and sets the comparator throughout so linearization uses the correct
38
+ * (merged) event ordering.
39
+ *
40
+ * At each level: union of RGA nodes, tombstones win. For nodes present in
41
+ * both trees with children, recursively merges children RGAs.
42
+ */
43
+ export declare function mergeRGATrees<E extends RGAEvent>(target: RGATreeRoot<E>, source: RGATreeRoot<E>, compareEvents: EventComparator<E>): RGATreeRoot<E>;
package/dist/rga-tree.js CHANGED
@@ -208,3 +208,67 @@ export function applyRGAChangeSet(root, changeset, compareEvents) {
208
208
  }
209
209
  return { type: "root", children: updatedChildren };
210
210
  }
211
+ // ---- Tree Merge ----
212
+ /**
213
+ * Clone an RGATreeNode, deep-cloning any children RGA.
214
+ */
215
+ function cloneTreeNode(node) {
216
+ if (isRGAParent(node)) {
217
+ return { ...node, children: cloneRGA(node.children) };
218
+ }
219
+ return node;
220
+ }
221
+ /**
222
+ * Merge source RGA nodes into target RGA (mutates target).
223
+ * For nodes present in both that have children, recursively merges children.
224
+ * Tombstones win.
225
+ */
226
+ function mergeChildrenRGA(target, source) {
227
+ for (const [key, sourceNode] of source.nodes) {
228
+ const targetNode = target.nodes.get(key);
229
+ if (!targetNode) {
230
+ target.nodes.set(key, {
231
+ ...sourceNode,
232
+ value: cloneTreeNode(sourceNode.value),
233
+ });
234
+ }
235
+ else {
236
+ if (sourceNode.tombstone) {
237
+ targetNode.tombstone = true;
238
+ }
239
+ if (isRGAParent(targetNode.value) && isRGAParent(sourceNode.value)) {
240
+ mergeChildrenRGA(targetNode.value.children, sourceNode.value.children);
241
+ }
242
+ }
243
+ }
244
+ }
245
+ /**
246
+ * Update the compareEvents function on an RGA and all nested child RGAs.
247
+ */
248
+ function updateComparators(rga, compareEvents) {
249
+ rga.compareEvents = compareEvents;
250
+ for (const node of rga.nodes.values()) {
251
+ if (isRGAParent(node.value)) {
252
+ updateComparators(node.value.children, compareEvents);
253
+ }
254
+ }
255
+ }
256
+ /**
257
+ * Merge two RGA trees, returning a new tree with nodes from both.
258
+ *
259
+ * Clones the target first (non-destructive), merges all source nodes in,
260
+ * and sets the comparator throughout so linearization uses the correct
261
+ * (merged) event ordering.
262
+ *
263
+ * At each level: union of RGA nodes, tombstones win. For nodes present in
264
+ * both trees with children, recursively merges children RGAs.
265
+ */
266
+ export function mergeRGATrees(target, source, compareEvents) {
267
+ const result = {
268
+ type: "root",
269
+ children: cloneRGA(target.children),
270
+ };
271
+ mergeChildrenRGA(result.children, source.children);
272
+ updateComparators(result.children, compareEvents);
273
+ return result;
274
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storacha/md-merge",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  applyMdastToRGATree,
25
25
  generateRGAChangeSet,
26
26
  applyRGAChangeSet,
27
+ mergeRGATrees,
27
28
  } from "./rga-tree.js";
28
29
  export {
29
30
  encodeTree,
@@ -96,3 +97,10 @@ export function fromMarkdown<E extends RGAEvent>(
96
97
  const root = parse(markdown);
97
98
  return toRGATree(root, event, compareEvents);
98
99
  }
100
+
101
+ /**
102
+ * Convert an RGA tree back to a markdown string.
103
+ */
104
+ export function toMarkdown<E extends RGAEvent>(tree: RGATreeRoot<E>): string {
105
+ return stringify(toMdast(tree));
106
+ }
package/src/rga-tree.ts CHANGED
@@ -299,3 +299,83 @@ export function applyRGAChangeSet<E extends RGAEvent>(
299
299
 
300
300
  return { type: "root", children: updatedChildren };
301
301
  }
302
+
303
+ // ---- Tree Merge ----
304
+
305
+ /**
306
+ * Clone an RGATreeNode, deep-cloning any children RGA.
307
+ */
308
+ function cloneTreeNode<E extends RGAEvent>(
309
+ node: RGATreeNode<E>,
310
+ ): RGATreeNode<E> {
311
+ if (isRGAParent(node)) {
312
+ return { ...node, children: cloneRGA(node.children) } as RGATreeNode<E>;
313
+ }
314
+ return node;
315
+ }
316
+
317
+ /**
318
+ * Merge source RGA nodes into target RGA (mutates target).
319
+ * For nodes present in both that have children, recursively merges children.
320
+ * Tombstones win.
321
+ */
322
+ function mergeChildrenRGA<E extends RGAEvent>(
323
+ target: RGA<RGATreeNode<E>, E>,
324
+ source: RGA<RGATreeNode<E>, E>,
325
+ ): void {
326
+ for (const [key, sourceNode] of source.nodes) {
327
+ const targetNode = target.nodes.get(key);
328
+ if (!targetNode) {
329
+ target.nodes.set(key, {
330
+ ...sourceNode,
331
+ value: cloneTreeNode(sourceNode.value),
332
+ });
333
+ } else {
334
+ if (sourceNode.tombstone) {
335
+ targetNode.tombstone = true;
336
+ }
337
+ if (isRGAParent(targetNode.value) && isRGAParent(sourceNode.value)) {
338
+ mergeChildrenRGA(targetNode.value.children, sourceNode.value.children);
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Update the compareEvents function on an RGA and all nested child RGAs.
346
+ */
347
+ function updateComparators<E extends RGAEvent>(
348
+ rga: RGA<RGATreeNode<E>, E>,
349
+ compareEvents: EventComparator<E>,
350
+ ): void {
351
+ rga.compareEvents = compareEvents;
352
+ for (const node of rga.nodes.values()) {
353
+ if (isRGAParent(node.value)) {
354
+ updateComparators(node.value.children, compareEvents);
355
+ }
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Merge two RGA trees, returning a new tree with nodes from both.
361
+ *
362
+ * Clones the target first (non-destructive), merges all source nodes in,
363
+ * and sets the comparator throughout so linearization uses the correct
364
+ * (merged) event ordering.
365
+ *
366
+ * At each level: union of RGA nodes, tombstones win. For nodes present in
367
+ * both trees with children, recursively merges children RGAs.
368
+ */
369
+ export function mergeRGATrees<E extends RGAEvent>(
370
+ target: RGATreeRoot<E>,
371
+ source: RGATreeRoot<E>,
372
+ compareEvents: EventComparator<E>,
373
+ ): RGATreeRoot<E> {
374
+ const result: RGATreeRoot<E> = {
375
+ type: "root",
376
+ children: cloneRGA(target.children),
377
+ };
378
+ mergeChildrenRGA(result.children, source.children);
379
+ updateComparators(result.children, compareEvents);
380
+ return result;
381
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { parse, stringify } from "../src/parse.js";
3
- import { toRGATree, toMdast, applyMdastToRGATree } from "../src/rga-tree.js";
3
+ import { toRGATree, toMdast, applyMdastToRGATree, mergeRGATrees } from "../src/rga-tree.js";
4
4
  import {
5
5
  RGA,
6
6
  type RGAEvent,
@@ -208,3 +208,116 @@ describe("applyMdast", () => {
208
208
  expect(stringify(result).trim()).toBe(stringify(newRoot).trim());
209
209
  });
210
210
  });
211
+
212
+ describe("mergeRGATrees", () => {
213
+
214
+ const r2 = new TestEvent("r2");
215
+ const r3 = new TestEvent("r3");
216
+
217
+ it("merges two trees from the same base with different additions", () => {
218
+ const baseMd = "# Hello\n\nOriginal.\n";
219
+ const baseRoot = parse(baseMd);
220
+ const base = toRGATree(baseRoot, r1, cmp);
221
+
222
+ // Branch 1: add paragraph from r2
223
+ const tree1 = applyMdastToRGATree(
224
+ base,
225
+ parse("# Hello\n\nOriginal.\n\nFrom branch 1.\n"),
226
+ r2,
227
+ cmp,
228
+ );
229
+
230
+ // Branch 2: add paragraph from r3
231
+ const tree2 = applyMdastToRGATree(
232
+ base,
233
+ parse("# Hello\n\nOriginal.\n\nFrom branch 2.\n"),
234
+ r3,
235
+ cmp,
236
+ );
237
+
238
+ const merged = mergeRGATrees(tree1, tree2, cmp);
239
+ const result = stringify(toMdast(merged));
240
+
241
+ expect(result).toContain("Original.");
242
+ expect(result).toContain("From branch 1.");
243
+ expect(result).toContain("From branch 2.");
244
+ });
245
+
246
+ it("merges two independently created trees (concurrent initials)", () => {
247
+ // Two trees created independently — no shared base
248
+ const tree1 = toRGATree(parse("# Doc\n\nFrom replica 1.\n"), r2, cmp);
249
+ const tree2 = toRGATree(parse("# Doc\n\nFrom replica 2.\n"), r3, cmp);
250
+
251
+ const merged = mergeRGATrees(tree1, tree2, cmp);
252
+ const result = stringify(toMdast(merged));
253
+
254
+ // Both trees' content should be present (interleaved by event order)
255
+ expect(result).toContain("From replica 1.");
256
+ expect(result).toContain("From replica 2.");
257
+ });
258
+
259
+ it("tombstones win during merge", () => {
260
+ const baseMd = "# Hello\n\nParagraph one.\n\nParagraph two.\n";
261
+ const baseRoot = parse(baseMd);
262
+ const base = toRGATree(baseRoot, r1, cmp);
263
+
264
+ // Branch 1: delete paragraph two
265
+ const tree1 = applyMdastToRGATree(
266
+ base,
267
+ parse("# Hello\n\nParagraph one.\n"),
268
+ r2,
269
+ cmp,
270
+ );
271
+
272
+ // Branch 2: no changes (still has paragraph two)
273
+ const tree2 = applyMdastToRGATree(base, baseRoot, r1, cmp);
274
+
275
+ const merged = mergeRGATrees(tree1, tree2, cmp);
276
+ const result = stringify(toMdast(merged));
277
+
278
+ expect(result).toContain("Paragraph one.");
279
+ expect(result).not.toContain("Paragraph two.");
280
+ });
281
+
282
+ it("recursively merges nested children (list items)", () => {
283
+ const baseMd = "- item 1\n- item 2\n";
284
+ const base = toRGATree(parse(baseMd), r1, cmp);
285
+
286
+ // Branch 1: add item 3
287
+ const tree1 = applyMdastToRGATree(
288
+ base,
289
+ parse("- item 1\n- item 2\n- item 3\n"),
290
+ r2,
291
+ cmp,
292
+ );
293
+
294
+ // Branch 2: add item 4
295
+ const tree2 = applyMdastToRGATree(
296
+ base,
297
+ parse("- item 1\n- item 2\n- item 4\n"),
298
+ r3,
299
+ cmp,
300
+ );
301
+
302
+ const merged = mergeRGATrees(tree1, tree2, cmp);
303
+ const result = stringify(toMdast(merged));
304
+
305
+ expect(result).toContain("item 1");
306
+ expect(result).toContain("item 2");
307
+ expect(result).toContain("item 3");
308
+ expect(result).toContain("item 4");
309
+ });
310
+
311
+ it("does not mutate the input trees", () => {
312
+ const tree1 = toRGATree(parse("# A\n"), r2, cmp);
313
+ const tree2 = toRGATree(parse("# B\n"), r3, cmp);
314
+
315
+ const origSize1 = tree1.children.nodes.size;
316
+ const origSize2 = tree2.children.nodes.size;
317
+
318
+ mergeRGATrees(tree1, tree2, cmp);
319
+
320
+ expect(tree1.children.nodes.size).toBe(origSize1);
321
+ expect(tree2.children.nodes.size).toBe(origSize2);
322
+ });
323
+ });