Spaces:
Running
Running
/** | |
* Async worker thread wrapper around SQLite, written to improve concurrent performance. | |
* @author mia-pi-git | |
*/ | |
import { QueryProcessManager } from './process-manager'; | |
import type * as sqlite from 'better-sqlite3'; | |
import { FS } from './fs'; | |
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
// @ts-ignore in case not installed | |
import type { SQLStatement } from 'sql-template-strings'; | |
export const DB_NOT_FOUND = null; | |
export interface SQLOptions { | |
file: string; | |
/** file to import database functions from - this should be relative to this filename. */ | |
extension?: string; | |
/** options to be passed to better-sqlite3 */ | |
sqliteOptions?: sqlite.Options; | |
/** | |
* You can choose to return custom error information, or just crashlog and return void. | |
* doing that will make it reject in main as normal. | |
* (it only returns a diff result if you do, otherwise it's a default error). | |
* You can also choose to only handle errors in the parent - see the third param, isParentProcess. | |
*/ | |
onError?: ErrorHandler; | |
} | |
type DataType = unknown[] | Record<string, unknown>; | |
export type SQLInput = string | number | null; | |
export interface ResultRow { [k: string]: SQLInput } | |
export interface TransactionEnvironment { | |
db: sqlite.Database; | |
statements: Map<string, sqlite.Statement>; | |
} | |
export type DatabaseQuery = { | |
/** Prepare a statement - data is the statement. */ | |
type: 'prepare', data: string, | |
} | { | |
/** Get all lines from a statement. Data is the params. */ | |
type: 'all', data: DataType, statement: string, noPrepare?: boolean, | |
} | { | |
/** Execute raw SQL in the database. */ | |
type: "exec", data: string, | |
} | { | |
/** Get one line from a prepared statement. */ | |
type: 'get', data: DataType, statement: string, noPrepare?: boolean, | |
} | { | |
/** Run a prepared statement. */ | |
type: 'run', data: DataType, statement: string, noPrepare?: boolean, | |
} | { | |
type: 'transaction', name: string, data: DataType, | |
} | { | |
type: 'start', options: SQLOptions, | |
} | { | |
type: 'load-extension', data: string, | |
}; | |
type ErrorHandler = (error: Error, data: DatabaseQuery, isParentProcess: boolean) => any; | |
function getModule() { | |
try { | |
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
// @ts-ignore does not exist while building NPM package??? | |
return require('better-sqlite3') as typeof sqlite.default; | |
} catch { | |
return null; | |
} | |
} | |
export class Statement<R extends DataType = DataType, T = any> { | |
private db: SQLDatabaseManager; | |
private statement: string; | |
constructor(statement: string, db: SQLDatabaseManager) { | |
this.db = db; | |
this.statement = statement; | |
} | |
run(data: R) { | |
return this.db.run(this.statement, data); | |
} | |
all(data: R) { | |
return this.db.all<T>(this.statement, data); | |
} | |
get(data: R) { | |
return this.db.get<T>(this.statement, data); | |
} | |
toString() { | |
return this.statement; | |
} | |
toJSON() { | |
return this.statement; | |
} | |
} | |
export class SQLDatabaseManager extends QueryProcessManager<DatabaseQuery, any> { | |
options: SQLOptions; | |
database: null | sqlite.Database = null; | |
state: { | |
transactions: Map<string, sqlite.Transaction>, | |
statements: Map<string, sqlite.Statement>, | |
}; | |
private dbReady = false; | |
constructor(module: NodeJS.Module, options: SQLOptions) { | |
super(module, query => { | |
if (!this.dbReady) { | |
this.setupDatabase(); | |
} | |
try { | |
switch (query.type) { | |
case 'load-extension': { | |
if (!this.database) return null; | |
this.loadExtensionFile(query.data); | |
return true; | |
} | |
case 'transaction': { | |
const transaction = this.state.transactions.get(query.name); | |
// !transaction covers db not existing, typically, but this is just to appease ts | |
if (!transaction || !this.database) { | |
return null; | |
} | |
const env: TransactionEnvironment = { | |
db: this.database, | |
statements: this.state.statements, | |
}; | |
return transaction(query.data, env) || null; | |
} | |
case 'exec': { | |
if (!this.database) return { changes: 0 }; | |
this.database.exec(query.data); | |
return true; | |
} | |
case 'get': { | |
if (!this.database) { | |
return null; | |
} | |
return this.extractStatement(query).get(query.data); | |
} | |
case 'run': { | |
if (!this.database) { | |
return null; | |
} | |
return this.extractStatement(query).run(query.data); | |
} | |
case 'all': { | |
if (!this.database) { | |
return null; | |
} | |
return this.extractStatement(query).all(query.data); | |
} | |
case 'prepare': | |
if (!this.database) { | |
return null; | |
} | |
this.state.statements.set(query.data, this.database.prepare(query.data)); | |
return query.data; | |
} | |
} catch (error: any) { | |
return this.onError(error, query); | |
} | |
}); | |
this.options = options; | |
this.state = { | |
transactions: new Map(), | |
statements: new Map(), | |
}; | |
if (!this.isParentProcess) this.setupDatabase(); | |
} | |
private onError(err: Error, query: DatabaseQuery) { | |
if (this.options.onError) { | |
const result = this.options.onError(err, query, false); | |
if (result) return result; | |
} | |
return { | |
queryError: { | |
stack: err.stack, | |
message: err.message, | |
query, | |
}, | |
}; | |
} | |
private cacheStatement(source: string) { | |
source = source.trim(); | |
let statement = this.state.statements.get(source); | |
if (!statement) { | |
statement = this.database!.prepare(source); | |
this.state.statements.set(source, statement); | |
} | |
return statement; | |
} | |
registerFunction(key: string, cb: (...args: any) => any) { | |
this.database!.function(key, cb); | |
} | |
private extractStatement( | |
query: DatabaseQuery & { statement: string, noPrepare?: boolean } | |
) { | |
query.statement = query.statement.trim(); | |
const statement = query.noPrepare ? | |
this.state.statements.get(query.statement) : | |
this.cacheStatement(query.statement); | |
if (!statement) throw new Error(`Missing cached statement "${query.statement}" where required`); | |
return statement; | |
} | |
setupDatabase() { | |
if (this.dbReady) return; | |
this.dbReady = true; | |
const { file, extension } = this.options; | |
const Database = getModule(); | |
this.database = Database ? new Database(file) : null; | |
if (extension) this.loadExtensionFile(extension); | |
} | |
loadExtensionFile(extension: string) { | |
return this.handleExtensions(require('../' + extension)); | |
} | |
handleExtensions(imports: any) { | |
if (!this.database) return; | |
const { | |
functions, | |
transactions: storedTransactions, | |
statements: storedStatements, | |
onDatabaseStart, | |
} = imports; | |
// migrations usually are run here, so this needs to be first | |
if (onDatabaseStart) { | |
onDatabaseStart.call(this, this.database); | |
} | |
if (functions) { | |
for (const k in functions) { | |
this.registerFunction(k, functions[k]); | |
} | |
} | |
if (storedTransactions) { | |
for (const t in storedTransactions) { | |
const transaction = this.database.transaction(storedTransactions[t]); | |
this.state.transactions.set(t, transaction); | |
} | |
} | |
if (storedStatements) { | |
for (const k in storedStatements) { | |
const statement = this.database.prepare(storedStatements[k]); | |
this.state.statements.set(statement.source, statement); | |
} | |
} | |
} | |
async query(input: DatabaseQuery) { | |
const result = await super.query(input); | |
if (result?.queryError) { | |
const err = new Error(result.queryError.message); | |
err.stack = result.queryError.stack; | |
if (this.options.onError) { | |
const errResult = this.options.onError(err, result.queryError.query, true); | |
if (errResult) return errResult; | |
} | |
throw err; | |
} | |
return result; | |
} | |
all<T = any>( | |
statement: string | Statement, data: DataType = [], noPrepare?: boolean | |
): Promise<T[]> { | |
if (typeof statement !== 'string') statement = statement.toString(); | |
return this.query({ type: 'all', statement, data, noPrepare }); | |
} | |
get<T = any>( | |
statement: string | Statement, data: DataType = [], noPrepare?: boolean | |
): Promise<T> { | |
if (typeof statement !== 'string') statement = statement.toString(); | |
return this.query({ type: 'get', statement, data, noPrepare }); | |
} | |
run( | |
statement: string | Statement, data: DataType = [], noPrepare?: boolean | |
): Promise<sqlite.RunResult> { | |
if (typeof statement !== 'string') statement = statement.toString(); | |
return this.query({ type: 'run', statement, data, noPrepare }); | |
} | |
transaction<T = any>(name: string, data: DataType = []): Promise<T> { | |
return this.query({ type: 'transaction', name, data }); | |
} | |
async prepare(statement: string): Promise<Statement | null> { | |
const source = await this.query({ type: 'prepare', data: statement }); | |
if (!source) return null; | |
return new Statement(source, this); | |
} | |
exec(data: string): Promise<{ changes: number }> { | |
return this.query({ type: 'exec', data }); | |
} | |
loadExtension(filepath: string) { | |
return this.query({ type: 'load-extension', data: filepath }); | |
} | |
async runFile(file: string) { | |
const contents = await FS(file).read(); | |
return this.query({ type: 'exec', data: contents }); | |
} | |
} | |
export const tables = new Map<string, DatabaseTable<any>>(); | |
export class DatabaseTable<T> { | |
database: SQLDatabaseManager; | |
name: string; | |
primaryKeyName: string; | |
constructor( | |
name: string, | |
primaryKeyName: string, | |
database: SQLDatabaseManager | |
) { | |
this.name = name; | |
this.database = database; | |
this.primaryKeyName = primaryKeyName; | |
tables.set(this.name, this); | |
} | |
async selectOne<R = T>( | |
entries: string | string[], | |
where?: SQLStatement | |
): Promise<R | null> { | |
const query = where || SQL.SQL``; | |
query.append(' LIMIT 1'); | |
const rows = await this.selectAll<R>(entries, query); | |
return rows?.[0] || null; | |
} | |
selectAll<R = T>( | |
entries: string | string[], | |
where?: SQLStatement | |
): Promise<R[]> { | |
const query = SQL.SQL`SELECT `; | |
if (typeof entries === 'string') { | |
query.append(` ${entries} `); | |
} else { | |
for (let i = 0; i < entries.length; i++) { | |
query.append(entries[i]); | |
if (typeof entries[i + 1] !== 'undefined') query.append(', '); | |
} | |
query.append(' '); | |
} | |
query.append(`FROM ${this.name} `); | |
if (where) { | |
query.append(' WHERE '); | |
query.append(where); | |
} | |
return this.all<R>(query); | |
} | |
get(entries: string | string[], keyId: SQLInput) { | |
const query = SQL.SQL``; | |
query.append(this.primaryKeyName); | |
query.append(SQL.SQL` = ${keyId}`); | |
return this.selectOne(entries, query); | |
} | |
updateAll(toParams: Partial<T>, where?: SQLStatement, limit?: number) { | |
const to = Object.entries(toParams); | |
const query = SQL.SQL`UPDATE `; | |
query.append(this.name + ' SET '); | |
for (let i = 0; i < to.length; i++) { | |
const [k, v] = to[i]; | |
query.append(`${k} = `); | |
query.append(SQL.SQL`${v}`); | |
if (typeof to[i + 1] !== 'undefined') { | |
query.append(', '); | |
} | |
} | |
if (where) { | |
query.append(` WHERE `); | |
query.append(where); | |
} | |
if (limit) query.append(SQL.SQL` LIMIT ${limit}`); | |
return this.run(query); | |
} | |
updateOne(to: Partial<T>, where?: SQLStatement) { | |
return this.updateAll(to, where, 1); | |
} | |
deleteAll(where?: SQLStatement, limit?: number) { | |
const query = SQL.SQL`DELETE FROM `; | |
query.append(this.name); | |
if (where) { | |
query.append(' WHERE '); | |
query.append(where); | |
} | |
if (limit) { | |
query.append(SQL.SQL` LIMIT ${limit}`); | |
} | |
return this.run(query); | |
} | |
delete(keyEntry: SQLInput) { | |
const query = SQL.SQL``; | |
query.append(this.primaryKeyName); | |
query.append(SQL.SQL` = ${keyEntry}`); | |
return this.deleteOne(query); | |
} | |
deleteOne(where: SQLStatement) { | |
return this.deleteAll(where, 1); | |
} | |
insert(colMap: Partial<T>, rest?: SQLStatement, isReplace = false) { | |
const query = SQL.SQL``; | |
query.append(`${isReplace ? 'REPLACE' : 'INSERT'} INTO ${this.name} (`); | |
const keys = Object.keys(colMap); | |
for (let i = 0; i < keys.length; i++) { | |
query.append(keys[i]); | |
if (typeof keys[i + 1] !== 'undefined') query.append(', '); | |
} | |
query.append(') VALUES ('); | |
for (let i = 0; i < keys.length; i++) { | |
const key = keys[i]; | |
query.append(SQL.SQL`${colMap[key as keyof T]}`); | |
if (typeof keys[i + 1] !== 'undefined') query.append(', '); | |
} | |
query.append(') '); | |
if (rest) query.append(rest); | |
return this.database.run(query.sql, query.values); | |
} | |
replace(cols: Partial<T>, rest?: SQLStatement) { | |
return this.insert(cols, rest, true); | |
} | |
update(primaryKey: SQLInput, data: Partial<T>) { | |
const query = SQL.SQL``; | |
query.append(this.primaryKeyName + ' = '); | |
query.append(SQL.SQL`${primaryKey}`); | |
return this.updateOne(data, query); | |
} | |
// catch-alls for "we can't fit this query into any of the wrapper functions" | |
run(sql: SQLStatement) { | |
return this.database.run(sql.sql, sql.values) as Promise<{ changes: number }>; | |
} | |
all<R = T>(sql: SQLStatement) { | |
return this.database.all<R>(sql.sql, sql.values); | |
} | |
} | |
function getSQL( | |
module: NodeJS.Module, input: SQLOptions & { processes?: number } | |
) { | |
const { processes } = input; | |
const PM = new SQLDatabaseManager(module, input); | |
if (PM.isParentProcess) { | |
if (processes) PM.spawn(processes); | |
} | |
return PM; | |
} | |
export const SQL = Object.assign(getSQL, { | |
DatabaseTable, | |
SQLDatabaseManager, | |
tables, | |
SQL: (() => { | |
try { | |
return require('sql-template-strings'); | |
} catch { | |
return () => { | |
throw new Error("Using SQL-template-strings without it installed"); | |
}; | |
} | |
})() as typeof import('sql-template-strings').SQL, | |
}); | |
export declare namespace SQL { | |
export type DatabaseManager = import('./sql').SQLDatabaseManager; | |
export type Statement = import('./sql').Statement; | |
export type Options = import('./sql').SQLOptions; | |
// eslint-disable-next-line @typescript-eslint/no-shadow | |
export type TransactionEnvironment = import('./sql').TransactionEnvironment; | |
export type Query = import('./sql').DatabaseQuery; | |
export type DatabaseTable<T> = import('./sql').DatabaseTable<T>; | |
} | |