1// Guard against reloads and bundler duplication.
2type DbMap = Map<string, WrappedDatabase>;
3// @ts-ignore
4const map = (globalThis[Symbol.for("clover.db")] as DbMap) ??= new Map();
5for (const v of map.values()) {
6 v.node.close();
7}
8map.clear();
9
10export 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
21export 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
88export 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
169import * as fs from "#sitegen/fs";
170import * as path from "node:path";
171import { DatabaseSync, StatementSync } from "node:sqlite";