@storacha/clawracha 0.0.4 → 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/src/sync.ts DELETED
@@ -1,258 +0,0 @@
1
- /**
2
- * Sync engine - orchestrates the full sync loop
3
- *
4
- * 1. Watch for file changes
5
- * 2. Encode changed files to root CIDs
6
- * 3. Diff local vs pail entries → put/del ops
7
- * 4. Generate UCN revision via batch (puts) + individual ops (dels)
8
- * 5. Upload all blocks as CAR
9
- * 6. Apply remote changes to local filesystem
10
- */
11
-
12
- import { CID } from "multiformats/cid";
13
- // UCN Pail imports
14
- import { Agent, Name, NoValueError, Revision } from "@storacha/ucn/pail";
15
- import type { NameView, ValueView } from "@storacha/ucn/pail/api";
16
-
17
- import type {
18
- SyncState,
19
- FileChange,
20
- PailOp,
21
- DeviceConfig,
22
- } from "./types/index.js";
23
- import type { Block } from "multiformats";
24
- import {
25
- createWorkspaceBlockstore,
26
- type WorkspaceBlockstore,
27
- } from "./blockstore/index.js";
28
- import { applyPendingOps } from "./handlers/apply.js";
29
- import { applyRemoteChanges } from "./handlers/remote.js";
30
- import { processChanges } from "./handlers/process.js";
31
- import { diffRemoteChanges, type PailEntries } from "./utils/differ.js";
32
- import { WritableCar, makeTempCar } from "./utils/tempcar.js";
33
- import { Client } from "@storacha/client";
34
-
35
- export class SyncEngine {
36
- private workspace: string;
37
- private blocks: WorkspaceBlockstore;
38
- private name: NameView | null = null;
39
- private current: ValueView | null = null;
40
- private pendingOps: PailOp[] = [];
41
- private carFile: WritableCar | null = null;
42
- private running = false;
43
- private lastSync: number | null = null;
44
- private storachaClient: Client;
45
-
46
- constructor(storachaClient: Client, workspace: string) {
47
- this.storachaClient = storachaClient;
48
- this.workspace = workspace;
49
- this.blocks = createWorkspaceBlockstore(workspace);
50
- }
51
-
52
- /**
53
- * Initialize sync engine with device config
54
- */
55
- async init(config: DeviceConfig): Promise<void> {
56
- const agent = Agent.parse(config.agentKey);
57
-
58
- if (config.nameArchive) {
59
- // Restore from previously saved archive (has full state)
60
- const archiveBytes = Buffer.from(config.nameArchive, "base64");
61
- this.name = await Name.extract(agent, archiveBytes);
62
- } else if (config.nameDelegation) {
63
- // Reconstruct from delegation (granted by another device)
64
- const { extract } = await import("@storacha/client/delegation");
65
- const nameBytes = Buffer.from(config.nameDelegation, "base64");
66
- const { ok: delegation } = await extract(nameBytes);
67
- if (!delegation) throw new Error("Failed to extract name delegation");
68
- this.name = Name.from(agent, [delegation]);
69
- } else {
70
- // First device — create a new name
71
- this.name = await Name.create(agent);
72
- }
73
-
74
- try {
75
- const result = await Revision.resolve(this.blocks, this.name);
76
- this.current = result.value;
77
- await this.storeBlocks(result.additions);
78
- } catch (err) {
79
- if (err instanceof NoValueError) {
80
- this.current = null;
81
- } else {
82
- throw err;
83
- }
84
- }
85
- }
86
-
87
- /**
88
- * Process a batch of file changes
89
- */
90
- async processChanges(changes: FileChange[]): Promise<void> {
91
- if (!this.carFile) {
92
- this.carFile = await makeTempCar();
93
- }
94
- const pendingOps = await processChanges(
95
- changes,
96
- this.workspace,
97
- this.current,
98
- this.blocks,
99
- (block) => this.carFile!.put(block),
100
- (block) => this.blocks.put(block),
101
- );
102
- this.pendingOps.push(...pendingOps);
103
- }
104
-
105
- /**
106
- * Execute sync: generate revision, publish, upload, apply remote changes
107
- */
108
- async sync(): Promise<void> {
109
- if (!this.name) {
110
- throw new Error("Sync engine not initialized");
111
- }
112
-
113
- const beforeEntries = await this.getPailEntries();
114
-
115
- if (this.pendingOps.length > 0) {
116
- if (!this.carFile) {
117
- throw new Error("CAR file not initialized");
118
- }
119
- const result = await applyPendingOps(
120
- this.blocks,
121
- this.name,
122
- this.current,
123
- this.pendingOps,
124
- );
125
- this.current = result.current;
126
- for (const block of result.revisionBlocks) {
127
- await this.carFile.put(block);
128
- }
129
- await this.storeBlocks(result.revisionBlocks);
130
- } else {
131
- // No pending ops — just pull remote
132
- try {
133
- const result = await Revision.resolve(this.blocks, this.name, {
134
- base: this.current ?? undefined,
135
- });
136
- await this.storeBlocks(result.additions);
137
- this.current = result.value;
138
- } catch (err) {
139
- if (!(err instanceof NoValueError)) throw err;
140
- }
141
- }
142
-
143
- this.pendingOps = [];
144
-
145
- // Upload all accumulated blocks as CAR
146
- await this.possiblyUploadCAR();
147
-
148
- // Apply remote changes
149
- const afterEntries = await this.getPailEntries();
150
- const remoteChanges = diffRemoteChanges(beforeEntries, afterEntries);
151
- if (remoteChanges.length > 0) {
152
- await this.applyRemoteChanges(remoteChanges, afterEntries);
153
- }
154
-
155
- this.lastSync = Date.now();
156
- }
157
-
158
- /**
159
- * Create CAR and upload to Storacha
160
- */
161
- private async possiblyUploadCAR(): Promise<void> {
162
- if (this.carFile) {
163
- const readableCar = await this.carFile.switchToReadable();
164
- this.carFile = null;
165
- if (readableCar) {
166
- try {
167
- await this.storachaClient.uploadCAR(readableCar.readable);
168
- } finally {
169
- await readableCar.cleanup();
170
- }
171
- }
172
- }
173
- }
174
-
175
- /**
176
- * Get current pail entries as map
177
- */
178
- async getPailEntries(): Promise<PailEntries> {
179
- const entries: PailEntries = new Map();
180
- if (!this.current) return entries;
181
-
182
- for await (const [key, value] of Revision.entries(
183
- this.blocks,
184
- this.current,
185
- )) {
186
- const cid = value as CID;
187
- if (cid) entries.set(key, cid);
188
- }
189
- return entries;
190
- }
191
-
192
- /**
193
- * Apply remote changes to local filesystem
194
- */
195
- private async applyRemoteChanges(
196
- changedPaths: string[],
197
- entries: PailEntries,
198
- ): Promise<void> {
199
- await applyRemoteChanges(changedPaths, entries, this.workspace, {
200
- blocks: this.blocks,
201
- current: this.current ?? undefined,
202
- });
203
- }
204
-
205
-
206
- /**
207
- * Pull all remote state and write to local filesystem.
208
- * Used by /storacha-join to overwrite local with remote before watcher starts.
209
- */
210
- async pullRemote(): Promise<number> {
211
- if (!this.name) throw new Error("Sync engine not initialized");
212
-
213
- try {
214
- const result = await Revision.resolve(this.blocks, this.name, {
215
- base: this.current ?? undefined,
216
- });
217
- await this.storeBlocks(result.additions);
218
- this.current = result.value;
219
- } catch (err) {
220
- if (!(err instanceof NoValueError)) throw err;
221
- }
222
-
223
- const entries = await this.getPailEntries();
224
- if (entries.size > 0) {
225
- const allPaths = [...entries.keys()];
226
- await applyRemoteChanges(allPaths, entries, this.workspace, {
227
- blocks: this.blocks,
228
- current: this.current ?? undefined,
229
- });
230
- }
231
-
232
- this.lastSync = Date.now();
233
- return entries.size;
234
- }
235
-
236
- async status(): Promise<SyncState> {
237
- const entries = await this.getPailEntries();
238
- return {
239
- running: this.running,
240
- lastSync: this.lastSync,
241
- root: this.current?.root ?? null,
242
- entryCount: entries.size,
243
- pendingChanges: this.pendingOps.length,
244
- };
245
- }
246
-
247
- async exportNameArchive(): Promise<string> {
248
- if (!this.name) throw new Error("Sync engine not initialized");
249
- const bytes = await this.name.archive();
250
- return Buffer.from(bytes).toString("base64");
251
- }
252
-
253
- private async storeBlocks(blocks: Block[]): Promise<void> {
254
- for (const block of blocks) {
255
- await this.blocks.put(block);
256
- }
257
- }
258
- }
@@ -1,64 +0,0 @@
1
- /**
2
- * Type definitions for Storacha workspace sync
3
- */
4
-
5
- import type { UnknownLink } from "multiformats";
6
- import type { Block } from "multiformats";
7
- import type { CID } from "multiformats/cid";
8
-
9
- /** Plugin configuration (from openclaw.plugin.json schema) */
10
- export interface SyncPluginConfig {
11
- enabled: boolean;
12
- watchPatterns: string[];
13
- ignorePatterns: string[];
14
- }
15
-
16
- /** Stored in .storacha/config.json — device-specific, not synced */
17
- export interface DeviceConfig {
18
- /** Ed25519 agent private key (base64) */
19
- agentKey: string;
20
- /** UCN name archive (base64 CAR) */
21
- nameArchive?: string;
22
- /** Space → agent upload delegation (base64 archive) */
23
- uploadDelegation?: string;
24
- /** Name → agent delegation for pail sync (base64 archive) */
25
- nameDelegation?: string;
26
- /** Space DID extracted from upload delegation */
27
- spaceDID?: string;
28
- /** Whether setup is complete (watcher won't start without this) */
29
- setupComplete?: boolean;
30
- }
31
-
32
- /** Current sync state */
33
- export interface SyncState {
34
- /** Is sync currently running? */
35
- running: boolean;
36
- /** Last successful sync timestamp */
37
- lastSync: number | null;
38
- /** Current pail root CID */
39
- root: UnknownLink | null;
40
- /** Number of tracked entries */
41
- entryCount: number;
42
- /** Pending local changes not yet pushed */
43
- pendingChanges: number;
44
- }
45
-
46
- /** File change event from watcher */
47
- export interface FileChange {
48
- type: "add" | "change" | "unlink";
49
- path: string; // relative to workspace
50
- }
51
-
52
- /** Encoded file result */
53
- export interface EncodedFile {
54
- path: string;
55
- blocks: ReadableStream<Block>;
56
- size: number;
57
- }
58
-
59
- /** Diff operation for pail */
60
- export interface PailOp {
61
- type: "put" | "del";
62
- key: string; // file path
63
- value?: CID; // root CID for puts
64
- }
@@ -1,51 +0,0 @@
1
- /**
2
- * Create a @storacha/client instance from an agent private key and upload delegation.
3
- *
4
- * Uses the top-level `create()` factory which accepts principal + store directly,
5
- * avoiding internal @storacha/access imports.
6
- */
7
-
8
- import { Agent as PailAgent } from "@storacha/ucn/pail";
9
- import { create as createClient } from "@storacha/client";
10
- import { StoreMemory } from "@storacha/client/stores/memory";
11
- import { extract } from "@storacha/client/delegation";
12
- import type { DeviceConfig } from "../types/index.js";
13
- import type { Client } from "@storacha/client";
14
-
15
- /**
16
- * Build a Storacha Client from device config.
17
- * Requires agentKey and uploadDelegation to be present.
18
- */
19
- export async function createStorachaClient(
20
- config: DeviceConfig,
21
- ): Promise<Client> {
22
- if (!config.uploadDelegation) {
23
- throw new Error("No upload delegation in device config");
24
- }
25
-
26
- const agent = PailAgent.parse(config.agentKey);
27
-
28
- const client = await createClient({
29
- principal: agent,
30
- store: new StoreMemory(),
31
- });
32
-
33
- // Import the upload delegation (space → agent)
34
- const uploadBytes = Buffer.from(config.uploadDelegation, "base64");
35
- const { ok: uploadDelegation, error: uploadErr } =
36
- await extract(uploadBytes);
37
- if (!uploadDelegation) {
38
- throw new Error(`Failed to extract upload delegation: ${uploadErr}`);
39
- }
40
-
41
- // addSpace registers the space and adds the delegation as a proof
42
- await client.addSpace(uploadDelegation);
43
-
44
- // Set the space as current so uploads target it
45
- const spaceDID = uploadDelegation.capabilities[0]?.with;
46
- if (spaceDID) {
47
- await client.setCurrentSpace(spaceDID as `did:key:${string}`);
48
- }
49
-
50
- return client;
51
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * Differ - compares local directory tree with pail entries
3
- *
4
- * Generates put/del operations to sync local state to pail.
5
- */
6
-
7
- import type { CID } from "multiformats/cid";
8
- import type { PailOp } from "../types/index.js";
9
-
10
- /** Map of path → CID from pail entries */
11
- export type PailEntries = Map<string, CID>;
12
-
13
- /** Map of path → CID from local encoded files */
14
- export type LocalEntries = Map<string, CID>;
15
-
16
- /**
17
- * Compute diff between local files and pail entries
18
- *
19
- * @param local - Encoded local files (path → rootCID)
20
- * @param pail - Current pail entries (path → CID)
21
- * @returns Operations to apply to pail
22
- */
23
- export function diffEntries(local: LocalEntries, pail: PailEntries): PailOp[] {
24
- const ops: PailOp[] = [];
25
-
26
- // Find puts: files in local that are new or changed
27
- for (const [path, localCID] of local) {
28
- const pailCID = pail.get(path);
29
- if (!pailCID || !localCID.equals(pailCID)) {
30
- ops.push({ type: "put", key: path, value: localCID });
31
- }
32
- }
33
-
34
- // Find deletes: files in pail that aren't in local
35
- for (const path of pail.keys()) {
36
- if (!local.has(path)) {
37
- ops.push({ type: "del", key: path });
38
- }
39
- }
40
-
41
- return ops;
42
- }
43
-
44
-
45
- /**
46
- * Diff two pail states to find files that changed remotely
47
- * (Used after publish to determine what to download)
48
- *
49
- * @param before - Pail entries before publish
50
- * @param after - Pail entries after publish (may include remote changes)
51
- * @returns Paths that changed remotely (need to download)
52
- */
53
- export function diffRemoteChanges(
54
- before: PailEntries,
55
- after: PailEntries
56
- ): string[] {
57
- const changed: string[] = [];
58
-
59
- for (const [path, afterCID] of after) {
60
- const beforeCID = before.get(path);
61
- if (!beforeCID || !afterCID.equals(beforeCID)) {
62
- changed.push(path);
63
- }
64
- }
65
-
66
- return changed;
67
- }
@@ -1,64 +0,0 @@
1
- /**
2
- * File encoder - converts files to UnixFS DAG with root CID
3
- *
4
- * Uses @storacha/upload-client's UnixFS encoding to generate
5
- * content-addressed blocks for each file.
6
- */
7
-
8
- import * as fs from "node:fs/promises";
9
- import * as path from "node:path";
10
- import * as stream from "node:stream";
11
- import { createFileEncoderStream } from "@storacha/upload-client/unixfs";
12
- import type { EncodedFile } from "../types/index.js";
13
-
14
- /**
15
- * Encode a single file to UnixFS blocks
16
- */
17
- export async function encodeWorkspaceFile(
18
- workspacePath: string,
19
- relativePath: string,
20
- ): Promise<EncodedFile> {
21
- const fullPath = path.join(workspacePath, relativePath);
22
- const fileHandle = await fs.open(fullPath);
23
- const stat = await fs.stat(fullPath);
24
-
25
- // Encode to UnixFS - returns { cid, blocks }
26
- const blocks = createFileEncoderStream({
27
- // @ts-expect-error node web stream not type compatible with web stream
28
- stream() {
29
- return stream.Readable.toWeb(fileHandle.createReadStream());
30
- },
31
- });
32
-
33
- return {
34
- path: relativePath,
35
- size: stat.size,
36
- blocks: blocks as any,
37
- };
38
- }
39
-
40
- /**
41
- * Encode multiple files, returning all encoded results
42
- */
43
- export async function encodeFiles(
44
- workspacePath: string,
45
- relativePaths: string[],
46
- ): Promise<EncodedFile[]> {
47
- const results: EncodedFile[] = [];
48
-
49
- for (const relativePath of relativePaths) {
50
- try {
51
- const encoded = await encodeWorkspaceFile(workspacePath, relativePath);
52
- results.push(encoded);
53
- } catch (err: any) {
54
- if (err.code === "ENOENT") {
55
- // File was deleted between detection and encoding - skip
56
- console.warn(`File not found during encoding: ${relativePath}`);
57
- continue;
58
- }
59
- throw err;
60
- }
61
- }
62
-
63
- return results;
64
- }
@@ -1,79 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import { createReadStream, createWriteStream } from "node:fs";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import * as stream from "node:stream";
6
- import type { Block } from "multiformats";
7
- import { CarWriter } from "@ipld/car/writer";
8
- import type { BlobLike } from "@storacha/client/types";
9
-
10
- export interface WritableCar {
11
- /**
12
- * Write a block to the CAR file.
13
- * Must not be called after close/switchToReadable.
14
- */
15
- put(block: Block): Promise<void>;
16
- cleanup(): Promise<void>;
17
- /**
18
- * Close the writer and return a readable for upload.
19
- * Returns null if nothing was written.
20
- */
21
- switchToReadable(): Promise<ReadableCar | null>;
22
- }
23
-
24
- interface ReadableCar {
25
- readable: BlobLike;
26
- cleanup(): Promise<void>;
27
- }
28
-
29
- export const makeTempCar = async (): Promise<WritableCar> => {
30
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "sync-clawracha-"));
31
- const cleanup = async () => await fs.rm(dir, { recursive: true });
32
- const file = path.join(dir, "blocks.car");
33
-
34
- // CarWriter.createAppender() creates a headerless/rootless CAR
35
- // which is fine for uploadCAR (the client handles it)
36
- const { writer, out } = CarWriter.createAppender();
37
-
38
- // Pipe the output to a file
39
- const fsWriteStream = createWriteStream(file);
40
- const pipePromise = stream.promises.pipeline(
41
- stream.Readable.from(out),
42
- fsWriteStream,
43
- );
44
-
45
- let didWrite = false;
46
-
47
- const put = async (block: Block) => {
48
- didWrite = true;
49
- await writer.put({ cid: block.cid as any, bytes: block.bytes });
50
- };
51
-
52
- const switchToReadable = async (): Promise<ReadableCar | null> => {
53
- try {
54
- await writer.close();
55
- await pipePromise;
56
-
57
- if (!didWrite) {
58
- await cleanup();
59
- return null;
60
- }
61
-
62
- return {
63
- readable: {
64
- stream: () =>
65
- stream.Readable.toWeb(createReadStream(file)) as unknown as ReadableStream<
66
- Uint8Array<ArrayBuffer>
67
- >,
68
- },
69
- cleanup,
70
- };
71
- } catch (err) {
72
- console.error("Error switching to readable:", err);
73
- await cleanup();
74
- throw err;
75
- }
76
- };
77
-
78
- return { put, switchToReadable, cleanup };
79
- };