@statewalker/webrun-storage 0.2.1 → 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 +81 -31
- package/dist/files-blob-store.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/package.json +10 -5
- package/src/files-blob-store.ts +13 -1
package/README.md
CHANGED
|
@@ -1,58 +1,108 @@
|
|
|
1
1
|
# @statewalker/webrun-storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A small byte-persistence layer with two primitives: an immutable `BlobStore` keyed by a
|
|
6
|
+
caller-supplied id, and a mutable `KvStore` with atomic compare-and-set. A `RefStore` facade
|
|
7
|
+
stores string ids on top of a `KvStore`. Adapters are included for memory and for any
|
|
8
|
+
`FilesApi` from `@statewalker/webrun-files`.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
## Why it exists
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
Higher layers (a chunked content store, a git object store, ref tables) all need the same two
|
|
13
|
+
things from a backend: write an immutable blob under a known id, and update a pointer atomically.
|
|
14
|
+
This package defines exactly that contract and nothing more. A new backend (SQL, KV, cloud
|
|
15
|
+
object storage) implements two interfaces, and every layer above works on it unchanged.
|
|
10
16
|
|
|
11
|
-
##
|
|
17
|
+
## How to use
|
|
12
18
|
|
|
13
19
|
```bash
|
|
14
20
|
pnpm add @statewalker/webrun-storage
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
| Import | Contents |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `@statewalker/webrun-storage` | `memBlobStore`, `memKvStore`, `filesBlobStore`, `refStore`, and the types `BlobStore`, `KvStore`, `RefStore`, `ObjectStore`, `ByteStream`, `FilesBlobStoreOptions`. ESM, runs in browsers, Node and workers. |
|
|
26
|
+
|
|
27
|
+
`BlobStore`:
|
|
28
|
+
|
|
29
|
+
- `put(id, bytes, opts?)`: stores a byte stream. Re-putting the same id is idempotent.
|
|
30
|
+
`opts.verify(bytes)` re-derives the id; on mismatch `put` rejects and stores nothing.
|
|
31
|
+
- `get(id, range?)`: byte stream; `range` is `{ start?, end? }` with `end` exclusive. An absent id
|
|
32
|
+
yields an empty stream.
|
|
33
|
+
- `has(id)`, `remove(id)` (resolves `false` if absent), `size(id)` (`-1` if absent),
|
|
34
|
+
`list(prefix?)`.
|
|
35
|
+
|
|
36
|
+
`KvStore`: `get`, `put`, `remove`, `list(prefix?)`, and `cas(key, expected, next)`, which writes
|
|
37
|
+
and returns `true` only when the current value equals `expected` byte for byte. `expected ===
|
|
38
|
+
undefined` means "must not exist"; `next === undefined` deletes.
|
|
39
|
+
|
|
40
|
+
`RefStore`: `read(name)`, `compareAndSet(name, expected, next)`, `list(prefix?)`, all with string
|
|
41
|
+
ids. `ObjectStore` is an alias of `BlobStore`.
|
|
42
|
+
|
|
43
|
+
## Examples
|
|
44
|
+
|
|
45
|
+
Blobs in memory and over a `FilesApi`:
|
|
18
46
|
|
|
19
47
|
```typescript
|
|
20
48
|
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
21
|
-
import { filesBlobStore, memBlobStore
|
|
22
|
-
|
|
23
|
-
// Immutable, content-addressed blobs (in-memory, or over any FilesApi):
|
|
24
|
-
const blobs = memBlobStore();
|
|
25
|
-
const overFiles = filesBlobStore(new MemFilesApi());
|
|
49
|
+
import { filesBlobStore, memBlobStore } from "@statewalker/webrun-storage";
|
|
26
50
|
|
|
27
51
|
async function* streamOf(...parts: string[]) {
|
|
28
52
|
for (const p of parts) yield new TextEncoder().encode(p);
|
|
29
53
|
}
|
|
30
54
|
|
|
31
|
-
|
|
32
|
-
await blobs.
|
|
33
|
-
await blobs.size("
|
|
34
|
-
const tail = blobs.get("
|
|
55
|
+
const blobs = memBlobStore();
|
|
56
|
+
await blobs.put("a1b2c3", streamOf("hello ", "world")); // the caller owns the id
|
|
57
|
+
await blobs.size("a1b2c3"); // 11
|
|
58
|
+
const tail = blobs.get("a1b2c3", { start: 6 }); // "world"
|
|
59
|
+
|
|
60
|
+
const onDisk = filesBlobStore(new MemFilesApi(), { root: "/objects" });
|
|
61
|
+
await onDisk.put("a1b2c3", streamOf("hello")); // stored at /objects/a1/b2c3
|
|
62
|
+
```
|
|
35
63
|
|
|
36
|
-
|
|
37
|
-
const kv = memKvStore();
|
|
38
|
-
const created = await kv.cas("head", undefined, new TextEncoder().encode("v1")); // true (create)
|
|
64
|
+
Atomic pointer updates:
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
```typescript
|
|
67
|
+
import { memKvStore, refStore } from "@statewalker/webrun-storage";
|
|
68
|
+
|
|
69
|
+
const refs = refStore(memKvStore());
|
|
70
|
+
await refs.compareAndSet("refs/heads/main", undefined, "commit-1"); // true: created
|
|
71
|
+
await refs.compareAndSet("refs/heads/main", "commit-0", "commit-2"); // false: lost the race
|
|
72
|
+
await refs.read("refs/heads/main"); // "commit-1"
|
|
43
73
|
```
|
|
44
74
|
|
|
45
|
-
##
|
|
75
|
+
## Internals
|
|
76
|
+
|
|
77
|
+
### Ids belong to the caller
|
|
78
|
+
|
|
79
|
+
`put` never hashes. A git store passes git object ids, a chunk store passes its own chunk hashes.
|
|
80
|
+
The package therefore depends on no hash library and works with any id scheme. Integrity checking
|
|
81
|
+
is opt-in through `verify`; a mismatch fails with
|
|
82
|
+
`blob id mismatch: supplied "<id>", verify computed "<computed>"`.
|
|
83
|
+
|
|
84
|
+
### Compare-and-set is part of the contract
|
|
85
|
+
|
|
86
|
+
`cas` is a required method, not an optional capability, so `RefStore` can always offer atomic
|
|
87
|
+
updates without checking what the backend supports. The in-memory adapter is atomic because
|
|
88
|
+
JavaScript runs it in one turn.
|
|
89
|
+
|
|
90
|
+
### One backend object per blob
|
|
91
|
+
|
|
92
|
+
`filesBlobStore` writes each blob to its own file, sharded like git's loose objects:
|
|
93
|
+
`<root>/<first two chars>/<rest>`. Ids shorter than three characters produce odd paths; use
|
|
94
|
+
hash-like ids. Packing and chunk grouping are left to the layers above.
|
|
95
|
+
|
|
96
|
+
### Ranged reads
|
|
97
|
+
|
|
98
|
+
`get(id, { start, end })` matches the usual "inclusive start, exclusive end" slice convention, so
|
|
99
|
+
random access into pack files and large blobs needs no full read.
|
|
46
100
|
|
|
47
|
-
|
|
48
|
-
- **`KvStore`** — `get` / `put` / `remove` / `list`, plus `cas(key, expected, next)` (writes and returns `true` only if the current value deep-equals `expected`; `next === undefined` deletes).
|
|
49
|
-
- **`RefStore`** — thin string-id facade over a `KvStore`: `read`, `compareAndSet`, `list`.
|
|
101
|
+
### Dependencies
|
|
50
102
|
|
|
51
|
-
|
|
103
|
+
Only `@statewalker/webrun-files`, for the `FilesApi` type used by `filesBlobStore`. No runtime
|
|
104
|
+
dependencies.
|
|
52
105
|
|
|
53
|
-
##
|
|
106
|
+
## License
|
|
54
107
|
|
|
55
|
-
|
|
56
|
-
- **CAS is a required primitive** so `RefStore` always gets atomic compare-and-set with no per-consumer branching.
|
|
57
|
-
- **One backend object per blob;** packing/CDC-grouping lives in the layers above (`vcs-core` packfiles, `content-store` chunks).
|
|
58
|
-
- The same behavioural suite runs against every adapter (mem and files), and the seam is bounded-memory by construction. Built red/green TDD.
|
|
108
|
+
MIT
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files-blob-store.d.ts","sourceRoot":"","sources":["../src/files-blob-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,YAAY,CAAC;AAExD,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;
|
|
1
|
+
{"version":3,"file":"files-blob-store.d.ts","sourceRoot":"","sources":["../src/files-blob-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,YAAY,CAAC;AAExD,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAID;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CA4DvF"}
|
package/dist/index.js
CHANGED
|
@@ -42,13 +42,19 @@ async function assertVerified(id, chunks, verify) {
|
|
|
42
42
|
}
|
|
43
43
|
//#endregion
|
|
44
44
|
//#region src/files-blob-store.ts
|
|
45
|
+
const isDotSegment = (segment) => segment === "." || segment === "..";
|
|
45
46
|
/**
|
|
46
47
|
* {@link BlobStore} backed by a {@link FilesApi}. Ids map to git-style sharded
|
|
47
48
|
* paths (`ab/cdef…`) so a large id space never lands in one flat directory.
|
|
48
49
|
*/
|
|
49
50
|
function filesBlobStore(files, opts) {
|
|
50
51
|
const base = (opts?.root ?? "/").replace(/\/+$/, "");
|
|
51
|
-
const pathFor = (id) =>
|
|
52
|
+
const pathFor = (id) => {
|
|
53
|
+
const shard = id.slice(0, 2);
|
|
54
|
+
const rest = id.slice(2);
|
|
55
|
+
if (id === "" || /[/\\]/.test(id) || isDotSegment(shard) || isDotSegment(rest)) throw new Error(`Invalid blob id: ${JSON.stringify(id)}`);
|
|
56
|
+
return `${base}/${shard}/${rest}`;
|
|
57
|
+
};
|
|
52
58
|
const idFrom = (relative) => relative.replace("/", "");
|
|
53
59
|
return {
|
|
54
60
|
async put(id, bytes, opts) {
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Domain-neutral byte-persistence seam (BlobStore + KvStore with CAS) over the webrun-files FilesApi",
|
|
5
|
+
"bugs": {
|
|
6
|
+
"url": "https://github.com/statewalker/webrun-sync/issues"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/statewalker/webrun-sync/tree/main/packages/webrun-storage#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-storage"
|
|
10
14
|
},
|
|
11
15
|
"private": false,
|
|
@@ -27,10 +31,10 @@
|
|
|
27
31
|
"src"
|
|
28
32
|
],
|
|
29
33
|
"dependencies": {
|
|
30
|
-
"@statewalker/webrun-files": "^0.
|
|
34
|
+
"@statewalker/webrun-files": "^0.12.0"
|
|
31
35
|
},
|
|
32
36
|
"devDependencies": {
|
|
33
|
-
"@statewalker/webrun-files-mem": "^0.
|
|
37
|
+
"@statewalker/webrun-files-mem": "^0.12.0",
|
|
34
38
|
"rolldown": "^1.2.12",
|
|
35
39
|
"vitest": "^5.0.3",
|
|
36
40
|
"typescript": "^7.0.2",
|
|
@@ -45,6 +49,7 @@
|
|
|
45
49
|
"format": "biome format --write .",
|
|
46
50
|
"format:check": "biome format .",
|
|
47
51
|
"format:fix": "biome check --write --unsafe .",
|
|
48
|
-
"typecheck": "tsc --noEmit"
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json"
|
|
49
54
|
}
|
|
50
55
|
}
|
package/src/files-blob-store.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface FilesBlobStoreOptions {
|
|
|
7
7
|
root?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const isDotSegment = (segment: string): boolean => segment === "." || segment === "..";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* {@link BlobStore} backed by a {@link FilesApi}. Ids map to git-style sharded
|
|
12
14
|
* paths (`ab/cdef…`) so a large id space never lands in one flat directory.
|
|
@@ -14,7 +16,17 @@ export interface FilesBlobStoreOptions {
|
|
|
14
16
|
export function filesBlobStore(files: FilesApi, opts?: FilesBlobStoreOptions): BlobStore {
|
|
15
17
|
const base = (opts?.root ?? "/").replace(/\/+$/, "");
|
|
16
18
|
|
|
17
|
-
const pathFor = (id: string): string =>
|
|
19
|
+
const pathFor = (id: string): string => {
|
|
20
|
+
const shard = id.slice(0, 2);
|
|
21
|
+
const rest = id.slice(2);
|
|
22
|
+
// webrun-files resolves `..` before any mount or guard sees the path, so an
|
|
23
|
+
// id carrying a separator or a dot segment would land outside `root`. Ids
|
|
24
|
+
// come from remote peers (content-transfer's serve-store), so refuse them.
|
|
25
|
+
if (id === "" || /[/\\]/.test(id) || isDotSegment(shard) || isDotSegment(rest)) {
|
|
26
|
+
throw new Error(`Invalid blob id: ${JSON.stringify(id)}`);
|
|
27
|
+
}
|
|
28
|
+
return `${base}/${shard}/${rest}`;
|
|
29
|
+
};
|
|
18
30
|
const idFrom = (relative: string): string => relative.replace("/", "");
|
|
19
31
|
|
|
20
32
|
return {
|