import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persistence-core' import type { SQLiteDriver } from '@tanstack/db-sqlite-persistence-core' export type ExpoSQLiteBindParams = | ReadonlyArray | Record export type ExpoSQLiteRunResult = { changes: number lastInsertRowId: number } export type ExpoSQLiteQueryable = { execAsync: (sql: string) => Promise getAllAsync: ( sql: string, params?: ExpoSQLiteBindParams, ) => Promise> runAsync: ( sql: string, params?: ExpoSQLiteBindParams, ) => Promise } export type ExpoSQLiteTransaction = ExpoSQLiteQueryable export type ExpoSQLiteDatabaseLike = ExpoSQLiteQueryable & { withExclusiveTransactionAsync: ( task: (transaction: ExpoSQLiteTransaction) => Promise, ) => Promise closeAsync?: () => Promise } type ExpoSQLiteExistingDatabaseOptions = { database: ExpoSQLiteDatabaseLike } type ExpoSQLiteOpenDatabaseOptions = { openDatabase: () => Promise | ExpoSQLiteDatabaseLike } export type ExpoSQLiteDriverOptions = | ExpoSQLiteExistingDatabaseOptions | ExpoSQLiteOpenDatabaseOptions function hasExistingDatabase( options: ExpoSQLiteDriverOptions, ): options is ExpoSQLiteExistingDatabaseOptions { return `database` in options } function assertTransactionCallbackHasDriverArg( fn: (transactionDriver: SQLiteDriver) => Promise, ): void { if (fn.length > 0) { return } throw new InvalidPersistedCollectionConfigError( `SQLiteDriver.transaction callback must accept the transaction driver argument`, ) } function isExpoSQLiteDatabaseLike( value: unknown, ): value is ExpoSQLiteDatabaseLike { return ( typeof value === `object` && value !== null && typeof (value as ExpoSQLiteDatabaseLike).execAsync === `function` && typeof (value as ExpoSQLiteDatabaseLike).getAllAsync === `function` && typeof (value as ExpoSQLiteDatabaseLike).runAsync === `function` && typeof (value as ExpoSQLiteDatabaseLike).withExclusiveTransactionAsync === `function` ) } export class ExpoSQLiteDriver implements SQLiteDriver { private readonly databasePromise: Promise private readonly ownsDatabase: boolean private queue: Promise = Promise.resolve() private nextSavepointId = 1 constructor(options: ExpoSQLiteDriverOptions) { if (hasExistingDatabase(options)) { if (!isExpoSQLiteDatabaseLike(options.database)) { throw new InvalidPersistedCollectionConfigError( `Expo SQLite database must provide execAsync/getAllAsync/runAsync/withExclusiveTransactionAsync`, ) } this.databasePromise = Promise.resolve(options.database) this.ownsDatabase = false return } this.databasePromise = Promise.resolve(options.openDatabase()).then( (database) => { if (!isExpoSQLiteDatabaseLike(database)) { throw new InvalidPersistedCollectionConfigError( `Expo SQLite openDatabase() must resolve a database with execAsync/getAllAsync/runAsync/withExclusiveTransactionAsync`, ) } return database }, ) this.ownsDatabase = true } async exec(sql: string): Promise { await this.enqueue(async () => { const database = await this.getDatabase() await database.execAsync(sql) }) } async query( sql: string, params: ReadonlyArray = [], ): Promise> { return this.enqueue(async () => { const database = await this.getDatabase() return database.getAllAsync(sql, normalizeParams(params)) }) } async run(sql: string, params: ReadonlyArray = []): Promise { await this.enqueue(async () => { const database = await this.getDatabase() await database.runAsync(sql, normalizeParams(params)) }) } async transaction( fn: (transactionDriver: SQLiteDriver) => Promise, ): Promise { assertTransactionCallbackHasDriverArg(fn) return this.transactionWithDriver(fn) } async transactionWithDriver( fn: (transactionDriver: SQLiteDriver) => Promise, ): Promise { return this.enqueue(async () => { const database = await this.getDatabase() return database.withExclusiveTransactionAsync(async (transaction) => { const transactionDriver = this.createTransactionDriver(transaction) return fn(transactionDriver) }) }) } async close(): Promise { const database = await this.getDatabase() if (!this.ownsDatabase || typeof database.closeAsync !== `function`) { return } await database.closeAsync() } async getDatabase(): Promise { return this.databasePromise } private enqueue(operation: () => Promise): Promise { const queuedOperation = this.queue.then(operation, operation) this.queue = queuedOperation.then( () => undefined, () => undefined, ) return queuedOperation } private createTransactionDriver( transaction: ExpoSQLiteTransaction, ): SQLiteDriver { const transactionDriver: SQLiteDriver = { exec: async (sql) => { await transaction.execAsync(sql) }, query: async ( sql: string, params: ReadonlyArray = [], ): Promise> => { return transaction.getAllAsync(sql, normalizeParams(params)) }, run: async (sql: string, params: ReadonlyArray = []) => { await transaction.runAsync(sql, normalizeParams(params)) }, transaction: async ( fn: (nestedTransactionDriver: SQLiteDriver) => Promise, ): Promise => { assertTransactionCallbackHasDriverArg(fn) return this.runNestedTransaction(transaction, transactionDriver, fn) }, transactionWithDriver: async ( fn: (nestedTransactionDriver: SQLiteDriver) => Promise, ): Promise => this.runNestedTransaction(transaction, transactionDriver, fn), } return transactionDriver } private async runNestedTransaction( transaction: ExpoSQLiteTransaction, transactionDriver: SQLiteDriver, fn: (transactionDriver: SQLiteDriver) => Promise, ): Promise { const savepointName = `tsdb_sp_${this.nextSavepointId}` this.nextSavepointId++ await transaction.execAsync(`SAVEPOINT ${savepointName}`) try { const result = await fn(transactionDriver) await transaction.execAsync(`RELEASE SAVEPOINT ${savepointName}`) return result } catch (error) { await transaction.execAsync(`ROLLBACK TO SAVEPOINT ${savepointName}`) await transaction.execAsync(`RELEASE SAVEPOINT ${savepointName}`) throw error } } } function normalizeParams( params: ReadonlyArray, ): ExpoSQLiteBindParams | undefined { return params.length > 0 ? [...params] : undefined } export function createExpoSQLiteDriver( options: ExpoSQLiteDriverOptions, ): ExpoSQLiteDriver { return new ExpoSQLiteDriver(options) }