···11+import * as CBOR from '@atcute/cbor';
22+import type { CidLink } from '@atcute/cid';
33+import { allocUnsafe } from '@atcute/uint8array';
44+import * as varint from '@atcute/varint';
55+66+/**
77+ * Encodes a number as an unsigned varint (variable-length integer)
88+ * @param n the number to encode
99+ * @returns the varint-encoded bytes
1010+ */
1111+const encodeVarint = (n: number): Uint8Array<ArrayBuffer> => {
1212+ const length = varint.encodingLength(n);
1313+ const buf = allocUnsafe(length);
1414+ varint.encode(n, buf, 0);
1515+ return buf;
1616+};
1717+1818+/**
1919+ * Serializes a CAR v1 header
2020+ * @param roots array of root CIDs (typically just one)
2121+ * @returns the serialized header bytes
2222+ */
2323+export const serializeCarHeader = (roots: readonly CidLink[]): Uint8Array<ArrayBuffer> => {
2424+ const headerData = CBOR.encode({
2525+ version: 1,
2626+ roots: roots,
2727+ });
2828+2929+ const headerSize = encodeVarint(headerData.length);
3030+ const result = allocUnsafe(headerSize.length + headerData.length);
3131+3232+ result.set(headerSize, 0);
3333+ result.set(headerData, headerSize.length);
3434+3535+ return result;
3636+};
3737+3838+/**
3939+ * Serializes a single CAR entry (block)
4040+ * @param cid the CID of the block (as bytes)
4141+ * @param data the block data
4242+ * @returns the serialized entry bytes
4343+ */
4444+export const serializeCarEntry = (cid: Uint8Array, data: Uint8Array): Uint8Array<ArrayBuffer> => {
4545+ const entrySize = encodeVarint(cid.length + data.length);
4646+ const result = allocUnsafe(entrySize.length + cid.length + data.length);
4747+4848+ result.set(entrySize, 0);
4949+ result.set(cid, entrySize.length);
5050+ result.set(data, entrySize.length + cid.length);
5151+5252+ return result;
5353+};
5454+5555+/**
5656+ * Represents a block to be written to a CAR file
5757+ */
5858+export interface CarBlock {
5959+ /** the CID of the block (as bytes) */
6060+ cid: Uint8Array;
6161+ /** the block data */
6262+ data: Uint8Array;
6363+}
6464+6565+/**
6666+ * Creates an async generator that yields CAR file chunks
6767+ * @param root the root CID for the CAR file
6868+ * @param blocks async iterable of blocks to write
6969+ * @yields Uint8Array chunks of the CAR file (header, then entries)
7070+ *
7171+ * @example
7272+ * ```typescript
7373+ * const blocks = async function* () {
7474+ * yield { cid: commitCid.bytes, data: commitBytes };
7575+ * yield { cid: nodeCid.bytes, data: nodeBytes };
7676+ * };
7777+ *
7878+ * // Stream chunks
7979+ * for await (const chunk of createCarStream(rootCid, blocks())) {
8080+ * stream.write(chunk);
8181+ * }
8282+ *
8383+ * // Or collect into array (requires Array.fromAsync or polyfill)
8484+ * const chunks = await Array.fromAsync(createCarStream(rootCid, blocks()));
8585+ * ```
8686+ */
8787+export async function* createCarStream(
8888+ root: CidLink,
8989+ blocks: AsyncIterable<CarBlock> | Iterable<CarBlock>,
9090+): AsyncGenerator<Uint8Array<ArrayBuffer>> {
9191+ // Emit header first
9292+ yield serializeCarHeader([root]);
9393+9494+ // Then emit each block entry
9595+ for await (const block of blocks) {
9696+ yield serializeCarEntry(block.cid, block.data);
9797+ }
9898+}
+259
packages/utilities/mst/lib/diff.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import * as CID from '@atcute/cid';
44+import { encodeUtf8 } from '@atcute/uint8array';
55+66+import { DeltaType, mstDiff, recordDiff, verySlowMstDiff } from './diff.js';
77+import { NodeStore } from './node-store.js';
88+import { NodeWrangler } from './node-wrangler.js';
99+import { MemoryBlockStore } from './stores.js';
1010+1111+const createCid = async (data: string) => {
1212+ const bytes = encodeUtf8(data);
1313+ return CID.toCidLink(await CID.create(0x55, bytes));
1414+};
1515+1616+describe('mstDiff', () => {
1717+ it('should detect created records', async () => {
1818+ const store = new NodeStore(new MemoryBlockStore());
1919+ const wrangler = new NodeWrangler(store);
2020+2121+ // Build tree A with 2 records
2222+ let rootA: string | null = null;
2323+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
2424+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
2525+2626+ // Build tree B with 3 records (added c/3)
2727+ let rootB: string | null = null;
2828+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
2929+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2'));
3030+ rootB = await wrangler.putRecord(rootB, 'c/3', await createCid('value-c3'));
3131+3232+ const [created, deleted] = await mstDiff(store, rootA, rootB);
3333+3434+ expect(created.size).toBeGreaterThan(0);
3535+ // Note: deleted might contain the old root node that was replaced
3636+ // expect(deleted.size).toBe(0);
3737+3838+ // Verify record diff
3939+ const deltas = [];
4040+ for await (const delta of recordDiff(store, created, deleted)) {
4141+ deltas.push(delta);
4242+ }
4343+4444+ expect(deltas.length).toBe(1);
4545+ expect(deltas[0].deltaType).toBe(DeltaType.CREATED);
4646+ expect(deltas[0].path).toBe('c/3');
4747+ expect(deltas[0].priorValue).toBe(null);
4848+ expect(deltas[0].laterValue).not.toBe(null);
4949+ });
5050+5151+ it('should detect deleted records', async () => {
5252+ const store = new NodeStore(new MemoryBlockStore());
5353+ const wrangler = new NodeWrangler(store);
5454+5555+ // Build tree A with 3 records
5656+ let rootA: string | null = null;
5757+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
5858+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
5959+ rootA = await wrangler.putRecord(rootA, 'c/3', await createCid('value-c3'));
6060+6161+ // Build tree B with 2 records (deleted c/3)
6262+ let rootB: string | null = null;
6363+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
6464+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2'));
6565+6666+ const [created, deleted] = await mstDiff(store, rootA, rootB);
6767+6868+ // Note: created might contain the new root node that was created
6969+ // expect(created.size).toBe(0);
7070+ expect(deleted.size).toBeGreaterThan(0);
7171+7272+ // Verify record diff
7373+ const deltas = [];
7474+ for await (const delta of recordDiff(store, created, deleted)) {
7575+ deltas.push(delta);
7676+ }
7777+7878+ expect(deltas.length).toBe(1);
7979+ expect(deltas[0].deltaType).toBe(DeltaType.DELETED);
8080+ expect(deltas[0].path).toBe('c/3');
8181+ expect(deltas[0].priorValue).not.toBe(null);
8282+ expect(deltas[0].laterValue).toBe(null);
8383+ });
8484+8585+ it('should detect updated records', async () => {
8686+ const store = new NodeStore(new MemoryBlockStore());
8787+ const wrangler = new NodeWrangler(store);
8888+8989+ // Build tree A
9090+ let rootA: string | null = null;
9191+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
9292+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
9393+9494+ // Build tree B with updated value for b/2
9595+ let rootB: string | null = null;
9696+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
9797+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2-updated'));
9898+9999+ const [created, deleted] = await mstDiff(store, rootA, rootB);
100100+101101+ expect(created.size).toBeGreaterThan(0);
102102+ expect(deleted.size).toBeGreaterThan(0);
103103+104104+ // Verify record diff
105105+ const deltas = [];
106106+ for await (const delta of recordDiff(store, created, deleted)) {
107107+ deltas.push(delta);
108108+ }
109109+110110+ expect(deltas.length).toBe(1);
111111+ expect(deltas[0].deltaType).toBe(DeltaType.UPDATED);
112112+ expect(deltas[0].path).toBe('b/2');
113113+ expect(deltas[0].priorValue).not.toBe(null);
114114+ expect(deltas[0].laterValue).not.toBe(null);
115115+ expect(deltas[0].priorValue?.$link).not.toBe(deltas[0].laterValue?.$link);
116116+ });
117117+118118+ it('should handle identical trees', async () => {
119119+ const store = new NodeStore(new MemoryBlockStore());
120120+ const wrangler = new NodeWrangler(store);
121121+122122+ let root: string | null = null;
123123+ root = await wrangler.putRecord(root, 'a/1', await createCid('value-a1'));
124124+ root = await wrangler.putRecord(root, 'b/2', await createCid('value-b2'));
125125+126126+ const [created, deleted] = await mstDiff(store, root, root);
127127+128128+ expect(created.size).toBe(0);
129129+ expect(deleted.size).toBe(0);
130130+131131+ const deltas = [];
132132+ for await (const delta of recordDiff(store, created, deleted)) {
133133+ deltas.push(delta);
134134+ }
135135+136136+ expect(deltas.length).toBe(0);
137137+ });
138138+139139+ it('should handle empty to non-empty tree', async () => {
140140+ const store = new NodeStore(new MemoryBlockStore());
141141+ const wrangler = new NodeWrangler(store);
142142+143143+ const emptyRoot = (await store.get(null).then((n) => n.cid())).$link;
144144+145145+ let rootB: string | null = null;
146146+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
147147+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2'));
148148+149149+ const [created, deleted] = await mstDiff(store, emptyRoot, rootB);
150150+151151+ expect(created.size).toBeGreaterThan(0);
152152+153153+ const deltas = [];
154154+ for await (const delta of recordDiff(store, created, deleted)) {
155155+ deltas.push(delta);
156156+ }
157157+158158+ expect(deltas.length).toBe(2);
159159+ expect(deltas.every((d) => d.deltaType === DeltaType.CREATED)).toBe(true);
160160+ });
161161+162162+ it('should handle non-empty to empty tree', async () => {
163163+ const store = new NodeStore(new MemoryBlockStore());
164164+ const wrangler = new NodeWrangler(store);
165165+166166+ let rootA: string | null = null;
167167+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
168168+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
169169+170170+ const emptyRoot = (await store.get(null).then((n) => n.cid())).$link;
171171+172172+ const [created, deleted] = await mstDiff(store, rootA, emptyRoot);
173173+174174+ expect(deleted.size).toBeGreaterThan(0);
175175+176176+ const deltas = [];
177177+ for await (const delta of recordDiff(store, created, deleted)) {
178178+ deltas.push(delta);
179179+ }
180180+181181+ expect(deltas.length).toBe(2);
182182+ expect(deltas.every((d) => d.deltaType === DeltaType.DELETED)).toBe(true);
183183+ });
184184+185185+ it('should handle multiple operations', async () => {
186186+ const store = new NodeStore(new MemoryBlockStore());
187187+ const wrangler = new NodeWrangler(store);
188188+189189+ // Tree A: a/1, b/2, c/3
190190+ let rootA: string | null = null;
191191+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
192192+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
193193+ rootA = await wrangler.putRecord(rootA, 'c/3', await createCid('value-c3'));
194194+195195+ // Tree B: a/1 (same), b/2 (updated), d/4 (new), c/3 deleted
196196+ let rootB: string | null = null;
197197+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
198198+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2-updated'));
199199+ rootB = await wrangler.putRecord(rootB, 'd/4', await createCid('value-d4'));
200200+201201+ const [created, deleted] = await mstDiff(store, rootA, rootB);
202202+203203+ const deltas = [];
204204+ for await (const delta of recordDiff(store, created, deleted)) {
205205+ deltas.push(delta);
206206+ }
207207+208208+ // Should have: 1 created (d/4), 1 updated (b/2), 1 deleted (c/3)
209209+ expect(deltas.length).toBe(3);
210210+211211+ const deltasByType = {
212212+ [DeltaType.CREATED]: deltas.filter((d) => d.deltaType === DeltaType.CREATED),
213213+ [DeltaType.UPDATED]: deltas.filter((d) => d.deltaType === DeltaType.UPDATED),
214214+ [DeltaType.DELETED]: deltas.filter((d) => d.deltaType === DeltaType.DELETED),
215215+ };
216216+217217+ expect(deltasByType[DeltaType.CREATED].length).toBe(1);
218218+ expect(deltasByType[DeltaType.CREATED][0].path).toBe('d/4');
219219+220220+ expect(deltasByType[DeltaType.UPDATED].length).toBe(1);
221221+ expect(deltasByType[DeltaType.UPDATED][0].path).toBe('b/2');
222222+223223+ expect(deltasByType[DeltaType.DELETED].length).toBe(1);
224224+ expect(deltasByType[DeltaType.DELETED][0].path).toBe('c/3');
225225+ });
226226+});
227227+228228+describe('verySlowMstDiff', () => {
229229+ it('should match mstDiff results', async () => {
230230+ const store = new NodeStore(new MemoryBlockStore());
231231+ const wrangler = new NodeWrangler(store);
232232+233233+ // Build two different trees
234234+ let rootA: string | null = null;
235235+ rootA = await wrangler.putRecord(rootA, 'a/1', await createCid('value-a1'));
236236+ rootA = await wrangler.putRecord(rootA, 'b/2', await createCid('value-b2'));
237237+ rootA = await wrangler.putRecord(rootA, 'c/3', await createCid('value-c3'));
238238+239239+ let rootB: string | null = null;
240240+ rootB = await wrangler.putRecord(rootB, 'a/1', await createCid('value-a1'));
241241+ rootB = await wrangler.putRecord(rootB, 'b/2', await createCid('value-b2-updated'));
242242+ rootB = await wrangler.putRecord(rootB, 'd/4', await createCid('value-d4'));
243243+244244+ const [createdFast, deletedFast] = await mstDiff(store, rootA, rootB);
245245+ const [createdSlow, deletedSlow] = await verySlowMstDiff(store, rootA, rootB);
246246+247247+ // Both should produce the same sets
248248+ expect(createdFast.size).toBe(createdSlow.size);
249249+ expect(deletedFast.size).toBe(deletedSlow.size);
250250+251251+ for (const cid of createdFast) {
252252+ expect(createdSlow.has(cid)).toBe(true);
253253+ }
254254+255255+ for (const cid of deletedFast) {
256256+ expect(deletedSlow.has(cid)).toBe(true);
257257+ }
258258+ });
259259+});
+292
packages/utilities/mst/lib/diff.ts
···11+import type { CidLink } from '@atcute/cid';
22+33+import { MSTNode } from './node.js';
44+import type { NodeStore } from './node-store.js';
55+import { NodeWalker } from './node-walker.js';
66+77+/**
88+ * Type of change to a record
99+ */
1010+export enum DeltaType {
1111+ CREATED = 1,
1212+ UPDATED = 2,
1313+ DELETED = 3,
1414+}
1515+1616+/**
1717+ * Represents a change to a single record
1818+ */
1919+export interface RecordDelta {
2020+ /** type of change */
2121+ deltaType: DeltaType;
2222+ /** record path (collection/rkey) */
2323+ path: string;
2424+ /** CID before the change (null for creates) */
2525+ priorValue: CidLink | null;
2626+ /** CID after the change (null for deletes) */
2727+ laterValue: CidLink | null;
2828+}
2929+3030+/**
3131+ * Given two sets of MST nodes, returns an iterator of record-level changes
3232+ * @param ns the node store
3333+ * @param created set of node CIDs that were created
3434+ * @param deleted set of node CIDs that were deleted
3535+ * @yields record deltas describing the changes
3636+ */
3737+export async function* recordDiff(
3838+ ns: NodeStore,
3939+ created: Set<string>,
4040+ deleted: Set<string>,
4141+): AsyncGenerator<RecordDelta> {
4242+ // Build maps of all keys and values in created/deleted nodes
4343+ const createdKv = new Map<string, CidLink>();
4444+ for (const cid of created) {
4545+ const node = await ns.get(cid);
4646+ for (let i = 0; i < node.keys.length; i++) {
4747+ createdKv.set(node.keys[i], node.values[i]);
4848+ }
4949+ }
5050+5151+ const deletedKv = new Map<string, CidLink>();
5252+ for (const cid of deleted) {
5353+ const node = await ns.get(cid);
5454+ for (let i = 0; i < node.keys.length; i++) {
5555+ deletedKv.set(node.keys[i], node.values[i]);
5656+ }
5757+ }
5858+5959+ // Find keys that were created (in created but not deleted)
6060+ for (const [key, value] of createdKv) {
6161+ if (!deletedKv.has(key)) {
6262+ yield {
6363+ deltaType: DeltaType.CREATED,
6464+ path: key,
6565+ priorValue: null,
6666+ laterValue: value,
6767+ };
6868+ }
6969+ }
7070+7171+ // Find keys that were updated (in both, but with different values)
7272+ for (const [key, newValue] of createdKv) {
7373+ const oldValue = deletedKv.get(key);
7474+ if (oldValue !== undefined && oldValue.$link !== newValue.$link) {
7575+ yield {
7676+ deltaType: DeltaType.UPDATED,
7777+ path: key,
7878+ priorValue: oldValue,
7979+ laterValue: newValue,
8080+ };
8181+ }
8282+ }
8383+8484+ // Find keys that were deleted (in deleted but not created)
8585+ for (const [key, value] of deletedKv) {
8686+ if (!createdKv.has(key)) {
8787+ yield {
8888+ deltaType: DeltaType.DELETED,
8989+ path: key,
9090+ priorValue: value,
9191+ laterValue: null,
9292+ };
9393+ }
9494+ }
9595+}
9696+9797+/**
9898+ * Slow but obvious MST diff implementation for testing
9999+ * Enumerates all nodes in both trees and compares them
100100+ * @param ns the node store
101101+ * @param rootA CID of first MST root
102102+ * @param rootB CID of second MST root
103103+ * @returns tuple of [created nodes, deleted nodes]
104104+ */
105105+export const verySlowMstDiff = async (
106106+ ns: NodeStore,
107107+ rootA: string,
108108+ rootB: string,
109109+): Promise<[Set<string>, Set<string>]> => {
110110+ const walkerA = await NodeWalker.create(ns, rootA);
111111+ const walkerB = await NodeWalker.create(ns, rootB);
112112+113113+ const nodesA = new Set<string>();
114114+ for await (const cid of iterNodeCids(walkerA)) {
115115+ nodesA.add(cid);
116116+ }
117117+118118+ const nodesB = new Set<string>();
119119+ for await (const cid of iterNodeCids(walkerB)) {
120120+ nodesB.add(cid);
121121+ }
122122+123123+ const created = new Set<string>();
124124+ for (const cid of nodesB) {
125125+ if (!nodesA.has(cid)) {
126126+ created.add(cid);
127127+ }
128128+ }
129129+130130+ const deleted = new Set<string>();
131131+ for (const cid of nodesA) {
132132+ if (!nodesB.has(cid)) {
133133+ deleted.add(cid);
134134+ }
135135+ }
136136+137137+ return [created, deleted];
138138+};
139139+140140+/**
141141+ * Helper to iterate over all node CIDs in a tree
142142+ */
143143+async function* iterNodeCids(walker: NodeWalker): AsyncGenerator<string> {
144144+ // Always yield the current node
145145+ yield (await walker.frame.node.cid()).$link;
146146+147147+ // Recursively iterate through the tree
148148+ while (!walker.done) {
149149+ if (walker.subtree !== null) {
150150+ await walker.down();
151151+ yield (await walker.frame.node.cid()).$link;
152152+ } else {
153153+ walker.rightOrUp();
154154+ }
155155+ }
156156+}
157157+158158+const EMPTY_NODE_CID = (await MSTNode.empty().cid()).$link;
159159+160160+/**
161161+ * Efficiently computes the difference between two MSTs
162162+ * @param ns the node store
163163+ * @param rootA CID of first MST root
164164+ * @param rootB CID of second MST root
165165+ * @returns tuple of [created nodes, deleted nodes]
166166+ */
167167+export const mstDiff = async (ns: NodeStore, rootA: string, rootB: string): Promise<[Set<string>, Set<string>]> => {
168168+ const created = new Set<string>(); // nodes in B but not in A
169169+ const deleted = new Set<string>(); // nodes in A but not in B
170170+171171+ const walkerA = await NodeWalker.create(ns, rootA);
172172+ const walkerB = await NodeWalker.create(ns, rootB);
173173+174174+ await mstDiffRecursive(created, deleted, walkerA, walkerB);
175175+176176+ // Remove false positives (nodes that appeared in both sets)
177177+ const middle = new Set<string>();
178178+ for (const cid of created) {
179179+ if (deleted.has(cid)) {
180180+ middle.add(cid);
181181+ }
182182+ }
183183+184184+ for (const cid of middle) {
185185+ created.delete(cid);
186186+ deleted.delete(cid);
187187+ }
188188+189189+ // Special case: if one of the root nodes was empty
190190+ if (rootA === EMPTY_NODE_CID && rootB !== EMPTY_NODE_CID) {
191191+ deleted.add(EMPTY_NODE_CID);
192192+ }
193193+ if (rootB === EMPTY_NODE_CID && rootA !== EMPTY_NODE_CID) {
194194+ created.add(EMPTY_NODE_CID);
195195+ }
196196+197197+ return [created, deleted];
198198+};
199199+200200+/**
201201+ * Recursive helper for mstDiff
202202+ * Theory: most trees that get compared will have lots of shared blocks (which we can skip over)
203203+ * Completely different trees will inevitably have to visit every node.
204204+ */
205205+const mstDiffRecursive = async (
206206+ created: Set<string>,
207207+ deleted: Set<string>,
208208+ a: NodeWalker,
209209+ b: NodeWalker,
210210+): Promise<void> => {
211211+ // Easiest case: nodes are identical
212212+ const aNodeCid = (await a.frame.node.cid()).$link;
213213+ const bNodeCid = (await b.frame.node.cid()).$link;
214214+215215+ if (aNodeCid === bNodeCid) {
216216+ return; // no difference
217217+ }
218218+219219+ // Trivial case: a is empty, all of b is new
220220+ if (a.frame.node.isEmpty) {
221221+ for await (const cid of iterNodeCids(b)) {
222222+ created.add(cid);
223223+ }
224224+ return;
225225+ }
226226+227227+ // Likewise: b is empty, all of a is deleted
228228+ if (b.frame.node.isEmpty) {
229229+ for await (const cid of iterNodeCids(a)) {
230230+ deleted.add(cid);
231231+ }
232232+ return;
233233+ }
234234+235235+ // Now we're onto the hard part
236236+ // NB: these will end up as false-positives if one tree is a subtree of the other
237237+ created.add(bNodeCid);
238238+ deleted.add(aNodeCid);
239239+240240+ // General idea:
241241+ // 1. If one cursor is "behind" the other, catch it up
242242+ // 2. When we're matched up, skip over identical subtrees (and recursively diff non-identical subtrees)
243243+ while (true) {
244244+ // Catch up whichever cursor is behind
245245+ while (a.rpath !== b.rpath) {
246246+ // Catch up cursor a if it's behind
247247+ while (a.rpath < b.rpath && !a.done) {
248248+ if (a.subtree !== null) {
249249+ await a.down();
250250+ deleted.add((await a.frame.node.cid()).$link);
251251+ } else {
252252+ a.rightOrUp();
253253+ }
254254+ }
255255+256256+ // Catch up cursor b likewise
257257+ while (b.rpath < a.rpath && !b.done) {
258258+ if (b.subtree !== null) {
259259+ await b.down();
260260+ created.add((await b.frame.node.cid()).$link);
261261+ } else {
262262+ b.rightOrUp();
263263+ }
264264+ }
265265+ }
266266+267267+ // The rpaths now match, but the subtrees below us might not
268268+ const aSubtree = a.subtree;
269269+ const bSubtree = b.subtree;
270270+271271+ // Recursively diff the subtrees
272272+ const aSubWalker = await a.createSubtreeWalker();
273273+ const bSubWalker = await b.createSubtreeWalker();
274274+ await mstDiffRecursive(created, deleted, aSubWalker, bSubWalker);
275275+276276+ // Check if we can still go right
277277+ const aBottom = a.stack.peekBottom();
278278+ const bBottom = b.stack.peekBottom();
279279+280280+ if (
281281+ aBottom !== undefined &&
282282+ bBottom !== undefined &&
283283+ a.rpath === aBottom.rpath &&
284284+ b.rpath === bBottom.rpath
285285+ ) {
286286+ break;
287287+ }
288288+289289+ a.rightOrUp();
290290+ b.rightOrUp();
291291+ }
292292+};
+23-35
packages/utilities/mst/lib/node-walker.ts
···4040 static readonly PATH_MIN = ''; // string that compares less than all legal path strings
4141 static readonly PATH_MAX = '\xff'; // string that compares greater than all legal path strings
42424343- /**
4444- * node store for fetching nodes
4545- * @internal
4646- */
4747- _store: NodeStore;
4848- /**
4949- * stack of frames representing the traversal path
5050- * @internal
5151- */
5252- _stack: Stack<StackFrame>;
5353- /**
5454- * height of the root node
5555- * @internal
5656- */
5757- _rootHeight: number;
5858- /**
5959- * whether to skip height validation (for trusted trees)
6060- * @internal
6161- */
6262- _trusted: boolean;
4343+ /** node store for fetching nodes */
4444+ readonly store: NodeStore;
4545+ /** stack of frames representing the traversal path */
4646+ readonly stack: Stack<StackFrame>;
4747+ /** height of the root node */
4848+ readonly rootHeight: number;
4949+ /** whether to skip height validation (for trusted trees) */
5050+ readonly trusted: boolean;
63516452 private constructor(store: NodeStore, stack: Stack<StackFrame>, rootHeight: number, trusted: boolean) {
6565- this._store = store;
6666- this._stack = stack;
6767- this._rootHeight = rootHeight;
6868- this._trusted = trusted;
5353+ this.store = store;
5454+ this.stack = stack;
5555+ this.rootHeight = rootHeight;
5656+ this.trusted = trusted;
6957 }
70587159 /**
···11199 */
112100 async createSubtreeWalker(): Promise<NodeWalker> {
113101 return await NodeWalker.create(
114114- this._store,
102102+ this.store,
115103 this.subtree?.$link ?? null,
116104 this.lpath,
117105 this.rpath,
118118- this._trusted,
106106+ this.trusted,
119107 this.height - 1,
120108 );
121109 }
122110123111 /** current stack frame */
124112 get frame(): StackFrame {
125125- const frame = this._stack.peek();
113113+ const frame = this.stack.peek();
126114 if (frame === undefined) {
127115 throw new Error(`stack is empty`);
128116 }
···132120133121 /** current height in the tree (decreases as you descend) */
134122 get height(): number {
135135- return this._rootHeight - (this._stack.size - 1);
123123+ return this.rootHeight - (this.stack.size - 1);
136124 }
137125138126 /** key/path to the left of current cursor position */
···166154 /** whether the walker has reached the end of the tree */
167155 get done(): boolean {
168156 // is (not this.stack) really necessary here? is that a reachable state?
169169- const bottom = this._stack.peekBottom();
157157+ const bottom = this.stack.peekBottom();
170158 return (
171171- this._stack.size === 0 || (this.subtree === null && bottom !== undefined && this.rpath === bottom.rpath)
159159+ this.stack.size === 0 || (this.subtree === null && bottom !== undefined && this.rpath === bottom.rpath)
172160 );
173161 }
174162···185173 rightOrUp(): void {
186174 if (!this.canGoRight) {
187175 // we reached the end of this node, go up a level
188188- this._stack.pop();
189189- if (this._stack.size === 0) {
176176+ this.stack.pop();
177177+ if (this.stack.size === 0) {
190178 throw new Error(`cannot navigate beyond root; check .done before calling`);
191179 }
192180 return this.rightOrUp(); // we need to recurse, to skip over empty intermediates on the way back up
···215203 throw new Error(`cannot descend; no subtree at current position`);
216204 }
217205218218- const subtreeNode = await this._store.get(subtree.$link);
206206+ const subtreeNode = await this.store.get(subtree.$link);
219207220220- if (!this._trusted) {
208208+ if (!this.trusted) {
221209 // if we "trust" the source we can elide this check
222210 // the "null" case occurs for empty intermediate nodes
223211 const subtreeHeight = await subtreeNode.height();
···226214 }
227215 }
228216229229- this._stack.push({
217217+ this.stack.push({
230218 node: subtreeNode,
231219 lpath: this.lpath,
232220 rpath: this.rpath,
+299
packages/utilities/mst/lib/node-wrangler.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import * as CID from '@atcute/cid';
44+import { encodeUtf8 } from '@atcute/uint8array';
55+66+import { NodeStore } from './node-store.js';
77+import { NodeWalker } from './node-walker.js';
88+import { NodeWrangler } from './node-wrangler.js';
99+import { MSTNode } from './node.js';
1010+import { MemoryBlockStore } from './stores.js';
1111+1212+const createCid = async (data: string) => {
1313+ const bytes = encodeUtf8(data);
1414+ return CID.toCidLink(await CID.create(0x55, bytes));
1515+};
1616+1717+describe('NodeWrangler', () => {
1818+ it('should put a record into an empty tree', async () => {
1919+ const store = new NodeStore(new MemoryBlockStore());
2020+ const wrangler = new NodeWrangler(store);
2121+2222+ const emptyNode = MSTNode.empty();
2323+ await store.put(emptyNode);
2424+ const emptyCid = (await emptyNode.cid()).$link;
2525+2626+ const key = 'test/key';
2727+ const value = await createCid('test-value');
2828+2929+ const newRootCid = await wrangler.putRecord(emptyCid, key, value);
3030+3131+ // verify the tree contains the new entry
3232+ const walker = await NodeWalker.create(store, newRootCid);
3333+ const entries: Array<[string, any]> = [];
3434+ for await (const entry of walker.entries()) {
3535+ entries.push(entry);
3636+ }
3737+3838+ expect(entries.length).toBe(1);
3939+ expect(entries[0][0]).toBe(key);
4040+ expect(entries[0][1].$link).toBe(value.$link);
4141+ });
4242+4343+ it('should put a record into null (empty tree)', async () => {
4444+ const store = new NodeStore(new MemoryBlockStore());
4545+ const wrangler = new NodeWrangler(store);
4646+4747+ const key = 'test/key';
4848+ const value = await createCid('test-value');
4949+5050+ const newRootCid = await wrangler.putRecord(null, key, value);
5151+5252+ // verify the tree contains the new entry
5353+ const walker = await NodeWalker.create(store, newRootCid);
5454+ const entries: Array<[string, any]> = [];
5555+ for await (const entry of walker.entries()) {
5656+ entries.push(entry);
5757+ }
5858+5959+ expect(entries.length).toBe(1);
6060+ expect(entries[0][0]).toBe(key);
6161+ expect(entries[0][1].$link).toBe(value.$link);
6262+ });
6363+6464+ it('should put multiple records', async () => {
6565+ const store = new NodeStore(new MemoryBlockStore());
6666+ const wrangler = new NodeWrangler(store);
6767+6868+ let rootCid: string | null = null;
6969+7070+ const keys = ['a', 'b', 'c', 'd'];
7171+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
7272+7373+ for (let i = 0; i < keys.length; i++) {
7474+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
7575+ }
7676+7777+ // verify all entries are present
7878+ const walker = await NodeWalker.create(store, rootCid);
7979+ const entries: Array<[string, any]> = [];
8080+ for await (const entry of walker.entries()) {
8181+ entries.push(entry);
8282+ }
8383+8484+ expect(entries.length).toBe(keys.length);
8585+ for (let i = 0; i < keys.length; i++) {
8686+ expect(entries[i][0]).toBe(keys[i]);
8787+ expect(entries[i][1].$link).toBe(values[i].$link);
8888+ }
8989+ });
9090+9191+ it('should be a no-op when putting the same value twice', async () => {
9292+ const store = new NodeStore(new MemoryBlockStore());
9393+ const wrangler = new NodeWrangler(store);
9494+9595+ const key = 'test/key';
9696+ const value = await createCid('test-value');
9797+9898+ const rootCid1 = await wrangler.putRecord(null, key, value);
9999+ const rootCid2 = await wrangler.putRecord(rootCid1, key, value);
100100+101101+ // CIDs should be identical since nothing changed
102102+ expect(rootCid1).toBe(rootCid2);
103103+ });
104104+105105+ it('should update an existing key with a new value', async () => {
106106+ const store = new NodeStore(new MemoryBlockStore());
107107+ const wrangler = new NodeWrangler(store);
108108+109109+ const key = 'test/key';
110110+ const value1 = await createCid('value-1');
111111+ const value2 = await createCid('value-2');
112112+113113+ const rootCid1 = await wrangler.putRecord(null, key, value1);
114114+ const rootCid2 = await wrangler.putRecord(rootCid1, key, value2);
115115+116116+ // CIDs should be different
117117+ expect(rootCid1).not.toBe(rootCid2);
118118+119119+ // verify the new value is present
120120+ const walker = await NodeWalker.create(store, rootCid2);
121121+ const entries: Array<[string, any]> = [];
122122+ for await (const entry of walker.entries()) {
123123+ entries.push(entry);
124124+ }
125125+126126+ expect(entries.length).toBe(1);
127127+ expect(entries[0][0]).toBe(key);
128128+ expect(entries[0][1].$link).toBe(value2.$link);
129129+ });
130130+131131+ it('should delete a record', async () => {
132132+ const store = new NodeStore(new MemoryBlockStore());
133133+ const wrangler = new NodeWrangler(store);
134134+135135+ const key = 'test/key';
136136+ const value = await createCid('test-value');
137137+138138+ const rootCid1 = await wrangler.putRecord(null, key, value);
139139+ const rootCid2 = await wrangler.deleteRecord(rootCid1, key);
140140+141141+ // verify the tree is empty
142142+ const walker = await NodeWalker.create(store, rootCid2);
143143+ const entries: Array<[string, any]> = [];
144144+ for await (const entry of walker.entries()) {
145145+ entries.push(entry);
146146+ }
147147+148148+ expect(entries.length).toBe(0);
149149+ });
150150+151151+ it('should be a no-op when deleting a non-existent key', async () => {
152152+ const store = new NodeStore(new MemoryBlockStore());
153153+ const wrangler = new NodeWrangler(store);
154154+155155+ const key1 = 'test/key1';
156156+ const key2 = 'test/key2';
157157+ const value = await createCid('test-value');
158158+159159+ const rootCid1 = await wrangler.putRecord(null, key1, value);
160160+ const rootCid2 = await wrangler.deleteRecord(rootCid1, key2);
161161+162162+ // CIDs should be identical since nothing changed
163163+ expect(rootCid1).toBe(rootCid2);
164164+ });
165165+166166+ it('should handle multiple puts and deletes', async () => {
167167+ const store = new NodeStore(new MemoryBlockStore());
168168+ const wrangler = new NodeWrangler(store);
169169+170170+ let rootCid: string | null = null;
171171+172172+ // add several keys
173173+ const keys = ['a', 'b', 'c', 'd', 'e'];
174174+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
175175+176176+ for (let i = 0; i < keys.length; i++) {
177177+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
178178+ }
179179+180180+ // delete some keys
181181+ rootCid = await wrangler.deleteRecord(rootCid, 'b');
182182+ rootCid = await wrangler.deleteRecord(rootCid, 'd');
183183+184184+ // verify remaining entries
185185+ const walker = await NodeWalker.create(store, rootCid);
186186+ const entries: Array<[string, any]> = [];
187187+ for await (const entry of walker.entries()) {
188188+ entries.push(entry);
189189+ }
190190+191191+ expect(entries.length).toBe(3);
192192+ expect(entries[0][0]).toBe('a');
193193+ expect(entries[1][0]).toBe('c');
194194+ expect(entries[2][0]).toBe('e');
195195+ });
196196+197197+ it('should maintain sort order across operations', async () => {
198198+ const store = new NodeStore(new MemoryBlockStore());
199199+ const wrangler = new NodeWrangler(store);
200200+201201+ let rootCid: string | null = null;
202202+203203+ // add keys in random order
204204+ const keys = ['e', 'b', 'd', 'a', 'c'];
205205+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
206206+207207+ for (let i = 0; i < keys.length; i++) {
208208+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
209209+ }
210210+211211+ // verify entries are in sorted order
212212+ const walker = await NodeWalker.create(store, rootCid);
213213+ const entries: Array<[string, any]> = [];
214214+ for await (const entry of walker.entries()) {
215215+ entries.push(entry);
216216+ }
217217+218218+ expect(entries.length).toBe(5);
219219+ expect(entries[0][0]).toBe('a');
220220+ expect(entries[1][0]).toBe('b');
221221+ expect(entries[2][0]).toBe('c');
222222+ expect(entries[3][0]).toBe('d');
223223+ expect(entries[4][0]).toBe('e');
224224+ });
225225+226226+ it('should handle deleting from a tree with multiple levels', async () => {
227227+ const store = new NodeStore(new MemoryBlockStore());
228228+ const wrangler = new NodeWrangler(store);
229229+230230+ let rootCid: string | null = null;
231231+232232+ // add many keys to create a multi-level tree
233233+ const keys = Array.from({ length: 20 }, (_, i) => `key-${i.toString().padStart(3, '0')}`);
234234+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
235235+236236+ for (let i = 0; i < keys.length; i++) {
237237+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
238238+ }
239239+240240+ // delete half the keys
241241+ for (let i = 0; i < keys.length; i += 2) {
242242+ rootCid = await wrangler.deleteRecord(rootCid, keys[i]);
243243+ }
244244+245245+ // verify the remaining keys
246246+ const walker = await NodeWalker.create(store, rootCid);
247247+ const entries: Array<[string, any]> = [];
248248+ for await (const entry of walker.entries()) {
249249+ entries.push(entry);
250250+ }
251251+252252+ expect(entries.length).toBe(10);
253253+ for (let i = 0; i < 10; i++) {
254254+ expect(entries[i][0]).toBe(keys[i * 2 + 1]);
255255+ }
256256+ });
257257+258258+ it('should handle putting and deleting the same key multiple times', async () => {
259259+ const store = new NodeStore(new MemoryBlockStore());
260260+ const wrangler = new NodeWrangler(store);
261261+262262+ let rootCid: string | null = null;
263263+264264+ const key = 'test/key';
265265+ const value1 = await createCid('value-1');
266266+ const value2 = await createCid('value-2');
267267+268268+ // put, delete, put again
269269+ rootCid = await wrangler.putRecord(rootCid, key, value1);
270270+ rootCid = await wrangler.deleteRecord(rootCid, key);
271271+ rootCid = await wrangler.putRecord(rootCid, key, value2);
272272+273273+ // verify the final value
274274+ const walker = await NodeWalker.create(store, rootCid);
275275+ const entries: Array<[string, any]> = [];
276276+ for await (const entry of walker.entries()) {
277277+ entries.push(entry);
278278+ }
279279+280280+ expect(entries.length).toBe(1);
281281+ expect(entries[0][0]).toBe(key);
282282+ expect(entries[0][1].$link).toBe(value2.$link);
283283+ });
284284+285285+ it('should handle empty tree deletion', async () => {
286286+ const store = new NodeStore(new MemoryBlockStore());
287287+ const wrangler = new NodeWrangler(store);
288288+289289+ const emptyNode = MSTNode.empty();
290290+ await store.put(emptyNode);
291291+ const emptyCid = (await emptyNode.cid()).$link;
292292+293293+ const key = 'test/key';
294294+ const newRootCid = await wrangler.deleteRecord(emptyCid, key);
295295+296296+ // should be no-op
297297+ expect(newRootCid).toBe(emptyCid);
298298+ });
299299+});
+330
packages/utilities/mst/lib/node-wrangler.ts
···11+import type { CidLink } from '@atcute/cid';
22+33+import { MSTNode, getKeyHeight } from './node.js';
44+import { NodeStore } from './node-store.js';
55+66+/**
77+ * array helper: replaces element at index with a new value
88+ */
99+const replaceAt = <T>(arr: readonly T[], index: number, value: T): readonly T[] => {
1010+ return [...arr.slice(0, index), value, ...arr.slice(index + 1)];
1111+};
1212+1313+/**
1414+ * array helper: inserts element at index
1515+ */
1616+const insertAt = <T>(arr: readonly T[], index: number, value: T): readonly T[] => {
1717+ return [...arr.slice(0, index), value, ...arr.slice(index)];
1818+};
1919+2020+/**
2121+ * array helper: removes element at index
2222+ */
2323+const removeAt = <T>(arr: readonly T[], index: number): readonly T[] => {
2424+ return [...arr.slice(0, index), ...arr.slice(index + 1)];
2525+};
2626+2727+/**
2828+ * NodeWrangler is where core MST transformation ops are implemented, backed
2929+ * by a NodeStore
3030+ *
3131+ * the external APIs take a CID (the MST root) and return a CID (the new root),
3232+ * while storing any newly created nodes in the NodeStore.
3333+ *
3434+ * neither method should ever fail - deleting a node that doesn't exist is a nop,
3535+ * and adding the same node twice with the same value is also a nop. Callers
3636+ * can detect these cases by seeing if the initial and final CIDs changed.
3737+ */
3838+export class NodeWrangler {
3939+ /** underlying node store */
4040+ ns: NodeStore;
4141+4242+ constructor(ns: NodeStore) {
4343+ this.ns = ns;
4444+ }
4545+4646+ /**
4747+ * inserts or updates a record in the MST
4848+ * @param rootCid CID of the root node (or null for empty tree)
4949+ * @param key the key to insert/update
5050+ * @param val the value CID to associate with the key
5151+ * @returns the new root CID
5252+ */
5353+ async putRecord(rootCid: string | null, key: string, val: CidLink): Promise<string> {
5454+ const root = await this.ns.get(rootCid);
5555+5656+ if (root.isEmpty) {
5757+ // special case for empty tree
5858+ const newNode = await this._putHere(root, key, val);
5959+ return (await newNode.cid()).$link;
6060+ }
6161+6262+ const newNode = await this._putRecursive(
6363+ root,
6464+ key,
6565+ val,
6666+ await getKeyHeight(key),
6767+ await root.requireHeight(),
6868+ );
6969+ return (await newNode.cid()).$link;
7070+ }
7171+7272+ /**
7373+ * deletes a record from the MST
7474+ * @param rootCid CID of the root node (or null for empty tree)
7575+ * @param key the key to delete
7676+ * @returns the new root CID
7777+ */
7878+ async deleteRecord(rootCid: string | null, key: string): Promise<string> {
7979+ const root = await this.ns.get(rootCid);
8080+8181+ // Note: the seemingly redundant outer .get().cid is required to transform
8282+ // a null cid into the cid representing an empty node
8383+ const resultCid = await this._deleteRecursive(
8484+ root,
8585+ key,
8686+ await getKeyHeight(key),
8787+ await root.requireHeight(),
8888+ );
8989+ const squashed = await this._squashTop(resultCid?.$link ?? null);
9090+ const finalNode = await this.ns.get(squashed);
9191+9292+ return (await finalNode.cid()).$link;
9393+ }
9494+9595+ /**
9696+ * inserts a key-value pair into the current node
9797+ * @param node the node to insert into
9898+ * @param key the key to insert
9999+ * @param val the value to insert
100100+ * @returns the updated node
101101+ */
102102+ private async _putHere(node: MSTNode, key: string, val: CidLink): Promise<MSTNode> {
103103+ const idx = node.lowerBound(key);
104104+105105+ // the key is already present!
106106+ if (idx < node.keys.length && node.keys[idx] === key) {
107107+ if (node.values[idx].$link === val.$link) {
108108+ return node; // we can return our old self if there is no change
109109+ }
110110+111111+ return await this.ns.put(
112112+ await MSTNode.create(node.keys, replaceAt(node.values, idx, val), node.subtrees),
113113+ );
114114+ }
115115+116116+ // split the subtree at the insertion point
117117+ const [lsub, rsub] = await this._splitOnKey(node.subtrees[idx], key);
118118+119119+ return await this.ns.put(
120120+ await MSTNode.create(insertAt(node.keys, idx, key), insertAt(node.values, idx, val), [
121121+ ...node.subtrees.slice(0, idx),
122122+ lsub,
123123+ rsub,
124124+ ...node.subtrees.slice(idx + 1),
125125+ ]),
126126+ );
127127+ }
128128+129129+ /**
130130+ * recursively inserts a key-value pair, growing the tree if necessary
131131+ * @param node the current node
132132+ * @param key the key to insert
133133+ * @param val the value to insert
134134+ * @param keyHeight the height of the key (based on hash)
135135+ * @param treeHeight the current tree height
136136+ * @returns the updated node
137137+ */
138138+ private async _putRecursive(
139139+ node: MSTNode,
140140+ key: string,
141141+ val: CidLink,
142142+ keyHeight: number,
143143+ treeHeight: number,
144144+ ): Promise<MSTNode> {
145145+ if (keyHeight > treeHeight) {
146146+ // we need to grow the tree
147147+ return await this._putRecursive(
148148+ await this.ns.put(await MSTNode.create([], [], [await node.cid()])),
149149+ key,
150150+ val,
151151+ keyHeight,
152152+ treeHeight + 1,
153153+ );
154154+ }
155155+156156+ if (keyHeight < treeHeight) {
157157+ // we need to look below
158158+ const idx = node.lowerBound(key);
159159+ return await this.ns.put(
160160+ await MSTNode.create(
161161+ node.keys,
162162+ node.values,
163163+ replaceAt(
164164+ node.subtrees,
165165+ idx,
166166+ await (
167167+ await this._putRecursive(
168168+ await this.ns.get(node.subtrees[idx]?.$link ?? null),
169169+ key,
170170+ val,
171171+ keyHeight,
172172+ treeHeight - 1,
173173+ )
174174+ ).cid(),
175175+ ),
176176+ ),
177177+ );
178178+ }
179179+180180+ // we can insert here
181181+ return await this._putHere(node, key, val);
182182+ }
183183+184184+ /**
185185+ * splits a subtree around a key, producing left and right subtrees
186186+ * @param nodeCid the CID of the subtree to split (or null)
187187+ * @param key the key to split around
188188+ * @returns tuple of [left subtree CID, right subtree CID]
189189+ */
190190+ private async _splitOnKey(nodeCid: CidLink | null, key: string): Promise<[CidLink | null, CidLink | null]> {
191191+ if (nodeCid === null) {
192192+ return [null, null];
193193+ }
194194+195195+ const node = await this.ns.get(nodeCid.$link);
196196+ const idx = node.lowerBound(key);
197197+ const [lsub, rsub] = await this._splitOnKey(node.subtrees[idx], key);
198198+199199+ const leftNode = await this.ns.put(
200200+ await MSTNode.create(node.keys.slice(0, idx), node.values.slice(0, idx), [
201201+ ...node.subtrees.slice(0, idx),
202202+ lsub,
203203+ ]),
204204+ );
205205+206206+ const rightNode = await this.ns.put(
207207+ await MSTNode.create(node.keys.slice(idx), node.values.slice(idx), [
208208+ rsub,
209209+ ...node.subtrees.slice(idx + 1),
210210+ ]),
211211+ );
212212+213213+ return [await leftNode._toNullable(), await rightNode._toNullable()];
214214+ }
215215+216216+ /**
217217+ * strips empty nodes from the top of the tree
218218+ * @param nodeCid the CID of the node to check
219219+ * @returns the CID after removing empty top nodes
220220+ */
221221+ private async _squashTop(nodeCid: string | null): Promise<string | null> {
222222+ const node = await this.ns.get(nodeCid);
223223+224224+ if (node.keys.length > 0) {
225225+ return nodeCid;
226226+ }
227227+228228+ if (node.subtrees[0] === null) {
229229+ return nodeCid;
230230+ }
231231+232232+ return await this._squashTop(node.subtrees[0].$link);
233233+ }
234234+235235+ /**
236236+ * recursively deletes a key from the tree
237237+ * @param node the current node
238238+ * @param key the key to delete
239239+ * @param keyHeight the height of the key
240240+ * @param treeHeight the current tree height
241241+ * @returns the CID of the updated node, or null if it becomes empty
242242+ */
243243+ private async _deleteRecursive(
244244+ node: MSTNode,
245245+ key: string,
246246+ keyHeight: number,
247247+ treeHeight: number,
248248+ ): Promise<CidLink | null> {
249249+ if (keyHeight > treeHeight) {
250250+ // the key cannot possibly be in this tree, no change needed
251251+ return await node._toNullable();
252252+ }
253253+254254+ const idx = node.lowerBound(key);
255255+256256+ if (keyHeight < treeHeight) {
257257+ // the key must be deleted from a subtree
258258+ if (node.subtrees[idx] === null) {
259259+ return await node._toNullable(); // the key cannot be in this subtree, no change needed
260260+ }
261261+262262+ const updated = await this.ns.put(
263263+ await MSTNode.create(
264264+ node.keys,
265265+ node.values,
266266+ replaceAt(
267267+ node.subtrees,
268268+ idx,
269269+ await this._deleteRecursive(
270270+ await this.ns.get(node.subtrees[idx]!.$link),
271271+ key,
272272+ keyHeight,
273273+ treeHeight - 1,
274274+ ),
275275+ ),
276276+ ),
277277+ );
278278+279279+ return await updated._toNullable();
280280+ }
281281+282282+ if (idx === node.keys.length || node.keys[idx] !== key) {
283283+ return await node._toNullable(); // key already not present
284284+ }
285285+286286+ // merge the subtrees on either side of the deleted key
287287+ const merged = await this._merge(node.subtrees[idx], node.subtrees[idx + 1]);
288288+289289+ const updated = await this.ns.put(
290290+ await MSTNode.create(removeAt(node.keys, idx), removeAt(node.values, idx), [
291291+ ...node.subtrees.slice(0, idx),
292292+ merged,
293293+ ...node.subtrees.slice(idx + 2),
294294+ ]),
295295+ );
296296+297297+ return await updated._toNullable();
298298+ }
299299+300300+ /**
301301+ * merges two adjacent subtrees
302302+ * @param leftCid CID of the left subtree (or null)
303303+ * @param rightCid CID of the right subtree (or null)
304304+ * @returns the CID of the merged subtree (or null if both are null)
305305+ */
306306+ private async _merge(leftCid: CidLink | null, rightCid: CidLink | null): Promise<CidLink | null> {
307307+ if (leftCid === null) {
308308+ return rightCid; // includes the case where left == right == null
309309+ }
310310+ if (rightCid === null) {
311311+ return leftCid;
312312+ }
313313+314314+ const left = await this.ns.get(leftCid.$link);
315315+ const right = await this.ns.get(rightCid.$link);
316316+317317+ // recursively merge the adjacent subtrees at the boundary
318318+ const mergedBoundary = await this._merge(left.subtrees[left.subtrees.length - 1], right.subtrees[0]);
319319+320320+ const merged = await this.ns.put(
321321+ await MSTNode.create(
322322+ [...left.keys, ...right.keys],
323323+ [...left.values, ...right.values],
324324+ [...left.subtrees.slice(0, -1), mergedBoundary, ...right.subtrees.slice(1)],
325325+ ),
326326+ );
327327+328328+ return await merged._toNullable();
329329+ }
330330+}
+12
packages/utilities/mst/lib/node.ts
···234234235235 return len;
236236 }
237237+238238+ /**
239239+ * returns the node's CID if it's not empty, null otherwise
240240+ * @returns the CID or null
241241+ * @internal
242242+ */
243243+ async _toNullable(): Promise<CidLink | null> {
244244+ if (this.isEmpty) {
245245+ return null;
246246+ }
247247+ return await this.cid();
248248+ }
237249}
238250239251/**
+198
packages/utilities/mst/lib/proof.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import * as CID from '@atcute/cid';
44+import { encodeUtf8 } from '@atcute/uint8array';
55+66+import { NodeStore } from './node-store.js';
77+import { NodeWrangler } from './node-wrangler.js';
88+import {
99+ buildExclusionProof,
1010+ buildInclusionProof,
1111+ InvalidProofError,
1212+ ProofError,
1313+ verifyExclusion,
1414+ verifyInclusion,
1515+} from './proof.js';
1616+import { MemoryBlockStore } from './stores.js';
1717+1818+const createCid = async (data: string) => {
1919+ const bytes = encodeUtf8(data);
2020+ return CID.toCidLink(await CID.create(0x55, bytes));
2121+};
2222+2323+describe('Proof', () => {
2424+ it('should build and verify inclusion proof', async () => {
2525+ const store = new NodeStore(new MemoryBlockStore());
2626+ const wrangler = new NodeWrangler(store);
2727+2828+ // build a tree with some records
2929+ let rootCid: string | null = null;
3030+ const keys = ['a/1', 'b/2', 'c/3'];
3131+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
3232+3333+ for (let i = 0; i < keys.length; i++) {
3434+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
3535+ }
3636+3737+ // build inclusion proof for 'b/2'
3838+ const proof = await buildInclusionProof(store, rootCid!, 'b/2');
3939+4040+ expect(proof.size).toBeGreaterThan(0);
4141+4242+ // create a new store with only the proof blocks
4343+ const proofStore = new NodeStore(new MemoryBlockStore());
4444+ for (const cid of proof) {
4545+ const node = await store.get(cid);
4646+ await proofStore.put(node);
4747+ }
4848+4949+ // verify the inclusion proof
5050+ await expect(verifyInclusion(proofStore, rootCid!, 'b/2')).resolves.toBeUndefined();
5151+ });
5252+5353+ it('should build and verify exclusion proof', async () => {
5454+ const store = new NodeStore(new MemoryBlockStore());
5555+ const wrangler = new NodeWrangler(store);
5656+5757+ // build a tree with some records
5858+ let rootCid: string | null = null;
5959+ const keys = ['a/1', 'b/2', 'c/3'];
6060+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
6161+6262+ for (let i = 0; i < keys.length; i++) {
6363+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
6464+ }
6565+6666+ // build exclusion proof for 'd/4' (doesn't exist)
6767+ const proof = await buildExclusionProof(store, rootCid!, 'd/4');
6868+6969+ expect(proof.size).toBeGreaterThan(0);
7070+7171+ // create a new store with only the proof blocks
7272+ const proofStore = new NodeStore(new MemoryBlockStore());
7373+ for (const cid of proof) {
7474+ const node = await store.get(cid);
7575+ await proofStore.put(node);
7676+ }
7777+7878+ // verify the exclusion proof
7979+ await expect(verifyExclusion(proofStore, rootCid!, 'd/4')).resolves.toBeUndefined();
8080+ });
8181+8282+ it('should throw ProofError when building inclusion proof for non-existent record', async () => {
8383+ const store = new NodeStore(new MemoryBlockStore());
8484+ const wrangler = new NodeWrangler(store);
8585+8686+ let rootCid: string | null = null;
8787+ rootCid = await wrangler.putRecord(rootCid, 'a/1', await createCid('value-a'));
8888+8989+ await expect(buildInclusionProof(store, rootCid, 'b/2')).rejects.toThrow(ProofError);
9090+ await expect(buildInclusionProof(store, rootCid, 'b/2')).rejects.toThrow("doesn't exist");
9191+ });
9292+9393+ it('should throw ProofError when building exclusion proof for existing record', async () => {
9494+ const store = new NodeStore(new MemoryBlockStore());
9595+ const wrangler = new NodeWrangler(store);
9696+9797+ let rootCid: string | null = null;
9898+ rootCid = await wrangler.putRecord(rootCid, 'a/1', await createCid('value-a'));
9999+100100+ await expect(buildExclusionProof(store, rootCid, 'a/1')).rejects.toThrow(ProofError);
101101+ await expect(buildExclusionProof(store, rootCid, 'a/1')).rejects.toThrow('that exists');
102102+ });
103103+104104+ it('should throw InvalidProofError when verifying inclusion with missing blocks', async () => {
105105+ const store = new NodeStore(new MemoryBlockStore());
106106+ const wrangler = new NodeWrangler(store);
107107+108108+ let rootCid: string | null = null;
109109+ const keys = ['a/1', 'b/2', 'c/3'];
110110+ const values = await Promise.all(keys.map((k) => createCid(`value-${k}`)));
111111+112112+ for (let i = 0; i < keys.length; i++) {
113113+ rootCid = await wrangler.putRecord(rootCid, keys[i], values[i]);
114114+ }
115115+116116+ // create a store with no blocks
117117+ const emptyStore = new NodeStore(new MemoryBlockStore());
118118+119119+ await expect(verifyInclusion(emptyStore, rootCid!, 'b/2')).rejects.toThrow(InvalidProofError);
120120+ await expect(verifyInclusion(emptyStore, rootCid!, 'b/2')).rejects.toThrow('missing MST blocks');
121121+ });
122122+123123+ it('should throw InvalidProofError when verifying exclusion with missing blocks', async () => {
124124+ const store = new NodeStore(new MemoryBlockStore());
125125+ const wrangler = new NodeWrangler(store);
126126+127127+ let rootCid: string | null = null;
128128+ rootCid = await wrangler.putRecord(rootCid, 'a/1', await createCid('value-a'));
129129+130130+ // create a store with no blocks
131131+ const emptyStore = new NodeStore(new MemoryBlockStore());
132132+133133+ await expect(verifyExclusion(emptyStore, rootCid, 'd/4')).rejects.toThrow(InvalidProofError);
134134+ await expect(verifyExclusion(emptyStore, rootCid, 'd/4')).rejects.toThrow('missing MST blocks');
135135+ });
136136+137137+ it('should throw InvalidProofError when verifying inclusion proof for non-existent record', async () => {
138138+ const store = new NodeStore(new MemoryBlockStore());
139139+ const wrangler = new NodeWrangler(store);
140140+141141+ let rootCid: string | null = null;
142142+ rootCid = await wrangler.putRecord(rootCid, 'a/1', await createCid('value-a'));
143143+144144+ // build proof for existing record
145145+ const proof = await buildInclusionProof(store, rootCid, 'a/1');
146146+ const proofStore = new NodeStore(new MemoryBlockStore());
147147+ for (const cid of proof) {
148148+ const node = await store.get(cid);
149149+ await proofStore.put(node);
150150+ }
151151+152152+ // try to verify for a different record
153153+ await expect(verifyInclusion(proofStore, rootCid, 'b/2')).rejects.toThrow(InvalidProofError);
154154+ await expect(verifyInclusion(proofStore, rootCid, 'b/2')).rejects.toThrow('not present in MST');
155155+ });
156156+157157+ it('should throw InvalidProofError when verifying exclusion proof for existing record', async () => {
158158+ const store = new NodeStore(new MemoryBlockStore());
159159+ const wrangler = new NodeWrangler(store);
160160+161161+ let rootCid: string | null = null;
162162+ rootCid = await wrangler.putRecord(rootCid, 'a/1', await createCid('value-a'));
163163+164164+ // build proof for non-existing record
165165+ const proof = await buildExclusionProof(store, rootCid, 'b/2');
166166+ const proofStore = new NodeStore(new MemoryBlockStore());
167167+ for (const cid of proof) {
168168+ const node = await store.get(cid);
169169+ await proofStore.put(node);
170170+ }
171171+172172+ // try to verify exclusion for the existing record
173173+ await expect(verifyExclusion(proofStore, rootCid, 'a/1')).rejects.toThrow(InvalidProofError);
174174+ await expect(verifyExclusion(proofStore, rootCid, 'a/1')).rejects.toThrow('*is* present in MST');
175175+ });
176176+177177+ it('should handle proofs on empty tree', async () => {
178178+ const store = new NodeStore(new MemoryBlockStore());
179179+180180+ const emptyNode = await store.get(null);
181181+ const emptyCid = (await emptyNode.cid()).$link;
182182+183183+ // exclusion proof should work on empty tree
184184+ const proof = await buildExclusionProof(store, emptyCid, 'a/1');
185185+ expect(proof.size).toBeGreaterThan(0);
186186+187187+ const proofStore = new NodeStore(new MemoryBlockStore());
188188+ for (const cid of proof) {
189189+ const node = await store.get(cid);
190190+ await proofStore.put(node);
191191+ }
192192+193193+ await expect(verifyExclusion(proofStore, emptyCid, 'a/1')).resolves.toBeUndefined();
194194+195195+ // inclusion proof should fail on empty tree
196196+ await expect(buildInclusionProof(store, emptyCid, 'a/1')).rejects.toThrow(ProofError);
197197+ });
198198+});
+132
packages/utilities/mst/lib/proof.ts
···11+import type { CidLink } from '@atcute/cid';
22+33+import { MissingBlockError } from './errors.js';
44+import type { NodeStore } from './node-store.js';
55+import { NodeWalker } from './node-walker.js';
66+77+/**
88+ * Error thrown when validating a proof fails
99+ */
1010+export class InvalidProofError extends Error {
1111+ constructor(message: string) {
1212+ super(message);
1313+ this.name = 'InvalidProofError';
1414+ }
1515+}
1616+1717+/**
1818+ * Error thrown when constructing a proof fails
1919+ */
2020+export class ProofError extends Error {
2121+ constructor(message: string) {
2222+ super(message);
2323+ this.name = 'ProofError';
2424+ }
2525+}
2626+2727+/**
2828+ * Finds a record path and builds a proof (works for both inclusion and exclusion proofs)
2929+ * @param ns the node store
3030+ * @param rootCid the MST root CID
3131+ * @param rpath the record path to find
3232+ * @returns tuple of [value CID or null, set of proof node CIDs]
3333+ */
3434+export const findRpathAndBuildProof = async (
3535+ ns: NodeStore,
3636+ rootCid: string,
3737+ rpath: string,
3838+): Promise<[CidLink | null, Set<string>]> => {
3939+ const walker = await NodeWalker.create(ns, rootCid);
4040+ const value = await walker.findRpath(rpath);
4141+4242+ const proof = new Set<string>();
4343+ for (const frame of walker.stack) {
4444+ proof.add((await frame.node.cid()).$link);
4545+ }
4646+4747+ return [value, proof];
4848+};
4949+5050+/**
5151+ * Builds an exclusion proof for a record that should not exist
5252+ * @param ns the node store
5353+ * @param rootCid the MST root CID
5454+ * @param rpath the record path
5555+ * @returns set of MST node CIDs needed for the exclusion proof
5656+ * @throws {ProofError} if the record exists
5757+ */
5858+export const buildExclusionProof = async (
5959+ ns: NodeStore,
6060+ rootCid: string,
6161+ rpath: string,
6262+): Promise<Set<string>> => {
6363+ const [value, proof] = await findRpathAndBuildProof(ns, rootCid, rpath);
6464+ if (value !== null) {
6565+ throw new ProofError("can't build exclusion proof for a record that exists!");
6666+ }
6767+ return proof;
6868+};
6969+7070+/**
7171+ * Builds an inclusion proof for a record that should exist
7272+ * @param ns the node store
7373+ * @param rootCid the MST root CID
7474+ * @param rpath the record path
7575+ * @returns set of MST node CIDs needed for the inclusion proof
7676+ * @throws {ProofError} if the record doesn't exist
7777+ */
7878+export const buildInclusionProof = async (
7979+ ns: NodeStore,
8080+ rootCid: string,
8181+ rpath: string,
8282+): Promise<Set<string>> => {
8383+ const [value, proof] = await findRpathAndBuildProof(ns, rootCid, rpath);
8484+ if (value === null) {
8585+ throw new ProofError("can't build inclusion proof for a record that doesn't exist!");
8686+ }
8787+ return proof;
8888+};
8989+9090+/**
9191+ * Verifies an inclusion proof - that a record exists in the MST
9292+ * @param ns the node store (should only contain blocks from the proof)
9393+ * @param rootCid the MST root CID
9494+ * @param rpath the record path
9595+ * @throws {InvalidProofError} if the proof is invalid or the record doesn't exist
9696+ */
9797+export const verifyInclusion = async (ns: NodeStore, rootCid: string, rpath: string): Promise<void> => {
9898+ try {
9999+ const walker = await NodeWalker.create(ns, rootCid);
100100+ const value = await walker.findRpath(rpath);
101101+ if (value === null) {
102102+ throw new InvalidProofError('rpath not present in MST');
103103+ }
104104+ } catch (err) {
105105+ if (err instanceof MissingBlockError) {
106106+ throw new InvalidProofError('missing MST blocks');
107107+ }
108108+ throw err;
109109+ }
110110+};
111111+112112+/**
113113+ * Verifies an exclusion proof - that a record does not exist in the MST
114114+ * @param ns the node store (should only contain blocks from the proof)
115115+ * @param rootCid the MST root CID
116116+ * @param rpath the record path
117117+ * @throws {InvalidProofError} if the proof is invalid or the record exists
118118+ */
119119+export const verifyExclusion = async (ns: NodeStore, rootCid: string, rpath: string): Promise<void> => {
120120+ try {
121121+ const walker = await NodeWalker.create(ns, rootCid);
122122+ const value = await walker.findRpath(rpath);
123123+ if (value !== null) {
124124+ throw new InvalidProofError('rpath *is* present in MST');
125125+ }
126126+ } catch (err) {
127127+ if (err instanceof MissingBlockError) {
128128+ throw new InvalidProofError('missing MST blocks');
129129+ }
130130+ throw err;
131131+ }
132132+};
+191
packages/utilities/mst/lib/test-suite.test.ts
···11+import { readFileSync, readdirSync, statSync } from 'node:fs';
22+import { join } from 'node:path';
33+import { describe, expect, it } from 'vitest';
44+55+import { fromUint8Array } from '@atcute/car/v4/car-reader';
66+import * as CID from '@atcute/cid';
77+88+import { DeltaType, mstDiff, recordDiff } from './diff.js';
99+import { NodeStore } from './node-store.js';
1010+import { MemoryBlockStore } from './stores.js';
1111+1212+interface MstDiffTestCase {
1313+ $type: 'mst-diff';
1414+ description: string;
1515+ inputs: {
1616+ mst_a: string;
1717+ mst_b: string;
1818+ };
1919+ results: {
2020+ created_nodes: string[];
2121+ deleted_nodes: string[];
2222+ record_ops: Array<{
2323+ rpath: string;
2424+ old_value: string | null;
2525+ new_value: string | null;
2626+ }>;
2727+ proof_nodes: string[];
2828+ inductive_proof_nodes: string[];
2929+ firehose_cids: string | string[];
3030+ };
3131+}
3232+3333+/**
3434+ * Load a CAR file into a MemoryBlockStore and extract the root CID
3535+ */
3636+const loadCar = (carPath: string): { store: MemoryBlockStore; root: string } => {
3737+ const testSuiteRoot = join(__dirname, '..', '.research', 'mst-test-suite');
3838+ const fullPath = join(testSuiteRoot, carPath);
3939+ const carBytes = readFileSync(fullPath);
4040+4141+ const car = fromUint8Array(carBytes);
4242+ const store = new MemoryBlockStore();
4343+4444+ // Load all blocks from CAR into the store
4545+ for (const entry of car) {
4646+ const cidStr = CID.toCidLink(entry.cid).$link;
4747+ store.blocks.set(cidStr, entry.bytes);
4848+ }
4949+5050+ // Extract root CID from CAR header
5151+ if (car.roots.length !== 1) {
5252+ throw new Error(`Expected exactly 1 root in CAR, got ${car.roots.length}`);
5353+ }
5454+5555+ const root = car.roots[0].$link;
5656+ return { store, root };
5757+};
5858+5959+/**
6060+ * Recursively find all .json test files in a directory
6161+ */
6262+const findTestFiles = (dir: string): string[] => {
6363+ const results: string[] = [];
6464+ const entries = readdirSync(dir);
6565+6666+ for (const entry of entries) {
6767+ const fullPath = join(dir, entry);
6868+ const stat = statSync(fullPath);
6969+7070+ if (stat.isDirectory()) {
7171+ results.push(...findTestFiles(fullPath));
7272+ } else if (entry.endsWith('.json')) {
7373+ results.push(fullPath);
7474+ }
7575+ }
7676+7777+ return results;
7878+};
7979+8080+/**
8181+ * Load all test cases from the test suite
8282+ */
8383+const loadTestCases = (): Array<{ path: string; testCase: MstDiffTestCase }> => {
8484+ const testSuiteRoot = join(__dirname, '..', '.research', 'mst-test-suite');
8585+ const testsDir = join(testSuiteRoot, 'tests');
8686+ const testFiles = findTestFiles(testsDir);
8787+8888+ const testCases: Array<{ path: string; testCase: MstDiffTestCase }> = [];
8989+9090+ for (const filePath of testFiles) {
9191+ const content = readFileSync(filePath, 'utf-8');
9292+ const testCase = JSON.parse(content) as MstDiffTestCase;
9393+9494+ if (testCase.$type === 'mst-diff') {
9595+ testCases.push({ path: filePath, testCase });
9696+ }
9797+ }
9898+9999+ return testCases;
100100+};
101101+102102+describe('MST Test Suite', () => {
103103+ const allTestCases = loadTestCases();
104104+105105+ // Run all test cases
106106+ const testCases = allTestCases;
107107+108108+ it(`should have loaded test cases (${testCases.length} total)`, () => {
109109+ expect(testCases.length).toBeGreaterThan(1000); // Should have 16k+ tests
110110+ });
111111+112112+ describe.each(testCases)('$testCase.description', ({ testCase }) => {
113113+ it('should compute correct mstDiff', async () => {
114114+ // Load both CARs
115115+ const { store: storeA, root: rootA } = loadCar(testCase.inputs.mst_a);
116116+ const { store: storeB, root: rootB } = loadCar(testCase.inputs.mst_b);
117117+118118+ // Create NodeStores (combine both block stores for access to all blocks)
119119+ // We need an overlay approach since diff needs to read from both trees
120120+ const combinedStore = new MemoryBlockStore();
121121+ for (const [cid, bytes] of storeA.blocks) {
122122+ combinedStore.blocks.set(cid, bytes);
123123+ }
124124+ for (const [cid, bytes] of storeB.blocks) {
125125+ combinedStore.blocks.set(cid, bytes);
126126+ }
127127+128128+ const nodeStore = new NodeStore(combinedStore);
129129+130130+ // Run mstDiff
131131+ const [createdNodes, deletedNodes] = await mstDiff(nodeStore, rootA, rootB);
132132+133133+ // Compare created_nodes (as sets, order doesn't matter)
134134+ const expectedCreated = new Set(testCase.results.created_nodes);
135135+ expect(createdNodes).toEqual(expectedCreated);
136136+137137+ // Compare deleted_nodes (as sets, order doesn't matter)
138138+ const expectedDeleted = new Set(testCase.results.deleted_nodes);
139139+ expect(deletedNodes).toEqual(expectedDeleted);
140140+ });
141141+142142+ it('should compute correct recordDiff', async () => {
143143+ // Load both CARs
144144+ const { store: storeA, root: rootA } = loadCar(testCase.inputs.mst_a);
145145+ const { store: storeB, root: rootB } = loadCar(testCase.inputs.mst_b);
146146+147147+ // Create combined NodeStore
148148+ const combinedStore = new MemoryBlockStore();
149149+ for (const [cid, bytes] of storeA.blocks) {
150150+ combinedStore.blocks.set(cid, bytes);
151151+ }
152152+ for (const [cid, bytes] of storeB.blocks) {
153153+ combinedStore.blocks.set(cid, bytes);
154154+ }
155155+156156+ const nodeStore = new NodeStore(combinedStore);
157157+158158+ // Run mstDiff and recordDiff
159159+ const [createdNodes, deletedNodes] = await mstDiff(nodeStore, rootA, rootB);
160160+161161+ const deltas = [];
162162+ for await (const delta of recordDiff(nodeStore, createdNodes, deletedNodes)) {
163163+ deltas.push(delta);
164164+ }
165165+166166+ // Sort both actual and expected by rpath for comparison
167167+ const sortedDeltas = deltas.sort((a, b) => a.path.localeCompare(b.path));
168168+ const sortedExpected = [...testCase.results.record_ops].sort((a, b) => a.rpath.localeCompare(b.rpath));
169169+170170+ expect(sortedDeltas.length).toBe(sortedExpected.length);
171171+172172+ for (let i = 0; i < sortedDeltas.length; i++) {
173173+ const actual = sortedDeltas[i];
174174+ const expected = sortedExpected[i];
175175+176176+ expect(actual.path).toBe(expected.rpath);
177177+ expect(actual.priorValue?.$link ?? null).toBe(expected.old_value);
178178+ expect(actual.laterValue?.$link ?? null).toBe(expected.new_value);
179179+180180+ // Verify delta type is correct
181181+ if (expected.old_value === null) {
182182+ expect(actual.deltaType).toBe(DeltaType.CREATED);
183183+ } else if (expected.new_value === null) {
184184+ expect(actual.deltaType).toBe(DeltaType.DELETED);
185185+ } else {
186186+ expect(actual.deltaType).toBe(DeltaType.UPDATED);
187187+ }
188188+ }
189189+ });
190190+ });
191191+});