Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 6bace6b

Browse files
authored
D1 beta support (#329)
* Adding first version of D1 support for beta * Bunch of tweaks based on feedback * Added D1 API directly, rather than through .fetch() * rebased, bumped d1 version to 2.6, npm installed * Fixing types and tests * npm install * lazy-installing better-sqlite3 using npx-import * fixed tests and removed type alias * fixing npx-import problems with local installs of sqlite3 * Invoking better-sqlite3 through @miniflare/d1 instead * bumping npx-import to version compatible with npx 7 * bumping to npx-import v1.1.0 which should work on windows * npx import v1.1.2 * Refactored Storage to now provide getSqliteDatabase() * Added Jest integration test for D1 with push/pop & caching DB in storage * Added vitest integration test for D1 as well Co-authored-by: Glen Maddern <[email protected]>
1 parent 64e1b26 commit 6bace6b

File tree

29 files changed

+986
-15
lines changed

29 files changed

+986
-15
lines changed

package-lock.json

Lines changed: 312 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/d1/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# `@miniflare/d1`
2+
3+
Workers D1 module for [Miniflare](https://github.com/cloudflare/miniflare): a
4+
fun, full-featured, fully-local simulator for Cloudflare Workers. See
5+
[📦 D1](https://miniflare.dev/storage/d1) for more details.
6+
7+
## Example
8+
9+
```js
10+
import { BetaDatabase } from "@miniflare/d1";
11+
import { MemoryStorage } from "@miniflare/storage-memory";
12+
const db = new BetaDatabase(new MemoryStorage());
13+
14+
// BetaDatabase only supports .fetch(), once D1 is out of beta the full API will be available here:
15+
await db.fetch("/execute", {
16+
method: "POST",
17+
body: JSON.stringify({
18+
sql: `CREATE TABLE my_table (cid INTEGER PRIMARY KEY, name TEXT NOT NULL);`,
19+
}),
20+
});
21+
const response = await db.fetch("/query", {
22+
method: "POST",
23+
body: JSON.stringify({
24+
sql: `SELECT * FROM sqlite_schema`,
25+
}),
26+
});
27+
console.log(await response.json());
28+
/*
29+
{
30+
"success": true,
31+
"result": [
32+
[
33+
{
34+
"type": "table",
35+
"name": "my_table",
36+
"tbl_name": "my_table",
37+
"rootpage": 2,
38+
"sql": "CREATE TABLE my_table (cid INTEGER PRIMARY KEY, name TEXT NOT NULL)"
39+
}
40+
]
41+
]
42+
}
43+
*/
44+
```

packages/d1/package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@miniflare/d1",
3+
"version": "2.8.2",
4+
"description": "Workers D1 module for Miniflare: a fun, full-featured, fully-local simulator for Cloudflare Workers",
5+
"keywords": [
6+
"cloudflare",
7+
"workers",
8+
"worker",
9+
"local",
10+
"d1",
11+
"sqlite"
12+
],
13+
"author": "Glen Maddern <[email protected]>",
14+
"license": "MIT",
15+
"main": "./dist/src/index.js",
16+
"types": "./dist/src/index.d.ts",
17+
"files": [
18+
"dist/src"
19+
],
20+
"engines": {
21+
"node": ">=16.7"
22+
},
23+
"publishConfig": {
24+
"access": "public"
25+
},
26+
"repository": {
27+
"type": "git",
28+
"url": "git+https://github.com/cloudflare/miniflare.git",
29+
"directory": "packages/d1"
30+
},
31+
"bugs": {
32+
"url": "https://github.com/cloudflare/miniflare/issues"
33+
},
34+
"homepage": "https://github.com/cloudflare/miniflare/tree/master/packages/d1#readme",
35+
"volta": {
36+
"extends": "../../package.json"
37+
},
38+
"dependencies": {
39+
"@miniflare/core": "2.8.2",
40+
"@miniflare/shared": "2.8.2"
41+
},
42+
"devDependencies": {
43+
"@miniflare/shared-test": "2.8.2"
44+
}
45+
}

packages/d1/src/database.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { performance } from "node:perf_hooks";
2+
import type { SqliteDB } from "@miniflare/shared";
3+
import { Statement } from "./statement";
4+
5+
export class BetaDatabase {
6+
#db: SqliteDB;
7+
8+
constructor(db: SqliteDB) {
9+
this.#db = db;
10+
}
11+
12+
prepare(source: string) {
13+
return new Statement(this.#db, source);
14+
}
15+
16+
async batch(statements: Statement[]) {
17+
return await Promise.all(statements.map((s) => s.all()));
18+
}
19+
20+
async exec(multiLineStatements: string) {
21+
const statements = multiLineStatements
22+
.split("\n")
23+
.map((line) => line.trim())
24+
.filter((line) => line.length > 0);
25+
const start = performance.now();
26+
for (const statement of statements) {
27+
await new Statement(this.#db, statement).all();
28+
}
29+
return {
30+
count: statements.length,
31+
duration: performance.now() - start,
32+
};
33+
}
34+
35+
async dump() {
36+
throw new Error("DB.dump() not implemented locally!");
37+
}
38+
}

packages/d1/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./database";
2+
export * from "./plugin";
3+
export * from "./statement";

packages/d1/src/plugin.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
Context,
3+
Option,
4+
OptionType,
5+
Plugin,
6+
PluginContext,
7+
SetupResult,
8+
StorageFactory,
9+
resolveStoragePersist,
10+
} from "@miniflare/shared";
11+
import { BetaDatabase } from "./database";
12+
13+
export interface D1Options {
14+
d1Databases?: string[];
15+
d1Persist?: boolean | string;
16+
}
17+
const D1_BETA_PREFIX = `__D1_BETA__`;
18+
19+
export class D1Plugin extends Plugin<D1Options> implements D1Options {
20+
@Option({
21+
type: OptionType.ARRAY,
22+
name: "d1",
23+
description: "D1 namespace to bind",
24+
logName: "D1 Namespaces",
25+
fromWrangler: ({ d1_databases }) =>
26+
d1_databases?.map(({ binding }) => binding),
27+
})
28+
d1Databases?: string[];
29+
30+
@Option({
31+
type: OptionType.BOOLEAN_STRING,
32+
description: "Persist D1 data (to optional path)",
33+
logName: "D1 Persistence",
34+
fromWrangler: ({ miniflare }) => miniflare?.d1_persist,
35+
})
36+
d1Persist?: boolean | string;
37+
readonly #persist?: boolean | string;
38+
39+
constructor(ctx: PluginContext, options?: D1Options) {
40+
super(ctx);
41+
this.assignOptions(options);
42+
this.#persist = resolveStoragePersist(ctx.rootPath, this.d1Persist);
43+
}
44+
45+
async getBetaDatabase(
46+
storageFactory: StorageFactory,
47+
dbName: string
48+
): Promise<BetaDatabase> {
49+
const storage = await storageFactory.storage(dbName, this.#persist);
50+
return new BetaDatabase(await storage.getSqliteDatabase());
51+
}
52+
53+
async setup(storageFactory: StorageFactory): Promise<SetupResult> {
54+
const bindings: Context = {};
55+
for (const dbName of this.d1Databases ?? []) {
56+
if (dbName.startsWith(D1_BETA_PREFIX)) {
57+
bindings[dbName] = await this.getBetaDatabase(
58+
storageFactory,
59+
// Store it locally without the prefix
60+
dbName.slice(D1_BETA_PREFIX.length)
61+
);
62+
} else {
63+
console.warn(
64+
`Not injecting D1 Database for '${dbName}' as this version of Miniflare only supports D1 beta bindings. Upgrade Wrangler and/or Miniflare and try again.`
65+
);
66+
}
67+
}
68+
return { bindings };
69+
}
70+
}

packages/d1/src/statement.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { performance } from "node:perf_hooks";
2+
import type {
3+
Database as SqliteDB,
4+
Statement as SqliteStatement,
5+
} from "better-sqlite3";
6+
7+
export type BindParams = any[] | [Record<string, any>];
8+
9+
function errorWithCause(message: string, e: unknown) {
10+
// @ts-ignore Errors have causes now, why don't you know this Typescript?
11+
return new Error(message, { cause: e });
12+
}
13+
14+
export class Statement {
15+
readonly #db: SqliteDB;
16+
readonly #query: string;
17+
readonly #bindings: BindParams | undefined;
18+
19+
constructor(db: SqliteDB, query: string, bindings?: BindParams) {
20+
this.#db = db;
21+
this.#query = query;
22+
this.#bindings = bindings;
23+
}
24+
25+
// Lazily accumulate binding instructions, because ".bind" in better-sqlite3
26+
// is a real action that means the query must be valid when it's written,
27+
// not when it's about to be executed (i.e. in a batch).
28+
bind(...params: BindParams) {
29+
// Adopting better-sqlite3 behaviour—once bound, a statement cannot be bound again
30+
if (this.#bindings !== undefined) {
31+
throw new TypeError(
32+
"The bind() method can only be invoked once per statement object"
33+
);
34+
}
35+
return new Statement(this.#db, this.#query, params);
36+
}
37+
38+
private prepareAndBind() {
39+
const prepared = this.#db.prepare(this.#query);
40+
if (this.#bindings === undefined) return prepared;
41+
try {
42+
return prepared.bind(this.#bindings);
43+
} catch (e) {
44+
// For statements using ?1 ?2, etc, we want to pass them as varargs but
45+
// "better" sqlite3 wants them as an object of {1: params[0], 2: params[1], ...}
46+
if (this.#bindings.length > 0 && typeof this.#bindings[0] !== "object") {
47+
return prepared.bind(
48+
Object.fromEntries(this.#bindings.map((v, i) => [i + 1, v]))
49+
);
50+
} else {
51+
throw e;
52+
}
53+
}
54+
}
55+
56+
async all() {
57+
const start = performance.now();
58+
const statementWithBindings = this.prepareAndBind();
59+
try {
60+
const results = Statement.#all(statementWithBindings);
61+
return {
62+
results,
63+
duration: performance.now() - start,
64+
lastRowId: null,
65+
changes: null,
66+
success: true,
67+
served_by: "x-miniflare.db3",
68+
};
69+
} catch (e) {
70+
throw errorWithCause("D1_ALL_ERROR", e);
71+
}
72+
}
73+
74+
static #all(statementWithBindings: SqliteStatement) {
75+
try {
76+
return statementWithBindings.all();
77+
} catch (e: unknown) {
78+
// This is the quickest/simplest way I could find to return results by
79+
// default, falling back to .run()
80+
if (
81+
/This statement does not return data\. Use run\(\) instead/.exec(
82+
(e as Error).message
83+
)
84+
) {
85+
return Statement.#run(statementWithBindings);
86+
}
87+
throw e;
88+
}
89+
}
90+
91+
async first(col?: string) {
92+
const statementWithBindings = this.prepareAndBind();
93+
try {
94+
const data = Statement.#first(statementWithBindings);
95+
return typeof col === "string" ? data[col] : data;
96+
} catch (e) {
97+
throw errorWithCause("D1_FIRST_ERROR", e);
98+
}
99+
}
100+
101+
static #first(statementWithBindings: SqliteStatement) {
102+
return statementWithBindings.get();
103+
}
104+
105+
async run() {
106+
const start = performance.now();
107+
const statementWithBindings = this.prepareAndBind();
108+
try {
109+
const { changes, lastInsertRowid } = Statement.#run(
110+
statementWithBindings
111+
);
112+
return {
113+
results: null,
114+
duration: performance.now() - start,
115+
lastRowId: lastInsertRowid,
116+
changes,
117+
success: true,
118+
served_by: "x-miniflare.db3",
119+
};
120+
} catch (e) {
121+
throw errorWithCause("D1_RUN_ERROR", e);
122+
}
123+
}
124+
125+
static #run(statementWithBindings: SqliteStatement) {
126+
return statementWithBindings.run();
127+
}
128+
129+
async raw() {
130+
const statementWithBindings = this.prepareAndBind();
131+
return Statement.#raw(statementWithBindings);
132+
}
133+
134+
static #raw(statementWithBindings: SqliteStatement) {
135+
return statementWithBindings.raw() as any;
136+
}
137+
}

packages/d1/test/database.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { BetaDatabase } from "@miniflare/d1";
2+
import { Storage } from "@miniflare/shared";
3+
import { testClock } from "@miniflare/shared-test";
4+
import { MemoryStorage } from "@miniflare/storage-memory";
5+
import anyTest, { TestInterface } from "ava";
6+
7+
interface Context {
8+
storage: Storage;
9+
db: BetaDatabase;
10+
}
11+
12+
const test = anyTest as TestInterface<Context>;
13+
14+
test.beforeEach(async (t) => {
15+
const storage = new MemoryStorage(undefined, testClock);
16+
const db = new BetaDatabase(await storage.getSqliteDatabase());
17+
t.context = { storage, db };
18+
});
19+
20+
test("batch, prepare & all", async (t) => {
21+
const { db } = t.context;
22+
23+
await db.batch([
24+
db.prepare(
25+
`CREATE TABLE my_table (cid INTEGER PRIMARY KEY, name TEXT NOT NULL);`
26+
),
27+
]);
28+
const response = await db.prepare(`SELECT * FROM sqlite_schema`).all();
29+
t.deepEqual(Object.keys(response), [
30+
"results",
31+
"duration",
32+
"lastRowId",
33+
"changes",
34+
"success",
35+
"served_by",
36+
]);
37+
t.deepEqual(response.results, [
38+
{
39+
type: "table",
40+
name: "my_table",
41+
tbl_name: "my_table",
42+
rootpage: 2,
43+
sql: "CREATE TABLE my_table (cid INTEGER PRIMARY KEY, name TEXT NOT NULL)",
44+
},
45+
]);
46+
});

0 commit comments

Comments
 (0)