UNPKG

@livestore/sqlite-wasm

Version:

232 lines (205 loc) • 7.31 kB
import type { PersistenceInfo, PreparedBindValues, PreparedStatement, SqliteDb, SqliteDbChangeset, } from '@livestore/common' import { SqliteDbHelper, SqliteError } from '@livestore/common' import * as SqliteConstants from '@livestore/wa-sqlite/src/sqlite-constants.js' import { makeInMemoryDb } from './in-memory-vfs.js' export const makeSqliteDb = < TMetadata extends { dbPointer: number persistenceInfo: PersistenceInfo deleteDb: () => void configureDb: (db: SqliteDb<TMetadata>) => void }, >({ sqlite3, metadata, }: { sqlite3: SQLiteAPI metadata: TMetadata }): SqliteDb<TMetadata> => { const preparedStmts: PreparedStatement[] = [] const { dbPointer } = metadata let isClosed = false const sqliteDb: SqliteDb<TMetadata> = { _tag: 'SqliteDb', metadata, prepare: (queryStr) => { try { const stmts = sqlite3.statements(dbPointer, queryStr.trim(), { unscoped: true }) let isFinalized = false const preparedStmt = { execute: (bindValues, options) => { for (const stmt of stmts) { if (bindValues !== undefined && Object.keys(bindValues).length > 0) { sqlite3.bind_collection(stmt, bindValues as any) } try { sqlite3.step(stmt) } finally { if (options?.onRowsChanged) { options.onRowsChanged(sqlite3.changes(dbPointer)) } sqlite3.reset(stmt) // Reset is needed for next execution } } }, select: <T>(bindValues: PreparedBindValues) => { if (stmts.length !== 1) { throw new SqliteError({ query: { bindValues, sql: queryStr }, code: -1, cause: 'Expected only one statement when using `select`', }) } const stmt = stmts[0]! if (bindValues !== undefined && Object.keys(bindValues).length > 0) { sqlite3.bind_collection(stmt, bindValues as any) } const results: T[] = [] try { // NOTE `column_names` only works for `SELECT` statements, ignoring other statements for now let columns = undefined try { columns = sqlite3.column_names(stmt) // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_e) {} while (sqlite3.step(stmt) === SqliteConstants.SQLITE_ROW) { if (columns !== undefined) { const obj: { [key: string]: any } = {} for (let i = 0; i < columns.length; i++) { obj[columns[i]!] = sqlite3.column(stmt, i) } results.push(obj as unknown as T) } } } catch (e) { throw new SqliteError({ query: { bindValues, sql: queryStr }, code: (e as any).code, cause: e, }) } finally { // reset the cached statement so we can use it again in the future sqlite3.reset(stmt) } return results }, finalize: () => { // Avoid double finalization which leads to a crash if (isFinalized) { return } isFinalized = true for (const stmt of stmts) { sqlite3.finalize(stmt) } }, sql: queryStr, } satisfies PreparedStatement preparedStmts.push(preparedStmt) return preparedStmt } catch (e) { throw new SqliteError({ query: { sql: queryStr, bindValues: {} }, code: (e as any).code, cause: e, }) } }, export: () => sqlite3.serialize(dbPointer, 'main'), execute: SqliteDbHelper.makeExecute((queryStr, bindValues, options) => { const stmt = sqliteDb.prepare(queryStr) stmt.execute(bindValues, options) stmt.finalize() }), select: SqliteDbHelper.makeSelect((queryStr, bindValues) => { const stmt = sqliteDb.prepare(queryStr) const results = stmt.select(bindValues) stmt.finalize() return results as ReadonlyArray<any> }), destroy: () => { sqliteDb.close() metadata.deleteDb() // if (metadata._tag === 'opfs') { // metadata.vfs.resetAccessHandle(metadata.fileName) // } }, close: () => { if (isClosed) { return } for (const stmt of preparedStmts) { stmt.finalize() } sqlite3.close(dbPointer) isClosed = true }, import: (source) => { // https://www.sqlite.org/c3ref/c_deserialize_freeonclose.html // #define SQLITE_DESERIALIZE_FREEONCLOSE 1 /* Call sqlite3_free() on close */ // #define SQLITE_DESERIALIZE_RESIZEABLE 2 /* Resize using sqlite3_realloc64() */ // #define SQLITE_DESERIALIZE_READONLY 4 /* Database is read-only */ const FREE_ON_CLOSE = 1 const RESIZEABLE = 2 // NOTE in case we'll have a future use-case where we need a read-only database, we can reuse this code below // if (readOnly === true) { // sqlite3.deserialize(db, 'main', bytes, bytes.length, bytes.length, FREE_ON_CLOSE | RESIZEABLE) // } else { if (source instanceof Uint8Array) { const tmpDb = makeInMemoryDb(sqlite3) // TODO find a way to do this more efficiently with sqlite to avoid either of the deserialize + backup call // Maybe this can be done via the VFS API sqlite3.deserialize(tmpDb.dbPointer, 'main', source, source.length, source.length, FREE_ON_CLOSE | RESIZEABLE) sqlite3.backup(dbPointer, 'main', tmpDb.dbPointer, 'main') sqlite3.close(tmpDb.dbPointer) } else { sqlite3.backup(dbPointer, 'main', source.metadata.dbPointer, 'main') } metadata.configureDb(sqliteDb) }, session: () => { const sessionPointer = sqlite3.session_create(dbPointer, 'main') sqlite3.session_attach(sessionPointer, null) return { changeset: () => { const res = sqlite3.session_changeset(sessionPointer) return res.changeset ?? undefined }, finish: () => { sqlite3.session_delete(sessionPointer) }, } }, makeChangeset: (data) => { const changeset = { invert: () => { const inverted = sqlite3.changeset_invert(data) return sqliteDb.makeChangeset(inverted) }, apply: () => { try { sqlite3.changeset_apply(dbPointer, data) // @ts-expect-error data should be garbage collected after use // biome-ignore lint/style/noParameterAssign: data = undefined } catch (cause: any) { throw new SqliteError({ code: cause.code ?? -1, cause, note: `Failed calling makeChangeset.apply`, }) } }, } satisfies SqliteDbChangeset return changeset }, } satisfies SqliteDb<TMetadata> metadata.configureDb(sqliteDb) return sqliteDb }