import initSqlJs from "@jlongster/sql.js"; import { SQLiteFS } from "absurd-sql"; import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend"; import { runMigrations } from "../db-sql/migration"; import type { DatabaseService, QueryExecResultRaw } from "../interfaces/database"; import { logger } from "@/utils/logger"; interface QueuedOperation { type: "run" | "query" | "getOneRow" | "getAll"; sql: string; params: unknown[]; resolve: (value: unknown) => void; reject: (reason: unknown) => void; } interface AbsurdSqlDatabase { exec: (sql: string, params?: unknown[]) => Promise; run: ( sql: string, params?: unknown[], ) => Promise<{ changes: number; lastId?: number }>; } class AbsurdSqlDatabaseService implements DatabaseService { private static instance: AbsurdSqlDatabaseService | null = null; private db: AbsurdSqlDatabase | null; private initialized: boolean; private initializationPromise: Promise | null = null; private operationQueue: Array = []; private isProcessingQueue: boolean = false; private constructor() { this.db = null; this.initialized = false; } static getInstance(): AbsurdSqlDatabaseService { if (!AbsurdSqlDatabaseService.instance) { AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService(); } return AbsurdSqlDatabaseService.instance; } async initialize(): Promise { // If already initialized, return immediately if (this.initialized) { return; } // If initialization is in progress, wait for it if (this.initializationPromise) { return this.initializationPromise; } // Start initialization this.initializationPromise = this._initialize(); try { await this.initializationPromise; } catch (error) { logger.error(`AbsurdSqlDatabaseService initialize method failed:`, error); this.initializationPromise = null; // Reset on failure throw error; } } private async _initialize(): Promise { if (this.initialized) { return; } const SQL = await initSqlJs({ locateFile: (file: string) => { return new URL( `/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url, ).href; }, }); const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend()); SQL.register_for_idb(sqlFS); SQL.FS.mkdir("/sql"); SQL.FS.mount(sqlFS, {}, "/sql"); const path = "/sql/timesafari.absurd-sql"; if (typeof SharedArrayBuffer === "undefined") { const stream = SQL.FS.open(path, "a+"); await stream.node.contents.readIfFallback(); SQL.FS.close(stream); } this.db = new SQL.Database(path, { filename: true }); if (!this.db) { throw new Error( "The database initialization failed. We recommend you restart or reinstall.", ); } // An error is thrown without this pragma: "File has invalid page size. (the first block of a new file must be written first)" await this.db.exec(`PRAGMA journal_mode=MEMORY;`); const sqlExec = this.db.exec.bind(this.db); // Run migrations await runMigrations(sqlExec); this.initialized = true; // Start processing the queue after initialization this.processQueue(); } private async processQueue(): Promise { if (this.isProcessingQueue || !this.initialized || !this.db) { return; } this.isProcessingQueue = true; while (this.operationQueue.length > 0) { const operation = this.operationQueue.shift(); if (!operation) continue; try { let queryResult: QueryExecResultRaw[] = []; let result: unknown; switch (operation.type) { case "run": result = await this.db.run(operation.sql, operation.params); break; case "query": result = await this.db.exec(operation.sql, operation.params); break; case "getOneRow": queryResult = await this.db.exec(operation.sql, operation.params); result = queryResult[0]?.values[0]; break; case "getAll": queryResult = await this.db.exec(operation.sql, operation.params); result = queryResult[0]?.values || []; break; } operation.resolve(result); } catch (error) { logger.error( "Error while processing SQL queue:", error, " ... for sql:", operation.sql, " ... with params:", operation.params, ); operation.reject(error); } } this.isProcessingQueue = false; } private async queueOperation( type: QueuedOperation["type"], sql: string, params: unknown[] = [], ): Promise { return new Promise((resolve, reject) => { const operation: QueuedOperation = { type, sql, params, resolve: (value: unknown) => resolve(value as R), reject, }; this.operationQueue.push(operation); // If we're already initialized, start processing the queue if (this.initialized && this.db) { this.processQueue(); } }); } private async waitForInitialization(): Promise { // If we have an initialization promise, wait for it if (this.initializationPromise) { await this.initializationPromise; return; } // If not initialized and no promise, start initialization if (!this.initialized) { await this.initialize(); return; } // If initialized but no db, something went wrong if (!this.db) { logger.error( `Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`, ); throw new Error( `The database could not be initialized. We recommend you restart or reinstall.`, ); } } // Used for inserts, updates, and deletes async run( sql: string, params: unknown[] = [], ): Promise<{ changes: number; lastId?: number }> { await this.waitForInitialization(); return this.queueOperation<{ changes: number; lastId?: number }>( "run", sql, params, ); } // Note that the resulting array may be empty if there are no results from the query async query(sql: string, params: unknown[] = []): Promise { await this.waitForInitialization(); return this.queueOperation("query", sql, params); } } // Create a singleton instance const databaseService = AbsurdSqlDatabaseService.getInstance(); export default databaseService;