@statewalker/webrun-storage 0.0.0-stage → 0.2.1
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 +57 -2
- package/dist/blob-store.d.ts +10 -0
- package/dist/blob-store.d.ts.map +1 -0
- package/dist/facades.d.ts +8 -0
- package/dist/facades.d.ts.map +1 -0
- package/dist/files-blob-store.d.ts +12 -0
- package/dist/files-blob-store.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +181 -0
- package/dist/mem.d.ts +6 -0
- package/dist/mem.d.ts.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +47 -3
- package/src/blob-store.ts +23 -0
- package/src/facades.ts +28 -0
- package/src/files-blob-store.ts +64 -0
- package/src/index.ts +13 -0
- package/src/mem.ts +99 -0
- package/src/types.ts +87 -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
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @statewalker/webrun-storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Domain-neutral byte-persistence seam: an immutable content-addressed `BlobStore` and a mutable `KvStore` with atomic compare-and-set.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`storage` is the persistence seam both axes of the two-axis VCS architecture plug into. It defines two small low-level primitives — a `BlobStore` (immutable, keyed by a caller-supplied id, with ranged reads and byte `size`) and a `KvStore` (mutable, keyed, with an atomic `cas`) — plus a thin typed `RefStore` facade over the KV primitive. It stores bytes and nothing more: it knows nothing of git objects, chunks, or hashing algorithms (**the caller owns every id**).
|
|
8
|
+
|
|
9
|
+
Adapters implement the two primitives over different backends; `content-store` and `vcs-core` ride on top. This package ships an in-memory adapter and a `FilesApi`-backed adapter; SQL / KV backends are separate adapters over the same interfaces.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @statewalker/webrun-storage
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
21
|
+
import { filesBlobStore, memBlobStore, memKvStore, refStore } from "@statewalker/webrun-storage";
|
|
22
|
+
|
|
23
|
+
// Immutable, content-addressed blobs (in-memory, or over any FilesApi):
|
|
24
|
+
const blobs = memBlobStore();
|
|
25
|
+
const overFiles = filesBlobStore(new MemFilesApi());
|
|
26
|
+
|
|
27
|
+
async function* streamOf(...parts: string[]) {
|
|
28
|
+
for (const p of parts) yield new TextEncoder().encode(p);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await blobs.put("a1", streamOf("hello ", "world")); // caller owns the id
|
|
32
|
+
await blobs.has("a1"); // true
|
|
33
|
+
await blobs.size("a1"); // 11, or -1 if absent
|
|
34
|
+
const tail = blobs.get("a1", { start: 6 }); // ranged read -> "world"
|
|
35
|
+
|
|
36
|
+
// Mutable KV with atomic compare-and-set, and the ref facade over it:
|
|
37
|
+
const kv = memKvStore();
|
|
38
|
+
const created = await kv.cas("head", undefined, new TextEncoder().encode("v1")); // true (create)
|
|
39
|
+
|
|
40
|
+
const refs = refStore(kv);
|
|
41
|
+
await refs.compareAndSet("refs/heads/main", undefined, "commit-1");
|
|
42
|
+
const id = await refs.read("refs/heads/main"); // "commit-1"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
- **`BlobStore`** — `put(id, bytes, opts?)` (streaming, bounded-memory; `opts.verify` re-derives and checks the id), `get(id, range?)` with `{ start?, end? }` (end exclusive), `has(id)`, `remove(id)`, `size(id)` (`-1` when absent), `list(prefix?)`. Re-putting the same id is idempotent. `ObjectStore` is an alias.
|
|
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`.
|
|
50
|
+
|
|
51
|
+
**Adapters & facade:** `memBlobStore()`, `memKvStore()`, `filesBlobStore(files, opts?)`, `refStore(kv)`.
|
|
52
|
+
|
|
53
|
+
## Notes
|
|
54
|
+
|
|
55
|
+
- **Hash-agnostic.** `put(id, …)` takes the id from the caller — `content-store` hashes chunks, `vcs-core` computes git object ids. Optional integrity verification is a caller-injected `verify` on `put`.
|
|
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.
|
|
@@ -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"}
|
package/dist/index.d.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
|
+
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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-storage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Domain-neutral byte-persistence seam (BlobStore + KvStore with CAS) over the webrun-files FilesApi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/statewalker/webrun-vcs.git",
|
|
9
|
+
"directory": "packages/webrun-storage"
|
|
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
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@statewalker/webrun-files-mem": "^0.10.0",
|
|
34
|
+
"rolldown": "^1.2.12",
|
|
35
|
+
"vitest": "^5.0.3",
|
|
36
|
+
"typescript": "^7.0.2",
|
|
37
|
+
"@types/node": "^26.6.4"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "rm -rf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"lint": "biome check --write .",
|
|
43
|
+
"lint:check": "biome check .",
|
|
44
|
+
"lint:fix": "biome check --write --unsafe .",
|
|
45
|
+
"format": "biome format --write .",
|
|
46
|
+
"format:check": "biome format .",
|
|
47
|
+
"format:fix": "biome check --write --unsafe .",
|
|
48
|
+
"typecheck": "tsc --noEmit"
|
|
49
|
+
}
|
|
6
50
|
}
|
|
@@ -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
|
+
}
|