diff --git a/.gitignore b/.gitignore index 58fcabd7..cc5f3824 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ leveldb-store npm-debug.log .idea dist +package-lock.json diff --git a/databases/cloudflare_d1_db.ts b/databases/cloudflare_d1_db.ts new file mode 100644 index 00000000..6295111c --- /dev/null +++ b/databases/cloudflare_d1_db.ts @@ -0,0 +1,140 @@ +/** + * Cloudflare D1 database driver for ueberDB. + * + * D1 is Cloudflare's SQL database built on SQLite. This driver expects a + * D1Database binding to be passed via `settings.d1Database`. In a Cloudflare + * Worker you obtain the binding from the worker's `env` object: + * + * const db = new Database('cloudflare_d1', {d1Database: env.MY_D1_BINDING}); + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AbstractDatabase, {type Settings} from '../lib/AbstractDatabase'; +import type {BulkObject} from './cassandra_db'; + +/** + * Minimal subset of the Cloudflare D1 API used by this driver. + * This intentionally mirrors the official @cloudflare/workers-types + * definitions so that users get type-safety when passing real bindings, + * while keeping this package free of a hard dependency on that package. + */ +export interface D1Result> { + results: T[]; + success: boolean; + meta: Record; +} + +export interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + first>(colName?: string): Promise; + run>(): Promise>; + all>(): Promise>; + raw(options?: {columnNames?: boolean}): Promise; +} + +export interface D1Database { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise[]>; + exec(query: string): Promise<{count: number; duration: number}>; +} + +export type D1Settings = Settings & { + /** The D1Database binding provided by the Cloudflare Worker runtime. */ + d1Database?: D1Database; +}; + +export default class CloudflareD1DB extends AbstractDatabase { + private _d1db: D1Database | null; + + constructor(settings: D1Settings) { + super(settings); + this._d1db = settings.d1Database ?? null; + this.settings.json = true; + this.settings.cache = 1000; + this.settings.writeInterval = 100; + } + + get isAsync() { + return true; + } + + async init(): Promise { + if (!this._d1db) { + throw new Error( + 'CloudflareD1DB requires a D1Database binding passed via settings.d1Database', + ); + } + await this._d1db.exec( + 'CREATE TABLE IF NOT EXISTS store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)', + ); + } + + async get(key: string): Promise { + const row = await this._d1db! + .prepare('SELECT value FROM store WHERE key = ?') + .bind(key) + .first<{value: string}>(); + return row ? row.value : null; + } + + async findKeys(key: string, notKey?: string | null): Promise { + const likeKey = key.replace(/\*/g, '%'); + let stmt: D1PreparedStatement; + if (notKey != null) { + const likeNotKey = notKey.replace(/\*/g, '%'); + stmt = this._d1db! + .prepare('SELECT key FROM store WHERE key LIKE ? AND key NOT LIKE ?') + .bind(likeKey, likeNotKey); + } else { + stmt = this._d1db!.prepare('SELECT key FROM store WHERE key LIKE ?').bind(likeKey); + } + const result = await stmt.all<{key: string}>(); + return result.results.map((row) => row.key); + } + + async set(key: string, value: string): Promise { + if (key.length > 100) throw new Error('Your Key can only be 100 chars'); + await this._d1db! + .prepare('INSERT OR REPLACE INTO store (key, value) VALUES (?, ?)') + .bind(key, value) + .run(); + } + + async remove(key: string): Promise { + await this._d1db!.prepare('DELETE FROM store WHERE key = ?').bind(key).run(); + } + + async doBulk(bulk: BulkObject[]): Promise { + if (bulk.length === 0) return; + const statements: D1PreparedStatement[] = []; + for (const op of bulk) { + if (op.type === 'set') { + statements.push( + this._d1db! + .prepare('INSERT OR REPLACE INTO store (key, value) VALUES (?, ?)') + .bind(op.key, op.value), + ); + } else if (op.type === 'remove') { + statements.push( + this._d1db!.prepare('DELETE FROM store WHERE key = ?').bind(op.key), + ); + } + } + await this._d1db!.batch(statements); + } + + close(): void { + this._d1db = null; + } +} diff --git a/index.ts b/index.ts index d0fb0650..31ac87b9 100644 --- a/index.ts +++ b/index.ts @@ -28,6 +28,7 @@ export type {Logger} from './lib/logging'; export type DatabaseType = | 'cassandra' + | 'cloudflare_d1' | 'couch' | 'dirty' | 'dirtygit' @@ -103,6 +104,10 @@ export class Database { return new (await import('./databases/redis_db')).default(this.dbSettings as Settings); case 'cassandra': return new (await import('./databases/cassandra_db')).default(this.dbSettings as Settings); + case 'cloudflare_d1': { + const {default: CloudflareD1DB} = await import('./databases/cloudflare_d1_db'); + return new CloudflareD1DB(this.dbSettings as import('./databases/cloudflare_d1_db').D1Settings); + } case 'dirty': return new (await import('./databases/dirty_db')).default(this.dbSettings as Settings); case 'dirtygit': diff --git a/lib/AbstractDatabase.ts b/lib/AbstractDatabase.ts index 4b19c81f..484d87a5 100644 --- a/lib/AbstractDatabase.ts +++ b/lib/AbstractDatabase.ts @@ -9,6 +9,8 @@ export type Settings = { data?: unknown; table?: string; db?: string; + /** Cloudflare D1Database binding (used by the cloudflare_d1 driver). */ + d1Database?: unknown; idleTimeoutMillis?: number; min?: number; max?: number; diff --git a/test/cloudflare_d1/test.cloudflared1.spec.ts b/test/cloudflare_d1/test.cloudflared1.spec.ts new file mode 100644 index 00000000..6aac9af2 --- /dev/null +++ b/test/cloudflare_d1/test.cloudflared1.spec.ts @@ -0,0 +1,6 @@ +import {describe} from 'vitest'; +import {test_db} from '../lib/test_lib'; + +describe('cloudflare_d1 test', () => { + test_db('cloudflare_d1'); +}); diff --git a/test/lib/databases.ts b/test/lib/databases.ts index e48c3c68..6f9a1356 100644 --- a/test/lib/databases.ts +++ b/test/lib/databases.ts @@ -1,4 +1,5 @@ import os from 'os'; +import {MockD1Database} from './mock_d1'; type DatabaseType ={ [key:string]:any @@ -6,6 +7,13 @@ type DatabaseType ={ export const databases:DatabaseType = { memory: {}, + cloudflare_d1: { + // A fresh MockD1Database is constructed for each property access so that + // every test gets its own isolated in-memory SQLite instance. + get d1Database() { + return new MockD1Database(); + }, + }, dirty: { filename: `${os.tmpdir()}/ueberdb-test.db`, speeds: { diff --git a/test/lib/mock_d1.ts b/test/lib/mock_d1.ts new file mode 100644 index 00000000..e2e785fc --- /dev/null +++ b/test/lib/mock_d1.ts @@ -0,0 +1,81 @@ +/** + * A lightweight mock implementation of the Cloudflare D1 API backed by + * Node.js's built-in `node:sqlite` module. Used only in tests. + * + * The mock mirrors the interface defined in cloudflare_d1_db.ts so that + * the driver can be exercised locally without a real Cloudflare runtime. + */ + +import {DatabaseSync} from 'node:sqlite'; +import type {D1Database, D1PreparedStatement, D1Result} from '../../databases/cloudflare_d1_db'; + +class MockD1PreparedStatement implements D1PreparedStatement { + private readonly _db: DatabaseSync; + private readonly _sql: string; + private readonly _bindings: unknown[]; + + constructor(db: DatabaseSync, sql: string, bindings: unknown[] = []) { + this._db = db; + this._sql = sql; + this._bindings = bindings; + } + + bind(...values: unknown[]): D1PreparedStatement { + return new MockD1PreparedStatement(this._db, this._sql, values); + } + + async run>(): Promise> { + const stmt = this._db.prepare(this._sql); + stmt.run(...(this._bindings as Parameters)); + return {results: [], success: true, meta: {}}; + } + + async first>(colName?: string): Promise { + const stmt = this._db.prepare(this._sql); + const row = stmt.get(...(this._bindings as Parameters)) as + | Record + | undefined; + if (row == null) return null; + if (colName != null) return (row[colName] ?? null) as T | null; + return row as T; + } + + async all>(): Promise> { + const stmt = this._db.prepare(this._sql); + const results = stmt.all(...(this._bindings as Parameters)) as T[]; + return {results, success: true, meta: {}}; + } + + async raw(options?: {columnNames?: boolean}): Promise { + // Simplified: return rows as arrays without column names unless requested + const result = await this.all>(); + if (!result.results.length) return [] as unknown as T[]; + const cols = Object.keys(result.results[0]); + const rows: unknown[][] = result.results.map((r) => cols.map((c) => r[c])); + if (options?.columnNames) { + return [cols, ...rows] as unknown as T[]; + } + return rows as unknown as T[]; + } +} + +export class MockD1Database implements D1Database { + private readonly _db: DatabaseSync; + + constructor() { + this._db = new DatabaseSync(':memory:'); + } + + prepare(sql: string): D1PreparedStatement { + return new MockD1PreparedStatement(this._db, sql); + } + + async batch(statements: D1PreparedStatement[]): Promise[]> { + return Promise.all(statements.map((s) => s.run())); + } + + async exec(sql: string): Promise<{count: number; duration: number}> { + this._db.exec(sql); + return {count: 0, duration: 0}; + } +}