@powersync/common 0.0.0-dev-20250207081035 → 0.0.0-dev-20250319141441

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.
@@ -4,7 +4,8 @@ import { AbortOperation } from '../../../utils/AbortOperation.js';
4
4
  import { BaseObserver } from '../../../utils/BaseObserver.js';
5
5
  import { throttleLeadingTrailing } from '../../../utils/throttle.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,8 +25,14 @@ 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;
@@ -56,23 +63,32 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
56
63
  }
57
64
  async waitForReady() { }
58
65
  waitForStatus(status) {
66
+ return this.waitUntilStatusMatches((currentStatus) => {
67
+ /**
68
+ * Match only the partial status options provided in the
69
+ * matching status
70
+ */
71
+ const matchPartialObject = (compA, compB) => {
72
+ return Object.entries(compA).every(([key, value]) => {
73
+ const comparisonBValue = compB[key];
74
+ if (typeof value == 'object' && typeof comparisonBValue == 'object') {
75
+ return matchPartialObject(value, comparisonBValue);
76
+ }
77
+ return value == comparisonBValue;
78
+ });
79
+ };
80
+ return matchPartialObject(status, currentStatus);
81
+ });
82
+ }
83
+ waitUntilStatusMatches(predicate) {
59
84
  return new Promise((resolve) => {
85
+ if (predicate(this.syncStatus)) {
86
+ resolve();
87
+ return;
88
+ }
60
89
  const l = this.registerListener({
61
90
  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())) {
91
+ if (predicate(updatedStatus)) {
76
92
  resolve();
77
93
  l?.();
78
94
  }
@@ -301,6 +317,18 @@ The next upload iteration will be delayed.`);
301
317
  // Mark as disconnected if here
302
318
  this.updateSyncStatus({ connected: false, connecting: false });
303
319
  }
320
+ async collectLocalBucketState() {
321
+ const bucketEntries = await this.options.adapter.getBucketStates();
322
+ const req = bucketEntries.map((entry) => ({
323
+ name: entry.bucket,
324
+ after: entry.op_id
325
+ }));
326
+ const localDescriptions = new Map();
327
+ for (const entry of bucketEntries) {
328
+ localDescriptions.set(entry.bucket, null);
329
+ }
330
+ return [req, localDescriptions];
331
+ }
304
332
  async streamingSyncIteration(signal, options) {
305
333
  return await this.obtainLock({
306
334
  type: LockType.SYNC,
@@ -312,20 +340,11 @@ The next upload iteration will be delayed.`);
312
340
  };
313
341
  this.logger.debug('Streaming sync iteration started');
314
342
  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
- }));
343
+ let [req, bucketMap] = await this.collectLocalBucketState();
324
344
  // These are compared by reference
325
345
  let targetCheckpoint = null;
326
346
  let validatedCheckpoint = null;
327
347
  let appliedCheckpoint = null;
328
- let bucketSet = new Set(initialBuckets.keys());
329
348
  const clientId = await this.options.adapter.getClientId();
330
349
  this.logger.debug('Requesting stream from server');
331
350
  const syncOptions = {
@@ -339,9 +358,16 @@ The next upload iteration will be delayed.`);
339
358
  client_id: clientId
340
359
  }
341
360
  };
342
- const stream = resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP
343
- ? await this.options.remote.postStream(syncOptions)
344
- : await this.options.remote.socketStream(syncOptions);
361
+ let stream;
362
+ if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
363
+ stream = await this.options.remote.postStream(syncOptions);
364
+ }
365
+ else {
366
+ stream = await this.options.remote.socketStream({
367
+ ...syncOptions,
368
+ ...{ fetchStrategy: resolvedOptions.fetchStrategy }
369
+ });
370
+ }
345
371
  this.logger.debug('Stream established. Processing events');
346
372
  while (!stream.closed) {
347
373
  const line = await stream.read();
@@ -359,16 +385,19 @@ The next upload iteration will be delayed.`);
359
385
  }
360
386
  if (isStreamingSyncCheckpoint(line)) {
361
387
  targetCheckpoint = line.checkpoint;
362
- const bucketsToDelete = new Set(bucketSet);
363
- const newBuckets = new Set();
388
+ const bucketsToDelete = new Set(bucketMap.keys());
389
+ const newBuckets = new Map();
364
390
  for (const checksum of line.checkpoint.buckets) {
365
- newBuckets.add(checksum.bucket);
391
+ newBuckets.set(checksum.bucket, {
392
+ name: checksum.bucket,
393
+ priority: checksum.priority ?? FALLBACK_PRIORITY
394
+ });
366
395
  bucketsToDelete.delete(checksum.bucket);
367
396
  }
368
397
  if (bucketsToDelete.size > 0) {
369
398
  this.logger.debug('Removing buckets', [...bucketsToDelete]);
370
399
  }
371
- bucketSet = newBuckets;
400
+ bucketMap = newBuckets;
372
401
  await this.options.adapter.removeBuckets([...bucketsToDelete]);
373
402
  await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
374
403
  }
@@ -399,6 +428,35 @@ The next upload iteration will be delayed.`);
399
428
  }
400
429
  validatedCheckpoint = targetCheckpoint;
401
430
  }
431
+ else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
432
+ const priority = line.partial_checkpoint_complete.priority;
433
+ this.logger.debug('Partial checkpoint complete', priority);
434
+ const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
435
+ if (!result.checkpointValid) {
436
+ // This means checksums failed. Start again with a new checkpoint.
437
+ // TODO: better back-off
438
+ await new Promise((resolve) => setTimeout(resolve, 50));
439
+ return { retry: true };
440
+ }
441
+ else if (!result.ready) {
442
+ // Need more data for a consistent partial sync within a priority - continue waiting.
443
+ }
444
+ else {
445
+ // We'll keep on downloading, but can report that this priority is synced now.
446
+ this.logger.debug('partial checkpoint validation succeeded');
447
+ // All states with a higher priority can be deleted since this partial sync includes them.
448
+ const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
449
+ priorityStates.push({
450
+ priority,
451
+ lastSyncedAt: new Date(),
452
+ hasSynced: true
453
+ });
454
+ this.updateSyncStatus({
455
+ connected: true,
456
+ priorityStatusEntries: priorityStates
457
+ });
458
+ }
459
+ }
402
460
  else if (isStreamingSyncCheckpointDiff(line)) {
403
461
  // TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
404
462
  if (targetCheckpoint == null) {
@@ -421,7 +479,11 @@ The next upload iteration will be delayed.`);
421
479
  write_checkpoint: diff.write_checkpoint
422
480
  };
423
481
  targetCheckpoint = newCheckpoint;
424
- bucketSet = new Set(newBuckets.keys());
482
+ bucketMap = new Map();
483
+ newBuckets.forEach((checksum, name) => bucketMap.set(name, {
484
+ name: checksum.bucket,
485
+ priority: checksum.priority ?? FALLBACK_PRIORITY
486
+ }));
425
487
  const bucketsToDelete = diff.removed_buckets;
426
488
  if (bucketsToDelete.length > 0) {
427
489
  this.logger.debug('Remove buckets', bucketsToDelete);
@@ -457,7 +519,8 @@ The next upload iteration will be delayed.`);
457
519
  if (targetCheckpoint === appliedCheckpoint) {
458
520
  this.updateSyncStatus({
459
521
  connected: true,
460
- lastSyncedAt: new Date()
522
+ lastSyncedAt: new Date(),
523
+ priorityStatusEntries: []
461
524
  });
462
525
  }
463
526
  else if (validatedCheckpoint === targetCheckpoint) {
@@ -477,6 +540,7 @@ The next upload iteration will be delayed.`);
477
540
  this.updateSyncStatus({
478
541
  connected: true,
479
542
  lastSyncedAt: new Date(),
543
+ priorityStatusEntries: [],
480
544
  dataFlow: {
481
545
  downloading: false
482
546
  }
@@ -499,7 +563,8 @@ The next upload iteration will be delayed.`);
499
563
  dataFlow: {
500
564
  ...this.syncStatus.dataFlowStatus,
501
565
  ...options.dataFlow
502
- }
566
+ },
567
+ priorityStatusEntries: options.priorityStatusEntries ?? this.syncStatus.priorityStatusEntries
503
568
  });
504
569
  if (!this.syncStatus.isEqual(updatedStatus)) {
505
570
  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,8 @@ 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
+ /** Execute a single write statement and return raw results. */
43
+ executeRaw: (query: string, params?: any[] | undefined) => Promise<any[][]>;
42
44
  }
43
45
  export interface Transaction extends LockContext {
44
46
  /** Commit multiple changes to the local DB using the Transaction context. */
@@ -82,8 +84,9 @@ export interface DBLockOptions {
82
84
  timeoutMs?: number;
83
85
  }
84
86
  export interface DBAdapter extends BaseObserverInterface<DBAdapterListener>, DBGetUtils {
85
- close: () => void;
87
+ close: () => void | Promise<void>;
86
88
  execute: (query: string, params?: any[]) => Promise<QueryResult>;
89
+ executeRaw: (query: string, params?: any[]) => Promise<any[][]>;
87
90
  executeBatch: (query: string, params?: any[][]) => Promise<QueryResult>;
88
91
  name: string;
89
92
  readLock: <T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions) => Promise<T>;
@@ -2,12 +2,18 @@ export type SyncDataFlowStatus = Partial<{
2
2
  downloading: boolean;
3
3
  uploading: boolean;
4
4
  }>;
5
+ export interface SyncPriorityStatus {
6
+ priority: number;
7
+ lastSyncedAt?: Date;
8
+ hasSynced?: boolean;
9
+ }
5
10
  export type SyncStatusOptions = {
6
11
  connected?: boolean;
7
12
  connecting?: boolean;
8
13
  dataFlow?: SyncDataFlowStatus;
9
14
  lastSyncedAt?: Date;
10
15
  hasSynced?: boolean;
16
+ priorityStatusEntries?: SyncPriorityStatus[];
11
17
  };
12
18
  export declare class SyncStatus {
13
19
  protected options: SyncStatusOptions;
@@ -34,7 +40,28 @@ export declare class SyncStatus {
34
40
  downloading: boolean;
35
41
  uploading: boolean;
36
42
  }>;
43
+ /**
44
+ * Partial sync status for involved bucket priorities.
45
+ */
46
+ get priorityStatusEntries(): SyncPriorityStatus[];
47
+ /**
48
+ * Reports a pair of {@link SyncStatus#hasSynced} and {@link SyncStatus#lastSyncedAt} fields that apply
49
+ * to a specific bucket priority instead of the entire sync operation.
50
+ *
51
+ * When buckets with different priorities are declared, PowerSync may choose to synchronize higher-priority
52
+ * buckets first. When a consistent view over all buckets for all priorities up until the given priority is
53
+ * reached, PowerSync makes data from those buckets available before lower-priority buckets have finished
54
+ * synchronizing.
55
+ * When PowerSync makes data for a given priority available, all buckets in higher-priorities are guaranteed to
56
+ * be consistent with that checkpoint. For this reason, this method may also return the status for lower priorities.
57
+ * In a state where the PowerSync just finished synchronizing buckets in priority level 3, calling this method
58
+ * with a priority of 1 may return information for priority level 3.
59
+ *
60
+ * @param priority The bucket priority for which the status should be reported.
61
+ */
62
+ statusForPriority(priority: number): SyncPriorityStatus;
37
63
  isEqual(status: SyncStatus): boolean;
38
64
  getMessage(): string;
39
65
  toJSON(): SyncStatusOptions;
66
+ private static comparePriorities;
40
67
  }
@@ -42,6 +42,42 @@ export class SyncStatus {
42
42
  uploading: false
43
43
  });
44
44
  }
45
+ /**
46
+ * Partial sync status for involved bucket priorities.
47
+ */
48
+ get priorityStatusEntries() {
49
+ return (this.options.priorityStatusEntries ?? []).slice().sort(SyncStatus.comparePriorities);
50
+ }
51
+ /**
52
+ * Reports a pair of {@link SyncStatus#hasSynced} and {@link SyncStatus#lastSyncedAt} fields that apply
53
+ * to a specific bucket priority instead of the entire sync operation.
54
+ *
55
+ * When buckets with different priorities are declared, PowerSync may choose to synchronize higher-priority
56
+ * buckets first. When a consistent view over all buckets for all priorities up until the given priority is
57
+ * reached, PowerSync makes data from those buckets available before lower-priority buckets have finished
58
+ * synchronizing.
59
+ * When PowerSync makes data for a given priority available, all buckets in higher-priorities are guaranteed to
60
+ * be consistent with that checkpoint. For this reason, this method may also return the status for lower priorities.
61
+ * In a state where the PowerSync just finished synchronizing buckets in priority level 3, calling this method
62
+ * with a priority of 1 may return information for priority level 3.
63
+ *
64
+ * @param priority The bucket priority for which the status should be reported.
65
+ */
66
+ statusForPriority(priority) {
67
+ // priorityStatusEntries are sorted by ascending priorities (so higher numbers to lower numbers).
68
+ for (const known of this.priorityStatusEntries) {
69
+ // We look for the first entry that doesn't have a higher priority.
70
+ if (known.priority >= priority) {
71
+ return known;
72
+ }
73
+ }
74
+ // If we have a complete sync, that necessarily includes all priorities.
75
+ return {
76
+ priority,
77
+ lastSyncedAt: this.lastSyncedAt,
78
+ hasSynced: this.hasSynced
79
+ };
80
+ }
45
81
  isEqual(status) {
46
82
  return JSON.stringify(this.options) == JSON.stringify(status.options);
47
83
  }
@@ -55,7 +91,11 @@ export class SyncStatus {
55
91
  connecting: this.connecting,
56
92
  dataFlow: this.dataFlowStatus,
57
93
  lastSyncedAt: this.lastSyncedAt,
58
- hasSynced: this.hasSynced
94
+ hasSynced: this.hasSynced,
95
+ priorityStatusEntries: this.priorityStatusEntries
59
96
  };
60
97
  }
98
+ static comparePriorities(a, b) {
99
+ return b.priority - a.priority; // Reverse because higher priorities have lower numbers
100
+ }
61
101
  }
@@ -63,9 +63,6 @@ export class DataStream extends BaseObserver {
63
63
  * @returns a Data payload or Null if the stream closed.
64
64
  */
65
65
  async read() {
66
- if (this.dataQueue.length <= this.lowWatermark) {
67
- await this.iterateAsyncErrored(async (l) => l.lowWater?.());
68
- }
69
66
  if (this.closed) {
70
67
  return null;
71
68
  }
@@ -133,12 +130,14 @@ export class DataStream extends BaseObserver {
133
130
  return Array.from(this.listeners.values()).some((l) => !!l.data);
134
131
  }
135
132
  async _processQueue() {
136
- if (!this.dataQueue.length || this.isClosed || !this.hasDataReader()) {
133
+ if (this.isClosed || !this.hasDataReader()) {
137
134
  Promise.resolve().then(() => (this.processingPromise = null));
138
135
  return;
139
136
  }
140
- const data = this.dataQueue.shift();
141
- await this.iterateAsyncErrored(async (l) => l.data?.(data));
137
+ if (this.dataQueue.length) {
138
+ const data = this.dataQueue.shift();
139
+ await this.iterateAsyncErrored(async (l) => l.data?.(data));
140
+ }
142
141
  if (this.dataQueue.length <= this.lowWatermark) {
143
142
  await this.iterateAsyncErrored(async (l) => l.lowWater?.());
144
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powersync/common",
3
- "version": "0.0.0-dev-20250207081035",
3
+ "version": "0.0.0-dev-20250319141441",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"
@@ -52,8 +52,6 @@
52
52
  "rsocket-core": "1.0.0-alpha.3",
53
53
  "rsocket-websocket-client": "1.0.0-alpha.3",
54
54
  "text-encoding": "^0.7.0",
55
- "typescript": "^5.5.3",
56
- "vitest": "^2.0.5",
57
55
  "web-streams-polyfill": "3.2.1"
58
56
  },
59
57
  "scripts": {