@powersync/common 1.33.2 → 1.34.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.
@@ -3,7 +3,7 @@ import { SyncStatus } from '../../../db/crud/SyncStatus.js';
3
3
  import { FULL_SYNC_PRIORITY } from '../../../db/crud/SyncProgress.js';
4
4
  import { AbortOperation } from '../../../utils/AbortOperation.js';
5
5
  import { BaseObserver } from '../../../utils/BaseObserver.js';
6
- import { onAbortPromise, throttleLeadingTrailing } from '../../../utils/async.js';
6
+ import { throttleLeadingTrailing } from '../../../utils/async.js';
7
7
  import { PowerSyncControlCommand } from '../bucket/BucketStorageAdapter.js';
8
8
  import { SyncDataBucket } from '../bucket/SyncDataBucket.js';
9
9
  import { FetchStrategy } from './AbstractRemote.js';
@@ -65,14 +65,14 @@ export const DEFAULT_CRUD_UPLOAD_THROTTLE_MS = 1000;
65
65
  export const DEFAULT_RETRY_DELAY_MS = 5000;
66
66
  export const DEFAULT_STREAMING_SYNC_OPTIONS = {
67
67
  retryDelayMs: DEFAULT_RETRY_DELAY_MS,
68
- logger: Logger.get('PowerSyncStream'),
69
68
  crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS
70
69
  };
71
70
  export const DEFAULT_STREAM_CONNECTION_OPTIONS = {
72
71
  connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET,
73
72
  clientImplementation: DEFAULT_SYNC_CLIENT_IMPLEMENTATION,
74
73
  fetchStrategy: FetchStrategy.Buffered,
75
- params: {}
74
+ params: {},
75
+ serializedSchema: undefined
76
76
  };
77
77
  // The priority we assume when we receive checkpoint lines where no priority is set.
78
78
  // This is the default priority used by the sync service, but can be set to an arbitrary
@@ -85,13 +85,15 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
85
85
  abortController;
86
86
  crudUpdateListener;
87
87
  streamingSyncPromise;
88
- pendingCrudUpload;
88
+ logger;
89
+ isUploadingCrud = false;
89
90
  notifyCompletedUploads;
90
91
  syncStatus;
91
92
  triggerCrudUpload;
92
93
  constructor(options) {
93
94
  super();
94
95
  this.options = { ...DEFAULT_STREAMING_SYNC_OPTIONS, ...options };
96
+ this.logger = options.logger ?? Logger.get('PowerSyncStream');
95
97
  this.syncStatus = new SyncStatus({
96
98
  connected: false,
97
99
  connecting: false,
@@ -103,15 +105,13 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
103
105
  });
104
106
  this.abortController = null;
105
107
  this.triggerCrudUpload = throttleLeadingTrailing(() => {
106
- if (!this.syncStatus.connected || this.pendingCrudUpload != null) {
108
+ if (!this.syncStatus.connected || this.isUploadingCrud) {
107
109
  return;
108
110
  }
109
- this.pendingCrudUpload = new Promise((resolve) => {
110
- this._uploadAllCrud().finally(() => {
111
- this.notifyCompletedUploads?.();
112
- this.pendingCrudUpload = undefined;
113
- resolve();
114
- });
111
+ this.isUploadingCrud = true;
112
+ this._uploadAllCrud().finally(() => {
113
+ this.notifyCompletedUploads?.();
114
+ this.isUploadingCrud = false;
115
115
  });
116
116
  }, this.options.crudUploadThrottleMs);
117
117
  }
@@ -157,9 +157,6 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
157
157
  get isConnected() {
158
158
  return this.syncStatus.connected;
159
159
  }
160
- get logger() {
161
- return this.options.logger;
162
- }
163
160
  async dispose() {
164
161
  this.crudUpdateListener?.();
165
162
  this.crudUpdateListener = undefined;
@@ -171,7 +168,9 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
171
168
  const clientId = await this.options.adapter.getClientId();
172
169
  let path = `/write-checkpoint2.json?client_id=${clientId}`;
173
170
  const response = await this.options.remote.get(path);
174
- return response['data']['write_checkpoint'];
171
+ const checkpoint = response['data']['write_checkpoint'];
172
+ this.logger.debug(`Created write checkpoint: ${checkpoint}`);
173
+ return checkpoint;
175
174
  }
176
175
  async _uploadAllCrud() {
177
176
  return this.obtainLock({
@@ -210,7 +209,11 @@ The next upload iteration will be delayed.`);
210
209
  }
211
210
  else {
212
211
  // Uploading is completed
213
- await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint());
212
+ const neededUpdate = await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint());
213
+ if (neededUpdate == false && checkedCrudItem != null) {
214
+ // Only log this if there was something to upload
215
+ this.logger.debug('Upload complete, no write checkpoint needed.');
216
+ }
214
217
  break;
215
218
  }
216
219
  }
@@ -361,6 +364,7 @@ The next upload iteration will be delayed.`);
361
364
  });
362
365
  }
363
366
  finally {
367
+ this.notifyCompletedUploads = undefined;
364
368
  if (!signal.aborted) {
365
369
  nestedAbortController.abort(new AbortOperation('Closing sync stream network requests before retry.'));
366
370
  nestedAbortController = new AbortController();
@@ -435,13 +439,15 @@ The next upload iteration will be delayed.`);
435
439
  });
436
440
  }
437
441
  async legacyStreamingSyncIteration(signal, resolvedOptions) {
442
+ if (resolvedOptions.serializedSchema?.raw_tables != null) {
443
+ this.logger.warn('Raw tables require the Rust-based sync client. The JS client will ignore them.');
444
+ }
438
445
  this.logger.debug('Streaming sync iteration started');
439
446
  this.options.adapter.startSession();
440
447
  let [req, bucketMap] = await this.collectLocalBucketState();
441
- // These are compared by reference
442
448
  let targetCheckpoint = null;
443
- let validatedCheckpoint = null;
444
- let appliedCheckpoint = null;
449
+ // A checkpoint that has been validated but not applied (e.g. due to pending local writes)
450
+ let pendingValidatedCheckpoint = null;
445
451
  const clientId = await this.options.adapter.getClientId();
446
452
  const usingFixedKeyFormat = await this.requireKeyFormat(false);
447
453
  this.logger.debug('Requesting stream from server');
@@ -458,21 +464,55 @@ The next upload iteration will be delayed.`);
458
464
  };
459
465
  let stream;
460
466
  if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
461
- stream = await this.options.remote.postStream(syncOptions);
467
+ stream = await this.options.remote.postStreamRaw(syncOptions, (line) => {
468
+ if (typeof line == 'string') {
469
+ return JSON.parse(line);
470
+ }
471
+ else {
472
+ // Directly enqueued by us
473
+ return line;
474
+ }
475
+ });
462
476
  }
463
477
  else {
464
- stream = await this.options.remote.socketStream({
478
+ const bson = await this.options.remote.getBSON();
479
+ stream = await this.options.remote.socketStreamRaw({
465
480
  ...syncOptions,
466
481
  ...{ fetchStrategy: resolvedOptions.fetchStrategy }
467
- });
482
+ }, (payload) => {
483
+ if (payload instanceof Uint8Array) {
484
+ return bson.deserialize(payload);
485
+ }
486
+ else {
487
+ // Directly enqueued by us
488
+ return payload;
489
+ }
490
+ }, bson);
468
491
  }
469
492
  this.logger.debug('Stream established. Processing events');
493
+ this.notifyCompletedUploads = () => {
494
+ if (!stream.closed) {
495
+ stream.enqueueData({ crud_upload_completed: null });
496
+ }
497
+ };
470
498
  while (!stream.closed) {
471
499
  const line = await stream.read();
472
500
  if (!line) {
473
501
  // The stream has closed while waiting
474
502
  return;
475
503
  }
504
+ if ('crud_upload_completed' in line) {
505
+ if (pendingValidatedCheckpoint != null) {
506
+ const { applied, endIteration } = await this.applyCheckpoint(pendingValidatedCheckpoint);
507
+ if (applied) {
508
+ pendingValidatedCheckpoint = null;
509
+ }
510
+ else if (endIteration) {
511
+ break;
512
+ }
513
+ }
514
+ continue;
515
+ }
476
516
  // A connection is active and messages are being received
477
517
  if (!this.syncStatus.connected) {
478
518
  // There is a connection now
@@ -483,6 +523,8 @@ The next upload iteration will be delayed.`);
483
523
  }
484
524
  if (isStreamingSyncCheckpoint(line)) {
485
525
  targetCheckpoint = line.checkpoint;
526
+ // New checkpoint - existing validated checkpoint is no longer valid
527
+ pendingValidatedCheckpoint = null;
486
528
  const bucketsToDelete = new Set(bucketMap.keys());
487
529
  const newBuckets = new Map();
488
530
  for (const checksum of line.checkpoint.buckets) {
@@ -501,14 +543,20 @@ The next upload iteration will be delayed.`);
501
543
  await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint);
502
544
  }
503
545
  else if (isStreamingSyncCheckpointComplete(line)) {
504
- const result = await this.applyCheckpoint(targetCheckpoint, signal);
546
+ const result = await this.applyCheckpoint(targetCheckpoint);
505
547
  if (result.endIteration) {
506
548
  return;
507
549
  }
508
- else if (result.applied) {
509
- appliedCheckpoint = targetCheckpoint;
550
+ else if (!result.applied) {
551
+ // "Could not apply checkpoint due to local data". We need to retry after
552
+ // finishing uploads.
553
+ pendingValidatedCheckpoint = targetCheckpoint;
554
+ }
555
+ else {
556
+ // Nothing to retry later. This would likely already be null from the last
557
+ // checksum or checksum_diff operation, but we make sure.
558
+ pendingValidatedCheckpoint = null;
510
559
  }
511
- validatedCheckpoint = targetCheckpoint;
512
560
  }
513
561
  else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
514
562
  const priority = line.partial_checkpoint_complete.priority;
@@ -545,6 +593,8 @@ The next upload iteration will be delayed.`);
545
593
  if (targetCheckpoint == null) {
546
594
  throw new Error('Checkpoint diff without previous checkpoint');
547
595
  }
596
+ // New checkpoint - existing validated checkpoint is no longer valid
597
+ pendingValidatedCheckpoint = null;
548
598
  const diff = line.checkpoint_diff;
549
599
  const newBuckets = new Map();
550
600
  for (const checksum of targetCheckpoint.buckets) {
@@ -618,26 +668,7 @@ The next upload iteration will be delayed.`);
618
668
  this.triggerCrudUpload();
619
669
  }
620
670
  else {
621
- this.logger.debug('Sync complete');
622
- if (targetCheckpoint === appliedCheckpoint) {
623
- this.updateSyncStatus({
624
- connected: true,
625
- lastSyncedAt: new Date(),
626
- priorityStatusEntries: [],
627
- dataFlow: {
628
- downloadError: undefined
629
- }
630
- });
631
- }
632
- else if (validatedCheckpoint === targetCheckpoint) {
633
- const result = await this.applyCheckpoint(targetCheckpoint, signal);
634
- if (result.endIteration) {
635
- return;
636
- }
637
- else if (result.applied) {
638
- appliedCheckpoint = targetCheckpoint;
639
- }
640
- }
671
+ this.logger.debug('Received unknown sync line', line);
641
672
  }
642
673
  }
643
674
  this.logger.debug('Stream input empty');
@@ -803,9 +834,11 @@ The next upload iteration will be delayed.`);
803
834
  }
804
835
  }
805
836
  try {
806
- await control(PowerSyncControlCommand.START, JSON.stringify({
807
- parameters: resolvedOptions.params
808
- }));
837
+ const options = { parameters: resolvedOptions.params };
838
+ if (resolvedOptions.serializedSchema) {
839
+ options.schema = resolvedOptions.serializedSchema;
840
+ }
841
+ await control(PowerSyncControlCommand.START, JSON.stringify(options));
809
842
  this.notifyCompletedUploads = () => {
810
843
  controlInvocations?.enqueueData({ command: PowerSyncControlCommand.NOTIFY_CRUD_UPLOAD_COMPLETED });
811
844
  };
@@ -853,45 +886,30 @@ The next upload iteration will be delayed.`);
853
886
  }
854
887
  });
855
888
  }
856
- async applyCheckpoint(checkpoint, abort) {
889
+ async applyCheckpoint(checkpoint) {
857
890
  let result = await this.options.adapter.syncLocalDatabase(checkpoint);
858
- const pending = this.pendingCrudUpload;
859
891
  if (!result.checkpointValid) {
860
- this.logger.debug('Checksum mismatch in checkpoint, will reconnect');
892
+ this.logger.debug(`Checksum mismatch in checkpoint ${checkpoint.last_op_id}, will reconnect`);
861
893
  // This means checksums failed. Start again with a new checkpoint.
862
894
  // TODO: better back-off
863
895
  await new Promise((resolve) => setTimeout(resolve, 50));
864
896
  return { applied: false, endIteration: true };
865
897
  }
866
- else if (!result.ready && pending != null) {
867
- // We have pending entries in the local upload queue or are waiting to confirm a write
868
- // checkpoint, which prevented this checkpoint from applying. Wait for that to complete and
869
- // try again.
870
- this.logger.debug('Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying.');
871
- await Promise.race([pending, onAbortPromise(abort)]);
872
- if (abort.aborted) {
873
- return { applied: false, endIteration: true };
874
- }
875
- // Try again now that uploads have completed.
876
- result = await this.options.adapter.syncLocalDatabase(checkpoint);
877
- }
878
- if (result.checkpointValid && result.ready) {
879
- this.logger.debug('validated checkpoint', checkpoint);
880
- this.updateSyncStatus({
881
- connected: true,
882
- lastSyncedAt: new Date(),
883
- dataFlow: {
884
- downloading: false,
885
- downloadProgress: null,
886
- downloadError: undefined
887
- }
888
- });
889
- return { applied: true, endIteration: false };
890
- }
891
- else {
892
- this.logger.debug('Could not apply checkpoint. Waiting for next sync complete line.');
898
+ else if (!result.ready) {
899
+ this.logger.debug(`Could not apply checkpoint ${checkpoint.last_op_id} due to local data. We will retry applying the checkpoint after that upload is completed.`);
893
900
  return { applied: false, endIteration: false };
894
901
  }
902
+ this.logger.debug(`Applied checkpoint ${checkpoint.last_op_id}`, checkpoint);
903
+ this.updateSyncStatus({
904
+ connected: true,
905
+ lastSyncedAt: new Date(),
906
+ dataFlow: {
907
+ downloading: false,
908
+ downloadProgress: null,
909
+ downloadError: undefined
910
+ }
911
+ });
912
+ return { applied: true, endIteration: false };
895
913
  }
896
914
  updateSyncStatus(options) {
897
915
  const updatedStatus = new SyncStatus({
@@ -101,6 +101,10 @@ export interface StreamingSyncKeepalive {
101
101
  token_expires_in: number;
102
102
  }
103
103
  export type StreamingSyncLine = StreamingSyncDataJSON | StreamingSyncCheckpoint | StreamingSyncCheckpointDiff | StreamingSyncCheckpointComplete | StreamingSyncCheckpointPartiallyComplete | StreamingSyncKeepalive;
104
+ export type CrudUploadNotification = {
105
+ crud_upload_completed: null;
106
+ };
107
+ export type StreamingSyncLineOrCrudUploadComplete = StreamingSyncLine | CrudUploadNotification;
104
108
  export interface BucketRequest {
105
109
  name: string;
106
110
  /**
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A pending variant of a {@link RawTable} that doesn't have a name (because it would be inferred when creating the
3
+ * schema).
4
+ */
5
+ export type RawTableType = {
6
+ /**
7
+ * The statement to run when PowerSync detects that a row needs to be inserted or updated.
8
+ */
9
+ put: PendingStatement;
10
+ /**
11
+ * The statement to run when PowerSync detects that a row needs to be deleted.
12
+ */
13
+ delete: PendingStatement;
14
+ };
15
+ /**
16
+ * A parameter to use as part of {@link PendingStatement}.
17
+ *
18
+ * For delete statements, only the `"Id"` value is supported - the sync client will replace it with the id of the row to
19
+ * be synced.
20
+ *
21
+ * For insert and replace operations, the values of columns in the table are available as parameters through
22
+ * `{Column: 'name'}`.
23
+ */
24
+ export type PendingStatementParameter = 'Id' | {
25
+ Column: string;
26
+ };
27
+ /**
28
+ * A statement that the PowerSync client should use to insert or delete data into a table managed by the user.
29
+ */
30
+ export type PendingStatement = {
31
+ sql: string;
32
+ params: PendingStatementParameter[];
33
+ };
34
+ /**
35
+ * Instructs PowerSync to sync data into a "raw" table.
36
+ *
37
+ * Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
38
+ * using client-side table and column constraints.
39
+ *
40
+ * To collect local writes to raw tables with PowerSync, custom triggers are required. See
41
+ * {@link https://docs.powersync.com/usage/use-case-examples/raw-tables the documentation} for details and an example on
42
+ * using raw tables.
43
+ *
44
+ * Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
45
+ *
46
+ * @experimental Please note that this feature is experimental at the moment, and not covered by PowerSync semver or
47
+ * stability guarantees.
48
+ */
49
+ export declare class RawTable implements RawTableType {
50
+ /**
51
+ * The name of the table.
52
+ *
53
+ * This does not have to match the actual table name in the schema - {@link put} and {@link delete} are free to use
54
+ * another table. Instead, this name is used by the sync client to recognize that operations on this table (as it
55
+ * appears in the source / backend database) are to be handled specially.
56
+ */
57
+ name: string;
58
+ put: PendingStatement;
59
+ delete: PendingStatement;
60
+ constructor(name: string, type: RawTableType);
61
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Instructs PowerSync to sync data into a "raw" table.
3
+ *
4
+ * Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
5
+ * using client-side table and column constraints.
6
+ *
7
+ * To collect local writes to raw tables with PowerSync, custom triggers are required. See
8
+ * {@link https://docs.powersync.com/usage/use-case-examples/raw-tables the documentation} for details and an example on
9
+ * using raw tables.
10
+ *
11
+ * Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
12
+ *
13
+ * @experimental Please note that this feature is experimental at the moment, and not covered by PowerSync semver or
14
+ * stability guarantees.
15
+ */
16
+ export class RawTable {
17
+ /**
18
+ * The name of the table.
19
+ *
20
+ * This does not have to match the actual table name in the schema - {@link put} and {@link delete} are free to use
21
+ * another table. Instead, this name is used by the sync client to recognize that operations on this table (as it
22
+ * appears in the source / backend database) are to be handled specially.
23
+ */
24
+ name;
25
+ put;
26
+ delete;
27
+ constructor(name, type) {
28
+ this.name = name;
29
+ this.put = type.put;
30
+ this.delete = type.delete;
31
+ }
32
+ }
@@ -1,3 +1,4 @@
1
+ import { RawTable, RawTableType } from './RawTable.js';
1
2
  import { RowType, Table } from './Table.js';
2
3
  type SchemaType = Record<string, Table<any>>;
3
4
  export type SchemaTableType<S extends SchemaType> = {
@@ -10,7 +11,19 @@ export declare class Schema<S extends SchemaType = SchemaType> {
10
11
  readonly types: SchemaTableType<S>;
11
12
  readonly props: S;
12
13
  readonly tables: Table[];
14
+ readonly rawTables: RawTable[];
13
15
  constructor(tables: Table[] | S);
16
+ /**
17
+ * Adds raw tables to this schema. Raw tables are identified by their name, but entirely managed by the application
18
+ * developer instead of automatically by PowerSync.
19
+ * Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
20
+ * using client-side table and column constraints.
21
+ * Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
22
+ *
23
+ * @param tables An object of (table name, raw table definition) entries.
24
+ * @experimental Note that the raw tables API is still experimental and may change in the future.
25
+ */
26
+ withRawTables(tables: Record<string, RawTableType>): void;
14
27
  validate(): void;
15
28
  toJSON(): {
16
29
  tables: {
@@ -35,6 +48,7 @@ export declare class Schema<S extends SchemaType = SchemaType> {
35
48
  }[];
36
49
  }[];
37
50
  }[];
51
+ raw_tables: RawTable[];
38
52
  };
39
53
  private convertToClassicTables;
40
54
  }
@@ -1,3 +1,4 @@
1
+ import { RawTable } from './RawTable.js';
1
2
  /**
2
3
  * A schema is a collection of tables. It is used to define the structure of a database.
3
4
  */
@@ -8,6 +9,7 @@ export class Schema {
8
9
  types;
9
10
  props;
10
11
  tables;
12
+ rawTables;
11
13
  constructor(tables) {
12
14
  if (Array.isArray(tables)) {
13
15
  /*
@@ -26,6 +28,22 @@ export class Schema {
26
28
  this.props = tables;
27
29
  this.tables = this.convertToClassicTables(this.props);
28
30
  }
31
+ this.rawTables = [];
32
+ }
33
+ /**
34
+ * Adds raw tables to this schema. Raw tables are identified by their name, but entirely managed by the application
35
+ * developer instead of automatically by PowerSync.
36
+ * Since raw tables are not backed by JSON, running complex queries on them may be more efficient. Further, they allow
37
+ * using client-side table and column constraints.
38
+ * Note that raw tables are only supported when using the new `SyncClientImplementation.rust` sync client.
39
+ *
40
+ * @param tables An object of (table name, raw table definition) entries.
41
+ * @experimental Note that the raw tables API is still experimental and may change in the future.
42
+ */
43
+ withRawTables(tables) {
44
+ for (const [name, rawTableDefinition] of Object.entries(tables)) {
45
+ this.rawTables.push(new RawTable(name, rawTableDefinition));
46
+ }
29
47
  }
30
48
  validate() {
31
49
  for (const table of this.tables) {
@@ -35,7 +53,8 @@ export class Schema {
35
53
  toJSON() {
36
54
  return {
37
55
  // This is required because "name" field is not present in TableV2
38
- tables: this.tables.map((t) => t.toJSON())
56
+ tables: this.tables.map((t) => t.toJSON()),
57
+ raw_tables: this.rawTables
39
58
  };
40
59
  }
41
60
  convertToClassicTables(props) {
@@ -12,4 +12,3 @@ export declare function throttleTrailing(func: () => void, wait: number): () =>
12
12
  * Roughly equivalent to lodash/throttle with {leading: true, trailing: true}
13
13
  */
14
14
  export declare function throttleLeadingTrailing(func: () => void, wait: number): () => void;
15
- export declare function onAbortPromise(signal: AbortSignal): Promise<void>;
@@ -43,13 +43,3 @@ export function throttleLeadingTrailing(func, wait) {
43
43
  }
44
44
  };
45
45
  }
46
- export function onAbortPromise(signal) {
47
- return new Promise((resolve) => {
48
- if (signal.aborted) {
49
- resolve();
50
- }
51
- else {
52
- signal.onabort = () => resolve();
53
- }
54
- });
55
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powersync/common",
3
- "version": "1.33.2",
3
+ "version": "1.34.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"