@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
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
|
|
6
|
-
import { Agent, Name, Revision } from "@storacha/ucn/pail";
|
|
7
|
-
import * as Value from "@storacha/ucn/pail/value";
|
|
8
|
-
import { MemoryBlockstore } from "@storacha/ucn/block";
|
|
9
|
-
import type { ValueView } from "@storacha/ucn/pail/api";
|
|
10
|
-
import type { Block } from "multiformats";
|
|
11
|
-
|
|
12
|
-
import { processChanges } from "../../src/handlers/process.js";
|
|
13
|
-
import type { FileChange } from "../../src/types/index.js";
|
|
14
|
-
|
|
15
|
-
// --- Helpers ---
|
|
16
|
-
|
|
17
|
-
const storeBlocks = async (
|
|
18
|
-
store: MemoryBlockstore,
|
|
19
|
-
blocks: Array<{ cid: unknown; bytes: Uint8Array }>,
|
|
20
|
-
) => {
|
|
21
|
-
for (const block of blocks) {
|
|
22
|
-
await store.put(block as any);
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Create a block sink that collects blocks into an array.
|
|
28
|
-
*/
|
|
29
|
-
const makeBlockCollector = () => {
|
|
30
|
-
const collected: Block[] = [];
|
|
31
|
-
const sink = async (block: Block) => { collected.push(block); };
|
|
32
|
-
return { sink, collected };
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Encode a file and extract root CID + blocks for bootstrapping pail state.
|
|
37
|
-
*/
|
|
38
|
-
const encodeFileForBootstrap = async (
|
|
39
|
-
workspace: string,
|
|
40
|
-
relativePath: string,
|
|
41
|
-
): Promise<{ rootCID: any; blocks: Block[] }> => {
|
|
42
|
-
const { encodeWorkspaceFile } = await import("../../src/utils/encoder.js");
|
|
43
|
-
const encoded = await encodeWorkspaceFile(workspace, relativePath);
|
|
44
|
-
const blocks: Block[] = [];
|
|
45
|
-
let rootCID: any = null;
|
|
46
|
-
const reader = encoded.blocks.getReader();
|
|
47
|
-
while (true) {
|
|
48
|
-
const { done, value } = await reader.read();
|
|
49
|
-
if (done) break;
|
|
50
|
-
blocks.push(value);
|
|
51
|
-
rootCID = value.cid; // last block is the root
|
|
52
|
-
}
|
|
53
|
-
return { rootCID, blocks };
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Bootstrap a pail value with entries, no clock needed.
|
|
58
|
-
*/
|
|
59
|
-
const bootstrapValue = async (
|
|
60
|
-
blocks: MemoryBlockstore,
|
|
61
|
-
entries: Record<string, string>,
|
|
62
|
-
): Promise<{ value: ValueView }> => {
|
|
63
|
-
const agent = await Agent.generate();
|
|
64
|
-
const name = await Name.create(agent);
|
|
65
|
-
|
|
66
|
-
const keys = Object.keys(entries);
|
|
67
|
-
if (keys.length === 0) throw new Error("need at least one entry");
|
|
68
|
-
|
|
69
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "bootstrap-"));
|
|
70
|
-
for (const [key, content] of Object.entries(entries)) {
|
|
71
|
-
const filePath = path.join(tmpDir, key);
|
|
72
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
73
|
-
await fs.writeFile(filePath, content);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const firstKey = keys[0];
|
|
77
|
-
const firstEncoded = await encodeFileForBootstrap(tmpDir, firstKey);
|
|
78
|
-
for (const b of firstEncoded.blocks) await blocks.put(b as any);
|
|
79
|
-
|
|
80
|
-
const init = await Revision.v0Put(blocks, firstKey, firstEncoded.rootCID);
|
|
81
|
-
await storeBlocks(blocks, init.additions);
|
|
82
|
-
let value = (await Value.from(blocks, name, init.revision)).value;
|
|
83
|
-
|
|
84
|
-
for (const key of keys.slice(1)) {
|
|
85
|
-
const encoded = await encodeFileForBootstrap(tmpDir, key);
|
|
86
|
-
for (const b of encoded.blocks) await blocks.put(b as any);
|
|
87
|
-
|
|
88
|
-
const result = await Revision.put(blocks, value, key, encoded.rootCID);
|
|
89
|
-
await storeBlocks(blocks, result.additions);
|
|
90
|
-
value = (await Value.from(blocks, name, result.revision)).value;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
94
|
-
return { value };
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// --- Tests ---
|
|
98
|
-
|
|
99
|
-
describe("processChanges", () => {
|
|
100
|
-
let tmpDir: string;
|
|
101
|
-
|
|
102
|
-
beforeEach(async () => {
|
|
103
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawracha-process-"));
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
afterEach(async () => {
|
|
107
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const writeFile = async (name: string, content: string) => {
|
|
111
|
-
const filePath = path.join(tmpDir, name);
|
|
112
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
113
|
-
await fs.writeFile(filePath, content);
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
it("no current + put → one put op", async () => {
|
|
117
|
-
await writeFile("hello.txt", "hello world");
|
|
118
|
-
const changes: FileChange[] = [{ type: "add", path: "hello.txt" }];
|
|
119
|
-
|
|
120
|
-
const { sink, collected } = makeBlockCollector();
|
|
121
|
-
const ops = await processChanges(changes, tmpDir, null, { get: async () => undefined }, sink);
|
|
122
|
-
|
|
123
|
-
expect(ops).toHaveLength(1);
|
|
124
|
-
expect(ops[0].type).toBe("put");
|
|
125
|
-
expect(ops[0].key).toBe("hello.txt");
|
|
126
|
-
expect(ops[0].value).toBeDefined();
|
|
127
|
-
expect(collected.length).toBeGreaterThan(0);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("no current + delete → no op", async () => {
|
|
131
|
-
const changes: FileChange[] = [{ type: "unlink", path: "gone.txt" }];
|
|
132
|
-
|
|
133
|
-
const { sink, collected } = makeBlockCollector();
|
|
134
|
-
const ops = await processChanges(changes, tmpDir, null, { get: async () => undefined }, sink);
|
|
135
|
-
|
|
136
|
-
expect(ops).toHaveLength(0);
|
|
137
|
-
expect(collected).toHaveLength(0);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("existing current + put new key → one put op", async () => {
|
|
141
|
-
const blocks = new MemoryBlockstore();
|
|
142
|
-
const { value } = await bootstrapValue(blocks, { "a.txt": "aaa" });
|
|
143
|
-
|
|
144
|
-
await writeFile("b.txt", "bbb");
|
|
145
|
-
const changes: FileChange[] = [{ type: "add", path: "b.txt" }];
|
|
146
|
-
|
|
147
|
-
const { sink } = makeBlockCollector();
|
|
148
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
149
|
-
|
|
150
|
-
expect(ops).toHaveLength(1);
|
|
151
|
-
expect(ops[0].type).toBe("put");
|
|
152
|
-
expect(ops[0].key).toBe("b.txt");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("existing current + put same key different value → one put op", async () => {
|
|
156
|
-
const blocks = new MemoryBlockstore();
|
|
157
|
-
const { value } = await bootstrapValue(blocks, { "a.txt": "old content" });
|
|
158
|
-
|
|
159
|
-
await writeFile("a.txt", "new content");
|
|
160
|
-
const changes: FileChange[] = [{ type: "change", path: "a.txt" }];
|
|
161
|
-
|
|
162
|
-
const { sink } = makeBlockCollector();
|
|
163
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
164
|
-
|
|
165
|
-
expect(ops).toHaveLength(1);
|
|
166
|
-
expect(ops[0].type).toBe("put");
|
|
167
|
-
expect(ops[0].key).toBe("a.txt");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("existing current + put same key same value → no op", async () => {
|
|
171
|
-
const blocks = new MemoryBlockstore();
|
|
172
|
-
const { value } = await bootstrapValue(blocks, { "a.txt": "same content" });
|
|
173
|
-
|
|
174
|
-
await writeFile("a.txt", "same content");
|
|
175
|
-
const changes: FileChange[] = [{ type: "change", path: "a.txt" }];
|
|
176
|
-
|
|
177
|
-
const { sink, collected } = makeBlockCollector();
|
|
178
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
179
|
-
|
|
180
|
-
expect(ops).toHaveLength(0);
|
|
181
|
-
expect(collected.length).toBeGreaterThan(0);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("existing current + delete existing key → one delete op", async () => {
|
|
185
|
-
const blocks = new MemoryBlockstore();
|
|
186
|
-
const { value } = await bootstrapValue(blocks, { "a.txt": "delete me" });
|
|
187
|
-
|
|
188
|
-
const changes: FileChange[] = [{ type: "unlink", path: "a.txt" }];
|
|
189
|
-
|
|
190
|
-
const { sink } = makeBlockCollector();
|
|
191
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
192
|
-
|
|
193
|
-
expect(ops).toHaveLength(1);
|
|
194
|
-
expect(ops[0].type).toBe("del");
|
|
195
|
-
expect(ops[0].key).toBe("a.txt");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("existing current + delete non-existing key → no op", async () => {
|
|
199
|
-
const blocks = new MemoryBlockstore();
|
|
200
|
-
const { value } = await bootstrapValue(blocks, { "a.txt": "keep me" });
|
|
201
|
-
|
|
202
|
-
const changes: FileChange[] = [{ type: "unlink", path: "nonexistent.txt" }];
|
|
203
|
-
|
|
204
|
-
const { sink } = makeBlockCollector();
|
|
205
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
206
|
-
|
|
207
|
-
expect(ops).toHaveLength(0);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("markdown file → mdsync v0Put (no current)", async () => {
|
|
211
|
-
await writeFile("notes.md", "# Notes\n\nHello world.\n");
|
|
212
|
-
const changes: FileChange[] = [{ type: "add", path: "notes.md" }];
|
|
213
|
-
|
|
214
|
-
const { sink, collected } = makeBlockCollector();
|
|
215
|
-
const stored: Block[] = [];
|
|
216
|
-
const store = async (block: Block) => { stored.push(block); };
|
|
217
|
-
const ops = await processChanges(changes, tmpDir, null, { get: async () => undefined }, sink, store);
|
|
218
|
-
|
|
219
|
-
expect(ops).toHaveLength(1);
|
|
220
|
-
expect(ops[0].type).toBe("put");
|
|
221
|
-
expect(ops[0].key).toBe("notes.md");
|
|
222
|
-
expect(ops[0].value).toBeDefined();
|
|
223
|
-
// Markdown blocks should be sinked for CAR upload AND stored locally
|
|
224
|
-
expect(collected.length).toBeGreaterThan(0);
|
|
225
|
-
expect(stored.length).toBeGreaterThan(0);
|
|
226
|
-
expect(collected.length).toBe(stored.length);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("markdown file update → mdsync put (with current)", async () => {
|
|
230
|
-
const blocks = new MemoryBlockstore();
|
|
231
|
-
// Bootstrap with a markdown file via mdsync
|
|
232
|
-
const mdsync = await import("../../src/mdsync/index.js");
|
|
233
|
-
const { mdEntryCid, additions } = await mdsync.v0Put("# Old\n");
|
|
234
|
-
for (const b of additions) await blocks.put(b as any);
|
|
235
|
-
|
|
236
|
-
const { Agent, Name, Revision } = await import("@storacha/ucn/pail");
|
|
237
|
-
const agent = await Agent.generate();
|
|
238
|
-
const name = await Name.create(agent);
|
|
239
|
-
const init = await Revision.v0Put(blocks, "readme.md", mdEntryCid);
|
|
240
|
-
await storeBlocks(blocks, init.additions);
|
|
241
|
-
await blocks.put(init.revision.event as any);
|
|
242
|
-
const { value } = await (await import("@storacha/ucn/pail/value")).from(blocks, name, init.revision);
|
|
243
|
-
|
|
244
|
-
await writeFile("readme.md", "# New\n\nUpdated content.\n");
|
|
245
|
-
const changes: FileChange[] = [{ type: "change", path: "readme.md" }];
|
|
246
|
-
|
|
247
|
-
const { sink, collected } = makeBlockCollector();
|
|
248
|
-
const stored: Block[] = [];
|
|
249
|
-
const store = async (block: Block) => { stored.push(block); };
|
|
250
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink, store);
|
|
251
|
-
|
|
252
|
-
expect(ops).toHaveLength(1);
|
|
253
|
-
expect(ops[0].type).toBe("put");
|
|
254
|
-
expect(ops[0].key).toBe("readme.md");
|
|
255
|
-
expect(collected.length).toBeGreaterThan(0);
|
|
256
|
-
expect(stored.length).toBeGreaterThan(0);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("mixed markdown and regular files", async () => {
|
|
260
|
-
await writeFile("readme.md", "# Readme\n");
|
|
261
|
-
await writeFile("data.txt", "some data");
|
|
262
|
-
const changes: FileChange[] = [
|
|
263
|
-
{ type: "add", path: "readme.md" },
|
|
264
|
-
{ type: "add", path: "data.txt" },
|
|
265
|
-
];
|
|
266
|
-
|
|
267
|
-
const { sink } = makeBlockCollector();
|
|
268
|
-
const ops = await processChanges(changes, tmpDir, null, { get: async () => undefined }, sink);
|
|
269
|
-
|
|
270
|
-
expect(ops).toHaveLength(2);
|
|
271
|
-
const mdOp = ops.find((o) => o.key === "readme.md");
|
|
272
|
-
const txtOp = ops.find((o) => o.key === "data.txt");
|
|
273
|
-
expect(mdOp).toBeDefined();
|
|
274
|
-
expect(mdOp!.type).toBe("put");
|
|
275
|
-
expect(txtOp).toBeDefined();
|
|
276
|
-
expect(txtOp!.type).toBe("put");
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("markdown delete → del op", async () => {
|
|
280
|
-
const blocks = new MemoryBlockstore();
|
|
281
|
-
const mdsync = await import("../../src/mdsync/index.js");
|
|
282
|
-
const { mdEntryCid, additions } = await mdsync.v0Put("# Delete me\n");
|
|
283
|
-
for (const b of additions) await blocks.put(b as any);
|
|
284
|
-
|
|
285
|
-
const { Agent, Name, Revision } = await import("@storacha/ucn/pail");
|
|
286
|
-
const agent = await Agent.generate();
|
|
287
|
-
const name = await Name.create(agent);
|
|
288
|
-
const init = await Revision.v0Put(blocks, "delete.md", mdEntryCid);
|
|
289
|
-
await storeBlocks(blocks, init.additions);
|
|
290
|
-
await blocks.put(init.revision.event as any);
|
|
291
|
-
const { value } = await (await import("@storacha/ucn/pail/value")).from(blocks, name, init.revision);
|
|
292
|
-
|
|
293
|
-
const changes: FileChange[] = [{ type: "unlink", path: "delete.md" }];
|
|
294
|
-
const { sink } = makeBlockCollector();
|
|
295
|
-
const ops = await processChanges(changes, tmpDir, value, blocks, sink);
|
|
296
|
-
|
|
297
|
-
expect(ops).toHaveLength(1);
|
|
298
|
-
expect(ops[0].type).toBe("del");
|
|
299
|
-
expect(ops[0].key).toBe("delete.md");
|
|
300
|
-
});
|
|
301
|
-
});
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as http from "node:http";
|
|
6
|
-
import { CID } from "multiformats/cid";
|
|
7
|
-
import { sha256 } from "multiformats/hashes/sha2";
|
|
8
|
-
import * as raw from "multiformats/codecs/raw";
|
|
9
|
-
|
|
10
|
-
import { applyRemoteChanges } from "../../src/handlers/remote.js";
|
|
11
|
-
|
|
12
|
-
// --- Helpers ---
|
|
13
|
-
|
|
14
|
-
const createTestCID = async (content: string) => {
|
|
15
|
-
const bytes = new TextEncoder().encode(content);
|
|
16
|
-
const hash = await sha256.digest(bytes);
|
|
17
|
-
return CID.create(1, raw.code, hash);
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Spin up a minimal mock IPFS gateway that returns file bytes for known CIDs.
|
|
22
|
-
*/
|
|
23
|
-
const createMockGateway = (
|
|
24
|
-
files: Map<string, Uint8Array>,
|
|
25
|
-
): Promise<{ url: string; close: () => Promise<void> }> => {
|
|
26
|
-
return new Promise((resolve) => {
|
|
27
|
-
const server = http.createServer((req, res) => {
|
|
28
|
-
const cidStr = req.url?.replace("/ipfs/", "");
|
|
29
|
-
const data = cidStr ? files.get(cidStr) : undefined;
|
|
30
|
-
if (data) {
|
|
31
|
-
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
32
|
-
res.end(data);
|
|
33
|
-
} else {
|
|
34
|
-
res.writeHead(404);
|
|
35
|
-
res.end("Not Found");
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
server.listen(0, "127.0.0.1", () => {
|
|
40
|
-
const addr = server.address() as { port: number };
|
|
41
|
-
resolve({
|
|
42
|
-
url: `http://127.0.0.1:${addr.port}`,
|
|
43
|
-
close: () => new Promise((r) => server.close(() => r())),
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// --- Tests ---
|
|
50
|
-
|
|
51
|
-
describe("applyRemoteChanges", () => {
|
|
52
|
-
let tmpDir: string;
|
|
53
|
-
|
|
54
|
-
beforeEach(async () => {
|
|
55
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawracha-remote-"));
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterEach(async () => {
|
|
59
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("should fetch and write a new file from gateway", async () => {
|
|
63
|
-
const content = new TextEncoder().encode("hello from storacha");
|
|
64
|
-
const cid = await createTestCID("hello from storacha");
|
|
65
|
-
|
|
66
|
-
const gateway = await createMockGateway(new Map([[cid.toString(), content]]));
|
|
67
|
-
try {
|
|
68
|
-
const entries = new Map<string, CID>([["docs/hello.txt", cid]]);
|
|
69
|
-
await applyRemoteChanges(["docs/hello.txt"], entries, tmpDir, {
|
|
70
|
-
gateway: gateway.url,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const written = await fs.readFile(path.join(tmpDir, "docs/hello.txt"));
|
|
74
|
-
expect(new Uint8Array(written)).toEqual(content);
|
|
75
|
-
} finally {
|
|
76
|
-
await gateway.close();
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("should overwrite an existing file with new content", async () => {
|
|
81
|
-
// Write old content
|
|
82
|
-
await fs.writeFile(path.join(tmpDir, "file.txt"), "old");
|
|
83
|
-
|
|
84
|
-
const content = new TextEncoder().encode("new content");
|
|
85
|
-
const cid = await createTestCID("new content");
|
|
86
|
-
|
|
87
|
-
const gateway = await createMockGateway(new Map([[cid.toString(), content]]));
|
|
88
|
-
try {
|
|
89
|
-
const entries = new Map<string, CID>([["file.txt", cid]]);
|
|
90
|
-
await applyRemoteChanges(["file.txt"], entries, tmpDir, {
|
|
91
|
-
gateway: gateway.url,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const written = await fs.readFile(path.join(tmpDir, "file.txt"), "utf-8");
|
|
95
|
-
expect(written).toBe("new content");
|
|
96
|
-
} finally {
|
|
97
|
-
await gateway.close();
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("should delete a file that was removed remotely", async () => {
|
|
102
|
-
const filePath = path.join(tmpDir, "delete-me.txt");
|
|
103
|
-
await fs.writeFile(filePath, "goodbye");
|
|
104
|
-
|
|
105
|
-
// Entry missing from map = deleted
|
|
106
|
-
const entries = new Map<string, CID>();
|
|
107
|
-
await applyRemoteChanges(["delete-me.txt"], entries, tmpDir);
|
|
108
|
-
|
|
109
|
-
await expect(fs.access(filePath)).rejects.toThrow();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("should not throw when deleting a file that doesn't exist", async () => {
|
|
113
|
-
const entries = new Map<string, CID>();
|
|
114
|
-
await expect(
|
|
115
|
-
applyRemoteChanges(["nonexistent.txt"], entries, tmpDir),
|
|
116
|
-
).resolves.not.toThrow();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("should create nested directories as needed", async () => {
|
|
120
|
-
const content = new TextEncoder().encode("deep file");
|
|
121
|
-
const cid = await createTestCID("deep file");
|
|
122
|
-
|
|
123
|
-
const gateway = await createMockGateway(new Map([[cid.toString(), content]]));
|
|
124
|
-
try {
|
|
125
|
-
const entries = new Map<string, CID>([["a/b/c/deep.txt", cid]]);
|
|
126
|
-
await applyRemoteChanges(["a/b/c/deep.txt"], entries, tmpDir, {
|
|
127
|
-
gateway: gateway.url,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const written = await fs.readFile(path.join(tmpDir, "a/b/c/deep.txt"));
|
|
131
|
-
expect(new Uint8Array(written)).toEqual(content);
|
|
132
|
-
} finally {
|
|
133
|
-
await gateway.close();
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("should throw on gateway fetch failure", async () => {
|
|
138
|
-
const cid = await createTestCID("missing");
|
|
139
|
-
|
|
140
|
-
// Gateway with no files → 404
|
|
141
|
-
const gateway = await createMockGateway(new Map());
|
|
142
|
-
try {
|
|
143
|
-
const entries = new Map<string, CID>([["fail.txt", cid]]);
|
|
144
|
-
await expect(
|
|
145
|
-
applyRemoteChanges(["fail.txt"], entries, tmpDir, {
|
|
146
|
-
gateway: gateway.url,
|
|
147
|
-
}),
|
|
148
|
-
).rejects.toThrow(/Gateway fetch failed/);
|
|
149
|
-
} finally {
|
|
150
|
-
await gateway.close();
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("should resolve markdown file via mdsync instead of gateway", async () => {
|
|
155
|
-
// Set up a pail with a markdown entry
|
|
156
|
-
const { MemoryBlockstore } = await import("@storacha/ucn/block");
|
|
157
|
-
const { Agent, Name, Revision } = await import("@storacha/ucn/pail");
|
|
158
|
-
const Value = await import("@storacha/ucn/pail/value");
|
|
159
|
-
const mdsync = await import("../../src/mdsync/index.js");
|
|
160
|
-
|
|
161
|
-
const blocks = new MemoryBlockstore();
|
|
162
|
-
const md = "# Remote Doc\n\nFrom another device.\n";
|
|
163
|
-
const { mdEntryCid, additions } = await mdsync.v0Put(md);
|
|
164
|
-
for (const b of additions) await blocks.put(b as any);
|
|
165
|
-
|
|
166
|
-
const agent = await Agent.generate();
|
|
167
|
-
const name = await Name.create(agent);
|
|
168
|
-
const rev = await Revision.v0Put(blocks, "doc.md", mdEntryCid);
|
|
169
|
-
for (const b of rev.additions) await blocks.put(b as any);
|
|
170
|
-
await blocks.put(rev.revision.event as any);
|
|
171
|
-
const { value } = await Value.from(blocks, name, rev.revision);
|
|
172
|
-
|
|
173
|
-
const entries = new Map<string, CID>([["doc.md", mdEntryCid as unknown as CID]]);
|
|
174
|
-
await applyRemoteChanges(["doc.md"], entries, tmpDir, {
|
|
175
|
-
blocks,
|
|
176
|
-
current: value,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
const written = await fs.readFile(path.join(tmpDir, "doc.md"), "utf-8");
|
|
180
|
-
expect(written).toBe(md);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { MemoryBlockstore } from "@storacha/ucn/block";
|
|
3
|
-
import * as Revision from "@storacha/ucn/pail/revision";
|
|
4
|
-
import * as Value from "@storacha/ucn/pail/value";
|
|
5
|
-
import { Block } from "multiformats";
|
|
6
|
-
import * as mdsync from "../../src/mdsync/index.js";
|
|
7
|
-
|
|
8
|
-
class TestBlockstore extends MemoryBlockstore {
|
|
9
|
-
async putMany(blocks: Block[]) {
|
|
10
|
-
for (const block of blocks) {
|
|
11
|
-
await this.put(block);
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const mockName = {} as any;
|
|
17
|
-
|
|
18
|
-
/** Helper: v0Put markdown, store blocks, create ValueView. */
|
|
19
|
-
async function initPail(blocks: TestBlockstore, key: string, md: string) {
|
|
20
|
-
const { mdEntryCid, additions } = await mdsync.v0Put(md);
|
|
21
|
-
await blocks.putMany(additions);
|
|
22
|
-
const rev = await Revision.v0Put(blocks, key, mdEntryCid);
|
|
23
|
-
await blocks.putMany(rev.additions);
|
|
24
|
-
await blocks.put(rev.revision.event);
|
|
25
|
-
const { value } = await Value.from(blocks, mockName, rev.revision);
|
|
26
|
-
return value;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Helper: put markdown update, store blocks, create new ValueView. */
|
|
30
|
-
async function updatePail(
|
|
31
|
-
blocks: TestBlockstore,
|
|
32
|
-
current: any,
|
|
33
|
-
key: string,
|
|
34
|
-
md: string,
|
|
35
|
-
) {
|
|
36
|
-
const { mdEntryCid, additions } = await mdsync.put(blocks, current, key, md);
|
|
37
|
-
await blocks.putMany(additions);
|
|
38
|
-
const rev = await Revision.put(blocks, current, key, mdEntryCid);
|
|
39
|
-
await blocks.putMany(rev.additions);
|
|
40
|
-
await blocks.put(rev.revision.event);
|
|
41
|
-
const { value } = await Value.from(blocks, mockName, rev.revision);
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe("mdsync", () => {
|
|
46
|
-
it("v0Put then get returns the markdown", async () => {
|
|
47
|
-
const blocks = new TestBlockstore();
|
|
48
|
-
const md = "# Hello\n\nThis is a test.\n";
|
|
49
|
-
|
|
50
|
-
const value = await initPail(blocks, "test.md", md);
|
|
51
|
-
const retrieved = await mdsync.get(blocks, value, "test.md");
|
|
52
|
-
expect(retrieved).toBe(md);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("put updates existing markdown", async () => {
|
|
56
|
-
const blocks = new TestBlockstore();
|
|
57
|
-
const md1 = "# Hello\n\nFirst version.\n";
|
|
58
|
-
const md2 = "# Hello\n\nSecond version.\n";
|
|
59
|
-
|
|
60
|
-
const v1 = await initPail(blocks, "test.md", md1);
|
|
61
|
-
const v2 = await updatePail(blocks, v1, "test.md", md2);
|
|
62
|
-
|
|
63
|
-
const retrieved = await mdsync.get(blocks, v2, "test.md");
|
|
64
|
-
expect(retrieved).toBe(md2);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("get returns undefined for missing key", async () => {
|
|
68
|
-
const blocks = new TestBlockstore();
|
|
69
|
-
const value = await initPail(blocks, "test.md", "# Hello\n");
|
|
70
|
-
|
|
71
|
-
const retrieved = await mdsync.get(blocks, value, "missing.md");
|
|
72
|
-
expect(retrieved).toBeUndefined();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("multiple sequential puts", async () => {
|
|
76
|
-
const blocks = new TestBlockstore();
|
|
77
|
-
|
|
78
|
-
const v1 = await initPail(blocks, "doc.md", "# V1\n");
|
|
79
|
-
const v2 = await updatePail(blocks, v1, "doc.md", "# V2\n\nNew paragraph.\n");
|
|
80
|
-
const v3 = await updatePail(blocks, v2, "doc.md", "# V3\n\nNew paragraph.\n\nAnother one.\n");
|
|
81
|
-
|
|
82
|
-
const retrieved = await mdsync.get(blocks, v3, "doc.md");
|
|
83
|
-
expect(retrieved).toBe("# V3\n\nNew paragraph.\n\nAnother one.\n");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("resolves concurrent edits from two heads", async () => {
|
|
87
|
-
const blocks = new TestBlockstore();
|
|
88
|
-
|
|
89
|
-
// Common ancestor
|
|
90
|
-
const v0 = await initPail(blocks, "doc.md", "# Doc\n\nOriginal.\n");
|
|
91
|
-
|
|
92
|
-
// Two concurrent edits branching from v0
|
|
93
|
-
const { mdEntryCid: cid1, additions: a1 } = await mdsync.put(
|
|
94
|
-
blocks, v0, "doc.md", "# Doc\n\nOriginal.\n\nFrom replica 1.\n",
|
|
95
|
-
);
|
|
96
|
-
await blocks.putMany(a1);
|
|
97
|
-
const rev1 = await Revision.put(blocks, v0, "doc.md", cid1);
|
|
98
|
-
await blocks.putMany(rev1.additions);
|
|
99
|
-
await blocks.put(rev1.revision.event);
|
|
100
|
-
|
|
101
|
-
const { mdEntryCid: cid2, additions: a2 } = await mdsync.put(
|
|
102
|
-
blocks, v0, "doc.md", "# Doc\n\nOriginal.\n\nFrom replica 2.\n",
|
|
103
|
-
);
|
|
104
|
-
await blocks.putMany(a2);
|
|
105
|
-
const rev2 = await Revision.put(blocks, v0, "doc.md", cid2);
|
|
106
|
-
await blocks.putMany(rev2.additions);
|
|
107
|
-
await blocks.put(rev2.revision.event);
|
|
108
|
-
|
|
109
|
-
// Merge: ValueView with both heads
|
|
110
|
-
const { value: merged } = await Value.from(
|
|
111
|
-
blocks, mockName, rev1.revision, rev2.revision,
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const result = await mdsync.get(blocks, merged, "doc.md");
|
|
115
|
-
expect(result).toBeDefined();
|
|
116
|
-
// Both edits should be present in the resolved document
|
|
117
|
-
expect(result).toContain("From replica 1.");
|
|
118
|
-
expect(result).toContain("From replica 2.");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { CID } from "multiformats/cid";
|
|
3
|
-
import { sha256 } from "multiformats/hashes/sha2";
|
|
4
|
-
import * as raw from "multiformats/codecs/raw";
|
|
5
|
-
import {
|
|
6
|
-
diffEntries,
|
|
7
|
-
diffRemoteChanges,
|
|
8
|
-
type PailEntries,
|
|
9
|
-
type LocalEntries,
|
|
10
|
-
} from "../../src/utils/differ.js";
|
|
11
|
-
|
|
12
|
-
const createCID = async (content: string) => {
|
|
13
|
-
const bytes = new TextEncoder().encode(content);
|
|
14
|
-
const hash = await sha256.digest(bytes);
|
|
15
|
-
return CID.create(1, raw.code, hash);
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
describe("diffEntries", () => {
|
|
19
|
-
it("should detect new files", async () => {
|
|
20
|
-
const local: LocalEntries = new Map([
|
|
21
|
-
["new.md", await createCID("new content")],
|
|
22
|
-
]);
|
|
23
|
-
const pail: PailEntries = new Map();
|
|
24
|
-
|
|
25
|
-
const ops = diffEntries(local, pail);
|
|
26
|
-
|
|
27
|
-
expect(ops).toHaveLength(1);
|
|
28
|
-
expect(ops[0].type).toBe("put");
|
|
29
|
-
expect(ops[0].key).toBe("new.md");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("should detect changed files", async () => {
|
|
33
|
-
const cidOld = await createCID("old content");
|
|
34
|
-
const cidNew = await createCID("new content");
|
|
35
|
-
|
|
36
|
-
const local: LocalEntries = new Map([["file.md", cidNew]]);
|
|
37
|
-
const pail: PailEntries = new Map([["file.md", cidOld]]);
|
|
38
|
-
|
|
39
|
-
const ops = diffEntries(local, pail);
|
|
40
|
-
|
|
41
|
-
expect(ops).toHaveLength(1);
|
|
42
|
-
expect(ops[0].type).toBe("put");
|
|
43
|
-
expect(ops[0].key).toBe("file.md");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("should detect deleted files", async () => {
|
|
47
|
-
const cid = await createCID("content");
|
|
48
|
-
|
|
49
|
-
const local: LocalEntries = new Map();
|
|
50
|
-
const pail: PailEntries = new Map([["deleted.md", cid]]);
|
|
51
|
-
|
|
52
|
-
const ops = diffEntries(local, pail);
|
|
53
|
-
|
|
54
|
-
expect(ops).toHaveLength(1);
|
|
55
|
-
expect(ops[0].type).toBe("del");
|
|
56
|
-
expect(ops[0].key).toBe("deleted.md");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("should ignore unchanged files", async () => {
|
|
60
|
-
const cid = await createCID("same content");
|
|
61
|
-
|
|
62
|
-
const local: LocalEntries = new Map([["unchanged.md", cid]]);
|
|
63
|
-
const pail: PailEntries = new Map([["unchanged.md", cid]]);
|
|
64
|
-
|
|
65
|
-
const ops = diffEntries(local, pail);
|
|
66
|
-
|
|
67
|
-
expect(ops).toHaveLength(0);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("diffRemoteChanges", () => {
|
|
72
|
-
it("should detect files added remotely", async () => {
|
|
73
|
-
const cid = await createCID("remote file");
|
|
74
|
-
|
|
75
|
-
const before: PailEntries = new Map();
|
|
76
|
-
const after: PailEntries = new Map([["remote.md", cid]]);
|
|
77
|
-
|
|
78
|
-
const changes = diffRemoteChanges(before, after);
|
|
79
|
-
|
|
80
|
-
expect(changes).toEqual(["remote.md"]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("should detect files changed remotely", async () => {
|
|
84
|
-
const cidOld = await createCID("old");
|
|
85
|
-
const cidNew = await createCID("new");
|
|
86
|
-
|
|
87
|
-
const before: PailEntries = new Map([["file.md", cidOld]]);
|
|
88
|
-
const after: PailEntries = new Map([["file.md", cidNew]]);
|
|
89
|
-
|
|
90
|
-
const changes = diffRemoteChanges(before, after);
|
|
91
|
-
|
|
92
|
-
expect(changes).toEqual(["file.md"]);
|
|
93
|
-
});
|
|
94
|
-
});
|