authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-11 23:04:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
logbc5017b0e58626345037c3bd2e32e6a2a1b576b4
tree9e2a50c394352f533ee2dc8f3790836675a700ab
parent98be3e2bd40a40c6595de522d087092df63cdbb7
signature Commit is signed but in an unrecognized format.

feat: add sqlite database reloading

this is used by scan3 to notify active servers that there is new data to be aware of.

3 files changed, 66 insertions(+), 14 deletions(-)

framework/lib/sqlite.ts+29-11
...@@ -1,29 +1,32 @@...@@ -1,29 +1,32 @@
1// Guard against reloads and bundler duplication.1// Guard against reloads and bundler duplication.
2type DbMap = Map<string, WrappedDatabase>;
2// @ts-ignore3// @ts-ignore
3const map = globalThis[Symbol.for("clover.db")] ??= new Map<4const map = (globalThis[Symbol.for("clover.db")] as DbMap) ??= new Map();
4 string,5for (const v of map.values()) {
5 WrappedDatabase6 v.node.close();
6>();7}
8map.clear();
79
8export function getDb(file: string) {10export function getDb(file: string) {
9 let db: WrappedDatabase | null = map.get(file);11 let db = map.get(file);
10 if (db) return db;12 if (db) return db;
11 const fileWithExt = file.includes(".") ? file : file + ".sqlite";13 const fileWithExt = file.includes(".") ? file : file + ".sqlite";
12 db = new WrappedDatabase(14 db = new WrappedDatabase(
13 new DatabaseSync(15 path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt),
14 path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt),
15 ),
16 );16 );
17 map.set(file, db);17 map.set(file, db);
18 return db;18 return db;
19}19}
2020
21export class WrappedDatabase {21export class WrappedDatabase {
22 file: string;
22 node: DatabaseSync;23 node: DatabaseSync;
24 stmts: Stmt[];
23 stmtTableMigrate: WeakRef<StatementSync> | null = null;25 stmtTableMigrate: WeakRef<StatementSync> | null = null;
2426
25 constructor(node: DatabaseSync) {27 constructor(file: string) {
26 this.node = node;28 this.file = file;
29 this.node = new DatabaseSync(file);
27 this.node.exec(`30 this.node.exec(`
28 create table if not exists clover_migrations (31 create table if not exists clover_migrations (
29 key text not null primary key,32 key text not null primary key,
...@@ -66,7 +69,18 @@ export class WrappedDatabase {...@@ -66,7 +69,18 @@ export class WrappedDatabase {
66 if (err) (err as { query: string }).query = query;69 if (err) (err as { query: string }).query = query;
67 throw err;70 throw err;
68 }71 }
69 return new Stmt(prepared);72 const stmt = new Stmt<Args, Result>(prepared);
73 this.stmts.push(stmt);
74 return stmt;
75 }
76
77 reload() {
78 const newNode = new DatabaseSync(this.file);
79 this.node.close();
80 this.node = newNode;
81 for (const stmt of this.stmts) {
82 stmt["reload"](newNode);
83 }
70 }84 }
71}85}
7286
...@@ -80,6 +94,10 @@ export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {...@@ -80,6 +94,10 @@ export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {
80 this.query = node.sourceSQL;94 this.query = node.sourceSQL;
81 }95 }
8296
97 private reload(db: DatabaseSync) {
98 this.#node = db.prepare(this.query);
99 }
100
83 /** Get one row */101 /** Get one row */
84 get(...args: Args): Row | null {102 get(...args: Args): Row | null {
85 return this.#wrap(args, () => {103 return this.#wrap(args, () => {
src/file-viewer/bin/scan3.ts+26-1
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12// quality cannot be written with AI.12// quality cannot be written with AI.
13const root = path.resolve("/Volumes/clover/Published");13const root = path.resolve("/Volumes/clover/Published");
14const workDir = path.resolve(".clover/derived");14const workDir = path.resolve(".clover/derived");
15const sotToken = UNWRAP(process.env.CLOVER_SOT_KEY);
1516
16export async function main() {17export async function main() {
17 const start = performance.now();18 const start = performance.now();
...@@ -337,13 +338,37 @@ export async function main() {...@@ -337,13 +338,37 @@ export async function main() {
337 console.info("No new derived assets");338 console.info("No new derived assets");
338 }339 }
339340
341 MediaFile.db.prepare("VACUUM").run();
342 MediaFile.db.reload();
343 // TODO: reload prod web instance
344 await rsync.spawn({
345 args: [
346 MediaFile.db.file,
347 "clo@zenith:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite",
348 ],
349 title: "Uploading Database",
350 cwd: process.cwd(),
351 });
352 {
353 const res = await fetch("https://db.paperclover.net/reload", {
354 method: "post",
355 headers: {
356 Authorization: sotToken,
357 },
358 });
359 if (!res.ok) {
360 console.warn(
361 `Failed to reload remote database ${res.status} ${res.statusText}`,
362 );
363 }
364 }
365
340 console.info(366 console.info(
341 "Updated file viewer index in \x1b[1m" +367 "Updated file viewer index in \x1b[1m" +
342 ((performance.now() - start) / 1000).toFixed(1) +368 ((performance.now() - start) / 1000).toFixed(1) +
343 "s\x1b[0m",369 "s\x1b[0m",
344 );370 );
345371
346 MediaFile.db.prepare("VACUUM").run();
347 const { duration, count } = MediaFile.db372 const { duration, count } = MediaFile.db
348 .prepare<[], { count: number; duration: number }>(373 .prepare<[], { count: number; duration: number }>(
349 `374 `
src/source-of-truth.ts+11-2
...@@ -20,7 +20,7 @@...@@ -20,7 +20,7 @@
20const app = new Hono();20const app = new Hono();
21export default app;21export default app;
2222
23const token = process.env.CLOVER_SOT_KEY;23const token = UNWRAP(process.env.CLOVER_SOT_KEY);
2424
25const nasRoot = process.platform === "win32"25const nasRoot = process.platform === "win32"
26 ? "\\\\zenith\\clover"26 ? "\\\\zenith\\clover"
...@@ -48,12 +48,13 @@ const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();...@@ -48,12 +48,13 @@ const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();
48app.get("/file/*", async (c) => {48app.get("/file/*", async (c) => {
49 const fullQuery = c.req.path.slice("/file".length);49 const fullQuery = c.req.path.slice("/file".length);
50 const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/");50 const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/");
51 ASSERT(filePath != null);
51 if (invalid.length > 0) return c.notFound();52 if (invalid.length > 0) return c.notFound();
52 if (filePath.length <= 1) return c.notFound();53 if (filePath.length <= 1) return c.notFound();
53 const permissions = FilePermissions.getByPrefix(filePath);54 const permissions = FilePermissions.getByPrefix(filePath);
54 if (permissions !== 0) {55 if (permissions !== 0) {
55 if (c.req.header("Authorization") !== token) {56 if (c.req.header("Authorization") !== token) {
56 return c.json({ error: "invalid authorization header" });57 return c.json({ error: "invalid authorization header" }, 401);
57 }58 }
58 }59 }
59 const file = MediaFile.getByPath(filePath);60 const file = MediaFile.getByPath(filePath);
...@@ -104,6 +105,14 @@ app.get("/file/*", async (c) => {...@@ -104,6 +105,14 @@ app.get("/file/*", async (c) => {
104 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);105 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);
105});106});
106107
108app.post("/reload", async (c) => {
109 if (c.req.header("Authorization") !== token) {
110 return c.json({ error: "invalid authorization header" }, 401);
111 }
112 MediaFile.db.reload();
113 return c.body(null, 204);
114});
115
107const openFile = util.promisify(fsCallbacks.open);116const openFile = util.promisify(fsCallbacks.open);
108const closeFile = util.promisify(fsCallbacks.close);117const closeFile = util.promisify(fsCallbacks.close);
109118