@powersync/common 1.53.0 → 1.53.2

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.
@@ -3,7 +3,6 @@ import Logger, { ILogger } from 'js-logger';
3
3
  import { SyncStatus, SyncStatusOptions } from '../../../db/crud/SyncStatus.js';
4
4
  import { AbortOperation } from '../../../utils/AbortOperation.js';
5
5
  import { BaseListener, BaseObserver, BaseObserverInterface, Disposable } from '../../../utils/BaseObserver.js';
6
- import { throttleLeadingTrailing } from '../../../utils/async.js';
7
6
  import { BucketStorageAdapter, PowerSyncControlCommand } from '../bucket/BucketStorageAdapter.js';
8
7
  import { CrudEntry } from '../bucket/CrudEntry.js';
9
8
  import { AbstractRemote, FetchStrategy, SyncStreamOptions } from './AbstractRemote.js';
@@ -17,10 +16,10 @@ import {
17
16
  doneResult,
18
17
  injectable,
19
18
  InjectableIterator,
20
- map,
21
19
  SimpleAsyncIterator,
22
20
  valueResult
23
21
  } from '../../../utils/stream_transform.js';
22
+ import { asyncNotifier } from '../../../utils/async.js';
24
23
  import { StreamingSyncRequestParameterType } from './JsonValue.js';
25
24
 
26
25
  export enum LockType {
@@ -209,33 +208,23 @@ export type SubscribedStream = {
209
208
  params: Record<string, any> | null;
210
209
  };
211
210
 
212
- // The priority we assume when we receive checkpoint lines where no priority is set.
213
- // This is the default priority used by the sync service, but can be set to an arbitrary
214
- // value since sync services without priorities also won't send partial sync completion
215
- // messages.
216
- const FALLBACK_PRIORITY = 3;
217
-
218
211
  export abstract class AbstractStreamingSyncImplementation
219
212
  extends BaseObserver<StreamingSyncImplementationListener>
220
213
  implements StreamingSyncImplementation
221
214
  {
222
215
  protected options: AbstractStreamingSyncImplementationOptions;
223
216
  protected abortController: AbortController | null;
224
- // In rare cases, mostly for tests, uploads can be triggered without being properly connected.
225
- // This allows ensuring that all upload processes can be aborted.
226
- protected uploadAbortController: AbortController | undefined;
227
217
  protected crudUpdateListener?: () => void;
228
- protected streamingSyncPromise?: Promise<void>;
218
+ protected streamingSyncPromise?: Promise<[void, void]>;
229
219
  protected logger: ILogger;
230
220
  private activeStreams: SubscribedStream[];
231
221
  private connectionMayHaveChanged = false;
222
+ private crudUploadNotifier = asyncNotifier();
232
223
 
233
- private isUploadingCrud: boolean = false;
234
224
  private notifyCompletedUploads?: () => void;
235
225
  private handleActiveStreamsChange?: () => void;
236
226
 
237
227
  syncStatus: SyncStatus;
238
- triggerCrudUpload: () => void;
239
228
 
240
229
  constructor(options: AbstractStreamingSyncImplementationOptions) {
241
230
  super();
@@ -253,18 +242,10 @@ export abstract class AbstractStreamingSyncImplementation
253
242
  }
254
243
  });
255
244
  this.abortController = null;
245
+ }
256
246
 
257
- this.triggerCrudUpload = throttleLeadingTrailing(() => {
258
- if (!this.syncStatus.connected || this.isUploadingCrud) {
259
- return;
260
- }
261
-
262
- this.isUploadingCrud = true;
263
- this._uploadAllCrud().finally(() => {
264
- this.notifyCompletedUploads?.();
265
- this.isUploadingCrud = false;
266
- });
267
- }, this.options.crudUploadThrottleMs!);
247
+ triggerCrudUpload() {
248
+ this.crudUploadNotifier.notify();
268
249
  }
269
250
 
270
251
  async waitForReady() {}
@@ -320,7 +301,6 @@ export abstract class AbstractStreamingSyncImplementation
320
301
  super.dispose();
321
302
  this.crudUpdateListener?.();
322
303
  this.crudUpdateListener = undefined;
323
- this.uploadAbortController?.abort();
324
304
  }
325
305
 
326
306
  abstract obtainLock<T>(lockOptions: LockOptions<T>): Promise<T>;
@@ -334,7 +314,19 @@ export abstract class AbstractStreamingSyncImplementation
334
314
  return checkpoint;
335
315
  }
336
316
 
337
- protected async _uploadAllCrud(): Promise<void> {
317
+ private async crudUploadLoop(signal: AbortSignal): Promise<void> {
318
+ while (!signal.aborted) {
319
+ await Promise.all([
320
+ // Start the initial CRUD upload on connect. Then, keep polling until we're done.
321
+ this._uploadAllCrud(signal),
322
+ this.delayRetry(signal, this.options.crudUploadThrottleMs!)
323
+ ]);
324
+
325
+ await this.crudUploadNotifier.waitForNotification(signal);
326
+ }
327
+ }
328
+
329
+ private async _uploadAllCrud(signal: AbortSignal): Promise<void> {
338
330
  return this.obtainLock({
339
331
  type: LockType.CRUD,
340
332
  callback: async () => {
@@ -343,17 +335,7 @@ export abstract class AbstractStreamingSyncImplementation
343
335
  */
344
336
  let checkedCrudItem: CrudEntry | undefined;
345
337
 
346
- const controller = new AbortController();
347
- this.uploadAbortController = controller;
348
- this.abortController?.signal.addEventListener(
349
- 'abort',
350
- () => {
351
- controller.abort();
352
- },
353
- { once: true }
354
- );
355
-
356
- while (!controller.signal.aborted) {
338
+ while (!signal.aborted) {
357
339
  try {
358
340
  /**
359
341
  * This is the first item in the FIFO CRUD queue.
@@ -384,7 +366,9 @@ The next upload iteration will be delayed.`);
384
366
  } else {
385
367
  // Uploading is completed
386
368
  const neededUpdate = await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint());
387
- if (neededUpdate == false && checkedCrudItem != null) {
369
+ if (neededUpdate) {
370
+ this.notifyCompletedUploads?.();
371
+ } else if (checkedCrudItem != null) {
388
372
  // Only log this if there was something to upload
389
373
  this.logger.debug('Upload complete, no write checkpoint needed.');
390
374
  }
@@ -398,7 +382,7 @@ The next upload iteration will be delayed.`);
398
382
  uploadError: ex as Error
399
383
  }
400
384
  });
401
- await this.delayRetry(controller.signal);
385
+ await this.delayRetry(signal);
402
386
  if (!this.isConnected) {
403
387
  // Exit the upload loop if the sync stream is no longer connected
404
388
  break;
@@ -414,7 +398,6 @@ The next upload iteration will be delayed.`);
414
398
  });
415
399
  }
416
400
  }
417
- this.uploadAbortController = undefined;
418
401
  }
419
402
  });
420
403
  }
@@ -426,7 +409,10 @@ The next upload iteration will be delayed.`);
426
409
 
427
410
  const controller = new AbortController();
428
411
  this.abortController = controller;
429
- this.streamingSyncPromise = this.streamingSync(this.abortController.signal, options);
412
+ this.streamingSyncPromise = Promise.all([
413
+ this.crudUploadLoop(controller.signal).catch((ex) => this.logger.error('Error in crud upload loop', ex)),
414
+ this.streamingSync(controller.signal, options)
415
+ ]);
430
416
 
431
417
  // Return a promise that resolves when the connection status is updated to indicate that we're connected.
432
418
  return new Promise<void>((resolve) => {
@@ -469,15 +455,7 @@ The next upload iteration will be delayed.`);
469
455
  this.updateSyncStatus({ connected: false, connecting: false });
470
456
  }
471
457
 
472
- /**
473
- * @deprecated use [connect instead]
474
- */
475
- async streamingSync(signal?: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void> {
476
- if (!signal) {
477
- this.abortController = new AbortController();
478
- signal = this.abortController.signal;
479
- }
480
-
458
+ private async streamingSync(signal: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void> {
481
459
  /**
482
460
  * Listen for CRUD updates and trigger upstream uploads
483
461
  */
@@ -902,14 +880,13 @@ The next upload iteration will be delayed.`);
902
880
  this.iterateListeners((cb) => cb.statusUpdated?.(options));
903
881
  }
904
882
 
905
- private async delayRetry(signal?: AbortSignal): Promise<void> {
883
+ private async delayRetry(signal?: AbortSignal, delay = this.options.retryDelayMs): Promise<void> {
906
884
  return new Promise((resolve) => {
907
885
  if (signal?.aborted) {
908
886
  // If the signal is already aborted, resolve immediately
909
887
  resolve();
910
888
  return;
911
889
  }
912
- const { retryDelayMs } = this.options;
913
890
 
914
891
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
915
892
 
@@ -923,7 +900,7 @@ The next upload iteration will be delayed.`);
923
900
  };
924
901
 
925
902
  signal?.addEventListener('abort', endDelay, { once: true });
926
- timeoutId = setTimeout(endDelay, retryDelayMs);
903
+ timeoutId = setTimeout(endDelay, delay);
927
904
  });
928
905
  }
929
906
 
@@ -19,32 +19,59 @@ export function throttleTrailing(func: () => void, wait: number) {
19
19
  };
20
20
  }
21
21
 
22
- /**
23
- * Throttle a function to be called at most once every "wait" milliseconds,
24
- * on the leading and trailing edge.
25
- *
26
- * Roughly equivalent to lodash/throttle with {leading: true, trailing: true}
27
- */
28
- export function throttleLeadingTrailing(func: () => void, wait: number) {
29
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
30
- let lastCallTime: number = 0;
22
+ export interface AsyncNotifier {
23
+ /**
24
+ * @param signal Also resolve the promise once this signal completes.
25
+ * @returns A promise that resolves once {@link notify} is called after this promise was last resolved.
26
+ */
27
+ waitForNotification(signal: AbortSignal): Promise<void>;
31
28
 
32
- const invokeFunction = () => {
33
- func();
34
- lastCallTime = Date.now();
35
- timeoutId = null;
36
- };
29
+ /**
30
+ * Notifies a pending listener, or makes the next {@link waitForNotification} complete immediately if no listener
31
+ * is currently active.
32
+ */
33
+ notify(): void;
34
+ }
37
35
 
38
- return function () {
39
- const now = Date.now();
40
- const timeToWait = wait - (now - lastCallTime);
41
-
42
- if (timeToWait <= 0) {
43
- // Leading edge: Call the function immediately if enough time has passed
44
- invokeFunction();
45
- } else if (!timeoutId) {
46
- // Set a timeout for the trailing edge if not already set
47
- timeoutId = setTimeout(invokeFunction, timeToWait);
36
+ export function asyncNotifier(): AsyncNotifier {
37
+ let waitingConsumer: (() => void) | null = null;
38
+ let hasPendingNotification = false;
39
+
40
+ return {
41
+ notify() {
42
+ if (waitingConsumer != null) {
43
+ waitingConsumer();
44
+ waitingConsumer = null;
45
+ } else {
46
+ hasPendingNotification = true;
47
+ }
48
+ },
49
+ waitForNotification(signal: AbortSignal) {
50
+ return new Promise((resolve) => {
51
+ if (waitingConsumer != null) {
52
+ throw new Error('Illegal call to waitForNotification, already has a waiter.');
53
+ }
54
+
55
+ if (signal.aborted) {
56
+ resolve();
57
+ } else if (hasPendingNotification) {
58
+ resolve();
59
+ hasPendingNotification = false;
60
+ } else {
61
+ function complete() {
62
+ signal.removeEventListener('abort', onAbort);
63
+ resolve();
64
+ }
65
+
66
+ function onAbort() {
67
+ waitingConsumer = null;
68
+ resolve();
69
+ }
70
+
71
+ waitingConsumer = complete;
72
+ signal.addEventListener('abort', onAbort);
73
+ }
74
+ });
48
75
  }
49
76
  };
50
77
  }
@@ -50,6 +50,7 @@ export function injectable<T>(source: SimpleAsyncIterator<T>): InjectableIterato
50
50
  let waiter: Waiter | undefined = undefined; // An active, waiting next() call.
51
51
  // A pending upstream event that couldn't be dispatched because inject() has been called before it was resolved.
52
52
  let pendingSourceEvent: ((w: Waiter) => void) | null = null;
53
+ let sourceFetchInFlight = false;
53
54
 
54
55
  let pendingInjectedEvents: T[] = [];
55
56
 
@@ -61,6 +62,8 @@ export function injectable<T>(source: SimpleAsyncIterator<T>): InjectableIterato
61
62
 
62
63
  const fetchFromSource = () => {
63
64
  const resolveWaiter = (propagate: (w: Waiter) => void) => {
65
+ sourceFetchInFlight = false;
66
+
64
67
  const active = consumeWaiter();
65
68
  if (active) {
66
69
  propagate(active);
@@ -69,6 +72,7 @@ export function injectable<T>(source: SimpleAsyncIterator<T>): InjectableIterato
69
72
  }
70
73
  };
71
74
 
75
+ sourceFetchInFlight = true;
72
76
  const nextFromSource = source.next();
73
77
  nextFromSource.then(
74
78
  (value) => {
@@ -101,7 +105,9 @@ export function injectable<T>(source: SimpleAsyncIterator<T>): InjectableIterato
101
105
 
102
106
  // Nothing pending? Fetch from source
103
107
  waiter = { resolve, reject };
104
- return fetchFromSource();
108
+ if (!sourceFetchInFlight) {
109
+ fetchFromSource();
110
+ }
105
111
  });
106
112
  },
107
113
  inject: (event) => {