@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.
@@ -1,4 +1,4 @@
1
- import { AbortOperation, BaseObserver, ConnectionManager, createLogger, SqliteBucketStorage, SyncStatus } from '@powersync/common';
1
+ import { AbortOperation, BaseObserver, ConnectionManager, SqliteBucketStorage, SyncStatus, createLogger } from '@powersync/common';
2
2
  import { Mutex } from 'async-mutex';
3
3
  import * as Comlink from 'comlink';
4
4
  import { WebRemote } from '../../db/sync/WebRemote';
@@ -35,7 +35,6 @@ export class SharedSyncImplementation extends BaseObserver {
35
35
  statusListener;
36
36
  fetchCredentialsController;
37
37
  uploadDataController;
38
- dbAdapter;
39
38
  syncParams;
40
39
  logger;
41
40
  lastConnectOptions;
@@ -44,10 +43,10 @@ export class SharedSyncImplementation extends BaseObserver {
44
43
  connectionManager;
45
44
  syncStatus;
46
45
  broadCastLogger;
46
+ distributedDB;
47
47
  constructor() {
48
48
  super();
49
49
  this.ports = [];
50
- this.dbAdapter = null;
51
50
  this.syncParams = null;
52
51
  this.logger = createLogger('shared-sync');
53
52
  this.lastConnectOptions = undefined;
@@ -60,26 +59,23 @@ export class SharedSyncImplementation extends BaseObserver {
60
59
  }
61
60
  });
62
61
  });
62
+ // Should be configured once we get params
63
+ this.distributedDB = null;
63
64
  this.syncStatus = new SyncStatus({});
64
65
  this.broadCastLogger = new BroadcastLogger(this.ports);
65
66
  this.connectionManager = new ConnectionManager({
66
67
  createSyncImplementation: async () => {
67
- return this.portMutex.runExclusive(async () => {
68
- await this.waitForReady();
69
- if (!this.dbAdapter) {
70
- await this.openInternalDB();
68
+ await this.waitForReady();
69
+ const sync = this.generateStreamingImplementation();
70
+ const onDispose = sync.registerListener({
71
+ statusChanged: (status) => {
72
+ this.updateAllStatuses(status.toJSON());
71
73
  }
72
- const sync = this.generateStreamingImplementation();
73
- const onDispose = sync.registerListener({
74
- statusChanged: (status) => {
75
- this.updateAllStatuses(status.toJSON());
76
- }
77
- });
78
- return {
79
- sync,
80
- onDispose
81
- };
82
74
  });
75
+ return {
76
+ sync,
77
+ onDispose
78
+ };
83
79
  },
84
80
  logger: this.logger
85
81
  });
@@ -90,6 +86,18 @@ export class SharedSyncImplementation extends BaseObserver {
90
86
  get isConnected() {
91
87
  return this.connectionManager.syncStreamImplementation?.isConnected ?? false;
92
88
  }
89
+ /**
90
+ * Gets the last client port which we know is safe from unexpected closes.
91
+ */
92
+ get lastWrappedPort() {
93
+ // Find the last port which is protected from close
94
+ for (let i = this.ports.length - 1; i >= 0; i--) {
95
+ if (this.ports[i].isProtectedFromClose && !this.ports[i].isClosing) {
96
+ return this.ports[i];
97
+ }
98
+ }
99
+ return;
100
+ }
93
101
  async waitForStatus(status) {
94
102
  return this.withSyncImplementation(async (sync) => {
95
103
  return sync.waitForStatus(status);
@@ -132,10 +140,6 @@ export class SharedSyncImplementation extends BaseObserver {
132
140
  this.collectActiveSubscriptions();
133
141
  if (this.syncParams) {
134
142
  // Cannot modify already existing sync implementation params
135
- // But we can ask for a DB adapter, if required, at this point.
136
- if (!this.dbAdapter) {
137
- await this.openInternalDB();
138
- }
139
143
  return;
140
144
  }
141
145
  // First time setting params
@@ -143,13 +147,28 @@ export class SharedSyncImplementation extends BaseObserver {
143
147
  if (params.streamOptions?.flags?.broadcastLogs) {
144
148
  this.logger = this.broadCastLogger;
145
149
  }
150
+ const lockedAdapter = new LockedAsyncDatabaseAdapter({
151
+ name: params.dbParams.dbFilename,
152
+ openConnection: async () => {
153
+ // Gets a connection from the clients when a new connection is requested.
154
+ return await this.openInternalDB();
155
+ },
156
+ logger: this.logger,
157
+ reOpenOnConnectionClosed: true
158
+ });
159
+ this.distributedDB = lockedAdapter;
160
+ await lockedAdapter.init();
161
+ lockedAdapter.registerListener({
162
+ databaseReOpened: () => {
163
+ // We may have missed some table updates while the database was closed.
164
+ // We can poke the crud in case we missed any updates.
165
+ this.connectionManager.syncStreamImplementation?.triggerCrudUpload();
166
+ }
167
+ });
146
168
  self.onerror = (event) => {
147
169
  // Share any uncaught events on the broadcast logger
148
170
  this.logger.error('Uncaught exception in PowerSync shared sync worker', event);
149
171
  };
150
- if (!this.dbAdapter) {
151
- await this.openInternalDB();
152
- }
153
172
  this.iterateListeners((l) => l.initialized?.());
154
173
  });
155
174
  }
@@ -180,7 +199,9 @@ export class SharedSyncImplementation extends BaseObserver {
180
199
  port,
181
200
  clientProvider: Comlink.wrap(port),
182
201
  currentSubscriptions: [],
183
- closeListeners: []
202
+ closeListeners: [],
203
+ isProtectedFromClose: false,
204
+ isClosing: false
184
205
  };
185
206
  this.ports.push(portProvider);
186
207
  // Give the newly connected client the latest status
@@ -196,14 +217,16 @@ export class SharedSyncImplementation extends BaseObserver {
196
217
  * clients.
197
218
  */
198
219
  async removePort(port) {
220
+ // Ports might be removed faster than we can process them.
221
+ port.isClosing = true;
199
222
  // Remove the port within a mutex context.
200
223
  // Warns if the port is not found. This should not happen in practice.
201
224
  // We return early if the port is not found.
202
- const { trackedPort, shouldReconnect } = await this.portMutex.runExclusive(async () => {
225
+ return await this.portMutex.runExclusive(async () => {
203
226
  const index = this.ports.findIndex((p) => p == port);
204
227
  if (index < 0) {
205
228
  this.logger.warn(`Could not remove port ${port} since it is not present in active ports.`);
206
- return {};
229
+ return () => { };
207
230
  }
208
231
  const trackedPort = this.ports[index];
209
232
  // Remove from the list of active ports
@@ -217,35 +240,13 @@ export class SharedSyncImplementation extends BaseObserver {
217
240
  abortController.controller.abort(new AbortOperation('Closing pending requests after client port is removed'));
218
241
  }
219
242
  });
220
- const shouldReconnect = !!this.connectionManager.syncStreamImplementation && this.ports.length > 0;
221
- return {
222
- shouldReconnect,
223
- trackedPort
224
- };
225
- });
226
- if (!trackedPort) {
227
- // We could not find the port to remove
228
- return () => { };
229
- }
230
- for (const closeListener of trackedPort.closeListeners) {
231
- await closeListener();
232
- }
233
- if (this.dbAdapter && this.dbAdapter == trackedPort.db) {
234
- // Unconditionally close the connection because the database it's writing to has just been closed.
235
- // The connection has been closed previously, this might throw. We should be able to ignore it.
236
- await this.connectionManager
237
- .disconnect()
238
- .catch((ex) => this.logger.warn('Error while disconnecting. Will attempt to reconnect.', ex));
239
- // Clearing the adapter will result in a new one being opened in connect
240
- this.dbAdapter = null;
241
- if (shouldReconnect) {
242
- await this.connectionManager.connect(CONNECTOR_PLACEHOLDER, this.lastConnectOptions ?? {});
243
+ // Close the worker wrapped database connection, we can't accurately rely on this connection
244
+ for (const closeListener of trackedPort.closeListeners) {
245
+ await closeListener();
243
246
  }
244
- }
245
- // Re-index subscriptions, the subscriptions of the removed port would no longer be considered.
246
- this.collectActiveSubscriptions();
247
- // Release proxy
248
- return () => trackedPort.clientProvider[Comlink.releaseProxy]();
247
+ this.collectActiveSubscriptions();
248
+ return () => trackedPort.clientProvider[Comlink.releaseProxy]();
249
+ });
249
250
  }
250
251
  triggerCrudUpload() {
251
252
  this.withSyncImplementation(async (sync) => {
@@ -282,10 +283,13 @@ export class SharedSyncImplementation extends BaseObserver {
282
283
  const syncParams = this.syncParams;
283
284
  // Create a new StreamingSyncImplementation for each connect call. This is usually done is all SDKs.
284
285
  return new WebStreamingSyncImplementation({
285
- adapter: new SqliteBucketStorage(this.dbAdapter, this.logger),
286
+ adapter: new SqliteBucketStorage(this.distributedDB, this.logger),
286
287
  remote: new WebRemote({
287
288
  invalidateCredentials: async () => {
288
- const lastPort = this.ports[this.ports.length - 1];
289
+ const lastPort = this.lastWrappedPort;
290
+ if (!lastPort) {
291
+ throw new Error('No client port found to invalidate credentials');
292
+ }
289
293
  try {
290
294
  this.logger.log('calling the last port client provider to invalidate credentials');
291
295
  lastPort.clientProvider.invalidateCredentials();
@@ -295,7 +299,10 @@ export class SharedSyncImplementation extends BaseObserver {
295
299
  }
296
300
  },
297
301
  fetchCredentials: async () => {
298
- const lastPort = this.ports[this.ports.length - 1];
302
+ const lastPort = this.lastWrappedPort;
303
+ if (!lastPort) {
304
+ throw new Error('No client port found to fetch credentials');
305
+ }
299
306
  return new Promise(async (resolve, reject) => {
300
307
  const abortController = new AbortController();
301
308
  this.fetchCredentialsController = {
@@ -317,7 +324,10 @@ export class SharedSyncImplementation extends BaseObserver {
317
324
  }
318
325
  }, this.logger),
319
326
  uploadCrud: async () => {
320
- const lastPort = this.ports[this.ports.length - 1];
327
+ const lastPort = this.lastWrappedPort;
328
+ if (!lastPort) {
329
+ throw new Error('No client port found to upload crud');
330
+ }
321
331
  return new Promise(async (resolve, reject) => {
322
332
  const abortController = new AbortController();
323
333
  this.uploadDataController = {
@@ -343,19 +353,47 @@ export class SharedSyncImplementation extends BaseObserver {
343
353
  logger: this.logger
344
354
  });
345
355
  }
356
+ /**
357
+ * Opens a worker wrapped database connection. Using the last connected client port.
358
+ */
346
359
  async openInternalDB() {
347
- const lastClient = this.ports[this.ports.length - 1];
348
- if (!lastClient) {
349
- // Should not really happen in practice
350
- throw new Error(`Could not open DB connection since no client is connected.`);
351
- }
352
- const workerPort = await lastClient.clientProvider.getDBWorkerPort();
353
- const remote = Comlink.wrap(workerPort);
354
- const identifier = this.syncParams.dbParams.dbFilename;
355
- const db = await remote(this.syncParams.dbParams);
356
- const locked = new LockedAsyncDatabaseAdapter({
357
- name: identifier,
358
- openConnection: async () => {
360
+ while (true) {
361
+ try {
362
+ const lastClient = this.lastWrappedPort;
363
+ if (!lastClient) {
364
+ // Should not really happen in practice
365
+ throw new Error(`Could not open DB connection since no client is connected.`);
366
+ }
367
+ /**
368
+ * Handle cases where the client might close while opening a connection.
369
+ */
370
+ const abortController = new AbortController();
371
+ const closeListener = () => {
372
+ abortController.abort();
373
+ };
374
+ const removeCloseListener = () => {
375
+ const index = lastClient.closeListeners.indexOf(closeListener);
376
+ if (index >= 0) {
377
+ lastClient.closeListeners.splice(index, 1);
378
+ }
379
+ };
380
+ lastClient.closeListeners.push(closeListener);
381
+ const workerPort = await withAbort(() => lastClient.clientProvider.getDBWorkerPort(), abortController.signal).catch((ex) => {
382
+ removeCloseListener();
383
+ throw ex;
384
+ });
385
+ const remote = Comlink.wrap(workerPort);
386
+ const identifier = this.syncParams.dbParams.dbFilename;
387
+ /**
388
+ * The open could fail if the tab is closed while we're busy opening the database.
389
+ * This operation is typically executed inside an exclusive portMutex lock.
390
+ * We typically execute the closeListeners using the portMutex in a different context.
391
+ * We can't rely on the closeListeners to abort the operation if the tab is closed.
392
+ */
393
+ const db = await withAbort(() => remote(this.syncParams.dbParams), abortController.signal).finally(() => {
394
+ // We can remove the close listener here since we no longer need it past this point.
395
+ removeCloseListener();
396
+ });
359
397
  const wrapped = new WorkerWrappedAsyncDatabaseConnection({
360
398
  remote,
361
399
  baseConnection: db,
@@ -366,15 +404,21 @@ export class SharedSyncImplementation extends BaseObserver {
366
404
  });
367
405
  lastClient.closeListeners.push(async () => {
368
406
  this.logger.info('Aborting open connection because associated tab closed.');
407
+ /**
408
+ * Don't await this close operation. It might never resolve if the tab is closed.
409
+ * We run the close operation first, before marking the remote as closed. This gives the database some chance
410
+ * to close the connection.
411
+ */
369
412
  wrapped.close().catch((ex) => this.logger.warn('error closing database connection', ex));
370
413
  wrapped.markRemoteClosed();
371
414
  });
372
415
  return wrapped;
373
- },
374
- logger: this.logger
375
- });
376
- await locked.init();
377
- this.dbAdapter = lastClient.db = locked;
416
+ }
417
+ catch (ex) {
418
+ this.logger.warn('Error opening internal DB', ex);
419
+ await new Promise((resolve) => setTimeout(resolve, 1000));
420
+ }
421
+ }
378
422
  }
379
423
  /**
380
424
  * A method to update the all shared statuses for each
@@ -397,3 +441,26 @@ export class SharedSyncImplementation extends BaseObserver {
397
441
  this.updateAllStatuses(status);
398
442
  }
399
443
  }
444
+ /**
445
+ * Runs the action with an abort controller.
446
+ */
447
+ function withAbort(action, signal) {
448
+ return new Promise((resolve, reject) => {
449
+ if (signal.aborted) {
450
+ reject(new AbortOperation('Operation aborted by abort controller'));
451
+ return;
452
+ }
453
+ function handleAbort() {
454
+ signal.removeEventListener('abort', handleAbort);
455
+ reject(new AbortOperation('Operation aborted by abort controller'));
456
+ }
457
+ signal.addEventListener('abort', handleAbort, { once: true });
458
+ function completePromise(action) {
459
+ signal.removeEventListener('abort', handleAbort);
460
+ action();
461
+ }
462
+ action()
463
+ .then((data) => completePromise(() => resolve(data)))
464
+ .catch((e) => completePromise(() => reject(e)));
465
+ });
466
+ }
@@ -1,5 +1,5 @@
1
- import { SharedSyncImplementation, SharedSyncInitOptions } from './SharedSyncImplementation';
2
1
  import { ILogLevel, PowerSyncConnectionOptions, SubscribedStream, SyncStatusOptions } from '@powersync/common';
2
+ import { SharedSyncImplementation, SharedSyncInitOptions, WrappedSyncPort } from './SharedSyncImplementation';
3
3
  /**
4
4
  * A client to the shared sync worker.
5
5
  *
@@ -10,6 +10,7 @@ export declare class WorkerClient {
10
10
  private readonly sync;
11
11
  private readonly port;
12
12
  private resolvedPort;
13
+ protected resolvedPortPromise: Promise<WrappedSyncPort> | null;
13
14
  constructor(sync: SharedSyncImplementation, port: MessagePort);
14
15
  initialize(): Promise<void>;
15
16
  private removePort;
@@ -19,7 +20,7 @@ export declare class WorkerClient {
19
20
  * When the client tab is closed, its lock will be returned. So when the shared worker attempts to acquire the lock,
20
21
  * it can consider the connection to be closed.
21
22
  */
22
- addLockBasedCloseSignal(name: string): void;
23
+ addLockBasedCloseSignal(name: string): Promise<void>;
23
24
  setLogLevel(level: ILogLevel): void;
24
25
  triggerCrudUpload(): void;
25
26
  setParams(params: SharedSyncInitOptions, subscriptions: SubscribedStream[]): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import * as Comlink from 'comlink';
2
- import { SharedSyncClientEvent } from './SharedSyncImplementation';
3
2
  import { getNavigatorLocks } from '../../shared/navigator';
3
+ import { SharedSyncClientEvent } from './SharedSyncImplementation';
4
4
  /**
5
5
  * A client to the shared sync worker.
6
6
  *
@@ -11,9 +11,11 @@ export class WorkerClient {
11
11
  sync;
12
12
  port;
13
13
  resolvedPort = null;
14
+ resolvedPortPromise = null;
14
15
  constructor(sync, port) {
15
16
  this.sync = sync;
16
17
  this.port = port;
18
+ Comlink.expose(this, this.port);
17
19
  }
18
20
  async initialize() {
19
21
  /**
@@ -26,8 +28,15 @@ export class WorkerClient {
26
28
  await this.removePort();
27
29
  }
28
30
  });
29
- this.resolvedPort = await this.sync.addPort(this.port);
30
- Comlink.expose(this, this.port);
31
+ /**
32
+ * Keep a reference to the resolved port promise.
33
+ * The init timing is difficult to predict due to the async message passing.
34
+ * We only want to use a port if we are know it's been protected from being closed.
35
+ * The lock based close signal will be added asynchronously. We need to use the
36
+ * added port once the lock is configured.
37
+ */
38
+ this.resolvedPortPromise = this.sync.addPort(this.port);
39
+ this.resolvedPort = await this.resolvedPortPromise;
31
40
  }
32
41
  async removePort() {
33
42
  if (this.resolvedPort) {
@@ -48,7 +57,20 @@ export class WorkerClient {
48
57
  * When the client tab is closed, its lock will be returned. So when the shared worker attempts to acquire the lock,
49
58
  * it can consider the connection to be closed.
50
59
  */
51
- addLockBasedCloseSignal(name) {
60
+ async addLockBasedCloseSignal(name) {
61
+ if (!this.resolvedPortPromise) {
62
+ // The init logic above is actually synchronous, so this should not happen.
63
+ this.sync.broadCastLogger.warn('addLockBasedCloseSignal called before port promise registered');
64
+ }
65
+ else {
66
+ const wrappedPort = await this.resolvedPortPromise;
67
+ /**
68
+ * The client registered a navigator lock. We now can guarantee detecting if the client has closed.
69
+ * E.g. before this point: It's possible some ports might have been created and closed before the
70
+ * lock based close signal is added. We should not trust those ports.
71
+ */
72
+ wrappedPort.isProtectedFromClose = true;
73
+ }
52
74
  getNavigatorLocks().request(name, async () => {
53
75
  await this.removePort();
54
76
  });