@powersync/common 0.0.0-dev-20250528152729 → 0.0.0-dev-20250609122429
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.
- package/dist/bundle.mjs +2 -2
- package/lib/client/AbstractPowerSyncDatabase.d.ts +1 -1
- package/lib/client/AbstractPowerSyncDatabase.js +3 -3
- package/lib/client/ConnectionManager.d.ts +9 -1
- package/lib/client/ConnectionManager.js +20 -6
- package/lib/client/sync/stream/AbstractRemote.js +6 -14
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +48 -8
- package/lib/client/sync/stream/WebsocketClientTransport.d.ts +15 -0
- package/lib/client/sync/stream/WebsocketClientTransport.js +60 -0
- package/lib/db/crud/SyncStatus.js +15 -1
- package/package.json +1 -1
|
@@ -122,7 +122,7 @@ export declare abstract class AbstractPowerSyncDatabase extends BaseObserver<Pow
|
|
|
122
122
|
get syncStreamImplementation(): StreamingSyncImplementation | null;
|
|
123
123
|
protected _schema: Schema;
|
|
124
124
|
private _database;
|
|
125
|
-
protected
|
|
125
|
+
protected runExclusiveMutex: Mutex;
|
|
126
126
|
constructor(options: PowerSyncDatabaseOptionsWithDBAdapter);
|
|
127
127
|
constructor(options: PowerSyncDatabaseOptionsWithOpenFactory);
|
|
128
128
|
constructor(options: PowerSyncDatabaseOptionsWithSettings);
|
|
@@ -69,7 +69,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
69
69
|
}
|
|
70
70
|
_schema;
|
|
71
71
|
_database;
|
|
72
|
-
|
|
72
|
+
runExclusiveMutex;
|
|
73
73
|
constructor(options) {
|
|
74
74
|
super();
|
|
75
75
|
this.options = options;
|
|
@@ -96,7 +96,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
96
96
|
this._schema = schema;
|
|
97
97
|
this.ready = false;
|
|
98
98
|
this.sdkVersion = '';
|
|
99
|
-
this.
|
|
99
|
+
this.runExclusiveMutex = new Mutex();
|
|
100
100
|
// Start async init
|
|
101
101
|
this.connectionManager = new ConnectionManager({
|
|
102
102
|
createSyncImplementation: async (connector, options) => {
|
|
@@ -298,7 +298,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
298
298
|
* Locking here is mostly only important on web for multiple tab scenarios.
|
|
299
299
|
*/
|
|
300
300
|
runExclusive(callback) {
|
|
301
|
-
return this.
|
|
301
|
+
return this.runExclusiveMutex.runExclusive(callback);
|
|
302
302
|
}
|
|
303
303
|
/**
|
|
304
304
|
* Connects to stream of events from the PowerSync instance.
|
|
@@ -7,6 +7,10 @@ import { PowerSyncConnectionOptions, StreamingSyncImplementation } from './sync/
|
|
|
7
7
|
*/
|
|
8
8
|
export interface ConnectionManagerSyncImplementationResult {
|
|
9
9
|
sync: StreamingSyncImplementation;
|
|
10
|
+
/**
|
|
11
|
+
* Additional cleanup function which is called after the sync stream implementation
|
|
12
|
+
* is disposed.
|
|
13
|
+
*/
|
|
10
14
|
onDispose: () => Promise<void> | void;
|
|
11
15
|
}
|
|
12
16
|
/**
|
|
@@ -54,7 +58,11 @@ export declare class ConnectionManager extends BaseObserver<ConnectionManagerLis
|
|
|
54
58
|
*/
|
|
55
59
|
protected pendingConnectionOptions: StoredConnectionOptions | null;
|
|
56
60
|
syncStreamImplementation: StreamingSyncImplementation | null;
|
|
57
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Additional cleanup function which is called after the sync stream implementation
|
|
63
|
+
* is disposed.
|
|
64
|
+
*/
|
|
65
|
+
protected syncDisposer: (() => Promise<void> | void) | null;
|
|
58
66
|
constructor(options: ConnectionManagerOptions);
|
|
59
67
|
get logger(): ILogger;
|
|
60
68
|
close(): Promise<void>;
|
|
@@ -27,6 +27,10 @@ export class ConnectionManager extends BaseObserver {
|
|
|
27
27
|
*/
|
|
28
28
|
pendingConnectionOptions;
|
|
29
29
|
syncStreamImplementation;
|
|
30
|
+
/**
|
|
31
|
+
* Additional cleanup function which is called after the sync stream implementation
|
|
32
|
+
* is disposed.
|
|
33
|
+
*/
|
|
30
34
|
syncDisposer;
|
|
31
35
|
constructor(options) {
|
|
32
36
|
super();
|
|
@@ -42,6 +46,7 @@ export class ConnectionManager extends BaseObserver {
|
|
|
42
46
|
return this.options.logger;
|
|
43
47
|
}
|
|
44
48
|
async close() {
|
|
49
|
+
await this.syncStreamImplementation?.dispose();
|
|
45
50
|
await this.syncDisposer?.();
|
|
46
51
|
}
|
|
47
52
|
async connect(connector, options) {
|
|
@@ -58,7 +63,7 @@ export class ConnectionManager extends BaseObserver {
|
|
|
58
63
|
// If we do already have pending options, a disconnect has already been performed.
|
|
59
64
|
// The connectInternal method also does a sanity disconnect to prevent straggler connections.
|
|
60
65
|
// We should also disconnect if we have already completed a connection attempt.
|
|
61
|
-
if (!hadPendingOptions) {
|
|
66
|
+
if (!hadPendingOptions || this.syncStreamImplementation) {
|
|
62
67
|
await this.disconnectInternal();
|
|
63
68
|
}
|
|
64
69
|
// Triggers a connect which checks if pending options are available after the connect completes.
|
|
@@ -68,7 +73,9 @@ export class ConnectionManager extends BaseObserver {
|
|
|
68
73
|
if (this.pendingConnectionOptions) {
|
|
69
74
|
// Pending options have been placed while connecting.
|
|
70
75
|
// Need to reconnect.
|
|
71
|
-
this.connectingPromise = this.connectInternal()
|
|
76
|
+
this.connectingPromise = this.connectInternal()
|
|
77
|
+
.catch(() => { })
|
|
78
|
+
.finally(checkConnection);
|
|
72
79
|
return this.connectingPromise;
|
|
73
80
|
}
|
|
74
81
|
else {
|
|
@@ -77,7 +84,9 @@ export class ConnectionManager extends BaseObserver {
|
|
|
77
84
|
return;
|
|
78
85
|
}
|
|
79
86
|
};
|
|
80
|
-
this.connectingPromise ??= this.connectInternal()
|
|
87
|
+
this.connectingPromise ??= this.connectInternal()
|
|
88
|
+
.catch(() => { })
|
|
89
|
+
.finally(checkConnection);
|
|
81
90
|
return this.connectingPromise;
|
|
82
91
|
}
|
|
83
92
|
async connectInternal() {
|
|
@@ -95,6 +104,11 @@ export class ConnectionManager extends BaseObserver {
|
|
|
95
104
|
if (!this.pendingConnectionOptions) {
|
|
96
105
|
this.logger.debug('No pending connection options found, not creating sync stream implementation');
|
|
97
106
|
// A disconnect could have cleared this.
|
|
107
|
+
resolve();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.disconnectingPromise) {
|
|
111
|
+
resolve();
|
|
98
112
|
return;
|
|
99
113
|
}
|
|
100
114
|
const { connector, options } = this.pendingConnectionOptions;
|
|
@@ -139,14 +153,14 @@ export class ConnectionManager extends BaseObserver {
|
|
|
139
153
|
// A disconnect is already in progress
|
|
140
154
|
return this.disconnectingPromise;
|
|
141
155
|
}
|
|
142
|
-
// Wait if a sync stream implementation is being created before closing it
|
|
143
|
-
// (syncStreamImplementation must be assigned before we can properly dispose it)
|
|
144
|
-
await this.syncStreamInitPromise;
|
|
145
156
|
this.disconnectingPromise = this.performDisconnect();
|
|
146
157
|
await this.disconnectingPromise;
|
|
147
158
|
this.disconnectingPromise = null;
|
|
148
159
|
}
|
|
149
160
|
async performDisconnect() {
|
|
161
|
+
// Wait if a sync stream implementation is being created before closing it
|
|
162
|
+
// (syncStreamImplementation must be assigned before we can properly dispose it)
|
|
163
|
+
await this.syncStreamInitPromise;
|
|
150
164
|
// Keep reference to the sync stream implementation and disposer
|
|
151
165
|
// The class members will be cleared before we trigger the disconnect
|
|
152
166
|
// to prevent any further calls to the sync stream implementation.
|
|
@@ -2,10 +2,10 @@ import { Buffer } from 'buffer';
|
|
|
2
2
|
import ndjsonStream from 'can-ndjson-stream';
|
|
3
3
|
import Logger from 'js-logger';
|
|
4
4
|
import { RSocketConnector } from 'rsocket-core';
|
|
5
|
-
import { WebsocketClientTransport } from 'rsocket-websocket-client';
|
|
6
5
|
import PACKAGE from '../../../../package.json' with { type: 'json' };
|
|
7
6
|
import { AbortOperation } from '../../../utils/AbortOperation.js';
|
|
8
7
|
import { DataStream } from '../../../utils/DataStream.js';
|
|
8
|
+
import { WebsocketClientTransport } from './WebsocketClientTransport.js';
|
|
9
9
|
const POWERSYNC_TRAILING_SLASH_MATCH = /\/+$/;
|
|
10
10
|
const POWERSYNC_JS_VERSION = PACKAGE.version;
|
|
11
11
|
const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
|
|
@@ -211,17 +211,12 @@ export class AbstractRemote {
|
|
|
211
211
|
// headers with websockets on web. The browser userAgent is however added
|
|
212
212
|
// automatically as a header.
|
|
213
213
|
const userAgent = this.getUserAgent();
|
|
214
|
-
|
|
214
|
+
const url = this.options.socketUrlTransformer(request.url);
|
|
215
215
|
const connector = new RSocketConnector({
|
|
216
216
|
transport: new WebsocketClientTransport({
|
|
217
|
-
url
|
|
217
|
+
url,
|
|
218
218
|
wsCreator: (url) => {
|
|
219
|
-
|
|
220
|
-
s.addEventListener('error', (e) => {
|
|
221
|
-
socketCreationError = new Error('Failed to create connection to websocket: ', e.target.url ?? '');
|
|
222
|
-
this.logger.warn('Socket error', e);
|
|
223
|
-
});
|
|
224
|
-
return s;
|
|
219
|
+
return this.createSocket(url);
|
|
225
220
|
}
|
|
226
221
|
}),
|
|
227
222
|
setup: {
|
|
@@ -243,11 +238,8 @@ export class AbstractRemote {
|
|
|
243
238
|
rsocket = await connector.connect();
|
|
244
239
|
}
|
|
245
240
|
catch (ex) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
* with detecting the exception inside async-mutex
|
|
249
|
-
*/
|
|
250
|
-
throw new Error(`Could not connect to PowerSync instance: ${JSON.stringify(ex ?? socketCreationError)}`);
|
|
241
|
+
this.logger.error(`Failed to connect WebSocket`, ex);
|
|
242
|
+
throw ex;
|
|
251
243
|
}
|
|
252
244
|
const stream = new DataStream({
|
|
253
245
|
logger: this.logger,
|
|
@@ -286,6 +286,7 @@ The next upload iteration will be delayed.`);
|
|
|
286
286
|
*/
|
|
287
287
|
while (true) {
|
|
288
288
|
this.updateSyncStatus({ connecting: true });
|
|
289
|
+
let shouldDelayRetry = true;
|
|
289
290
|
try {
|
|
290
291
|
if (signal?.aborted) {
|
|
291
292
|
break;
|
|
@@ -298,12 +299,15 @@ The next upload iteration will be delayed.`);
|
|
|
298
299
|
* Either:
|
|
299
300
|
* - A network request failed with a failed connection or not OKAY response code.
|
|
300
301
|
* - There was a sync processing error.
|
|
301
|
-
*
|
|
302
|
+
* - The connection was aborted.
|
|
303
|
+
* This loop will retry after a delay if the connection was not aborted.
|
|
302
304
|
* The nested abort controller will cleanup any open network requests and streams.
|
|
303
305
|
* The WebRemote should only abort pending fetch requests or close active Readable streams.
|
|
304
306
|
*/
|
|
305
307
|
if (ex instanceof AbortOperation) {
|
|
306
308
|
this.logger.warn(ex);
|
|
309
|
+
shouldDelayRetry = false;
|
|
310
|
+
// A disconnect was requested, we should not delay since there is no explicit retry
|
|
307
311
|
}
|
|
308
312
|
else {
|
|
309
313
|
this.logger.error(ex);
|
|
@@ -313,8 +317,6 @@ The next upload iteration will be delayed.`);
|
|
|
313
317
|
downloadError: ex
|
|
314
318
|
}
|
|
315
319
|
});
|
|
316
|
-
// On error, wait a little before retrying
|
|
317
|
-
await this.delayRetry();
|
|
318
320
|
}
|
|
319
321
|
finally {
|
|
320
322
|
if (!signal.aborted) {
|
|
@@ -325,6 +327,10 @@ The next upload iteration will be delayed.`);
|
|
|
325
327
|
connected: false,
|
|
326
328
|
connecting: true // May be unnecessary
|
|
327
329
|
});
|
|
330
|
+
// On error, wait a little before retrying
|
|
331
|
+
if (shouldDelayRetry) {
|
|
332
|
+
await this.delayRetry(nestedAbortController.signal);
|
|
333
|
+
}
|
|
328
334
|
}
|
|
329
335
|
}
|
|
330
336
|
// Mark as disconnected if here
|
|
@@ -503,7 +509,7 @@ The next upload iteration will be delayed.`);
|
|
|
503
509
|
if (progressForBucket) {
|
|
504
510
|
updatedProgress[data.bucket] = {
|
|
505
511
|
...progressForBucket,
|
|
506
|
-
sinceLast: progressForBucket.sinceLast + data.data.length
|
|
512
|
+
sinceLast: Math.min(progressForBucket.sinceLast + data.data.length, progressForBucket.targetCount - progressForBucket.atLast)
|
|
507
513
|
};
|
|
508
514
|
}
|
|
509
515
|
}
|
|
@@ -568,16 +574,32 @@ The next upload iteration will be delayed.`);
|
|
|
568
574
|
async updateSyncStatusForStartingCheckpoint(checkpoint) {
|
|
569
575
|
const localProgress = await this.options.adapter.getBucketOperationProgress();
|
|
570
576
|
const progress = {};
|
|
577
|
+
let invalidated = false;
|
|
571
578
|
for (const bucket of checkpoint.buckets) {
|
|
572
579
|
const savedProgress = localProgress[bucket.bucket];
|
|
580
|
+
const atLast = savedProgress?.atLast ?? 0;
|
|
581
|
+
const sinceLast = savedProgress?.sinceLast ?? 0;
|
|
573
582
|
progress[bucket.bucket] = {
|
|
574
583
|
// The fallback priority doesn't matter here, but 3 is the one newer versions of the sync service
|
|
575
584
|
// will use by default.
|
|
576
585
|
priority: bucket.priority ?? 3,
|
|
577
|
-
atLast:
|
|
578
|
-
sinceLast:
|
|
586
|
+
atLast: atLast,
|
|
587
|
+
sinceLast: sinceLast,
|
|
579
588
|
targetCount: bucket.count ?? 0
|
|
580
589
|
};
|
|
590
|
+
if (bucket.count != null && bucket.count < atLast + sinceLast) {
|
|
591
|
+
// Either due to a defrag / sync rule deploy or a compaction operation, the size
|
|
592
|
+
// of the bucket shrank so much that the local ops exceed the ops in the updated
|
|
593
|
+
// bucket. We can't prossibly report progress in this case (it would overshoot 100%).
|
|
594
|
+
invalidated = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (invalidated) {
|
|
598
|
+
for (const bucket in progress) {
|
|
599
|
+
const bucketProgress = progress[bucket];
|
|
600
|
+
bucketProgress.atLast = 0;
|
|
601
|
+
bucketProgress.sinceLast = 0;
|
|
602
|
+
}
|
|
581
603
|
}
|
|
582
604
|
this.updateSyncStatus({
|
|
583
605
|
dataFlow: {
|
|
@@ -645,7 +667,25 @@ The next upload iteration will be delayed.`);
|
|
|
645
667
|
// trigger this for all updates
|
|
646
668
|
this.iterateListeners((cb) => cb.statusUpdated?.(options));
|
|
647
669
|
}
|
|
648
|
-
async delayRetry() {
|
|
649
|
-
return new Promise((resolve) =>
|
|
670
|
+
async delayRetry(signal) {
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
if (signal?.aborted) {
|
|
673
|
+
// If the signal is already aborted, resolve immediately
|
|
674
|
+
resolve();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const { retryDelayMs } = this.options;
|
|
678
|
+
let timeoutId;
|
|
679
|
+
const endDelay = () => {
|
|
680
|
+
resolve();
|
|
681
|
+
if (timeoutId) {
|
|
682
|
+
clearTimeout(timeoutId);
|
|
683
|
+
timeoutId = undefined;
|
|
684
|
+
}
|
|
685
|
+
signal?.removeEventListener('abort', endDelay);
|
|
686
|
+
};
|
|
687
|
+
signal?.addEventListener('abort', endDelay, { once: true });
|
|
688
|
+
timeoutId = setTimeout(endDelay, retryDelayMs);
|
|
689
|
+
});
|
|
650
690
|
}
|
|
651
691
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from rsocket-websocket-client
|
|
3
|
+
* https://github.com/rsocket/rsocket-js/blob/e224cf379e747c4f1ddc4f2fa111854626cc8575/packages/rsocket-websocket-client/src/WebsocketClientTransport.ts#L17
|
|
4
|
+
* This adds additional error handling for React Native iOS.
|
|
5
|
+
* This particularly adds a close listener to handle cases where the WebSocket
|
|
6
|
+
* connection closes immediately after opening without emitting an error.
|
|
7
|
+
*/
|
|
8
|
+
import { ClientTransport, Closeable, Demultiplexer, DuplexConnection, FrameHandler, Multiplexer, Outbound } from 'rsocket-core';
|
|
9
|
+
import { ClientOptions } from 'rsocket-websocket-client';
|
|
10
|
+
export declare class WebsocketClientTransport implements ClientTransport {
|
|
11
|
+
private readonly url;
|
|
12
|
+
private readonly factory;
|
|
13
|
+
constructor(options: ClientOptions);
|
|
14
|
+
connect(multiplexerDemultiplexerFactory: (outbound: Outbound & Closeable) => Multiplexer & Demultiplexer & FrameHandler): Promise<DuplexConnection>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from rsocket-websocket-client
|
|
3
|
+
* https://github.com/rsocket/rsocket-js/blob/e224cf379e747c4f1ddc4f2fa111854626cc8575/packages/rsocket-websocket-client/src/WebsocketClientTransport.ts#L17
|
|
4
|
+
* This adds additional error handling for React Native iOS.
|
|
5
|
+
* This particularly adds a close listener to handle cases where the WebSocket
|
|
6
|
+
* connection closes immediately after opening without emitting an error.
|
|
7
|
+
*/
|
|
8
|
+
import { Deserializer } from 'rsocket-core';
|
|
9
|
+
import { WebsocketDuplexConnection } from 'rsocket-websocket-client/dist/WebsocketDuplexConnection.js';
|
|
10
|
+
export class WebsocketClientTransport {
|
|
11
|
+
url;
|
|
12
|
+
factory;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.url = options.url;
|
|
15
|
+
this.factory = options.wsCreator ?? ((url) => new WebSocket(url));
|
|
16
|
+
}
|
|
17
|
+
connect(multiplexerDemultiplexerFactory) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const websocket = this.factory(this.url);
|
|
20
|
+
websocket.binaryType = 'arraybuffer';
|
|
21
|
+
let removeListeners;
|
|
22
|
+
const openListener = () => {
|
|
23
|
+
removeListeners();
|
|
24
|
+
resolve(new WebsocketDuplexConnection(websocket, new Deserializer(), multiplexerDemultiplexerFactory));
|
|
25
|
+
};
|
|
26
|
+
const errorListener = (ev) => {
|
|
27
|
+
removeListeners();
|
|
28
|
+
// We add a default error in that case.
|
|
29
|
+
if (ev.error != null) {
|
|
30
|
+
// undici typically provides an error object
|
|
31
|
+
reject(ev.error);
|
|
32
|
+
}
|
|
33
|
+
else if (ev.message != null) {
|
|
34
|
+
// React Native typically does not provide an error object, but does provide a message
|
|
35
|
+
reject(new Error(`Failed to create websocket connection: ${ev.message}`));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Browsers often provide no details at all
|
|
39
|
+
reject(new Error(`Failed to create websocket connection to ${this.url}`));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* In some cases, such as React Native iOS, the WebSocket connection may close immediately after opening
|
|
44
|
+
* without and error. In such cases, we need to handle the close event to reject the promise.
|
|
45
|
+
*/
|
|
46
|
+
const closeListener = () => {
|
|
47
|
+
removeListeners();
|
|
48
|
+
reject(new Error('WebSocket connection closed while opening'));
|
|
49
|
+
};
|
|
50
|
+
removeListeners = () => {
|
|
51
|
+
websocket.removeEventListener('open', openListener);
|
|
52
|
+
websocket.removeEventListener('error', errorListener);
|
|
53
|
+
websocket.removeEventListener('close', closeListener);
|
|
54
|
+
};
|
|
55
|
+
websocket.addEventListener('open', openListener);
|
|
56
|
+
websocket.addEventListener('error', errorListener);
|
|
57
|
+
websocket.addEventListener('close', closeListener);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -123,7 +123,21 @@ export class SyncStatus {
|
|
|
123
123
|
* @returns {boolean} True if the instances are considered equal, false otherwise
|
|
124
124
|
*/
|
|
125
125
|
isEqual(status) {
|
|
126
|
-
|
|
126
|
+
/**
|
|
127
|
+
* By default Error object are serialized to an empty object.
|
|
128
|
+
* This replaces Errors with more useful information before serialization.
|
|
129
|
+
*/
|
|
130
|
+
const replacer = (_, value) => {
|
|
131
|
+
if (value instanceof Error) {
|
|
132
|
+
return {
|
|
133
|
+
name: value.name,
|
|
134
|
+
message: value.message,
|
|
135
|
+
stack: value.stack
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
};
|
|
140
|
+
return JSON.stringify(this.options, replacer) == JSON.stringify(status.options, replacer);
|
|
127
141
|
}
|
|
128
142
|
/**
|
|
129
143
|
* Creates a human-readable string representation of the current sync status.
|