@cloudhal/storage 0.0.0-stage → 0.1.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 +202 -0
- package/README.md +26 -2
- package/dist/migrations/0001_init.d.ts +12 -0
- package/dist/migrations/0001_init.js +46 -0
- package/dist/migrations/0002_auth.d.ts +7 -0
- package/dist/migrations/0002_auth.js +26 -0
- package/dist/migrations/0003_agent.d.ts +6 -0
- package/dist/migrations/0003_agent.js +32 -0
- package/dist/migrations/0004_audit_actor_name.d.ts +3 -0
- package/dist/migrations/0004_audit_actor_name.js +7 -0
- package/dist/migrations/0005_auth_rate_limit.d.ts +7 -0
- package/dist/migrations/0005_auth_rate_limit.js +11 -0
- package/dist/migrations/0006_approvals.d.ts +7 -0
- package/dist/migrations/0006_approvals.js +31 -0
- package/dist/migrations/0007_audit_mode.d.ts +3 -0
- package/dist/migrations/0007_audit_mode.js +7 -0
- package/dist/migrations/0008_policy_detail.d.ts +7 -0
- package/dist/migrations/0008_policy_detail.js +17 -0
- package/dist/migrations/0009_audit_elevation.d.ts +3 -0
- package/dist/migrations/0009_audit_elevation.js +7 -0
- package/dist/migrations/0010_approval_result.d.ts +6 -0
- package/dist/migrations/0010_approval_result.js +10 -0
- package/dist/migrations/0011_op_guards.d.ts +17 -0
- package/dist/migrations/0011_op_guards.js +30 -0
- package/dist/migrations/0012_roles.d.ts +23 -0
- package/dist/migrations/0012_roles.js +78 -0
- package/dist/migrations/0013_media.d.ts +16 -0
- package/dist/migrations/0013_media.js +69 -0
- package/dist/migrations/0014_analytics.d.ts +19 -0
- package/dist/migrations/0014_analytics.js +105 -0
- package/dist/migrations/0015_analytics_privacy_signal.d.ts +8 -0
- package/dist/migrations/0015_analytics_privacy_signal.js +14 -0
- package/dist/migrations/0016_comments.d.ts +27 -0
- package/dist/migrations/0016_comments.js +173 -0
- package/dist/migrations/0017_schedules.d.ts +21 -0
- package/dist/migrations/0017_schedules.js +108 -0
- package/dist/migrations/0018_query_indexes.d.ts +12 -0
- package/dist/migrations/0018_query_indexes.js +22 -0
- package/dist/migrations/0019_body_proposals.d.ts +8 -0
- package/dist/migrations/0019_body_proposals.js +24 -0
- package/dist/migrations/0020_ai.d.ts +8 -0
- package/dist/migrations/0020_ai.js +31 -0
- package/dist/migrations/0021_search.d.ts +12 -0
- package/dist/migrations/0021_search.js +29 -0
- package/dist/migrations/0022_search_kinds.d.ts +12 -0
- package/dist/migrations/0022_search_kinds.js +35 -0
- package/dist/migrations/0023_search_usage.d.ts +7 -0
- package/dist/migrations/0023_search_usage.js +18 -0
- package/dist/migrations/0024_search_quota.d.ts +8 -0
- package/dist/migrations/0024_search_quota.js +18 -0
- package/dist/migrations/0025_translation_groups.d.ts +7 -0
- package/dist/migrations/0025_translation_groups.js +15 -0
- package/dist/migrations/0026_search_cjk.d.ts +8 -0
- package/dist/migrations/0026_search_cjk.js +20 -0
- package/dist/migrations/0027_multi_site.d.ts +14 -0
- package/dist/migrations/0027_multi_site.js +44 -0
- package/dist/migrations/0028_site_members.d.ts +8 -0
- package/dist/migrations/0028_site_members.js +22 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +61 -0
- package/dist/src/blob/fs.d.ts +19 -0
- package/dist/src/blob/fs.js +172 -0
- package/dist/src/blob/memory.d.ts +18 -0
- package/dist/src/blob/memory.js +79 -0
- package/dist/src/blob/r2.d.ts +19 -0
- package/dist/src/blob/r2.js +146 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +12 -0
- package/dist/src/migrate.d.ts +17 -0
- package/dist/src/migrate.js +67 -0
- package/dist/src/sql/better-sqlite3.d.ts +23 -0
- package/dist/src/sql/better-sqlite3.js +104 -0
- package/dist/src/sql/d1.d.ts +13 -0
- package/dist/src/sql/d1.js +70 -0
- package/dist/src/types.d.ts +131 -0
- package/dist/src/types.js +235 -0
- package/package.json +76 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type BlobMeta, type BlobPutOptions, type BlobRange, type BlobStore, type BlobStream } from "../types.js";
|
|
2
|
+
/** In-memory blob store for tests and ephemeral runs. */
|
|
3
|
+
export declare class MemoryBlobStore implements BlobStore {
|
|
4
|
+
#private;
|
|
5
|
+
readonly name = "memory";
|
|
6
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
7
|
+
head(key: string): Promise<BlobMeta | null>;
|
|
8
|
+
put(key: string, data: Uint8Array, options?: BlobPutOptions): Promise<void>;
|
|
9
|
+
putStream(key: string, data: ReadableStream<Uint8Array>, options: BlobPutOptions & {
|
|
10
|
+
size: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
getStream(key: string, options?: {
|
|
14
|
+
range?: BlobRange;
|
|
15
|
+
}): Promise<BlobStream | null>;
|
|
16
|
+
delete(key: string): Promise<void>;
|
|
17
|
+
list(prefix?: string): Promise<string[]>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBlobKey,
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
bytesStream,
|
|
9
|
+
readAllBytes,
|
|
10
|
+
resolveBlobRange,
|
|
11
|
+
StorageError,
|
|
12
|
+
verifyBlob,
|
|
13
|
+
} from "../types.js";
|
|
14
|
+
|
|
15
|
+
/** In-memory blob store for tests and ephemeral runs. */
|
|
16
|
+
export class MemoryBlobStore {
|
|
17
|
+
name = "memory";
|
|
18
|
+
#objects = new Map ();
|
|
19
|
+
|
|
20
|
+
async get(key ) {
|
|
21
|
+
assertBlobKey(key);
|
|
22
|
+
const o = this.#objects.get(key);
|
|
23
|
+
return o ? new Uint8Array(o.data) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async head(key ) {
|
|
27
|
+
assertBlobKey(key);
|
|
28
|
+
const o = this.#objects.get(key);
|
|
29
|
+
if (!o) return null;
|
|
30
|
+
return o.contentType === undefined
|
|
31
|
+
? { size: o.data.byteLength }
|
|
32
|
+
: { size: o.data.byteLength, contentType: o.contentType };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async put(key , data , options = {}) {
|
|
36
|
+
assertBlobKey(key);
|
|
37
|
+
if (options.sha256 !== undefined) await verifyBlob(data, options.sha256);
|
|
38
|
+
const entry = { data: new Uint8Array(data) };
|
|
39
|
+
if (options.contentType !== undefined) entry.contentType = options.contentType;
|
|
40
|
+
this.#objects.set(key, entry);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async putStream(
|
|
44
|
+
key ,
|
|
45
|
+
data ,
|
|
46
|
+
options ,
|
|
47
|
+
) {
|
|
48
|
+
assertBlobKey(key);
|
|
49
|
+
const bytes = await readAllBytes(data, options.size);
|
|
50
|
+
await verifyBlob(bytes, options.sha256, options.size);
|
|
51
|
+
await this.put(key, bytes, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getStream(key , options = {}) {
|
|
55
|
+
assertBlobKey(key);
|
|
56
|
+
const o = this.#objects.get(key);
|
|
57
|
+
if (!o) return null;
|
|
58
|
+
const size = o.data.byteLength;
|
|
59
|
+
const ct = o.contentType === undefined ? {} : { contentType: o.contentType };
|
|
60
|
+
if (!options.range) return { body: bytesStream(new Uint8Array(o.data)), size, ...ct };
|
|
61
|
+
const range = resolveBlobRange(options.range, size);
|
|
62
|
+
if (!range) throw new StorageError("range not satisfiable");
|
|
63
|
+
return {
|
|
64
|
+
body: bytesStream(o.data.slice(range.offset, range.offset + range.length)),
|
|
65
|
+
size,
|
|
66
|
+
range,
|
|
67
|
+
...ct,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async delete(key ) {
|
|
72
|
+
assertBlobKey(key);
|
|
73
|
+
this.#objects.delete(key);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async list(prefix = "") {
|
|
77
|
+
return [...this.#objects.keys()].filter((k) => k.startsWith(prefix)).sort();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type BlobMeta, type BlobPutOptions, type BlobRange, type BlobStore, type BlobStream } from "../types.js";
|
|
2
|
+
/** Cloudflare R2 adapter over an `R2Bucket` binding. */
|
|
3
|
+
export declare class R2BlobStore implements BlobStore {
|
|
4
|
+
#private;
|
|
5
|
+
readonly name = "r2";
|
|
6
|
+
constructor(bucket: R2Bucket);
|
|
7
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
8
|
+
head(key: string): Promise<BlobMeta | null>;
|
|
9
|
+
put(key: string, data: Uint8Array, options?: BlobPutOptions): Promise<void>;
|
|
10
|
+
putStream(key: string, data: ReadableStream<Uint8Array>, options: BlobPutOptions & {
|
|
11
|
+
size: number;
|
|
12
|
+
sha256: string;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
getStream(key: string, options?: {
|
|
15
|
+
range?: BlobRange;
|
|
16
|
+
}): Promise<BlobStream | null>;
|
|
17
|
+
delete(key: string): Promise<void>;
|
|
18
|
+
list(prefix?: string): Promise<string[]>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBlobKey,
|
|
3
|
+
BlobIntegrityError,
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
readAllBytes,
|
|
10
|
+
resolveBlobRange,
|
|
11
|
+
SHA256_HEX,
|
|
12
|
+
StorageError,
|
|
13
|
+
} from "../types.js";
|
|
14
|
+
|
|
15
|
+
const hex = (buf ) =>
|
|
16
|
+
Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
|
|
18
|
+
/** R2 refuses a put whose checksum does not match; its error names the mismatch. */
|
|
19
|
+
function isChecksumError(e ) {
|
|
20
|
+
return /checksum|digest|sha-?256|does not match|BadDigest/i.test(
|
|
21
|
+
e instanceof Error ? e.message : String(e),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cloudflare R2 adapter over an `R2Bucket` binding. */
|
|
26
|
+
export class R2BlobStore {
|
|
27
|
+
name = "r2";
|
|
28
|
+
#bucket ;
|
|
29
|
+
|
|
30
|
+
constructor(bucket ) {
|
|
31
|
+
this.#bucket = bucket;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async get(key ) {
|
|
35
|
+
assertBlobKey(key);
|
|
36
|
+
const obj = await this.#bucket.get(key);
|
|
37
|
+
if (!obj) return null;
|
|
38
|
+
return new Uint8Array(await obj.arrayBuffer());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async head(key ) {
|
|
42
|
+
assertBlobKey(key);
|
|
43
|
+
const obj = await this.#bucket.head(key);
|
|
44
|
+
if (!obj) return null;
|
|
45
|
+
const ct = obj.httpMetadata?.contentType;
|
|
46
|
+
const sha = obj.checksums?.sha256;
|
|
47
|
+
return {
|
|
48
|
+
size: obj.size,
|
|
49
|
+
...(ct === undefined ? {} : { contentType: ct }),
|
|
50
|
+
...(sha ? { sha256: hex(sha) } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async put(key , data , options = {}) {
|
|
55
|
+
assertBlobKey(key);
|
|
56
|
+
if (options.sha256 !== undefined && !SHA256_HEX.test(options.sha256))
|
|
57
|
+
throw new BlobIntegrityError("sha256 must be 64 lower-case hex");
|
|
58
|
+
try {
|
|
59
|
+
await this.#bucket.put(key, data, {
|
|
60
|
+
...(options.contentType ? { httpMetadata: { contentType: options.contentType } } : {}),
|
|
61
|
+
// R2 verifies the checksum and stores nothing on a mismatch (RFC-0004 §4.1).
|
|
62
|
+
...(options.sha256 ? { sha256: options.sha256 } : {}),
|
|
63
|
+
});
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (options.sha256 && isChecksumError(e))
|
|
66
|
+
throw new BlobIntegrityError("data does not match the declared sha256");
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async putStream(
|
|
72
|
+
key ,
|
|
73
|
+
data ,
|
|
74
|
+
options ,
|
|
75
|
+
) {
|
|
76
|
+
assertBlobKey(key);
|
|
77
|
+
if (!SHA256_HEX.test(options.sha256))
|
|
78
|
+
throw new BlobIntegrityError("sha256 must be 64 lower-case hex");
|
|
79
|
+
const Fixed = (
|
|
80
|
+
globalThis
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
).FixedLengthStream;
|
|
84
|
+
if (!Fixed) {
|
|
85
|
+
// Outside workerd (Miniflare's Node proxy): buffer, bounded by the declared size.
|
|
86
|
+
const bytes = await readAllBytes(data, options.size);
|
|
87
|
+
if (bytes.byteLength !== options.size)
|
|
88
|
+
throw new BlobIntegrityError(`expected ${options.size} bytes, got ${bytes.byteLength}`);
|
|
89
|
+
return this.put(key, bytes, options);
|
|
90
|
+
}
|
|
91
|
+
// FixedLengthStream errors on a short or long body, and R2 checks the SHA-256 before the
|
|
92
|
+
// object becomes visible, so nothing is stored under a wrong name.
|
|
93
|
+
const fixed = new Fixed(options.size);
|
|
94
|
+
const piped = data.pipeTo(fixed.writable).catch((e ) => e);
|
|
95
|
+
try {
|
|
96
|
+
await this.#bucket.put(key, fixed.readable , {
|
|
97
|
+
sha256: options.sha256,
|
|
98
|
+
...(options.contentType ? { httpMetadata: { contentType: options.contentType } } : {}),
|
|
99
|
+
});
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (isChecksumError(e))
|
|
102
|
+
throw new BlobIntegrityError("data does not match the declared sha256");
|
|
103
|
+
throw new BlobIntegrityError(
|
|
104
|
+
`upload failed: ${e instanceof Error ? e.message : String(e)}`.slice(0, 200),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const pipeError = await piped;
|
|
108
|
+
if (pipeError) throw new BlobIntegrityError("stream did not carry the declared size");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getStream(key , options = {}) {
|
|
112
|
+
assertBlobKey(key);
|
|
113
|
+
let range ;
|
|
114
|
+
if (options.range) {
|
|
115
|
+
const head = await this.#bucket.head(key);
|
|
116
|
+
if (!head) return null;
|
|
117
|
+
range = resolveBlobRange(options.range, head.size);
|
|
118
|
+
if (!range) throw new StorageError("range not satisfiable");
|
|
119
|
+
}
|
|
120
|
+
const obj = await this.#bucket.get(key, range ? { range } : {});
|
|
121
|
+
if (!obj || !("body" in obj)) return null;
|
|
122
|
+
const ct = obj.httpMetadata?.contentType;
|
|
123
|
+
return {
|
|
124
|
+
body: obj.body ,
|
|
125
|
+
size: obj.size,
|
|
126
|
+
...(range ? { range } : {}),
|
|
127
|
+
...(ct === undefined ? {} : { contentType: ct }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async delete(key ) {
|
|
132
|
+
assertBlobKey(key);
|
|
133
|
+
await this.#bucket.delete(key);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async list(prefix = "") {
|
|
137
|
+
const keys = [];
|
|
138
|
+
let cursor ;
|
|
139
|
+
do {
|
|
140
|
+
const page = await this.#bucket.list(cursor ? { prefix, cursor } : { prefix });
|
|
141
|
+
for (const o of page.objects) keys.push(o.key);
|
|
142
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
143
|
+
} while (cursor);
|
|
144
|
+
return keys.sort();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloudhal/storage — storage interfaces and adapters.
|
|
3
|
+
*
|
|
4
|
+
* Interfaces: `SqlDriver` (get/all/run/atomic batch/exec) and `BlobStore` (get/put/delete/list).
|
|
5
|
+
* Adapters: `@cloudhal/storage/sqlite` (better-sqlite3), `@cloudhal/storage/d1`, `@cloudhal/storage/fs`,
|
|
6
|
+
* `@cloudhal/storage/r2`, plus `MemoryBlobStore` here. Conformance suites: `@cloudhal/storage/testing`.
|
|
7
|
+
*/
|
|
8
|
+
export { migrations } from "../migrations/index.js";
|
|
9
|
+
export { MemoryBlobStore } from "./blob/memory.js";
|
|
10
|
+
export * from "./migrate.js";
|
|
11
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloudhal/storage — storage interfaces and adapters.
|
|
3
|
+
*
|
|
4
|
+
* Interfaces: `SqlDriver` (get/all/run/atomic batch/exec) and `BlobStore` (get/put/delete/list).
|
|
5
|
+
* Adapters: `@cloudhal/storage/sqlite` (better-sqlite3), `@cloudhal/storage/d1`, `@cloudhal/storage/fs`,
|
|
6
|
+
* `@cloudhal/storage/r2`, plus `MemoryBlobStore` here. Conformance suites: `@cloudhal/storage/testing`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { migrations } from "../migrations/index.js";
|
|
10
|
+
export { MemoryBlobStore } from "./blob/memory.js";
|
|
11
|
+
export * from "./migrate.js";
|
|
12
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SqlDriver } from "./types.js";
|
|
2
|
+
export interface Migration {
|
|
3
|
+
/** Unique, sortable name, e.g. "0001_init". */
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** One or more SQL statements separated by `;` at end of line. No semicolons inside string literals. */
|
|
6
|
+
readonly sql: string;
|
|
7
|
+
}
|
|
8
|
+
/** Splits a migration file into statements (semicolon at end of line, comments stripped). */
|
|
9
|
+
export declare function splitStatements(sql: string): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Applies pending migrations in order. Each migration is one atomic `batch` (its statements plus the
|
|
12
|
+
* bookkeeping row), so a failing migration leaves the database at the previous migration.
|
|
13
|
+
*/
|
|
14
|
+
export declare function migrate(driver: SqlDriver, migrations: readonly Migration[]): Promise<{
|
|
15
|
+
applied: string[];
|
|
16
|
+
}>;
|
|
17
|
+
export declare function appliedMigrations(driver: SqlDriver): Promise<string[]>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { StorageError } from "./types.js";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const MIGRATIONS_TABLE = "_migrations";
|
|
11
|
+
|
|
12
|
+
/** Splits a migration file into statements (semicolon at end of line, comments stripped). */
|
|
13
|
+
export function splitStatements(sql ) {
|
|
14
|
+
return sql
|
|
15
|
+
.split("\n")
|
|
16
|
+
.filter((line) => !line.trim().startsWith("--"))
|
|
17
|
+
.join("\n")
|
|
18
|
+
.split(/;\s*(?:\n|$)/)
|
|
19
|
+
.map((s) => s.trim())
|
|
20
|
+
.filter((s) => s.length > 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Applies pending migrations in order. Each migration is one atomic `batch` (its statements plus the
|
|
25
|
+
* bookkeeping row), so a failing migration leaves the database at the previous migration.
|
|
26
|
+
*/
|
|
27
|
+
export async function migrate(
|
|
28
|
+
driver ,
|
|
29
|
+
migrations ,
|
|
30
|
+
) {
|
|
31
|
+
await driver.exec(
|
|
32
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
|
|
33
|
+
);
|
|
34
|
+
const rows = await driver.all (
|
|
35
|
+
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,
|
|
36
|
+
);
|
|
37
|
+
const done = new Set(rows.map((r) => r.name));
|
|
38
|
+
const sorted = [...migrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
const names = new Set ();
|
|
40
|
+
for (const m of sorted) {
|
|
41
|
+
if (names.has(m.name)) throw new StorageError(`duplicate migration name "${m.name}"`);
|
|
42
|
+
names.add(m.name);
|
|
43
|
+
}
|
|
44
|
+
const applied = [];
|
|
45
|
+
for (const m of sorted) {
|
|
46
|
+
if (done.has(m.name)) continue;
|
|
47
|
+
const statements = splitStatements(m.sql).map((sql) => ({ sql }));
|
|
48
|
+
statements.push({
|
|
49
|
+
sql: `INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`,
|
|
50
|
+
params: [m.name, new Date().toISOString()],
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await driver.batch(statements);
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
throw new StorageError(`migration "${m.name}" failed: ${(cause ).message}`, cause);
|
|
56
|
+
}
|
|
57
|
+
applied.push(m.name);
|
|
58
|
+
}
|
|
59
|
+
return { applied };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function appliedMigrations(driver ) {
|
|
63
|
+
const rows = await driver
|
|
64
|
+
.all (`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`)
|
|
65
|
+
.catch(() => []);
|
|
66
|
+
return rows.map((r) => r.name);
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { type RunResult, type SqlDriver, type SqlStatement, type SqlValue } from "../types.js";
|
|
3
|
+
export interface BetterSqlite3DriverOptions {
|
|
4
|
+
/** Enforce D1 limits locally (default true). Turn off only for one-off maintenance scripts. */
|
|
5
|
+
readonly enforceLimits?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Local SQLite adapter over better-sqlite3 (synchronous under the hood, async interface).
|
|
9
|
+
* `batch` runs inside a SQLite transaction so it is atomic like D1's batch.
|
|
10
|
+
*/
|
|
11
|
+
export declare class BetterSqlite3Driver implements SqlDriver {
|
|
12
|
+
#private;
|
|
13
|
+
readonly name = "better-sqlite3";
|
|
14
|
+
constructor(db: Database.Database, options?: BetterSqlite3DriverOptions);
|
|
15
|
+
/** Open a database file (or ":memory:") and wrap it. */
|
|
16
|
+
static open(filename: string, options?: BetterSqlite3DriverOptions): Promise<BetterSqlite3Driver>;
|
|
17
|
+
get<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T | undefined>;
|
|
18
|
+
all<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T[]>;
|
|
19
|
+
run(sql: string, params?: readonly SqlValue[]): Promise<RunResult>;
|
|
20
|
+
batch(statements: readonly SqlStatement[]): Promise<RunResult[]>;
|
|
21
|
+
exec(sql: string): Promise<void>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
|
|
2
|
+
import {
|
|
3
|
+
assertBatchLimits,
|
|
4
|
+
assertStatementLimits,
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
StorageError,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function toParams(params ) {
|
|
18
|
+
return params ? [...params] : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Local SQLite adapter over better-sqlite3 (synchronous under the hood, async interface).
|
|
23
|
+
* `batch` runs inside a SQLite transaction so it is atomic like D1's batch.
|
|
24
|
+
*/
|
|
25
|
+
export class BetterSqlite3Driver {
|
|
26
|
+
name = "better-sqlite3";
|
|
27
|
+
#db ;
|
|
28
|
+
#enforce ;
|
|
29
|
+
|
|
30
|
+
constructor(db , options = {}) {
|
|
31
|
+
this.#db = db;
|
|
32
|
+
this.#enforce = options.enforceLimits ?? true;
|
|
33
|
+
this.#db.pragma("journal_mode = WAL");
|
|
34
|
+
this.#db.pragma("foreign_keys = ON");
|
|
35
|
+
// D1 rejects BEGIN from user SQL; mirror that so code never depends on it.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Open a database file (or ":memory:") and wrap it. */
|
|
39
|
+
static async open(
|
|
40
|
+
filename ,
|
|
41
|
+
options = {},
|
|
42
|
+
) {
|
|
43
|
+
const mod = await import("better-sqlite3");
|
|
44
|
+
const db = new mod.default(filename);
|
|
45
|
+
return new BetterSqlite3Driver(db, options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#check(sql , params ) {
|
|
49
|
+
if (this.#enforce) assertStatementLimits(sql, params);
|
|
50
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(sql)) {
|
|
51
|
+
throw new StorageError(
|
|
52
|
+
"interactive transactions are not supported (D1 parity); use batch() for atomic writes",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async get (
|
|
58
|
+
sql ,
|
|
59
|
+
params ,
|
|
60
|
+
) {
|
|
61
|
+
this.#check(sql, params);
|
|
62
|
+
return this.#db.prepare(sql).get(...toParams(params)) ;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async all (
|
|
66
|
+
sql ,
|
|
67
|
+
params ,
|
|
68
|
+
) {
|
|
69
|
+
this.#check(sql, params);
|
|
70
|
+
return this.#db.prepare(sql).all(...toParams(params)) ;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async run(sql , params ) {
|
|
74
|
+
this.#check(sql, params);
|
|
75
|
+
return this.#runSync(params ? { sql, params } : { sql });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#runSync(statement ) {
|
|
79
|
+
const stmt = this.#db.prepare(statement.sql);
|
|
80
|
+
if (stmt.reader) {
|
|
81
|
+
stmt.all(...toParams(statement.params));
|
|
82
|
+
return { changes: 0, lastRowId: null };
|
|
83
|
+
}
|
|
84
|
+
const info = stmt.run(...toParams(statement.params));
|
|
85
|
+
return { changes: info.changes, lastRowId: Number(info.lastInsertRowid) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async batch(statements ) {
|
|
89
|
+
if (this.#enforce) assertBatchLimits(statements);
|
|
90
|
+
for (const s of statements) this.#check(s.sql, s.params);
|
|
91
|
+
const tx = this.#db.transaction((stmts ) =>
|
|
92
|
+
stmts.map((s) => this.#runSync(s)),
|
|
93
|
+
);
|
|
94
|
+
return tx(statements);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async exec(sql ) {
|
|
98
|
+
this.#db.exec(sql);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async close() {
|
|
102
|
+
this.#db.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RunResult, type SqlDriver, type SqlStatement, type SqlValue } from "../types.js";
|
|
2
|
+
/** Cloudflare D1 adapter. Expects the `D1Database` binding from the Worker environment. */
|
|
3
|
+
export declare class D1Driver implements SqlDriver {
|
|
4
|
+
#private;
|
|
5
|
+
readonly name = "d1";
|
|
6
|
+
constructor(db: D1Database);
|
|
7
|
+
get<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T | undefined>;
|
|
8
|
+
all<T extends Record<string, unknown>>(sql: string, params?: readonly SqlValue[]): Promise<T[]>;
|
|
9
|
+
run(sql: string, params?: readonly SqlValue[]): Promise<RunResult>;
|
|
10
|
+
batch(statements: readonly SqlStatement[]): Promise<RunResult[]>;
|
|
11
|
+
exec(sql: string): Promise<void>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBatchLimits,
|
|
3
|
+
assertStatementLimits,
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
StorageError,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
/** Cloudflare D1 adapter. Expects the `D1Database` binding from the Worker environment. */
|
|
12
|
+
export class D1Driver {
|
|
13
|
+
name = "d1";
|
|
14
|
+
#db ;
|
|
15
|
+
|
|
16
|
+
constructor(db ) {
|
|
17
|
+
this.#db = db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#prepare(sql , params = []) {
|
|
21
|
+
assertStatementLimits(sql, params);
|
|
22
|
+
return this.#db.prepare(sql).bind(...params);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get (
|
|
26
|
+
sql ,
|
|
27
|
+
params ,
|
|
28
|
+
) {
|
|
29
|
+
const row = await this.#prepare(sql, params).first ();
|
|
30
|
+
return row ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async all (
|
|
34
|
+
sql ,
|
|
35
|
+
params ,
|
|
36
|
+
) {
|
|
37
|
+
const res = await this.#prepare(sql, params).all ();
|
|
38
|
+
return res.results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async run(sql , params ) {
|
|
42
|
+
const res = await this.#prepare(sql, params).run();
|
|
43
|
+
return toRunResult(res);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async batch(statements ) {
|
|
47
|
+
assertBatchLimits(statements);
|
|
48
|
+
const prepared = statements.map((s) => this.#prepare(s.sql, s.params));
|
|
49
|
+
let results ;
|
|
50
|
+
try {
|
|
51
|
+
results = await this.#db.batch(prepared);
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
throw new StorageError(`batch failed: ${(cause ).message}`, cause);
|
|
54
|
+
}
|
|
55
|
+
return results.map(toRunResult);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async exec(sql ) {
|
|
59
|
+
await this.#db.exec(sql);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async close() {
|
|
63
|
+
// D1 bindings are owned by the runtime; nothing to release.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toRunResult(res ) {
|
|
68
|
+
const meta = res.meta ;
|
|
69
|
+
return { changes: meta.changes ?? 0, lastRowId: meta.last_row_id ?? null };
|
|
70
|
+
}
|