@statewalker/webrun-content-transfer 0.2.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-present StateWalker Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @statewalker/webrun-content-transfer
2
+
3
+ Resumable, chunk-aware byte mover over the `@statewalker/webrun-streams` duplex substrate.
4
+
5
+ ## Overview
6
+
7
+ Given the object ids a destination needs, `content-transfer` transfers only the chunks the destination is **missing** from a source `ContentStore` to a destination store, over the domain-neutral `webrun-streams` `Duplex` substrate. It is **symmetric** — either endpoint may be a wire `remoteStore` proxy in front of a `serveStore` handler (upload = remote `to`, download = remote `from`). Every received chunk is verified by re-hash before it is written, and a serializable checkpoint makes an interrupted transfer resumable.
8
+
9
+ It is shared infrastructure: the Phase-4 chunk-dedup `Transfer` that plugs into `@statewalker/webrun-files-sync`, and the engine `@statewalker/vcs-transport-xet` wraps as a Git LFS custom transfer agent. It builds on `@statewalker/webrun-content-store` and knows nothing of git, LFS, or sync — it moves opaque chunks between two content stores.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pnpm add @statewalker/webrun-content-transfer
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```typescript
20
+ import { createHash } from "node:crypto";
21
+ import { memBlobStore } from "@statewalker/webrun-storage";
22
+ import { createContentStore } from "@statewalker/webrun-content-store";
23
+ import { transfer } from "@statewalker/webrun-content-transfer";
24
+
25
+ async function sha256(bytes: AsyncIterable<Uint8Array>): Promise<string> {
26
+ const h = createHash("sha256");
27
+ for await (const chunk of bytes) h.update(chunk);
28
+ return `sha256:${h.digest("hex")}`;
29
+ }
30
+ const mkStore = () =>
31
+ createContentStore({ chunks: memBlobStore(), manifests: memBlobStore(), hashContent: sha256 });
32
+
33
+ const from = mkStore();
34
+ const to = mkStore();
35
+ async function* streamOf(b: Uint8Array) { yield b; }
36
+ const { id } = await from.put(streamOf(payload));
37
+
38
+ // Move only the chunks `to` is missing; re-hash-verify each; emit progress.
39
+ for await (const event of transfer([id], from, to, { hashContent: sha256 })) {
40
+ if (event.type === "object-done") console.log("done", event.objectId);
41
+ }
42
+ // to.read(id) now reconstructs the identical object.
43
+ ```
44
+
45
+ For a remote peer, wrap a `webrun-streams` `Duplex`:
46
+
47
+ ```typescript
48
+ import { remoteStore, serveStore } from "@statewalker/webrun-content-transfer";
49
+
50
+ const handler = serveStore(serverStore); // server: Duplex handler over a ContentStore
51
+ const remote = remoteStore(clientDuplex); // client: an AssemblingStore proxy
52
+ await drain(transfer([id], from, remote, { hashContent: sha256 })); // upload to the remote
53
+ ```
54
+
55
+ ## API
56
+
57
+ - **`transfer(objectIds, from, to, opts?): AsyncGenerator<TransferEvent>`** — the mover. Negotiates missing chunks per object, streams them, verifies by re-hash, and assembles. Events: `resumed` / `negotiated` / `chunk-sent` / `object-done`.
58
+ - **`remoteStore(call: Duplex): AssemblingStore`** — client-side proxy `ContentStore` over a duplex (adds `putManifest` / `capabilities`).
59
+ - **`serveStore(store): Duplex`** — server-side handler exposing a `ContentStore` over a duplex.
60
+ - **`chunkTransfer(local, remote): FileTransfer`** — the `files-sync`-shaped `Transfer` adapter.
61
+
62
+ `TransferOptions` carries optional `limits` (`TransferLimits`: `concurrency`, `maxBufferedBytes`, `batchSize`), `checkpoint` (`TransferCheckpoint`), and `hashContent`. Types: `AssemblingStore`, `Capabilities`, `TransferEvent`, `HashContent`.
63
+
64
+ ## Notes
65
+
66
+ - **Integrity by re-hash.** The re-hash of a received chunk must equal its id (the same injected `hashContent` both stores use) or the chunk is rejected and the transfer fails loudly.
67
+ - **Resumable + bounded.** A serializable `TransferCheckpoint` (done / pending chunk sets) resumes an interrupted transfer after re-verifying the source still holds every object; the pipeline is bounded by `TransferLimits` + `maxBufferedBytes` backpressure.
68
+ - **Symmetric wire.** `hasChunks` negotiation is batched; the `remoteStore` proxy can `putManifest` so assemble costs only the manifest, not the object's bytes.
69
+ - Built red/green TDD.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * files-sync interop: adapt content-store dedup transfer to the files-sync
3
+ * {@link https://github.com/statewalker/vcs `Transfer`} seam. The returned object
4
+ * is structurally a files-sync `Transfer` (kept structural so `src/` stays
5
+ * domain-neutral and never imports files-sync). For a copy/update it ingests the
6
+ * source file into the `local` store, moves the missing chunks to `remote` via
7
+ * {@link transfer}, then writes the reassembled bytes to the destination — so a
8
+ * file whose chunks the remote already holds costs nothing to move.
9
+ */
10
+ import type { ContentStore } from "@statewalker/webrun-content-store";
11
+ import type { FilesApi } from "@statewalker/webrun-files";
12
+ /** The subset of a files-sync `SyncAction` this adapter reads (structural). */
13
+ export interface FileSyncAction {
14
+ kind: string;
15
+ path?: string;
16
+ }
17
+ /** Structurally compatible with files-sync's `Transfer`. */
18
+ export interface FileTransfer {
19
+ run(action: FileSyncAction, from: FilesApi, to: FilesApi): Promise<void>;
20
+ }
21
+ export declare function chunkTransfer(local: ContentStore, remote: ContentStore): FileTransfer;
22
+ //# sourceMappingURL=chunk-transfer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunk-transfer.d.ts","sourceRoot":"","sources":["../src/chunk-transfer.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAG1D,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,GAAG,YAAY,CAsBrF"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @statewalker/webrun-content-transfer
3
+ *
4
+ * Resumable, chunk-aware byte mover. Given the object ids a destination needs,
5
+ * it transfers only the chunks the destination is MISSING from a source
6
+ * {@link ContentStore} to a destination store, over the domain-neutral
7
+ * `@statewalker/webrun-streams` {@link Duplex} substrate. It is symmetric —
8
+ * either endpoint may be a wire {@link remoteStore} proxy in front of a
9
+ * {@link serveStore} handler. Every received chunk is verified by re-hash before
10
+ * it is written; a serializable checkpoint makes an interrupted transfer
11
+ * resumable. It knows nothing of git, LFS, or sync.
12
+ */
13
+ export type { FileSyncAction, FileTransfer } from "./chunk-transfer.js";
14
+ export { chunkTransfer } from "./chunk-transfer.js";
15
+ export { remoteStore } from "./remote-store.js";
16
+ export { serveStore } from "./serve-store.js";
17
+ export { transfer } from "./transfer.js";
18
+ export type { AssemblingStore, Capabilities, HashContent, TransferCheckpoint, TransferEvent, TransferLimits, TransferOptions, } from "./types.js";
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EACV,eAAe,EACf,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,309 @@
1
+ //#region src/transfer.ts
2
+ const DEFAULT_LIMITS = {
3
+ concurrency: 4,
4
+ maxBufferedBytes: 8388608,
5
+ batchSize: 256
6
+ };
7
+ /** A fresh single-shot stream over one buffer (streams are consumed once). */
8
+ async function* one(bytes) {
9
+ yield bytes;
10
+ }
11
+ async function collect(stream) {
12
+ const parts = [];
13
+ for await (const chunk of stream) parts.push(chunk);
14
+ const total = parts.reduce((n, p) => n + p.length, 0);
15
+ const out = new Uint8Array(total);
16
+ let off = 0;
17
+ for (const p of parts) {
18
+ out.set(p, off);
19
+ off += p.length;
20
+ }
21
+ return out;
22
+ }
23
+ /** Re-stream a store's own chunks in manifest order — feeds `put` without buffering. */
24
+ async function* reassembleFromStore(store, manifest) {
25
+ for (const ref of manifest.chunks) yield* store.getChunk(ref.id);
26
+ }
27
+ /**
28
+ * Register `manifest` at `to`, so `to.read(id)` reconstructs the object. When
29
+ * `to` is a wire proxy it assembles server-side from chunks it already holds
30
+ * (only the manifest crosses the wire); otherwise it re-`put`s a local
31
+ * reassembly (cheap, same-process) and verifies the resulting id.
32
+ */
33
+ async function registerManifest(to, manifest) {
34
+ const proxy = to;
35
+ if (typeof proxy.putManifest === "function") {
36
+ await proxy.putManifest(manifest);
37
+ return;
38
+ }
39
+ const descriptor = await to.put(reassembleFromStore(to, manifest));
40
+ if (descriptor.id !== manifest.id) throw new Error(`content-transfer: assemble id mismatch ${descriptor.id} != ${manifest.id}`);
41
+ }
42
+ async function maybeCapabilities(to) {
43
+ const proxy = to;
44
+ if (typeof proxy.capabilities === "function") return proxy.capabilities();
45
+ }
46
+ /**
47
+ * Run `work` over `refs` with at most `concurrency` in flight and never more
48
+ * than `maxBufferedBytes` of chunk bytes buffered (a single over-budget chunk is
49
+ * allowed alone, to guarantee progress). Yields each result as it settles.
50
+ */
51
+ async function* pipeline(refs, limits, work) {
52
+ const inflight = /* @__PURE__ */ new Set();
53
+ let buffered = 0;
54
+ let idx = 0;
55
+ const canLaunch = () => idx < refs.length && inflight.size < limits.concurrency && (inflight.size === 0 || buffered + refs[idx].size <= limits.maxBufferedBytes);
56
+ while (idx < refs.length || inflight.size > 0) {
57
+ while (canLaunch()) {
58
+ const ref = refs[idx++];
59
+ buffered += ref.size;
60
+ const entry = { ref };
61
+ entry.promise = work(ref).then((value) => {
62
+ entry.value = value;
63
+ return entry;
64
+ });
65
+ inflight.add(entry.promise);
66
+ }
67
+ const settled = await Promise.race(inflight);
68
+ inflight.delete(settled.promise);
69
+ buffered -= settled.ref.size;
70
+ yield settled.value;
71
+ }
72
+ }
73
+ async function* transfer(objectIds, from, to, opts) {
74
+ const limits = {
75
+ ...DEFAULT_LIMITS,
76
+ ...opts?.limits
77
+ };
78
+ const hashContent = opts?.hashContent;
79
+ const caps = await maybeCapabilities(to);
80
+ if (opts?.checkpoint) yield {
81
+ type: "resumed",
82
+ done: opts.checkpoint.done.length
83
+ };
84
+ const manifests = /* @__PURE__ */ new Map();
85
+ const refById = /* @__PURE__ */ new Map();
86
+ for (const id of objectIds) {
87
+ const manifest = await from.getManifest(id);
88
+ if (!manifest) throw new Error(`content-transfer: source is missing object ${id}`);
89
+ manifests.set(id, manifest);
90
+ for (const ref of manifest.chunks) if (!refById.has(ref.id)) refById.set(ref.id, ref);
91
+ }
92
+ const allIds = [...refById.keys()];
93
+ const missing = [];
94
+ for (let i = 0; i < allIds.length; i += limits.batchSize) {
95
+ const batch = allIds.slice(i, i + limits.batchSize);
96
+ for (const cid of await to.hasChunks(batch)) {
97
+ const ref = refById.get(cid);
98
+ if (ref) missing.push(ref);
99
+ }
100
+ }
101
+ yield {
102
+ type: "negotiated",
103
+ objects: objectIds.length,
104
+ chunks: allIds.length,
105
+ missing: missing.length,
106
+ protocol: caps?.protocol
107
+ };
108
+ for await (const sent of pipeline(missing, limits, async (ref) => {
109
+ const bytes = await collect(from.getChunk(ref.id));
110
+ if (hashContent) {
111
+ const actual = await hashContent(one(bytes));
112
+ if (actual !== ref.id) throw new Error(`content-transfer: integrity check failed for chunk ${ref.id} (re-hash ${actual})`);
113
+ }
114
+ await to.putChunk(ref.id, one(bytes));
115
+ return {
116
+ id: ref.id,
117
+ size: bytes.length
118
+ };
119
+ })) yield {
120
+ type: "chunk-sent",
121
+ chunkId: sent.id,
122
+ size: sent.size
123
+ };
124
+ for (const id of objectIds) {
125
+ const manifest = manifests.get(id);
126
+ if (!manifest) continue;
127
+ await registerManifest(to, manifest);
128
+ yield {
129
+ type: "object-done",
130
+ objectId: id
131
+ };
132
+ }
133
+ }
134
+ //#endregion
135
+ //#region src/chunk-transfer.ts
136
+ function chunkTransfer(local, remote) {
137
+ return { async run(action, from, to) {
138
+ if (action.kind !== "copy" && action.kind !== "update") throw new Error(`content-transfer: chunkTransfer only handles copy/update, got ${action.kind}`);
139
+ const path = action.path;
140
+ if (!path) throw new Error(`content-transfer: ${action.kind} action has no path`);
141
+ const descriptor = await local.put(from.read(path));
142
+ const iterator = transfer([descriptor.id], local, remote)[Symbol.asyncIterator]();
143
+ while (!(await iterator.next()).done);
144
+ await to.write(path, remote.read(descriptor.id));
145
+ } };
146
+ }
147
+ //#endregion
148
+ //#region src/protocol.ts
149
+ const encoder = new TextEncoder();
150
+ const decoder = new TextDecoder();
151
+ /** Encode `[len][header][body?]` as a byte stream. */
152
+ async function* frameMessage(header, body) {
153
+ const json = encoder.encode(JSON.stringify(header));
154
+ const prefix = /* @__PURE__ */ new Uint8Array(4);
155
+ prefix[0] = json.length >>> 24 & 255;
156
+ prefix[1] = json.length >>> 16 & 255;
157
+ prefix[2] = json.length >>> 8 & 255;
158
+ prefix[3] = json.length & 255;
159
+ yield prefix;
160
+ yield json;
161
+ if (body) {
162
+ for await (const b of body) if (b.byteLength) yield b;
163
+ }
164
+ }
165
+ /** Read one framed message: its parsed header + a body byte stream. Accepts
166
+ * either side of a {@link Duplex} (sync or async byte iterable). */
167
+ async function readFrame(input) {
168
+ const iter = Symbol.asyncIterator in input ? input[Symbol.asyncIterator]() : (() => {
169
+ const sync = input[Symbol.iterator]();
170
+ return { next: async () => sync.next() };
171
+ })();
172
+ let buf = /* @__PURE__ */ new Uint8Array(0);
173
+ async function fill(n) {
174
+ while (buf.length < n) {
175
+ const { value, done } = await iter.next();
176
+ if (done) throw new Error("content-transfer protocol: unexpected end of stream");
177
+ const next = new Uint8Array(buf.length + value.length);
178
+ next.set(buf, 0);
179
+ next.set(value, buf.length);
180
+ buf = next;
181
+ }
182
+ }
183
+ await fill(4);
184
+ const headerLen = (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
185
+ await fill(4 + headerLen);
186
+ const header = JSON.parse(decoder.decode(buf.subarray(4, 4 + headerLen)));
187
+ const rest = buf.subarray(4 + headerLen);
188
+ async function* body() {
189
+ if (rest.byteLength) yield rest;
190
+ while (true) {
191
+ const { value, done } = await iter.next();
192
+ if (done) return;
193
+ if (value.byteLength) yield value;
194
+ }
195
+ }
196
+ return {
197
+ header,
198
+ body: body()
199
+ };
200
+ }
201
+ //#endregion
202
+ //#region src/remote-store.ts
203
+ const unsupported = (name) => {
204
+ throw new Error(`content-transfer: remote proxy does not support "${name}"`);
205
+ };
206
+ function remoteStore(call) {
207
+ async function request(header, body) {
208
+ const { header: resp, body: respBody } = await readFrame(call(frameMessage(header, body)));
209
+ if (!resp.ok) throw new Error(resp.error ?? "content-transfer: remote error");
210
+ return {
211
+ header: resp,
212
+ body: respBody
213
+ };
214
+ }
215
+ return {
216
+ async capabilities() {
217
+ const { header } = await request({ op: "capabilities" });
218
+ if (!header.capabilities) throw new Error("content-transfer: no capabilities in response");
219
+ return header.capabilities;
220
+ },
221
+ async getManifest(id) {
222
+ const { header } = await request({
223
+ op: "getManifest",
224
+ id
225
+ });
226
+ return header.manifest ?? void 0;
227
+ },
228
+ async hasChunks(ids) {
229
+ const { header } = await request({
230
+ op: "hasChunks",
231
+ ids
232
+ });
233
+ return header.missing ?? [];
234
+ },
235
+ getChunk(id) {
236
+ return (async function* () {
237
+ const { body } = await request({
238
+ op: "getChunk",
239
+ id
240
+ });
241
+ yield* body;
242
+ })();
243
+ },
244
+ async putChunk(id, bytes) {
245
+ await request({
246
+ op: "putChunk",
247
+ id
248
+ }, bytes);
249
+ },
250
+ async putManifest(manifest) {
251
+ await request({
252
+ op: "putManifest",
253
+ manifest
254
+ });
255
+ },
256
+ put: () => unsupported("put"),
257
+ has: () => unsupported("has"),
258
+ read: () => unsupported("read"),
259
+ remove: () => unsupported("remove"),
260
+ gc: () => unsupported("gc")
261
+ };
262
+ }
263
+ //#endregion
264
+ //#region src/serve-store.ts
265
+ const PROTOCOL = "content-transfer/1";
266
+ function serveStore(store) {
267
+ return async function* handler(input) {
268
+ const { header, body } = await readFrame(input);
269
+ switch (header.op) {
270
+ case "capabilities":
271
+ yield* frameMessage({
272
+ ok: true,
273
+ capabilities: { protocol: PROTOCOL }
274
+ });
275
+ return;
276
+ case "getManifest":
277
+ yield* frameMessage({
278
+ ok: true,
279
+ manifest: await store.getManifest(header.id) ?? null
280
+ });
281
+ return;
282
+ case "hasChunks":
283
+ yield* frameMessage({
284
+ ok: true,
285
+ missing: await store.hasChunks(header.ids)
286
+ });
287
+ return;
288
+ case "getChunk":
289
+ yield* frameMessage({ ok: true }, store.getChunk(header.id));
290
+ return;
291
+ case "putChunk":
292
+ await store.putChunk(header.id, body);
293
+ yield* frameMessage({ ok: true });
294
+ return;
295
+ case "putManifest":
296
+ await registerManifest(store, header.manifest);
297
+ yield* frameMessage({ ok: true });
298
+ return;
299
+ default:
300
+ yield* frameMessage({
301
+ ok: false,
302
+ error: `unknown op ${header.op}`
303
+ });
304
+ return;
305
+ }
306
+ };
307
+ }
308
+ //#endregion
309
+ export { chunkTransfer, remoteStore, serveStore, transfer };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Wire framing for the content-transfer RPC. One {@link Duplex} call carries one
3
+ * request→response. Every message is `[4-byte big-endian header length][header
4
+ * JSON][body bytes...]`: the JSON header names the op and its scalar args, and
5
+ * the remaining bytes are the streamed body (a chunk's bytes, or none).
6
+ *
7
+ * Small op surface, one per content-store transfer primitive:
8
+ * - `capabilities` → protocol handshake (BATCHED handshake, no body)
9
+ * - `getManifest` → object recipe (needed for the download direction)
10
+ * - `hasChunks` → BATCHED: many ids per request → the missing ids
11
+ * - `getChunk` → STREAMED: response body is the chunk bytes
12
+ * - `putChunk` → STREAMED: request body is the chunk bytes
13
+ * - `putManifest` → server-side assemble from already-present chunks
14
+ *
15
+ * (A Bloom prefilter for `hasChunks` is intentionally deferred — the plain
16
+ * batched id list is enough; add it only when a payload budget demands it.)
17
+ */
18
+ import type { ChunkId, ObjectDescriptor, ObjectId } from "@statewalker/webrun-content-store";
19
+ export type Request = {
20
+ op: "capabilities";
21
+ } | {
22
+ op: "getManifest";
23
+ id: ObjectId;
24
+ } | {
25
+ op: "hasChunks";
26
+ ids: ChunkId[];
27
+ } | {
28
+ op: "getChunk";
29
+ id: ChunkId;
30
+ } | {
31
+ op: "putChunk";
32
+ id: ChunkId;
33
+ } | {
34
+ op: "putManifest";
35
+ manifest: ObjectDescriptor;
36
+ };
37
+ /** Encode `[len][header][body?]` as a byte stream. */
38
+ export declare function frameMessage(header: unknown, body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): AsyncGenerator<Uint8Array>;
39
+ /** Read one framed message: its parsed header + a body byte stream. Accepts
40
+ * either side of a {@link Duplex} (sync or async byte iterable). */
41
+ export declare function readFrame<H>(input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): Promise<{
42
+ header: H;
43
+ body: AsyncGenerator<Uint8Array>;
44
+ }>;
45
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,mCAAmC,CAAC;AAE7F,MAAM,MAAM,OAAO,GACf;IAAE,EAAE,EAAE,cAAc,CAAA;CAAE,GACtB;IAAE,EAAE,EAAE,aAAa,CAAC;IAAC,EAAE,EAAE,QAAQ,CAAA;CAAE,GACnC;IAAE,EAAE,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,OAAO,EAAE,CAAA;CAAE,GACnC;IAAE,EAAE,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,GAC/B;IAAE,EAAE,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,GAC/B;IAAE,EAAE,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAKtD,sDAAsD;AACtD,wBAAuB,YAAY,CACjC,MAAM,EAAE,OAAO,EACf,IAAI,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,GACtD,cAAc,CAAC,UAAU,CAAC,CAU5B;AAED;oEACoE;AACpE,wBAAsB,SAAS,CAAC,CAAC,EAC/B,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,GACtD,OAAO,CAAC;IAAE,MAAM,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,CAAA;CAAE,CAAC,CAqC1D"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Client half of the wire protocol: wraps a {@link Duplex} as a
3
+ * {@link ContentStore}-shaped proxy. Each method opens one call, frames the
4
+ * request, and reads the framed response. The proxy exposes exactly the
5
+ * chunk-transfer surface a {@link transfer} needs from a remote peer —
6
+ * `capabilities` / `getManifest` / `hasChunks` / `getChunk` / `putChunk`, plus
7
+ * `putManifest` for the server-side assemble. Whole-object operations that would
8
+ * need the store's chunker (`put`) or have no transfer meaning (`has` / `read` /
9
+ * `remove` / `gc`) are not carried over the wire and throw.
10
+ */
11
+ import type { Duplex } from "@statewalker/webrun-streams";
12
+ import type { AssemblingStore } from "./types.js";
13
+ export declare function remoteStore(call: Duplex): AssemblingStore;
14
+ //# sourceMappingURL=remote-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-store.d.ts","sourceRoot":"","sources":["../src/remote-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAQH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAE1D,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,YAAY,CAAC;AAkBhE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAmDzD"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Server half of the wire protocol: turns a {@link ContentStore} into a
3
+ * {@link Duplex} handler. Each incoming call is one framed request; the handler
4
+ * dispatches it to the store and frames the response. Symmetric with
5
+ * {@link remoteStore} — an in-process test wires this Duplex straight into
6
+ * `remoteStore(...)` with no transport.
7
+ */
8
+ import type { ContentStore } from "@statewalker/webrun-content-store";
9
+ import type { Duplex } from "@statewalker/webrun-streams";
10
+ export declare function serveStore(store: ContentStore): Duplex;
11
+ //# sourceMappingURL=serve-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-store.d.ts","sourceRoot":"","sources":["../src/serve-store.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAM1D,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAmCtD"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The transfer pipeline: negotiate → queue → bounded workers → verify → assemble.
3
+ *
4
+ * For the whole set of object ids it (1) fetches each manifest from the source,
5
+ * (2) negotiates the missing chunks against the destination in bounded batches,
6
+ * (3) streams each missing chunk through a concurrency- and byte-bounded pool,
7
+ * re-hashing it before it is written, and (4) registers each object's manifest
8
+ * at the destination once all its chunks are present.
9
+ */
10
+ import type { ContentStore, ObjectDescriptor } from "@statewalker/webrun-content-store";
11
+ import type { TransferEvent, TransferOptions } from "./types.js";
12
+ /**
13
+ * Register `manifest` at `to`, so `to.read(id)` reconstructs the object. When
14
+ * `to` is a wire proxy it assembles server-side from chunks it already holds
15
+ * (only the manifest crosses the wire); otherwise it re-`put`s a local
16
+ * reassembly (cheap, same-process) and verifies the resulting id.
17
+ */
18
+ export declare function registerManifest(to: ContentStore, manifest: ObjectDescriptor): Promise<void>;
19
+ export declare function transfer(objectIds: string[], from: ContentStore, to: ContentStore, opts?: TransferOptions): AsyncGenerator<TransferEvent>;
20
+ //# sourceMappingURL=transfer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transfer.d.ts","sourceRoot":"","sources":["../src/transfer.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAY,YAAY,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAClG,OAAO,KAAK,EAIV,aAAa,EAEb,eAAe,EAChB,MAAM,YAAY,CAAC;AA+BpB;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC,IAAI,CAAC,CAUf;AAkDD,wBAAuB,QAAQ,CAC7B,SAAS,EAAE,MAAM,EAAE,EACnB,IAAI,EAAE,YAAY,EAClB,EAAE,EAAE,YAAY,EAChB,IAAI,CAAC,EAAE,eAAe,GACrB,cAAc,CAAC,aAAa,CAAC,CA+D/B"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Public contract for the resumable, chunk-aware content transfer.
3
+ *
4
+ * The mover copies the chunks a destination is MISSING from a source
5
+ * {@link ContentStore} to a destination store, over the domain-neutral
6
+ * `@statewalker/webrun-streams` {@link Duplex} substrate. It is symmetric:
7
+ * either endpoint may be a {@link remoteStore} proxy (upload = remote `to`,
8
+ * download = remote `from`). It knows nothing of git, LFS, Xet, or sync — it
9
+ * moves opaque chunks between two content stores and verifies them by re-hash.
10
+ */
11
+ import type { ByteStream, ChunkId, ContentStore, ObjectDescriptor, ObjectId } from "@statewalker/webrun-content-store";
12
+ export type { ByteStream, ChunkId, ContentStore, ObjectId };
13
+ /** Bounds on the transfer pipeline. */
14
+ export interface TransferLimits {
15
+ /** Max chunks fetched + verified + written concurrently. Default 4. */
16
+ concurrency?: number;
17
+ /** Ceiling on the bytes of in-flight (fetched-but-not-yet-written) chunks. Default 8 MiB. */
18
+ maxBufferedBytes?: number;
19
+ /** Max chunk ids per `hasChunks` negotiation request. Default 256. */
20
+ batchSize?: number;
21
+ }
22
+ /**
23
+ * Serializable resume point. `done` are chunk ids already delivered to the
24
+ * destination; `pending` is advisory (the still-missing set at checkpoint time).
25
+ * Resume re-verifies the source still holds every object before continuing.
26
+ */
27
+ export interface TransferCheckpoint {
28
+ objectIds: ObjectId[];
29
+ done: ChunkId[];
30
+ pending: ChunkId[];
31
+ }
32
+ /** Injected identity — the SAME function the content stores use. Re-hash of a
33
+ * received chunk must equal its id, or the chunk is rejected and the transfer
34
+ * fails loudly. */
35
+ export type HashContent = (bytes: ByteStream) => Promise<string>;
36
+ /** Progress events streamed by {@link transfer}. */
37
+ export type TransferEvent = {
38
+ type: "resumed";
39
+ done: number;
40
+ } | {
41
+ type: "negotiated";
42
+ objects: number;
43
+ chunks: number;
44
+ missing: number;
45
+ protocol?: string;
46
+ } | {
47
+ type: "chunk-sent";
48
+ chunkId: ChunkId;
49
+ size: number;
50
+ } | {
51
+ type: "object-done";
52
+ objectId: ObjectId;
53
+ };
54
+ export interface TransferOptions {
55
+ limits?: TransferLimits;
56
+ checkpoint?: TransferCheckpoint;
57
+ hashContent?: HashContent;
58
+ }
59
+ /** Peer capabilities exchanged before transfer over a {@link remoteStore}. */
60
+ export interface Capabilities {
61
+ protocol: string;
62
+ }
63
+ /**
64
+ * A {@link ContentStore} that can register an object's manifest from chunks it
65
+ * already holds — the "assemble" step, without shipping the object's bytes.
66
+ * A local store lacks it (transfer falls back to {@link ContentStore.put}); the
67
+ * wire {@link remoteStore} proxy provides it so assemble costs only the manifest.
68
+ */
69
+ export interface AssemblingStore extends ContentStore {
70
+ putManifest(manifest: ObjectDescriptor): Promise<void>;
71
+ capabilities(): Promise<Capabilities>;
72
+ }
73
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,OAAO,EACP,YAAY,EACZ,gBAAgB,EAChB,QAAQ,EACT,MAAM,mCAAmC,CAAC;AAE3C,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC;AAE5D,uCAAuC;AACvC,MAAM,WAAW,cAAc;IAC7B,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6FAA6F;IAC7F,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,OAAO,EAAE,OAAO,EAAE,CAAC;CACpB;AAED;;mBAEmB;AACnB,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAEjE,oDAAoD;AACpD,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3F;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,UAAU,CAAC,EAAE,kBAAkB,CAAC;IAChC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAgB,SAAQ,YAAY;IACnD,WAAW,CAAC,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC;CACvC"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@statewalker/webrun-content-transfer",
3
+ "version": "0.2.2",
4
+ "description": "Resumable, chunk-aware content transfer over webrun-streams",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/statewalker/webrun-vcs.git",
9
+ "directory": "packages/webrun-content-transfer"
10
+ },
11
+ "private": false,
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "source": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "dependencies": {
30
+ "@statewalker/webrun-files": "^0.10.0",
31
+ "@statewalker/webrun-streams": "^0.2.0",
32
+ "@statewalker/webrun-content-store": "^0.2.2"
33
+ },
34
+ "devDependencies": {
35
+ "@statewalker/webrun-files-mem": "^0.10.0",
36
+ "rolldown": "^1.2.12",
37
+ "vitest": "^5.0.3",
38
+ "typescript": "^7.0.2",
39
+ "@types/node": "^26.6.4",
40
+ "@statewalker/webrun-storage": "^0.2.1",
41
+ "@statewalker/webrun-files-sync": "^0.2.2"
42
+ },
43
+ "scripts": {
44
+ "build": "rm -rf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
45
+ "test": "vitest run",
46
+ "lint": "biome check --write .",
47
+ "lint:check": "biome check .",
48
+ "lint:fix": "biome check --write --unsafe .",
49
+ "format": "biome format --write .",
50
+ "format:check": "biome format .",
51
+ "format:fix": "biome check --write --unsafe .",
52
+ "typecheck": "tsc --noEmit"
53
+ }
54
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * files-sync interop: adapt content-store dedup transfer to the files-sync
3
+ * {@link https://github.com/statewalker/vcs `Transfer`} seam. The returned object
4
+ * is structurally a files-sync `Transfer` (kept structural so `src/` stays
5
+ * domain-neutral and never imports files-sync). For a copy/update it ingests the
6
+ * source file into the `local` store, moves the missing chunks to `remote` via
7
+ * {@link transfer}, then writes the reassembled bytes to the destination — so a
8
+ * file whose chunks the remote already holds costs nothing to move.
9
+ */
10
+
11
+ import type { ContentStore } from "@statewalker/webrun-content-store";
12
+ import type { FilesApi } from "@statewalker/webrun-files";
13
+ import { transfer } from "./transfer.js";
14
+
15
+ /** The subset of a files-sync `SyncAction` this adapter reads (structural). */
16
+ export interface FileSyncAction {
17
+ kind: string;
18
+ path?: string;
19
+ }
20
+
21
+ /** Structurally compatible with files-sync's `Transfer`. */
22
+ export interface FileTransfer {
23
+ run(action: FileSyncAction, from: FilesApi, to: FilesApi): Promise<void>;
24
+ }
25
+
26
+ export function chunkTransfer(local: ContentStore, remote: ContentStore): FileTransfer {
27
+ return {
28
+ async run(action, from, to) {
29
+ if (action.kind !== "copy" && action.kind !== "update") {
30
+ throw new Error(
31
+ `content-transfer: chunkTransfer only handles copy/update, got ${action.kind}`,
32
+ );
33
+ }
34
+ const path = action.path;
35
+ if (!path) throw new Error(`content-transfer: ${action.kind} action has no path`);
36
+
37
+ const descriptor = await local.put(from.read(path));
38
+
39
+ const events = transfer([descriptor.id], local, remote);
40
+ const iterator = events[Symbol.asyncIterator]();
41
+ while (!(await iterator.next()).done) {
42
+ /* drain the transfer to completion */
43
+ }
44
+
45
+ await to.write(path, remote.read(descriptor.id));
46
+ },
47
+ };
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @statewalker/webrun-content-transfer
3
+ *
4
+ * Resumable, chunk-aware byte mover. Given the object ids a destination needs,
5
+ * it transfers only the chunks the destination is MISSING from a source
6
+ * {@link ContentStore} to a destination store, over the domain-neutral
7
+ * `@statewalker/webrun-streams` {@link Duplex} substrate. It is symmetric —
8
+ * either endpoint may be a wire {@link remoteStore} proxy in front of a
9
+ * {@link serveStore} handler. Every received chunk is verified by re-hash before
10
+ * it is written; a serializable checkpoint makes an interrupted transfer
11
+ * resumable. It knows nothing of git, LFS, or sync.
12
+ */
13
+
14
+ export type { FileSyncAction, FileTransfer } from "./chunk-transfer.js";
15
+ export { chunkTransfer } from "./chunk-transfer.js";
16
+ export { remoteStore } from "./remote-store.js";
17
+ export { serveStore } from "./serve-store.js";
18
+ export { transfer } from "./transfer.js";
19
+ export type {
20
+ AssemblingStore,
21
+ Capabilities,
22
+ HashContent,
23
+ TransferCheckpoint,
24
+ TransferEvent,
25
+ TransferLimits,
26
+ TransferOptions,
27
+ } from "./types.js";
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Wire framing for the content-transfer RPC. One {@link Duplex} call carries one
3
+ * request→response. Every message is `[4-byte big-endian header length][header
4
+ * JSON][body bytes...]`: the JSON header names the op and its scalar args, and
5
+ * the remaining bytes are the streamed body (a chunk's bytes, or none).
6
+ *
7
+ * Small op surface, one per content-store transfer primitive:
8
+ * - `capabilities` → protocol handshake (BATCHED handshake, no body)
9
+ * - `getManifest` → object recipe (needed for the download direction)
10
+ * - `hasChunks` → BATCHED: many ids per request → the missing ids
11
+ * - `getChunk` → STREAMED: response body is the chunk bytes
12
+ * - `putChunk` → STREAMED: request body is the chunk bytes
13
+ * - `putManifest` → server-side assemble from already-present chunks
14
+ *
15
+ * (A Bloom prefilter for `hasChunks` is intentionally deferred — the plain
16
+ * batched id list is enough; add it only when a payload budget demands it.)
17
+ */
18
+
19
+ import type { ChunkId, ObjectDescriptor, ObjectId } from "@statewalker/webrun-content-store";
20
+
21
+ export type Request =
22
+ | { op: "capabilities" }
23
+ | { op: "getManifest"; id: ObjectId }
24
+ | { op: "hasChunks"; ids: ChunkId[] }
25
+ | { op: "getChunk"; id: ChunkId }
26
+ | { op: "putChunk"; id: ChunkId }
27
+ | { op: "putManifest"; manifest: ObjectDescriptor };
28
+
29
+ const encoder = new TextEncoder();
30
+ const decoder = new TextDecoder();
31
+
32
+ /** Encode `[len][header][body?]` as a byte stream. */
33
+ export async function* frameMessage(
34
+ header: unknown,
35
+ body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
36
+ ): AsyncGenerator<Uint8Array> {
37
+ const json = encoder.encode(JSON.stringify(header));
38
+ const prefix = new Uint8Array(4);
39
+ prefix[0] = (json.length >>> 24) & 0xff;
40
+ prefix[1] = (json.length >>> 16) & 0xff;
41
+ prefix[2] = (json.length >>> 8) & 0xff;
42
+ prefix[3] = json.length & 0xff;
43
+ yield prefix;
44
+ yield json;
45
+ if (body) for await (const b of body) if (b.byteLength) yield b;
46
+ }
47
+
48
+ /** Read one framed message: its parsed header + a body byte stream. Accepts
49
+ * either side of a {@link Duplex} (sync or async byte iterable). */
50
+ export async function readFrame<H>(
51
+ input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
52
+ ): Promise<{ header: H; body: AsyncGenerator<Uint8Array> }> {
53
+ const iter: AsyncIterator<Uint8Array> =
54
+ Symbol.asyncIterator in input
55
+ ? input[Symbol.asyncIterator]()
56
+ : (() => {
57
+ const sync = input[Symbol.iterator]();
58
+ return { next: async () => sync.next() };
59
+ })();
60
+ let buf = new Uint8Array(0);
61
+
62
+ async function fill(n: number): Promise<void> {
63
+ while (buf.length < n) {
64
+ const { value, done } = await iter.next();
65
+ if (done) throw new Error("content-transfer protocol: unexpected end of stream");
66
+ const next = new Uint8Array(buf.length + value.length);
67
+ next.set(buf, 0);
68
+ next.set(value, buf.length);
69
+ buf = next;
70
+ }
71
+ }
72
+
73
+ await fill(4);
74
+ const headerLen = ((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]) >>> 0;
75
+ await fill(4 + headerLen);
76
+ const header = JSON.parse(decoder.decode(buf.subarray(4, 4 + headerLen))) as H;
77
+ const rest = buf.subarray(4 + headerLen);
78
+
79
+ async function* body(): AsyncGenerator<Uint8Array> {
80
+ if (rest.byteLength) yield rest;
81
+ while (true) {
82
+ const { value, done } = await iter.next();
83
+ if (done) return;
84
+ if (value.byteLength) yield value;
85
+ }
86
+ }
87
+
88
+ return { header, body: body() };
89
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Client half of the wire protocol: wraps a {@link Duplex} as a
3
+ * {@link ContentStore}-shaped proxy. Each method opens one call, frames the
4
+ * request, and reads the framed response. The proxy exposes exactly the
5
+ * chunk-transfer surface a {@link transfer} needs from a remote peer —
6
+ * `capabilities` / `getManifest` / `hasChunks` / `getChunk` / `putChunk`, plus
7
+ * `putManifest` for the server-side assemble. Whole-object operations that would
8
+ * need the store's chunker (`put`) or have no transfer meaning (`has` / `read` /
9
+ * `remove` / `gc`) are not carried over the wire and throw.
10
+ */
11
+
12
+ import type {
13
+ ByteStream,
14
+ ChunkId,
15
+ ObjectDescriptor,
16
+ ObjectId,
17
+ } from "@statewalker/webrun-content-store";
18
+ import type { Duplex } from "@statewalker/webrun-streams";
19
+ import { frameMessage, readFrame } from "./protocol.js";
20
+ import type { AssemblingStore, Capabilities } from "./types.js";
21
+
22
+ interface Ok {
23
+ ok: true;
24
+ manifest?: ObjectDescriptor | null;
25
+ missing?: ChunkId[];
26
+ capabilities?: Capabilities;
27
+ }
28
+ interface Err {
29
+ ok: false;
30
+ error?: string;
31
+ }
32
+ type Response = Ok | Err;
33
+
34
+ const unsupported = (name: string): never => {
35
+ throw new Error(`content-transfer: remote proxy does not support "${name}"`);
36
+ };
37
+
38
+ export function remoteStore(call: Duplex): AssemblingStore {
39
+ async function request(
40
+ header: unknown,
41
+ body?: ByteStream,
42
+ ): Promise<{ header: Ok; body: AsyncGenerator<Uint8Array> }> {
43
+ const response = call(frameMessage(header, body));
44
+ const { header: resp, body: respBody } = await readFrame<Response>(response);
45
+ if (!resp.ok) throw new Error(resp.error ?? "content-transfer: remote error");
46
+ return { header: resp, body: respBody };
47
+ }
48
+
49
+ const store: AssemblingStore = {
50
+ async capabilities(): Promise<Capabilities> {
51
+ const { header } = await request({ op: "capabilities" });
52
+ if (!header.capabilities) throw new Error("content-transfer: no capabilities in response");
53
+ return header.capabilities;
54
+ },
55
+
56
+ async getManifest(id: ObjectId): Promise<ObjectDescriptor | undefined> {
57
+ const { header } = await request({ op: "getManifest", id });
58
+ return header.manifest ?? undefined;
59
+ },
60
+
61
+ async hasChunks(ids: ChunkId[]): Promise<ChunkId[]> {
62
+ const { header } = await request({ op: "hasChunks", ids });
63
+ return header.missing ?? [];
64
+ },
65
+
66
+ getChunk(id: ChunkId): ByteStream {
67
+ return (async function* (): ByteStream {
68
+ const { body } = await request({ op: "getChunk", id });
69
+ yield* body;
70
+ })();
71
+ },
72
+
73
+ async putChunk(id: ChunkId, bytes: ByteStream): Promise<void> {
74
+ await request({ op: "putChunk", id }, bytes);
75
+ },
76
+
77
+ async putManifest(manifest: ObjectDescriptor): Promise<void> {
78
+ await request({ op: "putManifest", manifest });
79
+ },
80
+
81
+ put: () => unsupported("put"),
82
+ has: () => unsupported("has"),
83
+ read: () => unsupported("read"),
84
+ remove: () => unsupported("remove"),
85
+ gc: () => unsupported("gc"),
86
+ };
87
+
88
+ return store;
89
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Server half of the wire protocol: turns a {@link ContentStore} into a
3
+ * {@link Duplex} handler. Each incoming call is one framed request; the handler
4
+ * dispatches it to the store and frames the response. Symmetric with
5
+ * {@link remoteStore} — an in-process test wires this Duplex straight into
6
+ * `remoteStore(...)` with no transport.
7
+ */
8
+
9
+ import type { ContentStore } from "@statewalker/webrun-content-store";
10
+ import type { Duplex } from "@statewalker/webrun-streams";
11
+ import { frameMessage, type Request, readFrame } from "./protocol.js";
12
+ import { registerManifest } from "./transfer.js";
13
+
14
+ const PROTOCOL = "content-transfer/1";
15
+
16
+ export function serveStore(store: ContentStore): Duplex {
17
+ return async function* handler(input): AsyncGenerator<Uint8Array> {
18
+ const { header, body } = await readFrame<Request>(input);
19
+ switch (header.op) {
20
+ case "capabilities":
21
+ yield* frameMessage({ ok: true, capabilities: { protocol: PROTOCOL } });
22
+ return;
23
+ case "getManifest": {
24
+ const manifest = (await store.getManifest(header.id)) ?? null;
25
+ yield* frameMessage({ ok: true, manifest });
26
+ return;
27
+ }
28
+ case "hasChunks": {
29
+ const missing = await store.hasChunks(header.ids);
30
+ yield* frameMessage({ ok: true, missing });
31
+ return;
32
+ }
33
+ case "getChunk":
34
+ yield* frameMessage({ ok: true }, store.getChunk(header.id));
35
+ return;
36
+ case "putChunk": {
37
+ await store.putChunk(header.id, body);
38
+ yield* frameMessage({ ok: true });
39
+ return;
40
+ }
41
+ case "putManifest": {
42
+ await registerManifest(store, header.manifest);
43
+ yield* frameMessage({ ok: true });
44
+ return;
45
+ }
46
+ default:
47
+ yield* frameMessage({ ok: false, error: `unknown op ${(header as { op: string }).op}` });
48
+ return;
49
+ }
50
+ };
51
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The transfer pipeline: negotiate → queue → bounded workers → verify → assemble.
3
+ *
4
+ * For the whole set of object ids it (1) fetches each manifest from the source,
5
+ * (2) negotiates the missing chunks against the destination in bounded batches,
6
+ * (3) streams each missing chunk through a concurrency- and byte-bounded pool,
7
+ * re-hashing it before it is written, and (4) registers each object's manifest
8
+ * at the destination once all its chunks are present.
9
+ */
10
+
11
+ import type { ChunkRef, ContentStore, ObjectDescriptor } from "@statewalker/webrun-content-store";
12
+ import type {
13
+ AssemblingStore,
14
+ ByteStream,
15
+ Capabilities,
16
+ TransferEvent,
17
+ TransferLimits,
18
+ TransferOptions,
19
+ } from "./types.js";
20
+
21
+ const DEFAULT_LIMITS: Required<TransferLimits> = {
22
+ concurrency: 4,
23
+ maxBufferedBytes: 8 * 1024 * 1024,
24
+ batchSize: 256,
25
+ };
26
+
27
+ /** A fresh single-shot stream over one buffer (streams are consumed once). */
28
+ async function* one(bytes: Uint8Array): ByteStream {
29
+ yield bytes;
30
+ }
31
+
32
+ async function collect(stream: ByteStream): Promise<Uint8Array> {
33
+ const parts: Uint8Array[] = [];
34
+ for await (const chunk of stream) parts.push(chunk);
35
+ const total = parts.reduce((n, p) => n + p.length, 0);
36
+ const out = new Uint8Array(total);
37
+ let off = 0;
38
+ for (const p of parts) {
39
+ out.set(p, off);
40
+ off += p.length;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Re-stream a store's own chunks in manifest order — feeds `put` without buffering. */
46
+ async function* reassembleFromStore(store: ContentStore, manifest: ObjectDescriptor): ByteStream {
47
+ for (const ref of manifest.chunks) yield* store.getChunk(ref.id);
48
+ }
49
+
50
+ /**
51
+ * Register `manifest` at `to`, so `to.read(id)` reconstructs the object. When
52
+ * `to` is a wire proxy it assembles server-side from chunks it already holds
53
+ * (only the manifest crosses the wire); otherwise it re-`put`s a local
54
+ * reassembly (cheap, same-process) and verifies the resulting id.
55
+ */
56
+ export async function registerManifest(
57
+ to: ContentStore,
58
+ manifest: ObjectDescriptor,
59
+ ): Promise<void> {
60
+ const proxy = to as Partial<AssemblingStore>;
61
+ if (typeof proxy.putManifest === "function") {
62
+ await proxy.putManifest(manifest);
63
+ return;
64
+ }
65
+ const descriptor = await to.put(reassembleFromStore(to, manifest));
66
+ if (descriptor.id !== manifest.id) {
67
+ throw new Error(`content-transfer: assemble id mismatch ${descriptor.id} != ${manifest.id}`);
68
+ }
69
+ }
70
+
71
+ async function maybeCapabilities(to: ContentStore): Promise<Capabilities | undefined> {
72
+ const proxy = to as Partial<AssemblingStore>;
73
+ if (typeof proxy.capabilities === "function") return proxy.capabilities();
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Run `work` over `refs` with at most `concurrency` in flight and never more
79
+ * than `maxBufferedBytes` of chunk bytes buffered (a single over-budget chunk is
80
+ * allowed alone, to guarantee progress). Yields each result as it settles.
81
+ */
82
+ async function* pipeline<R>(
83
+ refs: ChunkRef[],
84
+ limits: Required<TransferLimits>,
85
+ work: (ref: ChunkRef) => Promise<R>,
86
+ ): AsyncGenerator<R> {
87
+ interface Entry {
88
+ ref: ChunkRef;
89
+ value: R;
90
+ promise: Promise<Entry>;
91
+ }
92
+ const inflight = new Set<Promise<Entry>>();
93
+ let buffered = 0;
94
+ let idx = 0;
95
+
96
+ const canLaunch = (): boolean =>
97
+ idx < refs.length &&
98
+ inflight.size < limits.concurrency &&
99
+ (inflight.size === 0 || buffered + refs[idx].size <= limits.maxBufferedBytes);
100
+
101
+ while (idx < refs.length || inflight.size > 0) {
102
+ while (canLaunch()) {
103
+ const ref = refs[idx++];
104
+ buffered += ref.size;
105
+ const entry = { ref } as Entry;
106
+ entry.promise = work(ref).then((value) => {
107
+ entry.value = value;
108
+ return entry;
109
+ });
110
+ inflight.add(entry.promise);
111
+ }
112
+ const settled = await Promise.race(inflight);
113
+ inflight.delete(settled.promise);
114
+ buffered -= settled.ref.size;
115
+ yield settled.value;
116
+ }
117
+ }
118
+
119
+ export async function* transfer(
120
+ objectIds: string[],
121
+ from: ContentStore,
122
+ to: ContentStore,
123
+ opts?: TransferOptions,
124
+ ): AsyncGenerator<TransferEvent> {
125
+ const limits: Required<TransferLimits> = { ...DEFAULT_LIMITS, ...opts?.limits };
126
+ const hashContent = opts?.hashContent;
127
+
128
+ const caps = await maybeCapabilities(to);
129
+
130
+ if (opts?.checkpoint) {
131
+ yield { type: "resumed", done: opts.checkpoint.done.length };
132
+ }
133
+
134
+ // 1. Gather manifests + the union of their chunk refs. This also re-verifies
135
+ // the source still holds every requested object (a resume precondition).
136
+ const manifests = new Map<string, ObjectDescriptor>();
137
+ const refById = new Map<string, ChunkRef>();
138
+ for (const id of objectIds) {
139
+ const manifest = await from.getManifest(id);
140
+ if (!manifest) throw new Error(`content-transfer: source is missing object ${id}`);
141
+ manifests.set(id, manifest);
142
+ for (const ref of manifest.chunks) if (!refById.has(ref.id)) refById.set(ref.id, ref);
143
+ }
144
+
145
+ // 2. Batched negotiation: many ids per request, not one request per id.
146
+ const allIds = [...refById.keys()];
147
+ const missing: ChunkRef[] = [];
148
+ for (let i = 0; i < allIds.length; i += limits.batchSize) {
149
+ const batch = allIds.slice(i, i + limits.batchSize);
150
+ for (const cid of await to.hasChunks(batch)) {
151
+ const ref = refById.get(cid);
152
+ if (ref) missing.push(ref);
153
+ }
154
+ }
155
+ yield {
156
+ type: "negotiated",
157
+ objects: objectIds.length,
158
+ chunks: allIds.length,
159
+ missing: missing.length,
160
+ protocol: caps?.protocol,
161
+ };
162
+
163
+ // 3. Bounded pipeline: fetch from source, re-hash, write to destination.
164
+ for await (const sent of pipeline(missing, limits, async (ref) => {
165
+ const bytes = await collect(from.getChunk(ref.id));
166
+ if (hashContent) {
167
+ const actual = await hashContent(one(bytes));
168
+ if (actual !== ref.id) {
169
+ throw new Error(
170
+ `content-transfer: integrity check failed for chunk ${ref.id} (re-hash ${actual})`,
171
+ );
172
+ }
173
+ }
174
+ await to.putChunk(ref.id, one(bytes));
175
+ return { id: ref.id, size: bytes.length };
176
+ })) {
177
+ yield { type: "chunk-sent", chunkId: sent.id, size: sent.size };
178
+ }
179
+
180
+ // 4. Assemble: every object's chunks are present — register its manifest.
181
+ for (const id of objectIds) {
182
+ const manifest = manifests.get(id);
183
+ if (!manifest) continue;
184
+ await registerManifest(to, manifest);
185
+ yield { type: "object-done", objectId: id };
186
+ }
187
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Public contract for the resumable, chunk-aware content transfer.
3
+ *
4
+ * The mover copies the chunks a destination is MISSING from a source
5
+ * {@link ContentStore} to a destination store, over the domain-neutral
6
+ * `@statewalker/webrun-streams` {@link Duplex} substrate. It is symmetric:
7
+ * either endpoint may be a {@link remoteStore} proxy (upload = remote `to`,
8
+ * download = remote `from`). It knows nothing of git, LFS, Xet, or sync — it
9
+ * moves opaque chunks between two content stores and verifies them by re-hash.
10
+ */
11
+
12
+ import type {
13
+ ByteStream,
14
+ ChunkId,
15
+ ContentStore,
16
+ ObjectDescriptor,
17
+ ObjectId,
18
+ } from "@statewalker/webrun-content-store";
19
+
20
+ export type { ByteStream, ChunkId, ContentStore, ObjectId };
21
+
22
+ /** Bounds on the transfer pipeline. */
23
+ export interface TransferLimits {
24
+ /** Max chunks fetched + verified + written concurrently. Default 4. */
25
+ concurrency?: number;
26
+ /** Ceiling on the bytes of in-flight (fetched-but-not-yet-written) chunks. Default 8 MiB. */
27
+ maxBufferedBytes?: number;
28
+ /** Max chunk ids per `hasChunks` negotiation request. Default 256. */
29
+ batchSize?: number;
30
+ }
31
+
32
+ /**
33
+ * Serializable resume point. `done` are chunk ids already delivered to the
34
+ * destination; `pending` is advisory (the still-missing set at checkpoint time).
35
+ * Resume re-verifies the source still holds every object before continuing.
36
+ */
37
+ export interface TransferCheckpoint {
38
+ objectIds: ObjectId[];
39
+ done: ChunkId[];
40
+ pending: ChunkId[];
41
+ }
42
+
43
+ /** Injected identity — the SAME function the content stores use. Re-hash of a
44
+ * received chunk must equal its id, or the chunk is rejected and the transfer
45
+ * fails loudly. */
46
+ export type HashContent = (bytes: ByteStream) => Promise<string>;
47
+
48
+ /** Progress events streamed by {@link transfer}. */
49
+ export type TransferEvent =
50
+ | { type: "resumed"; done: number }
51
+ | { type: "negotiated"; objects: number; chunks: number; missing: number; protocol?: string }
52
+ | { type: "chunk-sent"; chunkId: ChunkId; size: number }
53
+ | { type: "object-done"; objectId: ObjectId };
54
+
55
+ export interface TransferOptions {
56
+ limits?: TransferLimits;
57
+ checkpoint?: TransferCheckpoint;
58
+ hashContent?: HashContent;
59
+ }
60
+
61
+ /** Peer capabilities exchanged before transfer over a {@link remoteStore}. */
62
+ export interface Capabilities {
63
+ protocol: string;
64
+ }
65
+
66
+ /**
67
+ * A {@link ContentStore} that can register an object's manifest from chunks it
68
+ * already holds — the "assemble" step, without shipping the object's bytes.
69
+ * A local store lacks it (transfer falls back to {@link ContentStore.put}); the
70
+ * wire {@link remoteStore} proxy provides it so assemble costs only the manifest.
71
+ */
72
+ export interface AssemblingStore extends ContentStore {
73
+ putManifest(manifest: ObjectDescriptor): Promise<void>;
74
+ capabilities(): Promise<Capabilities>;
75
+ }