@storacha/md-merge 0.2.0 → 0.2.1
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/codec.d.ts +29 -0
- package/dist/codec.js +160 -0
- package/dist/crdt/rga.d.ts +53 -0
- package/dist/crdt/rga.js +118 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +214 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +41 -0
- package/dist/parse.d.ts +9 -0
- package/dist/parse.js +22 -0
- package/dist/rga-tree.d.ts +32 -0
- package/dist/rga-tree.js +218 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +2 -0
- package/package.json +1 -1
package/dist/codec.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG-CBOR serialization for RGATree and RGAChangeSet.
|
|
3
|
+
*
|
|
4
|
+
* Converts RGA class instances (with Maps, nested RGAs) to plain
|
|
5
|
+
* CBOR-friendly objects and back.
|
|
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
|
+
/**
|
|
11
|
+
* Encode an RGATree as a DAG-CBOR block.
|
|
12
|
+
*/
|
|
13
|
+
export declare function encodeTree<E extends RGAEvent>(root: RGATreeRoot<E>): Promise<import("multiformats").BlockView<unknown, 113, 18, 1>>;
|
|
14
|
+
/**
|
|
15
|
+
* Decode an RGATree from a DAG-CBOR block.
|
|
16
|
+
*/
|
|
17
|
+
export declare function decodeTree<E extends RGAEvent>(block: {
|
|
18
|
+
bytes: Uint8Array;
|
|
19
|
+
}, parseEvent: (s: string) => E, fingerprintFn: (value: RGATreeNode<E>) => string, compareEvents: EventComparator<E>): Promise<RGATreeRoot<E>>;
|
|
20
|
+
/**
|
|
21
|
+
* Encode an RGAChangeSet as a DAG-CBOR block.
|
|
22
|
+
*/
|
|
23
|
+
export declare function encodeChangeSet<E extends RGAEvent>(cs: RGAChangeSet<E>): Promise<import("multiformats").BlockView<unknown, 113, 18, 1>>;
|
|
24
|
+
/**
|
|
25
|
+
* Decode an RGAChangeSet from a DAG-CBOR block.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decodeChangeSet<E extends RGAEvent>(block: {
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
}, parseEvent: (s: string) => E): Promise<RGAChangeSet<E>>;
|
package/dist/codec.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG-CBOR serialization for RGATree and RGAChangeSet.
|
|
3
|
+
*
|
|
4
|
+
* Converts RGA class instances (with Maps, nested RGAs) to plain
|
|
5
|
+
* CBOR-friendly objects and back.
|
|
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
|
+
/** Recursively strip `undefined` values (not IPLD-compatible). */
|
|
12
|
+
function stripUndefined(obj) {
|
|
13
|
+
if (obj === null || obj === undefined)
|
|
14
|
+
return null;
|
|
15
|
+
if (Array.isArray(obj))
|
|
16
|
+
return obj.map(stripUndefined);
|
|
17
|
+
if (typeof obj === 'object') {
|
|
18
|
+
const clean = {};
|
|
19
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
20
|
+
if (v !== undefined)
|
|
21
|
+
clean[k] = stripUndefined(v);
|
|
22
|
+
}
|
|
23
|
+
return clean;
|
|
24
|
+
}
|
|
25
|
+
return obj;
|
|
26
|
+
}
|
|
27
|
+
// ---- Helpers ----
|
|
28
|
+
function serializeNodeId(id) {
|
|
29
|
+
return { uuid: id.uuid, event: id.event.toString() };
|
|
30
|
+
}
|
|
31
|
+
function deserializeNodeId(raw, parseEvent) {
|
|
32
|
+
return { uuid: raw.uuid, event: parseEvent(raw.event) };
|
|
33
|
+
}
|
|
34
|
+
function isRGAParentSerialized(node) {
|
|
35
|
+
return (node != null &&
|
|
36
|
+
typeof node === 'object' &&
|
|
37
|
+
'children' in node &&
|
|
38
|
+
typeof node.children === 'object' &&
|
|
39
|
+
node.children !== null &&
|
|
40
|
+
'nodes' in node.children);
|
|
41
|
+
}
|
|
42
|
+
function isRGAParentNode(node) {
|
|
43
|
+
return 'children' in node && node.children instanceof RGA;
|
|
44
|
+
}
|
|
45
|
+
// ---- RGATree serialization ----
|
|
46
|
+
function serializeRGA(rga) {
|
|
47
|
+
const nodes = [];
|
|
48
|
+
for (const node of rga.nodes.values()) {
|
|
49
|
+
nodes.push({
|
|
50
|
+
id: serializeNodeId(node.id),
|
|
51
|
+
value: serializeTreeNodeValue(node.value),
|
|
52
|
+
afterId: node.afterId ? serializeNodeId(node.afterId) : null,
|
|
53
|
+
tombstone: node.tombstone,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { nodes };
|
|
57
|
+
}
|
|
58
|
+
function serializeTreeNodeValue(node) {
|
|
59
|
+
if (isRGAParentNode(node)) {
|
|
60
|
+
const { children, ...rest } = node;
|
|
61
|
+
return { ...rest, children: serializeRGA(children) };
|
|
62
|
+
}
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
function serializeTree(root) {
|
|
66
|
+
return {
|
|
67
|
+
type: 'root',
|
|
68
|
+
children: serializeRGA(root.children),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function deserializeRGA(raw, parseEvent, fingerprintFn, compareEvents) {
|
|
72
|
+
const rga = new RGA(fingerprintFn, compareEvents);
|
|
73
|
+
for (const rawNode of raw.nodes) {
|
|
74
|
+
const id = deserializeNodeId(rawNode.id, parseEvent);
|
|
75
|
+
const afterId = rawNode.afterId ? deserializeNodeId(rawNode.afterId, parseEvent) : undefined;
|
|
76
|
+
const value = deserializeTreeNodeValue(rawNode.value, parseEvent, fingerprintFn, compareEvents);
|
|
77
|
+
const key = `${id.uuid}:${id.event.toString()}`;
|
|
78
|
+
rga.nodes.set(key, { id, value, afterId, tombstone: rawNode.tombstone });
|
|
79
|
+
}
|
|
80
|
+
return rga;
|
|
81
|
+
}
|
|
82
|
+
function deserializeTreeNodeValue(raw, parseEvent, fingerprintFn, compareEvents) {
|
|
83
|
+
if (isRGAParentSerialized(raw)) {
|
|
84
|
+
const { children, ...rest } = raw;
|
|
85
|
+
return {
|
|
86
|
+
...rest,
|
|
87
|
+
children: deserializeRGA(children, parseEvent, fingerprintFn, compareEvents),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
function deserializeTree(raw, parseEvent, fingerprintFn, compareEvents) {
|
|
93
|
+
return {
|
|
94
|
+
type: 'root',
|
|
95
|
+
children: deserializeRGA(raw.children, parseEvent, fingerprintFn, compareEvents),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ---- RGAChangeSet serialization ----
|
|
99
|
+
function serializeChangeSet(cs) {
|
|
100
|
+
return {
|
|
101
|
+
event: cs.event.toString(),
|
|
102
|
+
changes: cs.changes.map(c => ({
|
|
103
|
+
type: c.type,
|
|
104
|
+
parentPath: c.parentPath.map(serializeNodeId),
|
|
105
|
+
targetId: c.targetId ? serializeNodeId(c.targetId) : null,
|
|
106
|
+
afterId: c.afterId ? serializeNodeId(c.afterId) : null,
|
|
107
|
+
nodes: c.nodes,
|
|
108
|
+
before: c.before,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function deserializeChangeSet(raw, parseEvent) {
|
|
113
|
+
return {
|
|
114
|
+
event: parseEvent(raw.event),
|
|
115
|
+
changes: raw.changes.map(c => {
|
|
116
|
+
const change = {
|
|
117
|
+
type: c.type,
|
|
118
|
+
parentPath: c.parentPath.map(id => deserializeNodeId(id, parseEvent)),
|
|
119
|
+
};
|
|
120
|
+
if (c.targetId)
|
|
121
|
+
change.targetId = deserializeNodeId(c.targetId, parseEvent);
|
|
122
|
+
if (c.afterId)
|
|
123
|
+
change.afterId = deserializeNodeId(c.afterId, parseEvent);
|
|
124
|
+
if (c.nodes)
|
|
125
|
+
change.nodes = c.nodes;
|
|
126
|
+
if (c.before)
|
|
127
|
+
change.before = c.before;
|
|
128
|
+
return change;
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---- Public API: encode/decode to DAG-CBOR blocks ----
|
|
133
|
+
/**
|
|
134
|
+
* Encode an RGATree as a DAG-CBOR block.
|
|
135
|
+
*/
|
|
136
|
+
export async function encodeTree(root) {
|
|
137
|
+
const value = stripUndefined(serializeTree(root));
|
|
138
|
+
return encode({ value, codec: cbor, hasher: sha256 });
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Decode an RGATree from a DAG-CBOR block.
|
|
142
|
+
*/
|
|
143
|
+
export async function decodeTree(block, parseEvent, fingerprintFn, compareEvents) {
|
|
144
|
+
const decoded = await decode({ bytes: block.bytes, codec: cbor, hasher: sha256 });
|
|
145
|
+
return deserializeTree(decoded.value, parseEvent, fingerprintFn, compareEvents);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Encode an RGAChangeSet as a DAG-CBOR block.
|
|
149
|
+
*/
|
|
150
|
+
export async function encodeChangeSet(cs) {
|
|
151
|
+
const value = stripUndefined(serializeChangeSet(cs));
|
|
152
|
+
return encode({ value, codec: cbor, hasher: sha256 });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Decode an RGAChangeSet from a DAG-CBOR block.
|
|
156
|
+
*/
|
|
157
|
+
export async function decodeChangeSet(block, parseEvent) {
|
|
158
|
+
const decoded = await decode({ bytes: block.bytes, codec: cbor, hasher: sha256 });
|
|
159
|
+
return deserializeChangeSet(decoded.value, parseEvent);
|
|
160
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/crdt/rga.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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 { parse, stringify } from "./parse.js";
|
|
8
|
+
import { toRGATree, toMdast, generateRGAChangeSet, applyRGAChangeSet, } from "./rga-tree.js";
|
|
9
|
+
// Re-exports
|
|
10
|
+
export { parse, stringify, stringifyNode, fingerprint } from "./parse.js";
|
|
11
|
+
export { diff, applyChangeSet } from "./diff.js";
|
|
12
|
+
export { toRGATree, toMdast, applyMdastToRGATree, generateRGAChangeSet, applyRGAChangeSet, } from "./rga-tree.js";
|
|
13
|
+
export { encodeTree, decodeTree, encodeChangeSet, decodeChangeSet } from "./codec.js";
|
|
14
|
+
export { RGA } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Compute an RGA-addressed changeset between an existing RGA tree and new markdown.
|
|
17
|
+
*/
|
|
18
|
+
export function computeChangeSet(existing, newMarkdown, event) {
|
|
19
|
+
const newRoot = parse(newMarkdown);
|
|
20
|
+
return generateRGAChangeSet(existing, newRoot, event);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Apply an RGA changeset to an RGA tree, returning updated tree + markdown.
|
|
24
|
+
*/
|
|
25
|
+
export function applyToTree(tree, changeset, compareEvents) {
|
|
26
|
+
const updated = applyRGAChangeSet(tree, changeset, compareEvents);
|
|
27
|
+
return { tree: updated, markdown: stringify(toMdast(updated)) };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Apply an RGA changeset and return just the markdown string.
|
|
31
|
+
*/
|
|
32
|
+
export function applyToMarkdown(tree, changeset, compareEvents) {
|
|
33
|
+
return applyToTree(tree, changeset, compareEvents).markdown;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Bootstrap: create an RGA tree from a markdown string.
|
|
37
|
+
*/
|
|
38
|
+
export function fromMarkdown(markdown, event, compareEvents) {
|
|
39
|
+
const root = parse(markdown);
|
|
40
|
+
return toRGATree(root, event, compareEvents);
|
|
41
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Root, RootContent } from 'mdast';
|
|
2
|
+
/** Parse markdown string into mdast Root */
|
|
3
|
+
export declare function parse(markdown: string): Root;
|
|
4
|
+
/** Stringify an mdast Root back to markdown */
|
|
5
|
+
export declare function stringify(root: Root): string;
|
|
6
|
+
/** Stringify a single mdast node by wrapping in a temporary root */
|
|
7
|
+
export declare function stringifyNode(node: RootContent): string;
|
|
8
|
+
/** Fingerprint a top-level mdast node by stringifying it */
|
|
9
|
+
export declare function fingerprint(node: RootContent): string;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkStringify from 'remark-stringify';
|
|
4
|
+
const parser = unified().use(remarkParse);
|
|
5
|
+
const stringifier = unified().use(remarkStringify);
|
|
6
|
+
/** Parse markdown string into mdast Root */
|
|
7
|
+
export function parse(markdown) {
|
|
8
|
+
return parser.parse(markdown);
|
|
9
|
+
}
|
|
10
|
+
/** Stringify an mdast Root back to markdown */
|
|
11
|
+
export function stringify(root) {
|
|
12
|
+
return stringifier.stringify(root);
|
|
13
|
+
}
|
|
14
|
+
/** Stringify a single mdast node by wrapping in a temporary root */
|
|
15
|
+
export function stringifyNode(node) {
|
|
16
|
+
const tempRoot = { type: 'root', children: [node] };
|
|
17
|
+
return stringify(tempRoot).trim();
|
|
18
|
+
}
|
|
19
|
+
/** Fingerprint a top-level mdast node by stringifying it */
|
|
20
|
+
export function fingerprint(node) {
|
|
21
|
+
return stringifyNode(node);
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RGA-backed mdast tree.
|
|
3
|
+
*
|
|
4
|
+
* Every ordered `children` array in the mdast tree is replaced with an RGA,
|
|
5
|
+
* giving us CRDT merge semantics at every level.
|
|
6
|
+
*/
|
|
7
|
+
import type { RGAChangeSet, RGAEvent, RGATreeRoot, EventComparator } from "./types.js";
|
|
8
|
+
import type { Root } from "mdast";
|
|
9
|
+
export declare function toRGATree<E extends RGAEvent>(root: Root, event: E, compareEvents: EventComparator<E>): RGATreeRoot<E>;
|
|
10
|
+
export declare function toMdast<E extends RGAEvent>(rgaRoot: RGATreeRoot<E>): Root;
|
|
11
|
+
/**
|
|
12
|
+
* Apply a new mdast document to an existing RGA tree, preserving existing
|
|
13
|
+
* RGA node IDs where nodes haven't changed.
|
|
14
|
+
*
|
|
15
|
+
* Generates an RGAChangeSet from the diff, then applies it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyMdastToRGATree<E extends RGAEvent>(existing: RGATreeRoot<E>, newRoot: Root, event: E, compareEvents: EventComparator<E>): RGATreeRoot<E>;
|
|
18
|
+
/**
|
|
19
|
+
* Generate an RGA-addressed changeset by diffing an existing RGA tree against
|
|
20
|
+
* a new mdast document.
|
|
21
|
+
*
|
|
22
|
+
* 1. Convert RGA tree → plain mdast
|
|
23
|
+
* 2. Diff old mdast vs new mdast → index-based ChangeSet
|
|
24
|
+
* 3. Convert each index-based Change to an RGAChange by resolving indices
|
|
25
|
+
* to RGA node IDs via idAtIndex / predecessorForIndex
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateRGAChangeSet<E extends RGAEvent>(existing: RGATreeRoot<E>, newRoot: Root, event: E): RGAChangeSet<E>;
|
|
28
|
+
/**
|
|
29
|
+
* Apply an RGA-addressed changeset to an RGA tree.
|
|
30
|
+
* Navigates by node IDs at every level — no index dependency.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyRGAChangeSet<E extends RGAEvent>(root: RGATreeRoot<E>, changeset: RGAChangeSet<E>, compareEvents: EventComparator<E>): RGATreeRoot<E>;
|
package/dist/rga-tree.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { RGA } from "./types.js";
|
|
2
|
+
import { fingerprint } from "./parse.js";
|
|
3
|
+
import { diff as mdastDiff } from "./diff.js";
|
|
4
|
+
// ---- Helpers ----
|
|
5
|
+
function isParent(node) {
|
|
6
|
+
return "children" in node && Array.isArray(node.children);
|
|
7
|
+
}
|
|
8
|
+
function fpNode(node) {
|
|
9
|
+
if (isRGAParent(node)) {
|
|
10
|
+
const { children, ...rest } = node;
|
|
11
|
+
return JSON.stringify(rest);
|
|
12
|
+
}
|
|
13
|
+
return fingerprint(node);
|
|
14
|
+
}
|
|
15
|
+
function isRGAParent(node) {
|
|
16
|
+
return "children" in node && node.children instanceof RGA;
|
|
17
|
+
}
|
|
18
|
+
// ---- Conversion: mdast → RGA Tree ----
|
|
19
|
+
export function toRGATree(root, event, compareEvents) {
|
|
20
|
+
return {
|
|
21
|
+
type: "root",
|
|
22
|
+
children: childrenToRGA(root.children, event, compareEvents),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function childrenToRGA(children, event, compareEvents) {
|
|
26
|
+
const converted = children.map((child) => convertNode(child, event, compareEvents));
|
|
27
|
+
return RGA.fromArray(converted, event, (n) => fpNode(n), compareEvents);
|
|
28
|
+
}
|
|
29
|
+
function convertNode(node, event, compareEvents) {
|
|
30
|
+
if (!isParent(node)) {
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
const { children, ...rest } = node;
|
|
34
|
+
return {
|
|
35
|
+
...rest,
|
|
36
|
+
children: childrenToRGA(children, event, compareEvents),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ---- Conversion: RGA Tree → mdast ----
|
|
40
|
+
export function toMdast(rgaRoot) {
|
|
41
|
+
return {
|
|
42
|
+
type: "root",
|
|
43
|
+
children: rgaToChildren(rgaRoot.children),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function rgaToChildren(rga) {
|
|
47
|
+
return rga.toArray().map(revertNode);
|
|
48
|
+
}
|
|
49
|
+
function revertNode(node) {
|
|
50
|
+
if (!isRGAParent(node)) {
|
|
51
|
+
return node;
|
|
52
|
+
}
|
|
53
|
+
const { children, ...rest } = node;
|
|
54
|
+
return {
|
|
55
|
+
...rest,
|
|
56
|
+
children: rgaToChildren(children),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ---- Apply new mdast to existing RGA tree ----
|
|
60
|
+
/**
|
|
61
|
+
* Apply a new mdast document to an existing RGA tree, preserving existing
|
|
62
|
+
* RGA node IDs where nodes haven't changed.
|
|
63
|
+
*
|
|
64
|
+
* Generates an RGAChangeSet from the diff, then applies it.
|
|
65
|
+
*/
|
|
66
|
+
export function applyMdastToRGATree(existing, newRoot, event, compareEvents) {
|
|
67
|
+
const changeset = generateRGAChangeSet(existing, newRoot, event);
|
|
68
|
+
return applyRGAChangeSet(existing, changeset, compareEvents);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Deep clone an RGA and all nested RGA children.
|
|
72
|
+
*/
|
|
73
|
+
function cloneRGA(rga) {
|
|
74
|
+
const clone = new RGA(rga.fingerprintFn, rga.compareEvents);
|
|
75
|
+
for (const [key, node] of rga.nodes) {
|
|
76
|
+
clone.nodes.set(key, {
|
|
77
|
+
...node,
|
|
78
|
+
value: isRGAParent(node.value)
|
|
79
|
+
? {
|
|
80
|
+
...node.value,
|
|
81
|
+
children: cloneRGA(node.value.children),
|
|
82
|
+
}
|
|
83
|
+
: node.value,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return clone;
|
|
87
|
+
}
|
|
88
|
+
// ---- Generate RGA ChangeSet from mdast ----
|
|
89
|
+
/**
|
|
90
|
+
* Generate an RGA-addressed changeset by diffing an existing RGA tree against
|
|
91
|
+
* a new mdast document.
|
|
92
|
+
*
|
|
93
|
+
* 1. Convert RGA tree → plain mdast
|
|
94
|
+
* 2. Diff old mdast vs new mdast → index-based ChangeSet
|
|
95
|
+
* 3. Convert each index-based Change to an RGAChange by resolving indices
|
|
96
|
+
* to RGA node IDs via idAtIndex / predecessorForIndex
|
|
97
|
+
*/
|
|
98
|
+
export function generateRGAChangeSet(existing, newRoot, event) {
|
|
99
|
+
const oldMdast = toMdast(existing);
|
|
100
|
+
const changeset = mdastDiff(oldMdast, newRoot);
|
|
101
|
+
const rgaChanges = [];
|
|
102
|
+
for (const change of changeset.changes) {
|
|
103
|
+
const path = change.path;
|
|
104
|
+
// path = [...parentIndices, targetIndex]
|
|
105
|
+
// parentIndices navigate into nested RGAs, targetIndex is the operation target
|
|
106
|
+
const parentIndices = path.slice(0, -1);
|
|
107
|
+
const targetIndex = path[path.length - 1];
|
|
108
|
+
// Walk the RGA tree to resolve parent path to node IDs and find the target RGA
|
|
109
|
+
const parentIds = [];
|
|
110
|
+
let currentRGA = existing.children;
|
|
111
|
+
let valid = true;
|
|
112
|
+
for (const idx of parentIndices) {
|
|
113
|
+
const nodeId = currentRGA.idAtIndex(idx);
|
|
114
|
+
if (!nodeId) {
|
|
115
|
+
valid = false;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
parentIds.push(nodeId);
|
|
119
|
+
// Navigate into this node's children RGA
|
|
120
|
+
const nodeKey = `${nodeId.uuid}:${nodeId.event.toString()}`;
|
|
121
|
+
const node = currentRGA.nodes.get(nodeKey);
|
|
122
|
+
if (!node || !isRGAParent(node.value)) {
|
|
123
|
+
valid = false;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
currentRGA = node.value.children;
|
|
127
|
+
}
|
|
128
|
+
if (!valid)
|
|
129
|
+
continue;
|
|
130
|
+
// Now currentRGA is the RGA where the operation happens, resolve target/after IDs
|
|
131
|
+
switch (change.type) {
|
|
132
|
+
case "delete": {
|
|
133
|
+
const targetId = currentRGA.idAtIndex(targetIndex);
|
|
134
|
+
if (targetId) {
|
|
135
|
+
rgaChanges.push({
|
|
136
|
+
type: "delete",
|
|
137
|
+
parentPath: parentIds,
|
|
138
|
+
targetId,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "insert": {
|
|
144
|
+
const afterId = currentRGA.predecessorForIndex(targetIndex);
|
|
145
|
+
rgaChanges.push({
|
|
146
|
+
type: "insert",
|
|
147
|
+
parentPath: parentIds,
|
|
148
|
+
afterId,
|
|
149
|
+
nodes: change.nodes,
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "modify": {
|
|
154
|
+
const targetId = currentRGA.idAtIndex(targetIndex);
|
|
155
|
+
const afterId = currentRGA.predecessorForIndex(targetIndex);
|
|
156
|
+
if (targetId) {
|
|
157
|
+
rgaChanges.push({
|
|
158
|
+
type: "modify",
|
|
159
|
+
parentPath: parentIds,
|
|
160
|
+
targetId,
|
|
161
|
+
afterId,
|
|
162
|
+
nodes: change.nodes,
|
|
163
|
+
before: change.before,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { event, changes: rgaChanges };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Apply an RGA-addressed changeset to an RGA tree.
|
|
174
|
+
* Navigates by node IDs at every level — no index dependency.
|
|
175
|
+
*/
|
|
176
|
+
export function applyRGAChangeSet(root, changeset, compareEvents) {
|
|
177
|
+
const updatedChildren = cloneRGA(root.children);
|
|
178
|
+
for (const change of changeset.changes) {
|
|
179
|
+
// Navigate to the target RGA using parentPath node IDs
|
|
180
|
+
let currentRGA = updatedChildren;
|
|
181
|
+
let valid = true;
|
|
182
|
+
for (const parentId of change.parentPath) {
|
|
183
|
+
const nodeKey = `${parentId.uuid}:${parentId.event.toString()}`;
|
|
184
|
+
const node = currentRGA.nodes.get(nodeKey);
|
|
185
|
+
if (!node || !isRGAParent(node.value)) {
|
|
186
|
+
valid = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
currentRGA = node.value.children;
|
|
190
|
+
}
|
|
191
|
+
if (!valid)
|
|
192
|
+
continue;
|
|
193
|
+
switch (change.type) {
|
|
194
|
+
case "delete": {
|
|
195
|
+
if (change.targetId)
|
|
196
|
+
currentRGA.delete(change.targetId);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "insert": {
|
|
200
|
+
for (const node of change.nodes ?? []) {
|
|
201
|
+
const rgaNode = convertNode(node, changeset.event, compareEvents);
|
|
202
|
+
currentRGA.insert(change.afterId, rgaNode, changeset.event);
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "modify": {
|
|
207
|
+
if (change.targetId)
|
|
208
|
+
currentRGA.delete(change.targetId);
|
|
209
|
+
for (const node of change.nodes ?? []) {
|
|
210
|
+
const rgaNode = convertNode(node, changeset.event, compareEvents);
|
|
211
|
+
currentRGA.insert(change.afterId, rgaNode, changeset.event);
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { type: "root", children: updatedChildren };
|
|
218
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RootContent, Parent } from "mdast";
|
|
2
|
+
import { RGA, type RGANodeId, type RGAEvent, type EventComparator } from "./crdt/rga.js";
|
|
3
|
+
export { RGA, type RGANodeId, type RGAEvent, type EventComparator };
|
|
4
|
+
/** A changeset describing diffs between two mdast versions */
|
|
5
|
+
export interface ChangeSet {
|
|
6
|
+
changes: Change[];
|
|
7
|
+
}
|
|
8
|
+
/** A single change at any depth in the mdast tree */
|
|
9
|
+
export interface Change {
|
|
10
|
+
type: "insert" | "delete" | "modify";
|
|
11
|
+
/** Path into the tree, e.g. [2, 1, 0] = root.children[2].children[1].children[0] */
|
|
12
|
+
path: number[];
|
|
13
|
+
/** For insert/modify: the new mdast node(s) */
|
|
14
|
+
nodes?: RootContent[];
|
|
15
|
+
/** For modify: what it was before (for conflict detection) */
|
|
16
|
+
before?: RootContent[];
|
|
17
|
+
}
|
|
18
|
+
/** An RGA-addressed changeset — uses node IDs instead of indices */
|
|
19
|
+
export interface RGAChangeSet<E extends RGAEvent = RGAEvent> {
|
|
20
|
+
event: E;
|
|
21
|
+
changes: RGAChange<E>[];
|
|
22
|
+
}
|
|
23
|
+
/** A single RGA-addressed change at any depth in the RGA tree */
|
|
24
|
+
export interface RGAChange<E extends RGAEvent = RGAEvent> {
|
|
25
|
+
type: "insert" | "delete" | "modify";
|
|
26
|
+
/** Path of RGA node IDs from root to the parent containing this change (empty = root level) */
|
|
27
|
+
parentPath: RGANodeId<E>[];
|
|
28
|
+
/** For delete/modify: the target node to act on */
|
|
29
|
+
targetId?: RGANodeId<E>;
|
|
30
|
+
/** For insert/modify: insert after this node (undefined = insert at start) */
|
|
31
|
+
afterId?: RGANodeId<E>;
|
|
32
|
+
/** For insert/modify: the new mdast node(s) */
|
|
33
|
+
nodes?: RootContent[];
|
|
34
|
+
/** For modify: what it was before (for conflict detection) */
|
|
35
|
+
before?: RootContent[];
|
|
36
|
+
}
|
|
37
|
+
export type RGATreeNode<E extends RGAEvent = RGAEvent> = RGAParentNode<E> | RGALeafNode;
|
|
38
|
+
export type RGALeafNode = Exclude<RootContent, Parent>;
|
|
39
|
+
export interface RGAParentNode<E extends RGAEvent = RGAEvent> {
|
|
40
|
+
type: string;
|
|
41
|
+
children: RGA<RGATreeNode<E>, E>;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface RGATreeRoot<E extends RGAEvent = RGAEvent> {
|
|
45
|
+
type: "root";
|
|
46
|
+
children: RGA<RGATreeNode<E>, E>;
|
|
47
|
+
}
|
package/dist/types.js
ADDED