You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
243 lines
6.9 KiB
243 lines
6.9 KiB
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, QueryExecResult } 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<QueryExecResult[]>;
|
|
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<void> | null = null;
|
|
private operationQueue: Array<QueuedOperation> = [];
|
|
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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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: QueryExecResult[] = [];
|
|
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<R>(
|
|
type: QueuedOperation["type"],
|
|
sql: string,
|
|
params: unknown[] = [],
|
|
): Promise<R> {
|
|
return new Promise<R>((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<void> {
|
|
// 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<QueryExecResult[]> {
|
|
await this.waitForInitialization();
|
|
return this.queueOperation<QueryExecResult[]>("query", sql, params);
|
|
}
|
|
|
|
async getOneRow(
|
|
sql: string,
|
|
params: unknown[] = [],
|
|
): Promise<unknown[] | undefined> {
|
|
await this.waitForInitialization();
|
|
return this.queueOperation<unknown[] | undefined>("getOneRow", sql, params);
|
|
}
|
|
|
|
async getAll(sql: string, params: unknown[] = []): Promise<unknown[][]> {
|
|
await this.waitForInitialization();
|
|
return this.queueOperation<unknown[][]>("getAll", sql, params);
|
|
}
|
|
}
|
|
|
|
// Create a singleton instance
|
|
const databaseService = AbsurdSqlDatabaseService.getInstance();
|
|
|
|
export default databaseService;
|
|
|