@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.
@@ -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
- statusUpdated: (update) => {
206
- // This is triggered as soon as a connection is read from
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
- this.logger.debug('Streaming sync iteration started');
361
- this.options.adapter.startSession();
362
- let [req, bucketMap] = await this.collectLocalBucketState();
363
- // These are compared by reference
364
- let targetCheckpoint = null;
365
- let validatedCheckpoint = null;
366
- let appliedCheckpoint = null;
367
- const clientId = await this.options.adapter.getClientId();
368
- if (signal.aborted) {
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
- this.logger.debug('Requesting stream from server');
372
- const syncOptions = {
373
- path: '/sync/stream',
374
- abortSignal: signal,
375
- data: {
376
- buckets: req,
377
- include_checksum: true,
378
- raw_data: true,
379
- parameters: resolvedOptions.params,
380
- client_id: clientId
381
- }
382
- };
383
- let stream;
384
- if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
385
- stream = await this.options.remote.postStream(syncOptions);
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
- stream = await this.options.remote.socketStream({
389
- ...syncOptions,
390
- ...{ fetchStrategy: resolvedOptions.fetchStrategy }
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
- this.logger.debug('Stream established. Processing events');
394
- while (!stream.closed) {
395
- const line = await stream.read();
396
- if (!line) {
397
- // The stream has closed while waiting
398
- return;
399
- }
400
- // A connection is active and messages are being received
401
- if (!this.syncStatus.connected) {
402
- // There is a connection now
403
- Promise.resolve().then(() => this.triggerCrudUpload());
404
- this.updateSyncStatus({
405
- connected: true
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
- if (isStreamingSyncCheckpoint(line)) {
409
- targetCheckpoint = line.checkpoint;
410
- const bucketsToDelete = new Set(bucketMap.keys());
411
- const newBuckets = new Map();
412
- for (const checksum of line.checkpoint.buckets) {
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
- else if (isStreamingSyncCheckpointComplete(line)) {
428
- const result = await this.applyCheckpoint(targetCheckpoint, signal);
429
- if (result.endIteration) {
430
- return;
431
- }
432
- else if (result.applied) {
433
- appliedCheckpoint = targetCheckpoint;
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
- validatedCheckpoint = targetCheckpoint;
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 (isStreamingSyncCheckpointPartiallyComplete(line)) {
438
- const priority = line.partial_checkpoint_complete.priority;
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
- else if (isStreamingSyncCheckpointDiff(line)) {
468
- // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
469
- if (targetCheckpoint == null) {
470
- throw new Error('Checkpoint diff without previous checkpoint');
471
- }
472
- const diff = line.checkpoint_diff;
473
- const newBuckets = new Map();
474
- for (const checksum of targetCheckpoint.buckets) {
475
- newBuckets.set(checksum.bucket, checksum);
476
- }
477
- for (const checksum of diff.updated_buckets) {
478
- newBuckets.set(checksum.bucket, checksum);
479
- }
480
- for (const bucket of diff.removed_buckets) {
481
- newBuckets.delete(bucket);
482
- }
483
- const newCheckpoint = {
484
- last_op_id: diff.last_op_id,
485
- buckets: [...newBuckets.values()],
486
- write_checkpoint: diff.write_checkpoint
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 if (isStreamingSyncData(line)) {
503
- const { data } = line;
504
- const previousProgress = this.syncStatus.dataFlowStatus.downloadProgress;
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
- else if (isStreamingKeepalive(line)) {
525
- const remaining_seconds = line.token_expires_in;
526
- if (remaining_seconds == 0) {
527
- // Connection would be closed automatically right after this
528
- this.logger.debug('Token expiring; reconnect');
529
- this.options.remote.invalidateCredentials();
530
- /**
531
- * For a rare case where the backend connector does not update the token
532
- * (uses the same one), this should have some delay.
533
- */
534
- await this.delayRetry();
535
- return;
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
- this.logger.debug('Sync complete');
547
- if (targetCheckpoint === appliedCheckpoint) {
548
- this.updateSyncStatus({
549
- connected: true,
550
- lastSyncedAt: new Date(),
551
- priorityStatusEntries: [],
552
- dataFlow: {
553
- downloadError: undefined
554
- }
555
- });
556
- }
557
- else if (validatedCheckpoint === targetCheckpoint) {
558
- const result = await this.applyCheckpoint(targetCheckpoint, signal);
559
- if (result.endIteration) {
560
- return;
561
- }
562
- else if (result.applied) {
563
- appliedCheckpoint = targetCheckpoint;
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
- atLast: atLast,
587
- sinceLast: sinceLast,
588
- targetCount: bucket.count ?? 0
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.atLast = 0;
601
- bucketProgress.sinceLast = 0;
845
+ bucketProgress.at_last = 0;
846
+ bucketProgress.since_last = 0;
602
847
  }
603
848
  }
604
849
  this.updateSyncStatus({