@powersync/common 1.31.1 → 1.33.0
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 +5 -5
- package/lib/client/AbstractPowerSyncDatabase.d.ts +9 -2
- package/lib/client/AbstractPowerSyncDatabase.js +42 -30
- package/lib/client/ConnectionManager.d.ts +80 -0
- package/lib/client/ConnectionManager.js +175 -0
- package/lib/client/sync/bucket/BucketStorageAdapter.d.ts +15 -1
- package/lib/client/sync/bucket/BucketStorageAdapter.js +9 -0
- package/lib/client/sync/bucket/CrudEntry.d.ts +2 -0
- package/lib/client/sync/bucket/CrudEntry.js +13 -2
- package/lib/client/sync/bucket/OplogEntry.d.ts +4 -4
- package/lib/client/sync/bucket/OplogEntry.js +5 -3
- package/lib/client/sync/bucket/SqliteBucketStorage.d.ts +6 -2
- package/lib/client/sync/bucket/SqliteBucketStorage.js +24 -2
- package/lib/client/sync/bucket/SyncDataBucket.d.ts +1 -1
- package/lib/client/sync/bucket/SyncDataBucket.js +2 -2
- package/lib/client/sync/stream/AbstractRemote.d.ts +16 -3
- package/lib/client/sync/stream/AbstractRemote.js +74 -92
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +69 -0
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +469 -212
- package/lib/client/sync/stream/WebsocketClientTransport.d.ts +15 -0
- package/lib/client/sync/stream/WebsocketClientTransport.js +60 -0
- package/lib/client/sync/stream/core-instruction.d.ts +53 -0
- package/lib/client/sync/stream/core-instruction.js +1 -0
- package/lib/db/crud/SyncProgress.d.ts +2 -6
- package/lib/db/crud/SyncProgress.js +2 -2
- package/lib/db/crud/SyncStatus.js +15 -1
- package/lib/index.d.ts +13 -14
- package/lib/index.js +13 -14
- package/package.json +1 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import Logger from 'js-logger';
|
|
2
2
|
import { SyncStatus } from '../../../db/crud/SyncStatus.js';
|
|
3
|
+
import { FULL_SYNC_PRIORITY } from '../../../db/crud/SyncProgress.js';
|
|
3
4
|
import { AbortOperation } from '../../../utils/AbortOperation.js';
|
|
4
5
|
import { BaseObserver } from '../../../utils/BaseObserver.js';
|
|
5
6
|
import { onAbortPromise, throttleLeadingTrailing } from '../../../utils/async.js';
|
|
7
|
+
import { PowerSyncControlCommand } from '../bucket/BucketStorageAdapter.js';
|
|
6
8
|
import { SyncDataBucket } from '../bucket/SyncDataBucket.js';
|
|
7
9
|
import { FetchStrategy } from './AbstractRemote.js';
|
|
8
10
|
import { isStreamingKeepalive, isStreamingSyncCheckpoint, isStreamingSyncCheckpointComplete, isStreamingSyncCheckpointDiff, isStreamingSyncCheckpointPartiallyComplete, isStreamingSyncData } from './streaming-sync-types.js';
|
|
@@ -16,6 +18,49 @@ export var SyncStreamConnectionMethod;
|
|
|
16
18
|
SyncStreamConnectionMethod["HTTP"] = "http";
|
|
17
19
|
SyncStreamConnectionMethod["WEB_SOCKET"] = "web-socket";
|
|
18
20
|
})(SyncStreamConnectionMethod || (SyncStreamConnectionMethod = {}));
|
|
21
|
+
export var SyncClientImplementation;
|
|
22
|
+
(function (SyncClientImplementation) {
|
|
23
|
+
/**
|
|
24
|
+
* Decodes and handles sync lines received from the sync service in JavaScript.
|
|
25
|
+
*
|
|
26
|
+
* This is the default option.
|
|
27
|
+
*
|
|
28
|
+
* @deprecated Don't use {@link SyncClientImplementation.JAVASCRIPT} directly. Instead, use
|
|
29
|
+
* {@link DEFAULT_SYNC_CLIENT_IMPLEMENTATION} or omit the option. The explicit choice to use
|
|
30
|
+
* the JavaScript-based sync implementation will be removed from a future version of the SDK.
|
|
31
|
+
*/
|
|
32
|
+
SyncClientImplementation["JAVASCRIPT"] = "js";
|
|
33
|
+
/**
|
|
34
|
+
* This implementation offloads the sync line decoding and handling into the PowerSync
|
|
35
|
+
* core extension.
|
|
36
|
+
*
|
|
37
|
+
* @experimental
|
|
38
|
+
* While this implementation is more performant than {@link SyncClientImplementation.JAVASCRIPT},
|
|
39
|
+
* it has seen less real-world testing and is marked as __experimental__ at the moment.
|
|
40
|
+
*
|
|
41
|
+
* ## Compatibility warning
|
|
42
|
+
*
|
|
43
|
+
* The Rust sync client stores sync data in a format that is slightly different than the one used
|
|
44
|
+
* by the old {@link JAVASCRIPT} implementation. When adopting the {@link RUST} client on existing
|
|
45
|
+
* databases, the PowerSync SDK will migrate the format automatically.
|
|
46
|
+
* Further, the {@link JAVASCRIPT} client in recent versions of the PowerSync JS SDK (starting from
|
|
47
|
+
* the version introducing {@link RUST} as an option) also supports the new format, so you can switch
|
|
48
|
+
* back to {@link JAVASCRIPT} later.
|
|
49
|
+
*
|
|
50
|
+
* __However__: Upgrading the SDK version, then adopting {@link RUST} as a sync client and later
|
|
51
|
+
* downgrading the SDK to an older version (necessarily using the JavaScript-based implementation then)
|
|
52
|
+
* can lead to sync issues.
|
|
53
|
+
*/
|
|
54
|
+
SyncClientImplementation["RUST"] = "rust";
|
|
55
|
+
})(SyncClientImplementation || (SyncClientImplementation = {}));
|
|
56
|
+
/**
|
|
57
|
+
* The default {@link SyncClientImplementation} to use.
|
|
58
|
+
*
|
|
59
|
+
* Please use this field instead of {@link SyncClientImplementation.JAVASCRIPT} directly. A future version
|
|
60
|
+
* of the PowerSync SDK will enable {@link SyncClientImplementation.RUST} by default and remove the JavaScript
|
|
61
|
+
* option.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_SYNC_CLIENT_IMPLEMENTATION = SyncClientImplementation.JAVASCRIPT;
|
|
19
64
|
export const DEFAULT_CRUD_UPLOAD_THROTTLE_MS = 1000;
|
|
20
65
|
export const DEFAULT_RETRY_DELAY_MS = 5000;
|
|
21
66
|
export const DEFAULT_STREAMING_SYNC_OPTIONS = {
|
|
@@ -25,6 +70,7 @@ export const DEFAULT_STREAMING_SYNC_OPTIONS = {
|
|
|
25
70
|
};
|
|
26
71
|
export const DEFAULT_STREAM_CONNECTION_OPTIONS = {
|
|
27
72
|
connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET,
|
|
73
|
+
clientImplementation: DEFAULT_SYNC_CLIENT_IMPLEMENTATION,
|
|
28
74
|
fetchStrategy: FetchStrategy.Buffered,
|
|
29
75
|
params: {}
|
|
30
76
|
};
|
|
@@ -40,6 +86,7 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
|
|
|
40
86
|
crudUpdateListener;
|
|
41
87
|
streamingSyncPromise;
|
|
42
88
|
pendingCrudUpload;
|
|
89
|
+
notifyCompletedUploads;
|
|
43
90
|
syncStatus;
|
|
44
91
|
triggerCrudUpload;
|
|
45
92
|
constructor(options) {
|
|
@@ -61,6 +108,7 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
|
|
|
61
108
|
}
|
|
62
109
|
this.pendingCrudUpload = new Promise((resolve) => {
|
|
63
110
|
this._uploadAllCrud().finally(() => {
|
|
111
|
+
this.notifyCompletedUploads?.();
|
|
64
112
|
this.pendingCrudUpload = undefined;
|
|
65
113
|
resolve();
|
|
66
114
|
});
|
|
@@ -196,11 +244,12 @@ The next upload iteration will be delayed.`);
|
|
|
196
244
|
if (this.abortController) {
|
|
197
245
|
await this.disconnect();
|
|
198
246
|
}
|
|
199
|
-
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
this.abortController = controller;
|
|
200
249
|
this.streamingSyncPromise = this.streamingSync(this.abortController.signal, options);
|
|
201
250
|
// Return a promise that resolves when the connection status is updated
|
|
202
251
|
return new Promise((resolve) => {
|
|
203
|
-
const
|
|
252
|
+
const disposer = this.registerListener({
|
|
204
253
|
statusUpdated: (update) => {
|
|
205
254
|
// This is triggered as soon as a connection is read from
|
|
206
255
|
if (typeof update.connected == 'undefined') {
|
|
@@ -209,12 +258,14 @@ The next upload iteration will be delayed.`);
|
|
|
209
258
|
}
|
|
210
259
|
if (update.connected == false) {
|
|
211
260
|
/**
|
|
212
|
-
* This function does not reject if initial connect attempt failed
|
|
261
|
+
* This function does not reject if initial connect attempt failed.
|
|
262
|
+
* Connected can be false if the connection attempt was aborted or if the initial connection
|
|
263
|
+
* attempt failed.
|
|
213
264
|
*/
|
|
214
265
|
this.logger.warn('Initial connect attempt did not successfully connect to server');
|
|
215
266
|
}
|
|
267
|
+
disposer();
|
|
216
268
|
resolve();
|
|
217
|
-
l();
|
|
218
269
|
}
|
|
219
270
|
});
|
|
220
271
|
});
|
|
@@ -283,6 +334,7 @@ The next upload iteration will be delayed.`);
|
|
|
283
334
|
*/
|
|
284
335
|
while (true) {
|
|
285
336
|
this.updateSyncStatus({ connecting: true });
|
|
337
|
+
let shouldDelayRetry = true;
|
|
286
338
|
try {
|
|
287
339
|
if (signal?.aborted) {
|
|
288
340
|
break;
|
|
@@ -295,12 +347,15 @@ The next upload iteration will be delayed.`);
|
|
|
295
347
|
* Either:
|
|
296
348
|
* - A network request failed with a failed connection or not OKAY response code.
|
|
297
349
|
* - There was a sync processing error.
|
|
298
|
-
*
|
|
350
|
+
* - The connection was aborted.
|
|
351
|
+
* This loop will retry after a delay if the connection was not aborted.
|
|
299
352
|
* The nested abort controller will cleanup any open network requests and streams.
|
|
300
353
|
* The WebRemote should only abort pending fetch requests or close active Readable streams.
|
|
301
354
|
*/
|
|
302
355
|
if (ex instanceof AbortOperation) {
|
|
303
356
|
this.logger.warn(ex);
|
|
357
|
+
shouldDelayRetry = false;
|
|
358
|
+
// A disconnect was requested, we should not delay since there is no explicit retry
|
|
304
359
|
}
|
|
305
360
|
else {
|
|
306
361
|
this.logger.error(ex);
|
|
@@ -310,8 +365,6 @@ The next upload iteration will be delayed.`);
|
|
|
310
365
|
downloadError: ex
|
|
311
366
|
}
|
|
312
367
|
});
|
|
313
|
-
// On error, wait a little before retrying
|
|
314
|
-
await this.delayRetry();
|
|
315
368
|
}
|
|
316
369
|
finally {
|
|
317
370
|
if (!signal.aborted) {
|
|
@@ -322,6 +375,10 @@ The next upload iteration will be delayed.`);
|
|
|
322
375
|
connected: false,
|
|
323
376
|
connecting: true // May be unnecessary
|
|
324
377
|
});
|
|
378
|
+
// On error, wait a little before retrying
|
|
379
|
+
if (shouldDelayRetry) {
|
|
380
|
+
await this.delayRetry(nestedAbortController.signal);
|
|
381
|
+
}
|
|
325
382
|
}
|
|
326
383
|
}
|
|
327
384
|
// Mark as disconnected if here
|
|
@@ -339,6 +396,31 @@ The next upload iteration will be delayed.`);
|
|
|
339
396
|
}
|
|
340
397
|
return [req, localDescriptions];
|
|
341
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Older versions of the JS SDK used to encode subkeys as JSON in {@link OplogEntry.toJSON}.
|
|
401
|
+
* Because subkeys are always strings, this leads to quotes being added around them in `ps_oplog`.
|
|
402
|
+
* While this is not a problem as long as it's done consistently, it causes issues when a database
|
|
403
|
+
* created by the JS SDK is used with other SDKs, or (more likely) when the new Rust sync client
|
|
404
|
+
* is enabled.
|
|
405
|
+
*
|
|
406
|
+
* So, we add a migration from the old key format (with quotes) to the new one (no quotes). The
|
|
407
|
+
* migration is only triggered when necessary (for now). The function returns whether the new format
|
|
408
|
+
* should be used, so that the JS SDK is able to write to updated databases.
|
|
409
|
+
*
|
|
410
|
+
* @param requireFixedKeyFormat Whether we require the new format or also support the old one.
|
|
411
|
+
* The Rust client requires the new subkey format.
|
|
412
|
+
* @returns Whether the database is now using the new, fixed subkey format.
|
|
413
|
+
*/
|
|
414
|
+
async requireKeyFormat(requireFixedKeyFormat) {
|
|
415
|
+
const hasMigrated = await this.options.adapter.hasMigratedSubkeys();
|
|
416
|
+
if (requireFixedKeyFormat && !hasMigrated) {
|
|
417
|
+
await this.options.adapter.migrateToFixedSubkeys();
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
return hasMigrated;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
342
424
|
async streamingSyncIteration(signal, options) {
|
|
343
425
|
await this.obtainLock({
|
|
344
426
|
type: LockType.SYNC,
|
|
@@ -348,216 +430,373 @@ The next upload iteration will be delayed.`);
|
|
|
348
430
|
...DEFAULT_STREAM_CONNECTION_OPTIONS,
|
|
349
431
|
...(options ?? {})
|
|
350
432
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
let [req, bucketMap] = await this.collectLocalBucketState();
|
|
354
|
-
// These are compared by reference
|
|
355
|
-
let targetCheckpoint = null;
|
|
356
|
-
let validatedCheckpoint = null;
|
|
357
|
-
let appliedCheckpoint = null;
|
|
358
|
-
const clientId = await this.options.adapter.getClientId();
|
|
359
|
-
this.logger.debug('Requesting stream from server');
|
|
360
|
-
const syncOptions = {
|
|
361
|
-
path: '/sync/stream',
|
|
362
|
-
abortSignal: signal,
|
|
363
|
-
data: {
|
|
364
|
-
buckets: req,
|
|
365
|
-
include_checksum: true,
|
|
366
|
-
raw_data: true,
|
|
367
|
-
parameters: resolvedOptions.params,
|
|
368
|
-
client_id: clientId
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
let stream;
|
|
372
|
-
if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
373
|
-
stream = await this.options.remote.postStream(syncOptions);
|
|
433
|
+
if (resolvedOptions.clientImplementation == SyncClientImplementation.JAVASCRIPT) {
|
|
434
|
+
await this.legacyStreamingSyncIteration(signal, resolvedOptions);
|
|
374
435
|
}
|
|
375
436
|
else {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
437
|
+
await this.requireKeyFormat(true);
|
|
438
|
+
await this.rustSyncIteration(signal, resolvedOptions);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async legacyStreamingSyncIteration(signal, resolvedOptions) {
|
|
444
|
+
this.logger.debug('Streaming sync iteration started');
|
|
445
|
+
this.options.adapter.startSession();
|
|
446
|
+
let [req, bucketMap] = await this.collectLocalBucketState();
|
|
447
|
+
// These are compared by reference
|
|
448
|
+
let targetCheckpoint = null;
|
|
449
|
+
let validatedCheckpoint = null;
|
|
450
|
+
let appliedCheckpoint = null;
|
|
451
|
+
const clientId = await this.options.adapter.getClientId();
|
|
452
|
+
const usingFixedKeyFormat = await this.requireKeyFormat(false);
|
|
453
|
+
this.logger.debug('Requesting stream from server');
|
|
454
|
+
const syncOptions = {
|
|
455
|
+
path: '/sync/stream',
|
|
456
|
+
abortSignal: signal,
|
|
457
|
+
data: {
|
|
458
|
+
buckets: req,
|
|
459
|
+
include_checksum: true,
|
|
460
|
+
raw_data: true,
|
|
461
|
+
parameters: resolvedOptions.params,
|
|
462
|
+
client_id: clientId
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
let stream;
|
|
466
|
+
if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
467
|
+
stream = await this.options.remote.postStream(syncOptions);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
stream = await this.options.remote.socketStream({
|
|
471
|
+
...syncOptions,
|
|
472
|
+
...{ fetchStrategy: resolvedOptions.fetchStrategy }
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
this.logger.debug('Stream established. Processing events');
|
|
476
|
+
while (!stream.closed) {
|
|
477
|
+
const line = await stream.read();
|
|
478
|
+
if (!line) {
|
|
479
|
+
// The stream has closed while waiting
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// A connection is active and messages are being received
|
|
483
|
+
if (!this.syncStatus.connected) {
|
|
484
|
+
// There is a connection now
|
|
485
|
+
Promise.resolve().then(() => this.triggerCrudUpload());
|
|
486
|
+
this.updateSyncStatus({
|
|
487
|
+
connected: true
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (isStreamingSyncCheckpoint(line)) {
|
|
491
|
+
targetCheckpoint = line.checkpoint;
|
|
492
|
+
const bucketsToDelete = new Set(bucketMap.keys());
|
|
493
|
+
const newBuckets = new Map();
|
|
494
|
+
for (const checksum of line.checkpoint.buckets) {
|
|
495
|
+
newBuckets.set(checksum.bucket, {
|
|
496
|
+
name: checksum.bucket,
|
|
497
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
379
498
|
});
|
|
499
|
+
bucketsToDelete.delete(checksum.bucket);
|
|
380
500
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
501
|
+
if (bucketsToDelete.size > 0) {
|
|
502
|
+
this.logger.debug('Removing buckets', [...bucketsToDelete]);
|
|
503
|
+
}
|
|
504
|
+
bucketMap = newBuckets;
|
|
505
|
+
await this.options.adapter.removeBuckets([...bucketsToDelete]);
|
|
506
|
+
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
507
|
+
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
508
|
+
}
|
|
509
|
+
else if (isStreamingSyncCheckpointComplete(line)) {
|
|
510
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
511
|
+
if (result.endIteration) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
else if (result.applied) {
|
|
515
|
+
appliedCheckpoint = targetCheckpoint;
|
|
516
|
+
}
|
|
517
|
+
validatedCheckpoint = targetCheckpoint;
|
|
518
|
+
}
|
|
519
|
+
else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
|
|
520
|
+
const priority = line.partial_checkpoint_complete.priority;
|
|
521
|
+
this.logger.debug('Partial checkpoint complete', priority);
|
|
522
|
+
const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
|
|
523
|
+
if (!result.checkpointValid) {
|
|
524
|
+
// This means checksums failed. Start again with a new checkpoint.
|
|
525
|
+
// TODO: better back-off
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
else if (!result.ready) {
|
|
530
|
+
// If we have pending uploads, we can't complete new checkpoints outside of priority 0.
|
|
531
|
+
// We'll resolve this for a complete checkpoint.
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
// We'll keep on downloading, but can report that this priority is synced now.
|
|
535
|
+
this.logger.debug('partial checkpoint validation succeeded');
|
|
536
|
+
// All states with a higher priority can be deleted since this partial sync includes them.
|
|
537
|
+
const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
|
|
538
|
+
priorityStates.push({
|
|
539
|
+
priority,
|
|
540
|
+
lastSyncedAt: new Date(),
|
|
541
|
+
hasSynced: true
|
|
542
|
+
});
|
|
543
|
+
this.updateSyncStatus({
|
|
544
|
+
connected: true,
|
|
545
|
+
priorityStatusEntries: priorityStates
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else if (isStreamingSyncCheckpointDiff(line)) {
|
|
550
|
+
// TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
|
|
551
|
+
if (targetCheckpoint == null) {
|
|
552
|
+
throw new Error('Checkpoint diff without previous checkpoint');
|
|
553
|
+
}
|
|
554
|
+
const diff = line.checkpoint_diff;
|
|
555
|
+
const newBuckets = new Map();
|
|
556
|
+
for (const checksum of targetCheckpoint.buckets) {
|
|
557
|
+
newBuckets.set(checksum.bucket, checksum);
|
|
558
|
+
}
|
|
559
|
+
for (const checksum of diff.updated_buckets) {
|
|
560
|
+
newBuckets.set(checksum.bucket, checksum);
|
|
561
|
+
}
|
|
562
|
+
for (const bucket of diff.removed_buckets) {
|
|
563
|
+
newBuckets.delete(bucket);
|
|
564
|
+
}
|
|
565
|
+
const newCheckpoint = {
|
|
566
|
+
last_op_id: diff.last_op_id,
|
|
567
|
+
buckets: [...newBuckets.values()],
|
|
568
|
+
write_checkpoint: diff.write_checkpoint
|
|
569
|
+
};
|
|
570
|
+
targetCheckpoint = newCheckpoint;
|
|
571
|
+
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
572
|
+
bucketMap = new Map();
|
|
573
|
+
newBuckets.forEach((checksum, name) => bucketMap.set(name, {
|
|
574
|
+
name: checksum.bucket,
|
|
575
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
576
|
+
}));
|
|
577
|
+
const bucketsToDelete = diff.removed_buckets;
|
|
578
|
+
if (bucketsToDelete.length > 0) {
|
|
579
|
+
this.logger.debug('Remove buckets', bucketsToDelete);
|
|
580
|
+
}
|
|
581
|
+
await this.options.adapter.removeBuckets(bucketsToDelete);
|
|
582
|
+
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
583
|
+
}
|
|
584
|
+
else if (isStreamingSyncData(line)) {
|
|
585
|
+
const { data } = line;
|
|
586
|
+
const previousProgress = this.syncStatus.dataFlowStatus.downloadProgress;
|
|
587
|
+
let updatedProgress = null;
|
|
588
|
+
if (previousProgress) {
|
|
589
|
+
updatedProgress = { ...previousProgress };
|
|
590
|
+
const progressForBucket = updatedProgress[data.bucket];
|
|
591
|
+
if (progressForBucket) {
|
|
592
|
+
updatedProgress[data.bucket] = {
|
|
593
|
+
...progressForBucket,
|
|
594
|
+
since_last: progressForBucket.since_last + data.data.length
|
|
475
595
|
};
|
|
476
|
-
targetCheckpoint = newCheckpoint;
|
|
477
|
-
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
478
|
-
bucketMap = new Map();
|
|
479
|
-
newBuckets.forEach((checksum, name) => bucketMap.set(name, {
|
|
480
|
-
name: checksum.bucket,
|
|
481
|
-
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
482
|
-
}));
|
|
483
|
-
const bucketsToDelete = diff.removed_buckets;
|
|
484
|
-
if (bucketsToDelete.length > 0) {
|
|
485
|
-
this.logger.debug('Remove buckets', bucketsToDelete);
|
|
486
|
-
}
|
|
487
|
-
await this.options.adapter.removeBuckets(bucketsToDelete);
|
|
488
|
-
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
489
596
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
updatedProgress = { ...previousProgress };
|
|
496
|
-
const progressForBucket = updatedProgress[data.bucket];
|
|
497
|
-
if (progressForBucket) {
|
|
498
|
-
updatedProgress[data.bucket] = {
|
|
499
|
-
...progressForBucket,
|
|
500
|
-
sinceLast: Math.min(progressForBucket.sinceLast + data.data.length, progressForBucket.targetCount - progressForBucket.atLast)
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
this.updateSyncStatus({
|
|
505
|
-
dataFlow: {
|
|
506
|
-
downloading: true,
|
|
507
|
-
downloadProgress: updatedProgress
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
await this.options.adapter.saveSyncData({ buckets: [SyncDataBucket.fromRow(data)] });
|
|
597
|
+
}
|
|
598
|
+
this.updateSyncStatus({
|
|
599
|
+
dataFlow: {
|
|
600
|
+
downloading: true,
|
|
601
|
+
downloadProgress: updatedProgress
|
|
511
602
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
603
|
+
});
|
|
604
|
+
await this.options.adapter.saveSyncData({ buckets: [SyncDataBucket.fromRow(data)] }, usingFixedKeyFormat);
|
|
605
|
+
}
|
|
606
|
+
else if (isStreamingKeepalive(line)) {
|
|
607
|
+
const remaining_seconds = line.token_expires_in;
|
|
608
|
+
if (remaining_seconds == 0) {
|
|
609
|
+
// Connection would be closed automatically right after this
|
|
610
|
+
this.logger.debug('Token expiring; reconnect');
|
|
611
|
+
/**
|
|
612
|
+
* For a rare case where the backend connector does not update the token
|
|
613
|
+
* (uses the same one), this should have some delay.
|
|
614
|
+
*/
|
|
615
|
+
await this.delayRetry();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
else if (remaining_seconds < 30) {
|
|
619
|
+
this.logger.debug('Token will expire soon; reconnect');
|
|
620
|
+
// Pre-emptively refresh the token
|
|
621
|
+
this.options.remote.invalidateCredentials();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
this.triggerCrudUpload();
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
this.logger.debug('Sync complete');
|
|
628
|
+
if (targetCheckpoint === appliedCheckpoint) {
|
|
629
|
+
this.updateSyncStatus({
|
|
630
|
+
connected: true,
|
|
631
|
+
lastSyncedAt: new Date(),
|
|
632
|
+
priorityStatusEntries: [],
|
|
633
|
+
dataFlow: {
|
|
634
|
+
downloadError: undefined
|
|
530
635
|
}
|
|
531
|
-
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
else if (validatedCheckpoint === targetCheckpoint) {
|
|
639
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
640
|
+
if (result.endIteration) {
|
|
641
|
+
return;
|
|
532
642
|
}
|
|
533
|
-
else {
|
|
534
|
-
|
|
535
|
-
if (targetCheckpoint === appliedCheckpoint) {
|
|
536
|
-
this.updateSyncStatus({
|
|
537
|
-
connected: true,
|
|
538
|
-
lastSyncedAt: new Date(),
|
|
539
|
-
priorityStatusEntries: [],
|
|
540
|
-
dataFlow: {
|
|
541
|
-
downloadError: undefined
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
else if (validatedCheckpoint === targetCheckpoint) {
|
|
546
|
-
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
547
|
-
if (result.endIteration) {
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
else if (result.applied) {
|
|
551
|
-
appliedCheckpoint = targetCheckpoint;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
643
|
+
else if (result.applied) {
|
|
644
|
+
appliedCheckpoint = targetCheckpoint;
|
|
554
645
|
}
|
|
555
646
|
}
|
|
556
|
-
this.logger.debug('Stream input empty');
|
|
557
|
-
// Connection closed. Likely due to auth issue.
|
|
558
|
-
return;
|
|
559
647
|
}
|
|
560
|
-
}
|
|
648
|
+
}
|
|
649
|
+
this.logger.debug('Stream input empty');
|
|
650
|
+
// Connection closed. Likely due to auth issue.
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
async rustSyncIteration(signal, resolvedOptions) {
|
|
654
|
+
const syncImplementation = this;
|
|
655
|
+
const adapter = this.options.adapter;
|
|
656
|
+
const remote = this.options.remote;
|
|
657
|
+
let receivingLines = null;
|
|
658
|
+
const abortController = new AbortController();
|
|
659
|
+
signal.addEventListener('abort', () => abortController.abort());
|
|
660
|
+
// Pending sync lines received from the service, as well as local events that trigger a powersync_control
|
|
661
|
+
// invocation (local events include refreshed tokens and completed uploads).
|
|
662
|
+
// This is a single data stream so that we can handle all control calls from a single place.
|
|
663
|
+
let controlInvocations = null;
|
|
664
|
+
async function connect(instr) {
|
|
665
|
+
const syncOptions = {
|
|
666
|
+
path: '/sync/stream',
|
|
667
|
+
abortSignal: abortController.signal,
|
|
668
|
+
data: instr.request
|
|
669
|
+
};
|
|
670
|
+
if (resolvedOptions.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
671
|
+
controlInvocations = await remote.postStreamRaw(syncOptions, (line) => ({
|
|
672
|
+
command: PowerSyncControlCommand.PROCESS_TEXT_LINE,
|
|
673
|
+
payload: line
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
controlInvocations = await remote.socketStreamRaw({
|
|
678
|
+
...syncOptions,
|
|
679
|
+
fetchStrategy: resolvedOptions.fetchStrategy
|
|
680
|
+
}, (buffer) => ({
|
|
681
|
+
command: PowerSyncControlCommand.PROCESS_BSON_LINE,
|
|
682
|
+
payload: buffer
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
while (!controlInvocations.closed) {
|
|
687
|
+
const line = await controlInvocations.read();
|
|
688
|
+
if (line == null) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
await control(line.command, line.payload);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
const activeInstructions = controlInvocations;
|
|
696
|
+
// We concurrently add events to the active data stream when e.g. a CRUD upload is completed or a token is
|
|
697
|
+
// refreshed. That would throw after closing (and we can't handle those events either way), so set this back
|
|
698
|
+
// to null.
|
|
699
|
+
controlInvocations = null;
|
|
700
|
+
await activeInstructions.close();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function stop() {
|
|
704
|
+
await control(PowerSyncControlCommand.STOP);
|
|
705
|
+
}
|
|
706
|
+
async function control(op, payload) {
|
|
707
|
+
const rawResponse = await adapter.control(op, payload ?? null);
|
|
708
|
+
await handleInstructions(JSON.parse(rawResponse));
|
|
709
|
+
}
|
|
710
|
+
async function handleInstruction(instruction) {
|
|
711
|
+
if ('LogLine' in instruction) {
|
|
712
|
+
switch (instruction.LogLine.severity) {
|
|
713
|
+
case 'DEBUG':
|
|
714
|
+
syncImplementation.logger.debug(instruction.LogLine.line);
|
|
715
|
+
break;
|
|
716
|
+
case 'INFO':
|
|
717
|
+
syncImplementation.logger.info(instruction.LogLine.line);
|
|
718
|
+
break;
|
|
719
|
+
case 'WARNING':
|
|
720
|
+
syncImplementation.logger.warn(instruction.LogLine.line);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if ('UpdateSyncStatus' in instruction) {
|
|
725
|
+
function coreStatusToJs(status) {
|
|
726
|
+
return {
|
|
727
|
+
priority: status.priority,
|
|
728
|
+
hasSynced: status.has_synced ?? undefined,
|
|
729
|
+
lastSyncedAt: status?.last_synced_at != null ? new Date(status.last_synced_at) : undefined
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const info = instruction.UpdateSyncStatus.status;
|
|
733
|
+
const coreCompleteSync = info.priority_status.find((s) => s.priority == FULL_SYNC_PRIORITY);
|
|
734
|
+
const completeSync = coreCompleteSync != null ? coreStatusToJs(coreCompleteSync) : null;
|
|
735
|
+
syncImplementation.updateSyncStatus({
|
|
736
|
+
connected: info.connected,
|
|
737
|
+
connecting: info.connecting,
|
|
738
|
+
dataFlow: {
|
|
739
|
+
downloading: info.downloading != null,
|
|
740
|
+
downloadProgress: info.downloading?.buckets
|
|
741
|
+
},
|
|
742
|
+
lastSyncedAt: completeSync?.lastSyncedAt,
|
|
743
|
+
hasSynced: completeSync?.hasSynced,
|
|
744
|
+
priorityStatusEntries: info.priority_status.map(coreStatusToJs)
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
else if ('EstablishSyncStream' in instruction) {
|
|
748
|
+
if (receivingLines != null) {
|
|
749
|
+
// Already connected, this shouldn't happen during a single iteration.
|
|
750
|
+
throw 'Unexpected request to establish sync stream, already connected';
|
|
751
|
+
}
|
|
752
|
+
receivingLines = connect(instruction.EstablishSyncStream);
|
|
753
|
+
}
|
|
754
|
+
else if ('FetchCredentials' in instruction) {
|
|
755
|
+
if (instruction.FetchCredentials.did_expire) {
|
|
756
|
+
remote.invalidateCredentials();
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
remote.invalidateCredentials();
|
|
760
|
+
// Restart iteration after the credentials have been refreshed.
|
|
761
|
+
remote.fetchCredentials().then((_) => {
|
|
762
|
+
controlInvocations?.enqueueData({ command: PowerSyncControlCommand.NOTIFY_TOKEN_REFRESHED });
|
|
763
|
+
}, (err) => {
|
|
764
|
+
syncImplementation.logger.warn('Could not prefetch credentials', err);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
else if ('CloseSyncStream' in instruction) {
|
|
769
|
+
abortController.abort();
|
|
770
|
+
}
|
|
771
|
+
else if ('FlushFileSystem' in instruction) {
|
|
772
|
+
// Not necessary on JS platforms.
|
|
773
|
+
}
|
|
774
|
+
else if ('DidCompleteSync' in instruction) {
|
|
775
|
+
syncImplementation.updateSyncStatus({
|
|
776
|
+
dataFlow: {
|
|
777
|
+
downloadError: undefined
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async function handleInstructions(instructions) {
|
|
783
|
+
for (const instr of instructions) {
|
|
784
|
+
await handleInstruction(instr);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
await control(PowerSyncControlCommand.START, JSON.stringify({
|
|
789
|
+
parameters: resolvedOptions.params
|
|
790
|
+
}));
|
|
791
|
+
this.notifyCompletedUploads = () => {
|
|
792
|
+
controlInvocations?.enqueueData({ command: PowerSyncControlCommand.NOTIFY_CRUD_UPLOAD_COMPLETED });
|
|
793
|
+
};
|
|
794
|
+
await receivingLines;
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
this.notifyCompletedUploads = undefined;
|
|
798
|
+
await stop();
|
|
799
|
+
}
|
|
561
800
|
}
|
|
562
801
|
async updateSyncStatusForStartingCheckpoint(checkpoint) {
|
|
563
802
|
const localProgress = await this.options.adapter.getBucketOperationProgress();
|
|
@@ -571,9 +810,9 @@ The next upload iteration will be delayed.`);
|
|
|
571
810
|
// The fallback priority doesn't matter here, but 3 is the one newer versions of the sync service
|
|
572
811
|
// will use by default.
|
|
573
812
|
priority: bucket.priority ?? 3,
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
813
|
+
at_last: atLast,
|
|
814
|
+
since_last: sinceLast,
|
|
815
|
+
target_count: bucket.count ?? 0
|
|
577
816
|
};
|
|
578
817
|
if (bucket.count != null && bucket.count < atLast + sinceLast) {
|
|
579
818
|
// Either due to a defrag / sync rule deploy or a compaction operation, the size
|
|
@@ -585,8 +824,8 @@ The next upload iteration will be delayed.`);
|
|
|
585
824
|
if (invalidated) {
|
|
586
825
|
for (const bucket in progress) {
|
|
587
826
|
const bucketProgress = progress[bucket];
|
|
588
|
-
bucketProgress.
|
|
589
|
-
bucketProgress.
|
|
827
|
+
bucketProgress.at_last = 0;
|
|
828
|
+
bucketProgress.since_last = 0;
|
|
590
829
|
}
|
|
591
830
|
}
|
|
592
831
|
this.updateSyncStatus({
|
|
@@ -655,7 +894,25 @@ The next upload iteration will be delayed.`);
|
|
|
655
894
|
// trigger this for all updates
|
|
656
895
|
this.iterateListeners((cb) => cb.statusUpdated?.(options));
|
|
657
896
|
}
|
|
658
|
-
async delayRetry() {
|
|
659
|
-
return new Promise((resolve) =>
|
|
897
|
+
async delayRetry(signal) {
|
|
898
|
+
return new Promise((resolve) => {
|
|
899
|
+
if (signal?.aborted) {
|
|
900
|
+
// If the signal is already aborted, resolve immediately
|
|
901
|
+
resolve();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const { retryDelayMs } = this.options;
|
|
905
|
+
let timeoutId;
|
|
906
|
+
const endDelay = () => {
|
|
907
|
+
resolve();
|
|
908
|
+
if (timeoutId) {
|
|
909
|
+
clearTimeout(timeoutId);
|
|
910
|
+
timeoutId = undefined;
|
|
911
|
+
}
|
|
912
|
+
signal?.removeEventListener('abort', endDelay);
|
|
913
|
+
};
|
|
914
|
+
signal?.addEventListener('abort', endDelay, { once: true });
|
|
915
|
+
timeoutId = setTimeout(endDelay, retryDelayMs);
|
|
916
|
+
});
|
|
660
917
|
}
|
|
661
918
|
}
|