@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.
@@ -14,6 +14,9 @@ export type ProxiedQueryResult = Omit<QueryResult, 'rows'> & {
14
14
  * @internal
15
15
  */
16
16
  export type OnTableChangeCallback = (event: BatchedUpdateNotification) => void;
17
+ export declare class ConnectionClosedError extends Error {
18
+ constructor(message: string);
19
+ }
17
20
  /**
18
21
  * @internal
19
22
  * An async Database connection which provides basic async SQL methods.
@@ -1 +1,6 @@
1
- export {};
1
+ export class ConnectionClosedError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'ConnectionClosedError';
5
+ }
6
+ }
@@ -10,9 +10,15 @@ export interface LockedAsyncDatabaseAdapterOptions {
10
10
  openConnection: () => Promise<AsyncDatabaseConnection>;
11
11
  debugMode?: boolean;
12
12
  logger?: ILogger;
13
+ defaultLockTimeoutMs?: number;
14
+ reOpenOnConnectionClosed?: boolean;
13
15
  }
14
16
  export type LockedAsyncDatabaseAdapterListener = DBAdapterListener & {
15
17
  initialized?: () => void;
18
+ /**
19
+ * Fired when the database is re-opened after being closed.
20
+ */
21
+ databaseReOpened?: () => void;
16
22
  };
17
23
  /**
18
24
  * @internal
@@ -31,6 +37,8 @@ export declare class LockedAsyncDatabaseAdapter extends BaseObserver<LockedAsync
31
37
  private _config;
32
38
  protected pendingAbortControllers: Set<AbortController>;
33
39
  protected requiresHolds: boolean | null;
40
+ protected requiresReOpen: boolean;
41
+ protected databaseOpenPromise: Promise<void> | null;
34
42
  closing: boolean;
35
43
  closed: boolean;
36
44
  constructor(options: LockedAsyncDatabaseAdapterOptions);
@@ -40,6 +48,7 @@ export declare class LockedAsyncDatabaseAdapter extends BaseObserver<LockedAsync
40
48
  * Init is automatic, this helps catch errors or explicitly await initialization
41
49
  */
42
50
  init(): Promise<void>;
51
+ protected openInternalDB(): Promise<void>;
43
52
  protected _init(): Promise<void>;
44
53
  getConfiguration(): ResolvedWebSQLOpenOptions;
45
54
  protected waitForInitialized(): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { BaseObserver, createLogger } from '@powersync/common';
2
2
  import { getNavigatorLocks } from '../..//shared/navigator';
3
+ import { ConnectionClosedError } from './AsyncDatabaseConnection';
3
4
  import { WorkerWrappedAsyncDatabaseConnection } from './WorkerWrappedAsyncDatabaseConnection';
4
5
  import { WASQLiteVFS } from './wa-sqlite/WASQLiteConnection';
5
6
  /**
@@ -19,6 +20,8 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
19
20
  _config = null;
20
21
  pendingAbortControllers;
21
22
  requiresHolds;
23
+ requiresReOpen;
24
+ databaseOpenPromise = null;
22
25
  closing;
23
26
  closed;
24
27
  constructor(options) {
@@ -30,6 +33,7 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
30
33
  this.closed = false;
31
34
  this.closing = false;
32
35
  this.requiresHolds = null;
36
+ this.requiresReOpen = false;
33
37
  // Set the name if provided. We can query for the name if not available yet
34
38
  this.debugMode = options.debugMode ?? false;
35
39
  if (this.debugMode) {
@@ -68,17 +72,27 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
68
72
  async init() {
69
73
  return this.initPromise;
70
74
  }
71
- async _init() {
75
+ async openInternalDB() {
76
+ // Dispose any previous table change listener.
77
+ this._disposeTableChangeListener?.();
78
+ this._disposeTableChangeListener = null;
79
+ const isReOpen = !!this._db;
72
80
  this._db = await this.options.openConnection();
73
81
  await this._db.init();
74
82
  this._config = await this._db.getConfig();
75
83
  await this.registerOnChangeListener(this._db);
76
- this.iterateListeners((cb) => cb.initialized?.());
84
+ if (isReOpen) {
85
+ this.iterateListeners((cb) => cb.databaseReOpened?.());
86
+ }
77
87
  /**
78
88
  * This is only required for the long-lived shared IndexedDB connections.
79
89
  */
80
90
  this.requiresHolds = this._config.vfs == WASQLiteVFS.IDBBatchAtomicVFS;
81
91
  }
92
+ async _init() {
93
+ await this.openInternalDB();
94
+ this.iterateListeners((cb) => cb.initialized?.());
95
+ }
82
96
  getConfiguration() {
83
97
  if (!this._config) {
84
98
  throw new Error(`Cannot get config before initialization is completed`);
@@ -144,13 +158,13 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
144
158
  async readLock(fn, options) {
145
159
  await this.waitForInitialized();
146
160
  return this.acquireLock(async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), {
147
- timeoutMs: options?.timeoutMs
161
+ timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs
148
162
  });
149
163
  }
150
164
  async writeLock(fn, options) {
151
165
  await this.waitForInitialized();
152
166
  return this.acquireLock(async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), {
153
- timeoutMs: options?.timeoutMs
167
+ timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs
154
168
  });
155
169
  }
156
170
  async acquireLock(callback, options) {
@@ -161,7 +175,7 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
161
175
  const abortController = new AbortController();
162
176
  this.pendingAbortControllers.add(abortController);
163
177
  const { timeoutMs } = options ?? {};
164
- const timoutId = timeoutMs
178
+ const timeoutId = timeoutMs
165
179
  ? setTimeout(() => {
166
180
  abortController.abort(`Timeout after ${timeoutMs}ms`);
167
181
  this.pendingAbortControllers.delete(abortController);
@@ -169,13 +183,37 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
169
183
  : null;
170
184
  return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, { signal: abortController.signal }, async () => {
171
185
  this.pendingAbortControllers.delete(abortController);
172
- if (timoutId) {
173
- clearTimeout(timoutId);
186
+ if (timeoutId) {
187
+ clearTimeout(timeoutId);
174
188
  }
175
- const holdId = this.requiresHolds ? await this.baseDB.markHold() : null;
189
+ let holdId = null;
176
190
  try {
191
+ // The database is being opened in the background. Wait for it here.
192
+ if (this.databaseOpenPromise) {
193
+ try {
194
+ await this.databaseOpenPromise;
195
+ }
196
+ catch (ex) {
197
+ // This will cause a retry of opening the database.
198
+ const wrappedError = new ConnectionClosedError('Could not open database');
199
+ wrappedError.cause = ex;
200
+ throw wrappedError;
201
+ }
202
+ }
203
+ holdId = this.requiresHolds ? await this.baseDB.markHold() : null;
177
204
  return await callback();
178
205
  }
206
+ catch (ex) {
207
+ if (ex instanceof ConnectionClosedError) {
208
+ if (this.options.reOpenOnConnectionClosed && !this.databaseOpenPromise && !this.closing) {
209
+ // Immediately re-open the database. We need to miss as little table updates as possible.
210
+ this.databaseOpenPromise = this.openInternalDB().finally(() => {
211
+ this.databaseOpenPromise = null;
212
+ });
213
+ }
214
+ }
215
+ throw ex;
216
+ }
179
217
  finally {
180
218
  if (holdId) {
181
219
  await this.baseDB.releaseHold(holdId);
@@ -1,4 +1,5 @@
1
1
  import * as Comlink from 'comlink';
2
+ import { ConnectionClosedError } from './AsyncDatabaseConnection';
2
3
  /**
3
4
  * Wraps a provided instance of {@link AsyncDatabaseConnection}, providing necessary proxy
4
5
  * functions for worker listeners.
@@ -45,12 +46,12 @@ export class WorkerWrappedAsyncDatabaseConnection {
45
46
  if (controller) {
46
47
  return new Promise((resolve, reject) => {
47
48
  if (controller.signal.aborted) {
48
- reject(new Error('Called operation on closed remote'));
49
+ reject(new ConnectionClosedError('Called operation on closed remote'));
49
50
  // Don't run the operation if we're going to reject
50
51
  return;
51
52
  }
52
53
  function handleAbort() {
53
- reject(new Error('Remote peer closed with request in flight'));
54
+ reject(new ConnectionClosedError('Remote peer closed with request in flight'));
54
55
  }
55
56
  function completePromise(action) {
56
57
  controller.signal.removeEventListener('abort', handleAbort);
@@ -1,9 +1,9 @@
1
1
  import { PowerSyncConnectionOptions, PowerSyncCredentials, SubscribedStream, SyncStatusOptions } from '@powersync/common';
2
2
  import * as Comlink from 'comlink';
3
3
  import { AbstractSharedSyncClientProvider } from '../../worker/sync/AbstractSharedSyncClientProvider';
4
+ import { WorkerClient } from '../../worker/sync/WorkerClient';
4
5
  import { WebDBAdapter } from '../adapters/WebDBAdapter';
5
6
  import { WebStreamingSyncImplementation, WebStreamingSyncImplementationOptions } from './WebStreamingSyncImplementation';
6
- import { WorkerClient } from '../../worker/sync/WorkerClient';
7
7
  /**
8
8
  * The shared worker will trigger methods on this side of the message port
9
9
  * via this client provider.
@@ -41,6 +41,7 @@ export declare class SharedWebStreamingSyncImplementation extends WebStreamingSy
41
41
  protected dbAdapter: WebDBAdapter;
42
42
  private abortOnClose;
43
43
  constructor(options: SharedWebStreamingSyncImplementationOptions);
44
+ protected _init(): Promise<void>;
44
45
  /**
45
46
  * Starts the sync process, this effectively acts as a call to
46
47
  * `connect` if not yet connected.
@@ -1,9 +1,9 @@
1
1
  import * as Comlink from 'comlink';
2
+ import { getNavigatorLocks } from '../../shared/navigator';
2
3
  import { AbstractSharedSyncClientProvider } from '../../worker/sync/AbstractSharedSyncClientProvider';
3
4
  import { SharedSyncClientEvent } from '../../worker/sync/SharedSyncImplementation';
4
- import { DEFAULT_CACHE_SIZE_KB, resolveWebSQLFlags, TemporaryStorageOption } from '../adapters/web-sql-flags';
5
+ import { DEFAULT_CACHE_SIZE_KB, TemporaryStorageOption, resolveWebSQLFlags } from '../adapters/web-sql-flags';
5
6
  import { WebStreamingSyncImplementation } from './WebStreamingSyncImplementation';
6
- import { getNavigatorLocks } from '../../shared/navigator';
7
7
  /**
8
8
  * The shared worker will trigger methods on this side of the message port
9
9
  * via this client provider.
@@ -119,7 +119,19 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
119
119
  type: 'module'
120
120
  }).port;
121
121
  }
122
+ /**
123
+ * Pass along any sync status updates to this listener
124
+ */
125
+ this.clientProvider = new SharedSyncClientProvider(this.webOptions, (status) => {
126
+ this.updateSyncStatus(status);
127
+ }, options.db);
122
128
  this.syncManager = Comlink.wrap(this.messagePort);
129
+ /**
130
+ * The sync worker will call this client provider when it needs
131
+ * to fetch credentials or upload data.
132
+ * This performs bi-directional method calling.
133
+ */
134
+ Comlink.expose(this.clientProvider, this.messagePort);
123
135
  this.syncManager.setLogLevel(this.logger.getLevel());
124
136
  this.triggerCrudUpload = this.syncManager.triggerCrudUpload;
125
137
  /**
@@ -128,9 +140,41 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
128
140
  * DB worker, but a port to the DB worker can be transferred to the
129
141
  * sync worker.
130
142
  */
143
+ this.isInitialized = this._init();
144
+ }
145
+ async _init() {
146
+ /**
147
+ * The general flow of initialization is:
148
+ * - The client requests a unique navigator lock.
149
+ * - Once the lock is acquired, we register the lock with the shared worker.
150
+ * - The shared worker can then request the same lock. The client has been closed if the shared worker can acquire the lock.
151
+ * - Once the shared worker knows the client's lock, we can guarentee that the shared worker will detect if the client has been closed.
152
+ * - This makes the client safe for the shared worker to use.
153
+ * - The client side lock is held until the client is disposed.
154
+ * - We resolve the top-level promise after the lock has been registered with the shared worker.
155
+ * - The client sends the params to the shared worker after locks have been registered.
156
+ */
157
+ await new Promise((resolve) => {
158
+ // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which
159
+ // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker
160
+ // to free resources associated with this tab.
161
+ // We take hold of this lock as soon-as-possible in order to cater for potentially closed tabs.
162
+ getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => {
163
+ if (this.abortOnClose.signal.aborted) {
164
+ return;
165
+ }
166
+ // Awaiting here ensures the worker is waiting for the lock
167
+ await this.syncManager.addLockBasedCloseSignal(lock.name);
168
+ // The lock has been registered, we can continue with the initialization
169
+ resolve();
170
+ await new Promise((r) => {
171
+ this.abortOnClose.signal.onabort = () => r();
172
+ });
173
+ });
174
+ });
131
175
  const { crudUploadThrottleMs, identifier, retryDelayMs } = this.options;
132
176
  const flags = { ...this.webOptions.flags, workers: undefined };
133
- this.isInitialized = this.syncManager.setParams({
177
+ await this.syncManager.setParams({
134
178
  dbParams: this.dbAdapter.getConfiguration(),
135
179
  streamOptions: {
136
180
  crudUploadThrottleMs,
@@ -138,30 +182,7 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem
138
182
  retryDelayMs,
139
183
  flags: flags
140
184
  }
141
- }, options.subscriptions);
142
- /**
143
- * Pass along any sync status updates to this listener
144
- */
145
- this.clientProvider = new SharedSyncClientProvider(this.webOptions, (status) => {
146
- this.iterateListeners((l) => this.updateSyncStatus(status));
147
- }, options.db);
148
- /**
149
- * The sync worker will call this client provider when it needs
150
- * to fetch credentials or upload data.
151
- * This performs bi-directional method calling.
152
- */
153
- Comlink.expose(this.clientProvider, this.messagePort);
154
- // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which
155
- // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker
156
- // to free resources associated with this tab.
157
- getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => {
158
- if (!this.abortOnClose.signal.aborted) {
159
- this.syncManager.addLockBasedCloseSignal(lock.name);
160
- await new Promise((r) => {
161
- this.abortOnClose.signal.onabort = () => r();
162
- });
163
- }
164
- });
185
+ }, this.options.subscriptions);
165
186
  }
166
187
  /**
167
188
  * Starts the sync process, this effectively acts as a call to
@@ -1,7 +1,8 @@
1
- import { BaseObserver, ConnectionManager, DBAdapter, SubscribedStream, SyncStatus, type ILogger, type ILogLevel, type PowerSyncConnectionOptions, type StreamingSyncImplementation, type StreamingSyncImplementationListener, type SyncStatusOptions } from '@powersync/common';
1
+ import { BaseObserver, ConnectionManager, DBAdapter, SubscribedStream, SyncStatus, type ILogLevel, type ILogger, type PowerSyncConnectionOptions, type StreamingSyncImplementation, type StreamingSyncImplementationListener, type SyncStatusOptions } from '@powersync/common';
2
2
  import { Mutex } from 'async-mutex';
3
3
  import * as Comlink from 'comlink';
4
4
  import { WebStreamingSyncImplementation, WebStreamingSyncImplementationOptions } from '../../db/sync/WebStreamingSyncImplementation';
5
+ import { WorkerWrappedAsyncDatabaseConnection } from '../../db/adapters/WorkerWrappedAsyncDatabaseConnection';
5
6
  import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags';
6
7
  import { AbstractSharedSyncClientProvider } from './AbstractSharedSyncClientProvider';
7
8
  /**
@@ -45,6 +46,11 @@ export type WrappedSyncPort = {
45
46
  db?: DBAdapter;
46
47
  currentSubscriptions: SubscribedStream[];
47
48
  closeListeners: (() => void | Promise<void>)[];
49
+ /**
50
+ * If we can use Navigator locks to detect if the client has closed.
51
+ */
52
+ isProtectedFromClose: boolean;
53
+ isClosing: boolean;
48
54
  };
49
55
  /**
50
56
  * @internal
@@ -63,7 +69,6 @@ export declare class SharedSyncImplementation extends BaseObserver<SharedSyncImp
63
69
  protected statusListener?: () => void;
64
70
  protected fetchCredentialsController?: RemoteOperationAbortController;
65
71
  protected uploadDataController?: RemoteOperationAbortController;
66
- protected dbAdapter: DBAdapter | null;
67
72
  protected syncParams: SharedSyncInitOptions | null;
68
73
  protected logger: ILogger;
69
74
  protected lastConnectOptions: PowerSyncConnectionOptions | undefined;
@@ -72,9 +77,14 @@ export declare class SharedSyncImplementation extends BaseObserver<SharedSyncImp
72
77
  protected connectionManager: ConnectionManager;
73
78
  syncStatus: SyncStatus;
74
79
  broadCastLogger: ILogger;
80
+ protected distributedDB: DBAdapter | null;
75
81
  constructor();
76
82
  get lastSyncedAt(): Date | undefined;
77
83
  get isConnected(): boolean;
84
+ /**
85
+ * Gets the last client port which we know is safe from unexpected closes.
86
+ */
87
+ protected get lastWrappedPort(): WrappedSyncPort | undefined;
78
88
  waitForStatus(status: SyncStatusOptions): Promise<void>;
79
89
  waitUntilStatusMatches(predicate: (status: SyncStatus) => boolean): Promise<void>;
80
90
  waitForReady(): Promise<void>;
@@ -102,6 +112,8 @@ export declare class SharedSyncImplementation extends BaseObserver<SharedSyncImp
102
112
  clientProvider: Comlink.Remote<AbstractSharedSyncClientProvider>;
103
113
  currentSubscriptions: never[];
104
114
  closeListeners: never[];
115
+ isProtectedFromClose: false;
116
+ isClosing: false;
105
117
  }>;
106
118
  /**
107
119
  * Removes a message port client from this manager's managed
@@ -113,7 +125,10 @@ export declare class SharedSyncImplementation extends BaseObserver<SharedSyncImp
113
125
  getWriteCheckpoint(): Promise<string>;
114
126
  protected withSyncImplementation<T>(callback: (sync: StreamingSyncImplementation) => Promise<T>): Promise<T>;
115
127
  protected generateStreamingImplementation(): WebStreamingSyncImplementation;
116
- protected openInternalDB(): Promise<void>;
128
+ /**
129
+ * Opens a worker wrapped database connection. Using the last connected client port.
130
+ */
131
+ protected openInternalDB(): Promise<WorkerWrappedAsyncDatabaseConnection<ResolvedWebSQLOpenOptions>>;
117
132
  /**
118
133
  * A method to update the all shared statuses for each
119
134
  * client.