diff --git a/example/src/tests/db.ts b/example/src/tests/db.ts index 6e4e354c..c02a67c8 100644 --- a/example/src/tests/db.ts +++ b/example/src/tests/db.ts @@ -4,24 +4,40 @@ import type { BatchQueryCommand, } from 'react-native-nitro-sqlite' import { open } from 'react-native-nitro-sqlite' +import { + getDatabaseQueue, + type DatabaseQueue, +} from '../../../package/src/DatabaseQueue' const chance = new Chance() -export let testDb: NitroSQLiteConnection | undefined +export const TEST_DB_NAME = 'test' + +export let testDb: NitroSQLiteConnection +export let testDbQueue: DatabaseQueue export function resetTestDb() { try { if (testDb != null) { testDb.close() testDb.delete() } + testDb = open({ - name: 'test', + name: TEST_DB_NAME, }) + testDbQueue = getDatabaseQueue(TEST_DB_NAME) + + testDb.execute('DROP TABLE IF EXISTS User;') + testDb.execute( + 'CREATE TABLE User ( id REAL PRIMARY KEY, name TEXT NOT NULL, age REAL, networth REAL) STRICT;', + ) } catch (e) { console.warn('Error resetting user database', e) } } +const LARGE_DB_NAME = 'large' + // Copyright 2024 Oscar Franco // Taken from "op-sqlite" example project. // Used to demonstrate the performance of NitroSQLite. @@ -34,7 +50,7 @@ export function resetLargeDb() { largeDb.delete() } largeDb = open({ - name: 'large', + name: LARGE_DB_NAME, }) largeDb.execute( diff --git a/example/src/tests/unit/common.ts b/example/src/tests/unit/common.ts index 4d78cba5..c37ccbb8 100644 --- a/example/src/tests/unit/common.ts +++ b/example/src/tests/unit/common.ts @@ -1,35 +1,27 @@ import { Chance } from 'chance' import { - NitroSQLiteConnection, enableSimpleNullHandling, + NitroSQLiteError, } from 'react-native-nitro-sqlite' -import { testDb as testDbInternal, resetTestDb } from '../db' +import { resetTestDb } from '../db' import chai from 'chai' -export function isError(e: unknown): e is Error { - return e instanceof Error +export const TEST_ERROR_CODES = { + EXPECT_NITRO_SQLITE_ERROR: 'Should have thrown a valid NitroSQLiteError', + EXPECT_PROMISE_REJECTION: 'Should have thrown a promise rejection', +} as const + +export const TEST_ERROR_MESSAGE = 'Error from callback' +export const TEST_ERROR = new Error(TEST_ERROR_MESSAGE) + +export function isNitroSQLiteError(e: unknown): e is NitroSQLiteError { + return e instanceof NitroSQLiteError } export const expect = chai.expect export const chance = new Chance() -export let testDb: NitroSQLiteConnection - export function setupTestDb() { enableSimpleNullHandling(false) - - try { - resetTestDb() - - if (testDbInternal == null) throw new Error('Failed to reset test database') - - testDbInternal.execute('DROP TABLE IF EXISTS User;') - testDbInternal.execute( - 'CREATE TABLE User ( id REAL PRIMARY KEY, name TEXT NOT NULL, age REAL, networth REAL) STRICT;', - ) - - testDb = testDbInternal! - } catch (e) { - console.warn('Error resetting user database', e) - } + resetTestDb() } diff --git a/example/src/tests/unit/index.ts b/example/src/tests/unit/index.ts index 7b8762c6..bb4c1070 100644 --- a/example/src/tests/unit/index.ts +++ b/example/src/tests/unit/index.ts @@ -1,9 +1,10 @@ import { beforeEach, describe } from '../MochaRNAdapter' import { setupTestDb } from './common' -import registerExecuteUnitTests from './specs/execute.spec' -import registerTransactionUnitTests from './specs/transaction.spec' -import registerExecuteBatchUnitTests from './specs/executeBatch.spec' +import registerExecuteUnitTests from './specs/operations/execute.spec' +import registerTransactionUnitTests from './specs/operations/transaction.spec' +import registerExecuteBatchUnitTests from './specs/operations/executeBatch.spec' import registerTypeORMUnitTestsSpecs from './specs/typeorm.spec' +import registerDatabaseQueueUnitTests from './specs/DatabaseQueue.spec' export function registerUnitTests() { beforeEach(setupTestDb) @@ -13,6 +14,8 @@ export function registerUnitTests() { registerTransactionUnitTests() registerExecuteBatchUnitTests() }) + + registerDatabaseQueueUnitTests() } export function registerTypeORMUnitTests() { diff --git a/example/src/tests/unit/specs/DatabaseQueue.spec.ts b/example/src/tests/unit/specs/DatabaseQueue.spec.ts new file mode 100644 index 00000000..1d36a8f2 --- /dev/null +++ b/example/src/tests/unit/specs/DatabaseQueue.spec.ts @@ -0,0 +1,182 @@ +import { + expect, + isNitroSQLiteError, + TEST_ERROR, + TEST_ERROR_CODES, + TEST_ERROR_MESSAGE, +} from '../common' +import { describe, it } from '../../MochaRNAdapter' +import { testDb, testDbQueue } from '../../db' +import type { BatchQueryCommand } from 'react-native-nitro-sqlite' + +const TEST_QUERY = 'SELECT * FROM [User];' + +const TEST_BATCH_COMMANDS: BatchQueryCommand[] = [{ query: TEST_QUERY }] + +export default function registerDatabaseQueueUnitTests() { + describe('Database Queue', () => { + it('multiple transactions are queued', async () => { + const transaction1Promise = testDb.transaction(async (tx) => { + tx.execute(TEST_QUERY) + + expect(testDbQueue.queue.length).to.equal(2) + expect(testDbQueue.inProgress).to.equal(true) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + tx.execute(TEST_QUERY) + + expect(testDbQueue.queue.length).to.equal(2) + expect(testDbQueue.inProgress).to.equal(true) + }) + + expect(testDbQueue.inProgress).to.equal(true) + expect(testDbQueue.queue.length).to.equal(0) + + const transaction2Promise = testDb.transaction(async (tx) => { + tx.execute(TEST_QUERY) + }) + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + const transaction3Promise = testDb.transaction(async (tx) => { + tx.execute(TEST_QUERY) + }) + + await transaction1Promise + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + await transaction2Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + await transaction3Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(false) + }) + + it('multiple executeBatchAsync operations are queued', async () => { + const executeBatch1Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + const executeBatch2Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + const executeBatch3Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(2) + expect(testDbQueue.inProgress).to.equal(true) + + await executeBatch1Promise + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + await executeBatch2Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + await executeBatch3Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(false) + }) + + it('mixed transactions and executeBatchAsync operations are queued', async () => { + const transaction1Promise = testDb.transaction(async (tx) => { + tx.execute('SELECT * FROM [User];') + }) + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + const executeBatch1Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + const transaction2Promise = testDb.transaction(async (tx) => { + tx.execute(TEST_QUERY) + }) + + expect(testDbQueue.queue.length).to.equal(2) + expect(testDbQueue.inProgress).to.equal(true) + + const executeBatch2Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(3) + expect(testDbQueue.inProgress).to.equal(true) + + await transaction1Promise + + expect(testDbQueue.queue.length).to.equal(2) + expect(testDbQueue.inProgress).to.equal(true) + + await executeBatch1Promise + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + await transaction2Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + await executeBatch2Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(false) + }) + + it('errors are thrown through DatabaseQueue', async () => { + const transaction1Promise = testDb.transaction(async (tx) => { + tx.execute('SELECT * FROM [User];') + throw TEST_ERROR + }) + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + + const executeBatch1Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) + + expect(testDbQueue.queue.length).to.equal(1) + expect(testDbQueue.inProgress).to.equal(true) + + try { + await transaction1Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(true) + } catch (e) { + if (isNitroSQLiteError(e)) { + expect(e.message).to.include(TEST_ERROR_MESSAGE) + } else { + expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } + } + + try { + await executeBatch1Promise + + expect(testDbQueue.queue.length).to.equal(0) + expect(testDbQueue.inProgress).to.equal(false) + } catch (e) { + if (isNitroSQLiteError(e)) { + expect(e.message).to.include(TEST_ERROR_MESSAGE) + } else { + expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } + } + }) + }) +} diff --git a/example/src/tests/unit/specs/execute.spec.ts b/example/src/tests/unit/specs/operations/execute.spec.ts similarity index 96% rename from example/src/tests/unit/specs/execute.spec.ts rename to example/src/tests/unit/specs/operations/execute.spec.ts index 85e1269f..4c50196f 100644 --- a/example/src/tests/unit/specs/execute.spec.ts +++ b/example/src/tests/unit/specs/operations/execute.spec.ts @@ -1,9 +1,10 @@ -import { chance, expect, isError, testDb } from '../common' +import { chance, expect, isNitroSQLiteError } from '../../common' import { enableSimpleNullHandling, NITRO_SQLITE_NULL, } from 'react-native-nitro-sqlite' -import { describe, it } from '../../MochaRNAdapter' +import { describe, it } from '../../../MochaRNAdapter' +import { testDb } from '../../../db' export default function registerExecuteUnitTests() { describe('execute', () => { @@ -93,7 +94,7 @@ export default function registerExecuteUnitTests() { [id, name, age, networth], ) } catch (e: unknown) { - if (isError(e)) { + if (isNitroSQLiteError(e)) { expect(e.message).to.include( 'cannot store TEXT value in REAL column User.age', ) diff --git a/example/src/tests/unit/specs/executeBatch.spec.ts b/example/src/tests/unit/specs/operations/executeBatch.spec.ts similarity index 94% rename from example/src/tests/unit/specs/executeBatch.spec.ts rename to example/src/tests/unit/specs/operations/executeBatch.spec.ts index f5d776d0..901f019f 100644 --- a/example/src/tests/unit/specs/executeBatch.spec.ts +++ b/example/src/tests/unit/specs/operations/executeBatch.spec.ts @@ -1,6 +1,7 @@ -import { chance, expect, testDb } from '../common' +import { chance, expect } from '../../common' import type { BatchQueryCommand } from 'react-native-nitro-sqlite' -import { describe, it } from '../../MochaRNAdapter' +import { describe, it } from '../../../MochaRNAdapter' +import { testDb } from '../../../db' export default function registerExecuteBatchUnitTests() { describe('executeBatch', () => { diff --git a/example/src/tests/unit/specs/transaction.spec.ts b/example/src/tests/unit/specs/operations/transaction.spec.ts similarity index 87% rename from example/src/tests/unit/specs/transaction.spec.ts rename to example/src/tests/unit/specs/operations/transaction.spec.ts index 5c714cdc..fd194aa8 100644 --- a/example/src/tests/unit/specs/transaction.spec.ts +++ b/example/src/tests/unit/specs/operations/transaction.spec.ts @@ -1,6 +1,14 @@ -import { chance, expect, isError, testDb } from '../common' -import { describe, it } from '../../MochaRNAdapter' -import type { User } from '../../../model/User' +import { + chance, + expect, + isNitroSQLiteError, + TEST_ERROR, + TEST_ERROR_MESSAGE, + TEST_ERROR_CODES, +} from '../../common' +import { describe, it } from '../../../MochaRNAdapter' +import type { User } from '../../../../model/User' +import { testDb } from '../../../db' export default function registerTransactionUnitTests() { describe('transaction', () => { @@ -10,7 +18,7 @@ export default function registerTransactionUnitTests() { const age = chance.integer() const networth = chance.floating() - await testDb.transaction((tx) => { + await testDb.transaction(async (tx) => { const res = tx.execute( 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth], @@ -40,7 +48,7 @@ export default function registerTransactionUnitTests() { const age = chance.integer() const networth = chance.floating() - await testDb.transaction((tx) => { + await testDb.transaction(async (tx) => { const res = tx.execute( 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth], @@ -79,7 +87,7 @@ export default function registerTransactionUnitTests() { // ACT: Start multiple transactions to upsert and select the same record const promises = [] for (let iteration = 1; iteration <= iterations; iteration++) { - const promised = testDb.transaction((tx) => { + const promised = testDb.transaction(async (tx) => { // ACT: Upsert statement to create record / increment the value tx.execute( ` @@ -126,7 +134,7 @@ export default function registerTransactionUnitTests() { const age = chance.integer() const networth = chance.floating() - await testDb.transaction((tx) => { + await testDb.transaction(async (tx) => { const res = tx.execute( 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth], @@ -164,7 +172,7 @@ export default function registerTransactionUnitTests() { const age = chance.integer() const networth = chance.floating() - await testDb.transaction((tx) => { + await testDb.transaction(async (tx) => { try { tx.execute( 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', @@ -185,7 +193,7 @@ export default function registerTransactionUnitTests() { const age = chance.integer() const networth = chance.floating() - await testDb.transaction((tx) => { + await testDb.transaction(async (tx) => { tx.execute( 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth], @@ -197,34 +205,35 @@ export default function registerTransactionUnitTests() { }) it('Transaction, rejects on callback error', async () => { - const promised = testDb.transaction(() => { - throw new Error('Error from callback') + const promised = testDb.transaction(async () => { + throw TEST_ERROR }) // ASSERT: should return a promise that eventually rejects expect(promised).to.have.property('then').that.is.a('function') try { await promised - expect.fail('Should not resolve') + expect.fail(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) } catch (e) { - if (isError(e)) expect(e.message).to.equal('Error from callback') - else expect.fail('Should have thrown a valid NitroSQLiteException') + if (isNitroSQLiteError(e)) + expect(e.message).to.include(TEST_ERROR_MESSAGE) + else expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) } }) it('Transaction, rejects on invalid query', async () => { - const promised = testDb.transaction((tx) => { + const promised = testDb.transaction(async (tx) => { tx.execute('SELECT * FROM [tableThatDoesNotExist];') }) // ASSERT: should return a promise that eventually rejects expect(promised).to.have.property('then').that.is.a('function') try { await promised - expect.fail('Should not resolve') + expect.fail(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) } catch (e) { - if (isError(e)) + if (isNitroSQLiteError(e)) expect(e.message).to.include('no such table: tableThatDoesNotExist') - else expect.fail('Should have thrown a valid NitroSQLiteException') + else expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) } }) @@ -288,7 +297,7 @@ export default function registerTransactionUnitTests() { ) }) } catch (e) { - if (isError(e)) { + if (isNitroSQLiteError(e)) { expect(e.message) .to.include('SqlExecutionError') .and.to.include('cannot store TEXT value in REAL column User.id') @@ -296,7 +305,7 @@ export default function registerTransactionUnitTests() { const res = testDb.execute('SELECT * FROM User') expect(res.rows?._array).to.eql([]) } else { - expect.fail('Should have thrown a valid NitroSQLiteException') + expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) } } }) @@ -400,17 +409,18 @@ export default function registerTransactionUnitTests() { it('Async transaction, rejects on callback error', async () => { const promised = testDb.transaction(() => { - throw new Error('Error from callback') + throw new Error(TEST_ERROR_MESSAGE) }) // ASSERT: should return a promise that eventually rejects expect(promised).to.have.property('then').that.is.a('function') try { await promised - expect.fail('Should not resolve') + expect.fail(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) } catch (e) { - if (isError(e)) expect(e.message).to.equal('Error from callback') - else expect.fail('Should have thrown a valid NitroSQLiteException') + if (isNitroSQLiteError(e)) + expect(e.message).to.include(TEST_ERROR_MESSAGE) + else expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) } }) @@ -423,11 +433,11 @@ export default function registerTransactionUnitTests() { expect(promised).to.have.property('then').that.is.a('function') try { await promised - expect.fail('Should not resolve') + expect.fail(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) } catch (e) { - if (isError(e)) + if (isNitroSQLiteError(e)) expect(e.message).to.include('no such table: tableThatDoesNotExist') - else expect.fail('Should have thrown a valid NitroSQLiteException') + else expect.fail(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) } }) }) diff --git a/example/tsconfig.json b/example/tsconfig.json index 3b967a5e..b9d76a7d 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../config/tsconfig.json", - "include": ["src", "index.js"] + "include": ["src", "index.js", "../package"] } diff --git a/package/cpp/NitroSQLiteException.hpp b/package/cpp/NitroSQLiteException.hpp index 41e48ab8..4a8469bd 100644 --- a/package/cpp/NitroSQLiteException.hpp +++ b/package/cpp/NitroSQLiteException.hpp @@ -7,6 +7,7 @@ const std::string NITRO_SQLITE_EXCEPTION_PREFIX = "[NativeNitroSQLiteException]"; + enum NitroSQLiteExceptionType { UnknownError, DatabaseCannotBeOpened, diff --git a/package/src/DatabaseQueue.ts b/package/src/DatabaseQueue.ts new file mode 100644 index 00000000..bd485a61 --- /dev/null +++ b/package/src/DatabaseQueue.ts @@ -0,0 +1,129 @@ +import NitroSQLiteError from './NitroSQLiteError' + +export interface QueuedOperation { + /** + * Starts the operation + */ + start: () => void +} + +export type DatabaseQueue = { + queue: QueuedOperation[] + inProgress: boolean +} + +const databaseQueues = new Map() + +export function openDatabaseQueue(dbName: string) { + if (isDatabaseOpen(dbName)) { + throw new NitroSQLiteError( + `Database ${dbName} is already open. There is already a connection to the database.`, + ) + } + + databaseQueues.set(dbName, { queue: [], inProgress: false }) +} + +export function closeDatabaseQueue(dbName: string) { + const databaseQueue = getDatabaseQueue(dbName) + + if (databaseQueue.inProgress || databaseQueue.queue.length > 0) { + console.warn( + `Database queue for ${dbName} has operations in the queue. Closing anyway.`, + ) + } + + databaseQueues.delete(dbName) +} + +export function isDatabaseOpen(dbName: string) { + return databaseQueues.has(dbName) +} + +export function throwIfDatabaseIsNotOpen(dbName: string) { + if (!isDatabaseOpen(dbName)) + throw new NitroSQLiteError( + `Database ${dbName} is not open. There is no connection to the database.`, + ) +} + +export function getDatabaseQueue(dbName: string) { + throwIfDatabaseIsNotOpen(dbName) + + const queue = databaseQueues.get(dbName)! + return queue +} + +export function openDatabase(dbName: string) { + databaseQueues.set(dbName, { queue: [], inProgress: false }) +} + +export function closeDatabase(dbName: string) { + databaseQueues.delete(dbName) +} + +export function queueOperationAsync( + dbName: string, + callback: () => Promise, +) { + const databaseQueue = getDatabaseQueue(dbName) + + return new Promise((resolve, reject) => { + async function start() { + try { + const result = await callback() + resolve(result) + } catch (error) { + reject(error) + } finally { + databaseQueue.inProgress = false + startOperationAsync(dbName) + } + } + + const operation: QueuedOperation = { + start, + } + + databaseQueue.queue.push(operation) + startOperationAsync(dbName) + }) +} + +function startOperationAsync(dbName: string) { + const queue = getDatabaseQueue(dbName) + + // Queue is empty or in progress. Bail out. + if (queue.inProgress || queue.queue.length === 0) { + return + } + + queue.inProgress = true + + const operation = queue.queue.shift()! + setImmediate(() => { + operation.start() + }) +} + +export function startOperationSync< + OperationCallback extends () => Result, + Result = void, +>(dbName: string, callback: OperationCallback) { + const databaseQueue = getDatabaseQueue(dbName) + + // Database is busy - cannot execute synchronously + if (databaseQueue.inProgress || databaseQueue.queue.length > 0) { + throw new NitroSQLiteError( + `Cannot run synchronous operation on database. Database ${dbName} is busy with another operation.`, + ) + } + + // Execute synchronously + databaseQueue.inProgress = true + try { + return callback() + } finally { + databaseQueue.inProgress = false + } +} diff --git a/package/src/index.ts b/package/src/index.ts index e3f31db9..9f350d5a 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -4,8 +4,6 @@ import { open } from './operations/session' import { execute, executeAsync } from './operations/execute' import { init } from './OnLoad' import { executeBatch, executeBatchAsync } from './operations/executeBatch' -export type * from './types' -export { typeORMDriver } from './typeORM' init() @@ -30,3 +28,6 @@ export { isSimpleNullHandlingEnabled, enableSimpleNullHandling, } from './nullHandling' +export { default as NitroSQLiteError } from './NitroSQLiteError' +export type * from './types' +export { typeORMDriver } from './typeORM' diff --git a/package/src/nitro.ts b/package/src/nitro.ts index 293e5c35..c1c552e2 100644 --- a/package/src/nitro.ts +++ b/package/src/nitro.ts @@ -1,11 +1,5 @@ import { NitroModules } from 'react-native-nitro-modules' import type { NitroSQLite as NitroSQLiteSpec } from './specs/NitroSQLite.nitro' -import type { PendingTransaction } from './operations/transaction' export const HybridNitroSQLite = NitroModules.createHybridObject('NitroSQLite') - -export const locks: Record< - string, - { queue: PendingTransaction[]; inProgress: boolean } -> = {} diff --git a/package/src/operations/executeBatch.ts b/package/src/operations/executeBatch.ts index ea30a624..dde6501c 100644 --- a/package/src/operations/executeBatch.ts +++ b/package/src/operations/executeBatch.ts @@ -3,6 +3,11 @@ import { replaceWithNativeNullValue, } from '../nullHandling' import { HybridNitroSQLite } from '../nitro' +import { + queueOperationAsync, + startOperationSync, + throwIfDatabaseIsNotOpen, +} from '../DatabaseQueue' import type { NativeSQLiteQueryParams, BatchQueryResult, @@ -15,12 +20,16 @@ export function executeBatch( dbName: string, commands: BatchQueryCommand[], ): BatchQueryResult { + throwIfDatabaseIsNotOpen(dbName) + const transformedCommands = isSimpleNullHandlingEnabled() ? toNativeBatchQueryCommands(commands) : (commands as NativeBatchQueryCommand[]) try { - return HybridNitroSQLite.executeBatch(dbName, transformedCommands) + return startOperationSync(dbName, () => + HybridNitroSQLite.executeBatch(dbName, transformedCommands), + ) } catch (error) { throw NitroSQLiteError.fromError(error) } @@ -30,18 +39,22 @@ export async function executeBatchAsync( dbName: string, commands: BatchQueryCommand[], ): Promise { + throwIfDatabaseIsNotOpen(dbName) + const transformedCommands = isSimpleNullHandlingEnabled() ? toNativeBatchQueryCommands(commands) : (commands as NativeBatchQueryCommand[]) - try { - return await HybridNitroSQLite.executeBatchAsync( - dbName, - transformedCommands, - ) - } catch (error) { - throw NitroSQLiteError.fromError(error) - } + return queueOperationAsync(dbName, async () => { + try { + return await HybridNitroSQLite.executeBatchAsync( + dbName, + transformedCommands, + ) + } catch (error) { + throw NitroSQLiteError.fromError(error) + } + }) } function toNativeBatchQueryCommands( diff --git a/package/src/operations/session.ts b/package/src/operations/session.ts index f6fb51dd..da5c3536 100644 --- a/package/src/operations/session.ts +++ b/package/src/operations/session.ts @@ -1,4 +1,4 @@ -import { locks, HybridNitroSQLite } from '../nitro' +import { HybridNitroSQLite } from '../nitro' import { transaction } from './transaction' import type { BatchQueryCommand, @@ -12,16 +12,14 @@ import type { import { execute, executeAsync } from './execute' import { executeBatch, executeBatchAsync } from './executeBatch' import NitroSQLiteError from '../NitroSQLiteError' +import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' export function open( options: NitroSQLiteConnectionOptions, ): NitroSQLiteConnection { try { HybridNitroSQLite.open(options.name, options.location) - locks[options.name] = { - queue: [], - inProgress: false, - } + openDatabaseQueue(options.name) } catch (error) { throw NitroSQLiteError.fromError(error) } @@ -30,7 +28,7 @@ export function open( close: () => { try { HybridNitroSQLite.close(options.name) - delete locks[options.name] + closeDatabaseQueue(options.name) } catch (error) { throw NitroSQLiteError.fromError(error) } @@ -39,7 +37,7 @@ export function open( attach: (dbNameToAttach: string, alias: string, location?: string) => HybridNitroSQLite.attach(options.name, dbNameToAttach, alias, location), detach: (alias: string) => HybridNitroSQLite.detach(options.name, alias), - transaction: (fn: (tx: Transaction) => Promise | void) => + transaction: (fn: (tx: Transaction) => Promise) => transaction(options.name, fn), execute: ( query: string, diff --git a/package/src/operations/transaction.ts b/package/src/operations/transaction.ts index d57f2907..ec6a6d0b 100644 --- a/package/src/operations/transaction.ts +++ b/package/src/operations/transaction.ts @@ -1,5 +1,4 @@ -import { locks, HybridNitroSQLite } from '../nitro' -import NitroSQLiteError from '../NitroSQLiteError' +import { queueOperationAsync, throwIfDatabaseIsNotOpen } from '../DatabaseQueue' import type { QueryResult, Transaction, @@ -7,35 +6,22 @@ import type { QueryResultRow, } from '../types' import { execute, executeAsync } from './execute' +import NitroSQLiteError from '../NitroSQLiteError' -export interface PendingTransaction { - /* - * The start function should not throw or return a promise because the - * queue just calls it and does not monitor for failures or completions. - * - * It should catch any errors and call the resolve or reject of the wrapping - * promise when complete. - * - * It should also automatically commit or rollback the transaction if needed - */ - start: () => void -} - -export const transaction = ( +export const transaction = async ( dbName: string, - fn: (tx: Transaction) => Promise | void, -): Promise => { - if (locks[dbName] == null) - throw new NitroSQLiteError(`No lock found on db: ${dbName}`) + transactionCallback: (tx: Transaction) => Promise, + isExclusive = false, +) => { + throwIfDatabaseIsNotOpen(dbName) - let isFinalized = false + let isFinished = false - // Local transaction context object implementation - const executeOnTransaction = ( + const executeOnTransaction = ( query: string, params?: SQLiteQueryParams, - ): QueryResult => { - if (isFinalized) { + ): QueryResult => { + if (isFinished) { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -43,11 +29,11 @@ export const transaction = ( return execute(dbName, query, params) } - const executeAsyncOnTransaction = ( + const executeAsyncOnTransaction = ( query: string, params?: SQLiteQueryParams, - ): Promise> => { - if (isFinalized) { + ): Promise> => { + if (isFinished) { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -56,88 +42,52 @@ export const transaction = ( } const commit = () => { - if (isFinalized) { + if (isFinished) { throw new NitroSQLiteError( `Cannot execute commit on finalized transaction: ${dbName}`, ) } - const result = HybridNitroSQLite.execute(dbName, 'COMMIT') - isFinalized = true - return result + isFinished = true + return execute(dbName, 'COMMIT') } const rollback = () => { - if (isFinalized) { + if (isFinished) { throw new NitroSQLiteError( `Cannot execute rollback on finalized transaction: ${dbName}`, ) } - const result = HybridNitroSQLite.execute(dbName, 'ROLLBACK') - isFinalized = true - return result + isFinished = true + return execute(dbName, 'ROLLBACK') } - async function run() { + return await queueOperationAsync(dbName, async () => { try { - await HybridNitroSQLite.executeAsync(dbName, 'BEGIN TRANSACTION') + await executeAsync( + dbName, + isExclusive ? 'BEGIN EXCLUSIVE TRANSACTION' : 'BEGIN TRANSACTION', + ) - await fn({ + const result = await transactionCallback({ commit, execute: executeOnTransaction, executeAsync: executeAsyncOnTransaction, rollback, }) - if (!isFinalized) commit() + if (!isFinished) commit() + + return result } catch (executionError) { - if (!isFinalized) { + if (!isFinished) { try { rollback() } catch (rollbackError) { - throw rollbackError + throw NitroSQLiteError.fromError(rollbackError) } } - throw executionError - } finally { - locks[dbName]!.inProgress = false - isFinalized = false - startNextTransaction(dbName) + throw NitroSQLiteError.fromError(executionError) } - } - - return new Promise((resolve, reject) => { - const tx: PendingTransaction = { - start: async () => { - try { - const result = await run() - resolve(result) - } catch (error) { - reject(NitroSQLiteError.fromError(error)) - } - }, - } - - locks[dbName]?.queue.push(tx) - startNextTransaction(dbName) }) } - -function startNextTransaction(dbName: string) { - if (locks[dbName] == null) - throw new NitroSQLiteError(`Lock not found for db: ${dbName}`) - - if (locks[dbName].inProgress) { - // Transaction is already in process bail out - return - } - - if (locks[dbName].queue.length > 0) { - locks[dbName].inProgress = true - - const tx = locks[dbName].queue.shift()! - setImmediate(() => { - tx.start() - }) - } -} diff --git a/package/src/types.ts b/package/src/types.ts index 26e80f1b..dd11c308 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -8,7 +8,9 @@ export interface NitroSQLiteConnection { delete(): void attach(dbNameToAttach: string, alias: string, location?: string): void detach(alias: string): void - transaction(fn: (tx: Transaction) => Promise | void): Promise + transaction: ( + transactionCallback: (tx: Transaction) => Promise, + ) => Promise execute: ExecuteQuery executeAsync: ExecuteAsyncQuery executeBatch(commands: BatchQueryCommand[]): BatchQueryResult