@statewalker/webrun-content-store 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 +21 -0
- package/README.md +68 -0
- package/dist/chunker.d.ts +8 -0
- package/dist/chunker.d.ts.map +1 -0
- package/dist/content-store.d.ts +3 -0
- package/dist/content-store.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +238 -0
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/chunker.ts +136 -0
- package/src/content-store.ts +136 -0
- package/src/index.ts +22 -0
- package/src/manifest.ts +11 -0
- package/src/types.ts +79 -0
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,68 @@
|
|
|
1
|
+
# @statewalker/webrun-content-store
|
|
2
|
+
|
|
3
|
+
Domain-neutral, content-addressed large-object store with content-defined chunking and deduplication.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`content-store` persists large byte streams as deduplicated, content-defined chunks over the `@statewalker/webrun-storage` `BlobStore` seam. It is **algorithm-agnostic**: the whole-object and per-chunk identities are computed by an **injected `hashContent`**, and every id is treated as an opaque string (it carries whatever prefix the hasher produces). The content-defined chunker lives in-package; content below a threshold is stored as a single direct blob.
|
|
8
|
+
|
|
9
|
+
It is shared infrastructure for both axes: it backs Axis A's "ask which chunks exist → send only the missing → assemble" transfer flow (via `@statewalker/webrun-content-transfer`) and Axis B's LFS-pointer-to-object indirection (`@statewalker/vcs-transport-lfs` / `-xet`). It knows nothing of git, LFS, SHA-256, commits, or sync — those live in the skins above it.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @statewalker/webrun-content-store
|
|
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
|
+
|
|
24
|
+
async function sha256(bytes: AsyncIterable<Uint8Array>): Promise<string> {
|
|
25
|
+
const h = createHash("sha256");
|
|
26
|
+
for await (const chunk of bytes) h.update(chunk);
|
|
27
|
+
return `sha256:${h.digest("hex")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const store = createContentStore(
|
|
31
|
+
{ chunks: memBlobStore(), manifests: memBlobStore(), hashContent: sha256 },
|
|
32
|
+
{ chunkThreshold: 64, cdc: { min: 64, avg: 256, max: 1024 } },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
async function* streamOf(bytes: Uint8Array) { yield bytes; }
|
|
36
|
+
|
|
37
|
+
// Object level: chunk + hash in one bounded-memory pass.
|
|
38
|
+
const d = await store.put(streamOf(payload)); // ObjectDescriptor { id, size, chunks }
|
|
39
|
+
const whole = store.read(d.id); // reassembled stream
|
|
40
|
+
const slice = store.read(d.id, { offset: 3000, length: 2500 }); // arbitrary range across chunks
|
|
41
|
+
|
|
42
|
+
// Chunk level: negotiate and move only what a peer is missing.
|
|
43
|
+
const missing = await store.hasChunks(d.chunks.map((c) => c.id)); // ids NOT present
|
|
44
|
+
await store.gc([d.id]); // sweep everything not reachable from the live roots
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
**Object level**
|
|
50
|
+
- `put(content): Promise<ObjectDescriptor>` — chunk + hash in a bounded-memory pass, store chunks + manifest.
|
|
51
|
+
- `read(id, range?)` — stream the object's bytes, optionally sliced by `{ offset?, length? }`; empty stream if absent.
|
|
52
|
+
- `has(id)`, `getManifest(id)`, `remove(id)`.
|
|
53
|
+
|
|
54
|
+
**Chunk level** (transfer / dedup)
|
|
55
|
+
- `hasChunks(ids): Promise<ChunkId[]>` — return exactly the ids **not** present.
|
|
56
|
+
- `putChunk(id, bytes)`, `getChunk(id)`.
|
|
57
|
+
|
|
58
|
+
**Maintenance**
|
|
59
|
+
- `gc(liveRoots): Promise<{ removedObjects, removedChunks }>` — mark-sweep from caller-supplied roots.
|
|
60
|
+
|
|
61
|
+
**Factory:** `createContentStore(deps, opts?)`, where `deps` is `{ chunks: BlobStore, manifests: BlobStore, hashContent }` and `opts` is `{ chunkThreshold?, cdc? }`. Types: `ContentStore`, `ObjectDescriptor`, `ChunkRef`, `ObjectId`, `ChunkId`, `CdcParams`.
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- **Injected hash, opaque ids.** The contract sketch spoke of intrinsic BLAKE3; the shipped code takes an injected `hashContent` and treats every id as opaque — the store commits to no algorithm, and object/chunk ids simply carry the hasher's own prefix.
|
|
66
|
+
- **CDC + direct-blob threshold.** Content-defined chunking (so dedup survives inserts/shifts) is the default; content strictly below `chunkThreshold` is stored as a single blob with no chunking overhead. `hasChunks`/`getChunk`/`putChunk` let `content-transfer` move only missing chunks.
|
|
67
|
+
- **Immutable + external liveness.** Chunks and objects are immutable and content-addressed; there are no refcounts — `gc(liveRoots)` reaches objects→chunks from caller roots and sweeps the rest.
|
|
68
|
+
- Built red/green TDD.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ByteStream, CdcParams } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split `source` into content-defined chunks. Content strictly smaller than
|
|
4
|
+
* `threshold` is emitted as a single direct blob (no CDC). Bounded memory: at
|
|
5
|
+
* most one pending chunk (plus one incoming array) is buffered at a time.
|
|
6
|
+
*/
|
|
7
|
+
export declare function chunkContent(source: ByteStream, cdc: CdcParams, threshold: number): AsyncIterable<Uint8Array>;
|
|
8
|
+
//# sourceMappingURL=chunker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../src/chunker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AA6ExD;;;;GAIG;AACH,wBAAuB,YAAY,CACjC,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,SAAS,EACd,SAAS,EAAE,MAAM,GAChB,aAAa,CAAC,UAAU,CAAC,CAiD3B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-store.d.ts","sourceRoot":"","sources":["../src/content-store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAIV,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EAGpB,MAAM,YAAY,CAAC;AAuBpB,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,gBAAgB,EACtB,IAAI,CAAC,EAAE,mBAAmB,GACzB,YAAY,CAkGd"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statewalker/webrun-content-store
|
|
3
|
+
*
|
|
4
|
+
* Domain-neutral, content-addressed large-object store. It stores large byte
|
|
5
|
+
* streams as deduplicated, content-defined chunks over the `@statewalker/webrun-storage`
|
|
6
|
+
* BlobStore seam. It is algorithm-agnostic — object and chunk identities are
|
|
7
|
+
* computed by an injected `hashContent`, and every id is an opaque string. It
|
|
8
|
+
* knows nothing of git, LFS, SHA-256, commits, or sync.
|
|
9
|
+
*/
|
|
10
|
+
export { createContentStore } from "./content-store.js";
|
|
11
|
+
export type { ByteStream, CdcParams, ChunkId, ChunkRef, ContentStore, ContentStoreDeps, ContentStoreOptions, ObjectDescriptor, ObjectId, } from "./types.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EACV,UAAU,EACV,SAAS,EACT,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,QAAQ,GACT,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//#region src/chunker.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal Gear-hash FastCDC content-defined chunker.
|
|
4
|
+
*
|
|
5
|
+
* A rolling Gear fingerprint (`fp = (fp << 1) + GEAR[byte]`) restarts at every
|
|
6
|
+
* chunk boundary; a cut is taken when the low bits of `fp` are zero. Normalized
|
|
7
|
+
* chunking (Xia et al., FastCDC) uses a stricter mask before the average size
|
|
8
|
+
* and a looser one after, tightening the size distribution. Because the cut for
|
|
9
|
+
* a chunk depends only on the bytes since its own start, boundaries re-sync
|
|
10
|
+
* shortly after an edit — so a mid-file insert leaves surrounding chunks intact.
|
|
11
|
+
*/
|
|
12
|
+
const GEAR = buildGearTable();
|
|
13
|
+
/** 256 deterministic 32-bit gear values (xorshift32 from a fixed seed). */
|
|
14
|
+
function buildGearTable() {
|
|
15
|
+
const g = /* @__PURE__ */ new Uint32Array(256);
|
|
16
|
+
let x = 2654435769;
|
|
17
|
+
for (let i = 0; i < 256; i++) {
|
|
18
|
+
x ^= x << 13;
|
|
19
|
+
x ^= x >>> 17;
|
|
20
|
+
x ^= x << 5;
|
|
21
|
+
x >>>= 0;
|
|
22
|
+
g[i] = x;
|
|
23
|
+
}
|
|
24
|
+
return g;
|
|
25
|
+
}
|
|
26
|
+
function masksFor(cdc) {
|
|
27
|
+
const bits = Math.round(Math.log2(cdc.avg));
|
|
28
|
+
const maskS = (1 << Math.min(bits + 2, 31)) - 1;
|
|
29
|
+
const maskL = (1 << Math.max(bits - 2, 1)) - 1;
|
|
30
|
+
return {
|
|
31
|
+
min: cdc.min,
|
|
32
|
+
avg: cdc.avg,
|
|
33
|
+
max: cdc.max,
|
|
34
|
+
maskS,
|
|
35
|
+
maskL
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Find the cut index (exclusive end of the first chunk) within `buf`, whose
|
|
40
|
+
* chunk starts at index 0. Returns `-1` when more bytes are needed before a cut
|
|
41
|
+
* can be decided (only when `!final`).
|
|
42
|
+
*/
|
|
43
|
+
function findCut(buf, m, final) {
|
|
44
|
+
const n = buf.length;
|
|
45
|
+
if (n <= m.min) return final ? n : -1;
|
|
46
|
+
const limit = Math.min(n, m.max);
|
|
47
|
+
let fp = 0;
|
|
48
|
+
let i = m.min;
|
|
49
|
+
const avgStop = Math.min(m.avg, limit);
|
|
50
|
+
for (; i < avgStop; i++) {
|
|
51
|
+
fp = (fp << 1) + GEAR[buf[i]] >>> 0;
|
|
52
|
+
if ((fp & m.maskS) === 0) return i;
|
|
53
|
+
}
|
|
54
|
+
for (; i < limit; i++) {
|
|
55
|
+
fp = (fp << 1) + GEAR[buf[i]] >>> 0;
|
|
56
|
+
if ((fp & m.maskL) === 0) return i;
|
|
57
|
+
}
|
|
58
|
+
if (limit === m.max) return m.max;
|
|
59
|
+
return final ? n : -1;
|
|
60
|
+
}
|
|
61
|
+
function concat(a, b) {
|
|
62
|
+
const out = new Uint8Array(a.length + b.length);
|
|
63
|
+
out.set(a, 0);
|
|
64
|
+
out.set(b, a.length);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Split `source` into content-defined chunks. Content strictly smaller than
|
|
69
|
+
* `threshold` is emitted as a single direct blob (no CDC). Bounded memory: at
|
|
70
|
+
* most one pending chunk (plus one incoming array) is buffered at a time.
|
|
71
|
+
*/
|
|
72
|
+
async function* chunkContent(source, cdc, threshold) {
|
|
73
|
+
const iter = source[Symbol.asyncIterator]();
|
|
74
|
+
const prefix = [];
|
|
75
|
+
let prefixLen = 0;
|
|
76
|
+
let done = false;
|
|
77
|
+
while (prefixLen < threshold) {
|
|
78
|
+
const next = await iter.next();
|
|
79
|
+
if (next.done) {
|
|
80
|
+
done = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
prefix.push(next.value);
|
|
84
|
+
prefixLen += next.value.length;
|
|
85
|
+
}
|
|
86
|
+
if (done && prefixLen < threshold) {
|
|
87
|
+
let blob = /* @__PURE__ */ new Uint8Array(0);
|
|
88
|
+
for (const p of prefix) blob = concat(blob, p);
|
|
89
|
+
yield blob;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const m = masksFor(cdc);
|
|
93
|
+
let buf = /* @__PURE__ */ new Uint8Array(0);
|
|
94
|
+
const feed = async function* () {
|
|
95
|
+
for (const p of prefix) yield p;
|
|
96
|
+
if (!done) for (let n = await iter.next(); !n.done; n = await iter.next()) yield n.value;
|
|
97
|
+
};
|
|
98
|
+
for await (const incoming of feed()) {
|
|
99
|
+
buf = concat(buf, incoming);
|
|
100
|
+
for (let cut = findCut(buf, m, false); cut >= 0; cut = findCut(buf, m, false)) {
|
|
101
|
+
yield buf.subarray(0, cut);
|
|
102
|
+
buf = buf.subarray(cut);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
while (buf.length > 0) {
|
|
106
|
+
let cut = findCut(buf, m, true);
|
|
107
|
+
if (cut <= 0) cut = buf.length;
|
|
108
|
+
yield buf.subarray(0, cut);
|
|
109
|
+
buf = buf.subarray(cut);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/manifest.ts
|
|
114
|
+
/** Serialize a descriptor to JSON bytes for the manifests BlobStore. */
|
|
115
|
+
function serializeManifest(descriptor) {
|
|
116
|
+
return new TextEncoder().encode(JSON.stringify(descriptor));
|
|
117
|
+
}
|
|
118
|
+
/** Parse manifest bytes back into a descriptor. */
|
|
119
|
+
function parseManifest(bytes) {
|
|
120
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/content-store.ts
|
|
124
|
+
const DEFAULT_THRESHOLD = 4096;
|
|
125
|
+
const DEFAULT_CDC = {
|
|
126
|
+
min: 2048,
|
|
127
|
+
avg: 8192,
|
|
128
|
+
max: 32768
|
|
129
|
+
};
|
|
130
|
+
/** A fresh single-shot stream over one buffer (streams are consumed once). */
|
|
131
|
+
async function* one(bytes) {
|
|
132
|
+
yield bytes;
|
|
133
|
+
}
|
|
134
|
+
async function collect(stream) {
|
|
135
|
+
const parts = [];
|
|
136
|
+
for await (const chunk of stream) parts.push(chunk);
|
|
137
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
138
|
+
const out = new Uint8Array(total);
|
|
139
|
+
let off = 0;
|
|
140
|
+
for (const p of parts) {
|
|
141
|
+
out.set(p, off);
|
|
142
|
+
off += p.length;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function createContentStore(deps, opts) {
|
|
147
|
+
const { chunks, manifests, hashContent } = deps;
|
|
148
|
+
const threshold = opts?.chunkThreshold ?? DEFAULT_THRESHOLD;
|
|
149
|
+
const cdc = opts?.cdc ?? DEFAULT_CDC;
|
|
150
|
+
async function getManifest(id) {
|
|
151
|
+
if (!await manifests.has(id)) return void 0;
|
|
152
|
+
return parseManifest(await collect(manifests.get(id)));
|
|
153
|
+
}
|
|
154
|
+
/** Re-stream stored chunks in order — feeds the whole-object hash without buffering. */
|
|
155
|
+
async function* restream(refs) {
|
|
156
|
+
for (const ref of refs) yield* chunks.get(ref.id);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
async put(content) {
|
|
160
|
+
const refs = [];
|
|
161
|
+
let offset = 0;
|
|
162
|
+
for await (const bytes of chunkContent(content, cdc, threshold)) {
|
|
163
|
+
const id = await hashContent(one(bytes));
|
|
164
|
+
if (!await chunks.has(id)) await chunks.put(id, one(bytes));
|
|
165
|
+
refs.push({
|
|
166
|
+
id,
|
|
167
|
+
offset,
|
|
168
|
+
size: bytes.length
|
|
169
|
+
});
|
|
170
|
+
offset += bytes.length;
|
|
171
|
+
}
|
|
172
|
+
const objectId = await hashContent(restream(refs));
|
|
173
|
+
const descriptor = {
|
|
174
|
+
id: objectId,
|
|
175
|
+
size: offset,
|
|
176
|
+
chunks: refs
|
|
177
|
+
};
|
|
178
|
+
await manifests.put(objectId, one(serializeManifest(descriptor)));
|
|
179
|
+
return descriptor;
|
|
180
|
+
},
|
|
181
|
+
has(id) {
|
|
182
|
+
return manifests.has(id);
|
|
183
|
+
},
|
|
184
|
+
read(id, range) {
|
|
185
|
+
return (async function* () {
|
|
186
|
+
const descriptor = await getManifest(id);
|
|
187
|
+
if (!descriptor) return;
|
|
188
|
+
const start = range?.offset ?? 0;
|
|
189
|
+
const end = range?.length === void 0 ? descriptor.size : start + range.length;
|
|
190
|
+
for (const ref of descriptor.chunks) {
|
|
191
|
+
if (ref.offset + ref.size <= start) continue;
|
|
192
|
+
if (ref.offset >= end) break;
|
|
193
|
+
let pos = ref.offset;
|
|
194
|
+
for await (const piece of chunks.get(ref.id)) {
|
|
195
|
+
const pieceStart = pos;
|
|
196
|
+
pos += piece.length;
|
|
197
|
+
const from = Math.max(start, pieceStart);
|
|
198
|
+
const to = Math.min(end, pos);
|
|
199
|
+
if (from < to) yield piece.subarray(from - pieceStart, to - pieceStart);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
},
|
|
204
|
+
getManifest,
|
|
205
|
+
remove(id) {
|
|
206
|
+
return manifests.remove(id);
|
|
207
|
+
},
|
|
208
|
+
async hasChunks(ids) {
|
|
209
|
+
const missing = [];
|
|
210
|
+
for (const id of ids) if (!await chunks.has(id)) missing.push(id);
|
|
211
|
+
return missing;
|
|
212
|
+
},
|
|
213
|
+
async putChunk(id, bytes) {
|
|
214
|
+
await chunks.put(id, bytes);
|
|
215
|
+
},
|
|
216
|
+
getChunk(id) {
|
|
217
|
+
return chunks.get(id);
|
|
218
|
+
},
|
|
219
|
+
async gc(liveRoots) {
|
|
220
|
+
const live = new Set(liveRoots);
|
|
221
|
+
const liveChunks = /* @__PURE__ */ new Set();
|
|
222
|
+
for (const root of liveRoots) {
|
|
223
|
+
const d = await getManifest(root);
|
|
224
|
+
if (d) for (const ref of d.chunks) liveChunks.add(ref.id);
|
|
225
|
+
}
|
|
226
|
+
let removedObjects = 0;
|
|
227
|
+
let removedChunks = 0;
|
|
228
|
+
for await (const objId of manifests.list()) if (!live.has(objId) && await manifests.remove(objId)) removedObjects++;
|
|
229
|
+
for await (const chunkId of chunks.list()) if (!liveChunks.has(chunkId) && await chunks.remove(chunkId)) removedChunks++;
|
|
230
|
+
return {
|
|
231
|
+
removedObjects,
|
|
232
|
+
removedChunks
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
export { createContentStore };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ObjectDescriptor } from "./types.js";
|
|
2
|
+
/** Serialize a descriptor to JSON bytes for the manifests BlobStore. */
|
|
3
|
+
export declare function serializeManifest(descriptor: ObjectDescriptor): Uint8Array;
|
|
4
|
+
/** Parse manifest bytes back into a descriptor. */
|
|
5
|
+
export declare function parseManifest(bytes: Uint8Array): ObjectDescriptor;
|
|
6
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,gBAAgB,GAAG,UAAU,CAE1E;AAED,mDAAmD;AACnD,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAEjE"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public contract for the content-addressed large-object store.
|
|
3
|
+
*
|
|
4
|
+
* The store persists large byte streams as deduplicated, content-defined chunks
|
|
5
|
+
* over the domain-neutral {@link BlobStore} seam. It is algorithm-agnostic: the
|
|
6
|
+
* whole-object and per-chunk identities are computed by an injected
|
|
7
|
+
* `hashContent`, and every id is treated as an OPAQUE string (it carries
|
|
8
|
+
* whatever prefix the hasher produces). The store knows nothing of git, LFS,
|
|
9
|
+
* SHA-256, commits, or sync.
|
|
10
|
+
*/
|
|
11
|
+
import type { BlobStore, ByteStream } from "@statewalker/webrun-storage";
|
|
12
|
+
export type { ByteStream };
|
|
13
|
+
/** Opaque id of a single content-defined chunk (produced by `hashContent`). */
|
|
14
|
+
export type ChunkId = string;
|
|
15
|
+
/** Opaque id of a whole stored object (produced by `hashContent`). */
|
|
16
|
+
export type ObjectId = string;
|
|
17
|
+
/** Where one chunk sits inside its object. Refs tile `[0, size)` exactly. */
|
|
18
|
+
export interface ChunkRef {
|
|
19
|
+
id: ChunkId;
|
|
20
|
+
offset: number;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
/** Serialized recipe for reassembling an object from its chunks. */
|
|
24
|
+
export interface ObjectDescriptor {
|
|
25
|
+
id: ObjectId;
|
|
26
|
+
size: number;
|
|
27
|
+
/** One entry for a direct blob; many for a CDC-chunked object. */
|
|
28
|
+
chunks: ChunkRef[];
|
|
29
|
+
}
|
|
30
|
+
/** Injected backing stores + identity function. */
|
|
31
|
+
export interface ContentStoreDeps {
|
|
32
|
+
/** Stores chunk bytes, keyed by {@link ChunkId}. */
|
|
33
|
+
chunks: BlobStore;
|
|
34
|
+
/** Stores serialized {@link ObjectDescriptor}s, keyed by {@link ObjectId}. */
|
|
35
|
+
manifests: BlobStore;
|
|
36
|
+
/** REQUIRED — the injected, algorithm-agnostic identity. Ids are opaque. */
|
|
37
|
+
hashContent: (bytes: ByteStream) => Promise<string>;
|
|
38
|
+
}
|
|
39
|
+
/** Content-defined chunker tuning. */
|
|
40
|
+
export interface CdcParams {
|
|
41
|
+
min: number;
|
|
42
|
+
avg: number;
|
|
43
|
+
max: number;
|
|
44
|
+
}
|
|
45
|
+
export interface ContentStoreOptions {
|
|
46
|
+
/** Content strictly smaller than this is stored as a single direct blob (no CDC). */
|
|
47
|
+
chunkThreshold?: number;
|
|
48
|
+
/** Gear/FastCDC parameters. */
|
|
49
|
+
cdc?: CdcParams;
|
|
50
|
+
}
|
|
51
|
+
export interface ContentStore {
|
|
52
|
+
/** Chunk + hash `content` in a bounded-memory pass, storing chunks + manifest. */
|
|
53
|
+
put(content: ByteStream): Promise<ObjectDescriptor>;
|
|
54
|
+
has(id: ObjectId): Promise<boolean>;
|
|
55
|
+
/** Stream the object's bytes, optionally sliced; empty stream if absent. */
|
|
56
|
+
read(id: ObjectId, range?: {
|
|
57
|
+
offset?: number;
|
|
58
|
+
length?: number;
|
|
59
|
+
}): ByteStream;
|
|
60
|
+
getManifest(id: ObjectId): Promise<ObjectDescriptor | undefined>;
|
|
61
|
+
/** Drop the object entry; its chunks are reclaimed later by {@link ContentStore.gc}. */
|
|
62
|
+
remove(id: ObjectId): Promise<boolean>;
|
|
63
|
+
/** Return exactly the ids NOT present in the chunk store. */
|
|
64
|
+
hasChunks(ids: ChunkId[]): Promise<ChunkId[]>;
|
|
65
|
+
putChunk(id: ChunkId, bytes: ByteStream): Promise<void>;
|
|
66
|
+
getChunk(id: ChunkId): ByteStream;
|
|
67
|
+
/** Sweep every object not in `liveRoots` and every chunk they alone referenced. */
|
|
68
|
+
gc(liveRoots: ObjectId[]): Promise<{
|
|
69
|
+
removedObjects: number;
|
|
70
|
+
removedChunks: number;
|
|
71
|
+
}>;
|
|
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,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAEzE,YAAY,EAAE,UAAU,EAAE,CAAC;AAE3B,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,sEAAsE;AACtE,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE9B,6EAA6E;AAC7E,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,oEAAoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,QAAQ,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,MAAM,EAAE,QAAQ,EAAE,CAAC;CACpB;AAED,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,MAAM,EAAE,SAAS,CAAC;IAClB,8EAA8E;IAC9E,SAAS,EAAE,SAAS,CAAC;IACrB,4EAA4E;IAC5E,WAAW,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACrD;AAED,sCAAsC;AACtC,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB;IAClC,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,+BAA+B;IAC/B,GAAG,CAAC,EAAE,SAAS,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAE3B,kFAAkF;IAClF,GAAG,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACpD,GAAG,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACpC,4EAA4E;IAC5E,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,CAAC;IAC7E,WAAW,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAAC;IACjE,wFAAwF;IACxF,MAAM,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvC,6DAA6D;IAC7D,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC9C,QAAQ,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,UAAU,CAAC;IAElC,mFAAmF;IACnF,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-content-store",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Domain-neutral, content-addressed large-object store with content-defined chunking over the storage BlobStore",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/statewalker/webrun-vcs.git",
|
|
9
|
+
"directory": "packages/webrun-content-store"
|
|
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-storage": "^0.2.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@statewalker/webrun-files-mem": "^0.10.0",
|
|
35
|
+
"rolldown": "^1.2.12",
|
|
36
|
+
"vitest": "^5.0.3",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"@types/node": "^26.6.4"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rm -rf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"lint": "biome check --write .",
|
|
44
|
+
"lint:check": "biome check .",
|
|
45
|
+
"lint:fix": "biome check --write --unsafe .",
|
|
46
|
+
"format": "biome format --write .",
|
|
47
|
+
"format:check": "biome format .",
|
|
48
|
+
"format:fix": "biome check --write --unsafe .",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/chunker.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ByteStream, CdcParams } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Gear-hash FastCDC content-defined chunker.
|
|
5
|
+
*
|
|
6
|
+
* A rolling Gear fingerprint (`fp = (fp << 1) + GEAR[byte]`) restarts at every
|
|
7
|
+
* chunk boundary; a cut is taken when the low bits of `fp` are zero. Normalized
|
|
8
|
+
* chunking (Xia et al., FastCDC) uses a stricter mask before the average size
|
|
9
|
+
* and a looser one after, tightening the size distribution. Because the cut for
|
|
10
|
+
* a chunk depends only on the bytes since its own start, boundaries re-sync
|
|
11
|
+
* shortly after an edit — so a mid-file insert leaves surrounding chunks intact.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const GEAR = buildGearTable();
|
|
15
|
+
|
|
16
|
+
/** 256 deterministic 32-bit gear values (xorshift32 from a fixed seed). */
|
|
17
|
+
function buildGearTable(): Uint32Array {
|
|
18
|
+
const g = new Uint32Array(256);
|
|
19
|
+
let x = 0x9e3779b9;
|
|
20
|
+
for (let i = 0; i < 256; i++) {
|
|
21
|
+
x ^= x << 13;
|
|
22
|
+
x ^= x >>> 17;
|
|
23
|
+
x ^= x << 5;
|
|
24
|
+
x >>>= 0;
|
|
25
|
+
g[i] = x;
|
|
26
|
+
}
|
|
27
|
+
return g;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Masks {
|
|
31
|
+
min: number;
|
|
32
|
+
avg: number;
|
|
33
|
+
max: number;
|
|
34
|
+
maskS: number;
|
|
35
|
+
maskL: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function masksFor(cdc: CdcParams): Masks {
|
|
39
|
+
const bits = Math.round(Math.log2(cdc.avg));
|
|
40
|
+
// Normalization = 2: harder mask (more low bits ⇒ rarer cut) before avg,
|
|
41
|
+
// easier mask after, clamped so both stay valid low-bit masks.
|
|
42
|
+
const maskS = (1 << Math.min(bits + 2, 31)) - 1;
|
|
43
|
+
const maskL = (1 << Math.max(bits - 2, 1)) - 1;
|
|
44
|
+
return { min: cdc.min, avg: cdc.avg, max: cdc.max, maskS, maskL };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Find the cut index (exclusive end of the first chunk) within `buf`, whose
|
|
49
|
+
* chunk starts at index 0. Returns `-1` when more bytes are needed before a cut
|
|
50
|
+
* can be decided (only when `!final`).
|
|
51
|
+
*/
|
|
52
|
+
function findCut(buf: Uint8Array, m: Masks, final: boolean): number {
|
|
53
|
+
const n = buf.length;
|
|
54
|
+
if (n <= m.min) return final ? n : -1; // too small to cut yet
|
|
55
|
+
const limit = Math.min(n, m.max);
|
|
56
|
+
let fp = 0;
|
|
57
|
+
let i = m.min;
|
|
58
|
+
const avgStop = Math.min(m.avg, limit);
|
|
59
|
+
for (; i < avgStop; i++) {
|
|
60
|
+
fp = ((fp << 1) + GEAR[buf[i]]) >>> 0;
|
|
61
|
+
if ((fp & m.maskS) === 0) return i;
|
|
62
|
+
}
|
|
63
|
+
for (; i < limit; i++) {
|
|
64
|
+
fp = ((fp << 1) + GEAR[buf[i]]) >>> 0;
|
|
65
|
+
if ((fp & m.maskL) === 0) return i;
|
|
66
|
+
}
|
|
67
|
+
if (limit === m.max) return m.max; // forced cut at max size
|
|
68
|
+
return final ? n : -1; // remainder is a final chunk, or await more bytes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
72
|
+
const out = new Uint8Array(a.length + b.length);
|
|
73
|
+
out.set(a, 0);
|
|
74
|
+
out.set(b, a.length);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Split `source` into content-defined chunks. Content strictly smaller than
|
|
80
|
+
* `threshold` is emitted as a single direct blob (no CDC). Bounded memory: at
|
|
81
|
+
* most one pending chunk (plus one incoming array) is buffered at a time.
|
|
82
|
+
*/
|
|
83
|
+
export async function* chunkContent(
|
|
84
|
+
source: ByteStream,
|
|
85
|
+
cdc: CdcParams,
|
|
86
|
+
threshold: number,
|
|
87
|
+
): AsyncIterable<Uint8Array> {
|
|
88
|
+
const iter = source[Symbol.asyncIterator]();
|
|
89
|
+
|
|
90
|
+
// Buffer up to `threshold` bytes to decide direct-blob vs CDC.
|
|
91
|
+
const prefix: Uint8Array[] = [];
|
|
92
|
+
let prefixLen = 0;
|
|
93
|
+
let done = false;
|
|
94
|
+
while (prefixLen < threshold) {
|
|
95
|
+
const next = await iter.next();
|
|
96
|
+
if (next.done) {
|
|
97
|
+
done = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
prefix.push(next.value);
|
|
101
|
+
prefixLen += next.value.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (done && prefixLen < threshold) {
|
|
105
|
+
// Whole content is below threshold → one direct blob.
|
|
106
|
+
let blob = new Uint8Array(0);
|
|
107
|
+
for (const p of prefix) blob = concat(blob, p);
|
|
108
|
+
yield blob;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// At or above threshold → CDC over the buffered prefix, then the rest.
|
|
113
|
+
const m = masksFor(cdc);
|
|
114
|
+
let buf = new Uint8Array(0);
|
|
115
|
+
const feed = async function* (): AsyncIterable<Uint8Array> {
|
|
116
|
+
for (const p of prefix) yield p;
|
|
117
|
+
if (!done) {
|
|
118
|
+
for (let n = await iter.next(); !n.done; n = await iter.next()) yield n.value;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for await (const incoming of feed()) {
|
|
123
|
+
buf = concat(buf, incoming);
|
|
124
|
+
for (let cut = findCut(buf, m, false); cut >= 0; cut = findCut(buf, m, false)) {
|
|
125
|
+
yield buf.subarray(0, cut);
|
|
126
|
+
buf = buf.subarray(cut);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Flush the tail.
|
|
130
|
+
while (buf.length > 0) {
|
|
131
|
+
let cut = findCut(buf, m, true);
|
|
132
|
+
if (cut <= 0) cut = buf.length;
|
|
133
|
+
yield buf.subarray(0, cut);
|
|
134
|
+
buf = buf.subarray(cut);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { chunkContent } from "./chunker.js";
|
|
2
|
+
import { parseManifest, serializeManifest } from "./manifest.js";
|
|
3
|
+
import type {
|
|
4
|
+
ByteStream,
|
|
5
|
+
ChunkId,
|
|
6
|
+
ChunkRef,
|
|
7
|
+
ContentStore,
|
|
8
|
+
ContentStoreDeps,
|
|
9
|
+
ContentStoreOptions,
|
|
10
|
+
ObjectDescriptor,
|
|
11
|
+
ObjectId,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_THRESHOLD = 4096;
|
|
15
|
+
const DEFAULT_CDC = { min: 2048, avg: 8192, max: 32768 };
|
|
16
|
+
|
|
17
|
+
/** A fresh single-shot stream over one buffer (streams are consumed once). */
|
|
18
|
+
async function* one(bytes: Uint8Array): ByteStream {
|
|
19
|
+
yield bytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function collect(stream: ByteStream): Promise<Uint8Array> {
|
|
23
|
+
const parts: Uint8Array[] = [];
|
|
24
|
+
for await (const chunk of stream) parts.push(chunk);
|
|
25
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
26
|
+
const out = new Uint8Array(total);
|
|
27
|
+
let off = 0;
|
|
28
|
+
for (const p of parts) {
|
|
29
|
+
out.set(p, off);
|
|
30
|
+
off += p.length;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createContentStore(
|
|
36
|
+
deps: ContentStoreDeps,
|
|
37
|
+
opts?: ContentStoreOptions,
|
|
38
|
+
): ContentStore {
|
|
39
|
+
const { chunks, manifests, hashContent } = deps;
|
|
40
|
+
const threshold = opts?.chunkThreshold ?? DEFAULT_THRESHOLD;
|
|
41
|
+
const cdc = opts?.cdc ?? DEFAULT_CDC;
|
|
42
|
+
|
|
43
|
+
async function getManifest(id: ObjectId): Promise<ObjectDescriptor | undefined> {
|
|
44
|
+
if (!(await manifests.has(id))) return undefined;
|
|
45
|
+
return parseManifest(await collect(manifests.get(id)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Re-stream stored chunks in order — feeds the whole-object hash without buffering. */
|
|
49
|
+
async function* restream(refs: ChunkRef[]): ByteStream {
|
|
50
|
+
for (const ref of refs) yield* chunks.get(ref.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const store: ContentStore = {
|
|
54
|
+
async put(content) {
|
|
55
|
+
const refs: ChunkRef[] = [];
|
|
56
|
+
let offset = 0;
|
|
57
|
+
for await (const bytes of chunkContent(content, cdc, threshold)) {
|
|
58
|
+
const id = await hashContent(one(bytes));
|
|
59
|
+
if (!(await chunks.has(id))) await chunks.put(id, one(bytes));
|
|
60
|
+
refs.push({ id, offset, size: bytes.length });
|
|
61
|
+
offset += bytes.length;
|
|
62
|
+
}
|
|
63
|
+
const objectId = await hashContent(restream(refs));
|
|
64
|
+
const descriptor: ObjectDescriptor = { id: objectId, size: offset, chunks: refs };
|
|
65
|
+
await manifests.put(objectId, one(serializeManifest(descriptor)));
|
|
66
|
+
return descriptor;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
has(id) {
|
|
70
|
+
return manifests.has(id);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
read(id, range) {
|
|
74
|
+
return (async function* (): ByteStream {
|
|
75
|
+
const descriptor = await getManifest(id);
|
|
76
|
+
if (!descriptor) return;
|
|
77
|
+
const start = range?.offset ?? 0;
|
|
78
|
+
const end = range?.length === undefined ? descriptor.size : start + range.length;
|
|
79
|
+
for (const ref of descriptor.chunks) {
|
|
80
|
+
if (ref.offset + ref.size <= start) continue; // entirely before the range
|
|
81
|
+
if (ref.offset >= end) break; // chunks are ordered — nothing left overlaps
|
|
82
|
+
let pos = ref.offset;
|
|
83
|
+
for await (const piece of chunks.get(ref.id)) {
|
|
84
|
+
const pieceStart = pos;
|
|
85
|
+
pos += piece.length;
|
|
86
|
+
const from = Math.max(start, pieceStart);
|
|
87
|
+
const to = Math.min(end, pos);
|
|
88
|
+
if (from < to) yield piece.subarray(from - pieceStart, to - pieceStart);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getManifest,
|
|
95
|
+
|
|
96
|
+
remove(id) {
|
|
97
|
+
return manifests.remove(id);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async hasChunks(ids) {
|
|
101
|
+
const missing: ChunkId[] = [];
|
|
102
|
+
for (const id of ids) {
|
|
103
|
+
if (!(await chunks.has(id))) missing.push(id);
|
|
104
|
+
}
|
|
105
|
+
return missing;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async putChunk(id, bytes) {
|
|
109
|
+
await chunks.put(id, bytes);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
getChunk(id) {
|
|
113
|
+
return chunks.get(id);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async gc(liveRoots) {
|
|
117
|
+
const live = new Set(liveRoots);
|
|
118
|
+
const liveChunks = new Set<ChunkId>();
|
|
119
|
+
for (const root of liveRoots) {
|
|
120
|
+
const d = await getManifest(root);
|
|
121
|
+
if (d) for (const ref of d.chunks) liveChunks.add(ref.id);
|
|
122
|
+
}
|
|
123
|
+
let removedObjects = 0;
|
|
124
|
+
let removedChunks = 0;
|
|
125
|
+
for await (const objId of manifests.list()) {
|
|
126
|
+
if (!live.has(objId) && (await manifests.remove(objId))) removedObjects++;
|
|
127
|
+
}
|
|
128
|
+
for await (const chunkId of chunks.list()) {
|
|
129
|
+
if (!liveChunks.has(chunkId) && (await chunks.remove(chunkId))) removedChunks++;
|
|
130
|
+
}
|
|
131
|
+
return { removedObjects, removedChunks };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return store;
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statewalker/webrun-content-store
|
|
3
|
+
*
|
|
4
|
+
* Domain-neutral, content-addressed large-object store. It stores large byte
|
|
5
|
+
* streams as deduplicated, content-defined chunks over the `@statewalker/webrun-storage`
|
|
6
|
+
* BlobStore seam. It is algorithm-agnostic — object and chunk identities are
|
|
7
|
+
* computed by an injected `hashContent`, and every id is an opaque string. It
|
|
8
|
+
* knows nothing of git, LFS, SHA-256, commits, or sync.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createContentStore } from "./content-store.js";
|
|
12
|
+
export type {
|
|
13
|
+
ByteStream,
|
|
14
|
+
CdcParams,
|
|
15
|
+
ChunkId,
|
|
16
|
+
ChunkRef,
|
|
17
|
+
ContentStore,
|
|
18
|
+
ContentStoreDeps,
|
|
19
|
+
ContentStoreOptions,
|
|
20
|
+
ObjectDescriptor,
|
|
21
|
+
ObjectId,
|
|
22
|
+
} from "./types.js";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ObjectDescriptor } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Serialize a descriptor to JSON bytes for the manifests BlobStore. */
|
|
4
|
+
export function serializeManifest(descriptor: ObjectDescriptor): Uint8Array {
|
|
5
|
+
return new TextEncoder().encode(JSON.stringify(descriptor));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Parse manifest bytes back into a descriptor. */
|
|
9
|
+
export function parseManifest(bytes: Uint8Array): ObjectDescriptor {
|
|
10
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as ObjectDescriptor;
|
|
11
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public contract for the content-addressed large-object store.
|
|
3
|
+
*
|
|
4
|
+
* The store persists large byte streams as deduplicated, content-defined chunks
|
|
5
|
+
* over the domain-neutral {@link BlobStore} seam. It is algorithm-agnostic: the
|
|
6
|
+
* whole-object and per-chunk identities are computed by an injected
|
|
7
|
+
* `hashContent`, and every id is treated as an OPAQUE string (it carries
|
|
8
|
+
* whatever prefix the hasher produces). The store knows nothing of git, LFS,
|
|
9
|
+
* SHA-256, commits, or sync.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { BlobStore, ByteStream } from "@statewalker/webrun-storage";
|
|
13
|
+
|
|
14
|
+
export type { ByteStream };
|
|
15
|
+
|
|
16
|
+
/** Opaque id of a single content-defined chunk (produced by `hashContent`). */
|
|
17
|
+
export type ChunkId = string;
|
|
18
|
+
|
|
19
|
+
/** Opaque id of a whole stored object (produced by `hashContent`). */
|
|
20
|
+
export type ObjectId = string;
|
|
21
|
+
|
|
22
|
+
/** Where one chunk sits inside its object. Refs tile `[0, size)` exactly. */
|
|
23
|
+
export interface ChunkRef {
|
|
24
|
+
id: ChunkId;
|
|
25
|
+
offset: number;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Serialized recipe for reassembling an object from its chunks. */
|
|
30
|
+
export interface ObjectDescriptor {
|
|
31
|
+
id: ObjectId;
|
|
32
|
+
size: number;
|
|
33
|
+
/** One entry for a direct blob; many for a CDC-chunked object. */
|
|
34
|
+
chunks: ChunkRef[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Injected backing stores + identity function. */
|
|
38
|
+
export interface ContentStoreDeps {
|
|
39
|
+
/** Stores chunk bytes, keyed by {@link ChunkId}. */
|
|
40
|
+
chunks: BlobStore;
|
|
41
|
+
/** Stores serialized {@link ObjectDescriptor}s, keyed by {@link ObjectId}. */
|
|
42
|
+
manifests: BlobStore;
|
|
43
|
+
/** REQUIRED — the injected, algorithm-agnostic identity. Ids are opaque. */
|
|
44
|
+
hashContent: (bytes: ByteStream) => Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Content-defined chunker tuning. */
|
|
48
|
+
export interface CdcParams {
|
|
49
|
+
min: number;
|
|
50
|
+
avg: number;
|
|
51
|
+
max: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ContentStoreOptions {
|
|
55
|
+
/** Content strictly smaller than this is stored as a single direct blob (no CDC). */
|
|
56
|
+
chunkThreshold?: number;
|
|
57
|
+
/** Gear/FastCDC parameters. */
|
|
58
|
+
cdc?: CdcParams;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ContentStore {
|
|
62
|
+
// object level
|
|
63
|
+
/** Chunk + hash `content` in a bounded-memory pass, storing chunks + manifest. */
|
|
64
|
+
put(content: ByteStream): Promise<ObjectDescriptor>;
|
|
65
|
+
has(id: ObjectId): Promise<boolean>;
|
|
66
|
+
/** Stream the object's bytes, optionally sliced; empty stream if absent. */
|
|
67
|
+
read(id: ObjectId, range?: { offset?: number; length?: number }): ByteStream;
|
|
68
|
+
getManifest(id: ObjectId): Promise<ObjectDescriptor | undefined>;
|
|
69
|
+
/** Drop the object entry; its chunks are reclaimed later by {@link ContentStore.gc}. */
|
|
70
|
+
remove(id: ObjectId): Promise<boolean>;
|
|
71
|
+
// chunk level (transfer / dedup)
|
|
72
|
+
/** Return exactly the ids NOT present in the chunk store. */
|
|
73
|
+
hasChunks(ids: ChunkId[]): Promise<ChunkId[]>;
|
|
74
|
+
putChunk(id: ChunkId, bytes: ByteStream): Promise<void>;
|
|
75
|
+
getChunk(id: ChunkId): ByteStream;
|
|
76
|
+
// maintenance
|
|
77
|
+
/** Sweep every object not in `liveRoots` and every chunk they alone referenced. */
|
|
78
|
+
gc(liveRoots: ObjectId[]): Promise<{ removedObjects: number; removedChunks: number }>;
|
|
79
|
+
}
|