| 1 | // Guard against reloads and bundler duplication. |
| 2 | type DbMap = Map<string, WrappedDatabase>; |
| 3 | // @ts-ignore |
| 4 | const map = (globalThis[Symbol.for("clover.db")] as DbMap) ??= new Map(); |
| 5 | for (const v of map.values()) { |
| 6 | v.node.close(); |
| 7 | } |
| 8 | map.clear(); |
| 9 | |
| 10 | export function getDb(file: string) { |
| 11 | let db = map.get(file); |
| 12 | if (db) return db; |
| 13 | const fileWithExt = file.includes(".") ? file : file + ".sqlite"; |
| 14 | const dir = process.env.CLOVER_DB ?? ".clover"; |
| 15 | fs.mkdirSync(dir); |
| 16 | db = new WrappedDatabase(path.join(dir, fileWithExt)); |
| 17 | map.set(file, db); |
| 18 | return db; |
| 19 | } |
| 20 | |
| 21 | export class WrappedDatabase { |
| 22 | file: string; |
| 23 | node: DatabaseSync; |
| 24 | stmts: Stmt[] = []; |
| 25 | stmtTableMigrate: WeakRef<StatementSync> | null = null; |
| 26 | |
| 27 | constructor(file: string) { |
| 28 | this.file = file; |
| 29 | this.node = WrappedDatabase.open(file); |
| 30 | this.node.exec(` |
| 31 | create table if not exists clover_migrations ( |
| 32 | key text not null primary key, |
| 33 | version integer not null |
| 34 | ); |
| 35 | `); |
| 36 | } |
| 37 | |
| 38 | // wal so the source of truth can serve reads + db snapshots while the |
| 39 | // indexer writes. foreign keys are per-connection and must be re-applied |
| 40 | // on reload. |
| 41 | private static open(file: string) { |
| 42 | const node = new DatabaseSync(file); |
| 43 | node.exec(`pragma journal_mode = wal;`); |
| 44 | node.exec(`pragma foreign_keys = on;`); |
| 45 | return node; |
| 46 | } |
| 47 | |
| 48 | // TODO: add migration support |
| 49 | // the idea is you keep `schema` as the new schema but can add |
| 50 | // migrations to the mix really easily. |
| 51 | table(name: string, schema: string) { |
| 52 | let s = this.stmtTableMigrate?.deref(); |
| 53 | s ?? (this.stmtTableMigrate = new WeakRef( |
| 54 | s = this.node.prepare(` |
| 55 | insert or ignore into clover_migrations |
| 56 | (key, version) values (?, ?); |
| 57 | `), |
| 58 | )); |
| 59 | const { changes } = s.run(name, 1); |
| 60 | if (changes === 1) this.node.exec(schema); |
| 61 | } |
| 62 | |
| 63 | prepare<Args extends unknown[] = [], Result = unknown>( |
| 64 | query: string, |
| 65 | ): Stmt<Args, Result> { |
| 66 | query = query.trim(); |
| 67 | const lines = query.split("\n"); |
| 68 | const trim = Math.min( |
| 69 | ...lines.map((line) => line.trim().length === 0 ? Infinity : line.match(/^\s*/)![0].length), |
| 70 | ); |
| 71 | query = lines.map((x) => x.slice(trim)).join("\n"); |
| 72 | |
| 73 | const stmt = new Stmt<Args, Result>(this, query); |
| 74 | this.stmts.push(stmt); |
| 75 | return stmt; |
| 76 | } |
| 77 | |
| 78 | reload() { |
| 79 | const newNode = WrappedDatabase.open(this.file); |
| 80 | this.node.close(); |
| 81 | this.node = newNode; |
| 82 | for (const stmt of this.stmts) { |
| 83 | stmt["reload"](newNode); |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | export class Stmt<Args extends unknown[] = unknown[], Row = unknown> { |
| 89 | // statements prepare lazily on first use, so that importing a model |
| 90 | // module never requires its tables to exist yet (the migration scripts |
| 91 | // depend on this, and it makes `reload` cheap). |
| 92 | #db: WrappedDatabase; |
| 93 | #lazyNode: StatementSync | null = null; |
| 94 | #class: any | null = null; |
| 95 | query: string; |
| 96 | |
| 97 | constructor(db: WrappedDatabase, query: string) { |
| 98 | this.#db = db; |
| 99 | this.query = query; |
| 100 | } |
| 101 | |
| 102 | get #node(): StatementSync { |
| 103 | if (this.#lazyNode) return this.#lazyNode; |
| 104 | try { |
| 105 | return this.#lazyNode = this.#db.node.prepare(this.query); |
| 106 | } catch (err) { |
| 107 | if (err) (err as any).query = this.query; |
| 108 | throw err; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | private reload(_db: DatabaseSync) { |
| 113 | this.#lazyNode = null; |
| 114 | } |
| 115 | |
| 116 | /** Get one row */ |
| 117 | get(...args: Args): Row | null { |
| 118 | return this.#wrap(args, () => { |
| 119 | const item = this.#node.get(...args as any) as Row; |
| 120 | if (!item) return null; |
| 121 | const C = this.#class; |
| 122 | if (C) Object.setPrototypeOf(item, C.prototype); |
| 123 | return item; |
| 124 | }); |
| 125 | } |
| 126 | getNonNull(...args: Args) { |
| 127 | const item = this.get(...args); |
| 128 | if (!item) { |
| 129 | throw this.#wrap(args, () => new Error("Query returned no result")); |
| 130 | } |
| 131 | return item; |
| 132 | } |
| 133 | iter(...args: Args): IterableIterator<Row> { |
| 134 | return this.#wrap(args, () => this.array(...args)[Symbol.iterator]()); |
| 135 | } |
| 136 | /** Get all rows */ |
| 137 | array(...args: Args): Row[] { |
| 138 | return this.#wrap(args, () => { |
| 139 | const array = this.#node.all(...args as any) as Row[]; |
| 140 | const C = this.#class; |
| 141 | if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype)); |
| 142 | return array; |
| 143 | }); |
| 144 | } |
| 145 | /** Return the number of changes / row ID */ |
| 146 | run(...args: Args) { |
| 147 | return this.#wrap(args, () => this.#node.run(...args as any)); |
| 148 | } |
| 149 | |
| 150 | as<R>(Class: { new(): R }): Stmt<Args, R> { |
| 151 | this.#class = Class; |
| 152 | return this as any; |
| 153 | } |
| 154 | |
| 155 | #wrap<T>(args: unknown[], fn: () => T) { |
| 156 | try { |
| 157 | return fn(); |
| 158 | } catch (err: any) { |
| 159 | if (err && typeof err === "object") { |
| 160 | err.query = this.query; |
| 161 | args = args.flat(Infinity); |
| 162 | err.queryArgs = args.length === 1 ? args[0] : args; |
| 163 | } |
| 164 | throw err; |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | import * as fs from "#sitegen/fs"; |
| 170 | import * as path from "node:path"; |
| 171 | import { DatabaseSync, StatementSync } from "node:sqlite"; |