Skip to content

Commit 8e6d64a

Browse files
trigger update table callbacks only if changes are commited inside transactions
1 parent 7262d47 commit 8e6d64a

File tree

7 files changed

+330
-37
lines changed

7 files changed

+330
-37
lines changed

.changeset/chilled-queens-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@journeyapps/react-native-quick-sqlite': minor
3+
---
4+
5+
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.

src/DBListenerManager.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import _ from 'lodash';
2+
import { registerUpdateHook } from './table-updates';
3+
import { UpdateCallback, UpdateNotification } from './types';
4+
import { BaseListener, BaseObserver } from './utils/BaseObserver';
5+
6+
export interface DBListenerManagerOptions {
7+
dbName: string;
8+
}
9+
10+
export enum WriteTransactionEventType {
11+
STARTED = 'started',
12+
COMMIT = 'commit',
13+
ROLLBACK = 'rollback'
14+
}
15+
16+
export interface WriteTransactionEvent {
17+
type: WriteTransactionEventType;
18+
}
19+
20+
export interface DBListener extends BaseListener {
21+
/**
22+
* Register a listener to be fired for any table change.
23+
* Changes inside write transactions are reported immediately.
24+
*/
25+
rawTableChange: UpdateCallback;
26+
27+
/**
28+
* Register a listener for when table changes are persisted
29+
* into the DB. Changes during write transactions which are
30+
* rolled back are not reported.
31+
* Any changes during write transactions are buffered and reported
32+
* after commit.
33+
* Table changes are reported individually for now in order to maintain
34+
* API compatibility. These can be batched in future.
35+
*/
36+
tableUpdated: UpdateCallback;
37+
38+
/**
39+
* Listener event triggered whenever a write transaction
40+
* is started, committed or rolled back.
41+
*/
42+
writeTransaction: (event: WriteTransactionEvent) => void;
43+
}
44+
45+
export class DBListenerManager extends BaseObserver<DBListener> {}
46+
47+
export class DBListenerManagerInternal extends DBListenerManager {
48+
private _writeTransactionActive: boolean;
49+
private updateBuffer: UpdateNotification[];
50+
51+
get writeTransactionActive() {
52+
return this._writeTransactionActive;
53+
}
54+
55+
constructor(protected options: DBListenerManagerOptions) {
56+
super();
57+
this._writeTransactionActive = false;
58+
this.updateBuffer = [];
59+
registerUpdateHook(this.options.dbName, (update) => this.handleTableUpdates(update));
60+
}
61+
62+
transactionStarted() {
63+
this._writeTransactionActive = true;
64+
this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.STARTED }));
65+
}
66+
67+
transactionCommitted() {
68+
this._writeTransactionActive = false;
69+
// flush updates
70+
const uniqueUpdates = _.uniq(this.updateBuffer);
71+
this.updateBuffer = [];
72+
this.iterateListeners((l) => {
73+
l.writeTransaction?.({ type: WriteTransactionEventType.COMMIT });
74+
uniqueUpdates.forEach((update) => l.tableUpdated?.(update));
75+
});
76+
}
77+
78+
transactionReverted() {
79+
this._writeTransactionActive = false;
80+
// clear updates
81+
this.updateBuffer = [];
82+
this.iterateListeners((l) => l?.writeTransaction?.({ type: WriteTransactionEventType.ROLLBACK }));
83+
}
84+
85+
handleTableUpdates(notification: UpdateNotification) {
86+
// Fire updates for any change
87+
this.iterateListeners((l) => l.rawTableChange?.({ ...notification, pendingCommit: this._writeTransactionActive }));
88+
89+
if (this.writeTransactionActive) {
90+
this.updateBuffer.push(notification);
91+
return;
92+
}
93+
94+
this.iterateListeners((l) => l.tableUpdated?.(notification));
95+
}
96+
}

src/lock-hooks.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Hooks which can be triggered during the execution of read/write locks
3+
*/
4+
export interface LockHooks {
5+
/**
6+
* Executed after a SQL statement has been executed
7+
*/
8+
execute?: (sql: string, args?: any[]) => Promise<void>;
9+
lockAcquired?: () => Promise<void>;
10+
lockReleased?: () => Promise<void>;
11+
}
12+
13+
export interface TransactionHooks extends LockHooks {
14+
begin?: () => Promise<void>;
15+
commit?: () => Promise<void>;
16+
rollback?: () => Promise<void>;
17+
}

src/setup-open.ts

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,26 @@ import {
88
TransactionContext,
99
UpdateCallback,
1010
SQLBatchTuple,
11-
OpenOptions
11+
OpenOptions,
12+
QueryResult
1213
} from './types';
1314

1415
import uuid from 'uuid';
1516
import _ from 'lodash';
1617
import { enhanceQueryResult } from './utils';
17-
import { registerUpdateHook } from './table-updates';
18+
import { DBListenerManagerInternal } from './DBListenerManager';
19+
import { LockHooks, TransactionHooks } from './lock-hooks';
1820

1921
type LockCallbackRecord = {
2022
callback: (context: LockContext) => Promise<any>;
2123
timeout?: NodeJS.Timeout;
2224
};
2325

26+
enum TransactionFinalizer {
27+
COMMIT = 'commit',
28+
ROLLBACK = 'rollback'
29+
}
30+
2431
const DEFAULT_READ_CONNECTIONS = 4;
2532

2633
const LockCallbacks: Record<ContextLockID, LockCallbackRecord> = {};
@@ -90,14 +97,17 @@ export function setupOpen(QuickSQLite: ISQLite) {
9097
numReadConnections: options?.numReadConnections ?? DEFAULT_READ_CONNECTIONS
9198
});
9299

100+
const listenerManager = new DBListenerManagerInternal({ dbName });
101+
93102
/**
94103
* Wraps lock requests and their callbacks in order to resolve the lock
95104
* request with the callback result once triggered from the connection pool.
96105
*/
97106
const requestLock = <T>(
98107
type: ConcurrentLockType,
99108
callback: (context: LockContext) => Promise<T>,
100-
options?: LockOptions
109+
options?: LockOptions,
110+
hooks?: LockHooks
101111
): Promise<T> => {
102112
const id = uuid.v4(); // TODO maybe do this in C++
103113
// Wrap the callback in a promise that will resolve to the callback result
@@ -106,12 +116,22 @@ export function setupOpen(QuickSQLite: ISQLite) {
106116
const record = (LockCallbacks[id] = {
107117
callback: async (context: LockContext) => {
108118
try {
109-
const res = await callback(context);
119+
await hooks?.lockAcquired?.();
120+
const res = await callback({
121+
...context,
122+
execute: async (sql, args) => {
123+
const result = await context.execute(sql, args);
124+
await hooks?.execute?.(sql, args);
125+
return result;
126+
}
127+
});
110128

111129
// Ensure that we only resolve after locks are freed
112130
_.defer(() => resolve(res));
113131
} catch (ex) {
114132
_.defer(() => reject(ex));
133+
} finally {
134+
_.defer(() => hooks?.lockReleased?.());
115135
}
116136
}
117137
} as LockCallbackRecord);
@@ -137,15 +157,20 @@ export function setupOpen(QuickSQLite: ISQLite) {
137157
const readLock = <T>(callback: (context: LockContext) => Promise<T>, options?: LockOptions): Promise<T> =>
138158
requestLock(ConcurrentLockType.READ, callback, options);
139159

140-
const writeLock = <T>(callback: (context: LockContext) => Promise<T>, options?: LockOptions): Promise<T> =>
141-
requestLock(ConcurrentLockType.WRITE, callback, options);
160+
const writeLock = <T>(
161+
callback: (context: LockContext) => Promise<T>,
162+
options?: LockOptions,
163+
hooks?: LockHooks
164+
): Promise<T> => requestLock(ConcurrentLockType.WRITE, callback, options, hooks);
142165

143166
const wrapTransaction = async <T>(
144167
context: LockContext,
145168
callback: (context: TransactionContext) => Promise<T>,
146-
defaultFinally: 'commit' | 'rollback' = 'commit'
169+
defaultFinalizer: TransactionFinalizer = TransactionFinalizer.COMMIT,
170+
hooks?: TransactionHooks
147171
) => {
148172
await context.execute('BEGIN TRANSACTION');
173+
await hooks?.begin();
149174
let finalized = false;
150175

151176
const finalizedStatement =
@@ -158,19 +183,29 @@ export function setupOpen(QuickSQLite: ISQLite) {
158183
return action();
159184
};
160185

161-
const commit = finalizedStatement(() => context.execute('COMMIT'));
162-
const commitAsync = finalizedStatement(() => context.execute('COMMIT'));
186+
const commit = finalizedStatement(async () => {
187+
const result = await context.execute('COMMIT');
188+
await hooks?.commit?.();
189+
return result;
190+
});
163191

164-
const rollback = finalizedStatement(() => context.execute('ROLLBACK'));
165-
const rollbackAsync = finalizedStatement(() => context.execute('ROLLBACK'));
192+
const rollback = finalizedStatement(async () => {
193+
const result = await context.execute('ROLLBACK');
194+
await hooks?.rollback?.();
195+
return result;
196+
});
166197

167198
const wrapExecute =
168-
<T>(method: (sql: string, params?: any[]) => T): ((sql: string, params?: any[]) => T) =>
169-
(sql: string, params?: any[]) => {
199+
<T>(
200+
method: (sql: string, params?: any[]) => Promise<QueryResult>
201+
): ((sql: string, params?: any[]) => Promise<QueryResult>) =>
202+
async (sql: string, params?: any[]) => {
170203
if (finalized) {
171204
throw new Error(`Cannot execute in transaction after it has been finalized with commit/rollback.`);
172205
}
173-
return method(sql, params);
206+
const result = await method(sql, params);
207+
await hooks?.execute?.(sql, params);
208+
return result;
174209
};
175210

176211
try {
@@ -180,17 +215,17 @@ export function setupOpen(QuickSQLite: ISQLite) {
180215
rollback,
181216
execute: wrapExecute(context.execute)
182217
});
183-
switch (defaultFinally) {
184-
case 'commit':
185-
await commitAsync();
218+
switch (defaultFinalizer) {
219+
case TransactionFinalizer.COMMIT:
220+
await commit();
186221
break;
187-
case 'rollback':
188-
await rollbackAsync();
222+
case TransactionFinalizer.ROLLBACK:
223+
await rollback();
189224
break;
190225
}
191226
return res;
192227
} catch (ex) {
193-
await rollbackAsync();
228+
await rollback();
194229
throw ex;
195230
}
196231
};
@@ -202,20 +237,55 @@ export function setupOpen(QuickSQLite: ISQLite) {
202237
readLock,
203238
readTransaction: async <T>(callback: (context: TransactionContext) => Promise<T>, options?: LockOptions) =>
204239
readLock((context) => wrapTransaction(context, callback)),
205-
writeLock,
240+
writeLock: async <T>(callback: (context: TransactionContext) => Promise<T>, options?: LockOptions) =>
241+
writeLock(callback, options, {
242+
execute: async (sql) => {
243+
if (!listenerManager.writeTransactionActive) {
244+
// check if starting a transaction
245+
if (sql == 'BEGIN' || sql == 'BEGIN IMMEDIATE') {
246+
listenerManager.transactionStarted();
247+
return;
248+
}
249+
}
250+
// check if finishing a transaction
251+
switch (sql) {
252+
case 'ROLLBACK':
253+
listenerManager.transactionReverted();
254+
break;
255+
case 'COMMIT':
256+
case 'END TRANSACTION':
257+
listenerManager.transactionCommitted();
258+
break;
259+
}
260+
},
261+
lockReleased: async () => {
262+
if (listenerManager.writeTransactionActive) {
263+
// The lock was completed without ending the transaction.
264+
// This should not occur, but do not report these updates
265+
listenerManager.transactionReverted();
266+
}
267+
}
268+
}),
206269
writeTransaction: async <T>(callback: (context: TransactionContext) => Promise<T>, options?: LockOptions) =>
207-
writeLock((context) => wrapTransaction(context, callback), options),
208-
registerUpdateHook: (callback: UpdateCallback) => {
209-
registerUpdateHook(dbName, callback);
210-
},
270+
writeLock(
271+
(context) =>
272+
wrapTransaction(context, callback, TransactionFinalizer.COMMIT, {
273+
begin: async () => listenerManager.transactionStarted(),
274+
commit: async () => listenerManager.transactionCommitted(),
275+
rollback: async () => listenerManager.transactionReverted()
276+
}),
277+
options
278+
),
279+
registerUpdateHook: (callback: UpdateCallback) => listenerManager.registerListener({ tableUpdated: callback }),
211280
delete: () => QuickSQLite.delete(dbName, options?.location),
212281
executeBatch: (commands: SQLBatchTuple[]) =>
213282
writeLock((context) => QuickSQLite.executeBatch(dbName, commands, (context as any)._contextId)),
214283
attach: (dbNameToAttach: string, alias: string, location?: string) =>
215284
QuickSQLite.attach(dbName, dbNameToAttach, alias, location),
216285
detach: (alias: string) => QuickSQLite.detach(dbName, alias),
217286
loadFile: (location: string) =>
218-
writeLock((context) => QuickSQLite.loadFile(dbName, location, (context as any)._contextId))
287+
writeLock((context) => QuickSQLite.loadFile(dbName, location, (context as any)._contextId)),
288+
listenerManager
219289
};
220290
}
221291
};

src/types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { DBListenerManager } from './DBListenerManager';
2+
13
/**
24
* Object returned by SQL Query executions {
35
* insertId: Represent the auto-generated row id if applicable
@@ -76,6 +78,11 @@ export interface UpdateNotification {
7678
opType: RowUpdateType;
7779
table: string;
7880
rowId: number;
81+
/**
82+
* If this change ocurred during a write transaction which has not been
83+
* committed yet.
84+
*/
85+
pendingCommit?: boolean;
7986
}
8087

8188
export type UpdateCallback = (update: UpdateNotification) => void;
@@ -149,8 +156,9 @@ export type QuickSQLiteConnection = {
149156
executeBatch: (commands: SQLBatchTuple[]) => Promise<BatchQueryResult>;
150157
loadFile: (location: string) => Promise<FileLoadResult>;
151158
/**
152-
* Note that only one listener can be registered per database connection.
153-
* Any new hook registration will override the previous one.
159+
* @deprecated
160+
* Use listenerManager instead
154161
*/
155162
registerUpdateHook(callback: UpdateCallback): void;
163+
listenerManager: DBListenerManager;
156164
};

0 commit comments

Comments
 (0)