import { ConcurrentLockType, ContextLockID, ISQLite, LockContext, LockOptions, OpenOptions, QueryResult, QuickSQLiteConnection, SQLBatchTuple, TransactionContext, UpdateCallback } from './types'; import { DBListenerManagerInternal } from './DBListenerManager'; import { LockHooks } from './lock-hooks'; import { enhanceQueryResult } from './utils'; type LockCallbackRecord = { callback: (context: LockContext) => Promise; timeout?: NodeJS.Timeout; }; enum TransactionFinalizer { COMMIT = 'commit', ROLLBACK = 'rollback' } const DEFAULT_READ_CONNECTIONS = 4; // A incrementing integer ID for tracking lock requests let requestIdCounter = 1; const getRequestId = () => { requestIdCounter++; return `${requestIdCounter}`; }; const LockCallbacks: Record = {}; let proxy: ISQLite; /** * Closes the context in JS and C++ */ function closeContextLock(dbName: string, id: ContextLockID) { delete LockCallbacks[id]; // This is configured by the setupOpen function proxy.releaseLock(dbName, id); } /** * JS callback to trigger queued callbacks when a lock context is available. * Declared on the global scope so that C++ can call it. * @param lockId * @returns */ global.onLockContextIsAvailable = async (dbName: string, lockId: ContextLockID) => { // Don't hold C++ bridge side up waiting to complete setImmediate(async () => { try { const record = LockCallbacks[lockId]; // clear record after fetching, the hash should only contain pending requests delete LockCallbacks[lockId]; if (record?.timeout) { clearTimeout(record.timeout); } await record?.callback({ // @ts-expect-error This is not part of the public interface, but is used internally _contextId: lockId, execute: async (sql: string, args?: any[]) => { const result = await proxy.executeInContext(dbName, lockId, sql, args); enhanceQueryResult(result); return result; } }); } catch (ex) { console.error(ex); } }); }; /** * Generates the entry point for opening concurrent connections * @param proxy * @returns */ export function setupOpen(QuickSQLite: ISQLite) { // Allow the Global callbacks to close lock contexts proxy = QuickSQLite; return { /** * Opens a SQLite DB connection. * By default opens DB in WAL mode with 4 Read connections and a single * write connection */ open: (dbName: string, options: OpenOptions = {}): QuickSQLiteConnection => { // Opens the connection QuickSQLite.open(dbName, { ...options, numReadConnections: options?.numReadConnections ?? DEFAULT_READ_CONNECTIONS }); const listenerManager = new DBListenerManagerInternal({ dbName }); /** * Wraps lock requests and their callbacks in order to resolve the lock * request with the callback result once triggered from the connection pool. */ const requestLock = ( type: ConcurrentLockType, callback: (context: LockContext) => Promise, options?: LockOptions, hooks?: LockHooks ): Promise => { const id = getRequestId(); // Wrap the callback in a promise that will resolve to the callback result return new Promise((resolve, reject) => { // Add callback to the queue for timing const closedListener = listenerManager.registerListener({ closed: () => { closedListener?.(); // Remove callback from the queue delete LockCallbacks[id]; // Reject the lock request if the connection is closed reject(new Error('Connection is closed')); } }); const record = (LockCallbacks[id] = { callback: async (context: LockContext) => { try { // Remove the close listener closedListener?.(); await hooks?.lockAcquired?.(); const res = await callback(context); closeContextLock(dbName, id); resolve(res); } catch (ex) { closeContextLock(dbName, id); reject(ex); } finally { hooks?.lockReleased?.(); } } } as LockCallbackRecord); try { // throws if lock could not be requested QuickSQLite.requestLock(dbName, id, type); const timeout = options?.timeoutMs; if (timeout) { record.timeout = setTimeout(() => { // The callback won't be executed delete LockCallbacks[id]; reject(new Error(`Lock request timed out after ${timeout}ms`)); }, timeout); } } catch (ex) { closedListener?.(); // Remove callback from the queue delete LockCallbacks[id]; reject(ex); } }); }; const readLock = (callback: (context: LockContext) => Promise, options?: LockOptions): Promise => requestLock(ConcurrentLockType.READ, callback, options); const writeLock = (callback: (context: LockContext) => Promise, options?: LockOptions): Promise => requestLock(ConcurrentLockType.WRITE, callback, options, { lockReleased: async () => { // flush updates once a write lock has been released listenerManager.flushUpdates(); } }); const wrapTransaction = async ( context: LockContext, callback: (context: TransactionContext) => Promise, defaultFinalizer: TransactionFinalizer = TransactionFinalizer.COMMIT ) => { await context.execute('BEGIN TRANSACTION'); let finalized = false; const finalizedStatement = (action: () => T): (() => T) => () => { if (finalized) { return; } finalized = true; return action(); }; const commit = finalizedStatement(async () => context.execute('COMMIT')); const rollback = finalizedStatement(async () => context.execute('ROLLBACK')); const wrapExecute = ( method: (sql: string, params?: any[]) => Promise ): ((sql: string, params?: any[]) => Promise) => async (sql: string, params?: any[]) => { if (finalized) { throw new Error(`Cannot execute in transaction after it has been finalized with commit/rollback.`); } return method(sql, params); }; try { const res = await callback({ ...context, commit, rollback, execute: wrapExecute(context.execute) }); switch (defaultFinalizer) { case TransactionFinalizer.COMMIT: await commit(); break; case TransactionFinalizer.ROLLBACK: await rollback(); break; } return res; } catch (ex) { try { await rollback(); } catch (ex2) { // In rare cases, a rollback may fail. // Safe to ignore. } throw ex; } }; // Return the concurrent connection object return { close: () => { QuickSQLite.close(dbName); // Close any pending listeners listenerManager.iterateListeners((l) => l.closed?.()); }, refreshSchema: () => QuickSQLite.refreshSchema(dbName), execute: (sql: string, args?: any[]) => writeLock((context) => context.execute(sql, args)), readLock, readTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => readLock((context) => wrapTransaction(context, callback)), writeLock, writeTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => writeLock((context) => wrapTransaction(context, callback, TransactionFinalizer.COMMIT), options), delete: () => QuickSQLite.delete(dbName, options?.location), executeBatch: (commands: SQLBatchTuple[]) => writeLock((context) => QuickSQLite.executeBatch(dbName, commands, (context as any)._contextId)), attach: (dbNameToAttach: string, alias: string, location?: string) => QuickSQLite.attach(dbName, dbNameToAttach, alias, location), detach: (alias: string) => QuickSQLite.detach(dbName, alias), loadFile: (location: string) => writeLock((context) => QuickSQLite.loadFile(dbName, location, (context as any)._contextId)), listenerManager, registerUpdateHook: (callback: UpdateCallback) => listenerManager.registerListener({ rawTableChange: callback }), registerTablesChangedHook: (callback) => listenerManager.registerListener({ tablesUpdated: callback }) }; } }; }