@powersync/web 0.0.0-dev-20251126195153 → 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.
@@ -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,42 +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
- return {
326
- shouldReconnect,
327
- trackedPort
328
- };
329
- });
330
-
331
- if (!trackedPort) {
332
- // We could not find the port to remove
333
- return () => {};
334
- }
335
-
336
- for (const closeListener of trackedPort.closeListeners) {
337
- await closeListener();
338
- }
339
-
340
- if (this.dbAdapter && this.dbAdapter == trackedPort.db) {
341
- // Unconditionally close the connection because the database it's writing to has just been closed.
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));
346
-
347
- // Clearing the adapter will result in a new one being opened in connect
348
- this.dbAdapter = null;
349
-
350
- if (shouldReconnect) {
351
- await this.connectionManager.connect(CONNECTOR_PLACEHOLDER, this.lastConnectOptions ?? {});
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();
352
358
  }
353
- }
354
359
 
355
- // Re-index subscriptions, the subscriptions of the removed port would no longer be considered.
356
- this.collectActiveSubscriptions();
360
+ this.collectActiveSubscriptions();
357
361
 
358
- // Release proxy
359
- return () => trackedPort.clientProvider[Comlink.releaseProxy]();
362
+ return () => trackedPort.clientProvider[Comlink.releaseProxy]();
363
+ });
360
364
  }
361
365
 
362
366
  triggerCrudUpload() {
@@ -401,11 +405,14 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
401
405
  const syncParams = this.syncParams!;
402
406
  // Create a new StreamingSyncImplementation for each connect call. This is usually done is all SDKs.
403
407
  return new WebStreamingSyncImplementation({
404
- adapter: new SqliteBucketStorage(this.dbAdapter!, this.logger),
408
+ adapter: new SqliteBucketStorage(this.distributedDB!, this.logger),
405
409
  remote: new WebRemote(
406
410
  {
407
411
  invalidateCredentials: async () => {
408
- 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
+ }
409
416
  try {
410
417
  this.logger.log('calling the last port client provider to invalidate credentials');
411
418
  lastPort.clientProvider.invalidateCredentials();
@@ -414,7 +421,10 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
414
421
  }
415
422
  },
416
423
  fetchCredentials: async () => {
417
- 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
+ }
418
428
  return new Promise(async (resolve, reject) => {
419
429
  const abortController = new AbortController();
420
430
  this.fetchCredentialsController = {
@@ -437,7 +447,10 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
437
447
  this.logger
438
448
  ),
439
449
  uploadCrud: async () => {
440
- 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
+ }
441
454
 
442
455
  return new Promise(async (resolve, reject) => {
443
456
  const abortController = new AbortController();
@@ -464,19 +477,57 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
464
477
  });
465
478
  }
466
479
 
480
+ /**
481
+ * Opens a worker wrapped database connection. Using the last connected client port.
482
+ */
467
483
  protected async openInternalDB() {
468
- const lastClient = this.ports[this.ports.length - 1];
469
- if (!lastClient) {
470
- // Should not really happen in practice
471
- throw new Error(`Could not open DB connection since no client is connected.`);
472
- }
473
- const workerPort = await lastClient.clientProvider.getDBWorkerPort();
474
- const remote = Comlink.wrap<OpenAsyncDatabaseConnection>(workerPort);
475
- const identifier = this.syncParams!.dbParams.dbFilename;
476
- const db = await remote(this.syncParams!.dbParams);
477
- const locked = new LockedAsyncDatabaseAdapter({
478
- name: identifier,
479
- 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
+
480
531
  const wrapped = new WorkerWrappedAsyncDatabaseConnection({
481
532
  remote,
482
533
  baseConnection: db,
@@ -487,16 +538,21 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
487
538
  });
488
539
  lastClient.closeListeners.push(async () => {
489
540
  this.logger.info('Aborting open connection because associated tab closed.');
541
+ /**
542
+ * Don't await this close operation. It might never resolve if the tab is closed.
543
+ * We run the close operation first, before marking the remote as closed. This gives the database some chance
544
+ * to close the connection.
545
+ */
490
546
  wrapped.close().catch((ex) => this.logger.warn('error closing database connection', ex));
491
547
  wrapped.markRemoteClosed();
492
548
  });
493
549
 
494
550
  return wrapped;
495
- },
496
- logger: this.logger
497
- });
498
- await locked.init();
499
- 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
+ }
500
556
  }
501
557
 
502
558
  /**
@@ -521,3 +577,31 @@ export class SharedSyncImplementation extends BaseObserver<SharedSyncImplementat
521
577
  this.updateAllStatuses(status);
522
578
  }
523
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
+ }
@@ -1,4 +1,6 @@
1
+ import { ILogLevel, PowerSyncConnectionOptions, SubscribedStream, SyncStatusOptions } from '@powersync/common';
1
2
  import * as Comlink from 'comlink';
3
+ import { getNavigatorLocks } from '../../shared/navigator';
2
4
  import {
3
5
  ManualSharedSyncPayload,
4
6
  SharedSyncClientEvent,
@@ -6,8 +8,6 @@ import {
6
8
  SharedSyncInitOptions,
7
9
  WrappedSyncPort
8
10
  } from './SharedSyncImplementation';
9
- import { ILogLevel, PowerSyncConnectionOptions, SubscribedStream, SyncStatusOptions } from '@powersync/common';
10
- import { getNavigatorLocks } from '../../shared/navigator';
11
11
 
12
12
  /**
13
13
  * A client to the shared sync worker.
@@ -17,11 +17,14 @@ import { getNavigatorLocks } from '../../shared/navigator';
17
17
  */
18
18
  export class WorkerClient {
19
19
  private resolvedPort: WrappedSyncPort | null = null;
20
+ protected resolvedPortPromise: Promise<WrappedSyncPort> | null = null;
20
21
 
21
22
  constructor(
22
23
  private readonly sync: SharedSyncImplementation,
23
24
  private readonly port: MessagePort
24
- ) {}
25
+ ) {
26
+ Comlink.expose(this, this.port);
27
+ }
25
28
 
26
29
  async initialize() {
27
30
  /**
@@ -35,8 +38,15 @@ export class WorkerClient {
35
38
  }
36
39
  });
37
40
 
38
- this.resolvedPort = await this.sync.addPort(this.port);
39
- Comlink.expose(this, this.port);
41
+ /**
42
+ * Keep a reference to the resolved port promise.
43
+ * The init timing is difficult to predict due to the async message passing.
44
+ * We only want to use a port if we are know it's been protected from being closed.
45
+ * The lock based close signal will be added asynchronously. We need to use the
46
+ * added port once the lock is configured.
47
+ */
48
+ this.resolvedPortPromise = this.sync.addPort(this.port);
49
+ this.resolvedPort = await this.resolvedPortPromise;
40
50
  }
41
51
 
42
52
  private async removePort() {
@@ -59,7 +69,19 @@ export class WorkerClient {
59
69
  * When the client tab is closed, its lock will be returned. So when the shared worker attempts to acquire the lock,
60
70
  * it can consider the connection to be closed.
61
71
  */
62
- addLockBasedCloseSignal(name: string) {
72
+ async addLockBasedCloseSignal(name: string) {
73
+ if (!this.resolvedPortPromise) {
74
+ // The init logic above is actually synchronous, so this should not happen.
75
+ this.sync.broadCastLogger.warn('addLockBasedCloseSignal called before port promise registered');
76
+ } else {
77
+ const wrappedPort = await this.resolvedPortPromise;
78
+ /**
79
+ * The client registered a navigator lock. We now can guarantee detecting if the client has closed.
80
+ * E.g. before this point: It's possible some ports might have been created and closed before the
81
+ * lock based close signal is added. We should not trust those ports.
82
+ */
83
+ wrappedPort.isProtectedFromClose = true;
84
+ }
63
85
  getNavigatorLocks().request(name, async () => {
64
86
  await this.removePort();
65
87
  });