@storacha/clawracha 0.2.2 → 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.
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * mdsync — CRDT markdown storage on top of UCN Pail.
3
3
  *
4
- * Stores RGA-backed markdown trees at Pail keys. Each key's value is a
5
- * MarkdownEntry containing:
4
+ * Each key's value is a single DAG-CBOR block (DeserializedMarkdownEntry)
5
+ * containing:
6
6
  * - The current RGA tree (full document state)
7
7
  * - An RGA of MarkdownEvents (causal history scoped to this key)
8
8
  * - The last changeset applied (for incremental replay during merge)
9
9
  *
10
10
  * On read, if the Pail has multiple heads (concurrent writes), we resolve
11
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.
12
+ * changesets and merging event RGAs.
14
13
  */
15
14
  import { MemoryBlockstore, withCache } from "@storacha/ucn/block";
16
15
  import * as CRDT from "@web3-storage/pail/crdt";
17
16
  import { CID } from "multiformats";
18
17
  import * as cbor from "@ipld/dag-cbor";
19
- import { fromMarkdown, encodeTree, encodeRGA, RGA, decodeRGA, decodeTree, computeChangeSet, applyRGAChangeSet, encodeChangeSet, decodeChangeSet, toMarkdown, mergeRGATrees, } from "@storacha/md-merge";
18
+ import { fromMarkdown, RGA, computeChangeSet, applyRGAChangeSet, toMarkdown, mergeRGATrees, serializeTree, deserializeTree, serializeRGA, deserializeRGA, serializeChangeSet, deserializeChangeSet as deserializeChangeSetStruct, stripUndefined, } from "@storacha/md-merge";
20
19
  import * as Pail from "@web3-storage/pail";
21
20
  import { decode, encode } from "multiformats/block";
22
21
  import { sha256 } from "multiformats/hashes/sha2";
@@ -69,40 +68,78 @@ const nohierarchyComparator = (a, b) => a.toString() === b.toString() ? 0 : a.to
69
68
  /**
70
69
  * Build a comparator from the event RGA's weighted BFS ordering.
71
70
  * Events earlier in the BFS (closer to the root of the causal tree)
72
- * compare as "less than" later events. This is used as the EventComparator
73
- * for all RGA tree operations — it determines how concurrent inserts are
74
- * ordered in the document.
71
+ * compare as "less than" later events.
75
72
  */
76
73
  const makeComparator = (events) => {
77
74
  const ordered = events.toWeightedArray();
78
75
  const index = new Map(ordered.map((e, i) => [e.toString(), i]));
79
76
  return (a, b) => (index.get(a.toString()) ?? -1) - (index.get(b.toString()) ?? -1);
80
77
  };
78
+ // ---- Single-block serialization ----
81
79
  /**
82
- * Create the first MarkdownEntry for a key bootstraps the RGA tree and
80
+ * Encode a DeserializedMarkdownEntry as a single DAG-CBOR block.
81
+ * All data (tree, events, changeset) is inlined — no CID references.
82
+ */
83
+ export const encodeMarkdownEntry = async (entry) => {
84
+ const flat = {
85
+ type: entry.type,
86
+ root: serializeTree(entry.root),
87
+ events: serializeRGA(entry.events, serializeMarkdownEvent),
88
+ };
89
+ if (entry.type === "update") {
90
+ flat.changeset = serializeChangeSet(entry.changeset);
91
+ }
92
+ return await encode({
93
+ value: stripUndefined(flat),
94
+ codec: cbor,
95
+ hasher: sha256,
96
+ });
97
+ };
98
+ /**
99
+ * Decode a single DAG-CBOR block back to a DeserializedMarkdownEntry.
100
+ */
101
+ export const decodeMarkdownEntry = async (block) => {
102
+ const decoded = await decode({
103
+ bytes: block.bytes,
104
+ codec: cbor,
105
+ hasher: sha256,
106
+ });
107
+ const flat = decoded.value;
108
+ const eventRGA = deserializeRGA(flat.events, parseMarkdownEvent, deserializeMarkdownEvent, nohierarchyComparator);
109
+ const comparator = makeComparator(eventRGA);
110
+ const root = deserializeTree(flat.root, parseMarkdownEvent, comparator);
111
+ if (flat.type === "initial") {
112
+ return { type: "initial", root, events: eventRGA };
113
+ }
114
+ const changeset = deserializeChangeSetStruct(flat.changeset, parseMarkdownEvent);
115
+ return { type: "update", root, events: eventRGA, changeset };
116
+ };
117
+ // ---- Core helpers ----
118
+ /**
119
+ * Create the first entry for a key — bootstraps the RGA tree and
83
120
  * event RGA from raw markdown. Used when a key doesn't exist yet.
84
121
  */
85
- const firstPut = async (newMarkdown, parents) => {
122
+ const firstPut = (newMarkdown, parents) => {
86
123
  const markdownEvent = new MarkdownEvent(parents);
87
124
  const eventRGA = new RGA(nohierarchyComparator);
88
125
  eventRGA.insert(undefined, markdownEvent, markdownEvent);
89
- const eventBlock = await encodeRGA(eventRGA, serializeMarkdownEvent);
90
126
  // Only one event, so comparator is trivial (all nodes have the same event).
91
127
  const rgaRoot = fromMarkdown(newMarkdown, markdownEvent, (a, b) => 0);
92
- return serializeMarkdownEntry({
128
+ return {
93
129
  type: "initial",
94
130
  root: rgaRoot,
95
131
  events: eventRGA,
96
- });
132
+ };
97
133
  };
98
134
  // ---- Public API ----
99
135
  /**
100
136
  * First put into an empty Pail (v0 = no existing revision).
101
- * Returns the markdown entry CID and blocks to store. Caller is
102
- * responsible for creating the Pail revision via Revision.v0Put.
137
+ * Returns a single block to store. Caller is responsible for
138
+ * creating the Pail revision via Revision.v0Put.
103
139
  */
104
140
  export const v0Put = async (newMarkdown) => {
105
- return firstPut(newMarkdown, []);
141
+ const entry = firstPut(newMarkdown, []);
142
+ return encodeMarkdownEntry(entry);
106
143
  };
107
144
  /**
108
145
  * Put markdown content at a key in an existing Pail.
@@ -112,135 +149,36 @@ export const v0Put = async (newMarkdown) => {
112
149
  * an RGA changeset against the resolved tree, applies it, and stores the
113
150
  * updated entry.
114
151
  *
115
- * Returns the markdown entry CID and blocks to store. Caller is
116
- * responsible for creating the Pail revision via Revision.put.
152
+ * Returns a single block to store, or null if no changes detected.
153
+ * Caller is responsible for creating the Pail revision via Revision.put.
117
154
  */
118
155
  export const put = async (blocks, current, key, newMarkdown) => {
119
156
  const mdEntry = await resolveValue(blocks, current, key);
120
157
  if (!mdEntry) {
121
158
  // Key doesn't exist yet — bootstrap with firstPut.
122
- return firstPut(newMarkdown, current.revision.map((r) => r.event.cid));
159
+ const entry = firstPut(newMarkdown, current.revision.map((r) => r.event.cid));
160
+ return encodeMarkdownEntry(entry);
123
161
  }
124
162
  const { events: eventRGA, root: rgaRoot } = mdEntry;
125
163
  // Create a new event anchored to the current Pail revision heads.
126
164
  const mdEvent = new MarkdownEvent(current.revision.map((r) => r.event.cid));
127
- // Insert after the last event in weighted order — this places the new event
128
- // deeper than all existing events in the causal tree, correctly representing
129
- // that it incorporates all known history.
165
+ // Insert after the last event in weighted order.
130
166
  const orderedNodes = eventRGA.toWeightedNodes();
131
167
  eventRGA.insert(orderedNodes[orderedNodes.length - 1].id, mdEvent, mdEvent);
132
168
  // Diff the current tree against the new markdown and apply.
133
169
  const changeset = computeChangeSet(rgaRoot, newMarkdown, mdEvent);
134
170
  if (changeset.changes.length === 0) {
135
- return null; // No changes to apply, skip writing a new entry.
171
+ return null; // No changes to apply.
136
172
  }
137
173
  const comparator = makeComparator(eventRGA);
138
174
  const newRoot = applyRGAChangeSet(rgaRoot, changeset, comparator);
139
- return serializeMarkdownEntry({
175
+ return encodeMarkdownEntry({
140
176
  type: "update",
141
177
  root: newRoot,
142
178
  events: eventRGA,
143
179
  changeset,
144
180
  });
145
181
  };
146
- // ---- Block fetching helpers ----
147
- /** Decode a MarkdownEntry (CID pointers) from a DAG-CBOR block. */
148
- const getMarkdownEntry = async (blocks, mdEntryCid) => {
149
- const mdEntryBlock = await blocks.get(mdEntryCid);
150
- if (!mdEntryBlock) {
151
- throw new Error(`Could not find markdown entry block for CID ${mdEntryCid}`);
152
- }
153
- return (await decode({ bytes: mdEntryBlock.bytes, codec: cbor, hasher: sha256 })).value;
154
- };
155
- /** Fetch and decode the event RGA from its block CID. */
156
- const getEventRGAFromCID = async (blocks, eventsCid) => {
157
- const eventBlock = await blocks.get(eventsCid);
158
- if (!eventBlock) {
159
- throw new Error(`Could not find event block for CID ${eventsCid}`);
160
- }
161
- const eventRGA = await decodeRGA(eventBlock, parseMarkdownEvent, deserializeMarkdownEvent, nohierarchyComparator);
162
- return eventRGA;
163
- };
164
- /** Fetch and decode an RGAChangeSet from its block CID. */
165
- const getRGAChangeSetFromCID = async (blocks, changesetCid) => {
166
- const changesetBlock = await blocks.get(changesetCid);
167
- if (!changesetBlock) {
168
- throw new Error(`Could not find changeset block for CID ${changesetCid}`);
169
- }
170
- return await decodeChangeSet(changesetBlock, parseMarkdownEvent);
171
- };
172
- /** Fetch and decode an RGA tree from its block CID, using the event RGA for ordering. */
173
- const getRGATreeFromRootCID = async (blocks, rootCid, eventRGA) => {
174
- const comparator = makeComparator(eventRGA);
175
- const rootBlock = await blocks.get(rootCid);
176
- if (!rootBlock) {
177
- throw new Error(`Could not find root block for CID ${rootCid}`);
178
- }
179
- return await decodeTree(rootBlock, parseMarkdownEvent, comparator);
180
- };
181
- /**
182
- * Fully deserialize a MarkdownEntry from its CID — fetches and decodes
183
- * the entry, event RGA, tree, and (if update) changeset.
184
- */
185
- const deserializedMarkdownEntryCID = async (blocks, mdEntryCid) => {
186
- const mdEntry = await getMarkdownEntry(blocks, mdEntryCid);
187
- const eventRGA = await getEventRGAFromCID(blocks, mdEntry.events);
188
- const rgaRoot = await getRGATreeFromRootCID(blocks, mdEntry.root, eventRGA);
189
- if (mdEntry.type === "initial") {
190
- return {
191
- type: "initial",
192
- root: rgaRoot,
193
- events: eventRGA,
194
- };
195
- }
196
- const changeset = await getRGAChangeSetFromCID(blocks, mdEntry.changeset);
197
- return {
198
- type: "update",
199
- root: rgaRoot,
200
- events: eventRGA,
201
- changeset,
202
- };
203
- };
204
- /**
205
- * Serialize a DeserializedMarkdownEntry to DAG-CBOR blocks.
206
- * Returns the entry's CID and all blocks that need to be stored
207
- * (event RGA, tree, changeset if update, and the entry itself).
208
- */
209
- const serializeMarkdownEntry = async (entry) => {
210
- let blocks = [];
211
- const eventBlock = await encodeRGA(entry.events, serializeMarkdownEvent);
212
- blocks.push(eventBlock);
213
- const rootBlock = await encodeTree(entry.root);
214
- blocks.push(rootBlock);
215
- const changesetBlock = entry.type === "update"
216
- ? await encodeChangeSet(entry.changeset)
217
- : undefined;
218
- if (changesetBlock) {
219
- blocks.push(changesetBlock);
220
- }
221
- const mdEntry = entry.type === "initial"
222
- ? {
223
- type: "initial",
224
- root: rootBlock.cid,
225
- events: eventBlock.cid,
226
- }
227
- : {
228
- type: "update",
229
- root: rootBlock.cid,
230
- events: eventBlock.cid,
231
- changeset: changesetBlock?.cid,
232
- };
233
- const mdEntryBlock = await encode({
234
- value: mdEntry,
235
- codec: cbor,
236
- hasher: sha256,
237
- });
238
- blocks.push(mdEntryBlock);
239
- return {
240
- mdEntryCid: mdEntryBlock.cid,
241
- additions: blocks,
242
- };
243
- };
244
182
  // ---- Resolution (multi-head merge) ----
245
183
  /**
246
184
  * Resolve the current markdown value for a key, merging concurrent heads.
@@ -249,16 +187,8 @@ const serializeMarkdownEntry = async (entry) => {
249
187
  *
250
188
  * If there are multiple heads, finds their common ancestor in the Pail's
251
189
  * merkle clock, then replays all events from ancestor → heads in causal order.
252
- * For each "put" event:
253
- * - If "initial": bootstraps the entry (must be the first event for this key)
254
- * - If "update": merges the event's causal history into the running event RGA,
255
- * then applies the changeset to the running tree using the merged comparator
256
- * For "del" events: clears the entry (file deleted).
257
- *
258
- * This is analogous to how Pail resolves concurrent root updates — walk from
259
- * common ancestor, replay operations in deterministic order.
260
190
  */
261
- const resolveValue = async (blocks, current, key) => {
191
+ const resolveValue = async (blocks, current, key, decrypt) => {
262
192
  const mdEntryBlockCid = await Pail.get(blocks, current.root, key);
263
193
  if (!mdEntryBlockCid) {
264
194
  return undefined;
@@ -266,9 +196,18 @@ const resolveValue = async (blocks, current, key) => {
266
196
  // Cache event blocks in memory so EventFetcher can find them.
267
197
  blocks = withCache(blocks, new MemoryBlockstore(current.revision.map((r) => r.event)));
268
198
  const events = new EventFetcher(blocks);
199
+ // Fetch entry bytes: decrypt callback for private spaces, or raw block fetch.
200
+ const getEntryBytes = async (cid) => {
201
+ if (decrypt)
202
+ return decrypt(cid);
203
+ const block = await blocks.get(cid);
204
+ if (!block)
205
+ throw new Error(`Could not find block for CID ${cid}`);
206
+ return block.bytes;
207
+ };
269
208
  // Fast path: single head, no merge needed.
270
209
  if (current.revision.length === 1) {
271
- return await deserializedMarkdownEntryCID(blocks, mdEntryBlockCid);
210
+ return decodeMarkdownEntry({ bytes: await getEntryBytes(mdEntryBlockCid) });
272
211
  }
273
212
  // Multi-head: find common ancestor and replay events in causal order.
274
213
  const ancestor = await CRDT.findCommonAncestor(events, current.revision.map((r) => r.event.cid));
@@ -279,9 +218,10 @@ const resolveValue = async (blocks, current, key) => {
279
218
  const aevent = await events.get(ancestor);
280
219
  let { root } = aevent.value.data;
281
220
  const rootMDEntryCid = await Pail.get(blocks, root, key);
282
- let mdEntry = rootMDEntryCid
283
- ? await deserializedMarkdownEntryCID(blocks, rootMDEntryCid)
284
- : undefined;
221
+ let mdEntry;
222
+ if (rootMDEntryCid) {
223
+ mdEntry = await decodeMarkdownEntry({ bytes: await getEntryBytes(rootMDEntryCid) });
224
+ }
285
225
  // Get all events from ancestor → heads, sorted in deterministic causal order.
286
226
  const sorted = await CRDT.findSortedEvents(events, current.revision.map((r) => r.event.cid), ancestor);
287
227
  // Filter to only events that touch this key.
@@ -310,33 +250,24 @@ const resolveValue = async (blocks, current, key) => {
310
250
  data = { ...data, ...op };
311
251
  }
312
252
  if (data.type === "put") {
313
- // Fetch the MarkdownEntry that was stored with this event.
314
253
  const mdEntryCid = await Pail.get(blocks, data.root, key);
315
254
  if (!mdEntryCid) {
316
255
  throw new Error(`Could not find markdown entry for CID ${data.root} and key ${key}`);
317
256
  }
318
- const newMDEntry = await getMarkdownEntry(blocks, mdEntryCid);
257
+ const newMDEntry = await decodeMarkdownEntry({ bytes: await getEntryBytes(mdEntryCid) });
319
258
  if (newMDEntry.type === "initial") {
320
- // First write for this key — bootstrap from the stored entry.
321
- const newEventRGA = await getEventRGAFromCID(blocks, newMDEntry.events);
322
- const newRoot = await getRGATreeFromRootCID(blocks, newMDEntry.root, newEventRGA);
323
259
  if (mdEntry) {
324
- // Concurrent initial — two branches independently created this key.
325
- // Merge event histories and merge the two RGA trees.
326
- mdEntry.events.merge(newEventRGA);
260
+ // Concurrent initial — merge event histories and RGA trees.
261
+ mdEntry.events.merge(newMDEntry.events);
327
262
  const comparator = makeComparator(mdEntry.events);
328
263
  mdEntry = {
329
264
  type: "initial",
330
265
  events: mdEntry.events,
331
- root: mergeRGATrees(mdEntry.root, newRoot, comparator),
266
+ root: mergeRGATrees(mdEntry.root, newMDEntry.root, comparator),
332
267
  };
333
268
  }
334
269
  else {
335
- mdEntry = {
336
- type: "initial",
337
- events: newEventRGA,
338
- root: newRoot,
339
- };
270
+ mdEntry = newMDEntry;
340
271
  }
341
272
  }
342
273
  else {
@@ -344,23 +275,17 @@ const resolveValue = async (blocks, current, key) => {
344
275
  if (!mdEntry) {
345
276
  throw new Error(`Expected existing markdown entry for update event, found none for CID ${mdEntryCid}`);
346
277
  }
347
- // Merge the event's causal history into our running history.
348
- // RGA merge is commutative — order of merging doesn't matter.
349
- const eventRGA = mdEntry.events;
350
- eventRGA.merge(await getEventRGAFromCID(blocks, newMDEntry.events));
351
- const changeset = await getRGAChangeSetFromCID(blocks, newMDEntry.changeset);
352
- // Rebuild comparator from merged event history, then apply changeset.
353
- const comparator = makeComparator(eventRGA);
278
+ mdEntry.events.merge(newMDEntry.events);
279
+ const comparator = makeComparator(mdEntry.events);
354
280
  mdEntry = {
355
281
  type: "update",
356
- events: eventRGA,
357
- root: applyRGAChangeSet(mdEntry.root, changeset, comparator),
358
- changeset,
282
+ events: mdEntry.events,
283
+ root: applyRGAChangeSet(mdEntry.root, newMDEntry.changeset, comparator),
284
+ changeset: newMDEntry.changeset,
359
285
  };
360
286
  }
361
287
  }
362
288
  else if (data.type === "del") {
363
- // Key deleted — clear state. If re-created later, it'll be a new "initial".
364
289
  mdEntry = undefined;
365
290
  }
366
291
  }
@@ -370,8 +295,8 @@ const resolveValue = async (blocks, current, key) => {
370
295
  * Get the current markdown string for a key, resolving concurrent heads.
371
296
  * Returns undefined if the key doesn't exist.
372
297
  */
373
- export const get = async (blocks, current, key) => {
374
- const mdEntry = await resolveValue(blocks, current, key);
298
+ export const get = async (blocks, current, key, decrypt) => {
299
+ const mdEntry = await resolveValue(blocks, current, key, decrypt);
375
300
  if (!mdEntry) {
376
301
  return undefined;
377
302
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EACV,iBAAiB,EAGlB,MAAM,qBAAqB,CAAC;AAkC7B,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,GAAG,EAAE,iBAAiB,QAspBpD"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EACV,iBAAiB,EAGlB,MAAM,qBAAqB,CAAC;AA0B7B,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,GAAG,EAAE,iBAAiB,QAqkBpD"}