@powersync/op-sqlite 0.0.0-dev-20260311081226 → 0.0.0-dev-20260414110516

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,11 @@ import {
3
3
  BaseObserver,
4
4
  BatchedUpdateNotification,
5
5
  DBAdapterListener,
6
+ DBGetUtilsDefaultMixin,
7
+ LockContext,
6
8
  QueryResult,
7
9
  RowUpdateType,
10
+ SqlExecutor,
8
11
  UpdateNotification
9
12
  } from '@powersync/common';
10
13
 
@@ -19,7 +22,7 @@ export type OPSQLiteUpdateNotification = {
19
22
  rowId: number;
20
23
  };
21
24
 
22
- export class OPSQLiteConnection extends BaseObserver<DBAdapterListener> {
25
+ class OPSQLiteExecutor extends BaseObserver<DBAdapterListener> implements Omit<SqlExecutor, 'executeBatch'> {
23
26
  protected DB: DB;
24
27
  private updateBuffer: UpdateNotification[];
25
28
 
@@ -101,7 +104,7 @@ export class OPSQLiteConnection extends BaseObserver<DBAdapterListener> {
101
104
  return await this.DB.executeRaw(query, params);
102
105
  }
103
106
 
104
- async executeBatch(query: string, params: any[][] = []): Promise<QueryResult> {
107
+ async executeNativeBatch(query: string, params: any[][] = []): Promise<QueryResult> {
105
108
  const tuple: SQLBatchTuple[] = [[query, params[0]]];
106
109
  params.slice(1).forEach((p) => tuple.push([query, p]));
107
110
 
@@ -110,25 +113,9 @@ export class OPSQLiteConnection extends BaseObserver<DBAdapterListener> {
110
113
  rowsAffected: result.rowsAffected ?? 0
111
114
  };
112
115
  }
116
+ }
113
117
 
114
- async getAll<T>(sql: string, parameters?: any[]): Promise<T[]> {
115
- const result = await this.DB.execute(sql, parameters);
116
- return (result.rows ?? []) as T[];
117
- }
118
-
119
- async getOptional<T>(sql: string, parameters?: any[]): Promise<T | null> {
120
- const result = await this.DB.execute(sql, parameters);
121
- return (result.rows?.[0] as T) ?? null;
122
- }
123
-
124
- async get<T>(sql: string, parameters?: any[]): Promise<T> {
125
- const result = await this.getOptional(sql, parameters);
126
- if (!result) {
127
- throw new Error('Result set is empty');
128
- }
129
- return result as T;
130
- }
131
-
118
+ export class OPSQLiteConnection extends DBGetUtilsDefaultMixin(OPSQLiteExecutor) implements LockContext {
132
119
  async refreshSchema() {
133
120
  await this.get("PRAGMA table_info('sqlite_master')");
134
121
  }
@@ -1,14 +1,16 @@
1
1
  import { getDylibPath, open, type DB } from '@op-engineering/op-sqlite';
2
2
  import {
3
3
  BaseObserver,
4
+ ConnectionPool,
4
5
  DBAdapter,
6
+ DBAdapterDefaultMixin,
5
7
  DBAdapterListener,
6
8
  DBLockOptions,
7
9
  QueryResult,
8
10
  Transaction,
9
- mutexRunExclusive
11
+ timeoutSignal,
12
+ Semaphore
10
13
  } from '@powersync/common';
11
- import { Mutex } from 'async-mutex';
12
14
  import { Platform } from 'react-native';
13
15
  import { OPSQLiteConnection } from './OPSQLiteConnection';
14
16
  import { SqliteOptions } from './SqliteOptions';
@@ -24,24 +26,20 @@ export type OPSQLiteAdapterOptions = {
24
26
 
25
27
  const READ_CONNECTIONS = 5;
26
28
 
27
- export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implements DBAdapter {
29
+ class OPSQLiteConnectionPool extends BaseObserver<DBAdapterListener> implements ConnectionPool {
28
30
  name: string;
29
- protected writeMutex: Mutex;
30
31
 
31
32
  protected initialized: Promise<void>;
32
33
 
33
- protected readConnections: Array<{ busy: boolean; connection: OPSQLiteConnection }> | null;
34
+ protected readConnections: Semaphore<OPSQLiteConnection> | null;
35
+ protected writeConnection: Semaphore<OPSQLiteConnection> | null;
34
36
 
35
- protected writeConnection: OPSQLiteConnection | null;
36
-
37
- private readQueue: Array<() => void> = [];
38
37
  private abortController: AbortController;
39
38
 
40
39
  constructor(protected options: OPSQLiteAdapterOptions) {
41
40
  super();
42
41
  this.name = this.options.name;
43
42
 
44
- this.writeMutex = new Mutex();
45
43
  this.readConnections = null;
46
44
  this.writeConnection = null;
47
45
  this.abortController = new AbortController();
@@ -53,7 +51,7 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
53
51
  this.options.sqliteOptions!;
54
52
  const dbFilename = this.options.name;
55
53
 
56
- this.writeConnection = await this.openConnection(dbFilename);
54
+ const underlyingWriteConnection = await this.openConnection(dbFilename);
57
55
 
58
56
  const baseStatements = [
59
57
  `PRAGMA busy_timeout = ${lockTimeoutMs}`,
@@ -73,7 +71,7 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
73
71
  for (const statement of writeConnectionStatements) {
74
72
  for (let tries = 0; tries < 30; tries++) {
75
73
  try {
76
- await this.writeConnection!.execute(statement);
74
+ await underlyingWriteConnection.execute(statement);
77
75
  break;
78
76
  } catch (e: any) {
79
77
  if (e instanceof Error && e.message.includes('database is locked') && tries < 29) {
@@ -86,18 +84,21 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
86
84
  }
87
85
 
88
86
  // Changes should only occur in the write connection
89
- this.writeConnection!.registerListener({
87
+ underlyingWriteConnection.registerListener({
90
88
  tablesUpdated: (notification) => this.iterateListeners((cb) => cb.tablesUpdated?.(notification))
91
89
  });
92
90
 
93
- this.readConnections = [];
91
+ const underlyingReadConnections = [];
94
92
  for (let i = 0; i < READ_CONNECTIONS; i++) {
95
93
  const conn = await this.openConnection(dbFilename);
96
94
  for (let statement of readConnectionStatements) {
97
95
  await conn.execute(statement);
98
96
  }
99
- this.readConnections.push({ busy: false, connection: conn });
97
+ underlyingReadConnections.push(conn);
100
98
  }
99
+
100
+ this.writeConnection = new Semaphore([underlyingWriteConnection]);
101
+ this.readConnections = new Semaphore(underlyingReadConnections);
101
102
  }
102
103
 
103
104
  protected async openConnection(filenameOverride?: string): Promise<OPSQLiteConnection> {
@@ -153,165 +154,89 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
153
154
  await this.initialized;
154
155
  // Abort any pending operations
155
156
  this.abortController.abort();
156
- this.readQueue = [];
157
-
158
- this.writeConnection!.close();
159
- this.readConnections!.forEach((c) => c.connection.close());
160
- }
161
-
162
- async readLock<T>(fn: (tx: OPSQLiteConnection) => Promise<T>, options?: DBLockOptions): Promise<T> {
163
- await this.initialized;
164
- return new Promise(async (resolve, reject) => {
165
- const execute = async () => {
166
- // Find an available connection that is not busy
167
- const availableConnection = this.readConnections!.find((conn) => !conn.busy);
168
-
169
- // If we have an available connection, use it
170
- if (availableConnection) {
171
- availableConnection.busy = true;
172
- try {
173
- resolve(await fn(availableConnection.connection));
174
- } catch (error) {
175
- reject(error);
176
- } finally {
177
- availableConnection.busy = false;
178
- // After query execution, process any queued tasks
179
- this.processQueue();
180
- }
181
- } else {
182
- // If no available connections, add to the queue
183
- this.readQueue.push(execute);
184
- }
185
- };
186
157
 
187
- execute();
188
- });
189
- }
158
+ const { item: writeConnection, release: returnWrite } = await this.writeConnection!.requestOne();
159
+ const { items: readers, release: returnReaders } = await this.readConnections!.requestAll();
190
160
 
191
- private async processQueue(): Promise<void> {
192
- if (this.readQueue.length > 0) {
193
- const next = this.readQueue.shift();
194
- if (next) {
195
- next();
196
- }
161
+ try {
162
+ writeConnection.close();
163
+ readers.forEach((c) => c.close());
164
+ } finally {
165
+ returnWrite();
166
+ returnReaders();
197
167
  }
198
168
  }
199
169
 
200
- async writeLock<T>(fn: (tx: OPSQLiteConnection) => Promise<T>, options?: DBLockOptions): Promise<T> {
201
- await this.initialized;
170
+ private generateNestedAbortSignal(options?: DBLockOptions) {
171
+ const outerSignal = this.abortController.signal;
172
+ let signal: AbortSignal;
173
+ let cleanUpInnerSignal: (() => void) | undefined;
202
174
 
203
- return new Promise(async (resolve, reject) => {
204
- // Set up abort signal listener
205
- const abortListener = () => {
206
- reject(new Error('Database connection was closed'));
175
+ if (options?.timeoutMs && !outerSignal.aborted) {
176
+ // This is essentially an AbortSignal.any() polyfill.
177
+ const innerController = new AbortController();
178
+ cleanUpInnerSignal = () => {
179
+ innerController.abort();
180
+ outerSignal.removeEventListener('abort', cleanUpInnerSignal!);
181
+ timeout.removeEventListener('abort', cleanUpInnerSignal!);
207
182
  };
208
- this.abortController.signal.addEventListener('abort', abortListener);
209
-
210
- try {
211
- await mutexRunExclusive(
212
- this.writeMutex,
213
- async () => {
214
- // Check if operation was aborted before executing
215
- if (this.abortController.signal.aborted) {
216
- reject(new Error('Database connection was closed'));
217
- }
218
- resolve(await fn(this.writeConnection!));
219
- },
220
- options
221
- );
222
- // flush updates once a write lock has been released
223
- this.writeConnection!.flushUpdates();
224
- } catch (ex) {
225
- reject(ex);
226
- } finally {
227
- this.abortController.signal.removeEventListener('abort', abortListener);
228
- }
229
- });
230
- }
231
-
232
- readTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions): Promise<T> {
233
- return this.readLock((ctx) => this.internalTransaction(ctx, fn));
234
- }
235
183
 
236
- writeTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions): Promise<T> {
237
- return this.writeLock((ctx) => this.internalTransaction(ctx, fn));
238
- }
239
-
240
- getAll<T>(sql: string, parameters?: any[]): Promise<T[]> {
241
- return this.readLock((ctx) => ctx.getAll(sql, parameters));
242
- }
184
+ outerSignal.addEventListener('abort', cleanUpInnerSignal);
185
+ const timeout = timeoutSignal(options.timeoutMs);
186
+ timeout.addEventListener('abort', cleanUpInnerSignal);
243
187
 
244
- getOptional<T>(sql: string, parameters?: any[]): Promise<T | null> {
245
- return this.readLock((ctx) => ctx.getOptional(sql, parameters));
246
- }
188
+ signal = innerController.signal;
189
+ } else {
190
+ signal = outerSignal;
191
+ }
247
192
 
248
- get<T>(sql: string, parameters?: any[]): Promise<T> {
249
- return this.readLock((ctx) => ctx.get(sql, parameters));
193
+ return { signal, cleanUpInnerSignal };
250
194
  }
251
195
 
252
- execute(query: string, params?: any[]) {
253
- return this.writeLock((ctx) => ctx.execute(query, params));
254
- }
196
+ async readLock<T>(fn: (tx: OPSQLiteConnection) => Promise<T>, options?: DBLockOptions): Promise<T> {
197
+ await this.initialized;
255
198
 
256
- executeRaw(query: string, params?: any[]) {
257
- return this.writeLock((ctx) => ctx.executeRaw(query, params));
199
+ const { signal, cleanUpInnerSignal } = this.generateNestedAbortSignal(options);
200
+ const { item, release } = await this.readConnections!.requestOne(signal);
201
+ try {
202
+ return await fn(item);
203
+ } finally {
204
+ release();
205
+ cleanUpInnerSignal?.();
206
+ }
258
207
  }
259
208
 
260
- async executeBatch(query: string, params: any[][] = []): Promise<QueryResult> {
261
- return this.writeLock((ctx) => ctx.executeBatch(query, params));
262
- }
209
+ async writeLock<T>(fn: (tx: OPSQLiteConnection) => Promise<T>, options?: DBLockOptions): Promise<T> {
210
+ await this.initialized;
263
211
 
264
- protected async internalTransaction<T>(
265
- connection: OPSQLiteConnection,
266
- fn: (tx: Transaction) => Promise<T>
267
- ): Promise<T> {
268
- let finalized = false;
269
- const commit = async (): Promise<QueryResult> => {
270
- if (finalized) {
271
- return { rowsAffected: 0 };
272
- }
273
- finalized = true;
274
- return connection.execute('COMMIT');
275
- };
276
- const rollback = async (): Promise<QueryResult> => {
277
- if (finalized) {
278
- return { rowsAffected: 0 };
279
- }
280
- finalized = true;
281
- return connection.execute('ROLLBACK');
282
- };
212
+ const { signal, cleanUpInnerSignal } = this.generateNestedAbortSignal(options);
213
+ const { item, release } = await this.writeConnection!.requestOne(signal);
283
214
  try {
284
- await connection.execute('BEGIN');
285
- const result = await fn({
286
- execute: (query, params) => connection.execute(query, params),
287
- executeRaw: (query, params) => connection.executeRaw(query, params),
288
- get: (query, params) => connection.get(query, params),
289
- getAll: (query, params) => connection.getAll(query, params),
290
- getOptional: (query, params) => connection.getOptional(query, params),
291
- commit,
292
- rollback
293
- });
294
- await commit();
295
- return result;
296
- } catch (ex) {
297
- try {
298
- await rollback();
299
- } catch (ex2) {
300
- // In rare cases, a rollback may fail.
301
- // Safe to ignore.
302
- }
303
- throw ex;
215
+ return await fn(item).finally(() => item.flushUpdates());
216
+ } finally {
217
+ release();
218
+ cleanUpInnerSignal?.();
304
219
  }
305
220
  }
306
221
 
307
222
  async refreshSchema(): Promise<void> {
308
223
  await this.initialized;
309
- await this.writeConnection!.refreshSchema();
310
-
311
- if (this.readConnections) {
312
- for (let readConnection of this.readConnections) {
313
- await readConnection.connection.refreshSchema();
224
+ await this.writeLock((l) => l.refreshSchema());
225
+ const { items, release } = await this.readConnections!.requestAll();
226
+ try {
227
+ for (let readConnection of items) {
228
+ await readConnection.refreshSchema();
314
229
  }
230
+ } finally {
231
+ release();
315
232
  }
316
233
  }
317
234
  }
235
+
236
+ export class OPSQLiteDBAdapter extends DBAdapterDefaultMixin(OPSQLiteConnectionPool) implements DBAdapter {
237
+ async executeBatch(query: string, params: any[][] = []): Promise<QueryResult> {
238
+ return await this.writeLock(async (tx) => {
239
+ return await (tx as OPSQLiteConnection).executeNativeBatch(query, params);
240
+ });
241
+ }
242
+ }