@powersync/common 0.0.0-dev-20250210155038 → 0.0.0-dev-20250416114737

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.
@@ -16,6 +16,21 @@ export type SyncStreamOptions = {
16
16
  abortSignal?: AbortSignal;
17
17
  fetchOptions?: Request;
18
18
  };
19
+ export declare enum FetchStrategy {
20
+ /**
21
+ * Queues multiple sync events before processing, reducing round-trips.
22
+ * This comes at the cost of more processing overhead, which may cause ACK timeouts on older/weaker devices for big enough datasets.
23
+ */
24
+ Buffered = "buffered",
25
+ /**
26
+ * Processes each sync event immediately before requesting the next.
27
+ * This reduces processing overhead and improves real-time responsiveness.
28
+ */
29
+ Sequential = "sequential"
30
+ }
31
+ export type SocketSyncStreamOptions = SyncStreamOptions & {
32
+ fetchStrategy: FetchStrategy;
33
+ };
19
34
  export type FetchImplementation = typeof fetch;
20
35
  /**
21
36
  * Class wrapper for providing a fetch implementation.
@@ -72,7 +87,7 @@ export declare abstract class AbstractRemote {
72
87
  /**
73
88
  * Connects to the sync/stream websocket endpoint
74
89
  */
75
- socketStream(options: SyncStreamOptions): Promise<DataStream<StreamingSyncLine>>;
90
+ socketStream(options: SocketSyncStreamOptions): Promise<DataStream<StreamingSyncLine>>;
76
91
  /**
77
92
  * Connects to the sync/stream http endpoint
78
93
  */
@@ -10,13 +10,25 @@ const POWERSYNC_TRAILING_SLASH_MATCH = /\/+$/;
10
10
  const POWERSYNC_JS_VERSION = PACKAGE.version;
11
11
  // Refresh at least 30 sec before it expires
12
12
  const REFRESH_CREDENTIALS_SAFETY_PERIOD_MS = 30_000;
13
- const SYNC_QUEUE_REQUEST_N = 10;
14
13
  const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
15
14
  // Keep alive message is sent every period
16
15
  const KEEP_ALIVE_MS = 20_000;
17
16
  // The ACK must be received in this period
18
17
  const KEEP_ALIVE_LIFETIME_MS = 30_000;
19
18
  export const DEFAULT_REMOTE_LOGGER = Logger.get('PowerSyncRemote');
19
+ export var FetchStrategy;
20
+ (function (FetchStrategy) {
21
+ /**
22
+ * Queues multiple sync events before processing, reducing round-trips.
23
+ * This comes at the cost of more processing overhead, which may cause ACK timeouts on older/weaker devices for big enough datasets.
24
+ */
25
+ FetchStrategy["Buffered"] = "buffered";
26
+ /**
27
+ * Processes each sync event immediately before requesting the next.
28
+ * This reduces processing overhead and improves real-time responsiveness.
29
+ */
30
+ FetchStrategy["Sequential"] = "sequential";
31
+ })(FetchStrategy || (FetchStrategy = {}));
20
32
  /**
21
33
  * Class wrapper for providing a fetch implementation.
22
34
  * The class wrapper is used to distinguish the fetchImplementation
@@ -145,7 +157,8 @@ export class AbstractRemote {
145
157
  * Connects to the sync/stream websocket endpoint
146
158
  */
147
159
  async socketStream(options) {
148
- const { path } = options;
160
+ const { path, fetchStrategy = FetchStrategy.Buffered } = options;
161
+ const syncQueueRequestSize = fetchStrategy == FetchStrategy.Buffered ? 10 : 1;
149
162
  const request = await this.buildRequest(path);
150
163
  const bson = await this.getBSON();
151
164
  // Add the user agent in the setup payload - we can't set custom
@@ -198,7 +211,7 @@ export class AbstractRemote {
198
211
  // Helps to prevent double close scenarios
199
212
  rsocket.onClose(() => (socketIsClosed = true));
200
213
  // We initially request this amount and expect these to arrive eventually
201
- let pendingEventsCount = SYNC_QUEUE_REQUEST_N;
214
+ let pendingEventsCount = syncQueueRequestSize;
202
215
  const disposeClosedListener = stream.registerListener({
203
216
  closed: () => {
204
217
  closeSocket();
@@ -212,7 +225,7 @@ export class AbstractRemote {
212
225
  metadata: Buffer.from(bson.serialize({
213
226
  path
214
227
  }))
215
- }, SYNC_QUEUE_REQUEST_N, // The initial N amount
228
+ }, syncQueueRequestSize, // The initial N amount
216
229
  {
217
230
  onError: (e) => {
218
231
  // Don't log closed as an error
@@ -251,10 +264,10 @@ export class AbstractRemote {
251
264
  const l = stream.registerListener({
252
265
  lowWater: async () => {
253
266
  // Request to fill up the queue
254
- const required = SYNC_QUEUE_REQUEST_N - pendingEventsCount;
267
+ const required = syncQueueRequestSize - pendingEventsCount;
255
268
  if (required > 0) {
256
- socket.request(SYNC_QUEUE_REQUEST_N - pendingEventsCount);
257
- pendingEventsCount = SYNC_QUEUE_REQUEST_N;
269
+ socket.request(syncQueueRequestSize - pendingEventsCount);
270
+ pendingEventsCount = syncQueueRequestSize;
258
271
  }
259
272
  },
260
273
  closed: () => {
@@ -2,7 +2,7 @@ import Logger, { ILogger } from 'js-logger';
2
2
  import { SyncStatus, SyncStatusOptions } from '../../../db/crud/SyncStatus.js';
3
3
  import { BaseListener, BaseObserver, Disposable } from '../../../utils/BaseObserver.js';
4
4
  import { BucketStorageAdapter } from '../bucket/BucketStorageAdapter.js';
5
- import { AbstractRemote } from './AbstractRemote.js';
5
+ import { AbstractRemote, FetchStrategy } from './AbstractRemote.js';
6
6
  import { StreamingSyncRequestParameterType } from './streaming-sync-types.js';
7
7
  export declare enum LockType {
8
8
  CRUD = "crud",
@@ -56,6 +56,10 @@ export interface BaseConnectionOptions {
56
56
  * Defaults to a HTTP streaming connection.
57
57
  */
58
58
  connectionMethod?: SyncStreamConnectionMethod;
59
+ /**
60
+ * The fetch strategy to use when streaming updates from the PowerSync backend instance.
61
+ */
62
+ fetchStrategy?: FetchStrategy;
59
63
  /**
60
64
  * These parameters are passed to the sync rules, and will be available under the`user_parameters` object.
61
65
  */
@@ -95,6 +99,7 @@ export interface StreamingSyncImplementation extends BaseObserver<StreamingSyncI
95
99
  triggerCrudUpload: () => void;
96
100
  waitForReady(): Promise<void>;
97
101
  waitForStatus(status: SyncStatusOptions): Promise<void>;
102
+ waitUntilStatusMatches(predicate: (status: SyncStatus) => boolean): Promise<void>;
98
103
  }
99
104
  export declare const DEFAULT_CRUD_UPLOAD_THROTTLE_MS = 1000;
100
105
  export declare const DEFAULT_RETRY_DELAY_MS = 5000;
@@ -111,11 +116,13 @@ export declare abstract class AbstractStreamingSyncImplementation extends BaseOb
111
116
  protected abortController: AbortController | null;
112
117
  protected crudUpdateListener?: () => void;
113
118
  protected streamingSyncPromise?: Promise<void>;
119
+ private pendingCrudUpload?;
114
120
  syncStatus: SyncStatus;
115
121
  triggerCrudUpload: () => void;
116
122
  constructor(options: AbstractStreamingSyncImplementationOptions);
117
123
  waitForReady(): Promise<void>;
118
124
  waitForStatus(status: SyncStatusOptions): Promise<void>;
125
+ waitUntilStatusMatches(predicate: (status: SyncStatus) => boolean): Promise<void>;
119
126
  get lastSyncedAt(): Date | undefined;
120
127
  get isConnected(): boolean;
121
128
  protected get logger(): Logger.ILogger;
@@ -130,9 +137,9 @@ export declare abstract class AbstractStreamingSyncImplementation extends BaseOb
130
137
  * @deprecated use [connect instead]
131
138
  */
132
139
  streamingSync(signal?: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void>;
133
- protected streamingSyncIteration(signal: AbortSignal, options?: PowerSyncConnectionOptions): Promise<{
134
- retry?: boolean;
135
- }>;
140
+ private collectLocalBucketState;
141
+ protected streamingSyncIteration(signal: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void>;
142
+ private applyCheckpoint;
136
143
  protected updateSyncStatus(options: SyncStatusOptions): void;
137
144
  private delayRetry;
138
145
  }
@@ -2,9 +2,10 @@ import Logger from 'js-logger';
2
2
  import { SyncStatus } from '../../../db/crud/SyncStatus.js';
3
3
  import { AbortOperation } from '../../../utils/AbortOperation.js';
4
4
  import { BaseObserver } from '../../../utils/BaseObserver.js';
5
- import { throttleLeadingTrailing } from '../../../utils/throttle.js';
5
+ import { onAbortPromise, throttleLeadingTrailing } from '../../../utils/async.js';
6
6
  import { SyncDataBucket } from '../bucket/SyncDataBucket.js';
7
- import { isStreamingKeepalive, isStreamingSyncCheckpoint, isStreamingSyncCheckpointComplete, isStreamingSyncCheckpointDiff, isStreamingSyncData } from './streaming-sync-types.js';
7
+ import { FetchStrategy } from './AbstractRemote.js';
8
+ import { isStreamingKeepalive, isStreamingSyncCheckpoint, isStreamingSyncCheckpointComplete, isStreamingSyncCheckpointDiff, isStreamingSyncCheckpointPartiallyComplete, isStreamingSyncData } from './streaming-sync-types.js';
8
9
  export var LockType;
9
10
  (function (LockType) {
10
11
  LockType["CRUD"] = "crud";
@@ -24,14 +25,21 @@ export const DEFAULT_STREAMING_SYNC_OPTIONS = {
24
25
  };
25
26
  export const DEFAULT_STREAM_CONNECTION_OPTIONS = {
26
27
  connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET,
28
+ fetchStrategy: FetchStrategy.Buffered,
27
29
  params: {}
28
30
  };
31
+ // The priority we assume when we receive checkpoint lines where no priority is set.
32
+ // This is the default priority used by the sync service, but can be set to an arbitrary
33
+ // value since sync services without priorities also won't send partial sync completion
34
+ // messages.
35
+ const FALLBACK_PRIORITY = 3;
29
36
  export class AbstractStreamingSyncImplementation extends BaseObserver {
30
37
  _lastSyncedAt;
31
38
  options;
32
39
  abortController;
33
40
  crudUpdateListener;
34
41
  streamingSyncPromise;
42
+ pendingCrudUpload;
35
43
  syncStatus;
36
44
  triggerCrudUpload;
37
45
  constructor(options) {
@@ -48,31 +56,45 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
48
56
  });
49
57
  this.abortController = null;
50
58
  this.triggerCrudUpload = throttleLeadingTrailing(() => {
51
- if (!this.syncStatus.connected || this.syncStatus.dataFlowStatus.uploading) {
59
+ if (!this.syncStatus.connected || this.pendingCrudUpload != null) {
52
60
  return;
53
61
  }
54
- this._uploadAllCrud();
62
+ this.pendingCrudUpload = new Promise((resolve) => {
63
+ this._uploadAllCrud().finally(() => {
64
+ this.pendingCrudUpload = undefined;
65
+ resolve();
66
+ });
67
+ });
55
68
  }, this.options.crudUploadThrottleMs);
56
69
  }
57
70
  async waitForReady() { }
58
71
  waitForStatus(status) {
72
+ return this.waitUntilStatusMatches((currentStatus) => {
73
+ /**
74
+ * Match only the partial status options provided in the
75
+ * matching status
76
+ */
77
+ const matchPartialObject = (compA, compB) => {
78
+ return Object.entries(compA).every(([key, value]) => {
79
+ const comparisonBValue = compB[key];
80
+ if (typeof value == 'object' && typeof comparisonBValue == 'object') {
81
+ return matchPartialObject(value, comparisonBValue);
82
+ }
83
+ return value == comparisonBValue;
84
+ });
85
+ };
86
+ return matchPartialObject(status, currentStatus);
87
+ });
88
+ }
89
+ waitUntilStatusMatches(predicate) {
59
90
  return new Promise((resolve) => {
91
+ if (predicate(this.syncStatus)) {
92
+ resolve();
93
+ return;
94
+ }
60
95
  const l = this.registerListener({
61
96
  statusChanged: (updatedStatus) => {
62
- /**
63
- * Match only the partial status options provided in the
64
- * matching status
65
- */
66
- const matchPartialObject = (compA, compB) => {
67
- return Object.entries(compA).every(([key, value]) => {
68
- const comparisonBValue = compB[key];
69
- if (typeof value == 'object' && typeof comparisonBValue == 'object') {
70
- return matchPartialObject(value, comparisonBValue);
71
- }
72
- return value == comparisonBValue;
73
- });
74
- };
75
- if (matchPartialObject(status, updatedStatus.toJSON())) {
97
+ if (predicate(updatedStatus)) {
76
98
  resolve();
77
99
  l?.();
78
100
  }
@@ -132,6 +154,11 @@ The next upload iteration will be delayed.`);
132
154
  }
133
155
  checkedCrudItem = nextCrudItem;
134
156
  await this.options.uploadCrud();
157
+ this.updateSyncStatus({
158
+ dataFlow: {
159
+ uploadError: undefined
160
+ }
161
+ });
135
162
  }
136
163
  else {
137
164
  // Uploading is completed
@@ -143,7 +170,8 @@ The next upload iteration will be delayed.`);
143
170
  checkedCrudItem = undefined;
144
171
  this.updateSyncStatus({
145
172
  dataFlow: {
146
- uploading: false
173
+ uploading: false,
174
+ uploadError: ex
147
175
  }
148
176
  });
149
177
  await this.delayRetry();
@@ -258,16 +286,8 @@ The next upload iteration will be delayed.`);
258
286
  if (signal?.aborted) {
259
287
  break;
260
288
  }
261
- const { retry } = await this.streamingSyncIteration(nestedAbortController.signal, options);
262
- if (!retry) {
263
- /**
264
- * A sync error ocurred that we cannot recover from here.
265
- * This loop must terminate.
266
- * The nestedAbortController will close any open network requests and streams below.
267
- */
268
- break;
269
- }
270
- // Continue immediately
289
+ await this.streamingSyncIteration(nestedAbortController.signal, options);
290
+ // Continue immediately, streamingSyncIteration will wait before completing if necessary.
271
291
  }
272
292
  catch (ex) {
273
293
  /**
@@ -284,6 +304,11 @@ The next upload iteration will be delayed.`);
284
304
  else {
285
305
  this.logger.error(ex);
286
306
  }
307
+ this.updateSyncStatus({
308
+ dataFlow: {
309
+ downloadError: ex
310
+ }
311
+ });
287
312
  // On error, wait a little before retrying
288
313
  await this.delayRetry();
289
314
  }
@@ -301,8 +326,20 @@ The next upload iteration will be delayed.`);
301
326
  // Mark as disconnected if here
302
327
  this.updateSyncStatus({ connected: false, connecting: false });
303
328
  }
329
+ async collectLocalBucketState() {
330
+ const bucketEntries = await this.options.adapter.getBucketStates();
331
+ const req = bucketEntries.map((entry) => ({
332
+ name: entry.bucket,
333
+ after: entry.op_id
334
+ }));
335
+ const localDescriptions = new Map();
336
+ for (const entry of bucketEntries) {
337
+ localDescriptions.set(entry.bucket, null);
338
+ }
339
+ return [req, localDescriptions];
340
+ }
304
341
  async streamingSyncIteration(signal, options) {
305
- return await this.obtainLock({
342
+ await this.obtainLock({
306
343
  type: LockType.SYNC,
307
344
  signal,
308
345
  callback: async () => {
@@ -312,20 +349,11 @@ The next upload iteration will be delayed.`);
312
349
  };
313
350
  this.logger.debug('Streaming sync iteration started');
314
351
  this.options.adapter.startSession();
315
- const bucketEntries = await this.options.adapter.getBucketStates();
316
- const initialBuckets = new Map();
317
- bucketEntries.forEach((entry) => {
318
- initialBuckets.set(entry.bucket, entry.op_id);
319
- });
320
- const req = Array.from(initialBuckets.entries()).map(([bucket, after]) => ({
321
- name: bucket,
322
- after: after
323
- }));
352
+ let [req, bucketMap] = await this.collectLocalBucketState();
324
353
  // These are compared by reference
325
354
  let targetCheckpoint = null;
326
355
  let validatedCheckpoint = null;
327
356
  let appliedCheckpoint = null;
328
- let bucketSet = new Set(initialBuckets.keys());
329
357
  const clientId = await this.options.adapter.getClientId();
330
358
  this.logger.debug('Requesting stream from server');
331
359
  const syncOptions = {
@@ -339,15 +367,22 @@ The next upload iteration will be delayed.`);
339
367
  client_id: clientId
340
368
  }
341
369
  };
342
- const stream = resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP
343
- ? await this.options.remote.postStream(syncOptions)
344
- : await this.options.remote.socketStream(syncOptions);
370
+ let stream;
371
+ if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
372
+ stream = await this.options.remote.postStream(syncOptions);
373
+ }
374
+ else {
375
+ stream = await this.options.remote.socketStream({
376
+ ...syncOptions,
377
+ ...{ fetchStrategy: resolvedOptions.fetchStrategy }
378
+ });
379
+ }
345
380
  this.logger.debug('Stream established. Processing events');
346
381
  while (!stream.closed) {
347
382
  const line = await stream.read();
348
383
  if (!line) {
349
384
  // The stream has closed while waiting
350
- return { retry: true };
385
+ return;
351
386
  }
352
387
  // A connection is active and messages are being received
353
388
  if (!this.syncStatus.connected) {
@@ -359,45 +394,61 @@ The next upload iteration will be delayed.`);
359
394
  }
360
395
  if (isStreamingSyncCheckpoint(line)) {
361
396
  targetCheckpoint = line.checkpoint;
362
- const bucketsToDelete = new Set(bucketSet);
363
- const newBuckets = new Set();
397
+ const bucketsToDelete = new Set(bucketMap.keys());
398
+ const newBuckets = new Map();
364
399
  for (const checksum of line.checkpoint.buckets) {
365
- newBuckets.add(checksum.bucket);
400
+ newBuckets.set(checksum.bucket, {
401
+ name: checksum.bucket,
402
+ priority: checksum.priority ?? FALLBACK_PRIORITY
403
+ });
366
404
  bucketsToDelete.delete(checksum.bucket);
367
405
  }
368
406
  if (bucketsToDelete.size > 0) {
369
407
  this.logger.debug('Removing buckets', [...bucketsToDelete]);
370
408
  }
371
- bucketSet = newBuckets;
409
+ bucketMap = newBuckets;
372
410
  await this.options.adapter.removeBuckets([...bucketsToDelete]);
373
411
  await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
374
412
  }
375
413
  else if (isStreamingSyncCheckpointComplete(line)) {
376
- this.logger.debug('Checkpoint complete', targetCheckpoint);
377
- const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint);
414
+ const result = await this.applyCheckpoint(targetCheckpoint, signal);
415
+ if (result.endIteration) {
416
+ return;
417
+ }
418
+ else if (result.applied) {
419
+ appliedCheckpoint = targetCheckpoint;
420
+ }
421
+ validatedCheckpoint = targetCheckpoint;
422
+ }
423
+ else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
424
+ const priority = line.partial_checkpoint_complete.priority;
425
+ this.logger.debug('Partial checkpoint complete', priority);
426
+ const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
378
427
  if (!result.checkpointValid) {
379
428
  // This means checksums failed. Start again with a new checkpoint.
380
429
  // TODO: better back-off
381
430
  await new Promise((resolve) => setTimeout(resolve, 50));
382
- return { retry: true };
431
+ return;
383
432
  }
384
433
  else if (!result.ready) {
385
- // Checksums valid, but need more data for a consistent checkpoint.
386
- // Continue waiting.
387
- // landing here the whole time
434
+ // If we have pending uploads, we can't complete new checkpoints outside of priority 0.
435
+ // We'll resolve this for a complete checkpoint.
388
436
  }
389
437
  else {
390
- appliedCheckpoint = targetCheckpoint;
391
- this.logger.debug('validated checkpoint', appliedCheckpoint);
438
+ // We'll keep on downloading, but can report that this priority is synced now.
439
+ this.logger.debug('partial checkpoint validation succeeded');
440
+ // All states with a higher priority can be deleted since this partial sync includes them.
441
+ const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
442
+ priorityStates.push({
443
+ priority,
444
+ lastSyncedAt: new Date(),
445
+ hasSynced: true
446
+ });
392
447
  this.updateSyncStatus({
393
448
  connected: true,
394
- lastSyncedAt: new Date(),
395
- dataFlow: {
396
- downloading: false
397
- }
449
+ priorityStatusEntries: priorityStates
398
450
  });
399
451
  }
400
- validatedCheckpoint = targetCheckpoint;
401
452
  }
402
453
  else if (isStreamingSyncCheckpointDiff(line)) {
403
454
  // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
@@ -421,7 +472,11 @@ The next upload iteration will be delayed.`);
421
472
  write_checkpoint: diff.write_checkpoint
422
473
  };
423
474
  targetCheckpoint = newCheckpoint;
424
- bucketSet = new Set(newBuckets.keys());
475
+ bucketMap = new Map();
476
+ newBuckets.forEach((checksum, name) => bucketMap.set(name, {
477
+ name: checksum.bucket,
478
+ priority: checksum.priority ?? FALLBACK_PRIORITY
479
+ }));
425
480
  const bucketsToDelete = diff.removed_buckets;
426
481
  if (bucketsToDelete.length > 0) {
427
482
  this.logger.debug('Remove buckets', bucketsToDelete);
@@ -448,7 +503,7 @@ The next upload iteration will be delayed.`);
448
503
  * (uses the same one), this should have some delay.
449
504
  */
450
505
  await this.delayRetry();
451
- return { retry: true };
506
+ return;
452
507
  }
453
508
  this.triggerCrudUpload();
454
509
  }
@@ -457,40 +512,69 @@ The next upload iteration will be delayed.`);
457
512
  if (targetCheckpoint === appliedCheckpoint) {
458
513
  this.updateSyncStatus({
459
514
  connected: true,
460
- lastSyncedAt: new Date()
515
+ lastSyncedAt: new Date(),
516
+ priorityStatusEntries: [],
517
+ dataFlow: {
518
+ downloadError: undefined
519
+ }
461
520
  });
462
521
  }
463
522
  else if (validatedCheckpoint === targetCheckpoint) {
464
- const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint);
465
- if (!result.checkpointValid) {
466
- // This means checksums failed. Start again with a new checkpoint.
467
- // TODO: better back-off
468
- await new Promise((resolve) => setTimeout(resolve, 50));
469
- return { retry: false };
523
+ const result = await this.applyCheckpoint(targetCheckpoint, signal);
524
+ if (result.endIteration) {
525
+ return;
470
526
  }
471
- else if (!result.ready) {
472
- // Checksums valid, but need more data for a consistent checkpoint.
473
- // Continue waiting.
474
- }
475
- else {
527
+ else if (result.applied) {
476
528
  appliedCheckpoint = targetCheckpoint;
477
- this.updateSyncStatus({
478
- connected: true,
479
- lastSyncedAt: new Date(),
480
- dataFlow: {
481
- downloading: false
482
- }
483
- });
484
529
  }
485
530
  }
486
531
  }
487
532
  }
488
533
  this.logger.debug('Stream input empty');
489
534
  // Connection closed. Likely due to auth issue.
490
- return { retry: true };
535
+ return;
491
536
  }
492
537
  });
493
538
  }
539
+ async applyCheckpoint(checkpoint, abort) {
540
+ let result = await this.options.adapter.syncLocalDatabase(checkpoint);
541
+ const pending = this.pendingCrudUpload;
542
+ if (!result.checkpointValid) {
543
+ this.logger.debug('Checksum mismatch in checkpoint, will reconnect');
544
+ // This means checksums failed. Start again with a new checkpoint.
545
+ // TODO: better back-off
546
+ await new Promise((resolve) => setTimeout(resolve, 50));
547
+ return { applied: false, endIteration: true };
548
+ }
549
+ else if (!result.ready && pending != null) {
550
+ // We have pending entries in the local upload queue or are waiting to confirm a write
551
+ // checkpoint, which prevented this checkpoint from applying. Wait for that to complete and
552
+ // try again.
553
+ this.logger.debug('Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying.');
554
+ await Promise.race([pending, onAbortPromise(abort)]);
555
+ if (abort.aborted) {
556
+ return { applied: false, endIteration: true };
557
+ }
558
+ // Try again now that uploads have completed.
559
+ result = await this.options.adapter.syncLocalDatabase(checkpoint);
560
+ }
561
+ if (result.checkpointValid && result.ready) {
562
+ this.logger.debug('validated checkpoint', checkpoint);
563
+ this.updateSyncStatus({
564
+ connected: true,
565
+ lastSyncedAt: new Date(),
566
+ dataFlow: {
567
+ downloading: false,
568
+ downloadError: undefined
569
+ }
570
+ });
571
+ return { applied: true, endIteration: false };
572
+ }
573
+ else {
574
+ this.logger.debug('Could not apply checkpoint. Waiting for next sync complete line.');
575
+ return { applied: false, endIteration: false };
576
+ }
577
+ }
494
578
  updateSyncStatus(options) {
495
579
  const updatedStatus = new SyncStatus({
496
580
  connected: options.connected ?? this.syncStatus.connected,
@@ -499,7 +583,8 @@ The next upload iteration will be delayed.`);
499
583
  dataFlow: {
500
584
  ...this.syncStatus.dataFlowStatus,
501
585
  ...options.dataFlow
502
- }
586
+ },
587
+ priorityStatusEntries: options.priorityStatusEntries ?? this.syncStatus.priorityStatusEntries
503
588
  });
504
589
  if (!this.syncStatus.isEqual(updatedStatus)) {
505
590
  this.syncStatus = updatedStatus;
@@ -90,11 +90,17 @@ export interface StreamingSyncCheckpointComplete {
90
90
  last_op_id: OpId;
91
91
  };
92
92
  }
93
+ export interface StreamingSyncCheckpointPartiallyComplete {
94
+ partial_checkpoint_complete: {
95
+ priority: number;
96
+ last_op_id: OpId;
97
+ };
98
+ }
93
99
  export interface StreamingSyncKeepalive {
94
100
  /** If specified, token expires in this many seconds. */
95
101
  token_expires_in: number;
96
102
  }
97
- export type StreamingSyncLine = StreamingSyncDataJSON | StreamingSyncCheckpoint | StreamingSyncCheckpointDiff | StreamingSyncCheckpointComplete | StreamingSyncKeepalive;
103
+ export type StreamingSyncLine = StreamingSyncDataJSON | StreamingSyncCheckpoint | StreamingSyncCheckpointDiff | StreamingSyncCheckpointComplete | StreamingSyncCheckpointPartiallyComplete | StreamingSyncKeepalive;
98
104
  export interface BucketRequest {
99
105
  name: string;
100
106
  /**
@@ -106,6 +112,7 @@ export declare function isStreamingSyncData(line: StreamingSyncLine): line is St
106
112
  export declare function isStreamingKeepalive(line: StreamingSyncLine): line is StreamingSyncKeepalive;
107
113
  export declare function isStreamingSyncCheckpoint(line: StreamingSyncLine): line is StreamingSyncCheckpoint;
108
114
  export declare function isStreamingSyncCheckpointComplete(line: StreamingSyncLine): line is StreamingSyncCheckpointComplete;
115
+ export declare function isStreamingSyncCheckpointPartiallyComplete(line: StreamingSyncLine): line is StreamingSyncCheckpointPartiallyComplete;
109
116
  export declare function isStreamingSyncCheckpointDiff(line: StreamingSyncLine): line is StreamingSyncCheckpointDiff;
110
117
  export declare function isContinueCheckpointRequest(request: SyncRequest): request is ContinueCheckpointRequest;
111
118
  export declare function isSyncNewCheckpointRequest(request: SyncRequest): request is SyncNewCheckpointRequest;
@@ -10,6 +10,9 @@ export function isStreamingSyncCheckpoint(line) {
10
10
  export function isStreamingSyncCheckpointComplete(line) {
11
11
  return line.checkpoint_complete != null;
12
12
  }
13
+ export function isStreamingSyncCheckpointPartiallyComplete(line) {
14
+ return line.partial_checkpoint_complete != null;
15
+ }
13
16
  export function isStreamingSyncCheckpointDiff(line) {
14
17
  return line.checkpoint_diff != null;
15
18
  }
@@ -39,6 +39,21 @@ export interface DBGetUtils {
39
39
  export interface LockContext extends DBGetUtils {
40
40
  /** Execute a single write statement. */
41
41
  execute: (query: string, params?: any[] | undefined) => Promise<QueryResult>;
42
+ /**
43
+ * Execute a single write statement and return raw results.
44
+ * Unlike `execute`, which returns an object with structured key-value pairs,
45
+ * `executeRaw` returns a nested array of raw values, where each row is
46
+ * represented as an array of column values without field names.
47
+ *
48
+ * Example result:
49
+ *
50
+ * ```[ [ '1', 'list 1', '33', 'Post content', '1' ] ]```
51
+ *
52
+ * Where as `execute`'s `rows._array` would have been:
53
+ *
54
+ * ```[ { id: '33', name: 'list 1', content: 'Post content', list_id: '1' } ]```
55
+ */
56
+ executeRaw: (query: string, params?: any[] | undefined) => Promise<any[][]>;
42
57
  }
43
58
  export interface Transaction extends LockContext {
44
59
  /** Commit multiple changes to the local DB using the Transaction context. */
@@ -82,8 +97,9 @@ export interface DBLockOptions {
82
97
  timeoutMs?: number;
83
98
  }
84
99
  export interface DBAdapter extends BaseObserverInterface<DBAdapterListener>, DBGetUtils {
85
- close: () => void;
100
+ close: () => void | Promise<void>;
86
101
  execute: (query: string, params?: any[]) => Promise<QueryResult>;
102
+ executeRaw: (query: string, params?: any[]) => Promise<any[][]>;
87
103
  executeBatch: (query: string, params?: any[][]) => Promise<QueryResult>;
88
104
  name: string;
89
105
  readLock: <T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions) => Promise<T>;