@powersync/web 0.0.0-dev-20251127205344 → 0.0.0-dev-20251129133952

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.
@@ -11,7 +11,7 @@ import {
11
11
  type ILogger
12
12
  } from '@powersync/common';
13
13
  import { getNavigatorLocks } from '../..//shared/navigator';
14
- import { AsyncDatabaseConnection } from './AsyncDatabaseConnection';
14
+ import { AsyncDatabaseConnection, ConnectionClosedError } from './AsyncDatabaseConnection';
15
15
  import { SharedConnectionWorker, WebDBAdapter } from './WebDBAdapter';
16
16
  import { WorkerWrappedAsyncDatabaseConnection } from './WorkerWrappedAsyncDatabaseConnection';
17
17
  import { WASQLiteVFS } from './wa-sqlite/WASQLiteConnection';
@@ -26,10 +26,16 @@ export interface LockedAsyncDatabaseAdapterOptions {
26
26
  openConnection: () => Promise<AsyncDatabaseConnection>;
27
27
  debugMode?: boolean;
28
28
  logger?: ILogger;
29
+ defaultLockTimeoutMs?: number;
30
+ reOpenOnConnectionClosed?: boolean;
29
31
  }
30
32
 
31
33
  export type LockedAsyncDatabaseAdapterListener = DBAdapterListener & {
32
34
  initialized?: () => void;
35
+ /**
36
+ * Fired when the database is re-opened after being closed.
37
+ */
38
+ databaseReOpened?: () => void;
33
39
  };
34
40
 
35
41
  /**
@@ -51,6 +57,8 @@ export class LockedAsyncDatabaseAdapter
51
57
  private _config: ResolvedWebSQLOpenOptions | null = null;
52
58
  protected pendingAbortControllers: Set<AbortController>;
53
59
  protected requiresHolds: boolean | null;
60
+ protected requiresReOpen: boolean;
61
+ protected databaseOpenPromise: Promise<void> | null = null;
54
62
 
55
63
  closing: boolean;
56
64
  closed: boolean;
@@ -63,6 +71,7 @@ export class LockedAsyncDatabaseAdapter
63
71
  this.closed = false;
64
72
  this.closing = false;
65
73
  this.requiresHolds = null;
74
+ this.requiresReOpen = false;
66
75
  // Set the name if provided. We can query for the name if not available yet
67
76
  this.debugMode = options.debugMode ?? false;
68
77
  if (this.debugMode) {
@@ -105,18 +114,31 @@ export class LockedAsyncDatabaseAdapter
105
114
  return this.initPromise;
106
115
  }
107
116
 
108
- protected async _init() {
117
+ protected async openInternalDB() {
118
+ // Dispose any previous table change listener.
119
+ this._disposeTableChangeListener?.();
120
+ this._disposeTableChangeListener = null;
121
+
122
+ const isReOpen = !!this._db;
123
+
109
124
  this._db = await this.options.openConnection();
110
125
  await this._db.init();
111
126
  this._config = await this._db.getConfig();
112
127
  await this.registerOnChangeListener(this._db);
113
- this.iterateListeners((cb) => cb.initialized?.());
128
+ if (isReOpen) {
129
+ this.iterateListeners((cb) => cb.databaseReOpened?.());
130
+ }
114
131
  /**
115
132
  * This is only required for the long-lived shared IndexedDB connections.
116
133
  */
117
134
  this.requiresHolds = (this._config as ResolvedWASQLiteOpenFactoryOptions).vfs == WASQLiteVFS.IDBBatchAtomicVFS;
118
135
  }
119
136
 
137
+ protected async _init() {
138
+ await this.openInternalDB();
139
+ this.iterateListeners((cb) => cb.initialized?.());
140
+ }
141
+
120
142
  getConfiguration(): ResolvedWebSQLOpenOptions {
121
143
  if (!this._config) {
122
144
  throw new Error(`Cannot get config before initialization is completed`);
@@ -196,7 +218,7 @@ export class LockedAsyncDatabaseAdapter
196
218
  return this.acquireLock(
197
219
  async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })),
198
220
  {
199
- timeoutMs: options?.timeoutMs
221
+ timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs
200
222
  }
201
223
  );
202
224
  }
@@ -206,7 +228,7 @@ export class LockedAsyncDatabaseAdapter
206
228
  return this.acquireLock(
207
229
  async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })),
208
230
  {
209
- timeoutMs: options?.timeoutMs
231
+ timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs
210
232
  }
211
233
  );
212
234
  }
@@ -222,7 +244,7 @@ export class LockedAsyncDatabaseAdapter
222
244
  this.pendingAbortControllers.add(abortController);
223
245
  const { timeoutMs } = options ?? {};
224
246
 
225
- const timoutId = timeoutMs
247
+ const timeoutId = timeoutMs
226
248
  ? setTimeout(() => {
227
249
  abortController.abort(`Timeout after ${timeoutMs}ms`);
228
250
  this.pendingAbortControllers.delete(abortController);
@@ -234,12 +256,35 @@ export class LockedAsyncDatabaseAdapter
234
256
  { signal: abortController.signal },
235
257
  async () => {
236
258
  this.pendingAbortControllers.delete(abortController);
237
- if (timoutId) {
238
- clearTimeout(timoutId);
259
+ if (timeoutId) {
260
+ clearTimeout(timeoutId);
239
261
  }
240
- const holdId = this.requiresHolds ? await this.baseDB.markHold() : null;
262
+ let holdId: string | null = null;
241
263
  try {
264
+ // The database is being opened in the background. Wait for it here.
265
+ if (this.databaseOpenPromise) {
266
+ try {
267
+ await this.databaseOpenPromise;
268
+ } catch (ex) {
269
+ // This will cause a retry of opening the database.
270
+ const wrappedError = new ConnectionClosedError('Could not open database');
271
+ wrappedError.cause = ex;
272
+ throw wrappedError;
273
+ }
274
+ }
275
+
276
+ holdId = this.requiresHolds ? await this.baseDB.markHold() : null;
242
277
  return await callback();
278
+ } catch (ex) {
279
+ if (ex instanceof ConnectionClosedError) {
280
+ if (this.options.reOpenOnConnectionClosed && !this.databaseOpenPromise && !this.closing) {
281
+ // Immediately re-open the database. We need to miss as little table updates as possible.
282
+ this.databaseOpenPromise = this.openInternalDB().finally(() => {
283
+ this.databaseOpenPromise = null;
284
+ });
285
+ }
286
+ }
287
+ throw ex;
243
288
  } finally {
244
289
  if (holdId) {
245
290
  await this.baseDB.releaseHold(holdId);
@@ -1,6 +1,7 @@
1
1
  import * as Comlink from 'comlink';
2
2
  import {
3
3
  AsyncDatabaseConnection,
4
+ ConnectionClosedError,
4
5
  OnTableChangeCallback,
5
6
  OpenAsyncDatabaseConnection,
6
7
  ProxiedQueryResult
@@ -77,13 +78,13 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
77
78
  if (controller) {
78
79
  return new Promise((resolve, reject) => {
79
80
  if (controller.signal.aborted) {
80
- reject(new Error('Called operation on closed remote'));
81
+ reject(new ConnectionClosedError('Called operation on closed remote'));
81
82
  // Don't run the operation if we're going to reject
82
83
  return;
83
84
  }
84
85
 
85
86
  function handleAbort() {
86
- reject(new Error('Remote peer closed with request in flight'));
87
+ reject(new ConnectionClosedError('Remote peer closed with request in flight'));
87
88
  }
88
89
 
89
90
  function completePromise(action: () => void) {
@@ -146,7 +146,25 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
146
146
  ).port;
147
147
  }
148
148
 
149
+ /**
150
+ * Pass along any sync status updates to this listener
151
+ */
152
+ this.clientProvider = new SharedSyncClientProvider(
153
+ this.webOptions,
154
+ (status) => {
155
+ this.updateSyncStatus(status);
156
+ },
157
+ options.db
158
+ );
159
+
149
160
  this.syncManager = Comlink.wrap<WorkerClient>(this.messagePort);
161
+ /**
162
+ * The sync worker will call this client provider when it needs
163
+ * to fetch credentials or upload data.
164
+ * This performs bi-directional method calling.
165
+ */
166
+ Comlink.expose(this.clientProvider, this.messagePort);
167
+
150
168
  this.syncManager.setLogLevel(this.logger.getLevel());
151
169
 
152
170
  this.triggerCrudUpload = this.syncManager.triggerCrudUpload;
@@ -157,25 +175,47 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
157
175
  * DB worker, but a port to the DB worker can be transferred to the
158
176
  * sync worker.
159
177
  */
160
- const { crudUploadThrottleMs, identifier, retryDelayMs } = this.options;
161
- const flags = { ...this.webOptions.flags, workers: undefined };
162
178
 
163
- // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which
164
- // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker
165
- // to free resources associated with this tab.
166
- // We take hold of this lock as soon-as-possible in order to cater for potentially closed tabs.
167
- getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => {
168
- if (!this.abortOnClose.signal.aborted) {
179
+ this.isInitialized = this._init();
180
+ }
181
+
182
+ protected async _init() {
183
+ /**
184
+ * The general flow of initialization is:
185
+ * - The client requests a unique navigator lock.
186
+ * - Once the lock is acquired, we register the lock with the shared worker.
187
+ * - The shared worker can then request the same lock. The client has been closed if the shared worker can acquire the lock.
188
+ * - Once the shared worker knows the client's lock, we can guarentee that the shared worker will detect if the client has been closed.
189
+ * - This makes the client safe for the shared worker to use.
190
+ * - The client side lock is held until the client is disposed.
191
+ * - We resolve the top-level promise after the lock has been registered with the shared worker.
192
+ * - The client sends the params to the shared worker after locks have been registered.
193
+ */
194
+ await new Promise<void>((resolve) => {
195
+ // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which
196
+ // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker
197
+ // to free resources associated with this tab.
198
+ // We take hold of this lock as soon-as-possible in order to cater for potentially closed tabs.
199
+ getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => {
200
+ if (this.abortOnClose.signal.aborted) {
201
+ return;
202
+ }
169
203
  // Awaiting here ensures the worker is waiting for the lock
170
204
  await this.syncManager.addLockBasedCloseSignal(lock!.name);
171
205
 
206
+ // The lock has been registered, we can continue with the initialization
207
+ resolve();
208
+
172
209
  await new Promise<void>((r) => {
173
210
  this.abortOnClose.signal.onabort = () => r();
174
211
  });
175
- }
212
+ });
176
213
  });
177
214
 
178
- this.isInitialized = this.syncManager.setParams(
215
+ const { crudUploadThrottleMs, identifier, retryDelayMs } = this.options;
216
+ const flags = { ...this.webOptions.flags, workers: undefined };
217
+
218
+ await this.syncManager.setParams(
179
219
  {
180
220
  dbParams: this.dbAdapter.getConfiguration(),
181
221
  streamOptions: {
@@ -185,26 +225,8 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
185
225
  flags: flags
186
226
  }
187
227
  },
188
- options.subscriptions
189
- );
190
-
191
- /**
192
- * Pass along any sync status updates to this listener
193
- */
194
- this.clientProvider = new SharedSyncClientProvider(
195
- this.webOptions,
196
- (status) => {
197
- this.iterateListeners((l) => this.updateSyncStatus(status));
198
- },
199
- options.db
228
+ this.options.subscriptions
200
229
  );
201
-
202
- /**
203
- * The sync worker will call this client provider when it needs
204
- * to fetch credentials or upload data.
205
- * This performs bi-directional method calling.
206
- */
207
- Comlink.expose(this.clientProvider, this.messagePort);
208
230
  }
209
231
 
210
232
  /**
@@ -2,14 +2,14 @@ import {
2
2
  AbortOperation,
3
3
  BaseObserver,
4
4
  ConnectionManager,
5
- createLogger,
6
5
  DBAdapter,
7
6
  PowerSyncBackendConnector,
8
7
  SqliteBucketStorage,
9
8
  SubscribedStream,
10
9
  SyncStatus,
11
- type ILogger,
10
+ createLogger,
12
11
  type ILogLevel,
12
+ type ILogger,
13
13
  type PowerSyncConnectionOptions,
14
14
  type StreamingSyncImplementation,
15
15
  type StreamingSyncImplementationListener,
@@ -25,8 +25,8 @@ import {
25
25
 
26
26
  import { OpenAsyncDatabaseConnection } from '../../db/adapters/AsyncDatabaseConnection';
27
27
  import { LockedAsyncDatabaseAdapter } from '../../db/adapters/LockedAsyncDatabaseAdapter';
28
- import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags';
29
28
  import { WorkerWrappedAsyncDatabaseConnection } from '../../db/adapters/WorkerWrappedAsyncDatabaseConnection';
29
+ import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags';
30
30
  import { AbstractSharedSyncClientProvider } from './AbstractSharedSyncClientProvider';
31
31
  import { BroadcastLogger } from './BroadcastLogger';
32
32
 
@@ -76,6 +76,11 @@ export type WrappedSyncPort = {
76
76
  db?: DBAdapter;
77
77
  currentSubscriptions: SubscribedStream[];
78
78
  closeListeners: (() => void | Promise<void>)[];
79
+ /**
80
+ * If we can use Navigator locks to detect if the client has closed.
81
+ */
82
+ isProtectedFromClose: boolean;
83
+ isClosing: boolean;
79
84
  };
80
85
 
81
86
  /**
@@ -106,7 +111,6 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
106
111
  protected fetchCredentialsController?: RemoteOperationAbortController;
107
112
  protected uploadDataController?: RemoteOperationAbortController;
108
113
 
109
- protected dbAdapter: DBAdapter | null;
110
114
  protected syncParams: SharedSyncInitOptions | null;
111
115
  protected logger: ILogger;
112
116
  protected lastConnectOptions: PowerSyncConnectionOptions | undefined;
@@ -116,11 +120,11 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
116
120
  protected connectionManager: ConnectionManager;
117
121
  syncStatus: SyncStatus;
118
122
  broadCastLogger: ILogger;
123
+ protected distributedDB: DBAdapter | null;
119
124
 
120
125
  constructor() {
121
126
  super();
122
127
  this.ports = [];
123
- this.dbAdapter = null;
124
128
  this.syncParams = null;
125
129
  this.logger = createLogger('shared-sync');
126
130
  this.lastConnectOptions = undefined;
@@ -135,29 +139,27 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
135
139
  });
136
140
  });
137
141
 
142
+ // Should be configured once we get params
143
+ this.distributedDB = null;
144
+
138
145
  this.syncStatus = new SyncStatus({});
139
146
  this.broadCastLogger = new BroadcastLogger(this.ports);
140
147
 
141
148
  this.connectionManager = new ConnectionManager({
142
149
  createSyncImplementation: async () => {
143
- return this.portMutex.runExclusive(async () => {
144
- await this.waitForReady();
145
- if (!this.dbAdapter) {
146
- await this.openInternalDB();
147
- }
150
+ await this.waitForReady();
148
151
 
149
- const sync = this.generateStreamingImplementation();
150
- const onDispose = sync.registerListener({
151
- statusChanged: (status) => {
152
- this.updateAllStatuses(status.toJSON());
153
- }
154
- });
155
-
156
- return {
157
- sync,
158
- onDispose
159
- };
152
+ const sync = this.generateStreamingImplementation();
153
+ const onDispose = sync.registerListener({
154
+ statusChanged: (status) => {
155
+ this.updateAllStatuses(status.toJSON());
156
+ }
160
157
  });
158
+
159
+ return {
160
+ sync,
161
+ onDispose
162
+ };
161
163
  },
162
164
  logger: this.logger
163
165
  });
@@ -171,6 +173,19 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
171
173
  return this.connectionManager.syncStreamImplementation?.isConnected ?? false;
172
174
  }
173
175
 
176
+ /**
177
+ * Gets the last client port which we know is safe from unexpected closes.
178
+ */
179
+ protected get lastWrappedPort(): WrappedSyncPort | undefined {
180
+ // Find the last port which is protected from close
181
+ for (let i = this.ports.length - 1; i >= 0; i--) {
182
+ if (this.ports[i].isProtectedFromClose && !this.ports[i].isClosing) {
183
+ return this.ports[i];
184
+ }
185
+ }
186
+ return;
187
+ }
188
+
174
189
  async waitForStatus(status: SyncStatusOptions): Promise<void> {
175
190
  return this.withSyncImplementation(async (sync) => {
176
191
  return sync.waitForStatus(status);
@@ -219,11 +234,6 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
219
234
  this.collectActiveSubscriptions();
220
235
  if (this.syncParams) {
221
236
  // Cannot modify already existing sync implementation params
222
- // But we can ask for a DB adapter, if required, at this point.
223
-
224
- if (!this.dbAdapter) {
225
- await this.openInternalDB();
226
- }
227
237
  return;
228
238
  }
229
239
 
@@ -233,15 +243,31 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
233
243
  this.logger = this.broadCastLogger;
234
244
  }
235
245
 
246
+ const lockedAdapter = new LockedAsyncDatabaseAdapter({
247
+ name: params.dbParams.dbFilename,
248
+ openConnection: async () => {
249
+ // Gets a connection from the clients when a new connection is requested.
250
+ return await this.openInternalDB();
251
+ },
252
+ logger: this.logger,
253
+ reOpenOnConnectionClosed: true
254
+ });
255
+ this.distributedDB = lockedAdapter;
256
+ await lockedAdapter.init();
257
+
258
+ lockedAdapter.registerListener({
259
+ databaseReOpened: () => {
260
+ // We may have missed some table updates while the database was closed.
261
+ // We can poke the crud in case we missed any updates.
262
+ this.connectionManager.syncStreamImplementation?.triggerCrudUpload();
263
+ }
264
+ });
265
+
236
266
  self.onerror = (event) => {
237
267
  // Share any uncaught events on the broadcast logger
238
268
  this.logger.error('Uncaught exception in PowerSync shared sync worker', event);
239
269
  };
240
270
 
241
- if (!this.dbAdapter) {
242
- await this.openInternalDB();
243
- }
244
-
245
271
  this.iterateListeners((l) => l.initialized?.());
246
272
  });
247
273
  }
@@ -276,7 +302,9 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
276
302
  port,
277
303
  clientProvider: Comlink.wrap<AbstractSharedSyncClientProvider>(port),
278
304
  currentSubscriptions: [],
279
- closeListeners: []
305
+ closeListeners: [],
306
+ isProtectedFromClose: false,
307
+ isClosing: false
280
308
  } satisfies WrappedSyncPort;
281
309
  this.ports.push(portProvider);
282
310
 
@@ -295,14 +323,17 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
295
323
  * clients.
296
324
  */
297
325
  async removePort(port: WrappedSyncPort) {
326
+ // Ports might be removed faster than we can process them.
327
+ port.isClosing = true;
328
+
298
329
  // Remove the port within a mutex context.
299
330
  // Warns if the port is not found. This should not happen in practice.
300
331
  // We return early if the port is not found.
301
- const { trackedPort, shouldReconnect } = await this.portMutex.runExclusive(async () => {
332
+ return await this.portMutex.runExclusive(async () => {
302
333
  const index = this.ports.findIndex((p) => p == port);
303
334
  if (index < 0) {
304
335
  this.logger.warn(`Could not remove port ${port} since it is not present in active ports.`);
305
- return {};
336
+ return () => {};
306
337
  }
307
338
 
308
339
  const trackedPort = this.ports[index];
@@ -321,47 +352,15 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
321
352
  }
322
353
  });
323
354
 
324
- const shouldReconnect = !!this.connectionManager.syncStreamImplementation && this.ports.length > 0;
325
-
326
- /**
327
- * If the current database adapter is the one that is being closed, we need to disconnect from the backend.
328
- * We can disconnect in the portMutex lock. This ensures the disconnect is not affected by potential other
329
- * connect operations coming from other tabs.
330
- */
331
- if (this.dbAdapter && this.dbAdapter == trackedPort.db) {
332
- this.logger.debug(`Disconnecting due to closed database: should reconnect: ${shouldReconnect}`);
333
- this.dbAdapter = null;
334
- // Unconditionally close the connection because the database it's writing to has just been closed.
335
- // The connection has been closed previously, this might throw. We should be able to ignore it.
336
- await this.connectionManager
337
- .disconnect()
338
- .catch((ex) => this.logger.warn('Error while disconnecting. Will attempt to reconnect.', ex));
355
+ // Close the worker wrapped database connection, we can't accurately rely on this connection
356
+ for (const closeListener of trackedPort.closeListeners) {
357
+ await closeListener();
339
358
  }
340
359
 
341
- return {
342
- shouldReconnect,
343
- trackedPort
344
- };
345
- });
346
-
347
- if (!trackedPort) {
348
- // We could not find the port to remove
349
- return () => {};
350
- }
351
-
352
- for (const closeListener of trackedPort.closeListeners) {
353
- await closeListener();
354
- }
355
-
356
- if (shouldReconnect) {
357
- await this.connectionManager.connect(CONNECTOR_PLACEHOLDER, this.lastConnectOptions ?? {});
358
- }
359
-
360
- // Re-index subscriptions, the subscriptions of the removed port would no longer be considered.
361
- this.collectActiveSubscriptions();
360
+ this.collectActiveSubscriptions();
362
361
 
363
- // Release proxy
364
- return () => trackedPort.clientProvider[Comlink.releaseProxy]();
362
+ return () => trackedPort.clientProvider[Comlink.releaseProxy]();
363
+ });
365
364
  }
366
365
 
367
366
  triggerCrudUpload() {
@@ -406,11 +405,14 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
406
405
  const syncParams = this.syncParams!;
407
406
  // Create a new StreamingSyncImplementation for each connect call. This is usually done is all SDKs.
408
407
  return new WebStreamingSyncImplementation({
409
- adapter: new SqliteBucketStorage(this.dbAdapter!, this.logger),
408
+ adapter: new SqliteBucketStorage(this.distributedDB!, this.logger),
410
409
  remote: new WebRemote(
411
410
  {
412
411
  invalidateCredentials: async () => {
413
- const lastPort = this.ports[this.ports.length - 1];
412
+ const lastPort = this.lastWrappedPort;
413
+ if (!lastPort) {
414
+ throw new Error('No client port found to invalidate credentials');
415
+ }
414
416
  try {
415
417
  this.logger.log('calling the last port client provider to invalidate credentials');
416
418
  lastPort.clientProvider.invalidateCredentials();
@@ -419,7 +421,10 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
419
421
  }
420
422
  },
421
423
  fetchCredentials: async () => {
422
- const lastPort = this.ports[this.ports.length - 1];
424
+ const lastPort = this.lastWrappedPort;
425
+ if (!lastPort) {
426
+ throw new Error('No client port found to fetch credentials');
427
+ }
423
428
  return new Promise(async (resolve, reject) => {
424
429
  const abortController = new AbortController();
425
430
  this.fetchCredentialsController = {
@@ -442,7 +447,10 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
442
447
  this.logger
443
448
  ),
444
449
  uploadCrud: async () => {
445
- const lastPort = this.ports[this.ports.length - 1];
450
+ const lastPort = this.lastWrappedPort;
451
+ if (!lastPort) {
452
+ throw new Error('No client port found to upload crud');
453
+ }
446
454
 
447
455
  return new Promise(async (resolve, reject) => {
448
456
  const abortController = new AbortController();
@@ -469,19 +477,57 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
469
477
  });
470
478
  }
471
479
 
480
+ /**
481
+ * Opens a worker wrapped database connection. Using the last connected client port.
482
+ */
472
483
  protected async openInternalDB() {
473
- const lastClient = this.ports[this.ports.length - 1];
474
- if (!lastClient) {
475
- // Should not really happen in practice
476
- throw new Error(`Could not open DB connection since no client is connected.`);
477
- }
478
- const workerPort = await lastClient.clientProvider.getDBWorkerPort();
479
- const remote = Comlink.wrap<OpenAsyncDatabaseConnection>(workerPort);
480
- const identifier = this.syncParams!.dbParams.dbFilename;
481
- const db = await remote(this.syncParams!.dbParams);
482
- const locked = new LockedAsyncDatabaseAdapter({
483
- name: identifier,
484
- openConnection: async () => {
484
+ while (true) {
485
+ try {
486
+ const lastClient = this.lastWrappedPort;
487
+ if (!lastClient) {
488
+ // Should not really happen in practice
489
+ throw new Error(`Could not open DB connection since no client is connected.`);
490
+ }
491
+
492
+ /**
493
+ * Handle cases where the client might close while opening a connection.
494
+ */
495
+ const abortController = new AbortController();
496
+ const closeListener = () => {
497
+ abortController.abort();
498
+ };
499
+
500
+ const removeCloseListener = () => {
501
+ const index = lastClient.closeListeners.indexOf(closeListener);
502
+ if (index >= 0) {
503
+ lastClient.closeListeners.splice(index, 1);
504
+ }
505
+ };
506
+
507
+ lastClient.closeListeners.push(closeListener);
508
+
509
+ const workerPort = await withAbort(
510
+ () => lastClient.clientProvider.getDBWorkerPort(),
511
+ abortController.signal
512
+ ).catch((ex) => {
513
+ removeCloseListener();
514
+ throw ex;
515
+ });
516
+
517
+ const remote = Comlink.wrap<OpenAsyncDatabaseConnection>(workerPort);
518
+ const identifier = this.syncParams!.dbParams.dbFilename;
519
+
520
+ /**
521
+ * The open could fail if the tab is closed while we're busy opening the database.
522
+ * This operation is typically executed inside an exclusive portMutex lock.
523
+ * We typically execute the closeListeners using the portMutex in a different context.
524
+ * We can't rely on the closeListeners to abort the operation if the tab is closed.
525
+ */
526
+ const db = await withAbort(() => remote(this.syncParams!.dbParams), abortController.signal).finally(() => {
527
+ // We can remove the close listener here since we no longer need it past this point.
528
+ removeCloseListener();
529
+ });
530
+
485
531
  const wrapped = new WorkerWrappedAsyncDatabaseConnection({
486
532
  remote,
487
533
  baseConnection: db,
@@ -502,11 +548,11 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
502
548
  });
503
549
 
504
550
  return wrapped;
505
- },
506
- logger: this.logger
507
- });
508
- await locked.init();
509
- this.dbAdapter = lastClient.db = locked;
551
+ } catch (ex) {
552
+ this.logger.warn('Error opening internal DB', ex);
553
+ await new Promise((resolve) => setTimeout(resolve, 1000));
554
+ }
555
+ }
510
556
  }
511
557
 
512
558
  /**
@@ -531,3 +577,31 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
531
577
  this.updateAllStatuses(status);
532
578
  }
533
579
  }
580
+
581
+ /**
582
+ * Runs the action with an abort controller.
583
+ */
584
+ function withAbort<T>(action: () => Promise<T>, signal: AbortSignal): Promise<T> {
585
+ return new Promise((resolve, reject) => {
586
+ if (signal.aborted) {
587
+ reject(new AbortOperation('Operation aborted by abort controller'));
588
+ return;
589
+ }
590
+
591
+ function handleAbort() {
592
+ signal.removeEventListener('abort', handleAbort);
593
+ reject(new AbortOperation('Operation aborted by abort controller'));
594
+ }
595
+
596
+ signal.addEventListener('abort', handleAbort, { once: true });
597
+
598
+ function completePromise(action: () => void) {
599
+ signal.removeEventListener('abort', handleAbort);
600
+ action();
601
+ }
602
+
603
+ action()
604
+ .then((data) => completePromise(() => resolve(data)))
605
+ .catch((e) => completePromise(() => reject(e)));
606
+ });
607
+ }