@storacha/md-merge 0.2.0 → 0.3.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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * DAG-CBOR serialization for RGATree and RGAChangeSet.
3
+ *
4
+ * Tree-specific serialization wraps the generic RGA codec from crdt/codec.ts,
5
+ * adding recursive handling of nested RGA children in tree nodes.
6
+ */
7
+ import { type RGAEvent, type EventComparator } from './crdt/rga.js';
8
+ import type { RGATreeRoot, RGATreeNode } from './types.js';
9
+ import type { RGAChangeSet } from './types.js';
10
+ export { encodeRGA, decodeRGA } from './crdt/codec.js';
11
+ export declare function encodeTree<E extends RGAEvent>(root: RGATreeRoot<E>): Promise<import("multiformats").BlockView<unknown, 113, 18, 1>>;
12
+ export declare function decodeTree<E extends RGAEvent>(block: {
13
+ bytes: Uint8Array;
14
+ }, parseEvent: (s: string) => E, fingerprintFn: (value: RGATreeNode<E>) => string, compareEvents: EventComparator<E>): Promise<RGATreeRoot<E>>;
15
+ export declare function encodeChangeSet<E extends RGAEvent>(cs: RGAChangeSet<E>): Promise<import("multiformats").BlockView<unknown, 113, 18, 1>>;
16
+ export declare function decodeChangeSet<E extends RGAEvent>(block: {
17
+ bytes: Uint8Array;
18
+ }, parseEvent: (s: string) => E): Promise<RGAChangeSet<E>>;
package/dist/codec.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DAG-CBOR serialization for RGATree and RGAChangeSet.
3
+ *
4
+ * Tree-specific serialization wraps the generic RGA codec from crdt/codec.ts,
5
+ * adding recursive handling of nested RGA children in tree nodes.
6
+ */
7
+ import { encode, decode } from 'multiformats/block';
8
+ import { sha256 } from 'multiformats/hashes/sha2';
9
+ import * as cbor from '@ipld/dag-cbor';
10
+ import { RGA } from './crdt/rga.js';
11
+ import { stripUndefined, serializeNodeId, deserializeNodeId, serializeRGA, deserializeRGA, } from './crdt/codec.js';
12
+ // Re-export plain RGA codec
13
+ export { encodeRGA, decodeRGA } from './crdt/codec.js';
14
+ // ---- Tree-specific helpers ----
15
+ function isRGAParentSerialized(node) {
16
+ return (node != null &&
17
+ typeof node === 'object' &&
18
+ 'children' in node &&
19
+ typeof node.children === 'object' &&
20
+ node.children !== null &&
21
+ 'nodes' in node.children);
22
+ }
23
+ function isRGAParentNode(node) {
24
+ return 'children' in node && node.children instanceof RGA;
25
+ }
26
+ // ---- RGATree serialization ----
27
+ function serializeTreeNodeValue(node) {
28
+ if (isRGAParentNode(node)) {
29
+ const { children, ...rest } = node;
30
+ return { ...rest, children: serializeRGA(children, (n) => serializeTreeNodeValue(n)) };
31
+ }
32
+ return node;
33
+ }
34
+ function serializeTree(root) {
35
+ return {
36
+ type: 'root',
37
+ children: serializeRGA(root.children, (n) => serializeTreeNodeValue(n)),
38
+ };
39
+ }
40
+ function deserializeTreeNodeValue(raw, parseEvent, fingerprintFn, compareEvents) {
41
+ if (isRGAParentSerialized(raw)) {
42
+ const { children, ...rest } = raw;
43
+ return {
44
+ ...rest,
45
+ children: deserializeTreeRGA(children, parseEvent, fingerprintFn, compareEvents),
46
+ };
47
+ }
48
+ return raw;
49
+ }
50
+ function deserializeTreeRGA(raw, parseEvent, fingerprintFn, compareEvents) {
51
+ return deserializeRGA(raw, parseEvent, (v) => deserializeTreeNodeValue(v, parseEvent, fingerprintFn, compareEvents), fingerprintFn, compareEvents);
52
+ }
53
+ function deserializeTree(raw, parseEvent, fingerprintFn, compareEvents) {
54
+ return {
55
+ type: 'root',
56
+ children: deserializeTreeRGA(raw.children, parseEvent, fingerprintFn, compareEvents),
57
+ };
58
+ }
59
+ function serializeChangeSet(cs) {
60
+ return {
61
+ event: cs.event.toString(),
62
+ changes: cs.changes.map(c => ({
63
+ type: c.type,
64
+ parentPath: c.parentPath.map(serializeNodeId),
65
+ targetId: c.targetId ? serializeNodeId(c.targetId) : null,
66
+ afterId: c.afterId ? serializeNodeId(c.afterId) : null,
67
+ nodes: c.nodes,
68
+ before: c.before,
69
+ })),
70
+ };
71
+ }
72
+ function deserializeChangeSet(raw, parseEvent) {
73
+ return {
74
+ event: parseEvent(raw.event),
75
+ changes: raw.changes.map(c => {
76
+ const change = {
77
+ type: c.type,
78
+ parentPath: c.parentPath.map(id => deserializeNodeId(id, parseEvent)),
79
+ };
80
+ if (c.targetId)
81
+ change.targetId = deserializeNodeId(c.targetId, parseEvent);
82
+ if (c.afterId)
83
+ change.afterId = deserializeNodeId(c.afterId, parseEvent);
84
+ if (c.nodes)
85
+ change.nodes = c.nodes;
86
+ if (c.before)
87
+ change.before = c.before;
88
+ return change;
89
+ }),
90
+ };
91
+ }
92
+ // ---- Public API: encode/decode to DAG-CBOR blocks ----
93
+ export async function encodeTree(root) {
94
+ const value = stripUndefined(serializeTree(root));
95
+ return encode({ value, codec: cbor, hasher: sha256 });
96
+ }
97
+ export async function decodeTree(block, parseEvent, fingerprintFn, compareEvents) {
98
+ const decoded = await decode({ bytes: block.bytes, codec: cbor, hasher: sha256 });
99
+ return deserializeTree(decoded.value, parseEvent, fingerprintFn, compareEvents);
100
+ }
101
+ export async function encodeChangeSet(cs) {
102
+ const value = stripUndefined(serializeChangeSet(cs));
103
+ return encode({ value, codec: cbor, hasher: sha256 });
104
+ }
105
+ export async function decodeChangeSet(block, parseEvent) {
106
+ const decoded = await decode({ bytes: block.bytes, codec: cbor, hasher: sha256 });
107
+ return deserializeChangeSet(decoded.value, parseEvent);
108
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * DAG-CBOR serialization for plain RGA instances.
3
+ */
4
+ import { RGA, type RGANodeId, type RGAEvent, type EventComparator } from './rga.js';
5
+ /** Recursively strip `undefined` values (not IPLD-compatible). */
6
+ export declare function stripUndefined(obj: unknown): unknown;
7
+ export interface SerializedNodeId {
8
+ uuid: string;
9
+ event: string;
10
+ }
11
+ export interface SerializedRGANode<V = unknown> {
12
+ id: SerializedNodeId;
13
+ value: V;
14
+ afterId: SerializedNodeId | null;
15
+ tombstone: boolean;
16
+ }
17
+ export interface SerializedRGA<V = unknown> {
18
+ nodes: SerializedRGANode<V>[];
19
+ }
20
+ export declare function serializeNodeId<E extends RGAEvent>(id: RGANodeId<E>): SerializedNodeId;
21
+ export declare function deserializeNodeId<E extends RGAEvent>(raw: SerializedNodeId, parseEvent: (s: string) => E): RGANodeId<E>;
22
+ export declare function serializeRGA<T, E extends RGAEvent>(rga: RGA<T, E>, serializeValue?: (v: T) => unknown): SerializedRGA;
23
+ export declare function deserializeRGA<T, E extends RGAEvent>(raw: SerializedRGA, parseEvent: (s: string) => E, deserializeValue: (v: unknown) => T, fingerprintFn: (value: T) => string, compareEvents: EventComparator<E>): RGA<T, E>;
24
+ /**
25
+ * Encode a plain RGA as a DAG-CBOR block.
26
+ */
27
+ export declare function encodeRGA<T, E extends RGAEvent>(rga: RGA<T, E>, serializeValue?: (v: T) => unknown): Promise<import("multiformats").BlockView<unknown, 113, 18, 1>>;
28
+ /**
29
+ * Decode a plain RGA from a DAG-CBOR block.
30
+ */
31
+ export declare function decodeRGA<T, E extends RGAEvent>(block: {
32
+ bytes: Uint8Array;
33
+ }, parseEvent: (s: string) => E, deserializeValue: (v: unknown) => T, fingerprintFn: (value: T) => string, compareEvents: EventComparator<E>): Promise<RGA<T, E>>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DAG-CBOR serialization for plain RGA instances.
3
+ */
4
+ import { encode, decode } from 'multiformats/block';
5
+ import { sha256 } from 'multiformats/hashes/sha2';
6
+ import * as cbor from '@ipld/dag-cbor';
7
+ import { RGA } from './rga.js';
8
+ /** Recursively strip `undefined` values (not IPLD-compatible). */
9
+ export function stripUndefined(obj) {
10
+ if (obj === null || obj === undefined)
11
+ return null;
12
+ if (Array.isArray(obj))
13
+ return obj.map(stripUndefined);
14
+ if (typeof obj === 'object') {
15
+ const clean = {};
16
+ for (const [k, v] of Object.entries(obj)) {
17
+ if (v !== undefined)
18
+ clean[k] = stripUndefined(v);
19
+ }
20
+ return clean;
21
+ }
22
+ return obj;
23
+ }
24
+ // ---- Helpers ----
25
+ export function serializeNodeId(id) {
26
+ return { uuid: id.uuid, event: id.event.toString() };
27
+ }
28
+ export function deserializeNodeId(raw, parseEvent) {
29
+ return { uuid: raw.uuid, event: parseEvent(raw.event) };
30
+ }
31
+ // ---- Serialize / Deserialize ----
32
+ export function serializeRGA(rga, serializeValue = (v) => v) {
33
+ const nodes = [];
34
+ for (const node of rga.nodes.values()) {
35
+ nodes.push({
36
+ id: serializeNodeId(node.id),
37
+ value: serializeValue(node.value),
38
+ afterId: node.afterId ? serializeNodeId(node.afterId) : null,
39
+ tombstone: node.tombstone,
40
+ });
41
+ }
42
+ return { nodes };
43
+ }
44
+ export function deserializeRGA(raw, parseEvent, deserializeValue, fingerprintFn, compareEvents) {
45
+ const rga = new RGA(fingerprintFn, compareEvents);
46
+ for (const rawNode of raw.nodes) {
47
+ const id = deserializeNodeId(rawNode.id, parseEvent);
48
+ const afterId = rawNode.afterId ? deserializeNodeId(rawNode.afterId, parseEvent) : undefined;
49
+ const value = deserializeValue(rawNode.value);
50
+ const key = `${id.uuid}:${id.event.toString()}`;
51
+ rga.nodes.set(key, { id, value, afterId, tombstone: rawNode.tombstone });
52
+ }
53
+ return rga;
54
+ }
55
+ // ---- Public API: encode/decode to DAG-CBOR blocks ----
56
+ /**
57
+ * Encode a plain RGA as a DAG-CBOR block.
58
+ */
59
+ export async function encodeRGA(rga, serializeValue = (v) => v) {
60
+ const value = stripUndefined(serializeRGA(rga, serializeValue));
61
+ return encode({ value, codec: cbor, hasher: sha256 });
62
+ }
63
+ /**
64
+ * Decode a plain RGA from a DAG-CBOR block.
65
+ */
66
+ export async function decodeRGA(block, parseEvent, deserializeValue, fingerprintFn, compareEvents) {
67
+ const decoded = await decode({ bytes: block.bytes, codec: cbor, hasher: sha256 });
68
+ return deserializeRGA(decoded.value, parseEvent, deserializeValue, fingerprintFn, compareEvents);
69
+ }
@@ -0,0 +1,53 @@
1
+ /** Interface for event types used in RGA node IDs */
2
+ export interface RGAEvent {
3
+ toString(): string;
4
+ }
5
+ /** Structured identifier for RGA nodes */
6
+ export interface RGANodeId<E extends RGAEvent> {
7
+ uuid: string;
8
+ event: E;
9
+ }
10
+ /** An element in the RGA */
11
+ export interface RGANode<T, E extends RGAEvent> {
12
+ id: RGANodeId<E>;
13
+ value: T;
14
+ afterId: RGANodeId<E> | undefined;
15
+ tombstone: boolean;
16
+ }
17
+ /** Comparator for events — determines precedence for concurrent inserts */
18
+ export type EventComparator<E extends RGAEvent> = (a: E, b: E) => number;
19
+ /**
20
+ * RGA (Replicated Growable Array) — a CRDT for ordered sequences.
21
+ *
22
+ * Each element has a unique ID (UUID + Event) and a pointer to its predecessor.
23
+ * Concurrent inserts after the same predecessor are ordered by event comparator (then uuid).
24
+ * Deletes are tombstoned.
25
+ */
26
+ export declare class RGA<T, E extends RGAEvent = RGAEvent> {
27
+ nodes: Map<string, RGANode<T, E>>;
28
+ fingerprintFn: (value: T) => string;
29
+ compareEvents: EventComparator<E>;
30
+ constructor(fingerprintFn: (value: T) => string, compareEvents: EventComparator<E>);
31
+ /** Compare two node IDs: primary by event, secondary by uuid */
32
+ private compareIds;
33
+ /** Insert a new element after the given predecessor. Returns the new node's ID. */
34
+ insert(afterId: RGANodeId<E> | undefined, value: T, event: E): RGANodeId<E>;
35
+ /** Mark an element as deleted (tombstone). */
36
+ delete(id: RGANodeId<E>): void;
37
+ /** Rebuild the ordered array from the graph, excluding tombstones. */
38
+ toArray(): T[];
39
+ /** Rebuild ordered nodes (including tombstones for internal use). */
40
+ private allOrdered;
41
+ /** Get ordered non-tombstoned nodes. */
42
+ toNodes(): RGANode<T, E>[];
43
+ /** Get all ordered nodes including tombstones. */
44
+ toAllNodes(): RGANode<T, E>[];
45
+ /** Merge another RGA into this one. Union of all nodes; tombstones win. */
46
+ merge(other: RGA<T, E>): void;
47
+ /** Create an RGA from an array of items. Each item is inserted sequentially. */
48
+ static fromArray<T, E extends RGAEvent>(items: T[], event: E, fingerprintFn: (value: T) => string, compareEvents: EventComparator<E>): RGA<T, E>;
49
+ /** Get the node ID at a given index (among non-tombstoned nodes). */
50
+ idAtIndex(index: number): RGANodeId<E> | undefined;
51
+ /** Get the afterId for inserting at a given index. */
52
+ predecessorForIndex(index: number): RGANodeId<E> | undefined;
53
+ }
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Serialize an RGANodeId for use as a Map key */
3
+ function serializeId(id) {
4
+ return `${id.uuid}:${id.event.toString()}`;
5
+ }
6
+ /**
7
+ * RGA (Replicated Growable Array) — a CRDT for ordered sequences.
8
+ *
9
+ * Each element has a unique ID (UUID + Event) and a pointer to its predecessor.
10
+ * Concurrent inserts after the same predecessor are ordered by event comparator (then uuid).
11
+ * Deletes are tombstoned.
12
+ */
13
+ export class RGA {
14
+ nodes = new Map();
15
+ fingerprintFn;
16
+ compareEvents;
17
+ constructor(fingerprintFn, compareEvents) {
18
+ this.fingerprintFn = fingerprintFn;
19
+ this.compareEvents = compareEvents;
20
+ }
21
+ /** Compare two node IDs: primary by event, secondary by uuid */
22
+ compareIds(a, b) {
23
+ const cmp = this.compareEvents(a.event, b.event);
24
+ if (cmp !== 0)
25
+ return cmp;
26
+ if (a.uuid < b.uuid)
27
+ return -1;
28
+ if (a.uuid > b.uuid)
29
+ return 1;
30
+ return 0;
31
+ }
32
+ /** Insert a new element after the given predecessor. Returns the new node's ID. */
33
+ insert(afterId, value, event) {
34
+ const id = { uuid: randomUUID(), event };
35
+ this.nodes.set(serializeId(id), { id, value, afterId, tombstone: false });
36
+ return id;
37
+ }
38
+ /** Mark an element as deleted (tombstone). */
39
+ delete(id) {
40
+ const node = this.nodes.get(serializeId(id));
41
+ if (node)
42
+ node.tombstone = true;
43
+ }
44
+ /** Rebuild the ordered array from the graph, excluding tombstones. */
45
+ toArray() {
46
+ return this.toNodes().map(n => n.value);
47
+ }
48
+ /** Rebuild ordered nodes (including tombstones for internal use). */
49
+ allOrdered() {
50
+ const ROOT_KEY = '__ROOT__';
51
+ const children = new Map();
52
+ for (const node of this.nodes.values()) {
53
+ const key = node.afterId ? serializeId(node.afterId) : ROOT_KEY;
54
+ let list = children.get(key);
55
+ if (!list) {
56
+ list = [];
57
+ children.set(key, list);
58
+ }
59
+ list.push(node);
60
+ }
61
+ for (const list of children.values()) {
62
+ list.sort((a, b) => this.compareIds(a.id, b.id));
63
+ }
64
+ const result = [];
65
+ const visit = (parentKey) => {
66
+ const kids = children.get(parentKey);
67
+ if (!kids)
68
+ return;
69
+ for (const kid of kids) {
70
+ result.push(kid);
71
+ visit(serializeId(kid.id));
72
+ }
73
+ };
74
+ visit(ROOT_KEY);
75
+ return result;
76
+ }
77
+ /** Get ordered non-tombstoned nodes. */
78
+ toNodes() {
79
+ return this.allOrdered().filter(n => !n.tombstone);
80
+ }
81
+ /** Get all ordered nodes including tombstones. */
82
+ toAllNodes() {
83
+ return this.allOrdered();
84
+ }
85
+ /** Merge another RGA into this one. Union of all nodes; tombstones win. */
86
+ merge(other) {
87
+ for (const [key, node] of other.nodes) {
88
+ const existing = this.nodes.get(key);
89
+ if (!existing) {
90
+ this.nodes.set(key, { ...node });
91
+ }
92
+ else if (node.tombstone) {
93
+ existing.tombstone = true;
94
+ }
95
+ }
96
+ }
97
+ /** Create an RGA from an array of items. Each item is inserted sequentially. */
98
+ static fromArray(items, event, fingerprintFn, compareEvents) {
99
+ const rga = new RGA(fingerprintFn, compareEvents);
100
+ let afterId = undefined;
101
+ for (const item of items) {
102
+ afterId = rga.insert(afterId, item, event);
103
+ }
104
+ return rga;
105
+ }
106
+ /** Get the node ID at a given index (among non-tombstoned nodes). */
107
+ idAtIndex(index) {
108
+ const nodes = this.toNodes();
109
+ return index < nodes.length ? nodes[index].id : undefined;
110
+ }
111
+ /** Get the afterId for inserting at a given index. */
112
+ predecessorForIndex(index) {
113
+ const nodes = this.toNodes();
114
+ if (index <= 0)
115
+ return undefined;
116
+ return nodes[index - 1].id;
117
+ }
118
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Root } from "mdast";
2
+ import type { ChangeSet } from "./types.js";
3
+ /**
4
+ * Diff two mdast Root nodes recursively.
5
+ */
6
+ export declare function diff(oldRoot: Root, newRoot: Root): ChangeSet;
7
+ /**
8
+ * Apply a changeset to a root, producing a new root.
9
+ * Deep-clones the tree, then applies changes deepest-first to avoid index shifting.
10
+ */
11
+ export declare function applyChangeSet(root: Root, changeset: ChangeSet): Root;
package/dist/diff.js ADDED
@@ -0,0 +1,214 @@
1
+ import { fingerprint } from "./parse.js";
2
+ /** Check if a node has children */
3
+ function hasChildren(node) {
4
+ return (node != null &&
5
+ typeof node === "object" &&
6
+ "children" in node &&
7
+ Array.isArray(node.children));
8
+ }
9
+ function nodeType(node) {
10
+ return node.type;
11
+ }
12
+ /**
13
+ * Compute LCS table for two string arrays.
14
+ */
15
+ function lcsTable(a, b) {
16
+ const m = a.length;
17
+ const n = b.length;
18
+ const table = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
19
+ for (let i = 1; i <= m; i++) {
20
+ for (let j = 1; j <= n; j++) {
21
+ if (a[i - 1] === b[j - 1]) {
22
+ table[i][j] = table[i - 1][j - 1] + 1;
23
+ }
24
+ else {
25
+ table[i][j] = Math.max(table[i - 1][j], table[i][j - 1]);
26
+ }
27
+ }
28
+ }
29
+ return table;
30
+ }
31
+ /**
32
+ * Extract LCS matches from the table.
33
+ * Returns array of [oldIndex, newIndex] pairs.
34
+ */
35
+ function lcsMatches(aFp, bFp, table) {
36
+ const matches = [];
37
+ let i = aFp.length;
38
+ let j = bFp.length;
39
+ while (i > 0 && j > 0) {
40
+ if (aFp[i - 1] === bFp[j - 1]) {
41
+ matches.push([i - 1, j - 1]);
42
+ i--;
43
+ j--;
44
+ }
45
+ else if (table[i - 1][j] >= table[i][j - 1]) {
46
+ i--;
47
+ }
48
+ else {
49
+ j--;
50
+ }
51
+ }
52
+ matches.reverse();
53
+ return matches;
54
+ }
55
+ function fpChild(node) {
56
+ return fingerprint(node);
57
+ }
58
+ /**
59
+ * Diff the gap between LCS matches: unmatched old/new nodes.
60
+ * Pairs same-type nodes for recursive descent; remainder are insert/delete.
61
+ */
62
+ function diffGap(oldNodes, newNodes, oldStartIdx, newStartIdx, pathPrefix) {
63
+ const changes = [];
64
+ // Try to pair same-type nodes sequentially for recursive diffing
65
+ let oi = 0;
66
+ let ni = 0;
67
+ while (oi < oldNodes.length && ni < newNodes.length) {
68
+ const oldNode = oldNodes[oi];
69
+ const newNode = newNodes[ni];
70
+ if (nodeType(oldNode) === nodeType(newNode) &&
71
+ hasChildren(oldNode) &&
72
+ hasChildren(newNode)) {
73
+ // Same type with children — recurse
74
+ const childChanges = diffChildren(oldNode.children, newNode.children, [
75
+ ...pathPrefix,
76
+ newStartIdx + ni,
77
+ ]);
78
+ changes.push(...childChanges);
79
+ oi++;
80
+ ni++;
81
+ }
82
+ else if (nodeType(oldNode) === nodeType(newNode)) {
83
+ // Same type leaf but different content — modify
84
+ changes.push({
85
+ type: "modify",
86
+ path: [...pathPrefix, newStartIdx + ni],
87
+ nodes: [newNode],
88
+ before: [oldNode],
89
+ });
90
+ oi++;
91
+ ni++;
92
+ }
93
+ else {
94
+ // Different types — delete old, insert new
95
+ changes.push({
96
+ type: "delete",
97
+ path: [...pathPrefix, oldStartIdx + oi],
98
+ nodes: [oldNode],
99
+ });
100
+ oi++;
101
+ }
102
+ }
103
+ // Remaining old = deletes
104
+ while (oi < oldNodes.length) {
105
+ changes.push({
106
+ type: "delete",
107
+ path: [...pathPrefix, oldStartIdx + oi],
108
+ nodes: [oldNodes[oi]],
109
+ });
110
+ oi++;
111
+ }
112
+ // Remaining new = inserts
113
+ while (ni < newNodes.length) {
114
+ changes.push({
115
+ type: "insert",
116
+ path: [...pathPrefix, newStartIdx + ni],
117
+ nodes: [newNodes[ni]],
118
+ });
119
+ ni++;
120
+ }
121
+ return changes;
122
+ }
123
+ /**
124
+ * Recursively diff two arrays of child nodes at a given path prefix.
125
+ */
126
+ function diffChildren(oldChildren, newChildren, pathPrefix) {
127
+ const oldFp = oldChildren.map(fpChild);
128
+ const newFp = newChildren.map(fpChild);
129
+ const table = lcsTable(oldFp, newFp);
130
+ const matches = lcsMatches(oldFp, newFp, table);
131
+ const changes = [];
132
+ // Process gaps between matches
133
+ let prevOld = 0;
134
+ let prevNew = 0;
135
+ for (const [oi, ni] of matches) {
136
+ // Gap before this match
137
+ if (oi > prevOld || ni > prevNew) {
138
+ const oldGap = oldChildren.slice(prevOld, oi);
139
+ const newGap = newChildren.slice(prevNew, ni);
140
+ changes.push(...diffGap(oldGap, newGap, prevOld, prevNew, pathPrefix));
141
+ }
142
+ // Matched node — identical fingerprints, no changes needed
143
+ prevOld = oi + 1;
144
+ prevNew = ni + 1;
145
+ }
146
+ // Trailing gap after last match
147
+ if (prevOld < oldChildren.length || prevNew < newChildren.length) {
148
+ const oldGap = oldChildren.slice(prevOld);
149
+ const newGap = newChildren.slice(prevNew);
150
+ changes.push(...diffGap(oldGap, newGap, prevOld, prevNew, pathPrefix));
151
+ }
152
+ return changes;
153
+ }
154
+ /**
155
+ * Diff two mdast Root nodes recursively.
156
+ */
157
+ export function diff(oldRoot, newRoot) {
158
+ const changes = diffChildren(oldRoot.children, newRoot.children, []);
159
+ return { changes };
160
+ }
161
+ function comparePaths(a, b) {
162
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
163
+ if (a[i] !== b[i])
164
+ return a[i] - b[i];
165
+ }
166
+ return a.length - b.length;
167
+ }
168
+ function navigateToParent(root, path) {
169
+ if (path.length === 0)
170
+ return null;
171
+ let current = root;
172
+ for (let i = 0; i < path.length - 1; i++) {
173
+ if (!hasChildren(current))
174
+ return null;
175
+ current = current.children[path[i]];
176
+ }
177
+ if (!hasChildren(current))
178
+ return null;
179
+ return {
180
+ siblings: current.children,
181
+ index: path[path.length - 1],
182
+ };
183
+ }
184
+ /**
185
+ * Apply a changeset to a root, producing a new root.
186
+ * Deep-clones the tree, then applies changes deepest-first to avoid index shifting.
187
+ */
188
+ export function applyChangeSet(root, changeset) {
189
+ const newRoot = JSON.parse(JSON.stringify(root));
190
+ // Sort: deepest first, then higher indices first to avoid shifting
191
+ const sorted = [...changeset.changes].sort((a, b) => {
192
+ if (a.path.length !== b.path.length)
193
+ return b.path.length - a.path.length;
194
+ return -comparePaths(a.path, b.path);
195
+ });
196
+ for (const change of sorted) {
197
+ const nav = navigateToParent(newRoot, change.path);
198
+ if (!nav)
199
+ continue;
200
+ const { siblings, index } = nav;
201
+ switch (change.type) {
202
+ case "insert":
203
+ siblings.splice(index, 0, ...(change.nodes ?? []));
204
+ break;
205
+ case "delete":
206
+ siblings.splice(index, 1);
207
+ break;
208
+ case "modify":
209
+ siblings.splice(index, 1, ...(change.nodes ?? []));
210
+ break;
211
+ }
212
+ }
213
+ return newRoot;
214
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * UCN integration — types and helpers for using md-merge with @storacha/ucn.
3
+ *
4
+ * Operations are stored as RGAChangeSets (RGA-addressed, index-free) so they
5
+ * compose via CRDT merge rather than requiring three-way diff.
6
+ */
7
+ import type { RGAChangeSet, RGATreeRoot } from "./types.js";
8
+ import type { RGAEvent, EventComparator } from "./crdt/rga.js";
9
+ export { parse, stringify, stringifyNode, fingerprint } from "./parse.js";
10
+ export { diff, applyChangeSet } from "./diff.js";
11
+ export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, } from "./rga-tree.js";
12
+ export { encodeTree, decodeTree, encodeChangeSet, decodeChangeSet } from "./codec.js";
13
+ export type { ChangeSet, Change, RGAChangeSet, RGAChange, RGATreeRoot, RGATreeNode, RGAParentNode, RGALeafNode, } from "./types.js";
14
+ export { RGA, type RGAEvent, type RGANodeId, type EventComparator } from "./types.js";
15
+ /**
16
+ * Compute an RGA-addressed changeset between an existing RGA tree and new markdown.
17
+ */
18
+ export declare function computeChangeSet<E extends RGAEvent>(existing: RGATreeRoot<E>, newMarkdown: string, event: E): RGAChangeSet<E>;
19
+ /**
20
+ * Apply an RGA changeset to an RGA tree, returning updated tree + markdown.
21
+ */
22
+ export declare function applyToTree<E extends RGAEvent>(tree: RGATreeRoot<E>, changeset: RGAChangeSet<E>, compareEvents: EventComparator<E>): {
23
+ tree: RGATreeRoot<E>;
24
+ markdown: string;
25
+ };
26
+ /**
27
+ * Apply an RGA changeset and return just the markdown string.
28
+ */
29
+ export declare function applyToMarkdown<E extends RGAEvent>(tree: RGATreeRoot<E>, changeset: RGAChangeSet<E>, compareEvents: EventComparator<E>): string;
30
+ /**
31
+ * Bootstrap: create an RGA tree from a markdown string.
32
+ */
33
+ export declare function fromMarkdown<E extends RGAEvent>(markdown: string, event: E, compareEvents: EventComparator<E>): RGATreeRoot<E>;