@cloudhal/storage 0.2.0 → 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 +18 -3
- package/dist/src/blob/fetcher.d.ts +24 -0
- package/dist/src/blob/fetcher.js +73 -0
- package/dist/src/blob/layered.d.ts +24 -0
- package/dist/src/blob/layered.js +85 -0
- package/dist/src/blob/sql.d.ts +32 -0
- package/dist/src/blob/sql.js +167 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +9 -1
- package/dist/src/sql/do-sqlite.d.ts +21 -0
- package/dist/src/sql/do-sqlite.js +94 -0
- package/dist/src/testing-do.d.ts +15 -0
- package/dist/src/testing-do.js +146 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -4,12 +4,13 @@ Storage interfaces and adapters for Cloudhal. The interface is shaped by the mos
|
|
|
4
4
|
|
|
5
5
|
| Export | What |
|
|
6
6
|
|---|---|
|
|
7
|
-
| `@cloudhal/storage` | `SqlDriver`, `BlobStore`, limits, `migrate()`, `migrations`, `MemoryBlobStore` |
|
|
7
|
+
| `@cloudhal/storage` | `SqlDriver`, `BlobStore`, limits, `migrate()`, `migrations`, `MemoryBlobStore`, `SqlBlobStore` (blobs in a SQL table, capped), `LayeredBlobStore` (read-only base under a writable upper), `FetcherBlobStore` (read-only, over a Worker `ASSETS` binding) |
|
|
8
8
|
| `@cloudhal/storage/sqlite` | `BetterSqlite3Driver` — local files or `:memory:` |
|
|
9
9
|
| `@cloudhal/storage/d1` | `D1Driver` — Cloudflare D1 binding |
|
|
10
|
+
| `@cloudhal/storage/do-sqlite` | `DoSqliteDriver` — a SQLite-backed Durable Object's `ctx.storage` (RFC-0036); `batch` runs in `transactionSync` |
|
|
10
11
|
| `@cloudhal/storage/fs` | `FsBlobStore` — directory on disk |
|
|
11
12
|
| `@cloudhal/storage/r2` | `R2BlobStore` — Cloudflare R2 binding |
|
|
12
|
-
| `@cloudhal/storage/testing` | conformance suites + Miniflare helpers (`createMiniflareD1`, `createMiniflareR2`) |
|
|
13
|
+
| `@cloudhal/storage/testing` | conformance suites + Miniflare helpers (`createMiniflareD1`, `createMiniflareR2`, `createMiniflareDoSqlite`) |
|
|
13
14
|
|
|
14
15
|
```ts
|
|
15
16
|
import { migrate, migrations } from "@cloudhal/storage";
|
|
@@ -24,4 +25,18 @@ await sql.batch([
|
|
|
24
25
|
|
|
25
26
|
Migrations are TypeScript modules under `migrations/` (plain SQL strings) so they bundle for Workers without filesystem access. Each migration is applied as one atomic batch.
|
|
26
27
|
|
|
27
|
-
Every adapter must pass `describeSqlDriverConformance` / `describeBlobStoreConformance`; the D1 and
|
|
28
|
+
Every adapter must pass `describeSqlDriverConformance` / `describeBlobStoreConformance`; the D1, R2 and Durable Object suites run against Miniflare (workerd in-process), so no Cloudflare account is needed. `createMiniflareDoSqlite()` runs `DoSqliteDriver` inside a test-only Durable Object and hands Node a driver whose every call is one request to it, so any `SqlDriver` suite (core-ts's included) runs on Durable Object SQLite.
|
|
29
|
+
|
|
30
|
+
A whole site in one Durable Object (the playground's shape):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { FetcherBlobStore, LayeredBlobStore, migrate, migrations, SqlBlobStore } from "@cloudhal/storage";
|
|
34
|
+
import { DoSqliteDriver } from "@cloudhal/storage/do-sqlite";
|
|
35
|
+
|
|
36
|
+
const sql = new DoSqliteDriver(ctx.storage);
|
|
37
|
+
await migrate(sql, migrations);
|
|
38
|
+
const blobs = new LayeredBlobStore(
|
|
39
|
+
new FetcherBlobStore(env.ASSETS, "demo/"), // read-only demo media
|
|
40
|
+
new SqlBlobStore(sql, { maxBytes: 8 * 1024 * 1024 }), // the session's own blobs
|
|
41
|
+
);
|
|
42
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type BlobMeta, type BlobStore } from "../types.js";
|
|
2
|
+
/** The part of a Worker `Fetcher` binding (e.g. `ASSETS`) this store uses. */
|
|
3
|
+
export interface BlobFetcher {
|
|
4
|
+
fetch(input: string): Promise<Response>;
|
|
5
|
+
}
|
|
6
|
+
export interface FetcherBlobStoreOptions {
|
|
7
|
+
/** Keys `list` reports (static assets cannot be listed). Default: none. */
|
|
8
|
+
readonly keys?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A read-only `BlobStore` over static files (RFC-0036 §2): `key` is fetched as
|
|
12
|
+
* `/<prefix><key>` from the Worker's `ASSETS` binding. Writes throw `StorageError`. Used as the
|
|
13
|
+
* base layer of a `LayeredBlobStore` for demo media.
|
|
14
|
+
*/
|
|
15
|
+
export declare class FetcherBlobStore implements BlobStore {
|
|
16
|
+
#private;
|
|
17
|
+
readonly name = "fetcher";
|
|
18
|
+
constructor(fetcher: BlobFetcher, prefix: string, options?: FetcherBlobStoreOptions);
|
|
19
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
20
|
+
head(key: string): Promise<BlobMeta | null>;
|
|
21
|
+
put(key: string): Promise<void>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
list(prefix?: string): Promise<string[]>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBlobKey,
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
StorageError,
|
|
6
|
+
sha256OfBytes,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
|
|
9
|
+
/** The part of a Worker `Fetcher` binding (e.g. `ASSETS`) this store uses. */
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A read-only `BlobStore` over static files (RFC-0036 §2): `key` is fetched as
|
|
21
|
+
* `/<prefix><key>` from the Worker's `ASSETS` binding. Writes throw `StorageError`. Used as the
|
|
22
|
+
* base layer of a `LayeredBlobStore` for demo media.
|
|
23
|
+
*/
|
|
24
|
+
export class FetcherBlobStore {
|
|
25
|
+
name = "fetcher";
|
|
26
|
+
#fetcher ;
|
|
27
|
+
#prefix ;
|
|
28
|
+
#keys ;
|
|
29
|
+
|
|
30
|
+
constructor(fetcher , prefix , options = {}) {
|
|
31
|
+
this.#fetcher = fetcher;
|
|
32
|
+
this.#prefix = prefix.replace(/^\/+/, "");
|
|
33
|
+
this.#keys = [...(options.keys ?? [])].sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async #fetch(key ) {
|
|
37
|
+
assertBlobKey(key);
|
|
38
|
+
const res = await this.#fetcher.fetch(`https://assets.local/${this.#prefix}${key}`);
|
|
39
|
+
if (res.status === 404) return null;
|
|
40
|
+
if (!res.ok) throw new StorageError(`fetching ${key} failed with ${res.status}`);
|
|
41
|
+
return res;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(key ) {
|
|
45
|
+
const res = await this.#fetch(key);
|
|
46
|
+
return res ? new Uint8Array(await res.arrayBuffer()) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async head(key ) {
|
|
50
|
+
// A full read: the size and SHA-256 must be exact, and demo media are small.
|
|
51
|
+
const res = await this.#fetch(key);
|
|
52
|
+
if (!res) return null;
|
|
53
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
54
|
+
const ct = res.headers.get("content-type");
|
|
55
|
+
return {
|
|
56
|
+
size: data.byteLength,
|
|
57
|
+
sha256: await sha256OfBytes(data),
|
|
58
|
+
...(ct ? { contentType: ct } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async put(key ) {
|
|
63
|
+
throw new StorageError(`blob store "${this.name}" is read-only (put ${key})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delete(key ) {
|
|
67
|
+
throw new StorageError(`blob store "${this.name}" is read-only (delete ${key})`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async list(prefix = "") {
|
|
71
|
+
return this.#keys.filter((k) => k.startsWith(prefix));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type BlobMeta, type BlobPutOptions, type BlobRange, type BlobStore, type BlobStream } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads `upper` first, then `base`; every write goes to `upper`; `list` merges both (RFC-0036 §2).
|
|
4
|
+
* The playground puts read-only demo media (`base`) under a writable per-session store (`upper`).
|
|
5
|
+
* `base` is never written: deleting a key only removes `upper`'s copy, so a base object stays
|
|
6
|
+
* readable after a delete.
|
|
7
|
+
*/
|
|
8
|
+
export declare class LayeredBlobStore implements BlobStore {
|
|
9
|
+
#private;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
constructor(base: BlobStore, upper: BlobStore);
|
|
12
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
13
|
+
head(key: string): Promise<BlobMeta | null>;
|
|
14
|
+
put(key: string, data: Uint8Array, options?: BlobPutOptions): Promise<void>;
|
|
15
|
+
putStream(key: string, data: ReadableStream<Uint8Array>, options: BlobPutOptions & {
|
|
16
|
+
size: number;
|
|
17
|
+
sha256: string;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
getStream(key: string, options?: {
|
|
20
|
+
range?: BlobRange;
|
|
21
|
+
}): Promise<BlobStream | null>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
list(prefix?: string): Promise<string[]>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBlobKey,
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
bytesStream,
|
|
9
|
+
resolveBlobRange,
|
|
10
|
+
StorageError,
|
|
11
|
+
} from "../types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads `upper` first, then `base`; every write goes to `upper`; `list` merges both (RFC-0036 §2).
|
|
15
|
+
* The playground puts read-only demo media (`base`) under a writable per-session store (`upper`).
|
|
16
|
+
* `base` is never written: deleting a key only removes `upper`'s copy, so a base object stays
|
|
17
|
+
* readable after a delete.
|
|
18
|
+
*/
|
|
19
|
+
export class LayeredBlobStore {
|
|
20
|
+
name ;
|
|
21
|
+
#base ;
|
|
22
|
+
#upper ;
|
|
23
|
+
|
|
24
|
+
constructor(base , upper ) {
|
|
25
|
+
this.#base = base;
|
|
26
|
+
this.#upper = upper;
|
|
27
|
+
this.name = `layered(${upper.name}/${base.name})`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(key ) {
|
|
31
|
+
return (await this.#upper.get(key)) ?? this.#base.get(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async head(key ) {
|
|
35
|
+
return (await this.#upper.head(key)) ?? this.#base.head(key);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
put(key , data , options ) {
|
|
39
|
+
return this.#upper.put(key, data, options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async putStream(
|
|
43
|
+
key ,
|
|
44
|
+
data ,
|
|
45
|
+
options ,
|
|
46
|
+
) {
|
|
47
|
+
if (this.#upper.putStream) return this.#upper.putStream(key, data, options);
|
|
48
|
+
const bytes = new Uint8Array(await new Response(data).arrayBuffer());
|
|
49
|
+
return this.#upper.put(key, bytes, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getStream(key , options = {}) {
|
|
53
|
+
assertBlobKey(key);
|
|
54
|
+
for (const store of [this.#upper, this.#base]) {
|
|
55
|
+
if (store.getStream) {
|
|
56
|
+
const s = await store.getStream(key, options);
|
|
57
|
+
if (s) return s;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const data = await store.get(key);
|
|
61
|
+
if (data) return streamOf(data, options.range);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete(key ) {
|
|
67
|
+
return this.#upper.delete(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async list(prefix = "") {
|
|
71
|
+
const [a, b] = await Promise.all([this.#upper.list(prefix), this.#base.list(prefix)]);
|
|
72
|
+
return [...new Set([...a, ...b])].sort();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function streamOf(data , range ) {
|
|
77
|
+
if (!range) return { body: bytesStream(data), size: data.byteLength };
|
|
78
|
+
const r = resolveBlobRange(range, data.byteLength);
|
|
79
|
+
if (!r) throw new StorageError("range not satisfiable");
|
|
80
|
+
return {
|
|
81
|
+
body: bytesStream(data.slice(r.offset, r.offset + r.length)),
|
|
82
|
+
size: data.byteLength,
|
|
83
|
+
range: r,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type BlobMeta, type BlobPutOptions, type BlobRange, type BlobStore, type BlobStream, type SqlDriver } from "../types.js";
|
|
2
|
+
export interface SqlBlobStoreOptions {
|
|
3
|
+
/** Cap on the sum of all object sizes; a put past it throws `StorageLimitError`. Default: none. */
|
|
4
|
+
readonly maxBytes?: number;
|
|
5
|
+
/** Cap on one object. Default 1 MiB, under DO SQLite's and D1's 2 MB value limit. */
|
|
6
|
+
readonly maxObjectBytes?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A `BlobStore` kept in a table of the same SQL database (RFC-0036 §2): one database holds a
|
|
10
|
+
* whole site, as in a playground session's Durable Object. Works over any `SqlDriver`. Every put
|
|
11
|
+
* records the SHA-256 and checks a declared one; the total-size cap is checked in the same
|
|
12
|
+
* statement that writes, so concurrent puts cannot overshoot it.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SqlBlobStore implements BlobStore {
|
|
15
|
+
#private;
|
|
16
|
+
readonly name = "sql";
|
|
17
|
+
constructor(sql: SqlDriver, options?: SqlBlobStoreOptions);
|
|
18
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
19
|
+
head(key: string): Promise<BlobMeta | null>;
|
|
20
|
+
put(key: string, data: Uint8Array, options?: BlobPutOptions): Promise<void>;
|
|
21
|
+
putStream(key: string, data: ReadableStream<Uint8Array>, options: BlobPutOptions & {
|
|
22
|
+
size: number;
|
|
23
|
+
sha256: string;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
getStream(key: string, options?: {
|
|
26
|
+
range?: BlobRange;
|
|
27
|
+
}): Promise<BlobStream | null>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
list(prefix?: string): Promise<string[]>;
|
|
30
|
+
/** Sum of all object sizes, for usage displays and caps. */
|
|
31
|
+
totalBytes(): Promise<number>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBlobKey,
|
|
3
|
+
BlobIntegrityError,
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
bytesStream,
|
|
10
|
+
readAllBytes,
|
|
11
|
+
resolveBlobRange,
|
|
12
|
+
|
|
13
|
+
StorageError,
|
|
14
|
+
StorageLimitError,
|
|
15
|
+
sha256OfBytes,
|
|
16
|
+
verifyBlob,
|
|
17
|
+
} from "../types.js";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
const DDL =
|
|
27
|
+
"CREATE TABLE IF NOT EXISTS _blobs (key TEXT PRIMARY KEY, data BLOB NOT NULL, content_type TEXT, size INTEGER NOT NULL, sha256 TEXT NOT NULL)";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A `BlobStore` kept in a table of the same SQL database (RFC-0036 §2): one database holds a
|
|
31
|
+
* whole site, as in a playground session's Durable Object. Works over any `SqlDriver`. Every put
|
|
32
|
+
* records the SHA-256 and checks a declared one; the total-size cap is checked in the same
|
|
33
|
+
* statement that writes, so concurrent puts cannot overshoot it.
|
|
34
|
+
*/
|
|
35
|
+
export class SqlBlobStore {
|
|
36
|
+
name = "sql";
|
|
37
|
+
#sql ;
|
|
38
|
+
#maxBytes ;
|
|
39
|
+
#maxObjectBytes ;
|
|
40
|
+
#ready ;
|
|
41
|
+
|
|
42
|
+
constructor(sql , options = {}) {
|
|
43
|
+
this.#sql = sql;
|
|
44
|
+
this.#maxBytes = options.maxBytes ?? Number.MAX_SAFE_INTEGER;
|
|
45
|
+
this.#maxObjectBytes = options.maxObjectBytes ?? 1024 * 1024;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#table() {
|
|
49
|
+
this.#ready ??= this.#sql.exec(DDL).catch((e ) => {
|
|
50
|
+
this.#ready = undefined;
|
|
51
|
+
throw e;
|
|
52
|
+
});
|
|
53
|
+
return this.#ready;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async get(key ) {
|
|
57
|
+
assertBlobKey(key);
|
|
58
|
+
await this.#table();
|
|
59
|
+
const row = await this.#sql.get (
|
|
60
|
+
"SELECT data FROM _blobs WHERE key = ?",
|
|
61
|
+
[key],
|
|
62
|
+
);
|
|
63
|
+
return row ? new Uint8Array(row.data) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async head(key ) {
|
|
67
|
+
assertBlobKey(key);
|
|
68
|
+
await this.#table();
|
|
69
|
+
const row = await this.#sql.get (
|
|
70
|
+
"SELECT size, content_type, sha256 FROM _blobs WHERE key = ?",
|
|
71
|
+
[key],
|
|
72
|
+
);
|
|
73
|
+
if (!row) return null;
|
|
74
|
+
return {
|
|
75
|
+
size: row.size,
|
|
76
|
+
sha256: row.sha256,
|
|
77
|
+
...(row.content_type === null ? {} : { contentType: row.content_type }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async put(key , data , options = {}) {
|
|
82
|
+
assertBlobKey(key);
|
|
83
|
+
if (options.sha256 !== undefined) await verifyBlob(data, options.sha256);
|
|
84
|
+
if (data.byteLength > this.#maxObjectBytes)
|
|
85
|
+
throw new StorageLimitError(
|
|
86
|
+
`blob is ${data.byteLength} bytes; the limit is ${this.#maxObjectBytes}`,
|
|
87
|
+
);
|
|
88
|
+
await this.#table();
|
|
89
|
+
const sha256 = options.sha256 ?? (await sha256OfBytes(data));
|
|
90
|
+
const res = await this.#sql.run(
|
|
91
|
+
`INSERT INTO _blobs (key, data, content_type, size, sha256)
|
|
92
|
+
SELECT ?, ?, ?, ?, ?
|
|
93
|
+
WHERE (SELECT coalesce(sum(size), 0) FROM _blobs WHERE key <> ?) + ? <= ?
|
|
94
|
+
ON CONFLICT (key) DO UPDATE SET data = excluded.data, content_type = excluded.content_type,
|
|
95
|
+
size = excluded.size, sha256 = excluded.sha256`,
|
|
96
|
+
[
|
|
97
|
+
key,
|
|
98
|
+
data,
|
|
99
|
+
options.contentType ?? null,
|
|
100
|
+
data.byteLength,
|
|
101
|
+
sha256,
|
|
102
|
+
key,
|
|
103
|
+
data.byteLength,
|
|
104
|
+
this.#maxBytes,
|
|
105
|
+
],
|
|
106
|
+
);
|
|
107
|
+
if (res.changes === 0)
|
|
108
|
+
throw new StorageLimitError(`blob store is full (limit ${this.#maxBytes} bytes)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async putStream(
|
|
112
|
+
key ,
|
|
113
|
+
data ,
|
|
114
|
+
options ,
|
|
115
|
+
) {
|
|
116
|
+
assertBlobKey(key);
|
|
117
|
+
if (options.size > this.#maxObjectBytes)
|
|
118
|
+
throw new StorageLimitError(
|
|
119
|
+
`blob is ${options.size} bytes; the limit is ${this.#maxObjectBytes}`,
|
|
120
|
+
);
|
|
121
|
+
const bytes = await readAllBytes(data, options.size);
|
|
122
|
+
if (bytes.byteLength !== options.size)
|
|
123
|
+
throw new BlobIntegrityError(`expected ${options.size} bytes, got ${bytes.byteLength}`);
|
|
124
|
+
await this.put(key, bytes, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getStream(key , options = {}) {
|
|
128
|
+
const data = await this.get(key);
|
|
129
|
+
if (!data) return null;
|
|
130
|
+
const meta = await this.head(key);
|
|
131
|
+
const ct = meta?.contentType === undefined ? {} : { contentType: meta.contentType };
|
|
132
|
+
const size = data.byteLength;
|
|
133
|
+
if (!options.range) return { body: bytesStream(data), size, ...ct };
|
|
134
|
+
const range = resolveBlobRange(options.range, size);
|
|
135
|
+
if (!range) throw new StorageError("range not satisfiable");
|
|
136
|
+
return {
|
|
137
|
+
body: bytesStream(data.slice(range.offset, range.offset + range.length)),
|
|
138
|
+
size,
|
|
139
|
+
range,
|
|
140
|
+
...ct,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async delete(key ) {
|
|
145
|
+
assertBlobKey(key);
|
|
146
|
+
await this.#table();
|
|
147
|
+
await this.#sql.run("DELETE FROM _blobs WHERE key = ?", [key]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async list(prefix = "") {
|
|
151
|
+
await this.#table();
|
|
152
|
+
const rows = await this.#sql.all (
|
|
153
|
+
"SELECT key FROM _blobs WHERE substr(key, 1, ?) = ? ORDER BY key",
|
|
154
|
+
[prefix.length, prefix],
|
|
155
|
+
);
|
|
156
|
+
return rows.map((r) => r.key);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sum of all object sizes, for usage displays and caps. */
|
|
160
|
+
async totalBytes() {
|
|
161
|
+
await this.#table();
|
|
162
|
+
const row = await this.#sql.get (
|
|
163
|
+
"SELECT coalesce(sum(size), 0) AS n FROM _blobs",
|
|
164
|
+
);
|
|
165
|
+
return row?.n ?? 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Interfaces: `SqlDriver` (get/all/run/atomic batch/exec) and `BlobStore` (get/put/delete/list).
|
|
5
5
|
* Adapters: `@cloudhal/storage/sqlite` (better-sqlite3), `@cloudhal/storage/d1`, `@cloudhal/storage/fs`,
|
|
6
|
-
* `@cloudhal/storage/r2`,
|
|
6
|
+
* `@cloudhal/storage/r2`, `@cloudhal/storage/do-sqlite` (Durable Object SQLite), plus `MemoryBlobStore`,
|
|
7
|
+
* `SqlBlobStore`, `LayeredBlobStore` and `FetcherBlobStore` here. Conformance suites: `@cloudhal/storage/testing`.
|
|
7
8
|
*/
|
|
8
9
|
export { migrations } from "../migrations/index.js";
|
|
10
|
+
export { type BlobFetcher, FetcherBlobStore, type FetcherBlobStoreOptions, } from "./blob/fetcher.js";
|
|
11
|
+
export { LayeredBlobStore } from "./blob/layered.js";
|
|
9
12
|
export { MemoryBlobStore } from "./blob/memory.js";
|
|
13
|
+
export { SqlBlobStore, type SqlBlobStoreOptions } from "./blob/sql.js";
|
|
10
14
|
export * from "./migrate.js";
|
|
11
15
|
export * from "./types.js";
|
package/dist/src/index.js
CHANGED
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Interfaces: `SqlDriver` (get/all/run/atomic batch/exec) and `BlobStore` (get/put/delete/list).
|
|
5
5
|
* Adapters: `@cloudhal/storage/sqlite` (better-sqlite3), `@cloudhal/storage/d1`, `@cloudhal/storage/fs`,
|
|
6
|
-
* `@cloudhal/storage/r2`,
|
|
6
|
+
* `@cloudhal/storage/r2`, `@cloudhal/storage/do-sqlite` (Durable Object SQLite), plus `MemoryBlobStore`,
|
|
7
|
+
* `SqlBlobStore`, `LayeredBlobStore` and `FetcherBlobStore` here. Conformance suites: `@cloudhal/storage/testing`.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
export { migrations } from "../migrations/index.js";
|
|
11
|
+
export {
|
|
12
|
+
|
|
13
|
+
FetcherBlobStore,
|
|
14
|
+
|
|
15
|
+
} from "./blob/fetcher.js";
|
|
16
|
+
export { LayeredBlobStore } from "./blob/layered.js";
|
|
10
17
|
export { MemoryBlobStore } from "./blob/memory.js";
|
|
18
|
+
export { SqlBlobStore, } from "./blob/sql.js";
|
|
11
19
|
export * from "./migrate.js";
|
|
12
20
|
export * from "./types.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type RunResult, type SqlDriver, type SqlStatement, type SqlValue } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* SQLite-backed Durable Object adapter (RFC-0036 §2) over `ctx.storage` of the DO that owns the
|
|
4
|
+
* database. Every call runs synchronously on the DO's own SQLite; the async signature is the
|
|
5
|
+
* `SqlDriver` contract. `batch` runs inside `transactionSync`, so a throw rolls every statement back;
|
|
6
|
+
* the transaction is never handed to callers, and `BEGIN` is refused by the runtime, so the
|
|
7
|
+
* batch-only rule holds here as on D1 (ADR-0002). D1's limits are enforced like on every adapter.
|
|
8
|
+
*/
|
|
9
|
+
export declare class DoSqliteDriver implements SqlDriver {
|
|
10
|
+
#private;
|
|
11
|
+
readonly name = "do-sqlite";
|
|
12
|
+
constructor(storage: DurableObjectStorage);
|
|
13
|
+
/** Bytes the database takes on disk (`ctx.storage.sql.databaseSize`), for size caps. */
|
|
14
|
+
get databaseSize(): number;
|
|
15
|
+
get<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T | undefined>;
|
|
16
|
+
all<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T[]>;
|
|
17
|
+
run(sql: string, params?: readonly SqlValue[]): Promise<RunResult>;
|
|
18
|
+
batch(statements: readonly SqlStatement[]): Promise<RunResult[]>;
|
|
19
|
+
exec(sql: string): Promise<void>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBatchLimits,
|
|
3
|
+
assertStatementLimits,
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
StorageError,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SQLite-backed Durable Object adapter (RFC-0036 §2) over `ctx.storage` of the DO that owns the
|
|
15
|
+
* database. Every call runs synchronously on the DO's own SQLite; the async signature is the
|
|
16
|
+
* `SqlDriver` contract. `batch` runs inside `transactionSync`, so a throw rolls every statement back;
|
|
17
|
+
* the transaction is never handed to callers, and `BEGIN` is refused by the runtime, so the
|
|
18
|
+
* batch-only rule holds here as on D1 (ADR-0002). D1's limits are enforced like on every adapter.
|
|
19
|
+
*/
|
|
20
|
+
export class DoSqliteDriver {
|
|
21
|
+
name = "do-sqlite";
|
|
22
|
+
#storage ;
|
|
23
|
+
|
|
24
|
+
constructor(storage ) {
|
|
25
|
+
this.#storage = storage;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bytes the database takes on disk (`ctx.storage.sql.databaseSize`), for size caps. */
|
|
29
|
+
get databaseSize() {
|
|
30
|
+
return this.#storage.sql.databaseSize;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#rows (sql , params = []) {
|
|
34
|
+
assertStatementLimits(sql, params);
|
|
35
|
+
const cursor = this.#storage.sql.exec(sql, ...params.map(toBinding));
|
|
36
|
+
return cursor.toArray().map((row) => fromRow(row) );
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#run(sql , params = []) {
|
|
40
|
+
this.#rows(sql, params);
|
|
41
|
+
const meta = this.#storage.sql.exec("SELECT changes() AS c, last_insert_rowid() AS id").one();
|
|
42
|
+
const changes = Number(meta.c ?? 0);
|
|
43
|
+
return { changes, lastRowId: changes > 0 ? Number(meta.id) : null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async get (
|
|
47
|
+
sql ,
|
|
48
|
+
params ,
|
|
49
|
+
) {
|
|
50
|
+
return this.#rows (sql, params)[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async all (
|
|
54
|
+
sql ,
|
|
55
|
+
params ,
|
|
56
|
+
) {
|
|
57
|
+
return this.#rows (sql, params);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async run(sql , params ) {
|
|
61
|
+
return this.#run(sql, params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async batch(statements ) {
|
|
65
|
+
assertBatchLimits(statements);
|
|
66
|
+
try {
|
|
67
|
+
return this.#storage.transactionSync(() => statements.map((s) => this.#run(s.sql, s.params)));
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new StorageError(`batch failed: ${(cause ).message}`, cause);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async exec(sql ) {
|
|
74
|
+
this.#storage.sql.exec(sql);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async close() {
|
|
78
|
+
// The database belongs to the Durable Object; nothing to release.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toBinding(value ) {
|
|
83
|
+
if (value instanceof Uint8Array)
|
|
84
|
+
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) ;
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** BLOB columns come back as ArrayBuffer; hand them out as Uint8Array like the other adapters. */
|
|
89
|
+
function fromRow(row ) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [k, v] of Object.entries(row))
|
|
92
|
+
out[k] = v instanceof ArrayBuffer ? new Uint8Array(v) : v;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for `DoSqliteDriver` (RFC-0036 §2 invariant 6). A Miniflare Worker holds a test-only,
|
|
3
|
+
* SQLite-backed Durable Object that runs the real driver on its own `ctx.storage`; from Node, every
|
|
4
|
+
* `SqlDriver` call is one request to that object. Suites written for any `SqlDriver` (the storage
|
|
5
|
+
* conformance suite, core-ts's behavioural suite) thereby run against Durable Object SQLite.
|
|
6
|
+
*/
|
|
7
|
+
import { type SqlDriver } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Spins up a Miniflare Worker with a SQLite-backed Durable Object running `DoSqliteDriver`.
|
|
10
|
+
* `driver(db)` returns a driver for the object named `db` (a fresh, empty database per name).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createMiniflareDoSqlite(): Promise<{
|
|
13
|
+
driver(db?: string): SqlDriver;
|
|
14
|
+
dispose(): Promise<void>;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for `DoSqliteDriver` (RFC-0036 §2 invariant 6). A Miniflare Worker holds a test-only,
|
|
3
|
+
* SQLite-backed Durable Object that runs the real driver on its own `ctx.storage`; from Node, every
|
|
4
|
+
* `SqlDriver` call is one request to that object. Suites written for any `SqlDriver` (the storage
|
|
5
|
+
* conformance suite, core-ts's behavioural suite) thereby run against Durable Object SQLite.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
BlobIntegrityError,
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
StorageError,
|
|
14
|
+
StorageLimitError,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
const WORKER = `
|
|
18
|
+
import { DurableObject } from "cloudflare:workers";
|
|
19
|
+
import { DoSqliteDriver } from "./sql/do-sqlite.EXT";
|
|
20
|
+
const b64 = (u) => { let s = ""; for (const c of u) s += String.fromCharCode(c); return btoa(s); };
|
|
21
|
+
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
22
|
+
const revive = (_k, v) => (v && typeof v === "object" && typeof v.$b === "string" ? unb64(v.$b) : v);
|
|
23
|
+
const encode = (v) => JSON.stringify(v ?? null, (_k, x) => (x instanceof Uint8Array ? { $b: b64(x) } : x));
|
|
24
|
+
export class TestDb extends DurableObject {
|
|
25
|
+
constructor(ctx, env) { super(ctx, env); this.driver = new DoSqliteDriver(ctx.storage); }
|
|
26
|
+
async fetch(req) {
|
|
27
|
+
const { op, args } = JSON.parse(await req.text(), revive);
|
|
28
|
+
try {
|
|
29
|
+
return new Response(encode({ ok: await this.driver[op](...args) }));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return new Response(encode({ error: { name: e.name, message: e.message } }));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export default {
|
|
36
|
+
fetch(req, env) {
|
|
37
|
+
const db = new URL(req.url).searchParams.get("db") ?? "default";
|
|
38
|
+
return env.DB.get(env.DB.idFromName(db)).fetch(req);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const ERRORS = {
|
|
44
|
+
StorageError,
|
|
45
|
+
StorageLimitError,
|
|
46
|
+
BlobIntegrityError,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const toB64 = (u ) => Buffer.from(u).toString("base64");
|
|
50
|
+
const encode = (v ) =>
|
|
51
|
+
JSON.stringify(v, (_k, x) => (x instanceof Uint8Array ? { $b: toB64(x) } : x));
|
|
52
|
+
const revive = (_k , v ) =>
|
|
53
|
+
v && typeof v === "object" && typeof (v ).$b === "string"
|
|
54
|
+
? new Uint8Array(Buffer.from((v ).$b, "base64"))
|
|
55
|
+
: v;
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
/** Node-side `SqlDriver` whose every call runs `DoSqliteDriver` inside the test Durable Object. */
|
|
63
|
+
class RemoteDoSqliteDriver {
|
|
64
|
+
name = "do-sqlite";
|
|
65
|
+
#dispatch ;
|
|
66
|
+
#db ;
|
|
67
|
+
|
|
68
|
+
constructor(dispatch , db ) {
|
|
69
|
+
this.#dispatch = dispatch;
|
|
70
|
+
this.#db = db;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async #call (op , args ) {
|
|
74
|
+
const res = await this.#dispatch(`http://do.test/?db=${encodeURIComponent(this.#db)}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: encode({ op, args }),
|
|
77
|
+
});
|
|
78
|
+
const out = JSON.parse(await res.text(), revive)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
;
|
|
82
|
+
if (out.error) {
|
|
83
|
+
const E = ERRORS[out.error.name] ?? Error;
|
|
84
|
+
throw new E(out.error.message);
|
|
85
|
+
}
|
|
86
|
+
return out.ok ;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async get (sql , params ) {
|
|
90
|
+
return (await this.#call ("get", [sql, params ?? []])) ?? undefined;
|
|
91
|
+
}
|
|
92
|
+
all (sql , params ) {
|
|
93
|
+
return this.#call ("all", [sql, params ?? []]);
|
|
94
|
+
}
|
|
95
|
+
run(sql , params ) {
|
|
96
|
+
return this.#call ("run", [sql, params ?? []]);
|
|
97
|
+
}
|
|
98
|
+
batch(statements ) {
|
|
99
|
+
return this.#call ("batch", [statements]);
|
|
100
|
+
}
|
|
101
|
+
async exec(sql ) {
|
|
102
|
+
await this.#call("exec", [sql]);
|
|
103
|
+
}
|
|
104
|
+
async close() {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Spins up a Miniflare Worker with a SQLite-backed Durable Object running `DoSqliteDriver`.
|
|
109
|
+
* `driver(db)` returns a driver for the object named `db` (a fresh, empty database per name).
|
|
110
|
+
*/
|
|
111
|
+
export async function createMiniflareDoSqlite()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
const { Miniflare } = await import("miniflare");
|
|
116
|
+
const { readFile } = await import("node:fs/promises");
|
|
117
|
+
const { stripTypeScriptTypes } = await import("node:module");
|
|
118
|
+
const { fileURLToPath } = await import("node:url");
|
|
119
|
+
// Sources in the repo are TypeScript; the published package carries compiled JavaScript.
|
|
120
|
+
const ext = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
|
121
|
+
const load = async (rel ) => {
|
|
122
|
+
const text = await readFile(fileURLToPath(new URL(rel, import.meta.url).href), "utf8");
|
|
123
|
+
return ext === "ts" ? stripTypeScriptTypes(text) : text;
|
|
124
|
+
};
|
|
125
|
+
const root = fileURLToPath(new URL(".", import.meta.url).href);
|
|
126
|
+
const mf = new Miniflare({
|
|
127
|
+
compatibilityDate: "2026-07-01",
|
|
128
|
+
modulesRoot: root,
|
|
129
|
+
modules: [
|
|
130
|
+
{ type: "ESModule", path: `${root}worker.js`, contents: WORKER.replace("EXT", ext) },
|
|
131
|
+
{
|
|
132
|
+
type: "ESModule",
|
|
133
|
+
path: `${root}sql/do-sqlite.${ext}`,
|
|
134
|
+
contents: await load(`./sql/do-sqlite.${ext}`),
|
|
135
|
+
},
|
|
136
|
+
{ type: "ESModule", path: `${root}types.${ext}`, contents: await load(`./types.${ext}`) },
|
|
137
|
+
],
|
|
138
|
+
durableObjects: { DB: { className: "TestDb", useSQLite: true } },
|
|
139
|
+
});
|
|
140
|
+
await mf.ready;
|
|
141
|
+
const dispatch = (url, init) => mf.dispatchFetch(url, init);
|
|
142
|
+
return {
|
|
143
|
+
driver: (db = "default") => new RemoteDoSqliteDriver(dispatch, db),
|
|
144
|
+
dispose: () => mf.dispose(),
|
|
145
|
+
};
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudhal/storage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cloudhal storage interfaces (SQL driver, blob store) with Cloudflare D1/R2 and local SQLite/filesystem adapters",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Cloudhal storage interfaces (SQL driver, blob store) with Cloudflare D1/R2/Durable Object SQLite and local SQLite/filesystem adapters",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://cloudhal.dev",
|
|
7
7
|
"repository": {
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./dist/src/sql/d1.d.ts",
|
|
27
27
|
"default": "./dist/src/sql/d1.js"
|
|
28
28
|
},
|
|
29
|
+
"./do-sqlite": {
|
|
30
|
+
"types": "./dist/src/sql/do-sqlite.d.ts",
|
|
31
|
+
"default": "./dist/src/sql/do-sqlite.js"
|
|
32
|
+
},
|
|
29
33
|
"./r2": {
|
|
30
34
|
"types": "./dist/src/blob/r2.d.ts",
|
|
31
35
|
"default": "./dist/src/blob/r2.js"
|
|
@@ -45,7 +49,7 @@
|
|
|
45
49
|
"README.md"
|
|
46
50
|
],
|
|
47
51
|
"dependencies": {
|
|
48
|
-
"@cloudhal/core-contracts": "0.
|
|
52
|
+
"@cloudhal/core-contracts": "0.3.0"
|
|
49
53
|
},
|
|
50
54
|
"peerDependencies": {
|
|
51
55
|
"better-sqlite3": "^13.0.0"
|