@statewalker/webrun-content-store 0.2.2 → 0.3.0
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/README.md +88 -35
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -1,20 +1,47 @@
|
|
|
1
1
|
# @statewalker/webrun-content-store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A content-addressed store for large byte streams. It splits content into content-defined chunks,
|
|
6
|
+
stores each distinct chunk once, and keeps a manifest per object listing its chunks. Chunks and
|
|
7
|
+
manifests live in two `BlobStore`s from `@statewalker/webrun-storage`; object and chunk ids come
|
|
8
|
+
from a hash function you supply.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
## Why it exists
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
Large files are expensive to store twice and to send whole. With content-defined chunking, two
|
|
13
|
+
versions of a file share most of their chunks, so storage grows by the changed region only, and a
|
|
14
|
+
peer that already has most chunks needs only the missing ones. This package provides that chunk
|
|
15
|
+
layer for any domain: it knows nothing of git, LFS or sync, and treats every id as an opaque
|
|
16
|
+
string. `@statewalker/webrun-content-transfer` moves its chunks between stores.
|
|
10
17
|
|
|
11
|
-
##
|
|
18
|
+
## How to use
|
|
12
19
|
|
|
13
20
|
```bash
|
|
14
|
-
pnpm add @statewalker/webrun-content-store
|
|
21
|
+
pnpm add @statewalker/webrun-content-store @statewalker/webrun-storage
|
|
15
22
|
```
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
| Import | Contents |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `@statewalker/webrun-content-store` | `createContentStore` and the types `ContentStore`, `ContentStoreDeps`, `ContentStoreOptions`, `ObjectDescriptor`, `ChunkRef`, `ObjectId`, `ChunkId`, `CdcParams`, `ByteStream`. ESM, runs in browsers, Node and workers. |
|
|
27
|
+
|
|
28
|
+
`createContentStore(deps, opts?)`:
|
|
29
|
+
|
|
30
|
+
- `deps`: `{ chunks: BlobStore, manifests: BlobStore, hashContent }`.
|
|
31
|
+
- `opts`: `{ chunkThreshold?, cdc? }`. Defaults: `chunkThreshold` 4096 bytes,
|
|
32
|
+
`cdc` `{ min: 2048, avg: 8192, max: 32768 }`.
|
|
33
|
+
|
|
34
|
+
The returned `ContentStore`:
|
|
35
|
+
|
|
36
|
+
| Level | Methods |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| Object | `put(content)` returns `{ id, size, chunks }`; `read(id, { offset?, length? }?)`; `has(id)`; `getManifest(id)`; `remove(id)` |
|
|
39
|
+
| Chunk | `hasChunks(ids)` returns the ids that are **not** stored; `putChunk(id, bytes)`; `getChunk(id)` |
|
|
40
|
+
| Maintenance | `gc(liveRoots)` returns `{ removedObjects, removedChunks }` |
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
Store, read back and slice an object:
|
|
18
45
|
|
|
19
46
|
```typescript
|
|
20
47
|
import { createHash } from "node:crypto";
|
|
@@ -26,43 +53,69 @@ async function sha256(bytes: AsyncIterable<Uint8Array>): Promise<string> {
|
|
|
26
53
|
for await (const chunk of bytes) h.update(chunk);
|
|
27
54
|
return `sha256:${h.digest("hex")}`;
|
|
28
55
|
}
|
|
56
|
+
async function* streamOf(bytes: Uint8Array) {
|
|
57
|
+
yield bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const store = createContentStore({
|
|
61
|
+
chunks: memBlobStore(),
|
|
62
|
+
manifests: memBlobStore(),
|
|
63
|
+
hashContent: sha256,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const payload = new Uint8Array(100_000).map((_, i) => (i * 7919) % 251);
|
|
67
|
+
const d = await store.put(streamOf(payload)); // { id, size: 100000, chunks: [...] }
|
|
68
|
+
const whole = store.read(d.id);
|
|
69
|
+
const slice = store.read(d.id, { offset: 30_000, length: 2_500 }); // crosses chunk borders
|
|
70
|
+
```
|
|
29
71
|
|
|
30
|
-
|
|
31
|
-
{ chunks: memBlobStore(), manifests: memBlobStore(), hashContent: sha256 },
|
|
32
|
-
{ chunkThreshold: 64, cdc: { min: 64, avg: 256, max: 1024 } },
|
|
33
|
-
);
|
|
72
|
+
Find what a peer is missing, and clean up:
|
|
34
73
|
|
|
35
|
-
|
|
74
|
+
```typescript
|
|
75
|
+
const missing = await store.hasChunks(d.chunks.map((c) => c.id)); // [] here: all present
|
|
76
|
+
await store.remove(d.id); // removes the manifest only
|
|
77
|
+
await store.gc([]); // no live roots: removes every remaining object and chunk
|
|
78
|
+
```
|
|
36
79
|
|
|
37
|
-
|
|
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
|
|
80
|
+
## Internals
|
|
41
81
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
82
|
+
### How content is cut into chunks
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
bytes ──> Gear rolling hash ──> cut where low bits are zero ──> chunks (min..max bytes)
|
|
86
|
+
(restarts at every cut) (stricter mask below avg, looser above)
|
|
45
87
|
```
|
|
46
88
|
|
|
47
|
-
|
|
89
|
+
The chunker is a minimal FastCDC: a Gear rolling fingerprint that restarts at each boundary, with
|
|
90
|
+
normalized masks that pull chunk sizes toward `avg`. Because a cut depends only on the bytes since
|
|
91
|
+
the previous cut, boundaries re-synchronise shortly after an insert or delete, and the chunks
|
|
92
|
+
around the edit keep their ids. Content strictly smaller than `chunkThreshold` is stored as one
|
|
93
|
+
chunk; chunking small files only adds manifest overhead.
|
|
94
|
+
|
|
95
|
+
### Memory stays bounded
|
|
96
|
+
|
|
97
|
+
`put` reads the input one chunk at a time. The whole-object id is computed by re-streaming the
|
|
98
|
+
stored chunks in order through `hashContent`, so an object is never held in memory in one piece.
|
|
99
|
+
`read` with a range skips chunks outside the range.
|
|
100
|
+
|
|
101
|
+
### Ids are opaque
|
|
102
|
+
|
|
103
|
+
Object and chunk ids are whatever `hashContent` returns, including any prefix such as `sha256:`.
|
|
104
|
+
The store commits to no algorithm. Use the same `hashContent` for every store that exchanges
|
|
105
|
+
chunks; ids from different hashes never match.
|
|
48
106
|
|
|
49
|
-
|
|
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)`.
|
|
107
|
+
### Liveness is external
|
|
53
108
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
109
|
+
There are no reference counts. `remove(id)` deletes only the manifest: chunks stay, because
|
|
110
|
+
another object may share them. `gc(liveRoots)` marks every chunk reachable from the manifests you
|
|
111
|
+
list and deletes all other manifests and chunks. Anything not in `liveRoots` is removed, so pass
|
|
112
|
+
the complete set of objects you still need.
|
|
57
113
|
|
|
58
|
-
|
|
59
|
-
- `gc(liveRoots): Promise<{ removedObjects, removedChunks }>` — mark-sweep from caller-supplied roots.
|
|
114
|
+
### Dependencies
|
|
60
115
|
|
|
61
|
-
|
|
116
|
+
`@statewalker/webrun-storage`, for the `BlobStore` type. `@statewalker/webrun-files` is declared in
|
|
117
|
+
`package.json` but not imported by the sources. The chunker and manifest format are in-package. No runtime dependencies.
|
|
62
118
|
|
|
63
|
-
##
|
|
119
|
+
## License
|
|
64
120
|
|
|
65
|
-
|
|
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.
|
|
121
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-content-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Domain-neutral, content-addressed large-object store with content-defined chunking over the storage BlobStore",
|
|
5
|
+
"bugs": {
|
|
6
|
+
"url": "https://github.com/statewalker/webrun-sync/issues"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/statewalker/webrun-sync/tree/main/packages/webrun-content-store#readme",
|
|
5
9
|
"license": "MIT",
|
|
6
10
|
"repository": {
|
|
7
11
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/statewalker/webrun-
|
|
12
|
+
"url": "git+https://github.com/statewalker/webrun-sync.git",
|
|
9
13
|
"directory": "packages/webrun-content-store"
|
|
10
14
|
},
|
|
11
15
|
"private": false,
|
|
@@ -27,11 +31,11 @@
|
|
|
27
31
|
"src"
|
|
28
32
|
],
|
|
29
33
|
"dependencies": {
|
|
30
|
-
"@statewalker/webrun-files": "^0.
|
|
31
|
-
"@statewalker/webrun-storage": "^0.
|
|
34
|
+
"@statewalker/webrun-files": "^0.12.0",
|
|
35
|
+
"@statewalker/webrun-storage": "^0.3.0"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
|
-
"@statewalker/webrun-files-mem": "^0.
|
|
38
|
+
"@statewalker/webrun-files-mem": "^0.12.0",
|
|
35
39
|
"rolldown": "^1.2.12",
|
|
36
40
|
"vitest": "^5.0.3",
|
|
37
41
|
"typescript": "^7.0.2",
|
|
@@ -46,6 +50,7 @@
|
|
|
46
50
|
"format": "biome format --write .",
|
|
47
51
|
"format:check": "biome format .",
|
|
48
52
|
"format:fix": "biome check --write --unsafe .",
|
|
49
|
-
"typecheck": "tsc --noEmit"
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json"
|
|
50
55
|
}
|
|
51
56
|
}
|