UNPKG

sqlocal

Version:

SQLocal makes it easy to run SQLite3 in the browser, backed by the origin private file system.

286 lines (241 loc) 6.94 kB
import type { DataChange, DriverConfig, DriverStatement, RawResultData, Sqlite3, Sqlite3Db, Sqlite3InitModule, Sqlite3StorageType, SQLocalDriver, UserFunction, } from '../types.js'; import { normalizeDatabaseFile } from '../lib/normalize-database-file.js'; import type { PreparedStatement } from '@sqlite.org/sqlite-wasm'; /** * A SQLocal driver that implements the interface needed for * interacting with SQLite databases in memory. */ export class SQLiteMemoryDriver implements SQLocalDriver { protected sqlite3InitModule?: Sqlite3InitModule; protected sqlite3?: Sqlite3; protected db?: Sqlite3Db; protected config?: DriverConfig; protected pointers: number[] = []; protected writeCallbacks: Set<(change: DataChange) => void> = new Set(); readonly storageType: Sqlite3StorageType = 'memory'; constructor(sqlite3InitModule?: Sqlite3InitModule) { this.sqlite3InitModule = sqlite3InitModule; } async init(config: DriverConfig): Promise<void> { const { databasePath } = config; const flags = this.getFlags(config); if (!this.sqlite3InitModule) { const { default: sqlite3InitModule } = await import('@sqlite.org/sqlite-wasm'); this.sqlite3InitModule = sqlite3InitModule; } if (!this.sqlite3) { this.sqlite3 = await this.sqlite3InitModule(); } if (this.db) { await this.destroy(); } this.db = new this.sqlite3.oo1.DB(databasePath, flags); this.config = config; this.initWriteHook(); } onWrite(callback: (change: DataChange) => void): () => void { this.writeCallbacks.add(callback); return () => { this.writeCallbacks.delete(callback); }; } async exec(statement: DriverStatement): Promise<RawResultData> { if (!this.db) throw new Error('Driver not initialized'); return this.execOnDb(this.db, statement); } async execBatch( statements: DriverStatement[], method: 'transaction' | 'savepoint' = 'transaction' ): Promise<RawResultData[]> { if (!this.db) throw new Error('Driver not initialized'); const results: RawResultData[] = []; this.db[method]((tx) => { const prepared = new Map<string, PreparedStatement>(); try { for (let statement of statements) { let stmt = prepared.get(statement.sql); if (!stmt) { const newStmt = tx.prepare(statement.sql); prepared.set(statement.sql, newStmt); stmt = newStmt; } if (statement.params?.length) { stmt.bind(statement.params); } let columns: string[] = []; let rows: unknown[][] = []; while (stmt.step()) { columns = stmt.getColumnNames([]); rows.push(stmt.get([])); } results.push({ columns, rows }); stmt.reset(); } } finally { prepared.forEach((stmt) => { stmt.finalize(); }); } }); return results; } async isDatabasePersisted(): Promise<boolean> { return false; } async getDatabaseSizeBytes(): Promise<number> { const sizeResult = await this.exec({ sql: `SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()`, method: 'get', }); const size = sizeResult?.rows?.[0]; if (typeof size !== 'number') { throw new Error('Failed to query database size'); } return size; } async createFunction(fn: UserFunction): Promise<void> { if (!this.db) throw new Error('Driver not initialized'); switch (fn.type) { case 'callback': case 'scalar': this.db.createFunction({ name: fn.name, xFunc: (_: number, ...args: any[]) => fn.func(...args), arity: -1, }); break; case 'aggregate': this.db.createFunction({ name: fn.name, xStep: (_: number, ...args: any[]) => fn.func.step(...args), xFinal: (_: number, ...args: any[]) => fn.func.final(...args), arity: -1, }); break; case 'window': this.db.createFunction({ name: fn.name, xStep: (_: number, ...args: any[]) => fn.func.step(...args), xValue: (_: number, ...args: any[]) => fn.func.value(...args), xInverse: (_: number, ...args: any[]) => fn.func.inverse(...args), xFinal: (_: number, ...args: any[]) => fn.func.final(...args), arity: -1, }); break; } } async import( database: | ArrayBuffer | Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array<ArrayBuffer>> ): Promise<void> { if (!this.sqlite3 || !this.db || !this.config) { throw new Error('Driver not initialized'); } const data = await normalizeDatabaseFile(database, 'buffer'); const dataPointer = this.sqlite3.wasm.allocFromTypedArray(data); this.pointers.push(dataPointer); const resultCode = this.sqlite3.capi.sqlite3_deserialize( this.db, 'main', dataPointer, data.byteLength, data.byteLength, this.config.readOnly ? this.sqlite3.capi.SQLITE_DESERIALIZE_READONLY : this.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE ); this.db.checkRc(resultCode); } async export(): Promise<{ name: string; data: ArrayBuffer | Uint8Array<ArrayBuffer>; }> { if (!this.sqlite3 || !this.db) { throw new Error('Driver not initialized'); } return { name: 'database.sqlite3', data: this.sqlite3.capi.sqlite3_js_db_export(this.db), }; } async clear(): Promise<void> {} async destroy(): Promise<void> { this.closeDb(); this.pointers.forEach((pointer) => this.sqlite3?.wasm.dealloc(pointer)); this.pointers = []; this.writeCallbacks.clear(); } protected getFlags(config: DriverConfig): string { const { readOnly, verbose } = config; const parts = [readOnly === true ? 'r' : 'cw', verbose === true ? 't' : '']; return parts.join(''); } protected execOnDb(db: Sqlite3Db, statement: DriverStatement): RawResultData { const changesBefore = db.changes(true, true); const statementData: RawResultData = { rows: [], columns: [], }; const rows = db.exec({ sql: statement.sql, bind: statement.params, returnValue: 'resultRows', rowMode: 'array', columnNames: statementData.columns, }); switch (statement.method) { case 'run': break; case 'get': statementData.rows = rows[0] ?? []; break; case 'all': default: statementData.rows = rows; break; } statementData.numAffectedRows = db.changes(true, true) - changesBefore; return statementData; } protected initWriteHook(): void { if (!this.config?.reactive) return; if (!this.sqlite3 || !this.db) { throw new Error('Driver not initialized'); } const opMap: Record<number, DataChange['operation']> = { [this.sqlite3.capi.SQLITE_INSERT]: 'insert', [this.sqlite3.capi.SQLITE_UPDATE]: 'update', [this.sqlite3.capi.SQLITE_DELETE]: 'delete', }; this.sqlite3.capi.sqlite3_update_hook( this.db, (_ctx, opId, _db, table, rowid) => { this.writeCallbacks.forEach((cb) => { cb({ table, rowid, operation: opMap[opId] }); }); }, 0 ); } protected closeDb(): void { if (this.db) { this.db.close(); this.db = undefined; } } }