// Guard against reloads and bundler duplication. type DbMap = Map; // @ts-ignore const map = (globalThis[Symbol.for("clover.db")] as DbMap) ??= new Map(); for (const v of map.values()) { v.node.close(); } map.clear(); export function getDb(file: string) { let db = map.get(file); if (db) return db; const fileWithExt = file.includes(".") ? file : file + ".sqlite"; db = new WrappedDatabase( path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt), ); map.set(file, db); return db; } export class WrappedDatabase { file: string; node: DatabaseSync; stmts: Stmt[]; stmtTableMigrate: WeakRef | null = null; constructor(file: string) { this.file = file; this.node = new DatabaseSync(file); this.node.exec(` create table if not exists clover_migrations ( key text not null primary key, version integer not null ); `); } // TODO: add migration support // the idea is you keep `schema` as the new schema but can add // migrations to the mix really easily. table(name: string, schema: string) { let s = this.stmtTableMigrate?.deref(); s ?? (this.stmtTableMigrate = new WeakRef( s = this.node.prepare(` insert or ignore into clover_migrations (key, version) values (?, ?); `), )); const { changes } = s.run(name, 1); if (changes === 1) this.node.exec(schema); } prepare( query: string, ): Stmt { query = query.trim(); const lines = query.split("\n"); const trim = Math.min( ...lines.map((line) => line.trim().length === 0 ? Infinity : line.match(/^\s*/)![0].length ), ); query = lines.map((x) => x.slice(trim)).join("\n"); let prepared; try { prepared = this.node.prepare(query); } catch (err) { if (err) (err as { query: string }).query = query; throw err; } const stmt = new Stmt(prepared); this.stmts.push(stmt); return stmt; } reload() { const newNode = new DatabaseSync(this.file); this.node.close(); this.node = newNode; for (const stmt of this.stmts) { stmt["reload"](newNode); } } } export class Stmt { #node: StatementSync; #class: any | null = null; query: string; constructor(node: StatementSync) { this.#node = node; this.query = node.sourceSQL; } private reload(db: DatabaseSync) { this.#node = db.prepare(this.query); } /** Get one row */ get(...args: Args): Row | null { return this.#wrap(args, () => { const item = this.#node.get(...args as any) as Row; if (!item) return null; const C = this.#class; if (C) Object.setPrototypeOf(item, C.prototype); return item; }); } getNonNull(...args: Args) { const item = this.get(...args); if (!item) { throw this.#wrap(args, () => new Error("Query returned no result")); } return item; } iter(...args: Args): IterableIterator { return this.#wrap(args, () => this.array(...args)[Symbol.iterator]()); } /** Get all rows */ array(...args: Args): Row[] { return this.#wrap(args, () => { const array = this.#node.all(...args as any) as Row[]; const C = this.#class; if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype)); return array; }); } /** Return the number of changes / row ID */ run(...args: Args) { return this.#wrap(args, () => this.#node.run(...args as any)); } as(Class: { new (): R }): Stmt { this.#class = Class; return this as any; } #wrap(args: unknown[], fn: () => T) { try { return fn(); } catch (err: any) { if (err && typeof err === "object") { err.query = this.query; args = args.flat(Infinity); err.queryArgs = args.length === 1 ? args[0] : args; } throw err; } } } import { DatabaseSync, StatementSync } from "node:sqlite"; import * as path from "node:path";