sitegen/framework/lib/sqlite.ts

135 lines
3.6 KiB
TypeScript

// Guard against reloads and bundler duplication.
// @ts-ignore
const map = globalThis[Symbol.for("clover.db")] ??= new Map<
string,
WrappedDatabase
>();
export function getDb(file: string) {
let db: WrappedDatabase | null = map.get(file);
if (db) return db;
const fileWithExt = file.includes(".") ? file : file + ".sqlite";
db = new WrappedDatabase(
new DatabaseSync(path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt)),
);
map.set(file, db);
return db;
}
export class WrappedDatabase {
node: DatabaseSync;
stmtTableMigrate: WeakRef<StatementSync> | null = null;
constructor(node: DatabaseSync) {
this.node = node;
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<Args extends unknown[] = [], Result = unknown>(
query: string,
): Stmt<Args, Result> {
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;
}
return new Stmt(prepared);
}
}
export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {
#node: StatementSync;
#class: any | null = null;
query: string;
constructor(node: StatementSync) {
this.#node = node;
this.query = node.sourceSQL;
}
/** 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<Row> {
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<R>(Class: { new (): R }): Stmt<Args, R> {
this.#class = Class;
return this as any;
}
#wrap<T>(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";