@statewalker/webrun-storage 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # @statewalker/webrun-storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## What it is
4
+
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`.
9
+
10
+ ## Why it exists
11
+
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.
16
+
17
+ ## How to use
18
+
19
+ ```bash
20
+ pnpm add @statewalker/webrun-storage
21
+ ```
22
+
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`:
46
+
47
+ ```typescript
48
+ import { MemFilesApi } from "@statewalker/webrun-files-mem";
49
+ import { filesBlobStore, memBlobStore } from "@statewalker/webrun-storage";
50
+
51
+ async function* streamOf(...parts: string[]) {
52
+ for (const p of parts) yield new TextEncoder().encode(p);
53
+ }
54
+
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
+ ```
63
+
64
+ Atomic pointer updates:
65
+
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"
73
+ ```
74
+
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.
100
+
101
+ ### Dependencies
102
+
103
+ Only `@statewalker/webrun-files`, for the `FilesApi` type used by `filesBlobStore`. No runtime
104
+ dependencies.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,10 @@
1
+ import type { ByteStream } from "./types.js";
2
+ /** Wrap already-collected chunks as a re-readable byte stream. */
3
+ export declare function toStream(chunks: Uint8Array[]): ByteStream;
4
+ /**
5
+ * Run the optional integrity check for a blob `put`. Rejects when the caller's
6
+ * `verify` re-derives an id that differs from the supplied one. Shared by every
7
+ * {@link import("./types.js").BlobStore} adapter so the flag behaves identically.
8
+ */
9
+ export declare function assertVerified(id: string, chunks: Uint8Array[], verify?: (bytes: ByteStream) => Promise<string>): Promise<void>;
10
+ //# sourceMappingURL=blob-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blob-store.d.ts","sourceRoot":"","sources":["../src/blob-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,kEAAkE;AAClE,wBAAuB,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,UAAU,CAEhE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,UAAU,EAAE,EACpB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,GAC9C,OAAO,CAAC,IAAI,CAAC,CAMf"}
@@ -0,0 +1,8 @@
1
+ import type { KvStore, RefStore } from "./types.js";
2
+ /**
3
+ * Build a {@link RefStore} over a {@link KvStore}: string ids are stored as
4
+ * UTF-8 bytes and `compareAndSet` maps directly onto {@link KvStore.cas}, so the
5
+ * facade reuses the primitive's atomicity rather than re-implementing storage.
6
+ */
7
+ export declare function refStore(kv: KvStore): RefStore;
8
+ //# sourceMappingURL=facades.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"facades.d.ts","sourceRoot":"","sources":["../src/facades.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,QAAQ,CAoB9C"}
@@ -0,0 +1,12 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ import type { BlobStore } from "./types.js";
3
+ export interface FilesBlobStoreOptions {
4
+ /** Directory the sharded blob tree lives under. Defaults to `/`. */
5
+ root?: string;
6
+ }
7
+ /**
8
+ * {@link BlobStore} backed by a {@link FilesApi}. Ids map to git-style sharded
9
+ * paths (`ab/cdef…`) so a large id space never lands in one flat directory.
10
+ */
11
+ export declare function filesBlobStore(files: FilesApi, opts?: FilesBlobStoreOptions): BlobStore;
12
+ //# sourceMappingURL=files-blob-store.d.ts.map
@@ -0,0 +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;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAkDvF"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @statewalker/webrun-storage
3
+ *
4
+ * Domain-neutral byte-persistence seam: an immutable content-addressed
5
+ * {@link BlobStore} and a mutable {@link KvStore} with atomic compare-and-set,
6
+ * plus a thin {@link RefStore} facade — all over the webrun-files FilesApi.
7
+ * The seam stores bytes; it knows nothing of git objects, chunks, or hashing.
8
+ */
9
+ export { refStore } from "./facades.js";
10
+ export { type FilesBlobStoreOptions, filesBlobStore } from "./files-blob-store.js";
11
+ export { memBlobStore, memKvStore } from "./mem.js";
12
+ export type { BlobStore, ByteStream, KvStore, ObjectStore, RefStore } from "./types.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACxC,OAAO,EAAE,KAAK,qBAAqB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACpD,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ //#region src/facades.ts
2
+ /**
3
+ * Build a {@link RefStore} over a {@link KvStore}: string ids are stored as
4
+ * UTF-8 bytes and `compareAndSet` maps directly onto {@link KvStore.cas}, so the
5
+ * facade reuses the primitive's atomicity rather than re-implementing storage.
6
+ */
7
+ function refStore(kv) {
8
+ const encoder = new TextEncoder();
9
+ const decoder = new TextDecoder();
10
+ const encode = (s) => s === void 0 ? void 0 : encoder.encode(s);
11
+ return {
12
+ async read(name) {
13
+ const value = await kv.get(name);
14
+ return value === void 0 ? void 0 : decoder.decode(value);
15
+ },
16
+ compareAndSet(name, expected, next) {
17
+ return kv.cas(name, encode(expected), encode(next));
18
+ },
19
+ async *list(prefix) {
20
+ for await (const { key, value } of kv.list(prefix)) yield {
21
+ name: key,
22
+ id: decoder.decode(value)
23
+ };
24
+ }
25
+ };
26
+ }
27
+ //#endregion
28
+ //#region src/blob-store.ts
29
+ /** Wrap already-collected chunks as a re-readable byte stream. */
30
+ async function* toStream(chunks) {
31
+ yield* chunks;
32
+ }
33
+ /**
34
+ * Run the optional integrity check for a blob `put`. Rejects when the caller's
35
+ * `verify` re-derives an id that differs from the supplied one. Shared by every
36
+ * {@link import("./types.js").BlobStore} adapter so the flag behaves identically.
37
+ */
38
+ async function assertVerified(id, chunks, verify) {
39
+ if (!verify) return;
40
+ const computed = await verify(toStream(chunks));
41
+ if (computed !== id) throw new Error(`blob id mismatch: supplied "${id}", verify computed "${computed}"`);
42
+ }
43
+ //#endregion
44
+ //#region src/files-blob-store.ts
45
+ /**
46
+ * {@link BlobStore} backed by a {@link FilesApi}. Ids map to git-style sharded
47
+ * paths (`ab/cdef…`) so a large id space never lands in one flat directory.
48
+ */
49
+ function filesBlobStore(files, opts) {
50
+ const base = (opts?.root ?? "/").replace(/\/+$/, "");
51
+ const pathFor = (id) => `${base}/${id.slice(0, 2)}/${id.slice(2)}`;
52
+ const idFrom = (relative) => relative.replace("/", "");
53
+ return {
54
+ async put(id, bytes, opts) {
55
+ const path = pathFor(id);
56
+ if (opts?.verify) {
57
+ const chunks = [];
58
+ for await (const chunk of bytes) chunks.push(chunk);
59
+ await assertVerified(id, chunks, opts.verify);
60
+ await files.write(path, chunks);
61
+ } else await files.write(path, bytes);
62
+ },
63
+ get(id, range) {
64
+ if (!range) return files.read(pathFor(id));
65
+ const start = range.start ?? 0;
66
+ const length = range.end !== void 0 ? range.end - start : void 0;
67
+ return files.read(pathFor(id), {
68
+ start,
69
+ length
70
+ });
71
+ },
72
+ async has(id) {
73
+ return files.exists(pathFor(id));
74
+ },
75
+ async size(id) {
76
+ const stats = await files.stats(pathFor(id));
77
+ return stats?.kind === "file" ? stats.size : -1;
78
+ },
79
+ async remove(id) {
80
+ return files.remove(pathFor(id));
81
+ },
82
+ async *list(prefix) {
83
+ const rootPath = base === "" ? "/" : base;
84
+ const cut = rootPath === "/" ? 1 : rootPath.length + 1;
85
+ for await (const info of files.list(rootPath, { recursive: true })) {
86
+ if (info.kind !== "file") continue;
87
+ const id = idFrom(info.path.slice(cut));
88
+ if (prefix === void 0 || id.startsWith(prefix)) yield id;
89
+ }
90
+ }
91
+ };
92
+ }
93
+ //#endregion
94
+ //#region src/mem.ts
95
+ /** In-memory {@link BlobStore}. Chunks are kept as a list (never one big concat). */
96
+ function memBlobStore() {
97
+ const store = /* @__PURE__ */ new Map();
98
+ return {
99
+ async put(id, bytes, opts) {
100
+ const chunks = [];
101
+ for await (const chunk of bytes) chunks.push(chunk);
102
+ await assertVerified(id, chunks, opts?.verify);
103
+ store.set(id, chunks);
104
+ },
105
+ get(id, range) {
106
+ const chunks = store.get(id) ?? [];
107
+ return toStream(range ? sliceChunks(chunks, range) : chunks);
108
+ },
109
+ async has(id) {
110
+ return store.has(id);
111
+ },
112
+ async size(id) {
113
+ const chunks = store.get(id);
114
+ if (chunks === void 0) return -1;
115
+ return chunks.reduce((n, c) => n + c.length, 0);
116
+ },
117
+ async remove(id) {
118
+ return store.delete(id);
119
+ },
120
+ async *list(prefix) {
121
+ for (const id of store.keys()) if (prefix === void 0 || id.startsWith(prefix)) yield id;
122
+ }
123
+ };
124
+ }
125
+ /**
126
+ * Return the `[start, end)` slice of a chunk list (end exclusive, start default
127
+ * 0, end default total length) as a new chunk list — mirrors RawStorage.load.
128
+ */
129
+ function sliceChunks(chunks, range) {
130
+ const start = range.start ?? 0;
131
+ const total = chunks.reduce((n, c) => n + c.length, 0);
132
+ const end = range.end ?? total;
133
+ const out = [];
134
+ let pos = 0;
135
+ for (const chunk of chunks) {
136
+ const chunkStart = pos;
137
+ const chunkEnd = pos + chunk.length;
138
+ pos = chunkEnd;
139
+ if (chunkEnd <= start || chunkStart >= end) continue;
140
+ const from = Math.max(start, chunkStart) - chunkStart;
141
+ const to = Math.min(end, chunkEnd) - chunkStart;
142
+ out.push(chunk.subarray(from, to));
143
+ }
144
+ return out;
145
+ }
146
+ function bytesEqual(a, b) {
147
+ if (a === void 0 || b === void 0) return a === b;
148
+ if (a.length !== b.length) return false;
149
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
150
+ return true;
151
+ }
152
+ /** In-memory {@link KvStore} with a natively-atomic compare-and-set. */
153
+ function memKvStore() {
154
+ const store = /* @__PURE__ */ new Map();
155
+ return {
156
+ async get(key) {
157
+ const v = store.get(key);
158
+ return v === void 0 ? void 0 : v.slice();
159
+ },
160
+ async put(key, value) {
161
+ store.set(key, value.slice());
162
+ },
163
+ async cas(key, expected, next) {
164
+ if (!bytesEqual(store.get(key), expected)) return false;
165
+ if (next === void 0) store.delete(key);
166
+ else store.set(key, next.slice());
167
+ return true;
168
+ },
169
+ async remove(key) {
170
+ return store.delete(key);
171
+ },
172
+ async *list(prefix) {
173
+ for (const [key, value] of store) if (prefix === void 0 || key.startsWith(prefix)) yield {
174
+ key,
175
+ value: value.slice()
176
+ };
177
+ }
178
+ };
179
+ }
180
+ //#endregion
181
+ export { filesBlobStore, memBlobStore, memKvStore, refStore };
package/dist/mem.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { BlobStore, KvStore } from "./types.js";
2
+ /** In-memory {@link BlobStore}. Chunks are kept as a list (never one big concat). */
3
+ export declare function memBlobStore(): BlobStore;
4
+ /** In-memory {@link KvStore} with a natively-atomic compare-and-set. */
5
+ export declare function memKvStore(): KvStore;
6
+ //# sourceMappingURL=mem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem.d.ts","sourceRoot":"","sources":["../src/mem.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAc,OAAO,EAAE,MAAM,YAAY,CAAC;AAEjE,qFAAqF;AACrF,wBAAgB,YAAY,IAAI,SAAS,CA8BxC;AAiCD,wEAAwE;AACxE,wBAAgB,UAAU,IAAI,OAAO,CA8BpC"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Public contract for the domain-neutral byte-persistence seam.
3
+ *
4
+ * Two low-level primitives — an immutable content-addressed {@link BlobStore}
5
+ * and a mutable {@link KvStore} with atomic compare-and-set — plus thin typed
6
+ * facades built over them. The seam stores bytes: it knows nothing of git
7
+ * objects, chunks, or hashing. The caller owns every id (storage is
8
+ * hash-agnostic).
9
+ */
10
+ /** A stream of bytes, as produced by {@link import("@statewalker/webrun-files").FilesApi.read}. */
11
+ export type ByteStream = AsyncIterable<Uint8Array>;
12
+ /**
13
+ * Immutable, content-addressed byte store keyed by a caller-supplied id.
14
+ *
15
+ * `put` is idempotent for a given id: because the id names the content, a
16
+ * re-put of the same id overwrites with identical bytes and never errors.
17
+ */
18
+ export interface BlobStore {
19
+ /**
20
+ * Store `bytes` under `id`. Streaming and bounded-memory — the payload is
21
+ * never buffered into one contiguous allocation. When `opts.verify` is given,
22
+ * the re-derived id must equal `id`, otherwise `put` rejects and stores nothing.
23
+ */
24
+ put(id: string, bytes: ByteStream, opts?: {
25
+ verify?: (bytes: ByteStream) => Promise<string>;
26
+ }): Promise<void>;
27
+ /**
28
+ * Read the blob's bytes. Yields an empty stream if the id is absent (FilesApi
29
+ * convention).
30
+ *
31
+ * The optional `range` reads a partial slice, matching `RawStorage.load`'s
32
+ * semantics exactly so an adapter is a straight pass-through: `start` is the
33
+ * inclusive byte offset (default 0) and `end` is the **exclusive** upper bound
34
+ * (default end of content). `{ start }` reads from `start` to the end;
35
+ * `{ end }` reads `[0, end)`. Existing callers pass nothing and are unaffected.
36
+ */
37
+ get(id: string, range?: {
38
+ start?: number;
39
+ end?: number;
40
+ }): ByteStream;
41
+ has(id: string): Promise<boolean>;
42
+ /** Remove the blob; resolves `true` if something was removed, `false` if absent. */
43
+ remove(id: string): Promise<boolean>;
44
+ /**
45
+ * Byte length of the stored blob, or `-1` if the id is absent — matching
46
+ * `RawStorage.size`.
47
+ */
48
+ size(id: string): Promise<number>;
49
+ /** Ids currently stored, optionally filtered by prefix. */
50
+ list(prefix?: string): AsyncIterable<string>;
51
+ }
52
+ /**
53
+ * `BlobStore` keyed by a git object id. The seam adds no git semantics, so this
54
+ * is a plain alias; `content-store` callers use {@link BlobStore} directly.
55
+ */
56
+ export type ObjectStore = BlobStore;
57
+ /** Mutable keyed byte store with an atomic compare-and-set. */
58
+ export interface KvStore {
59
+ get(key: string): Promise<Uint8Array | undefined>;
60
+ put(key: string, value: Uint8Array): Promise<void>;
61
+ /**
62
+ * Atomic compare-and-set. Writes `next` and returns `true` iff the current
63
+ * value deep-equals `expected` (both-undefined counts as a match, i.e. create).
64
+ * On mismatch returns `false` and writes nothing. `next === undefined` deletes.
65
+ */
66
+ cas(key: string, expected: Uint8Array | undefined, next: Uint8Array | undefined): Promise<boolean>;
67
+ remove(key: string): Promise<boolean>;
68
+ list(prefix?: string): AsyncIterable<{
69
+ key: string;
70
+ value: Uint8Array;
71
+ }>;
72
+ }
73
+ /** Thin facade over a {@link KvStore} that stores string ids as UTF-8 bytes. */
74
+ export interface RefStore {
75
+ read(name: string): Promise<string | undefined>;
76
+ compareAndSet(name: string, expected: string | undefined, next: string | undefined): Promise<boolean>;
77
+ list(prefix?: string): AsyncIterable<{
78
+ name: string;
79
+ id: string;
80
+ }>;
81
+ }
82
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,mGAAmG;AACnG,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB;;;;OAIG;IACH,GAAG,CACD,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,UAAU,EACjB,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;KAAE,GACzD,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB;;;;;;;;;OASG;IACH,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,CAAC;IACtE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClC,oFAAoF;IACpF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrC;;;OAGG;IACH,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,2DAA2D;IAC3D,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9C;AAED;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,SAAS,CAAC;AAEpC,+DAA+D;AAC/D,MAAM,WAAW,OAAO;IACtB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD;;;;OAIG;IACH,GAAG,CACD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,UAAU,GAAG,SAAS,EAChC,IAAI,EAAE,UAAU,GAAG,SAAS,GAC3B,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,UAAU,CAAA;KAAE,CAAC,CAAC;CAC1E;AAED,gFAAgF;AAChF,MAAM,WAAW,QAAQ;IACvB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAChD,aAAa,CACX,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpE"}
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-storage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.2",
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",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/statewalker/webrun-sync.git",
13
+ "directory": "packages/webrun-storage"
14
+ },
15
+ "private": false,
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "module",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "source": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "dependencies": {
34
+ "@statewalker/webrun-files": "^0.10.0"
35
+ },
36
+ "devDependencies": {
37
+ "@statewalker/webrun-files-mem": "^0.10.0",
38
+ "rolldown": "^1.2.12",
39
+ "vitest": "^5.0.3",
40
+ "typescript": "^7.0.2",
41
+ "@types/node": "^26.6.4"
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
+ }
6
54
  }
@@ -0,0 +1,23 @@
1
+ import type { ByteStream } from "./types.js";
2
+
3
+ /** Wrap already-collected chunks as a re-readable byte stream. */
4
+ export async function* toStream(chunks: Uint8Array[]): ByteStream {
5
+ yield* chunks;
6
+ }
7
+
8
+ /**
9
+ * Run the optional integrity check for a blob `put`. Rejects when the caller's
10
+ * `verify` re-derives an id that differs from the supplied one. Shared by every
11
+ * {@link import("./types.js").BlobStore} adapter so the flag behaves identically.
12
+ */
13
+ export async function assertVerified(
14
+ id: string,
15
+ chunks: Uint8Array[],
16
+ verify?: (bytes: ByteStream) => Promise<string>,
17
+ ): Promise<void> {
18
+ if (!verify) return;
19
+ const computed = await verify(toStream(chunks));
20
+ if (computed !== id) {
21
+ throw new Error(`blob id mismatch: supplied "${id}", verify computed "${computed}"`);
22
+ }
23
+ }
package/src/facades.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { KvStore, RefStore } from "./types.js";
2
+
3
+ /**
4
+ * Build a {@link RefStore} over a {@link KvStore}: string ids are stored as
5
+ * UTF-8 bytes and `compareAndSet` maps directly onto {@link KvStore.cas}, so the
6
+ * facade reuses the primitive's atomicity rather than re-implementing storage.
7
+ */
8
+ export function refStore(kv: KvStore): RefStore {
9
+ const encoder = new TextEncoder();
10
+ const decoder = new TextDecoder();
11
+ const encode = (s: string | undefined): Uint8Array | undefined =>
12
+ s === undefined ? undefined : encoder.encode(s);
13
+
14
+ return {
15
+ async read(name) {
16
+ const value = await kv.get(name);
17
+ return value === undefined ? undefined : decoder.decode(value);
18
+ },
19
+ compareAndSet(name, expected, next) {
20
+ return kv.cas(name, encode(expected), encode(next));
21
+ },
22
+ async *list(prefix) {
23
+ for await (const { key, value } of kv.list(prefix)) {
24
+ yield { name: key, id: decoder.decode(value) };
25
+ }
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,64 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ import { assertVerified } from "./blob-store.js";
3
+ import type { BlobStore, ByteStream } from "./types.js";
4
+
5
+ export interface FilesBlobStoreOptions {
6
+ /** Directory the sharded blob tree lives under. Defaults to `/`. */
7
+ root?: string;
8
+ }
9
+
10
+ /**
11
+ * {@link BlobStore} backed by a {@link FilesApi}. Ids map to git-style sharded
12
+ * paths (`ab/cdef…`) so a large id space never lands in one flat directory.
13
+ */
14
+ export function filesBlobStore(files: FilesApi, opts?: FilesBlobStoreOptions): BlobStore {
15
+ const base = (opts?.root ?? "/").replace(/\/+$/, "");
16
+
17
+ const pathFor = (id: string): string => `${base}/${id.slice(0, 2)}/${id.slice(2)}`;
18
+ const idFrom = (relative: string): string => relative.replace("/", "");
19
+
20
+ return {
21
+ async put(id, bytes, opts) {
22
+ const path = pathFor(id);
23
+ if (opts?.verify) {
24
+ // Verify needs the bytes replayed to the caller's hash, so buffer the
25
+ // chunk list (not one contiguous concat), check, then write.
26
+ const chunks: Uint8Array[] = [];
27
+ for await (const chunk of bytes) chunks.push(chunk);
28
+ await assertVerified(id, chunks, opts.verify);
29
+ await files.write(path, chunks);
30
+ } else {
31
+ // No verify: pass the stream straight through — the backend drives
32
+ // iteration, so nothing is buffered up front.
33
+ await files.write(path, bytes);
34
+ }
35
+ },
36
+ get(id, range): ByteStream {
37
+ if (!range) return files.read(pathFor(id));
38
+ // FilesApi.read takes {start,length}; our range is {start,end} with end
39
+ // exclusive — translate length = end - start (undefined ⇒ read to end).
40
+ const start = range.start ?? 0;
41
+ const length = range.end !== undefined ? range.end - start : undefined;
42
+ return files.read(pathFor(id), { start, length });
43
+ },
44
+ async has(id) {
45
+ return files.exists(pathFor(id));
46
+ },
47
+ async size(id) {
48
+ const stats = await files.stats(pathFor(id));
49
+ return stats?.kind === "file" ? stats.size : -1;
50
+ },
51
+ async remove(id) {
52
+ return files.remove(pathFor(id));
53
+ },
54
+ async *list(prefix) {
55
+ const rootPath = base === "" ? "/" : base;
56
+ const cut = rootPath === "/" ? 1 : rootPath.length + 1;
57
+ for await (const info of files.list(rootPath, { recursive: true })) {
58
+ if (info.kind !== "file") continue;
59
+ const id = idFrom(info.path.slice(cut));
60
+ if (prefix === undefined || id.startsWith(prefix)) yield id;
61
+ }
62
+ },
63
+ };
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @statewalker/webrun-storage
3
+ *
4
+ * Domain-neutral byte-persistence seam: an immutable content-addressed
5
+ * {@link BlobStore} and a mutable {@link KvStore} with atomic compare-and-set,
6
+ * plus a thin {@link RefStore} facade — all over the webrun-files FilesApi.
7
+ * The seam stores bytes; it knows nothing of git objects, chunks, or hashing.
8
+ */
9
+
10
+ export { refStore } from "./facades.js";
11
+ export { type FilesBlobStoreOptions, filesBlobStore } from "./files-blob-store.js";
12
+ export { memBlobStore, memKvStore } from "./mem.js";
13
+ export type { BlobStore, ByteStream, KvStore, ObjectStore, RefStore } from "./types.js";
package/src/mem.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { assertVerified, toStream } from "./blob-store.js";
2
+ import type { BlobStore, ByteStream, KvStore } from "./types.js";
3
+
4
+ /** In-memory {@link BlobStore}. Chunks are kept as a list (never one big concat). */
5
+ export function memBlobStore(): BlobStore {
6
+ const store = new Map<string, Uint8Array[]>();
7
+ return {
8
+ async put(id, bytes, opts) {
9
+ const chunks: Uint8Array[] = [];
10
+ for await (const chunk of bytes) chunks.push(chunk);
11
+ await assertVerified(id, chunks, opts?.verify);
12
+ store.set(id, chunks);
13
+ },
14
+ get(id, range): ByteStream {
15
+ const chunks = store.get(id) ?? [];
16
+ return toStream(range ? sliceChunks(chunks, range) : chunks);
17
+ },
18
+ async has(id) {
19
+ return store.has(id);
20
+ },
21
+ async size(id) {
22
+ const chunks = store.get(id);
23
+ if (chunks === undefined) return -1;
24
+ return chunks.reduce((n, c) => n + c.length, 0);
25
+ },
26
+ async remove(id) {
27
+ return store.delete(id);
28
+ },
29
+ async *list(prefix) {
30
+ for (const id of store.keys()) {
31
+ if (prefix === undefined || id.startsWith(prefix)) yield id;
32
+ }
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Return the `[start, end)` slice of a chunk list (end exclusive, start default
39
+ * 0, end default total length) as a new chunk list — mirrors RawStorage.load.
40
+ */
41
+ function sliceChunks(chunks: Uint8Array[], range: { start?: number; end?: number }): Uint8Array[] {
42
+ const start = range.start ?? 0;
43
+ const total = chunks.reduce((n, c) => n + c.length, 0);
44
+ const end = range.end ?? total;
45
+ const out: Uint8Array[] = [];
46
+ let pos = 0;
47
+ for (const chunk of chunks) {
48
+ const chunkStart = pos;
49
+ const chunkEnd = pos + chunk.length;
50
+ pos = chunkEnd;
51
+ if (chunkEnd <= start || chunkStart >= end) continue;
52
+ const from = Math.max(start, chunkStart) - chunkStart;
53
+ const to = Math.min(end, chunkEnd) - chunkStart;
54
+ out.push(chunk.subarray(from, to));
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function bytesEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
60
+ if (a === undefined || b === undefined) return a === b;
61
+ if (a.length !== b.length) return false;
62
+ for (let i = 0; i < a.length; i++) {
63
+ if (a[i] !== b[i]) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ /** In-memory {@link KvStore} with a natively-atomic compare-and-set. */
69
+ export function memKvStore(): KvStore {
70
+ const store = new Map<string, Uint8Array>();
71
+ return {
72
+ async get(key) {
73
+ const v = store.get(key);
74
+ return v === undefined ? undefined : v.slice();
75
+ },
76
+ async put(key, value) {
77
+ store.set(key, value.slice());
78
+ },
79
+ async cas(key, expected, next) {
80
+ // Single-threaded JS: compare-then-set with no intervening await is
81
+ // atomic, so two concurrent cas with the same `expected` run sequentially
82
+ // on the event loop and exactly one wins. Backends without native
83
+ // conditional writes (sql/kv) will need a lease/lock shim — deferred to
84
+ // that adapter slice; it is unnecessary for this natively-atomic store.
85
+ if (!bytesEqual(store.get(key), expected)) return false;
86
+ if (next === undefined) store.delete(key);
87
+ else store.set(key, next.slice());
88
+ return true;
89
+ },
90
+ async remove(key) {
91
+ return store.delete(key);
92
+ },
93
+ async *list(prefix) {
94
+ for (const [key, value] of store) {
95
+ if (prefix === undefined || key.startsWith(prefix)) yield { key, value: value.slice() };
96
+ }
97
+ },
98
+ };
99
+ }
package/src/types.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Public contract for the domain-neutral byte-persistence seam.
3
+ *
4
+ * Two low-level primitives — an immutable content-addressed {@link BlobStore}
5
+ * and a mutable {@link KvStore} with atomic compare-and-set — plus thin typed
6
+ * facades built over them. The seam stores bytes: it knows nothing of git
7
+ * objects, chunks, or hashing. The caller owns every id (storage is
8
+ * hash-agnostic).
9
+ */
10
+
11
+ /** A stream of bytes, as produced by {@link import("@statewalker/webrun-files").FilesApi.read}. */
12
+ export type ByteStream = AsyncIterable<Uint8Array>;
13
+
14
+ /**
15
+ * Immutable, content-addressed byte store keyed by a caller-supplied id.
16
+ *
17
+ * `put` is idempotent for a given id: because the id names the content, a
18
+ * re-put of the same id overwrites with identical bytes and never errors.
19
+ */
20
+ export interface BlobStore {
21
+ /**
22
+ * Store `bytes` under `id`. Streaming and bounded-memory — the payload is
23
+ * never buffered into one contiguous allocation. When `opts.verify` is given,
24
+ * the re-derived id must equal `id`, otherwise `put` rejects and stores nothing.
25
+ */
26
+ put(
27
+ id: string,
28
+ bytes: ByteStream,
29
+ opts?: { verify?: (bytes: ByteStream) => Promise<string> },
30
+ ): Promise<void>;
31
+ /**
32
+ * Read the blob's bytes. Yields an empty stream if the id is absent (FilesApi
33
+ * convention).
34
+ *
35
+ * The optional `range` reads a partial slice, matching `RawStorage.load`'s
36
+ * semantics exactly so an adapter is a straight pass-through: `start` is the
37
+ * inclusive byte offset (default 0) and `end` is the **exclusive** upper bound
38
+ * (default end of content). `{ start }` reads from `start` to the end;
39
+ * `{ end }` reads `[0, end)`. Existing callers pass nothing and are unaffected.
40
+ */
41
+ get(id: string, range?: { start?: number; end?: number }): ByteStream;
42
+ has(id: string): Promise<boolean>;
43
+ /** Remove the blob; resolves `true` if something was removed, `false` if absent. */
44
+ remove(id: string): Promise<boolean>;
45
+ /**
46
+ * Byte length of the stored blob, or `-1` if the id is absent — matching
47
+ * `RawStorage.size`.
48
+ */
49
+ size(id: string): Promise<number>;
50
+ /** Ids currently stored, optionally filtered by prefix. */
51
+ list(prefix?: string): AsyncIterable<string>;
52
+ }
53
+
54
+ /**
55
+ * `BlobStore` keyed by a git object id. The seam adds no git semantics, so this
56
+ * is a plain alias; `content-store` callers use {@link BlobStore} directly.
57
+ */
58
+ export type ObjectStore = BlobStore;
59
+
60
+ /** Mutable keyed byte store with an atomic compare-and-set. */
61
+ export interface KvStore {
62
+ get(key: string): Promise<Uint8Array | undefined>;
63
+ put(key: string, value: Uint8Array): Promise<void>;
64
+ /**
65
+ * Atomic compare-and-set. Writes `next` and returns `true` iff the current
66
+ * value deep-equals `expected` (both-undefined counts as a match, i.e. create).
67
+ * On mismatch returns `false` and writes nothing. `next === undefined` deletes.
68
+ */
69
+ cas(
70
+ key: string,
71
+ expected: Uint8Array | undefined,
72
+ next: Uint8Array | undefined,
73
+ ): Promise<boolean>;
74
+ remove(key: string): Promise<boolean>;
75
+ list(prefix?: string): AsyncIterable<{ key: string; value: Uint8Array }>;
76
+ }
77
+
78
+ /** Thin facade over a {@link KvStore} that stores string ids as UTF-8 bytes. */
79
+ export interface RefStore {
80
+ read(name: string): Promise<string | undefined>;
81
+ compareAndSet(
82
+ name: string,
83
+ expected: string | undefined,
84
+ next: string | undefined,
85
+ ): Promise<boolean>;
86
+ list(prefix?: string): AsyncIterable<{ name: string; id: string }>;
87
+ }