@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.
@@ -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) {
@@ -6,16 +6,16 @@ import {
6
6
  SyncStatusOptions
7
7
  } from '@powersync/common';
8
8
  import * as Comlink from 'comlink';
9
+ import { getNavigatorLocks } from '../../shared/navigator';
9
10
  import { AbstractSharedSyncClientProvider } from '../../worker/sync/AbstractSharedSyncClientProvider';
10
11
  import { ManualSharedSyncPayload, SharedSyncClientEvent } from '../../worker/sync/SharedSyncImplementation';
11
- import { DEFAULT_CACHE_SIZE_KB, resolveWebSQLFlags, TemporaryStorageOption } from '../adapters/web-sql-flags';
12
+ import { WorkerClient } from '../../worker/sync/WorkerClient';
12
13
  import { WebDBAdapter } from '../adapters/WebDBAdapter';
14
+ import { DEFAULT_CACHE_SIZE_KB, TemporaryStorageOption, resolveWebSQLFlags } from '../adapters/web-sql-flags';
13
15
  import {
14
16
  WebStreamingSyncImplementation,
15
17
  WebStreamingSyncImplementationOptions
16
18
  } from './WebStreamingSyncImplementation';
17
- import { WorkerClient } from '../../worker/sync/WorkerClient';
18
- import { getNavigatorLocks } from '../../shared/navigator';
19
19
 
20
20
  /**
21
21
  * The shared worker will trigger methods on this side of the message port
@@ -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,10 +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
  */
178
+
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
+ }
203
+ // Awaiting here ensures the worker is waiting for the lock
204
+ await this.syncManager.addLockBasedCloseSignal(lock!.name);
205
+
206
+ // The lock has been registered, we can continue with the initialization
207
+ resolve();
208
+
209
+ await new Promise<void>((r) => {
210
+ this.abortOnClose.signal.onabort = () => r();
211
+ });
212
+ });
213
+ });
214
+
160
215
  const { crudUploadThrottleMs, identifier, retryDelayMs } = this.options;
161
216
  const flags = { ...this.webOptions.flags, workers: undefined };
162
217
 
163
- this.isInitialized = this.syncManager.setParams(
218
+ await this.syncManager.setParams(
164
219
  {
165
220
  dbParams: this.dbAdapter.getConfiguration(),
166
221
  streamOptions: {
@@ -170,39 +225,8 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
170
225
  flags: flags
171
226
  }
172
227
  },
173
- options.subscriptions
174
- );
175
-
176
- /**
177
- * Pass along any sync status updates to this listener
178
- */
179
- this.clientProvider = new SharedSyncClientProvider(
180
- this.webOptions,
181
- (status) => {
182
- this.iterateListeners((l) => this.updateSyncStatus(status));
183
- },
184
- options.db
228
+ this.options.subscriptions
185
229
  );
186
-
187
- /**
188
- * The sync worker will call this client provider when it needs
189
- * to fetch credentials or upload data.
190
- * This performs bi-directional method calling.
191
- */
192
- Comlink.expose(this.clientProvider, this.messagePort);
193
-
194
- // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which
195
- // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker
196
- // to free resources associated with this tab.
197
- getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => {
198
- if (!this.abortOnClose.signal.aborted) {
199
- this.syncManager.addLockBasedCloseSignal(lock!.name);
200
-
201
- await new Promise<void>((r) => {
202
- this.abortOnClose.signal.onabort = () => r();
203
- });
204
- }
205
- });
206
230
  }
207
231
 
208
232
  /**