@powersync/common 1.32.0 → 1.33.1
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.cjs +22 -0
- package/dist/bundle.mjs +5 -5
- package/lib/client/ConnectionManager.js +0 -1
- package/lib/client/sync/bucket/BucketStorageAdapter.d.ts +15 -9
- 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 -14
- package/lib/client/sync/bucket/SqliteBucketStorage.js +24 -44
- 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 +15 -3
- package/lib/client/sync/stream/AbstractRemote.js +95 -83
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +69 -0
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +462 -217
- 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/utils/DataStream.d.ts +13 -14
- package/lib/utils/DataStream.js +27 -29
- package/package.json +4 -4
|
@@ -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
|
});
|
|
@@ -134,17 +182,17 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
|
|
|
134
182
|
*/
|
|
135
183
|
let checkedCrudItem;
|
|
136
184
|
while (true) {
|
|
137
|
-
this.updateSyncStatus({
|
|
138
|
-
dataFlow: {
|
|
139
|
-
uploading: true
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
185
|
try {
|
|
143
186
|
/**
|
|
144
187
|
* This is the first item in the FIFO CRUD queue.
|
|
145
188
|
*/
|
|
146
189
|
const nextCrudItem = await this.options.adapter.nextCrudItem();
|
|
147
190
|
if (nextCrudItem) {
|
|
191
|
+
this.updateSyncStatus({
|
|
192
|
+
dataFlow: {
|
|
193
|
+
uploading: true
|
|
194
|
+
}
|
|
195
|
+
});
|
|
148
196
|
if (nextCrudItem.clientId == checkedCrudItem?.clientId) {
|
|
149
197
|
// This will force a higher log level than exceptions which are caught here.
|
|
150
198
|
this.logger.warn(`Potentially previously uploaded CRUD entries are still present in the upload queue.
|
|
@@ -199,23 +247,17 @@ The next upload iteration will be delayed.`);
|
|
|
199
247
|
const controller = new AbortController();
|
|
200
248
|
this.abortController = controller;
|
|
201
249
|
this.streamingSyncPromise = this.streamingSync(this.abortController.signal, options);
|
|
202
|
-
// Return a promise that resolves when the connection status is updated
|
|
250
|
+
// Return a promise that resolves when the connection status is updated to indicate that we're connected.
|
|
203
251
|
return new Promise((resolve) => {
|
|
204
252
|
const disposer = this.registerListener({
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (typeof update.connected == 'undefined') {
|
|
208
|
-
// only concern with connection updates
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (update.connected == false) {
|
|
212
|
-
/**
|
|
213
|
-
* This function does not reject if initial connect attempt failed.
|
|
214
|
-
* Connected can be false if the connection attempt was aborted or if the initial connection
|
|
215
|
-
* attempt failed.
|
|
216
|
-
*/
|
|
253
|
+
statusChanged: (status) => {
|
|
254
|
+
if (status.dataFlowStatus.downloadError != null) {
|
|
217
255
|
this.logger.warn('Initial connect attempt did not successfully connect to server');
|
|
218
256
|
}
|
|
257
|
+
else if (status.connecting) {
|
|
258
|
+
// Still connecting.
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
219
261
|
disposer();
|
|
220
262
|
resolve();
|
|
221
263
|
}
|
|
@@ -348,6 +390,31 @@ The next upload iteration will be delayed.`);
|
|
|
348
390
|
}
|
|
349
391
|
return [req, localDescriptions];
|
|
350
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Older versions of the JS SDK used to encode subkeys as JSON in {@link OplogEntry.toJSON}.
|
|
395
|
+
* Because subkeys are always strings, this leads to quotes being added around them in `ps_oplog`.
|
|
396
|
+
* While this is not a problem as long as it's done consistently, it causes issues when a database
|
|
397
|
+
* created by the JS SDK is used with other SDKs, or (more likely) when the new Rust sync client
|
|
398
|
+
* is enabled.
|
|
399
|
+
*
|
|
400
|
+
* So, we add a migration from the old key format (with quotes) to the new one (no quotes). The
|
|
401
|
+
* migration is only triggered when necessary (for now). The function returns whether the new format
|
|
402
|
+
* should be used, so that the JS SDK is able to write to updated databases.
|
|
403
|
+
*
|
|
404
|
+
* @param requireFixedKeyFormat Whether we require the new format or also support the old one.
|
|
405
|
+
* The Rust client requires the new subkey format.
|
|
406
|
+
* @returns Whether the database is now using the new, fixed subkey format.
|
|
407
|
+
*/
|
|
408
|
+
async requireKeyFormat(requireFixedKeyFormat) {
|
|
409
|
+
const hasMigrated = await this.options.adapter.hasMigratedSubkeys();
|
|
410
|
+
if (requireFixedKeyFormat && !hasMigrated) {
|
|
411
|
+
await this.options.adapter.migrateToFixedSubkeys();
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
return hasMigrated;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
351
418
|
async streamingSyncIteration(signal, options) {
|
|
352
419
|
await this.obtainLock({
|
|
353
420
|
type: LockType.SYNC,
|
|
@@ -357,219 +424,397 @@ The next upload iteration will be delayed.`);
|
|
|
357
424
|
...DEFAULT_STREAM_CONNECTION_OPTIONS,
|
|
358
425
|
...(options ?? {})
|
|
359
426
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
427
|
+
if (resolvedOptions.clientImplementation == SyncClientImplementation.JAVASCRIPT) {
|
|
428
|
+
await this.legacyStreamingSyncIteration(signal, resolvedOptions);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
await this.requireKeyFormat(true);
|
|
432
|
+
await this.rustSyncIteration(signal, resolvedOptions);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async legacyStreamingSyncIteration(signal, resolvedOptions) {
|
|
438
|
+
this.logger.debug('Streaming sync iteration started');
|
|
439
|
+
this.options.adapter.startSession();
|
|
440
|
+
let [req, bucketMap] = await this.collectLocalBucketState();
|
|
441
|
+
// These are compared by reference
|
|
442
|
+
let targetCheckpoint = null;
|
|
443
|
+
let validatedCheckpoint = null;
|
|
444
|
+
let appliedCheckpoint = null;
|
|
445
|
+
const clientId = await this.options.adapter.getClientId();
|
|
446
|
+
const usingFixedKeyFormat = await this.requireKeyFormat(false);
|
|
447
|
+
this.logger.debug('Requesting stream from server');
|
|
448
|
+
const syncOptions = {
|
|
449
|
+
path: '/sync/stream',
|
|
450
|
+
abortSignal: signal,
|
|
451
|
+
data: {
|
|
452
|
+
buckets: req,
|
|
453
|
+
include_checksum: true,
|
|
454
|
+
raw_data: true,
|
|
455
|
+
parameters: resolvedOptions.params,
|
|
456
|
+
client_id: clientId
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
let stream;
|
|
460
|
+
if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
461
|
+
stream = await this.options.remote.postStream(syncOptions);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
stream = await this.options.remote.socketStream({
|
|
465
|
+
...syncOptions,
|
|
466
|
+
...{ fetchStrategy: resolvedOptions.fetchStrategy }
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
this.logger.debug('Stream established. Processing events');
|
|
470
|
+
while (!stream.closed) {
|
|
471
|
+
const line = await stream.read();
|
|
472
|
+
if (!line) {
|
|
473
|
+
// The stream has closed while waiting
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// A connection is active and messages are being received
|
|
477
|
+
if (!this.syncStatus.connected) {
|
|
478
|
+
// There is a connection now
|
|
479
|
+
Promise.resolve().then(() => this.triggerCrudUpload());
|
|
480
|
+
this.updateSyncStatus({
|
|
481
|
+
connected: true
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (isStreamingSyncCheckpoint(line)) {
|
|
485
|
+
targetCheckpoint = line.checkpoint;
|
|
486
|
+
const bucketsToDelete = new Set(bucketMap.keys());
|
|
487
|
+
const newBuckets = new Map();
|
|
488
|
+
for (const checksum of line.checkpoint.buckets) {
|
|
489
|
+
newBuckets.set(checksum.bucket, {
|
|
490
|
+
name: checksum.bucket,
|
|
491
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
492
|
+
});
|
|
493
|
+
bucketsToDelete.delete(checksum.bucket);
|
|
494
|
+
}
|
|
495
|
+
if (bucketsToDelete.size > 0) {
|
|
496
|
+
this.logger.debug('Removing buckets', [...bucketsToDelete]);
|
|
497
|
+
}
|
|
498
|
+
bucketMap = newBuckets;
|
|
499
|
+
await this.options.adapter.removeBuckets([...bucketsToDelete]);
|
|
500
|
+
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
501
|
+
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
502
|
+
}
|
|
503
|
+
else if (isStreamingSyncCheckpointComplete(line)) {
|
|
504
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
505
|
+
if (result.endIteration) {
|
|
369
506
|
return;
|
|
370
507
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
508
|
+
else if (result.applied) {
|
|
509
|
+
appliedCheckpoint = targetCheckpoint;
|
|
510
|
+
}
|
|
511
|
+
validatedCheckpoint = targetCheckpoint;
|
|
512
|
+
}
|
|
513
|
+
else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
|
|
514
|
+
const priority = line.partial_checkpoint_complete.priority;
|
|
515
|
+
this.logger.debug('Partial checkpoint complete', priority);
|
|
516
|
+
const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
|
|
517
|
+
if (!result.checkpointValid) {
|
|
518
|
+
// This means checksums failed. Start again with a new checkpoint.
|
|
519
|
+
// TODO: better back-off
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
else if (!result.ready) {
|
|
524
|
+
// If we have pending uploads, we can't complete new checkpoints outside of priority 0.
|
|
525
|
+
// We'll resolve this for a complete checkpoint.
|
|
386
526
|
}
|
|
387
527
|
else {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
528
|
+
// We'll keep on downloading, but can report that this priority is synced now.
|
|
529
|
+
this.logger.debug('partial checkpoint validation succeeded');
|
|
530
|
+
// All states with a higher priority can be deleted since this partial sync includes them.
|
|
531
|
+
const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
|
|
532
|
+
priorityStates.push({
|
|
533
|
+
priority,
|
|
534
|
+
lastSyncedAt: new Date(),
|
|
535
|
+
hasSynced: true
|
|
536
|
+
});
|
|
537
|
+
this.updateSyncStatus({
|
|
538
|
+
connected: true,
|
|
539
|
+
priorityStatusEntries: priorityStates
|
|
391
540
|
});
|
|
392
541
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
542
|
+
}
|
|
543
|
+
else if (isStreamingSyncCheckpointDiff(line)) {
|
|
544
|
+
// TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
|
|
545
|
+
if (targetCheckpoint == null) {
|
|
546
|
+
throw new Error('Checkpoint diff without previous checkpoint');
|
|
547
|
+
}
|
|
548
|
+
const diff = line.checkpoint_diff;
|
|
549
|
+
const newBuckets = new Map();
|
|
550
|
+
for (const checksum of targetCheckpoint.buckets) {
|
|
551
|
+
newBuckets.set(checksum.bucket, checksum);
|
|
552
|
+
}
|
|
553
|
+
for (const checksum of diff.updated_buckets) {
|
|
554
|
+
newBuckets.set(checksum.bucket, checksum);
|
|
555
|
+
}
|
|
556
|
+
for (const bucket of diff.removed_buckets) {
|
|
557
|
+
newBuckets.delete(bucket);
|
|
558
|
+
}
|
|
559
|
+
const newCheckpoint = {
|
|
560
|
+
last_op_id: diff.last_op_id,
|
|
561
|
+
buckets: [...newBuckets.values()],
|
|
562
|
+
write_checkpoint: diff.write_checkpoint
|
|
563
|
+
};
|
|
564
|
+
targetCheckpoint = newCheckpoint;
|
|
565
|
+
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
566
|
+
bucketMap = new Map();
|
|
567
|
+
newBuckets.forEach((checksum, name) => bucketMap.set(name, {
|
|
568
|
+
name: checksum.bucket,
|
|
569
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
570
|
+
}));
|
|
571
|
+
const bucketsToDelete = diff.removed_buckets;
|
|
572
|
+
if (bucketsToDelete.length > 0) {
|
|
573
|
+
this.logger.debug('Remove buckets', bucketsToDelete);
|
|
574
|
+
}
|
|
575
|
+
await this.options.adapter.removeBuckets(bucketsToDelete);
|
|
576
|
+
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
577
|
+
}
|
|
578
|
+
else if (isStreamingSyncData(line)) {
|
|
579
|
+
const { data } = line;
|
|
580
|
+
const previousProgress = this.syncStatus.dataFlowStatus.downloadProgress;
|
|
581
|
+
let updatedProgress = null;
|
|
582
|
+
if (previousProgress) {
|
|
583
|
+
updatedProgress = { ...previousProgress };
|
|
584
|
+
const progressForBucket = updatedProgress[data.bucket];
|
|
585
|
+
if (progressForBucket) {
|
|
586
|
+
updatedProgress[data.bucket] = {
|
|
587
|
+
...progressForBucket,
|
|
588
|
+
since_last: progressForBucket.since_last + data.data.length
|
|
589
|
+
};
|
|
407
590
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
newBuckets.set(checksum.bucket, {
|
|
414
|
-
name: checksum.bucket,
|
|
415
|
-
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
416
|
-
});
|
|
417
|
-
bucketsToDelete.delete(checksum.bucket);
|
|
418
|
-
}
|
|
419
|
-
if (bucketsToDelete.size > 0) {
|
|
420
|
-
this.logger.debug('Removing buckets', [...bucketsToDelete]);
|
|
421
|
-
}
|
|
422
|
-
bucketMap = newBuckets;
|
|
423
|
-
await this.options.adapter.removeBuckets([...bucketsToDelete]);
|
|
424
|
-
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
425
|
-
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
591
|
+
}
|
|
592
|
+
this.updateSyncStatus({
|
|
593
|
+
dataFlow: {
|
|
594
|
+
downloading: true,
|
|
595
|
+
downloadProgress: updatedProgress
|
|
426
596
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
597
|
+
});
|
|
598
|
+
await this.options.adapter.saveSyncData({ buckets: [SyncDataBucket.fromRow(data)] }, usingFixedKeyFormat);
|
|
599
|
+
}
|
|
600
|
+
else if (isStreamingKeepalive(line)) {
|
|
601
|
+
const remaining_seconds = line.token_expires_in;
|
|
602
|
+
if (remaining_seconds == 0) {
|
|
603
|
+
// Connection would be closed automatically right after this
|
|
604
|
+
this.logger.debug('Token expiring; reconnect');
|
|
605
|
+
/**
|
|
606
|
+
* For a rare case where the backend connector does not update the token
|
|
607
|
+
* (uses the same one), this should have some delay.
|
|
608
|
+
*/
|
|
609
|
+
await this.delayRetry();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
else if (remaining_seconds < 30) {
|
|
613
|
+
this.logger.debug('Token will expire soon; reconnect');
|
|
614
|
+
// Pre-emptively refresh the token
|
|
615
|
+
this.options.remote.invalidateCredentials();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
this.triggerCrudUpload();
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
this.logger.debug('Sync complete');
|
|
622
|
+
if (targetCheckpoint === appliedCheckpoint) {
|
|
623
|
+
this.updateSyncStatus({
|
|
624
|
+
connected: true,
|
|
625
|
+
lastSyncedAt: new Date(),
|
|
626
|
+
priorityStatusEntries: [],
|
|
627
|
+
dataFlow: {
|
|
628
|
+
downloadError: undefined
|
|
434
629
|
}
|
|
435
|
-
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
else if (validatedCheckpoint === targetCheckpoint) {
|
|
633
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
634
|
+
if (result.endIteration) {
|
|
635
|
+
return;
|
|
436
636
|
}
|
|
437
|
-
else if (
|
|
438
|
-
|
|
439
|
-
this.logger.debug('Partial checkpoint complete', priority);
|
|
440
|
-
const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
|
|
441
|
-
if (!result.checkpointValid) {
|
|
442
|
-
// This means checksums failed. Start again with a new checkpoint.
|
|
443
|
-
// TODO: better back-off
|
|
444
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
else if (!result.ready) {
|
|
448
|
-
// If we have pending uploads, we can't complete new checkpoints outside of priority 0.
|
|
449
|
-
// We'll resolve this for a complete checkpoint.
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
// We'll keep on downloading, but can report that this priority is synced now.
|
|
453
|
-
this.logger.debug('partial checkpoint validation succeeded');
|
|
454
|
-
// All states with a higher priority can be deleted since this partial sync includes them.
|
|
455
|
-
const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
|
|
456
|
-
priorityStates.push({
|
|
457
|
-
priority,
|
|
458
|
-
lastSyncedAt: new Date(),
|
|
459
|
-
hasSynced: true
|
|
460
|
-
});
|
|
461
|
-
this.updateSyncStatus({
|
|
462
|
-
connected: true,
|
|
463
|
-
priorityStatusEntries: priorityStates
|
|
464
|
-
});
|
|
465
|
-
}
|
|
637
|
+
else if (result.applied) {
|
|
638
|
+
appliedCheckpoint = targetCheckpoint;
|
|
466
639
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
this.logger.debug('Stream input empty');
|
|
644
|
+
// Connection closed. Likely due to auth issue.
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
async rustSyncIteration(signal, resolvedOptions) {
|
|
648
|
+
const syncImplementation = this;
|
|
649
|
+
const adapter = this.options.adapter;
|
|
650
|
+
const remote = this.options.remote;
|
|
651
|
+
let receivingLines = null;
|
|
652
|
+
let hadSyncLine = false;
|
|
653
|
+
const abortController = new AbortController();
|
|
654
|
+
signal.addEventListener('abort', () => abortController.abort());
|
|
655
|
+
// Pending sync lines received from the service, as well as local events that trigger a powersync_control
|
|
656
|
+
// invocation (local events include refreshed tokens and completed uploads).
|
|
657
|
+
// This is a single data stream so that we can handle all control calls from a single place.
|
|
658
|
+
let controlInvocations = null;
|
|
659
|
+
async function connect(instr) {
|
|
660
|
+
const syncOptions = {
|
|
661
|
+
path: '/sync/stream',
|
|
662
|
+
abortSignal: abortController.signal,
|
|
663
|
+
data: instr.request
|
|
664
|
+
};
|
|
665
|
+
if (resolvedOptions.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
666
|
+
controlInvocations = await remote.postStreamRaw(syncOptions, (line) => {
|
|
667
|
+
if (typeof line == 'string') {
|
|
668
|
+
return {
|
|
669
|
+
command: PowerSyncControlCommand.PROCESS_TEXT_LINE,
|
|
670
|
+
payload: line
|
|
487
671
|
};
|
|
488
|
-
targetCheckpoint = newCheckpoint;
|
|
489
|
-
await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
|
|
490
|
-
bucketMap = new Map();
|
|
491
|
-
newBuckets.forEach((checksum, name) => bucketMap.set(name, {
|
|
492
|
-
name: checksum.bucket,
|
|
493
|
-
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
494
|
-
}));
|
|
495
|
-
const bucketsToDelete = diff.removed_buckets;
|
|
496
|
-
if (bucketsToDelete.length > 0) {
|
|
497
|
-
this.logger.debug('Remove buckets', bucketsToDelete);
|
|
498
|
-
}
|
|
499
|
-
await this.options.adapter.removeBuckets(bucketsToDelete);
|
|
500
|
-
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
501
672
|
}
|
|
502
|
-
else
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
let updatedProgress = null;
|
|
506
|
-
if (previousProgress) {
|
|
507
|
-
updatedProgress = { ...previousProgress };
|
|
508
|
-
const progressForBucket = updatedProgress[data.bucket];
|
|
509
|
-
if (progressForBucket) {
|
|
510
|
-
updatedProgress[data.bucket] = {
|
|
511
|
-
...progressForBucket,
|
|
512
|
-
sinceLast: Math.min(progressForBucket.sinceLast + data.data.length, progressForBucket.targetCount - progressForBucket.atLast)
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
this.updateSyncStatus({
|
|
517
|
-
dataFlow: {
|
|
518
|
-
downloading: true,
|
|
519
|
-
downloadProgress: updatedProgress
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
await this.options.adapter.saveSyncData({ buckets: [SyncDataBucket.fromRow(data)] });
|
|
673
|
+
else {
|
|
674
|
+
// Directly enqueued by us
|
|
675
|
+
return line;
|
|
523
676
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
else if (remaining_seconds < 30) {
|
|
538
|
-
this.logger.debug('Token will expire soon; reconnect');
|
|
539
|
-
// Pre-emptively refresh the token
|
|
540
|
-
this.options.remote.invalidateCredentials();
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
this.triggerCrudUpload();
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
controlInvocations = await remote.socketStreamRaw({
|
|
681
|
+
...syncOptions,
|
|
682
|
+
fetchStrategy: resolvedOptions.fetchStrategy
|
|
683
|
+
}, (payload) => {
|
|
684
|
+
if (payload instanceof Uint8Array) {
|
|
685
|
+
return {
|
|
686
|
+
command: PowerSyncControlCommand.PROCESS_BSON_LINE,
|
|
687
|
+
payload: payload
|
|
688
|
+
};
|
|
544
689
|
}
|
|
545
690
|
else {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
565
|
-
}
|
|
691
|
+
// Directly enqueued by us
|
|
692
|
+
return payload;
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
// The rust client will set connected: true after the first sync line because that's when it gets invoked, but
|
|
697
|
+
// we're already connected here and can report that.
|
|
698
|
+
syncImplementation.updateSyncStatus({ connected: true });
|
|
699
|
+
try {
|
|
700
|
+
while (!controlInvocations.closed) {
|
|
701
|
+
const line = await controlInvocations.read();
|
|
702
|
+
if (line == null) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
await control(line.command, line.payload);
|
|
706
|
+
if (!hadSyncLine) {
|
|
707
|
+
syncImplementation.triggerCrudUpload();
|
|
708
|
+
hadSyncLine = true;
|
|
566
709
|
}
|
|
567
710
|
}
|
|
568
|
-
this.logger.debug('Stream input empty');
|
|
569
|
-
// Connection closed. Likely due to auth issue.
|
|
570
|
-
return;
|
|
571
711
|
}
|
|
572
|
-
|
|
712
|
+
finally {
|
|
713
|
+
const activeInstructions = controlInvocations;
|
|
714
|
+
// We concurrently add events to the active data stream when e.g. a CRUD upload is completed or a token is
|
|
715
|
+
// refreshed. That would throw after closing (and we can't handle those events either way), so set this back
|
|
716
|
+
// to null.
|
|
717
|
+
controlInvocations = null;
|
|
718
|
+
await activeInstructions.close();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function stop() {
|
|
722
|
+
await control(PowerSyncControlCommand.STOP);
|
|
723
|
+
}
|
|
724
|
+
async function control(op, payload) {
|
|
725
|
+
const rawResponse = await adapter.control(op, payload ?? null);
|
|
726
|
+
await handleInstructions(JSON.parse(rawResponse));
|
|
727
|
+
}
|
|
728
|
+
async function handleInstruction(instruction) {
|
|
729
|
+
if ('LogLine' in instruction) {
|
|
730
|
+
switch (instruction.LogLine.severity) {
|
|
731
|
+
case 'DEBUG':
|
|
732
|
+
syncImplementation.logger.debug(instruction.LogLine.line);
|
|
733
|
+
break;
|
|
734
|
+
case 'INFO':
|
|
735
|
+
syncImplementation.logger.info(instruction.LogLine.line);
|
|
736
|
+
break;
|
|
737
|
+
case 'WARNING':
|
|
738
|
+
syncImplementation.logger.warn(instruction.LogLine.line);
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else if ('UpdateSyncStatus' in instruction) {
|
|
743
|
+
function coreStatusToJs(status) {
|
|
744
|
+
return {
|
|
745
|
+
priority: status.priority,
|
|
746
|
+
hasSynced: status.has_synced ?? undefined,
|
|
747
|
+
lastSyncedAt: status?.last_synced_at != null ? new Date(status.last_synced_at * 1000) : undefined
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const info = instruction.UpdateSyncStatus.status;
|
|
751
|
+
const coreCompleteSync = info.priority_status.find((s) => s.priority == FULL_SYNC_PRIORITY);
|
|
752
|
+
const completeSync = coreCompleteSync != null ? coreStatusToJs(coreCompleteSync) : null;
|
|
753
|
+
syncImplementation.updateSyncStatus({
|
|
754
|
+
connected: info.connected,
|
|
755
|
+
connecting: info.connecting,
|
|
756
|
+
dataFlow: {
|
|
757
|
+
downloading: info.downloading != null,
|
|
758
|
+
downloadProgress: info.downloading?.buckets
|
|
759
|
+
},
|
|
760
|
+
lastSyncedAt: completeSync?.lastSyncedAt,
|
|
761
|
+
hasSynced: completeSync?.hasSynced,
|
|
762
|
+
priorityStatusEntries: info.priority_status.map(coreStatusToJs)
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
else if ('EstablishSyncStream' in instruction) {
|
|
766
|
+
if (receivingLines != null) {
|
|
767
|
+
// Already connected, this shouldn't happen during a single iteration.
|
|
768
|
+
throw 'Unexpected request to establish sync stream, already connected';
|
|
769
|
+
}
|
|
770
|
+
receivingLines = connect(instruction.EstablishSyncStream);
|
|
771
|
+
}
|
|
772
|
+
else if ('FetchCredentials' in instruction) {
|
|
773
|
+
if (instruction.FetchCredentials.did_expire) {
|
|
774
|
+
remote.invalidateCredentials();
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
remote.invalidateCredentials();
|
|
778
|
+
// Restart iteration after the credentials have been refreshed.
|
|
779
|
+
remote.fetchCredentials().then((_) => {
|
|
780
|
+
controlInvocations?.enqueueData({ command: PowerSyncControlCommand.NOTIFY_TOKEN_REFRESHED });
|
|
781
|
+
}, (err) => {
|
|
782
|
+
syncImplementation.logger.warn('Could not prefetch credentials', err);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else if ('CloseSyncStream' in instruction) {
|
|
787
|
+
abortController.abort();
|
|
788
|
+
}
|
|
789
|
+
else if ('FlushFileSystem' in instruction) {
|
|
790
|
+
// Not necessary on JS platforms.
|
|
791
|
+
}
|
|
792
|
+
else if ('DidCompleteSync' in instruction) {
|
|
793
|
+
syncImplementation.updateSyncStatus({
|
|
794
|
+
dataFlow: {
|
|
795
|
+
downloadError: undefined
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async function handleInstructions(instructions) {
|
|
801
|
+
for (const instr of instructions) {
|
|
802
|
+
await handleInstruction(instr);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
await control(PowerSyncControlCommand.START, JSON.stringify({
|
|
807
|
+
parameters: resolvedOptions.params
|
|
808
|
+
}));
|
|
809
|
+
this.notifyCompletedUploads = () => {
|
|
810
|
+
controlInvocations?.enqueueData({ command: PowerSyncControlCommand.NOTIFY_CRUD_UPLOAD_COMPLETED });
|
|
811
|
+
};
|
|
812
|
+
await receivingLines;
|
|
813
|
+
}
|
|
814
|
+
finally {
|
|
815
|
+
this.notifyCompletedUploads = undefined;
|
|
816
|
+
await stop();
|
|
817
|
+
}
|
|
573
818
|
}
|
|
574
819
|
async updateSyncStatusForStartingCheckpoint(checkpoint) {
|
|
575
820
|
const localProgress = await this.options.adapter.getBucketOperationProgress();
|
|
@@ -583,9 +828,9 @@ The next upload iteration will be delayed.`);
|
|
|
583
828
|
// The fallback priority doesn't matter here, but 3 is the one newer versions of the sync service
|
|
584
829
|
// will use by default.
|
|
585
830
|
priority: bucket.priority ?? 3,
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
831
|
+
at_last: atLast,
|
|
832
|
+
since_last: sinceLast,
|
|
833
|
+
target_count: bucket.count ?? 0
|
|
589
834
|
};
|
|
590
835
|
if (bucket.count != null && bucket.count < atLast + sinceLast) {
|
|
591
836
|
// Either due to a defrag / sync rule deploy or a compaction operation, the size
|
|
@@ -597,8 +842,8 @@ The next upload iteration will be delayed.`);
|
|
|
597
842
|
if (invalidated) {
|
|
598
843
|
for (const bucket in progress) {
|
|
599
844
|
const bucketProgress = progress[bucket];
|
|
600
|
-
bucketProgress.
|
|
601
|
-
bucketProgress.
|
|
845
|
+
bucketProgress.at_last = 0;
|
|
846
|
+
bucketProgress.since_last = 0;
|
|
602
847
|
}
|
|
603
848
|
}
|
|
604
849
|
this.updateSyncStatus({
|