@powersync/common 1.54.0 → 1.56.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.
@@ -1,4 +1,3 @@
1
- import { EventIterator } from 'event-iterator';
2
1
  import Logger, { ILogger } from 'js-logger';
3
2
  import {
4
3
  BatchedUpdateNotification,
@@ -14,7 +13,7 @@ import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
14
13
  import { Schema } from '../db/schema/Schema.js';
15
14
  import { BaseObserver } from '../utils/BaseObserver.js';
16
15
  import { ControlledExecutor } from '../utils/ControlledExecutor.js';
17
- import { throttleTrailing } from '../utils/async.js';
16
+ import { EventQueue, throttleTrailing } from '../utils/async.js';
18
17
  import {
19
18
  ConnectionManager,
20
19
  CreateSyncImplementationOptions,
@@ -47,6 +46,7 @@ import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/Watch
47
46
  import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js';
48
47
  import { WatchedQueryComparator } from './watched/processors/comparators.js';
49
48
  import { Mutex } from '../utils/mutex.js';
49
+ import { symbolAsyncIterator } from '../utils/compatibility.js';
50
50
 
51
51
  /**
52
52
  * @public
@@ -557,7 +557,9 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
557
557
  }
558
558
  this._schema = schema;
559
559
 
560
- await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
560
+ await this.database.writeTransaction((tx) =>
561
+ tx.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())])
562
+ );
561
563
  await this.database.refreshSchema();
562
564
  this.iterateListeners(async (cb) => cb.schemaChanged?.(schema));
563
565
  }
@@ -762,7 +764,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
762
764
  * @returns A transaction of CRUD operations to upload, or null if there are none
763
765
  */
764
766
  async getNextCrudTransaction(): Promise<CrudTransaction | null> {
765
- const iterator = this.getCrudTransactions()[Symbol.asyncIterator]();
767
+ const iterator = this.getCrudTransactions()[symbolAsyncIterator]();
766
768
  return (await iterator.next()).value;
767
769
  }
768
770
 
@@ -799,7 +801,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
799
801
  */
800
802
  getCrudTransactions(): AsyncIterable<CrudTransaction, null> {
801
803
  return {
802
- [Symbol.asyncIterator]: () => {
804
+ [symbolAsyncIterator]: () => {
803
805
  let lastCrudItemId = -1;
804
806
  const sql = `
805
807
  WITH RECURSIVE crud_entries AS (
@@ -1193,22 +1195,18 @@ SELECT * FROM crud_entries;
1193
1195
  * @returns An AsyncIterable that yields QueryResults whenever the data changes
1194
1196
  */
1195
1197
  watchWithAsyncGenerator(sql: string, parameters?: any[], options?: SQLWatchOptions): AsyncIterable<QueryResult> {
1196
- return new EventIterator<QueryResult>((eventOptions) => {
1198
+ return EventQueue.queueBasedAsyncIterable((queue, abort) => {
1197
1199
  const handler: WatchHandler = {
1198
1200
  onResult: (result) => {
1199
- eventOptions.push(result);
1201
+ queue.notify(result);
1200
1202
  },
1201
1203
  onError: (error) => {
1202
- eventOptions.fail(error);
1204
+ queue.notifyError(error);
1203
1205
  }
1204
1206
  };
1205
1207
 
1206
- this.watchWithCallback(sql, parameters, handler, options);
1207
-
1208
- options?.signal?.addEventListener('abort', () => {
1209
- eventOptions.stop();
1210
- });
1211
- });
1208
+ this.watchWithCallback(sql, parameters, handler, { ...options, signal: abort });
1209
+ }, options?.signal);
1212
1210
  }
1213
1211
 
1214
1212
  /**
@@ -1353,34 +1351,27 @@ SELECT * FROM crud_entries;
1353
1351
  * This is preferred over {@link AbstractPowerSyncDatabase.watchWithAsyncGenerator} when multiple queries need to be
1354
1352
  * performed together when data is changed.
1355
1353
  *
1356
- * Note: do not declare this as `async *onChange` as it will not work in React Native.
1357
- *
1358
1354
  * @param options - Options for configuring watch behavior
1359
1355
  * @returns An AsyncIterable that yields change events whenever the specified tables change
1360
1356
  */
1357
+ // Note: do not declare this as `async *onChange` as it will not work in React Native.
1361
1358
  onChangeWithAsyncGenerator(options?: SQLWatchOptions): AsyncIterable<WatchOnChangeEvent> {
1362
- const resolvedOptions = options ?? {};
1363
-
1364
- return new EventIterator<WatchOnChangeEvent>((eventOptions) => {
1365
- const dispose = this.onChangeWithCallback(
1359
+ return EventQueue.queueBasedAsyncIterable((queue, abort) => {
1360
+ this.onChangeWithCallback(
1366
1361
  {
1367
1362
  onChange: (event): void => {
1368
- eventOptions.push(event);
1363
+ queue.notify(event);
1369
1364
  },
1370
1365
  onError: (error) => {
1371
- eventOptions.fail(error);
1366
+ queue.notifyError(error);
1372
1367
  }
1373
1368
  },
1374
- options
1369
+ { ...options, signal: abort }
1375
1370
  );
1376
1371
 
1377
- resolvedOptions.signal?.addEventListener('abort', () => {
1378
- eventOptions.stop();
1379
- // Maybe fail?
1380
- });
1381
-
1382
- return () => dispose();
1383
- });
1372
+ // Note: We don't have to track the dispose function returned by onChangeWithCallback, it cleans up
1373
+ // after the abort signal completes.
1374
+ }, options?.signal);
1384
1375
  }
1385
1376
 
1386
1377
  private handleTableChanges(
@@ -9,10 +9,10 @@ import {
9
9
  doneResult,
10
10
  extractBsonObjects,
11
11
  extractJsonLines,
12
- SimpleAsyncIterator
12
+ SimpleAsyncIterator,
13
+ valueResult
13
14
  } from '../../../utils/stream_transform.js';
14
- import { EventIterator } from 'event-iterator';
15
- import type { Queue } from 'event-iterator/lib/event-iterator.js';
15
+ import { EventQueue } from '../../../utils/async.js';
16
16
 
17
17
  /**
18
18
  * @internal
@@ -329,8 +329,19 @@ export abstract class AbstractRemote {
329
329
  let pendingSocket: WebSocket | null = null;
330
330
  let keepAliveTimeout: any;
331
331
  let rsocket: RSocket | null = null;
332
- let queue: Queue<Uint8Array> | null = null;
332
+ let paused = false;
333
+ const queue = new EventQueue<Uint8Array | null>({
334
+ eventDelivered: () => {
335
+ if (queue.countOutstandingEvents <= SYNC_QUEUE_REQUEST_LOW_WATER) {
336
+ paused = false;
337
+ requestMore();
338
+ }
339
+ }
340
+ });
333
341
  let didClose = false;
342
+ let connectionEstablished = false;
343
+ let pendingEventsCount = syncQueueRequestSize;
344
+ let res: Requestable | null = null;
334
345
 
335
346
  const abortRequest = () => {
336
347
  if (didClose) {
@@ -348,11 +359,26 @@ export abstract class AbstractRemote {
348
359
  rsocket.close();
349
360
  }
350
361
 
351
- if (queue) {
352
- queue.stop();
353
- }
362
+ // Send a bogus event to the queue to ensure a pending listener gets woken up. We check for didClose and would
363
+ // return a doneEvent.
364
+ queue.notify(null);
354
365
  };
355
366
 
367
+ function push(event: Uint8Array) {
368
+ queue.notify(event);
369
+ if (queue.countOutstandingEvents >= SYNC_QUEUE_REQUEST_HIGH_WATER) {
370
+ paused = true;
371
+ }
372
+ }
373
+
374
+ function requestMore() {
375
+ const delta = syncQueueRequestSize - pendingEventsCount;
376
+ if (!paused && delta > 0) {
377
+ res?.request(delta);
378
+ pendingEventsCount = syncQueueRequestSize;
379
+ }
380
+ }
381
+
356
382
  // Handle upstream abort
357
383
  if (options.abortSignal.aborted) {
358
384
  throw new AbortOperation('Connection request aborted');
@@ -413,31 +439,18 @@ export abstract class AbstractRemote {
413
439
  rsocket.onClose(() => (rsocket = null));
414
440
 
415
441
  return await new Promise((resolve, reject) => {
416
- let connectionEstablished = false;
417
- let pendingEventsCount = syncQueueRequestSize;
418
- let paused = false;
419
- let res: Requestable | null = null;
420
-
421
- function requestMore() {
422
- const delta = syncQueueRequestSize - pendingEventsCount;
423
- if (!paused && delta > 0) {
424
- res?.request(delta);
425
- pendingEventsCount = syncQueueRequestSize;
426
- }
427
- }
428
-
429
- const events = new EventIterator<Uint8Array>(
430
- (q) => {
431
- queue = q;
442
+ const queueAsIterator: SimpleAsyncIterator<Uint8Array> = {
443
+ next: async () => {
444
+ if (didClose) return doneResult;
432
445
 
433
- q.on('highWater', () => (paused = true));
434
- q.on('lowWater', () => {
435
- paused = false;
436
- requestMore();
437
- });
438
- },
439
- { highWaterMark: SYNC_QUEUE_REQUEST_HIGH_WATER, lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER }
440
- )[Symbol.asyncIterator]();
446
+ const notification = await queue.waitForEvent(options.abortSignal);
447
+ if (didClose) {
448
+ return doneResult;
449
+ } else {
450
+ return valueResult(notification!);
451
+ }
452
+ }
453
+ };
441
454
 
442
455
  res = rsocket!.requestStream(
443
456
  {
@@ -476,12 +489,12 @@ export abstract class AbstractRemote {
476
489
  // The connection is active
477
490
  if (!connectionEstablished) {
478
491
  connectionEstablished = true;
479
- resolve(events);
492
+ resolve(queueAsIterator);
480
493
  }
481
494
  const { data } = payload;
482
495
 
483
496
  if (data) {
484
- queue!.push(data);
497
+ push(data);
485
498
  }
486
499
 
487
500
  // Less events are now pending
@@ -234,11 +234,7 @@ export class SyncStatus {
234
234
  */
235
235
  const replacer = (_: string, value: any) => {
236
236
  if (value instanceof Error) {
237
- return {
238
- name: value.name,
239
- message: value.message,
240
- stack: value.stack
241
- };
237
+ return this.serializeError(value);
242
238
  }
243
239
  return value;
244
240
  };
@@ -281,15 +277,22 @@ export class SyncStatus {
281
277
  * Not all errors are serializable over a MessagePort. E.g. some `DomExceptions` fail to be passed across workers.
282
278
  * This explicitly serializes errors in the SyncStatus.
283
279
  */
284
- protected serializeError(error?: Error) {
280
+ protected serializeError(error?: Error): Error | undefined {
285
281
  if (typeof error == 'undefined') {
286
282
  return undefined;
287
283
  }
288
- return {
284
+ const serialized: Error = {
289
285
  name: error.name,
290
286
  message: error.message,
291
287
  stack: error.stack
292
288
  };
289
+ // `Error.cause` can be any value (the spec types it as unknown). Preserve it
290
+ // so consumers reading uploadError/downloadError keep the failure context.
291
+ // Recurse for Error causes so the whole chain is flattened the same way.
292
+ if (typeof error.cause != 'undefined') {
293
+ serialized.cause = error.cause instanceof Error ? this.serializeError(error.cause) : error.cause;
294
+ }
295
+ return serialized;
293
296
  }
294
297
 
295
298
  private static comparePriorities(a: SyncPriorityStatus, b: SyncPriorityStatus) {
@@ -1,3 +1,6 @@
1
+ import { symbolAsyncIterator } from './compatibility.js';
2
+ import { doneResult, valueResult } from './stream_transform.js';
3
+
1
4
  /**
2
5
  * Throttle a function to be called at most once every "wait" milliseconds,
3
6
  * on the trailing edge.
@@ -34,44 +37,149 @@ export interface AsyncNotifier {
34
37
  }
35
38
 
36
39
  export function asyncNotifier(): AsyncNotifier {
37
- let waitingConsumer: (() => void) | null = null;
38
- let hasPendingNotification = false;
40
+ const queue = new EventQueue<void>();
39
41
 
40
42
  return {
41
43
  notify() {
42
- if (waitingConsumer != null) {
43
- waitingConsumer();
44
- waitingConsumer = null;
44
+ if (queue.countOutstandingEvents > 0) {
45
+ // Already has an outstanding event, no need to buffer another one.
45
46
  } else {
46
- hasPendingNotification = true;
47
+ queue.notify();
47
48
  }
48
49
  },
49
50
  waitForNotification(signal: AbortSignal) {
50
- return new Promise((resolve) => {
51
- if (waitingConsumer != null) {
52
- throw new Error('Illegal call to waitForNotification, already has a waiter.');
51
+ return queue.waitForEvent(signal);
52
+ }
53
+ };
54
+ }
55
+
56
+ type QueueWaiter<T> = { resolve: (event: T) => void; reject: (error: unknown) => void };
57
+
58
+ export interface QueueOptions {
59
+ eventDelivered?: () => void;
60
+ }
61
+
62
+ export class EventQueue<T> {
63
+ private waitingConsumer: QueueWaiter<T> | undefined;
64
+ private readonly outstandingEvents: Array<(waiter: QueueWaiter<T>) => void>;
65
+
66
+ constructor(private readonly options: QueueOptions = {}) {
67
+ this.outstandingEvents = [];
68
+ }
69
+
70
+ /**
71
+ * The amount of buffered events not yet dispatched to listeners.
72
+ */
73
+ get countOutstandingEvents(): number {
74
+ return this.outstandingEvents.length;
75
+ }
76
+
77
+ private notifyInner(dispatch: (waiter: QueueWaiter<T>) => void) {
78
+ const existing = this.waitingConsumer;
79
+ this.waitingConsumer = undefined;
80
+
81
+ const dispatchAndNotifyListeners = (waiter: QueueWaiter<T>) => {
82
+ dispatch(waiter);
83
+ this.options.eventDelivered?.();
84
+ };
85
+
86
+ if (existing) {
87
+ dispatchAndNotifyListeners(existing);
88
+ } else {
89
+ this.outstandingEvents.push(dispatchAndNotifyListeners);
90
+ }
91
+ }
92
+
93
+ notify(value: T) {
94
+ this.notifyInner((l) => l.resolve(value));
95
+ }
96
+
97
+ notifyError(error: unknown) {
98
+ this.notifyInner((l) => l.reject(error));
99
+ }
100
+
101
+ waitForEvent(signal: AbortSignal): Promise<T | undefined> {
102
+ return new Promise((resolve, reject) => {
103
+ if (this.waitingConsumer != null) {
104
+ throw new Error('Illegal call to waitForEvent, already has a waiter.');
105
+ }
106
+
107
+ const complete = () => {
108
+ signal?.removeEventListener('abort', onAbort);
109
+ };
110
+
111
+ const onAbort = () => {
112
+ complete();
113
+ this.waitingConsumer = undefined;
114
+ resolve(undefined);
115
+ };
116
+
117
+ const waiter: QueueWaiter<T> = {
118
+ resolve: (value) => {
119
+ complete();
120
+ resolve(value);
121
+ },
122
+ reject: (error) => {
123
+ complete();
124
+ reject(error);
53
125
  }
126
+ };
54
127
 
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
- }
128
+ if (signal.aborted) {
129
+ resolve(undefined);
130
+ } else if (this.countOutstandingEvents > 0) {
131
+ const [event] = this.outstandingEvents.splice(0, 1);
132
+ event(waiter);
133
+ } else {
134
+ this.waitingConsumer = waiter;
135
+ signal.addEventListener('abort', onAbort);
136
+ }
137
+ });
138
+ }
65
139
 
66
- function onAbort() {
67
- waitingConsumer = null;
68
- resolve();
69
- }
140
+ /**
141
+ * Creates an async iterable backed by event queues.
142
+ *
143
+ * @param run A function invoked for every new listener. It receives a queue backing the async iterator.
144
+ * @param abort An additional abort signal. The `run` callback will also be aborted when `AsyncIterator.return` is
145
+ * called.
146
+ * @returns An object conforming to the async iterable protocol.
147
+ */
148
+ static queueBasedAsyncIterable<T>(
149
+ run: (queue: EventQueue<T>, abort: AbortSignal) => void,
150
+ abort?: AbortSignal
151
+ ): AsyncIterable<T> {
152
+ return {
153
+ [symbolAsyncIterator]: () => {
154
+ const queue = new EventQueue<T>();
155
+ const controller = new AbortController();
70
156
 
71
- waitingConsumer = complete;
72
- signal.addEventListener('abort', onAbort);
157
+ function dispose() {
158
+ controller.abort();
159
+ abort?.removeEventListener('abort', dispose);
73
160
  }
74
- });
75
- }
76
- };
161
+
162
+ if (abort) {
163
+ if (abort.aborted) {
164
+ controller.abort();
165
+ } else {
166
+ abort.addEventListener('abort', dispose);
167
+ }
168
+ }
169
+
170
+ run(queue, controller.signal);
171
+
172
+ return {
173
+ async next(): Promise<IteratorResult<T>> {
174
+ const event = await queue.waitForEvent(controller.signal);
175
+ return event == null ? doneResult : valueResult(event);
176
+ },
177
+ async return(): Promise<IteratorResult<T>> {
178
+ dispose();
179
+ return doneResult;
180
+ }
181
+ };
182
+ }
183
+ };
184
+ }
77
185
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Some JavaScript engines, in particular older versions of React Native, don't support Symbol.asyncIterator.
3
+ *
4
+ * For those, users relying on async generators typically lower them with a transpiler and [this polyfill](https://github.com/Azure/azure-sdk-for-js/blob/%40azure/core-asynciterator-polyfill_1.0.2/sdk/core/core-asynciterator-polyfill/src/index.ts#L4-L6).
5
+ * This definition is compatible with that polyfill, so transpiled apps can use async iterables created by the PowerSync
6
+ * SDK.
7
+ */
8
+ export const symbolAsyncIterator: typeof Symbol.asyncIterator =
9
+ Symbol.asyncIterator ?? Symbol.for('Symbol.asyncIterator');