@storacha/md-merge 0.6.0 → 0.8.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/diff.js +10 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rga-tree.d.ts +11 -0
- package/dist/rga-tree.js +68 -2
- package/package.json +1 -1
- package/src/diff.ts +10 -5
- package/src/index.ts +2 -3
- package/src/rga-tree.ts +84 -2
- package/test/rga-tree.test.ts +240 -1
package/dist/diff.js
CHANGED
|
@@ -109,14 +109,19 @@ function diffGap(oldNodes, newNodes, oldStartIdx, newStartIdx, pathPrefix) {
|
|
|
109
109
|
});
|
|
110
110
|
oi++;
|
|
111
111
|
}
|
|
112
|
-
// Remaining new = inserts
|
|
113
|
-
|
|
112
|
+
// Remaining new = inserts (batched into a single change)
|
|
113
|
+
if (ni < newNodes.length) {
|
|
114
|
+
const insertNodes = [];
|
|
115
|
+
const insertIdx = newStartIdx + ni;
|
|
116
|
+
while (ni < newNodes.length) {
|
|
117
|
+
insertNodes.push(newNodes[ni]);
|
|
118
|
+
ni++;
|
|
119
|
+
}
|
|
114
120
|
changes.push({
|
|
115
121
|
type: "insert",
|
|
116
|
-
path: [...pathPrefix,
|
|
117
|
-
nodes:
|
|
122
|
+
path: [...pathPrefix, insertIdx],
|
|
123
|
+
nodes: insertNodes,
|
|
118
124
|
});
|
|
119
|
-
ni++;
|
|
120
125
|
}
|
|
121
126
|
return changes;
|
|
122
127
|
}
|
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";
|
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
|
/**
|
package/dist/rga-tree.d.ts
CHANGED
|
@@ -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
|
@@ -189,18 +189,20 @@ export function applyRGAChangeSet(root, changeset, compareEvents) {
|
|
|
189
189
|
break;
|
|
190
190
|
}
|
|
191
191
|
case "insert": {
|
|
192
|
+
let afterId = change.afterId;
|
|
192
193
|
for (const node of change.nodes ?? []) {
|
|
193
194
|
const rgaNode = convertNode(node, changeset.event, compareEvents);
|
|
194
|
-
currentRGA.insert(
|
|
195
|
+
afterId = currentRGA.insert(afterId, rgaNode, changeset.event);
|
|
195
196
|
}
|
|
196
197
|
break;
|
|
197
198
|
}
|
|
198
199
|
case "modify": {
|
|
199
200
|
if (change.targetId)
|
|
200
201
|
currentRGA.delete(change.targetId);
|
|
202
|
+
let afterId = change.afterId;
|
|
201
203
|
for (const node of change.nodes ?? []) {
|
|
202
204
|
const rgaNode = convertNode(node, changeset.event, compareEvents);
|
|
203
|
-
currentRGA.insert(
|
|
205
|
+
afterId = currentRGA.insert(afterId, rgaNode, changeset.event);
|
|
204
206
|
}
|
|
205
207
|
break;
|
|
206
208
|
}
|
|
@@ -208,3 +210,67 @@ export function applyRGAChangeSet(root, changeset, compareEvents) {
|
|
|
208
210
|
}
|
|
209
211
|
return { type: "root", children: updatedChildren };
|
|
210
212
|
}
|
|
213
|
+
// ---- Tree Merge ----
|
|
214
|
+
/**
|
|
215
|
+
* Clone an RGATreeNode, deep-cloning any children RGA.
|
|
216
|
+
*/
|
|
217
|
+
function cloneTreeNode(node) {
|
|
218
|
+
if (isRGAParent(node)) {
|
|
219
|
+
return { ...node, children: cloneRGA(node.children) };
|
|
220
|
+
}
|
|
221
|
+
return node;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Merge source RGA nodes into target RGA (mutates target).
|
|
225
|
+
* For nodes present in both that have children, recursively merges children.
|
|
226
|
+
* Tombstones win.
|
|
227
|
+
*/
|
|
228
|
+
function mergeChildrenRGA(target, source) {
|
|
229
|
+
for (const [key, sourceNode] of source.nodes) {
|
|
230
|
+
const targetNode = target.nodes.get(key);
|
|
231
|
+
if (!targetNode) {
|
|
232
|
+
target.nodes.set(key, {
|
|
233
|
+
...sourceNode,
|
|
234
|
+
value: cloneTreeNode(sourceNode.value),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if (sourceNode.tombstone) {
|
|
239
|
+
targetNode.tombstone = true;
|
|
240
|
+
}
|
|
241
|
+
if (isRGAParent(targetNode.value) && isRGAParent(sourceNode.value)) {
|
|
242
|
+
mergeChildrenRGA(targetNode.value.children, sourceNode.value.children);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Update the compareEvents function on an RGA and all nested child RGAs.
|
|
249
|
+
*/
|
|
250
|
+
function updateComparators(rga, compareEvents) {
|
|
251
|
+
rga.compareEvents = compareEvents;
|
|
252
|
+
for (const node of rga.nodes.values()) {
|
|
253
|
+
if (isRGAParent(node.value)) {
|
|
254
|
+
updateComparators(node.value.children, compareEvents);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Merge two RGA trees, returning a new tree with nodes from both.
|
|
260
|
+
*
|
|
261
|
+
* Clones the target first (non-destructive), merges all source nodes in,
|
|
262
|
+
* and sets the comparator throughout so linearization uses the correct
|
|
263
|
+
* (merged) event ordering.
|
|
264
|
+
*
|
|
265
|
+
* At each level: union of RGA nodes, tombstones win. For nodes present in
|
|
266
|
+
* both trees with children, recursively merges children RGAs.
|
|
267
|
+
*/
|
|
268
|
+
export function mergeRGATrees(target, source, compareEvents) {
|
|
269
|
+
const result = {
|
|
270
|
+
type: "root",
|
|
271
|
+
children: cloneRGA(target.children),
|
|
272
|
+
};
|
|
273
|
+
mergeChildrenRGA(result.children, source.children);
|
|
274
|
+
updateComparators(result.children, compareEvents);
|
|
275
|
+
return result;
|
|
276
|
+
}
|
package/package.json
CHANGED
package/src/diff.ts
CHANGED
|
@@ -131,14 +131,19 @@ function diffGap(
|
|
|
131
131
|
});
|
|
132
132
|
oi++;
|
|
133
133
|
}
|
|
134
|
-
// Remaining new = inserts
|
|
135
|
-
|
|
134
|
+
// Remaining new = inserts (batched into a single change)
|
|
135
|
+
if (ni < newNodes.length) {
|
|
136
|
+
const insertNodes: RootContent[] = [];
|
|
137
|
+
const insertIdx = newStartIdx + ni;
|
|
138
|
+
while (ni < newNodes.length) {
|
|
139
|
+
insertNodes.push(newNodes[ni] as RootContent);
|
|
140
|
+
ni++;
|
|
141
|
+
}
|
|
136
142
|
changes.push({
|
|
137
143
|
type: "insert",
|
|
138
|
-
path: [...pathPrefix,
|
|
139
|
-
nodes:
|
|
144
|
+
path: [...pathPrefix, insertIdx],
|
|
145
|
+
nodes: insertNodes,
|
|
140
146
|
});
|
|
141
|
-
ni++;
|
|
142
147
|
}
|
|
143
148
|
return changes;
|
|
144
149
|
}
|
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,
|
|
@@ -100,8 +101,6 @@ export function fromMarkdown<E extends RGAEvent>(
|
|
|
100
101
|
/**
|
|
101
102
|
* Convert an RGA tree back to a markdown string.
|
|
102
103
|
*/
|
|
103
|
-
export function toMarkdown<E extends RGAEvent>(
|
|
104
|
-
tree: RGATreeRoot<E>,
|
|
105
|
-
): string {
|
|
104
|
+
export function toMarkdown<E extends RGAEvent>(tree: RGATreeRoot<E>): string {
|
|
106
105
|
return stringify(toMdast(tree));
|
|
107
106
|
}
|
package/src/rga-tree.ts
CHANGED
|
@@ -272,25 +272,27 @@ export function applyRGAChangeSet<E extends RGAEvent>(
|
|
|
272
272
|
break;
|
|
273
273
|
}
|
|
274
274
|
case "insert": {
|
|
275
|
+
let afterId = change.afterId;
|
|
275
276
|
for (const node of change.nodes ?? []) {
|
|
276
277
|
const rgaNode = convertNode(
|
|
277
278
|
node as Node,
|
|
278
279
|
changeset.event,
|
|
279
280
|
compareEvents,
|
|
280
281
|
);
|
|
281
|
-
currentRGA.insert(
|
|
282
|
+
afterId = currentRGA.insert(afterId, rgaNode, changeset.event);
|
|
282
283
|
}
|
|
283
284
|
break;
|
|
284
285
|
}
|
|
285
286
|
case "modify": {
|
|
286
287
|
if (change.targetId) currentRGA.delete(change.targetId);
|
|
288
|
+
let afterId = change.afterId;
|
|
287
289
|
for (const node of change.nodes ?? []) {
|
|
288
290
|
const rgaNode = convertNode(
|
|
289
291
|
node as Node,
|
|
290
292
|
changeset.event,
|
|
291
293
|
compareEvents,
|
|
292
294
|
);
|
|
293
|
-
currentRGA.insert(
|
|
295
|
+
afterId = currentRGA.insert(afterId, rgaNode, changeset.event);
|
|
294
296
|
}
|
|
295
297
|
break;
|
|
296
298
|
}
|
|
@@ -299,3 +301,83 @@ export function applyRGAChangeSet<E extends RGAEvent>(
|
|
|
299
301
|
|
|
300
302
|
return { type: "root", children: updatedChildren };
|
|
301
303
|
}
|
|
304
|
+
|
|
305
|
+
// ---- Tree Merge ----
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Clone an RGATreeNode, deep-cloning any children RGA.
|
|
309
|
+
*/
|
|
310
|
+
function cloneTreeNode<E extends RGAEvent>(
|
|
311
|
+
node: RGATreeNode<E>,
|
|
312
|
+
): RGATreeNode<E> {
|
|
313
|
+
if (isRGAParent(node)) {
|
|
314
|
+
return { ...node, children: cloneRGA(node.children) } as RGATreeNode<E>;
|
|
315
|
+
}
|
|
316
|
+
return node;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Merge source RGA nodes into target RGA (mutates target).
|
|
321
|
+
* For nodes present in both that have children, recursively merges children.
|
|
322
|
+
* Tombstones win.
|
|
323
|
+
*/
|
|
324
|
+
function mergeChildrenRGA<E extends RGAEvent>(
|
|
325
|
+
target: RGA<RGATreeNode<E>, E>,
|
|
326
|
+
source: RGA<RGATreeNode<E>, E>,
|
|
327
|
+
): void {
|
|
328
|
+
for (const [key, sourceNode] of source.nodes) {
|
|
329
|
+
const targetNode = target.nodes.get(key);
|
|
330
|
+
if (!targetNode) {
|
|
331
|
+
target.nodes.set(key, {
|
|
332
|
+
...sourceNode,
|
|
333
|
+
value: cloneTreeNode(sourceNode.value),
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
if (sourceNode.tombstone) {
|
|
337
|
+
targetNode.tombstone = true;
|
|
338
|
+
}
|
|
339
|
+
if (isRGAParent(targetNode.value) && isRGAParent(sourceNode.value)) {
|
|
340
|
+
mergeChildrenRGA(targetNode.value.children, sourceNode.value.children);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Update the compareEvents function on an RGA and all nested child RGAs.
|
|
348
|
+
*/
|
|
349
|
+
function updateComparators<E extends RGAEvent>(
|
|
350
|
+
rga: RGA<RGATreeNode<E>, E>,
|
|
351
|
+
compareEvents: EventComparator<E>,
|
|
352
|
+
): void {
|
|
353
|
+
rga.compareEvents = compareEvents;
|
|
354
|
+
for (const node of rga.nodes.values()) {
|
|
355
|
+
if (isRGAParent(node.value)) {
|
|
356
|
+
updateComparators(node.value.children, compareEvents);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Merge two RGA trees, returning a new tree with nodes from both.
|
|
363
|
+
*
|
|
364
|
+
* Clones the target first (non-destructive), merges all source nodes in,
|
|
365
|
+
* and sets the comparator throughout so linearization uses the correct
|
|
366
|
+
* (merged) event ordering.
|
|
367
|
+
*
|
|
368
|
+
* At each level: union of RGA nodes, tombstones win. For nodes present in
|
|
369
|
+
* both trees with children, recursively merges children RGAs.
|
|
370
|
+
*/
|
|
371
|
+
export function mergeRGATrees<E extends RGAEvent>(
|
|
372
|
+
target: RGATreeRoot<E>,
|
|
373
|
+
source: RGATreeRoot<E>,
|
|
374
|
+
compareEvents: EventComparator<E>,
|
|
375
|
+
): RGATreeRoot<E> {
|
|
376
|
+
const result: RGATreeRoot<E> = {
|
|
377
|
+
type: "root",
|
|
378
|
+
children: cloneRGA(target.children),
|
|
379
|
+
};
|
|
380
|
+
mergeChildrenRGA(result.children, source.children);
|
|
381
|
+
updateComparators(result.children, compareEvents);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
package/test/rga-tree.test.ts
CHANGED
|
@@ -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, applyRGAChangeSet } from "../src/rga-tree.js";
|
|
4
4
|
import {
|
|
5
5
|
RGA,
|
|
6
6
|
type RGAEvent,
|
|
@@ -208,3 +208,242 @@ 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
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("multi-append", () => {
|
|
326
|
+
it("handles appending multiple nodes at once", () => {
|
|
327
|
+
const oldMd = "# Hello\n\nOriginal.\n";
|
|
328
|
+
const newMd = "# Hello\n\nOriginal.\n\nSecond.\n\nThird.\n";
|
|
329
|
+
const oldRoot = parse(oldMd);
|
|
330
|
+
const newRoot = parse(newMd);
|
|
331
|
+
const tree = toRGATree(oldRoot, r1, cmp);
|
|
332
|
+
|
|
333
|
+
const r2 = new TestEvent("r2");
|
|
334
|
+
const updated = applyMdastToRGATree(tree, newRoot, r2, cmp);
|
|
335
|
+
const result = stringify(toMdast(updated));
|
|
336
|
+
|
|
337
|
+
expect(result).toContain("Original.");
|
|
338
|
+
expect(result).toContain("Second.");
|
|
339
|
+
expect(result).toContain("Third.");
|
|
340
|
+
// Verify order is preserved
|
|
341
|
+
const origIdx = result.indexOf("Original.");
|
|
342
|
+
const secIdx = result.indexOf("Second.");
|
|
343
|
+
const thirdIdx = result.indexOf("Third.");
|
|
344
|
+
expect(origIdx).toBeLessThan(secIdx);
|
|
345
|
+
expect(secIdx).toBeLessThan(thirdIdx);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("handles appending to an empty children array", () => {
|
|
349
|
+
const oldMd = "";
|
|
350
|
+
const newMd = "# Hello\n\nWorld.\n";
|
|
351
|
+
const oldRoot = parse(oldMd);
|
|
352
|
+
const newRoot = parse(newMd);
|
|
353
|
+
const tree = toRGATree(oldRoot, r1, cmp);
|
|
354
|
+
|
|
355
|
+
const r2 = new TestEvent("r2");
|
|
356
|
+
const updated = applyMdastToRGATree(tree, newRoot, r2, cmp);
|
|
357
|
+
const result = stringify(toMdast(updated));
|
|
358
|
+
|
|
359
|
+
expect(result).toContain("Hello");
|
|
360
|
+
expect(result).toContain("World.");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("applyRGAChangeSet multi-node", () => {
|
|
365
|
+
const r2 = new TestEvent("r2");
|
|
366
|
+
|
|
367
|
+
it("insert with multiple nodes preserves order", () => {
|
|
368
|
+
const tree = toRGATree(parse("# Hello\n"), r1, cmp);
|
|
369
|
+
const afterId = tree.children.toNodes()[0].id; // after the heading
|
|
370
|
+
|
|
371
|
+
const changeset = {
|
|
372
|
+
event: r2,
|
|
373
|
+
changes: [{
|
|
374
|
+
type: "insert" as const,
|
|
375
|
+
parentPath: [],
|
|
376
|
+
afterId,
|
|
377
|
+
nodes: [
|
|
378
|
+
{ type: "paragraph", children: [{ type: "text", value: "First" }] },
|
|
379
|
+
{ type: "paragraph", children: [{ type: "text", value: "Second" }] },
|
|
380
|
+
{ type: "paragraph", children: [{ type: "text", value: "Third" }] },
|
|
381
|
+
] as any,
|
|
382
|
+
}],
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const result = applyRGAChangeSet(tree, changeset, cmp);
|
|
386
|
+
const md = stringify(toMdast(result));
|
|
387
|
+
|
|
388
|
+
expect(md).toContain("First");
|
|
389
|
+
expect(md).toContain("Second");
|
|
390
|
+
expect(md).toContain("Third");
|
|
391
|
+
expect(md.indexOf("First")).toBeLessThan(md.indexOf("Second"));
|
|
392
|
+
expect(md.indexOf("Second")).toBeLessThan(md.indexOf("Third"));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("modify with multiple replacement nodes preserves order", () => {
|
|
396
|
+
const tree = toRGATree(parse("# Hello\n\nOriginal.\n"), r1, cmp);
|
|
397
|
+
const nodes = tree.children.toNodes();
|
|
398
|
+
const targetId = nodes[1].id; // the paragraph
|
|
399
|
+
const afterId = nodes[0].id; // after the heading
|
|
400
|
+
|
|
401
|
+
const changeset = {
|
|
402
|
+
event: r2,
|
|
403
|
+
changes: [{
|
|
404
|
+
type: "modify" as const,
|
|
405
|
+
parentPath: [],
|
|
406
|
+
targetId,
|
|
407
|
+
afterId,
|
|
408
|
+
nodes: [
|
|
409
|
+
{ type: "paragraph", children: [{ type: "text", value: "Replacement A" }] },
|
|
410
|
+
{ type: "paragraph", children: [{ type: "text", value: "Replacement B" }] },
|
|
411
|
+
] as any,
|
|
412
|
+
}],
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const result = applyRGAChangeSet(tree, changeset, cmp);
|
|
416
|
+
const md = stringify(toMdast(result));
|
|
417
|
+
|
|
418
|
+
expect(md).not.toContain("Original.");
|
|
419
|
+
expect(md).toContain("Replacement A");
|
|
420
|
+
expect(md).toContain("Replacement B");
|
|
421
|
+
expect(md.indexOf("Replacement A")).toBeLessThan(md.indexOf("Replacement B"));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("insert at start (no afterId) with multiple nodes preserves relative order", () => {
|
|
425
|
+
// Insert two nodes at root with no afterId. RGA ordering means they
|
|
426
|
+
// appear after existing r1 nodes (r2 > r1), but their relative order
|
|
427
|
+
// (A before B) is preserved via chained afterIds.
|
|
428
|
+
const tree = toRGATree(parse("# Existing\n"), r1, cmp);
|
|
429
|
+
|
|
430
|
+
const changeset = {
|
|
431
|
+
event: r2,
|
|
432
|
+
changes: [{
|
|
433
|
+
type: "insert" as const,
|
|
434
|
+
parentPath: [],
|
|
435
|
+
afterId: undefined,
|
|
436
|
+
nodes: [
|
|
437
|
+
{ type: "paragraph", children: [{ type: "text", value: "Added A" }] },
|
|
438
|
+
{ type: "paragraph", children: [{ type: "text", value: "Added B" }] },
|
|
439
|
+
] as any,
|
|
440
|
+
}],
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const result = applyRGAChangeSet(tree, changeset, cmp);
|
|
444
|
+
const md = stringify(toMdast(result));
|
|
445
|
+
|
|
446
|
+
// Relative order of the inserted nodes is preserved
|
|
447
|
+
expect(md.indexOf("Added A")).toBeLessThan(md.indexOf("Added B"));
|
|
448
|
+
});
|
|
449
|
+
});
|