@storacha/clawracha 0.0.3 → 0.0.5
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/openclaw.plugin.json +2 -2
- package/package.json +7 -1
- package/src/blockstore/disk.ts +0 -57
- package/src/blockstore/index.ts +0 -23
- package/src/blockstore/workspace.ts +0 -41
- package/src/handlers/apply.ts +0 -79
- package/src/handlers/process.ts +0 -118
- package/src/handlers/remote.ts +0 -61
- package/src/index.ts +0 -13
- package/src/mdsync/index.ts +0 -557
- package/src/plugin.ts +0 -489
- package/src/sync.ts +0 -258
- package/src/types/index.ts +0 -64
- package/src/utils/client.ts +0 -51
- package/src/utils/differ.ts +0 -67
- package/src/utils/encoder.ts +0 -64
- package/src/utils/tempcar.ts +0 -79
- package/src/watcher.ts +0 -151
- package/test/blockstore/blockstore.test.ts +0 -113
- package/test/handlers/apply.test.ts +0 -276
- package/test/handlers/process.test.ts +0 -301
- package/test/handlers/remote.test.ts +0 -182
- package/test/mdsync/mdsync.test.ts +0 -120
- package/test/utils/differ.test.ts +0 -94
- package/tsconfig.json +0 -18
package/src/mdsync/index.ts
DELETED
|
@@ -1,557 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mdsync — CRDT markdown storage on top of UCN Pail.
|
|
3
|
-
*
|
|
4
|
-
* Stores RGA-backed markdown trees at Pail keys. Each key's value is a
|
|
5
|
-
* MarkdownEntry containing:
|
|
6
|
-
* - The current RGA tree (full document state)
|
|
7
|
-
* - An RGA of MarkdownEvents (causal history scoped to this key)
|
|
8
|
-
* - The last changeset applied (for incremental replay during merge)
|
|
9
|
-
*
|
|
10
|
-
* On read, if the Pail has multiple heads (concurrent writes), we resolve
|
|
11
|
-
* by walking from the common ancestor forward, replaying each branch's
|
|
12
|
-
* changesets and merging event RGAs — analogous to how Pail itself resolves
|
|
13
|
-
* concurrent root updates.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { MemoryBlockstore, withCache } from "@storacha/ucn/block";
|
|
17
|
-
import {
|
|
18
|
-
BlockFetcher,
|
|
19
|
-
EventLink,
|
|
20
|
-
Operation,
|
|
21
|
-
ValueView,
|
|
22
|
-
} from "@storacha/ucn/pail/api";
|
|
23
|
-
import * as CRDT from "@web3-storage/pail/crdt";
|
|
24
|
-
import { Block, CID } from "multiformats";
|
|
25
|
-
import * as cbor from "@ipld/dag-cbor";
|
|
26
|
-
import {
|
|
27
|
-
fromMarkdown,
|
|
28
|
-
encodeTree,
|
|
29
|
-
encodeRGA,
|
|
30
|
-
RGA,
|
|
31
|
-
decodeRGA,
|
|
32
|
-
decodeTree,
|
|
33
|
-
computeChangeSet,
|
|
34
|
-
applyRGAChangeSet,
|
|
35
|
-
encodeChangeSet,
|
|
36
|
-
RGATreeRoot,
|
|
37
|
-
RGAChangeSet,
|
|
38
|
-
decodeChangeSet,
|
|
39
|
-
toMarkdown,
|
|
40
|
-
} from "@storacha/md-merge";
|
|
41
|
-
import * as Pail from "@web3-storage/pail";
|
|
42
|
-
import { decode, encode } from "multiformats/block";
|
|
43
|
-
import { sha256 } from "multiformats/hashes/sha2";
|
|
44
|
-
import { EventFetcher } from "@web3-storage/pail/clock";
|
|
45
|
-
|
|
46
|
-
// ---- Event type ----
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A MarkdownEvent ties an RGA tree mutation to its position in the Pail's
|
|
50
|
-
* merkle clock. `parents` are the EventLinks of the Pail revision that was
|
|
51
|
-
* current when this edit was made — this is what links the md-merge causal
|
|
52
|
-
* history to the Pail's causal history.
|
|
53
|
-
*
|
|
54
|
-
* Implements RGAEvent (toString) so it can be used as the event type in
|
|
55
|
-
* md-merge's RGA and RGATree.
|
|
56
|
-
*/
|
|
57
|
-
class MarkdownEvent {
|
|
58
|
-
parents: Array<EventLink>;
|
|
59
|
-
|
|
60
|
-
constructor(parents: Array<EventLink>) {
|
|
61
|
-
this.parents = parents;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
toString() {
|
|
65
|
-
return this.parents.map((p) => p.toString()).join(",");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Deserialize a MarkdownEvent from its string representation (comma-separated CIDs). */
|
|
70
|
-
const parseMarkdownEvent = (str: string): MarkdownEvent => {
|
|
71
|
-
if (!str) return new MarkdownEvent([]);
|
|
72
|
-
const parentStrs = str.split(",");
|
|
73
|
-
const parents = parentStrs.map((s) => CID.parse(s) as EventLink);
|
|
74
|
-
return new MarkdownEvent(parents);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/** Serialize a MarkdownEvent for DAG-CBOR storage (via encodeRGA). */
|
|
78
|
-
const serializeMarkdownEvent = (event: MarkdownEvent): unknown => {
|
|
79
|
-
return event.toString();
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/** Deserialize a MarkdownEvent from a DAG-CBOR decoded value. */
|
|
83
|
-
const deserializeMarkdownEvent = (obj: unknown): MarkdownEvent => {
|
|
84
|
-
if (typeof obj !== "string") {
|
|
85
|
-
throw new Error(`Expected string, got ${typeof obj}`);
|
|
86
|
-
}
|
|
87
|
-
return parseMarkdownEvent(obj);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// ---- On-disk types (CID references, stored in DAG-CBOR) ----
|
|
91
|
-
|
|
92
|
-
interface MarkdownEntryBase {
|
|
93
|
-
type: string;
|
|
94
|
-
/** CID of the DAG-CBOR encoded RGATree — the full current document state. */
|
|
95
|
-
root: CID;
|
|
96
|
-
/** CID of the DAG-CBOR encoded event RGA — causal history for this key. */
|
|
97
|
-
events: CID;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface MarkdownEntryInitial extends MarkdownEntryBase {
|
|
101
|
-
type: "initial";
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface MarkdownEntryUpdate extends MarkdownEntryBase {
|
|
105
|
-
type: "update";
|
|
106
|
-
/** CID of the DAG-CBOR encoded RGAChangeSet applied in this event. */
|
|
107
|
-
changeset: CID;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Serialized form stored at each Pail key — CID pointers to the actual data blocks. */
|
|
111
|
-
type MarkdownEntry = MarkdownEntryInitial | MarkdownEntryUpdate;
|
|
112
|
-
|
|
113
|
-
/** Shorthand for the event RGA type used throughout. */
|
|
114
|
-
type EventRGA = RGA<MarkdownEvent, MarkdownEvent>;
|
|
115
|
-
|
|
116
|
-
// ---- In-memory types (deserialized, ready to operate on) ----
|
|
117
|
-
|
|
118
|
-
interface DeserializedMarkdownEntryBase {
|
|
119
|
-
type: string;
|
|
120
|
-
/** The full RGA tree for the document at this key. */
|
|
121
|
-
root: RGATreeRoot<MarkdownEvent>;
|
|
122
|
-
/**
|
|
123
|
-
* Causal history as an RGA. Each node is a MarkdownEvent; the RGA's
|
|
124
|
-
* afterId links encode causal ordering. toWeightedArray() gives a
|
|
125
|
-
* BFS linearization suitable for building a comparator.
|
|
126
|
-
*/
|
|
127
|
-
events: EventRGA;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
interface DeserializedMarkdownEntryInitial extends DeserializedMarkdownEntryBase {
|
|
131
|
-
type: "initial";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
interface DeserializedMarkdownEntryUpdate extends DeserializedMarkdownEntryBase {
|
|
135
|
-
type: "update";
|
|
136
|
-
/** The changeset that was applied to produce this version of the tree. */
|
|
137
|
-
changeset: RGAChangeSet<MarkdownEvent>;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
type DeserializedMarkdownEntry =
|
|
141
|
-
| DeserializedMarkdownEntryInitial
|
|
142
|
-
| DeserializedMarkdownEntryUpdate;
|
|
143
|
-
|
|
144
|
-
// ---- Comparators ----
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Fallback comparator used when deserializing event RGAs. Since the event
|
|
148
|
-
* RGA itself determines ordering (via toWeightedArray), this comparator
|
|
149
|
-
* only needs to be deterministic — it lexicographically compares toString().
|
|
150
|
-
*/
|
|
151
|
-
const nohierarchyComparator = (a: MarkdownEvent, b: MarkdownEvent) =>
|
|
152
|
-
a.toString() === b.toString() ? 0 : a.toString() < b.toString() ? -1 : 1;
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Build a comparator from the event RGA's weighted BFS ordering.
|
|
156
|
-
* Events earlier in the BFS (closer to the root of the causal tree)
|
|
157
|
-
* compare as "less than" later events. This is used as the EventComparator
|
|
158
|
-
* for all RGA tree operations — it determines how concurrent inserts are
|
|
159
|
-
* ordered in the document.
|
|
160
|
-
*/
|
|
161
|
-
const makeComparator = (
|
|
162
|
-
events: EventRGA,
|
|
163
|
-
): ((a: MarkdownEvent, b: MarkdownEvent) => number) => {
|
|
164
|
-
const ordered = events.toWeightedArray();
|
|
165
|
-
const index = new Map<string, number>(ordered.map((e, i) => [e.toString(), i]));
|
|
166
|
-
return (a, b) =>
|
|
167
|
-
(index.get(a.toString()) ?? -1) - (index.get(b.toString()) ?? -1);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
// ---- Serialization helpers ----
|
|
171
|
-
|
|
172
|
-
interface MarkdownResult {
|
|
173
|
-
mdEntryCid: CID;
|
|
174
|
-
additions: Block[];
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Create the first MarkdownEntry for a key — bootstraps the RGA tree and
|
|
179
|
-
* event RGA from raw markdown. Used when a key doesn't exist yet.
|
|
180
|
-
*/
|
|
181
|
-
const firstPut = async (
|
|
182
|
-
newMarkdown: string,
|
|
183
|
-
parents: Array<EventLink>,
|
|
184
|
-
): Promise<MarkdownResult> => {
|
|
185
|
-
const markdownEvent = new MarkdownEvent(parents);
|
|
186
|
-
const eventRGA = new RGA<MarkdownEvent, MarkdownEvent>(nohierarchyComparator);
|
|
187
|
-
eventRGA.insert(undefined, markdownEvent, markdownEvent);
|
|
188
|
-
const eventBlock = await encodeRGA(eventRGA, serializeMarkdownEvent);
|
|
189
|
-
// Only one event, so comparator is trivial (all nodes have the same event).
|
|
190
|
-
const rgaRoot = fromMarkdown(newMarkdown, markdownEvent, (a, b) => 0);
|
|
191
|
-
return serializeMarkdownEntry({
|
|
192
|
-
type: "initial",
|
|
193
|
-
root: rgaRoot,
|
|
194
|
-
events: eventRGA,
|
|
195
|
-
});
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
// ---- Public API ----
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* First put into an empty Pail (v0 = no existing revision).
|
|
202
|
-
* Returns the markdown entry CID and blocks to store. Caller is
|
|
203
|
-
* responsible for creating the Pail revision via Revision.v0Put.
|
|
204
|
-
*/
|
|
205
|
-
export const v0Put = async (
|
|
206
|
-
newMarkdown: string,
|
|
207
|
-
): Promise<MarkdownResult> => {
|
|
208
|
-
return firstPut(newMarkdown, []);
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Put markdown content at a key in an existing Pail.
|
|
213
|
-
*
|
|
214
|
-
* If the key doesn't exist, creates an initial entry. If it does exist,
|
|
215
|
-
* resolves the current value (merging concurrent heads if needed), computes
|
|
216
|
-
* an RGA changeset against the resolved tree, applies it, and stores the
|
|
217
|
-
* updated entry.
|
|
218
|
-
*
|
|
219
|
-
* Returns the markdown entry CID and blocks to store. Caller is
|
|
220
|
-
* responsible for creating the Pail revision via Revision.put.
|
|
221
|
-
*/
|
|
222
|
-
export const put = async (
|
|
223
|
-
blocks: BlockFetcher,
|
|
224
|
-
current: ValueView,
|
|
225
|
-
key: string,
|
|
226
|
-
newMarkdown: string,
|
|
227
|
-
): Promise<MarkdownResult> => {
|
|
228
|
-
const mdEntry = await resolveValue(blocks, current, key);
|
|
229
|
-
if (!mdEntry) {
|
|
230
|
-
// Key doesn't exist yet — bootstrap with firstPut.
|
|
231
|
-
return firstPut(
|
|
232
|
-
newMarkdown,
|
|
233
|
-
current.revision.map((r) => r.event.cid),
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
const { events: eventRGA, root: rgaRoot } = mdEntry;
|
|
237
|
-
|
|
238
|
-
// Create a new event anchored to the current Pail revision heads.
|
|
239
|
-
const mdEvent = new MarkdownEvent(current.revision.map((r) => r.event.cid));
|
|
240
|
-
|
|
241
|
-
// Insert after the last event in weighted order — this places the new event
|
|
242
|
-
// deeper than all existing events in the causal tree, correctly representing
|
|
243
|
-
// that it incorporates all known history.
|
|
244
|
-
const orderedNodes = eventRGA.toWeightedNodes();
|
|
245
|
-
eventRGA.insert(orderedNodes[orderedNodes.length - 1].id, mdEvent, mdEvent);
|
|
246
|
-
|
|
247
|
-
// Diff the current tree against the new markdown and apply.
|
|
248
|
-
const changeset = computeChangeSet(rgaRoot, newMarkdown, mdEvent);
|
|
249
|
-
const comparator = makeComparator(eventRGA);
|
|
250
|
-
const newRoot = applyRGAChangeSet(rgaRoot, changeset, comparator);
|
|
251
|
-
|
|
252
|
-
return serializeMarkdownEntry({
|
|
253
|
-
type: "update",
|
|
254
|
-
root: newRoot,
|
|
255
|
-
events: eventRGA,
|
|
256
|
-
changeset,
|
|
257
|
-
});
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
// ---- Block fetching helpers ----
|
|
261
|
-
|
|
262
|
-
/** Decode a MarkdownEntry (CID pointers) from a DAG-CBOR block. */
|
|
263
|
-
const getMarkdownEntry = async (
|
|
264
|
-
blocks: BlockFetcher,
|
|
265
|
-
mdEntryCid: CID,
|
|
266
|
-
): Promise<MarkdownEntry> => {
|
|
267
|
-
const mdEntryBlock = await blocks.get(mdEntryCid);
|
|
268
|
-
if (!mdEntryBlock) {
|
|
269
|
-
throw new Error(
|
|
270
|
-
`Could not find markdown entry block for CID ${mdEntryCid}`,
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
return (
|
|
274
|
-
await decode({ bytes: mdEntryBlock.bytes, codec: cbor, hasher: sha256 })
|
|
275
|
-
).value as MarkdownEntry;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
/** Fetch and decode the event RGA from its block CID. */
|
|
279
|
-
const getEventRGAFromCID = async (
|
|
280
|
-
blocks: BlockFetcher,
|
|
281
|
-
eventsCid: CID,
|
|
282
|
-
): Promise<EventRGA> => {
|
|
283
|
-
const eventBlock = await blocks.get(eventsCid);
|
|
284
|
-
if (!eventBlock) {
|
|
285
|
-
throw new Error(`Could not find event block for CID ${eventsCid}`);
|
|
286
|
-
}
|
|
287
|
-
const eventRGA = await decodeRGA(
|
|
288
|
-
eventBlock,
|
|
289
|
-
parseMarkdownEvent,
|
|
290
|
-
deserializeMarkdownEvent,
|
|
291
|
-
nohierarchyComparator,
|
|
292
|
-
);
|
|
293
|
-
return eventRGA;
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
/** Fetch and decode an RGAChangeSet from its block CID. */
|
|
297
|
-
const getRGAChangeSetFromCID = async (
|
|
298
|
-
blocks: BlockFetcher,
|
|
299
|
-
changesetCid: CID,
|
|
300
|
-
): Promise<RGAChangeSet<MarkdownEvent>> => {
|
|
301
|
-
const changesetBlock = await blocks.get(changesetCid);
|
|
302
|
-
if (!changesetBlock) {
|
|
303
|
-
throw new Error(`Could not find changeset block for CID ${changesetCid}`);
|
|
304
|
-
}
|
|
305
|
-
return await decodeChangeSet(changesetBlock, parseMarkdownEvent);
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
/** Fetch and decode an RGA tree from its block CID, using the event RGA for ordering. */
|
|
309
|
-
const getRGATreeFromRootCID = async (
|
|
310
|
-
blocks: BlockFetcher,
|
|
311
|
-
rootCid: CID,
|
|
312
|
-
eventRGA: EventRGA,
|
|
313
|
-
): Promise<RGATreeRoot<MarkdownEvent>> => {
|
|
314
|
-
const comparator = makeComparator(eventRGA);
|
|
315
|
-
const rootBlock = await blocks.get(rootCid);
|
|
316
|
-
if (!rootBlock) {
|
|
317
|
-
throw new Error(`Could not find root block for CID ${rootCid}`);
|
|
318
|
-
}
|
|
319
|
-
return await decodeTree(rootBlock, parseMarkdownEvent, comparator);
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Fully deserialize a MarkdownEntry from its CID — fetches and decodes
|
|
324
|
-
* the entry, event RGA, tree, and (if update) changeset.
|
|
325
|
-
*/
|
|
326
|
-
const deserializedMarkdownEntryCID = async (
|
|
327
|
-
blocks: BlockFetcher,
|
|
328
|
-
mdEntryCid: CID,
|
|
329
|
-
): Promise<DeserializedMarkdownEntry> => {
|
|
330
|
-
const mdEntry = await getMarkdownEntry(blocks, mdEntryCid);
|
|
331
|
-
const eventRGA = await getEventRGAFromCID(blocks, mdEntry.events);
|
|
332
|
-
const rgaRoot = await getRGATreeFromRootCID(blocks, mdEntry.root, eventRGA);
|
|
333
|
-
if (mdEntry.type === "initial") {
|
|
334
|
-
return {
|
|
335
|
-
type: "initial",
|
|
336
|
-
root: rgaRoot,
|
|
337
|
-
events: eventRGA,
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const changeset = await getRGAChangeSetFromCID(blocks, mdEntry.changeset);
|
|
342
|
-
return {
|
|
343
|
-
type: "update",
|
|
344
|
-
root: rgaRoot,
|
|
345
|
-
events: eventRGA,
|
|
346
|
-
changeset,
|
|
347
|
-
};
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Serialize a DeserializedMarkdownEntry to DAG-CBOR blocks.
|
|
352
|
-
* Returns the entry's CID and all blocks that need to be stored
|
|
353
|
-
* (event RGA, tree, changeset if update, and the entry itself).
|
|
354
|
-
*/
|
|
355
|
-
const serializeMarkdownEntry = async (
|
|
356
|
-
entry: DeserializedMarkdownEntry,
|
|
357
|
-
): Promise<MarkdownResult> => {
|
|
358
|
-
let blocks: Block[] = [];
|
|
359
|
-
const eventBlock = await encodeRGA(entry.events, serializeMarkdownEvent);
|
|
360
|
-
blocks.push(eventBlock);
|
|
361
|
-
const rootBlock = await encodeTree(entry.root);
|
|
362
|
-
blocks.push(rootBlock);
|
|
363
|
-
const changesetBlock =
|
|
364
|
-
entry.type === "update"
|
|
365
|
-
? await encodeChangeSet(entry.changeset)
|
|
366
|
-
: undefined;
|
|
367
|
-
if (changesetBlock) {
|
|
368
|
-
blocks.push(changesetBlock);
|
|
369
|
-
}
|
|
370
|
-
const mdEntry =
|
|
371
|
-
entry.type === "initial"
|
|
372
|
-
? {
|
|
373
|
-
type: "initial",
|
|
374
|
-
root: rootBlock.cid,
|
|
375
|
-
events: eventBlock.cid,
|
|
376
|
-
}
|
|
377
|
-
: {
|
|
378
|
-
type: "update",
|
|
379
|
-
root: rootBlock.cid,
|
|
380
|
-
events: eventBlock.cid,
|
|
381
|
-
changeset: changesetBlock?.cid,
|
|
382
|
-
};
|
|
383
|
-
const mdEntryBlock = await encode({
|
|
384
|
-
value: mdEntry,
|
|
385
|
-
codec: cbor,
|
|
386
|
-
hasher: sha256,
|
|
387
|
-
});
|
|
388
|
-
blocks.push(mdEntryBlock);
|
|
389
|
-
return {
|
|
390
|
-
mdEntryCid: mdEntryBlock.cid,
|
|
391
|
-
additions: blocks,
|
|
392
|
-
};
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
// ---- Resolution (multi-head merge) ----
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Resolve the current markdown value for a key, merging concurrent heads.
|
|
399
|
-
*
|
|
400
|
-
* If there's only one head (no concurrent writes), returns the entry directly.
|
|
401
|
-
*
|
|
402
|
-
* If there are multiple heads, finds their common ancestor in the Pail's
|
|
403
|
-
* merkle clock, then replays all events from ancestor → heads in causal order.
|
|
404
|
-
* For each "put" event:
|
|
405
|
-
* - If "initial": bootstraps the entry (must be the first event for this key)
|
|
406
|
-
* - If "update": merges the event's causal history into the running event RGA,
|
|
407
|
-
* then applies the changeset to the running tree using the merged comparator
|
|
408
|
-
* For "del" events: clears the entry (file deleted).
|
|
409
|
-
*
|
|
410
|
-
* This is analogous to how Pail resolves concurrent root updates — walk from
|
|
411
|
-
* common ancestor, replay operations in deterministic order.
|
|
412
|
-
*/
|
|
413
|
-
const resolveValue = async (
|
|
414
|
-
blocks: BlockFetcher,
|
|
415
|
-
current: ValueView,
|
|
416
|
-
key: string,
|
|
417
|
-
): Promise<DeserializedMarkdownEntry | undefined> => {
|
|
418
|
-
const mdEntryBlockCid = await Pail.get(blocks, current.root, key);
|
|
419
|
-
if (!mdEntryBlockCid) {
|
|
420
|
-
return undefined;
|
|
421
|
-
}
|
|
422
|
-
// Cache event blocks in memory so EventFetcher can find them.
|
|
423
|
-
blocks = withCache(
|
|
424
|
-
blocks,
|
|
425
|
-
new MemoryBlockstore(current.revision.map((r) => r.event)),
|
|
426
|
-
);
|
|
427
|
-
const events = new EventFetcher<Operation>(blocks);
|
|
428
|
-
|
|
429
|
-
// Fast path: single head, no merge needed.
|
|
430
|
-
if (current.revision.length === 1) {
|
|
431
|
-
return await deserializedMarkdownEntryCID(
|
|
432
|
-
blocks,
|
|
433
|
-
mdEntryBlockCid as CID,
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Multi-head: find common ancestor and replay events in causal order.
|
|
438
|
-
const ancestor = await CRDT.findCommonAncestor(
|
|
439
|
-
events,
|
|
440
|
-
current.revision.map((r) => r.event.cid),
|
|
441
|
-
);
|
|
442
|
-
if (!ancestor) {
|
|
443
|
-
throw new Error(
|
|
444
|
-
`Could not find common ancestor for revision ${current.revision.map((r) => r.event.cid)}`,
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Start from the ancestor's state for this key (if it existed there).
|
|
449
|
-
const aevent = await events.get(ancestor);
|
|
450
|
-
let { root } = aevent.value.data;
|
|
451
|
-
const rootMDEntryCid = await Pail.get(blocks, root, key);
|
|
452
|
-
let mdEntry = rootMDEntryCid
|
|
453
|
-
? await deserializedMarkdownEntryCID(blocks, rootMDEntryCid as CID)
|
|
454
|
-
: undefined;
|
|
455
|
-
|
|
456
|
-
// Get all events from ancestor → heads, sorted in deterministic causal order.
|
|
457
|
-
const sorted = await CRDT.findSortedEvents(
|
|
458
|
-
events,
|
|
459
|
-
current.revision.map((r) => r.event.cid),
|
|
460
|
-
ancestor,
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
// Filter to only events that touch this key.
|
|
464
|
-
const relevantSorted = sorted.filter((e) => {
|
|
465
|
-
const op = e.value.data;
|
|
466
|
-
switch (op.type) {
|
|
467
|
-
case "put":
|
|
468
|
-
return op.key === key;
|
|
469
|
-
case "del":
|
|
470
|
-
return op.key === key;
|
|
471
|
-
case "batch":
|
|
472
|
-
return op.ops.some((o) => o.key === key);
|
|
473
|
-
default:
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
// Replay each event, building up the merged tree and event history.
|
|
479
|
-
for (const { value: event } of relevantSorted) {
|
|
480
|
-
let data = event.data;
|
|
481
|
-
// Reduce batch operations to the single op for this key.
|
|
482
|
-
if (data.type === "batch") {
|
|
483
|
-
const op = data.ops.find((o) => o.key === key);
|
|
484
|
-
if (!op) {
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
data = { ...data, ...op };
|
|
488
|
-
}
|
|
489
|
-
if (data.type === "put") {
|
|
490
|
-
// Fetch the MarkdownEntry that was stored with this event.
|
|
491
|
-
const mdEntryCid = await Pail.get(blocks, data.root, key);
|
|
492
|
-
if (!mdEntryCid) {
|
|
493
|
-
throw new Error(
|
|
494
|
-
`Could not find markdown entry for CID ${data.root} and key ${key}`,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
const newMDEntry = await getMarkdownEntry(blocks, mdEntryCid as CID);
|
|
498
|
-
if (newMDEntry.type === "initial") {
|
|
499
|
-
// First write for this key — bootstrap from the stored entry.
|
|
500
|
-
if (mdEntry) {
|
|
501
|
-
throw new Error(
|
|
502
|
-
`Expected no existing markdown entry for initial event, found ${mdEntryCid}`,
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
const eventRGA = await getEventRGAFromCID(blocks, newMDEntry.events);
|
|
506
|
-
mdEntry = {
|
|
507
|
-
type: "initial",
|
|
508
|
-
events: eventRGA,
|
|
509
|
-
root: await getRGATreeFromRootCID(blocks, newMDEntry.root, eventRGA),
|
|
510
|
-
};
|
|
511
|
-
} else {
|
|
512
|
-
// Update — merge event histories, then apply the changeset.
|
|
513
|
-
if (!mdEntry) {
|
|
514
|
-
throw new Error(
|
|
515
|
-
`Expected existing markdown entry for update event, found none for CID ${mdEntryCid}`,
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
// Merge the event's causal history into our running history.
|
|
519
|
-
// RGA merge is commutative — order of merging doesn't matter.
|
|
520
|
-
const eventRGA = mdEntry.events;
|
|
521
|
-
eventRGA.merge(await getEventRGAFromCID(blocks, newMDEntry.events));
|
|
522
|
-
const changeset = await getRGAChangeSetFromCID(
|
|
523
|
-
blocks,
|
|
524
|
-
newMDEntry.changeset,
|
|
525
|
-
);
|
|
526
|
-
// Rebuild comparator from merged event history, then apply changeset.
|
|
527
|
-
const comparator = makeComparator(eventRGA);
|
|
528
|
-
mdEntry = {
|
|
529
|
-
type: "update",
|
|
530
|
-
events: eventRGA,
|
|
531
|
-
root: applyRGAChangeSet(mdEntry.root, changeset, comparator),
|
|
532
|
-
changeset,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
} else if (data.type === "del") {
|
|
536
|
-
// Key deleted — clear state. If re-created later, it'll be a new "initial".
|
|
537
|
-
mdEntry = undefined;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
return mdEntry;
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Get the current markdown string for a key, resolving concurrent heads.
|
|
545
|
-
* Returns undefined if the key doesn't exist.
|
|
546
|
-
*/
|
|
547
|
-
export const get = async (
|
|
548
|
-
blocks: BlockFetcher,
|
|
549
|
-
current: ValueView,
|
|
550
|
-
key: string,
|
|
551
|
-
): Promise<string | undefined> => {
|
|
552
|
-
const mdEntry = await resolveValue(blocks, current, key);
|
|
553
|
-
if (!mdEntry) {
|
|
554
|
-
return undefined;
|
|
555
|
-
}
|
|
556
|
-
return toMarkdown(mdEntry.root);
|
|
557
|
-
};
|