@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.
Files changed (29) hide show
  1. package/dist/bundle.mjs +5 -5
  2. package/lib/client/AbstractPowerSyncDatabase.d.ts +9 -2
  3. package/lib/client/AbstractPowerSyncDatabase.js +42 -30
  4. package/lib/client/ConnectionManager.d.ts +80 -0
  5. package/lib/client/ConnectionManager.js +175 -0
  6. package/lib/client/sync/bucket/BucketStorageAdapter.d.ts +15 -1
  7. package/lib/client/sync/bucket/BucketStorageAdapter.js +9 -0
  8. package/lib/client/sync/bucket/CrudEntry.d.ts +2 -0
  9. package/lib/client/sync/bucket/CrudEntry.js +13 -2
  10. package/lib/client/sync/bucket/OplogEntry.d.ts +4 -4
  11. package/lib/client/sync/bucket/OplogEntry.js +5 -3
  12. package/lib/client/sync/bucket/SqliteBucketStorage.d.ts +6 -2
  13. package/lib/client/sync/bucket/SqliteBucketStorage.js +24 -2
  14. package/lib/client/sync/bucket/SyncDataBucket.d.ts +1 -1
  15. package/lib/client/sync/bucket/SyncDataBucket.js +2 -2
  16. package/lib/client/sync/stream/AbstractRemote.d.ts +16 -3
  17. package/lib/client/sync/stream/AbstractRemote.js +74 -92
  18. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +69 -0
  19. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +469 -212
  20. package/lib/client/sync/stream/WebsocketClientTransport.d.ts +15 -0
  21. package/lib/client/sync/stream/WebsocketClientTransport.js +60 -0
  22. package/lib/client/sync/stream/core-instruction.d.ts +53 -0
  23. package/lib/client/sync/stream/core-instruction.js +1 -0
  24. package/lib/db/crud/SyncProgress.d.ts +2 -6
  25. package/lib/db/crud/SyncProgress.js +2 -2
  26. package/lib/db/crud/SyncStatus.js +15 -1
  27. package/lib/index.d.ts +13 -14
  28. package/lib/index.js +13 -14
  29. 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
- this.abortController = new AbortController();
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 l = this.registerListener({
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
- * This loop will retry.
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
- this.logger.debug('Streaming sync iteration started');
352
- this.options.adapter.startSession();
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
- stream = await this.options.remote.socketStream({
377
- ...syncOptions,
378
- ...{ fetchStrategy: resolvedOptions.fetchStrategy }
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
- this.logger.debug('Stream established. Processing events');
382
- while (!stream.closed) {
383
- const line = await stream.read();
384
- if (!line) {
385
- // The stream has closed while waiting
386
- return;
387
- }
388
- // A connection is active and messages are being received
389
- if (!this.syncStatus.connected) {
390
- // There is a connection now
391
- Promise.resolve().then(() => this.triggerCrudUpload());
392
- this.updateSyncStatus({
393
- connected: true
394
- });
395
- }
396
- if (isStreamingSyncCheckpoint(line)) {
397
- targetCheckpoint = line.checkpoint;
398
- const bucketsToDelete = new Set(bucketMap.keys());
399
- const newBuckets = new Map();
400
- for (const checksum of line.checkpoint.buckets) {
401
- newBuckets.set(checksum.bucket, {
402
- name: checksum.bucket,
403
- priority: checksum.priority ?? FALLBACK_PRIORITY
404
- });
405
- bucketsToDelete.delete(checksum.bucket);
406
- }
407
- if (bucketsToDelete.size > 0) {
408
- this.logger.debug('Removing buckets', [...bucketsToDelete]);
409
- }
410
- bucketMap = newBuckets;
411
- await this.options.adapter.removeBuckets([...bucketsToDelete]);
412
- await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
413
- await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
414
- }
415
- else if (isStreamingSyncCheckpointComplete(line)) {
416
- const result = await this.applyCheckpoint(targetCheckpoint, signal);
417
- if (result.endIteration) {
418
- return;
419
- }
420
- else if (result.applied) {
421
- appliedCheckpoint = targetCheckpoint;
422
- }
423
- validatedCheckpoint = targetCheckpoint;
424
- }
425
- else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
426
- const priority = line.partial_checkpoint_complete.priority;
427
- this.logger.debug('Partial checkpoint complete', priority);
428
- const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
429
- if (!result.checkpointValid) {
430
- // This means checksums failed. Start again with a new checkpoint.
431
- // TODO: better back-off
432
- await new Promise((resolve) => setTimeout(resolve, 50));
433
- return;
434
- }
435
- else if (!result.ready) {
436
- // If we have pending uploads, we can't complete new checkpoints outside of priority 0.
437
- // We'll resolve this for a complete checkpoint.
438
- }
439
- else {
440
- // We'll keep on downloading, but can report that this priority is synced now.
441
- this.logger.debug('partial checkpoint validation succeeded');
442
- // All states with a higher priority can be deleted since this partial sync includes them.
443
- const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
444
- priorityStates.push({
445
- priority,
446
- lastSyncedAt: new Date(),
447
- hasSynced: true
448
- });
449
- this.updateSyncStatus({
450
- connected: true,
451
- priorityStatusEntries: priorityStates
452
- });
453
- }
454
- }
455
- else if (isStreamingSyncCheckpointDiff(line)) {
456
- // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
457
- if (targetCheckpoint == null) {
458
- throw new Error('Checkpoint diff without previous checkpoint');
459
- }
460
- const diff = line.checkpoint_diff;
461
- const newBuckets = new Map();
462
- for (const checksum of targetCheckpoint.buckets) {
463
- newBuckets.set(checksum.bucket, checksum);
464
- }
465
- for (const checksum of diff.updated_buckets) {
466
- newBuckets.set(checksum.bucket, checksum);
467
- }
468
- for (const bucket of diff.removed_buckets) {
469
- newBuckets.delete(bucket);
470
- }
471
- const newCheckpoint = {
472
- last_op_id: diff.last_op_id,
473
- buckets: [...newBuckets.values()],
474
- write_checkpoint: diff.write_checkpoint
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
- else if (isStreamingSyncData(line)) {
491
- const { data } = line;
492
- const previousProgress = this.syncStatus.dataFlowStatus.downloadProgress;
493
- let updatedProgress = null;
494
- if (previousProgress) {
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
- else if (isStreamingKeepalive(line)) {
513
- const remaining_seconds = line.token_expires_in;
514
- if (remaining_seconds == 0) {
515
- // Connection would be closed automatically right after this
516
- this.logger.debug('Token expiring; reconnect');
517
- this.options.remote.invalidateCredentials();
518
- /**
519
- * For a rare case where the backend connector does not update the token
520
- * (uses the same one), this should have some delay.
521
- */
522
- await this.delayRetry();
523
- return;
524
- }
525
- else if (remaining_seconds < 30) {
526
- this.logger.debug('Token will expire soon; reconnect');
527
- // Pre-emptively refresh the token
528
- this.options.remote.invalidateCredentials();
529
- return;
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
- this.triggerCrudUpload();
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
- this.logger.debug('Sync complete');
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
- atLast: atLast,
575
- sinceLast: sinceLast,
576
- targetCount: bucket.count ?? 0
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.atLast = 0;
589
- bucketProgress.sinceLast = 0;
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) => setTimeout(resolve, this.options.retryDelayMs));
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
  }