@powersync/web 1.28.1 → 1.29.0

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.
Files changed (35) hide show
  1. package/dist/index.umd.js +71 -9
  2. package/dist/index.umd.js.map +1 -1
  3. package/dist/worker/SharedSyncImplementation.umd.js +8414 -4438
  4. package/dist/worker/SharedSyncImplementation.umd.js.map +1 -1
  5. package/dist/worker/WASQLiteDB.umd.js +12887 -10914
  6. package/dist/worker/WASQLiteDB.umd.js.map +1 -1
  7. package/lib/package.json +2 -2
  8. package/lib/src/db/adapters/AsyncDatabaseConnection.d.ts +15 -0
  9. package/lib/src/db/adapters/LockedAsyncDatabaseAdapter.d.ts +2 -1
  10. package/lib/src/db/adapters/LockedAsyncDatabaseAdapter.js +17 -2
  11. package/lib/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.d.ts +3 -0
  12. package/lib/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.js +11 -0
  13. package/lib/src/db/adapters/wa-sqlite/WASQLiteConnection.d.ts +22 -0
  14. package/lib/src/db/adapters/wa-sqlite/WASQLiteConnection.js +32 -0
  15. package/lib/src/worker/db/SharedWASQLiteConnection.d.ts +42 -0
  16. package/lib/src/worker/db/SharedWASQLiteConnection.js +90 -0
  17. package/lib/src/worker/db/WASQLiteDB.worker.js +22 -39
  18. package/lib/src/worker/db/WorkerWASQLiteConnection.d.ts +9 -0
  19. package/lib/src/worker/db/WorkerWASQLiteConnection.js +12 -0
  20. package/lib/src/worker/sync/SharedSyncImplementation.d.ts +2 -2
  21. package/lib/src/worker/sync/SharedSyncImplementation.js +7 -4
  22. package/lib/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +3 -3
  24. package/src/db/adapters/AsyncDatabaseConnection.ts +15 -0
  25. package/src/db/adapters/LockedAsyncDatabaseAdapter.ts +29 -10
  26. package/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts +14 -0
  27. package/src/db/adapters/wa-sqlite/WASQLiteConnection.ts +45 -0
  28. package/src/worker/db/SharedWASQLiteConnection.ts +131 -0
  29. package/src/worker/db/WASQLiteDB.worker.ts +25 -54
  30. package/src/worker/db/WorkerWASQLiteConnection.ts +14 -0
  31. package/src/worker/sync/SharedSyncImplementation.ts +15 -12
  32. package/dist/_journeyapps_wa-sqlite-_journeyapps_wa-sqlite_src_examples_AccessHandlePoolVFS_js-_journeyapp-81d3460.index.umd.js +0 -355
  33. package/dist/_journeyapps_wa-sqlite-_journeyapps_wa-sqlite_src_examples_AccessHandlePoolVFS_js-_journeyapp-81d3460.index.umd.js.map +0 -1
  34. package/dist/_journeyapps_wa-sqlite-_journeyapps_wa-sqlite_src_examples_AccessHandlePoolVFS_js-_journeyapp-81d3461.index.umd.js +0 -355
  35. package/dist/_journeyapps_wa-sqlite-_journeyapps_wa-sqlite_src_examples_AccessHandlePoolVFS_js-_journeyapp-81d3461.index.umd.js.map +0 -1
@@ -60,12 +60,26 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
60
60
  this.notifyRemoteClosed!.abort();
61
61
  }
62
62
 
63
+ markHold(): Promise<string> {
64
+ return this.withRemote(() => this.baseConnection.markHold());
65
+ }
66
+
67
+ releaseHold(holdId: string): Promise<void> {
68
+ return this.withRemote(() => this.baseConnection.releaseHold(holdId));
69
+ }
70
+
71
+ isAutoCommit(): Promise<boolean> {
72
+ return this.withRemote(() => this.baseConnection.isAutoCommit());
73
+ }
74
+
63
75
  private withRemote<T>(workerPromise: () => Promise<T>): Promise<T> {
64
76
  const controller = this.notifyRemoteClosed;
65
77
  if (controller) {
66
78
  return new Promise((resolve, reject) => {
67
79
  if (controller.signal.aborted) {
68
80
  reject(new Error('Called operation on closed remote'));
81
+ // Don't run the operation if we're going to reject
82
+ return;
69
83
  }
70
84
 
71
85
  function handleAbort() {
@@ -26,6 +26,14 @@ export type WASQLiteBroadCastTableUpdateEvent = {
26
26
  */
27
27
  export type WASQLiteConnectionListener = {
28
28
  tablesUpdated: (event: BatchedUpdateNotification) => void;
29
+ /**
30
+ * Triggered when an active hold is overwritten by a new hold.
31
+ * This is most likely to happen when a shared connection has been closed
32
+ * without releasing the hold.
33
+ * This listener can be used to cleanup any resources associated with the previous hold.
34
+ * @param holdId - The id of the hold that has been overwritten.
35
+ */
36
+ holdOverwritten: (holdId: string) => Promise<void>;
29
37
  };
30
38
 
31
39
  /**
@@ -148,6 +156,9 @@ export class WASqliteConnection
148
156
  */
149
157
  protected connectionId: number;
150
158
 
159
+ protected _holdCounter: number;
160
+ protected _holdId: string | null;
161
+
151
162
  constructor(protected options: ResolvedWASQLiteOpenFactoryOptions) {
152
163
  super();
153
164
  this.updatedTables = new Set();
@@ -156,6 +167,16 @@ export class WASqliteConnection
156
167
  this.connectionId = new Date().valueOf() + Math.random();
157
168
  this.statementMutex = new Mutex();
158
169
  this._moduleFactory = DEFAULT_MODULE_FACTORIES[this.options.vfs];
170
+ this._holdCounter = 0;
171
+ this._holdId = null;
172
+ }
173
+
174
+ /**
175
+ * Gets the id for the current hold.
176
+ * This can be used to check for invalid states.
177
+ */
178
+ get currentHoldId() {
179
+ return this._holdId;
159
180
  }
160
181
 
161
182
  protected get sqliteAPI() {
@@ -172,6 +193,30 @@ export class WASqliteConnection
172
193
  return this._dbP;
173
194
  }
174
195
 
196
+ /**
197
+ * Checks if the database connection is in autocommit mode.
198
+ * @returns true if in autocommit mode, false if in a transaction
199
+ */
200
+ async isAutoCommit(): Promise<boolean> {
201
+ return this.sqliteAPI.get_autocommit(this.dbP) != 0;
202
+ }
203
+
204
+ async markHold(): Promise<string> {
205
+ const previousHoldId = this._holdId;
206
+ this._holdId = `${++this._holdCounter}`;
207
+ if (previousHoldId) {
208
+ await this.iterateAsyncListeners(async (cb) => cb.holdOverwritten?.(previousHoldId));
209
+ }
210
+ return this._holdId;
211
+ }
212
+
213
+ async releaseHold(holdId: string): Promise<void> {
214
+ if (holdId != this._holdId) {
215
+ throw new Error(`Invalid hold state, expected ${this._holdId} but got ${holdId}`);
216
+ }
217
+ this._holdId = null;
218
+ }
219
+
175
220
  protected async openDB() {
176
221
  this._dbP = await this.sqliteAPI.open_v2(this.options.dbFilename);
177
222
  return this._dbP;
@@ -0,0 +1,131 @@
1
+ import { ILogger } from '@powersync/common';
2
+ import {
3
+ AsyncDatabaseConnection,
4
+ OnTableChangeCallback,
5
+ ProxiedQueryResult
6
+ } from '../../db/adapters/AsyncDatabaseConnection';
7
+ import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags';
8
+
9
+ /**
10
+ * Keeps track of open DB connections and the clients which
11
+ * are using it.
12
+ */
13
+ export type SharedDBWorkerConnection = {
14
+ clientIds: Set<number>;
15
+ db: AsyncDatabaseConnection;
16
+ };
17
+
18
+ export type SharedWASQLiteConnectionOptions = {
19
+ dbMap: Map<string, SharedDBWorkerConnection>;
20
+ dbFilename: string;
21
+ clientId: number;
22
+ logger: ILogger;
23
+ };
24
+
25
+ export class SharedWASQLiteConnection implements AsyncDatabaseConnection {
26
+ protected isClosing: boolean;
27
+ // Keeps track if this current hold if the shared connection has a hold
28
+ protected activeHoldId: string | null;
29
+
30
+ constructor(protected options: SharedWASQLiteConnectionOptions) {
31
+ // Add this client ID to the set of known clients
32
+ this.clientIds.add(options.clientId);
33
+ this.isClosing = false;
34
+ this.activeHoldId = null;
35
+ }
36
+
37
+ protected get logger() {
38
+ return this.options.logger;
39
+ }
40
+
41
+ protected get dbEntry() {
42
+ return this.options.dbMap.get(this.options.dbFilename)!;
43
+ }
44
+
45
+ protected get connection() {
46
+ return this.dbEntry.db;
47
+ }
48
+
49
+ protected get clientIds() {
50
+ return this.dbEntry.clientIds;
51
+ }
52
+
53
+ async init(): Promise<void> {
54
+ // No-op since the connection is already initialized when it was created
55
+ }
56
+
57
+ async markHold(): Promise<string> {
58
+ this.activeHoldId = await this.connection.markHold();
59
+ return this.activeHoldId;
60
+ }
61
+
62
+ async releaseHold(id: string): Promise<void> {
63
+ try {
64
+ await this.connection.releaseHold(id);
65
+ } finally {
66
+ this.activeHoldId = null;
67
+ }
68
+ }
69
+
70
+ async isAutoCommit(): Promise<boolean> {
71
+ return this.connection.isAutoCommit();
72
+ }
73
+
74
+ /**
75
+ * Handles closing of a shared connection.
76
+ * The connection is only closed if there are no active clients using it.
77
+ */
78
+ async close(): Promise<void> {
79
+ // This prevents further statements on this connection from being executed
80
+ this.isClosing = true;
81
+ const { clientIds, logger } = this;
82
+ const { clientId, dbFilename, dbMap } = this.options;
83
+ logger.debug(`Close requested from client ${clientId} of ${[...clientIds]}`);
84
+ clientIds.delete(clientId);
85
+
86
+ if (this.activeHoldId) {
87
+ // We can't cleanup here since we're not in a lock context.
88
+ // The cleanup will occur once a new hold is acquired.
89
+ this.logger.info(
90
+ `Hold ${this.activeHoldId} was still active when the connection was closed. Cleanup will occur once a new hold is acquired.`
91
+ );
92
+ }
93
+
94
+ if (clientIds.size == 0) {
95
+ logger.debug(`Closing connection to ${this.options}.`);
96
+ const connection = this.connection;
97
+ dbMap.delete(dbFilename);
98
+ await connection.close();
99
+ return;
100
+ }
101
+ logger.debug(`Connection to ${dbFilename} not closed yet due to active clients.`);
102
+ return;
103
+ }
104
+
105
+ protected async withClosing<T>(action: () => Promise<T>) {
106
+ if (this.isClosing) {
107
+ throw new Error('Connection is closing');
108
+ }
109
+ return action();
110
+ }
111
+
112
+ async execute(sql: string, params?: any[]): Promise<ProxiedQueryResult> {
113
+ return this.withClosing(() => this.connection.execute(sql, params));
114
+ }
115
+
116
+ async executeRaw(sql: string, params?: any[]): Promise<any[][]> {
117
+ return this.withClosing(() => this.connection.executeRaw(sql, params));
118
+ }
119
+
120
+ executeBatch(sql: string, params?: any[] | undefined): Promise<ProxiedQueryResult> {
121
+ return this.withClosing(() => this.connection.executeBatch(sql, params));
122
+ }
123
+
124
+ registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void> {
125
+ return this.connection.registerOnTableChange(callback);
126
+ }
127
+
128
+ getConfig(): Promise<ResolvedWebSQLOpenOptions> {
129
+ return this.connection.getConfig();
130
+ }
131
+ }
@@ -6,47 +6,20 @@ import '@journeyapps/wa-sqlite';
6
6
  import { createBaseLogger, createLogger } from '@powersync/common';
7
7
  import * as Comlink from 'comlink';
8
8
  import { AsyncDatabaseConnection } from '../../db/adapters/AsyncDatabaseConnection';
9
- import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
10
- import {
11
- ResolvedWASQLiteOpenFactoryOptions,
12
- WorkerDBOpenerOptions
13
- } from '../../db/adapters/wa-sqlite/WASQLiteOpenFactory';
9
+ import { WorkerDBOpenerOptions } from '../../db/adapters/wa-sqlite/WASQLiteOpenFactory';
14
10
  import { getNavigatorLocks } from '../../shared/navigator';
11
+ import { SharedDBWorkerConnection, SharedWASQLiteConnection } from './SharedWASQLiteConnection';
12
+ import { WorkerWASQLiteConnection } from './WorkerWASQLiteConnection';
15
13
 
16
14
  const baseLogger = createBaseLogger();
17
15
  baseLogger.useDefaults();
18
16
  const logger = createLogger('db-worker');
19
17
 
20
- /**
21
- * Keeps track of open DB connections and the clients which
22
- * are using it.
23
- */
24
- type SharedDBWorkerConnection = {
25
- clientIds: Set<number>;
26
- db: AsyncDatabaseConnection;
27
- };
28
-
29
18
  const DBMap = new Map<string, SharedDBWorkerConnection>();
30
19
  const OPEN_DB_LOCK = 'open-wasqlite-db';
31
20
 
32
21
  let nextClientId = 1;
33
22
 
34
- const openWorkerConnection = async (options: ResolvedWASQLiteOpenFactoryOptions): Promise<AsyncDatabaseConnection> => {
35
- const connection = new WASqliteConnection(options);
36
- return {
37
- init: Comlink.proxy(() => connection.init()),
38
- getConfig: Comlink.proxy(() => connection.getConfig()),
39
- close: Comlink.proxy(() => connection.close()),
40
- execute: Comlink.proxy(async (sql: string, params?: any[]) => connection.execute(sql, params)),
41
- executeRaw: Comlink.proxy(async (sql: string, params?: any[]) => connection.executeRaw(sql, params)),
42
- executeBatch: Comlink.proxy(async (sql: string, params?: any[]) => connection.executeBatch(sql, params)),
43
- registerOnTableChange: Comlink.proxy(async (callback) => {
44
- // Proxy the callback remove function
45
- return Comlink.proxy(await connection.registerOnTableChange(callback));
46
- })
47
- };
48
- };
49
-
50
23
  const openDBShared = async (options: WorkerDBOpenerOptions): Promise<AsyncDatabaseConnection> => {
51
24
  // Prevent multiple simultaneous opens from causing race conditions
52
25
  return getNavigatorLocks().request(OPEN_DB_LOCK, async () => {
@@ -57,38 +30,36 @@ const openDBShared = async (options: WorkerDBOpenerOptions): Promise<AsyncDataba
57
30
 
58
31
  if (!DBMap.has(dbFilename)) {
59
32
  const clientIds = new Set<number>();
60
- const connection = await openWorkerConnection(options);
33
+ // This format returns proxy objects for function callbacks
34
+ const connection = new WorkerWASQLiteConnection(options);
61
35
  await connection.init();
36
+
37
+ connection.registerListener({
38
+ holdOverwritten: async () => {
39
+ /**
40
+ * The previous hold has been overwritten, without being released.
41
+ * we need to cleanup any resources associated with it.
42
+ * We can perform a rollback to release any potential transactions that were started.
43
+ */
44
+ await connection.execute('ROLLBACK').catch(() => {});
45
+ }
46
+ });
47
+
62
48
  DBMap.set(dbFilename, {
63
49
  clientIds,
64
50
  db: connection
65
51
  });
66
52
  }
67
53
 
68
- const dbEntry = DBMap.get(dbFilename)!;
69
- dbEntry.clientIds.add(clientId);
70
- const { db } = dbEntry;
71
-
72
- const wrappedConnection = {
73
- ...db,
74
- init: Comlink.proxy(async () => {
75
- // the init has been done automatically
76
- }),
77
- close: Comlink.proxy(async () => {
78
- const { clientIds } = dbEntry;
79
- logger.debug(`Close requested from client ${clientId} of ${[...clientIds]}`);
80
- clientIds.delete(clientId);
81
- if (clientIds.size == 0) {
82
- logger.debug(`Closing connection to ${dbFilename}.`);
83
- DBMap.delete(dbFilename);
84
- return db.close?.();
85
- }
86
- logger.debug(`Connection to ${dbFilename} not closed yet due to active clients.`);
87
- return;
88
- })
89
- };
54
+ // Associates this clientId with the shared connection entry
55
+ const sharedConnection = new SharedWASQLiteConnection({
56
+ dbMap: DBMap,
57
+ dbFilename,
58
+ clientId,
59
+ logger
60
+ });
90
61
 
91
- return Comlink.proxy(wrappedConnection);
62
+ return Comlink.proxy(sharedConnection);
92
63
  });
93
64
  };
94
65
 
@@ -0,0 +1,14 @@
1
+ import * as Comlink from 'comlink';
2
+ import { OnTableChangeCallback } from '../../db/adapters/AsyncDatabaseConnection';
3
+ import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
4
+
5
+ /**
6
+ * A Small proxy wrapper around the WASqliteConnection.
7
+ * This ensures that certain return types are properly proxied.
8
+ */
9
+ export class WorkerWASQLiteConnection extends WASqliteConnection {
10
+ async registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void> {
11
+ // Proxy the callback remove function
12
+ return Comlink.proxy(await super.registerOnTableChange(callback));
13
+ }
14
+ }
@@ -1,10 +1,4 @@
1
1
  import {
2
- type ILogger,
3
- type ILogLevel,
4
- type PowerSyncConnectionOptions,
5
- type StreamingSyncImplementation,
6
- type StreamingSyncImplementationListener,
7
- type SyncStatusOptions,
8
2
  AbortOperation,
9
3
  BaseObserver,
10
4
  ConnectionManager,
@@ -13,7 +7,13 @@ import {
13
7
  PowerSyncBackendConnector,
14
8
  SqliteBucketStorage,
15
9
  SubscribedStream,
16
- SyncStatus
10
+ SyncStatus,
11
+ type ILogger,
12
+ type ILogLevel,
13
+ type PowerSyncConnectionOptions,
14
+ type StreamingSyncImplementation,
15
+ type StreamingSyncImplementationListener,
16
+ type SyncStatusOptions
17
17
  } from '@powersync/common';
18
18
  import { Mutex } from 'async-mutex';
19
19
  import * as Comlink from 'comlink';
@@ -75,7 +75,7 @@ export type WrappedSyncPort = {
75
75
  clientProvider: Comlink.Remote<AbstractSharedSyncClientProvider>;
76
76
  db?: DBAdapter;
77
77
  currentSubscriptions: SubscribedStream[];
78
- closeListeners: (() => void)[];
78
+ closeListeners: (() => void | Promise<void>)[];
79
79
  };
80
80
 
81
81
  /**
@@ -334,12 +334,15 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
334
334
  }
335
335
 
336
336
  for (const closeListener of trackedPort.closeListeners) {
337
- closeListener();
337
+ await closeListener();
338
338
  }
339
339
 
340
340
  if (this.dbAdapter && this.dbAdapter == trackedPort.db) {
341
341
  // Unconditionally close the connection because the database it's writing to has just been closed.
342
- await this.connectionManager.disconnect();
342
+ // The connection has been closed previously, this might throw. We should be able to ignore it.
343
+ await this.connectionManager
344
+ .disconnect()
345
+ .catch((ex) => this.logger.warn('Error while disconnecting. Will attempt to reconnect.', ex));
343
346
 
344
347
  // Clearing the adapter will result in a new one being opened in connect
345
348
  this.dbAdapter = null;
@@ -482,9 +485,9 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
482
485
  // that and ensure pending requests are aborted when the tab is closed.
483
486
  remoteCanCloseUnexpectedly: true
484
487
  });
485
- lastClient.closeListeners.push(() => {
488
+ lastClient.closeListeners.push(async () => {
486
489
  this.logger.info('Aborting open connection because associated tab closed.');
487
- wrapped.close();
490
+ await wrapped.close().catch((ex) => this.logger.warn('error closing database connection', ex));
488
491
  wrapped.markRemoteClosed();
489
492
  });
490
493