From 8e6d64aa59fb01f3718957b473cc0d07fe526a5f Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Mon, 22 Jan 2024 15:04:20 +0200 Subject: [PATCH 1/9] trigger update table callbacks only if changes are commited inside transactions --- .changeset/chilled-queens-explain.md | 5 ++ src/DBListenerManager.ts | 96 ++++++++++++++++++++ src/lock-hooks.ts | 17 ++++ src/setup-open.ts | 122 ++++++++++++++++++++------ src/types.ts | 12 ++- src/utils/BaseObserver.ts | 31 +++++++ tests/tests/sqlite/rawQueries.spec.ts | 84 ++++++++++++++++-- 7 files changed, 330 insertions(+), 37 deletions(-) create mode 100644 .changeset/chilled-queens-explain.md create mode 100644 src/DBListenerManager.ts create mode 100644 src/lock-hooks.ts create mode 100644 src/utils/BaseObserver.ts diff --git a/.changeset/chilled-queens-explain.md b/.changeset/chilled-queens-explain.md new file mode 100644 index 0000000..08ebaaf --- /dev/null +++ b/.changeset/chilled-queens-explain.md @@ -0,0 +1,5 @@ +--- +'@journeyapps/react-native-quick-sqlite': minor +--- + +Fixed table change updates to only trigger change updates for changes made in `writeTransaction` and `writeLock`s which have been commited. Added ability to listen to all table change events as they occur. Added listeners for when a transaction has started, been commited or rolled back. diff --git a/src/DBListenerManager.ts b/src/DBListenerManager.ts new file mode 100644 index 0000000..2af4337 --- /dev/null +++ b/src/DBListenerManager.ts @@ -0,0 +1,96 @@ +import _ from 'lodash'; +import { registerUpdateHook } from './table-updates'; +import { UpdateCallback, UpdateNotification } from './types'; +import { BaseListener, BaseObserver } from './utils/BaseObserver'; + +export interface DBListenerManagerOptions { + dbName: string; +} + +export enum WriteTransactionEventType { + STARTED = 'started', + COMMIT = 'commit', + ROLLBACK = 'rollback' +} + +export interface WriteTransactionEvent { + type: WriteTransactionEventType; +} + +export interface DBListener extends BaseListener { + /** + * Register a listener to be fired for any table change. + * Changes inside write transactions are reported immediately. + */ + rawTableChange: UpdateCallback; + + /** + * Register a listener for when table changes are persisted + * into the DB. Changes during write transactions which are + * rolled back are not reported. + * Any changes during write transactions are buffered and reported + * after commit. + * Table changes are reported individually for now in order to maintain + * API compatibility. These can be batched in future. + */ + tableUpdated: UpdateCallback; + + /** + * Listener event triggered whenever a write transaction + * is started, committed or rolled back. + */ + writeTransaction: (event: WriteTransactionEvent) => void; +} + +export class DBListenerManager extends BaseObserver {} + +export class DBListenerManagerInternal extends DBListenerManager { + private _writeTransactionActive: boolean; + private updateBuffer: UpdateNotification[]; + + get writeTransactionActive() { + return this._writeTransactionActive; + } + + constructor(protected options: DBListenerManagerOptions) { + super(); + this._writeTransactionActive = false; + this.updateBuffer = []; + registerUpdateHook(this.options.dbName, (update) => this.handleTableUpdates(update)); + } + + transactionStarted() { + this._writeTransactionActive = true; + this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.STARTED })); + } + + transactionCommitted() { + this._writeTransactionActive = false; + // flush updates + const uniqueUpdates = _.uniq(this.updateBuffer); + this.updateBuffer = []; + this.iterateListeners((l) => { + l.writeTransaction?.({ type: WriteTransactionEventType.COMMIT }); + uniqueUpdates.forEach((update) => l.tableUpdated?.(update)); + }); + } + + transactionReverted() { + this._writeTransactionActive = false; + // clear updates + this.updateBuffer = []; + this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.ROLLBACK })); + } + + handleTableUpdates(notification: UpdateNotification) { + // Fire updates for any change + this.iterateListeners((l) => l.rawTableChange?.({ ...notification, pendingCommit: this._writeTransactionActive })); + + if (this.writeTransactionActive) { + this.updateBuffer.push(notification); + return; + } + + this.iterateListeners((l) => l.tableUpdated?.(notification)); + } +} diff --git a/src/lock-hooks.ts b/src/lock-hooks.ts new file mode 100644 index 0000000..5d875b3 --- /dev/null +++ b/src/lock-hooks.ts @@ -0,0 +1,17 @@ +/** + * Hooks which can be triggered during the execution of read/write locks + */ +export interface LockHooks { + /** + * Executed after a SQL statement has been executed + */ + execute?: (sql: string, args?: any[]) => Promise; + lockAcquired?: () => Promise; + lockReleased?: () => Promise; +} + +export interface TransactionHooks extends LockHooks { + begin?: () => Promise; + commit?: () => Promise; + rollback?: () => Promise; +} diff --git a/src/setup-open.ts b/src/setup-open.ts index 5e776df..7a792e9 100644 --- a/src/setup-open.ts +++ b/src/setup-open.ts @@ -8,19 +8,26 @@ import { TransactionContext, UpdateCallback, SQLBatchTuple, - OpenOptions + OpenOptions, + QueryResult } from './types'; import uuid from 'uuid'; import _ from 'lodash'; import { enhanceQueryResult } from './utils'; -import { registerUpdateHook } from './table-updates'; +import { DBListenerManagerInternal } from './DBListenerManager'; +import { LockHooks, TransactionHooks } from './lock-hooks'; type LockCallbackRecord = { callback: (context: LockContext) => Promise; timeout?: NodeJS.Timeout; }; +enum TransactionFinalizer { + COMMIT = 'commit', + ROLLBACK = 'rollback' +} + const DEFAULT_READ_CONNECTIONS = 4; const LockCallbacks: Record = {}; @@ -90,6 +97,8 @@ export function setupOpen(QuickSQLite: ISQLite) { 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. @@ -97,7 +106,8 @@ export function setupOpen(QuickSQLite: ISQLite) { const requestLock = ( type: ConcurrentLockType, callback: (context: LockContext) => Promise, - options?: LockOptions + options?: LockOptions, + hooks?: LockHooks ): Promise => { const id = uuid.v4(); // TODO maybe do this in C++ // Wrap the callback in a promise that will resolve to the callback result @@ -106,12 +116,22 @@ export function setupOpen(QuickSQLite: ISQLite) { const record = (LockCallbacks[id] = { callback: async (context: LockContext) => { try { - const res = await callback(context); + await hooks?.lockAcquired?.(); + const res = await callback({ + ...context, + execute: async (sql, args) => { + const result = await context.execute(sql, args); + await hooks?.execute?.(sql, args); + return result; + } + }); // Ensure that we only resolve after locks are freed _.defer(() => resolve(res)); } catch (ex) { _.defer(() => reject(ex)); + } finally { + _.defer(() => hooks?.lockReleased?.()); } } } as LockCallbackRecord); @@ -137,15 +157,20 @@ export function setupOpen(QuickSQLite: ISQLite) { 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); + const writeLock = ( + callback: (context: LockContext) => Promise, + options?: LockOptions, + hooks?: LockHooks + ): Promise => requestLock(ConcurrentLockType.WRITE, callback, options, hooks); const wrapTransaction = async ( context: LockContext, callback: (context: TransactionContext) => Promise, - defaultFinally: 'commit' | 'rollback' = 'commit' + defaultFinalizer: TransactionFinalizer = TransactionFinalizer.COMMIT, + hooks?: TransactionHooks ) => { await context.execute('BEGIN TRANSACTION'); + await hooks?.begin(); let finalized = false; const finalizedStatement = @@ -158,19 +183,29 @@ export function setupOpen(QuickSQLite: ISQLite) { return action(); }; - const commit = finalizedStatement(() => context.execute('COMMIT')); - const commitAsync = finalizedStatement(() => context.execute('COMMIT')); + const commit = finalizedStatement(async () => { + const result = await context.execute('COMMIT'); + await hooks?.commit?.(); + return result; + }); - const rollback = finalizedStatement(() => context.execute('ROLLBACK')); - const rollbackAsync = finalizedStatement(() => context.execute('ROLLBACK')); + const rollback = finalizedStatement(async () => { + const result = await context.execute('ROLLBACK'); + await hooks?.rollback?.(); + return result; + }); const wrapExecute = - (method: (sql: string, params?: any[]) => T): ((sql: string, params?: any[]) => T) => - (sql: string, params?: any[]) => { + ( + 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); + const result = await method(sql, params); + await hooks?.execute?.(sql, params); + return result; }; try { @@ -180,17 +215,17 @@ export function setupOpen(QuickSQLite: ISQLite) { rollback, execute: wrapExecute(context.execute) }); - switch (defaultFinally) { - case 'commit': - await commitAsync(); + switch (defaultFinalizer) { + case TransactionFinalizer.COMMIT: + await commit(); break; - case 'rollback': - await rollbackAsync(); + case TransactionFinalizer.ROLLBACK: + await rollback(); break; } return res; } catch (ex) { - await rollbackAsync(); + await rollback(); throw ex; } }; @@ -202,12 +237,46 @@ export function setupOpen(QuickSQLite: ISQLite) { readLock, readTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => readLock((context) => wrapTransaction(context, callback)), - writeLock, + writeLock: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => + writeLock(callback, options, { + execute: async (sql) => { + if (!listenerManager.writeTransactionActive) { + // check if starting a transaction + if (sql == 'BEGIN' || sql == 'BEGIN IMMEDIATE') { + listenerManager.transactionStarted(); + return; + } + } + // check if finishing a transaction + switch (sql) { + case 'ROLLBACK': + listenerManager.transactionReverted(); + break; + case 'COMMIT': + case 'END TRANSACTION': + listenerManager.transactionCommitted(); + break; + } + }, + lockReleased: async () => { + if (listenerManager.writeTransactionActive) { + // The lock was completed without ending the transaction. + // This should not occur, but do not report these updates + listenerManager.transactionReverted(); + } + } + }), writeTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => - writeLock((context) => wrapTransaction(context, callback), options), - registerUpdateHook: (callback: UpdateCallback) => { - registerUpdateHook(dbName, callback); - }, + writeLock( + (context) => + wrapTransaction(context, callback, TransactionFinalizer.COMMIT, { + begin: async () => listenerManager.transactionStarted(), + commit: async () => listenerManager.transactionCommitted(), + rollback: async () => listenerManager.transactionReverted() + }), + options + ), + registerUpdateHook: (callback: UpdateCallback) => listenerManager.registerListener({ tableUpdated: callback }), delete: () => QuickSQLite.delete(dbName, options?.location), executeBatch: (commands: SQLBatchTuple[]) => writeLock((context) => QuickSQLite.executeBatch(dbName, commands, (context as any)._contextId)), @@ -215,7 +284,8 @@ export function setupOpen(QuickSQLite: ISQLite) { 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)) + writeLock((context) => QuickSQLite.loadFile(dbName, location, (context as any)._contextId)), + listenerManager }; } }; diff --git a/src/types.ts b/src/types.ts index 8191295..2c61df9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { DBListenerManager } from './DBListenerManager'; + /** * Object returned by SQL Query executions { * insertId: Represent the auto-generated row id if applicable @@ -76,6 +78,11 @@ export interface UpdateNotification { opType: RowUpdateType; table: string; rowId: number; + /** + * If this change ocurred during a write transaction which has not been + * committed yet. + */ + pendingCommit?: boolean; } export type UpdateCallback = (update: UpdateNotification) => void; @@ -149,8 +156,9 @@ export type QuickSQLiteConnection = { executeBatch: (commands: SQLBatchTuple[]) => Promise; loadFile: (location: string) => Promise; /** - * Note that only one listener can be registered per database connection. - * Any new hook registration will override the previous one. + * @deprecated + * Use listenerManager instead */ registerUpdateHook(callback: UpdateCallback): void; + listenerManager: DBListenerManager; }; diff --git a/src/utils/BaseObserver.ts b/src/utils/BaseObserver.ts new file mode 100644 index 0000000..90195bf --- /dev/null +++ b/src/utils/BaseObserver.ts @@ -0,0 +1,31 @@ +import { v4 } from 'uuid'; + +export interface BaseObserverInterface { + registerListener(listener: Partial): () => void; +} + +export type BaseListener = { + [key: string]: (...event: any) => any; +}; + +export class BaseObserver implements BaseObserverInterface { + protected listeners: { [id: string]: Partial }; + + constructor() { + this.listeners = {}; + } + + registerListener(listener: Partial): () => void { + const id = v4(); + this.listeners[id] = listener; + return () => { + delete this.listeners[id]; + }; + } + + iterateListeners(cb: (listener: Partial) => any) { + for (let i in this.listeners) { + cb(this.listeners[i]); + } + } +} diff --git a/tests/tests/sqlite/rawQueries.spec.ts b/tests/tests/sqlite/rawQueries.spec.ts index 19890eb..8c18dd9 100644 --- a/tests/tests/sqlite/rawQueries.spec.ts +++ b/tests/tests/sqlite/rawQueries.spec.ts @@ -1,5 +1,12 @@ import Chance from 'chance'; -import { open, QuickSQLite, QuickSQLiteConnection, SQLBatchTuple, UpdateNotification } from 'react-native-quick-sqlite'; +import { + open, + QueryResult, + QuickSQLite, + QuickSQLiteConnection, + SQLBatchTuple, + UpdateNotification +} from 'react-native-quick-sqlite'; import { beforeEach, describe, it } from '../mocha/MochaRNAdapter'; import chai from 'chai'; @@ -14,6 +21,13 @@ function generateUserInfo() { return { id: chance.integer(), name: chance.name(), age: chance.integer(), networth: chance.floating() }; } +function createTestUser(context: { execute: (sql: string, args?: any[]) => Promise } = db) { + const { id, name, age, networth } = generateUserInfo(); + return context.execute('INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth]); +} + +const NUM_READ_CONNECTIONS = 5; + export function registerBaseTests() { beforeEach(async () => { try { @@ -22,7 +36,7 @@ export function registerBaseTests() { db.delete(); } - global.db = db = open('test'); + global.db = db = open('test', { numReadConnections: NUM_READ_CONNECTIONS }); await db.execute('DROP TABLE IF EXISTS User; '); await db.execute('CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;'); @@ -33,13 +47,7 @@ export function registerBaseTests() { describe('Raw queries', () => { it('Insert', async () => { - const { id, name, age, networth } = generateUserInfo(); - const res = await db.execute('INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [ - id, - name, - age, - networth - ]); + const res = await createTestUser(); expect(res.rowsAffected).to.equal(1); expect(res.insertId).to.equal(1); expect(res.metadata).to.eql([]); @@ -464,6 +472,64 @@ export function registerBaseTests() { singleConnection.close(); }); + it('Should reflect writeLock and writeTransaction updates on read connections', async () => { + let readTriggerCallbacks = []; + + // Execute the read test whenever a table change ocurred + db.listenerManager.registerListener({ + tableUpdated: () => { + readTriggerCallbacks.forEach((cb) => cb()); + } + }); + + const createReaders = () => + new Array(NUM_READ_CONNECTIONS).fill(null).map(() => { + return db.readLock(async (tx) => { + return new Promise((resolve) => { + // start a read lock for each connection + readTriggerCallbacks.push(async () => { + const result = await tx.execute('SELECT * from User'); + if (result.rows?.length) { + // The data reflected on the read connection + resolve(result.rows?.length); + } + }); + }); + }); + }); + + // Test writeTransaction + let readerPromises = createReaders(); + + await db.writeTransaction(async (tx) => { + return createTestUser(tx); + }); + + let resolved = await Promise.all(readerPromises); + // The query result length for 1 item should be returned for all connections + expect(resolved, 'writeTransaction changes should reflect in read connections').to.deep.equal( + readerPromises.map(() => 1) + ); + + await db.execute('DELETE FROM User'); + + // Test writeLock + readerPromises = createReaders(); + readTriggerCallbacks = []; + + await db.writeLock(async (tx) => { + await tx.execute('BEGIN'); + await createTestUser(tx); + await tx.execute('COMMIT'); + }); + + resolved = await Promise.all(readerPromises); + // The query result length for 1 item should be returned for all connections + expect(resolved, 'writeLock changes should reflect in read connections').to.deep.equal( + readerPromises.map(() => 1) + ); + }); + it('Should attach DBs', async () => { const singleConnection = open('single_connection', { numReadConnections: 0 }); await singleConnection.execute('DROP TABLE IF EXISTS Places; '); From a6253c95e74b06910eba5209c070aab9796d8e7d Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Mon, 22 Jan 2024 16:39:17 +0200 Subject: [PATCH 2/9] 5 read connections might be too many for CI to handle :thinking_face: --- tests/tests/sqlite/rawQueries.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests/sqlite/rawQueries.spec.ts b/tests/tests/sqlite/rawQueries.spec.ts index 8c18dd9..3b300d6 100644 --- a/tests/tests/sqlite/rawQueries.spec.ts +++ b/tests/tests/sqlite/rawQueries.spec.ts @@ -26,7 +26,7 @@ function createTestUser(context: { execute: (sql: string, args?: any[]) => Promi return context.execute('INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth]); } -const NUM_READ_CONNECTIONS = 5; +const NUM_READ_CONNECTIONS = 3; export function registerBaseTests() { beforeEach(async () => { From 7cf93c51796594ebb0a7fe45c0945173fee623ab Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 23 Jan 2024 11:32:58 +0200 Subject: [PATCH 3/9] improvement: use sqlite commit and rollback hooks. Update table changes after write locks have completed or transaction has been committed. --- cpp/ConnectionPool.cpp | 28 ++++++++- cpp/ConnectionPool.h | 22 +++++++ cpp/bindings.cpp | 32 +++++++++- cpp/sqliteBridge.cpp | 3 + cpp/sqliteBridge.h | 2 + src/DBListenerManager.ts | 86 ++++++++++++++------------ src/lock-hooks.ts | 10 --- src/setup-open.ts | 89 ++++++--------------------- src/table-updates.ts | 20 +++++- src/types.ts | 45 +++++++++++--- tests/tests/sqlite/rawQueries.spec.ts | 52 ++++++++++++++-- 11 files changed, 248 insertions(+), 141 deletions(-) diff --git a/cpp/ConnectionPool.cpp b/cpp/ConnectionPool.cpp index 8288e78..7ab5ccf 100644 --- a/cpp/ConnectionPool.cpp +++ b/cpp/ConnectionPool.cpp @@ -9,7 +9,13 @@ ConnectionPool::ConnectionPool(std::string dbName, std::string docPath, : dbName(dbName), maxReads(numReadConnections), writeConnection(dbName, docPath, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | - SQLITE_OPEN_FULLMUTEX) { + SQLITE_OPEN_FULLMUTEX), + commitPayload( + {.dbName = &this->dbName, .event = TransactionEvent::COMMIT}), + rollbackPayload({ + .dbName = &this->dbName, + .event = TransactionEvent::ROLLBACK, + }) { onContextCallback = nullptr; isConcurrencyEnabled = maxReads > 0; @@ -127,6 +133,26 @@ void ConnectionPool::setTableUpdateHandler( (void *)(dbName.c_str())); } +/** + * The SQLite callback needs to return `0` in order for the commit to + * proceed correctly + */ +int onCommitIntermediate(ConnectionPool *pool) { + if (pool->onCommitCallback != NULL) { + pool->onCommitCallback(&(pool->commitPayload)); + } + return 0; +} + +void ConnectionPool::setTransactionFinalizerHandler( + void (*callback)(const TransactionCallbackPayload *)) { + this->onCommitCallback = callback; + sqlite3_commit_hook(writeConnection.connection, + (int (*)(void *))onCommitIntermediate, (void *)this); + sqlite3_rollback_hook(writeConnection.connection, (void (*)(void *))callback, + (void *)&rollbackPayload); +} + void ConnectionPool::closeContext(ConnectionLockId contextId) { if (writeConnection.matchesLock(contextId)) { if (writeQueue.size() > 0) { diff --git a/cpp/ConnectionPool.h b/cpp/ConnectionPool.h index 2ebecae..28d9b1f 100644 --- a/cpp/ConnectionPool.h +++ b/cpp/ConnectionPool.h @@ -7,6 +7,13 @@ #ifndef ConnectionPool_h #define ConnectionPool_h +enum TransactionEvent { COMMIT, ROLLBACK }; + +struct TransactionCallbackPayload { + std::string *dbName; + TransactionEvent event; +}; + // The number of concurrent read connections to the database. /** * Concurrent connection pool class. @@ -57,7 +64,12 @@ class ConnectionPool { std::vector readQueue; std::vector writeQueue; + // Cached constant payloads for c style commit/rollback callbacks + const TransactionCallbackPayload commitPayload; + const TransactionCallbackPayload rollbackPayload; + void (*onContextCallback)(std::string, ConnectionLockId); + void (*onCommitCallback)(const TransactionCallbackPayload *); bool isConcurrencyEnabled; @@ -66,6 +78,8 @@ class ConnectionPool { unsigned int numReadConnections); ~ConnectionPool(); + friend int onCommitIntermediate(ConnectionPool *pool); + /** * Add a task to the read queue. If there are no available connections, * the task will be queued. @@ -94,6 +108,12 @@ class ConnectionPool { void setTableUpdateHandler(void (*callback)(void *, int, const char *, const char *, sqlite3_int64)); + /** + * Set a callback function for transaction commits/rollbacks + */ + void setTransactionFinalizerHandler( + void (*callback)(const TransactionCallbackPayload *)); + /** * Close a context in order to progress queue */ @@ -124,4 +144,6 @@ class ConnectionPool { sqlite3 **db, int sqlOpenFlags); }; +int onCommitIntermediate(ConnectionPool *pool); + #endif \ No newline at end of file diff --git a/cpp/bindings.cpp b/cpp/bindings.cpp index a7fe689..320d39c 100644 --- a/cpp/bindings.cpp +++ b/cpp/bindings.cpp @@ -67,6 +67,32 @@ void updateTableHandler(void *voidDBName, int opType, char const *dbName, }); } +/** + * Callback handler for SQLite transaction updates + */ +void transactionFinalizerHandler(const TransactionCallbackPayload *payload) { + /** + * No DB operations should occur when this callback is fired from SQLite. + * This function triggers an async invocation to call watch callbacks, + * avoiding holding SQLite up. + */ + invoker->invokeAsync([payload] { + try { + auto global = runtime->global(); + jsi::Function handlerFunction = global.getPropertyAsFunction( + *runtime, "triggerTransactionFinalizerHook"); + + auto jsiDbName = jsi::String::createFromAscii(*runtime, *payload->dbName); + auto jsiEventType = jsi::Value((int)payload->event); + handlerFunction.call(*runtime, move(jsiDbName), move(jsiEventType)); + } catch (jsi::JSINativeException e) { + std::cout << e.what() << std::endl; + } catch (...) { + std::cout << "Unknown error" << std::endl; + } + }); +} + /** * Callback handler for Concurrent context is available */ @@ -137,9 +163,9 @@ void osp::install(jsi::Runtime &rt, } } - auto result = - sqliteOpenDb(dbName, tempDocPath, &contextLockAvailableHandler, - &updateTableHandler, numReadConnections); + auto result = sqliteOpenDb( + dbName, tempDocPath, &contextLockAvailableHandler, &updateTableHandler, + &transactionFinalizerHandler, numReadConnections); if (result.type == SQLiteError) { throw jsi::JSError(rt, result.errorMessage.c_str()); } diff --git a/cpp/sqliteBridge.cpp b/cpp/sqliteBridge.cpp index 2450077..c43cf11 100644 --- a/cpp/sqliteBridge.cpp +++ b/cpp/sqliteBridge.cpp @@ -40,6 +40,8 @@ sqliteOpenDb(string const dbName, string const docPath, void (*contextAvailableCallback)(std::string, ConnectionLockId), void (*updateTableCallback)(void *, int, const char *, const char *, sqlite3_int64), + void (*onTransactionFinalizedCallback)( + const TransactionCallbackPayload *event), uint32_t numReadConnections) { if (dbMap.count(dbName) == 1) { return SQLiteOPResult{ @@ -51,6 +53,7 @@ sqliteOpenDb(string const dbName, string const docPath, dbMap[dbName] = new ConnectionPool(dbName, docPath, numReadConnections); dbMap[dbName]->setOnContextAvailable(contextAvailableCallback); dbMap[dbName]->setTableUpdateHandler(updateTableCallback); + dbMap[dbName]->setTransactionFinalizerHandler(onTransactionFinalizedCallback); return SQLiteOPResult{ .type = SQLiteOk, diff --git a/cpp/sqliteBridge.h b/cpp/sqliteBridge.h index a24222d..d099d5c 100644 --- a/cpp/sqliteBridge.h +++ b/cpp/sqliteBridge.h @@ -29,6 +29,8 @@ sqliteOpenDb(std::string const dbName, std::string const docPath, void (*contextAvailableCallback)(std::string, ConnectionLockId), void (*updateTableCallback)(void *, int, const char *, const char *, sqlite3_int64), + void (*onTransactionFinalizedCallback)( + const TransactionCallbackPayload *event), uint32_t numReadConnections); SQLiteOPResult sqliteCloseDb(string const dbName); diff --git a/src/DBListenerManager.ts b/src/DBListenerManager.ts index 2af4337..4237590 100644 --- a/src/DBListenerManager.ts +++ b/src/DBListenerManager.ts @@ -1,26 +1,26 @@ import _ from 'lodash'; -import { registerUpdateHook } from './table-updates'; -import { UpdateCallback, UpdateNotification } from './types'; +import { registerTransactionHook, registerUpdateHook } from './table-updates'; +import { + BatchedUpdateCallback, + BatchedUpdateNotification, + TransactionEvent, + UpdateCallback, + UpdateNotification +} from './types'; import { BaseListener, BaseObserver } from './utils/BaseObserver'; export interface DBListenerManagerOptions { dbName: string; } -export enum WriteTransactionEventType { - STARTED = 'started', - COMMIT = 'commit', - ROLLBACK = 'rollback' -} - export interface WriteTransactionEvent { - type: WriteTransactionEventType; + type: TransactionEvent; } export interface DBListener extends BaseListener { /** * Register a listener to be fired for any table change. - * Changes inside write transactions are reported immediately. + * Changes inside write locks and transactions are reported immediately. */ rawTableChange: UpdateCallback; @@ -28,12 +28,12 @@ export interface DBListener extends BaseListener { * Register a listener for when table changes are persisted * into the DB. Changes during write transactions which are * rolled back are not reported. - * Any changes during write transactions are buffered and reported - * after commit. + * Any changes during write locks are buffered and reported + * after transaction commit and lock release. * Table changes are reported individually for now in order to maintain * API compatibility. These can be batched in future. */ - tableUpdated: UpdateCallback; + tablesUpdated: BatchedUpdateCallback; /** * Listener event triggered whenever a write transaction @@ -45,52 +45,56 @@ export interface DBListener extends BaseListener { export class DBListenerManager extends BaseObserver {} export class DBListenerManagerInternal extends DBListenerManager { - private _writeTransactionActive: boolean; private updateBuffer: UpdateNotification[]; - get writeTransactionActive() { - return this._writeTransactionActive; - } - constructor(protected options: DBListenerManagerOptions) { super(); - this._writeTransactionActive = false; this.updateBuffer = []; registerUpdateHook(this.options.dbName, (update) => this.handleTableUpdates(update)); + registerTransactionHook(this.options.dbName, (eventType) => { + switch (eventType) { + case TransactionEvent.COMMIT: + this.flushUpdates(); + break; + case TransactionEvent.ROLLBACK: + this.transactionReverted(); + break; + } + + this.iterateListeners((l) => + l.writeTransaction?.({ + type: eventType + }) + ); + }); } - transactionStarted() { - this._writeTransactionActive = true; - this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.STARTED })); - } + flushUpdates() { + console.log('updates', this.updateBuffer); + if (!this.updateBuffer.length) { + return; + } - transactionCommitted() { - this._writeTransactionActive = false; - // flush updates - const uniqueUpdates = _.uniq(this.updateBuffer); + const groupedUpdates = _.groupBy(this.updateBuffer, (update) => update.table); + const batchedUpdate: BatchedUpdateNotification = { + groupedUpdates, + rawUpdates: this.updateBuffer, + tables: _.keys(groupedUpdates) + }; this.updateBuffer = []; - this.iterateListeners((l) => { - l.writeTransaction?.({ type: WriteTransactionEventType.COMMIT }); - uniqueUpdates.forEach((update) => l.tableUpdated?.(update)); - }); + this.iterateListeners((l) => l.tablesUpdated?.(batchedUpdate)); } - transactionReverted() { - this._writeTransactionActive = false; + protected transactionReverted() { // clear updates this.updateBuffer = []; - this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.ROLLBACK })); } handleTableUpdates(notification: UpdateNotification) { // Fire updates for any change - this.iterateListeners((l) => l.rawTableChange?.({ ...notification, pendingCommit: this._writeTransactionActive })); - - if (this.writeTransactionActive) { - this.updateBuffer.push(notification); - return; - } + this.iterateListeners((l) => l.rawTableChange?.({ ...notification })); - this.iterateListeners((l) => l.tableUpdated?.(notification)); + // Queue changes until they are flushed + this.updateBuffer.push(notification); } } diff --git a/src/lock-hooks.ts b/src/lock-hooks.ts index 5d875b3..c8fb89e 100644 --- a/src/lock-hooks.ts +++ b/src/lock-hooks.ts @@ -2,16 +2,6 @@ * Hooks which can be triggered during the execution of read/write locks */ export interface LockHooks { - /** - * Executed after a SQL statement has been executed - */ - execute?: (sql: string, args?: any[]) => Promise; lockAcquired?: () => Promise; lockReleased?: () => Promise; } - -export interface TransactionHooks extends LockHooks { - begin?: () => Promise; - commit?: () => Promise; - rollback?: () => Promise; -} diff --git a/src/setup-open.ts b/src/setup-open.ts index 7a792e9..7d62cf5 100644 --- a/src/setup-open.ts +++ b/src/setup-open.ts @@ -16,7 +16,7 @@ import uuid from 'uuid'; import _ from 'lodash'; import { enhanceQueryResult } from './utils'; import { DBListenerManagerInternal } from './DBListenerManager'; -import { LockHooks, TransactionHooks } from './lock-hooks'; +import { LockHooks } from './lock-hooks'; type LockCallbackRecord = { callback: (context: LockContext) => Promise; @@ -117,14 +117,7 @@ export function setupOpen(QuickSQLite: ISQLite) { callback: async (context: LockContext) => { try { await hooks?.lockAcquired?.(); - const res = await callback({ - ...context, - execute: async (sql, args) => { - const result = await context.execute(sql, args); - await hooks?.execute?.(sql, args); - return result; - } - }); + const res = await callback(context); // Ensure that we only resolve after locks are freed _.defer(() => resolve(res)); @@ -157,20 +150,20 @@ export function setupOpen(QuickSQLite: ISQLite) { const readLock = (callback: (context: LockContext) => Promise, options?: LockOptions): Promise => requestLock(ConcurrentLockType.READ, callback, options); - const writeLock = ( - callback: (context: LockContext) => Promise, - options?: LockOptions, - hooks?: LockHooks - ): Promise => requestLock(ConcurrentLockType.WRITE, callback, options, hooks); + 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, - hooks?: TransactionHooks + defaultFinalizer: TransactionFinalizer = TransactionFinalizer.COMMIT ) => { await context.execute('BEGIN TRANSACTION'); - await hooks?.begin(); let finalized = false; const finalizedStatement = @@ -183,17 +176,9 @@ export function setupOpen(QuickSQLite: ISQLite) { return action(); }; - const commit = finalizedStatement(async () => { - const result = await context.execute('COMMIT'); - await hooks?.commit?.(); - return result; - }); + const commit = finalizedStatement(async () => context.execute('COMMIT')); - const rollback = finalizedStatement(async () => { - const result = await context.execute('ROLLBACK'); - await hooks?.rollback?.(); - return result; - }); + const rollback = finalizedStatement(async () => context.execute('ROLLBACK')); const wrapExecute = ( @@ -203,9 +188,7 @@ export function setupOpen(QuickSQLite: ISQLite) { if (finalized) { throw new Error(`Cannot execute in transaction after it has been finalized with commit/rollback.`); } - const result = await method(sql, params); - await hooks?.execute?.(sql, params); - return result; + return method(sql, params); }; try { @@ -237,46 +220,9 @@ export function setupOpen(QuickSQLite: ISQLite) { readLock, readTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => readLock((context) => wrapTransaction(context, callback)), - writeLock: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => - writeLock(callback, options, { - execute: async (sql) => { - if (!listenerManager.writeTransactionActive) { - // check if starting a transaction - if (sql == 'BEGIN' || sql == 'BEGIN IMMEDIATE') { - listenerManager.transactionStarted(); - return; - } - } - // check if finishing a transaction - switch (sql) { - case 'ROLLBACK': - listenerManager.transactionReverted(); - break; - case 'COMMIT': - case 'END TRANSACTION': - listenerManager.transactionCommitted(); - break; - } - }, - lockReleased: async () => { - if (listenerManager.writeTransactionActive) { - // The lock was completed without ending the transaction. - // This should not occur, but do not report these updates - listenerManager.transactionReverted(); - } - } - }), + writeLock, writeTransaction: async (callback: (context: TransactionContext) => Promise, options?: LockOptions) => - writeLock( - (context) => - wrapTransaction(context, callback, TransactionFinalizer.COMMIT, { - begin: async () => listenerManager.transactionStarted(), - commit: async () => listenerManager.transactionCommitted(), - rollback: async () => listenerManager.transactionReverted() - }), - options - ), - registerUpdateHook: (callback: UpdateCallback) => listenerManager.registerListener({ tableUpdated: callback }), + 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)), @@ -285,7 +231,10 @@ export function setupOpen(QuickSQLite: ISQLite) { detach: (alias: string) => QuickSQLite.detach(dbName, alias), loadFile: (location: string) => writeLock((context) => QuickSQLite.loadFile(dbName, location, (context as any)._contextId)), - listenerManager + listenerManager, + registerUpdateHook: (callback: UpdateCallback) => + listenerManager.registerListener({ rawTableChange: callback }), + registerTablesChangedHook: (callback) => listenerManager.registerListener({ tablesUpdated: callback }) }; } }; diff --git a/src/table-updates.ts b/src/table-updates.ts index 52d3e9d..9af3fcc 100644 --- a/src/table-updates.ts +++ b/src/table-updates.ts @@ -1,6 +1,7 @@ -import { RowUpdateType, UpdateCallback } from './types'; +import { RowUpdateType, TransactionCallback, TransactionEvent, UpdateCallback } from './types'; const updateCallbacks: Record = {}; +const transactionCallbacks: Record = {}; /** * Entry point for update callbacks. This is triggered from C++ with params. @@ -22,3 +23,20 @@ global.triggerUpdateHook = function (dbName: string, table: string, opType: RowU export const registerUpdateHook = (dbName: string, callback: UpdateCallback) => { updateCallbacks[dbName] = callback; }; + +/** + * Entry point for transaction callbacks. This is triggered from C++ with params. + */ +global.triggerTransactionFinalizerHook = function (dbName: string, eventType: TransactionEvent) { + const callback = transactionCallbacks[dbName]; + if (!callback) { + return; + } + + callback(eventType); + return null; +}; + +export const registerTransactionHook = (dbName: string, callback: TransactionCallback) => { + transactionCallbacks[dbName] = callback; +}; diff --git a/src/types.ts b/src/types.ts index 2c61df9..5ad9a63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,18 +74,30 @@ export enum RowUpdateType { SQLITE_DELETE = 9, SQLITE_UPDATE = 23 } -export interface UpdateNotification { + +export interface TableUpdateOperation { opType: RowUpdateType; - table: string; rowId: number; - /** - * If this change ocurred during a write transaction which has not been - * committed yet. - */ - pendingCommit?: boolean; +} +export interface UpdateNotification extends TableUpdateOperation { + table: string; +} + +export interface BatchedUpdateNotification { + rawUpdates: UpdateNotification[]; + tables: string[]; + groupedUpdates: Record; } export type UpdateCallback = (update: UpdateNotification) => void; +export type BatchedUpdateCallback = (update: BatchedUpdateNotification) => void; + +export enum TransactionEvent { + COMMIT, + ROLLBACK +} + +export type TransactionCallback = (eventType: TransactionEvent) => void; export type ContextLockID = string; @@ -156,9 +168,22 @@ export type QuickSQLiteConnection = { executeBatch: (commands: SQLBatchTuple[]) => Promise; loadFile: (location: string) => Promise; /** - * @deprecated - * Use listenerManager instead + * Register a callback which will be fired for each ROWID table change event. + * Table changes are reported immediately. + * Changes might not yet be committed if using a transaction. + * - Listen to transaction events in listenerManager if extra logic is required + * For most use cases use `` instead. + * @returns a function which will deregister the callback + */ + registerUpdateHook(callback: UpdateCallback): () => void; + /** + * Register a callback which will be fired whenever a change to a ROWID table + * has been committed. + * Changes inside write locks will be buffered until the lock is released or + * if a transaction inside the lock has been committed. + * Reverting a transaction inside a write lock will not fire table updates. + * @returns a function which will deregister the callback */ - registerUpdateHook(callback: UpdateCallback): void; + registerTablesChangedHook(callback: BatchedUpdateCallback): () => void; listenerManager: DBListenerManager; }; diff --git a/tests/tests/sqlite/rawQueries.spec.ts b/tests/tests/sqlite/rawQueries.spec.ts index 3b300d6..b9c710a 100644 --- a/tests/tests/sqlite/rawQueries.spec.ts +++ b/tests/tests/sqlite/rawQueries.spec.ts @@ -1,10 +1,12 @@ import Chance from 'chance'; import { + BatchedUpdateNotification, open, QueryResult, QuickSQLite, QuickSQLiteConnection, SQLBatchTuple, + TransactionEvent, UpdateNotification } from 'react-native-quick-sqlite'; import { beforeEach, describe, it } from '../mocha/MochaRNAdapter'; @@ -472,15 +474,55 @@ export function registerBaseTests() { singleConnection.close(); }); + it('should trigger write transaction commit hooks', async () => { + const commitPromise = new Promise((resolve) => + db.listenerManager.registerListener({ + writeTransaction: (event) => { + if (event.type == TransactionEvent.COMMIT) { + resolve(); + } + } + }) + ); + + const rollbackPromise = new Promise((resolve) => + db.listenerManager.registerListener({ + writeTransaction: (event) => { + if (event.type == TransactionEvent.ROLLBACK) { + resolve(); + } + } + }) + ); + + await db.writeTransaction(async (tx) => tx.rollback()); + await rollbackPromise; + + // Need to actually do something for the commit hook to fire + await db.writeTransaction(async (tx) => { + await createTestUser(tx); + }); + await commitPromise; + }); + + it('should batch table update changes', async () => { + const updatePromise = new Promise((resolve) => db.registerTablesChangedHook(resolve)); + + await db.writeTransaction(async (tx) => { + await createTestUser(tx); + await createTestUser(tx); + }); + + const update = await updatePromise; + + expect(update.rawUpdates.length).to.equal(2); + }); + it('Should reflect writeLock and writeTransaction updates on read connections', async () => { let readTriggerCallbacks = []; // Execute the read test whenever a table change ocurred - db.listenerManager.registerListener({ - tableUpdated: () => { - readTriggerCallbacks.forEach((cb) => cb()); - } - }); + db.registerTablesChangedHook((update) => readTriggerCallbacks.forEach((cb) => cb())); const createReaders = () => new Array(NUM_READ_CONNECTIONS).fill(null).map(() => { From d3ad2fd8b9764753182263aac1a7b57b997e3d53 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 23 Jan 2024 13:38:09 +0200 Subject: [PATCH 4/9] code cleanup --- .changeset/chilled-queens-explain.md | 2 +- src/DBListenerManager.ts | 1 - src/types.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/chilled-queens-explain.md b/.changeset/chilled-queens-explain.md index 08ebaaf..bdb9a25 100644 --- a/.changeset/chilled-queens-explain.md +++ b/.changeset/chilled-queens-explain.md @@ -2,4 +2,4 @@ '@journeyapps/react-native-quick-sqlite': minor --- -Fixed table change updates to only trigger change updates for changes made in `writeTransaction` and `writeLock`s which have been commited. Added ability to listen to all table change events as they occur. Added listeners for when a transaction has started, been commited or rolled back. +Added `registerTablesChangedHook` to DB connections which reports batched table updates once `writeTransaction`s and `writeLock`s have been committed. Maintained API compatibility with `registerUpdateHook` which reports table change events as they occur. Added listeners for when write transactions have been committed or rolled back. diff --git a/src/DBListenerManager.ts b/src/DBListenerManager.ts index 4237590..4cf6e7c 100644 --- a/src/DBListenerManager.ts +++ b/src/DBListenerManager.ts @@ -70,7 +70,6 @@ export class DBListenerManagerInternal extends DBListenerManager { } flushUpdates() { - console.log('updates', this.updateBuffer); if (!this.updateBuffer.length) { return; } diff --git a/src/types.ts b/src/types.ts index 5ad9a63..c4c29ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -172,12 +172,12 @@ export type QuickSQLiteConnection = { * Table changes are reported immediately. * Changes might not yet be committed if using a transaction. * - Listen to transaction events in listenerManager if extra logic is required - * For most use cases use `` instead. + * For most use cases use `registerTablesChangedHook` instead. * @returns a function which will deregister the callback */ registerUpdateHook(callback: UpdateCallback): () => void; /** - * Register a callback which will be fired whenever a change to a ROWID table + * Register a callback which will be fired whenever a update to a ROWID table * has been committed. * Changes inside write locks will be buffered until the lock is released or * if a transaction inside the lock has been committed. From 76c4d363ae5e879f9e76e046fbe29de5ae545117 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 23 Jan 2024 13:40:22 +0200 Subject: [PATCH 5/9] test --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fab4976..b11d6fe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ![screenshot](https://raw.githubusercontent.com/margelo/react-native-quick-sqlite/main/header2.png) +testing
     yarn add react-native-quick-sqlite

From 3537127c4d575a25c0afff188f2b5a5902f11603 Mon Sep 17 00:00:00 2001
From: Steven Ontong 
Date: Tue, 23 Jan 2024 17:25:00 +0200
Subject: [PATCH 6/9] use older version

---
 .github/workflows/test.yaml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 1843f9c..25a4c3b 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -54,7 +54,7 @@ jobs:
 
       - name: create AVD and generate snapshot for caching
         if: steps.avd-cache.outputs.cache-hit != 'true'
-        uses: reactivecircus/android-emulator-runner@v2
+        uses: reactivecircus/android-emulator-runner@v2.28.0
         with:
           api-level: 29
           force-avd-creation: false
@@ -66,7 +66,7 @@ jobs:
           script: echo "Generated AVD snapshot for caching."
 
       - name: Run connected tests
-        uses: ReactiveCircus/android-emulator-runner@v2
+        uses: ReactiveCircus/android-emulator-runner@v2.28.0
         with:
           api-level: 29
           target: google_apis

From 55000de1b5730075922546fb8b67c4b917cef0f0 Mon Sep 17 00:00:00 2001
From: Steven Ontong 
Date: Tue, 23 Jan 2024 19:39:49 +0200
Subject: [PATCH 7/9] test api 31

---
 .github/workflows/test.yaml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 25a4c3b..26717a7 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -8,7 +8,7 @@ jobs:
     name: Test
     runs-on: macos-latest
     env:
-      AVD_NAME: macOS-avd-x86_64-29
+      AVD_NAME: macOS-avd-x86_64-31
     steps:
       - uses: actions/checkout@v4
         with:
@@ -24,7 +24,7 @@ jobs:
           path: |
             ~/.android/avd/*
             ~/.android/adb*
-          key: avd-29
+          key: avd-31
 
       - name: Setup NodeJS
         uses: actions/setup-node@v2
@@ -56,7 +56,7 @@ jobs:
         if: steps.avd-cache.outputs.cache-hit != 'true'
         uses: reactivecircus/android-emulator-runner@v2.28.0
         with:
-          api-level: 29
+          api-level: 31
           force-avd-creation: false
           target: google_apis
           arch: x86_64
@@ -68,7 +68,7 @@ jobs:
       - name: Run connected tests
         uses: ReactiveCircus/android-emulator-runner@v2.28.0
         with:
-          api-level: 29
+          api-level: 31
           target: google_apis
           arch: x86_64
           avd-name: $AVD_NAME

From bb46217c6d69dbdd13a72df2147f6d2a8572f776 Mon Sep 17 00:00:00 2001
From: Steven Ontong 
Date: Tue, 23 Jan 2024 20:30:34 +0200
Subject: [PATCH 8/9] fix tests

---
 README.md | 1 -
 1 file changed, 1 deletion(-)

diff --git a/README.md b/README.md
index b11d6fe..fab4976 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
 ![screenshot](https://raw.githubusercontent.com/margelo/react-native-quick-sqlite/main/header2.png)
 
-testing
 
     yarn add react-native-quick-sqlite

From 3fb8f392aec7b0a16494bc000ee50b058bf08bb6 Mon Sep 17 00:00:00 2001
From: Steven Ontong 
Date: Wed, 24 Jan 2024 09:45:35 +0200
Subject: [PATCH 9/9] split tests

---
 tests/ios/Podfile.lock                |  4 +-
 tests/tests/sqlite/rawQueries.spec.ts | 66 ++++++++++++++-------------
 2 files changed, 36 insertions(+), 34 deletions(-)

diff --git a/tests/ios/Podfile.lock b/tests/ios/Podfile.lock
index 0a5a6af..1b69350 100644
--- a/tests/ios/Podfile.lock
+++ b/tests/ios/Podfile.lock
@@ -340,7 +340,7 @@ PODS:
     - glog
   - react-native-get-random-values (1.9.0):
     - React-Core
-  - react-native-quick-sqlite (0.0.1):
+  - react-native-quick-sqlite (1.0.0):
     - powersync-sqlite-core
     - React
     - React-callinvoker
@@ -658,7 +658,7 @@ SPEC CHECKSUMS:
   React-jsinspector: 194e32c6aab382d88713ad3dd0025c5f5c4ee072
   React-logger: cebf22b6cf43434e471dc561e5911b40ac01d289
   react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
-  react-native-quick-sqlite: bb695834cb1f4b88c3c2ae413ecf6522e9d221c3
+  react-native-quick-sqlite: d55edadf2d63f9ca9e3bc4bb138bf61739294413
   react-native-safe-area-context: 238cd8b619e05cb904ccad97ef42e84d1b5ae6ec
   React-NativeModulesApple: 02e35e9a51e10c6422f04f5e4076a7c02243fff2
   React-perflogger: e3596db7e753f51766bceadc061936ef1472edc3
diff --git a/tests/tests/sqlite/rawQueries.spec.ts b/tests/tests/sqlite/rawQueries.spec.ts
index b9c710a..46ea651 100644
--- a/tests/tests/sqlite/rawQueries.spec.ts
+++ b/tests/tests/sqlite/rawQueries.spec.ts
@@ -19,16 +19,36 @@ const chance = new Chance();
 // Attempting to open an already open DB results in an error.
 let db: QuickSQLiteConnection = global.db;
 
+const NUM_READ_CONNECTIONS = 3;
+
 function generateUserInfo() {
   return { id: chance.integer(), name: chance.name(), age: chance.integer(), networth: chance.floating() };
 }
 
 function createTestUser(context: { execute: (sql: string, args?: any[]) => Promise } = db) {
   const { id, name, age, networth } = generateUserInfo();
-  return context.execute('INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth]);
+  return context.execute('INSERT INTO User (id, name, age, networth) VALUES(?, ?, ?, ?)', [id, name, age, networth]);
 }
 
-const NUM_READ_CONNECTIONS = 3;
+/**
+ * Creates read locks then queries the User table.
+ * Returns an array of promises which resolve once each
+ * read connection contains rows.
+ */
+const createReaders = (callbacks: Array<() => void>) =>
+  new Array(NUM_READ_CONNECTIONS).fill(null).map(() => {
+    return db.readLock(async (tx) => {
+      return new Promise((resolve) => {
+        // start a read lock for each connection
+        callbacks.push(async () => {
+          const result = await tx.execute('SELECT * from User');
+          const length = result.rows?.length;
+          console.log(`Reading Users returned ${length} rows`);
+          resolve(result.rows?.length);
+        });
+      });
+    });
+  });
 
 export function registerBaseTests() {
   beforeEach(async () => {
@@ -518,30 +538,14 @@ export function registerBaseTests() {
       expect(update.rawUpdates.length).to.equal(2);
     });
 
-    it('Should reflect writeLock and writeTransaction updates on read connections', async () => {
-      let readTriggerCallbacks = [];
+    it('Should reflect writeTransaction updates on read connections', async () => {
+      const readTriggerCallbacks = [];
 
       // Execute the read test whenever a table change ocurred
       db.registerTablesChangedHook((update) => readTriggerCallbacks.forEach((cb) => cb()));
 
-      const createReaders = () =>
-        new Array(NUM_READ_CONNECTIONS).fill(null).map(() => {
-          return db.readLock(async (tx) => {
-            return new Promise((resolve) => {
-              // start a read lock for each connection
-              readTriggerCallbacks.push(async () => {
-                const result = await tx.execute('SELECT * from User');
-                if (result.rows?.length) {
-                  // The data reflected on the read connection
-                  resolve(result.rows?.length);
-                }
-              });
-            });
-          });
-        });
-
       // Test writeTransaction
-      let readerPromises = createReaders();
+      const readerPromises = createReaders(readTriggerCallbacks);
 
       await db.writeTransaction(async (tx) => {
         return createTestUser(tx);
@@ -549,15 +553,15 @@ export function registerBaseTests() {
 
       let resolved = await Promise.all(readerPromises);
       // The query result length for 1 item should be returned for all connections
-      expect(resolved, 'writeTransaction changes should reflect in read connections').to.deep.equal(
-        readerPromises.map(() => 1)
-      );
-
-      await db.execute('DELETE FROM User');
+      expect(resolved).to.deep.equal(readerPromises.map(() => 1));
+    });
 
+    it('Should reflect writeLock updates on read connections', async () => {
+      const readTriggerCallbacks = [];
       // Test writeLock
-      readerPromises = createReaders();
-      readTriggerCallbacks = [];
+      const readerPromises = createReaders(readTriggerCallbacks);
+      // Execute the read test whenever a table change ocurred
+      db.registerTablesChangedHook((update) => readTriggerCallbacks.forEach((cb) => cb()));
 
       await db.writeLock(async (tx) => {
         await tx.execute('BEGIN');
@@ -565,11 +569,9 @@ export function registerBaseTests() {
         await tx.execute('COMMIT');
       });
 
-      resolved = await Promise.all(readerPromises);
+      const resolved = await Promise.all(readerPromises);
       // The query result length for 1 item should be returned for all connections
-      expect(resolved, 'writeLock changes should reflect in read connections').to.deep.equal(
-        readerPromises.map(() => 1)
-      );
+      expect(resolved).to.deep.equal(readerPromises.map(() => 1));
     });
 
     it('Should attach DBs', async () => {