@powersync/common 1.51.0 → 1.52.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.
Files changed (37) hide show
  1. package/dist/bundle.cjs +431 -445
  2. package/dist/bundle.cjs.map +1 -1
  3. package/dist/bundle.mjs +432 -444
  4. package/dist/bundle.mjs.map +1 -1
  5. package/dist/bundle.node.cjs +429 -445
  6. package/dist/bundle.node.cjs.map +1 -1
  7. package/dist/bundle.node.mjs +430 -444
  8. package/dist/bundle.node.mjs.map +1 -1
  9. package/dist/index.d.cts +41 -70
  10. package/lib/client/AbstractPowerSyncDatabase.js +3 -3
  11. package/lib/client/AbstractPowerSyncDatabase.js.map +1 -1
  12. package/lib/client/sync/stream/AbstractRemote.d.ts +29 -8
  13. package/lib/client/sync/stream/AbstractRemote.js +154 -177
  14. package/lib/client/sync/stream/AbstractRemote.js.map +1 -1
  15. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +1 -0
  16. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +69 -88
  17. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -1
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +0 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/utils/async.d.ts +0 -9
  22. package/lib/utils/async.js +0 -9
  23. package/lib/utils/async.js.map +1 -1
  24. package/lib/utils/stream_transform.d.ts +39 -0
  25. package/lib/utils/stream_transform.js +206 -0
  26. package/lib/utils/stream_transform.js.map +1 -0
  27. package/package.json +9 -7
  28. package/src/client/AbstractPowerSyncDatabase.ts +3 -3
  29. package/src/client/sync/stream/AbstractRemote.ts +182 -206
  30. package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +82 -83
  31. package/src/index.ts +1 -1
  32. package/src/utils/async.ts +0 -11
  33. package/src/utils/stream_transform.ts +252 -0
  34. package/lib/utils/DataStream.d.ts +0 -62
  35. package/lib/utils/DataStream.js +0 -169
  36. package/lib/utils/DataStream.js.map +0 -1
  37. package/src/utils/DataStream.ts +0 -222
@@ -1,14 +1,20 @@
1
1
  import type { BSON } from 'bson';
2
2
  import { type fetch } from 'cross-fetch';
3
3
  import Logger, { ILogger } from 'js-logger';
4
- import { RSocket, RSocketConnector, Requestable } from 'rsocket-core';
4
+ import { Requestable, RSocket, RSocketConnector } from 'rsocket-core';
5
5
  import PACKAGE from '../../../../package.json' with { type: 'json' };
6
6
  import { AbortOperation } from '../../../utils/AbortOperation.js';
7
- import { DataStream } from '../../../utils/DataStream.js';
8
7
  import { PowerSyncCredentials } from '../../connection/PowerSyncCredentials.js';
9
8
  import { WebsocketClientTransport } from './WebsocketClientTransport.js';
10
9
  import { StreamingSyncRequest } from './streaming-sync-types.js';
11
-
10
+ import {
11
+ doneResult,
12
+ extractBsonObjects,
13
+ extractJsonLines,
14
+ SimpleAsyncIterator
15
+ } from '../../../utils/stream_transform.js';
16
+ import { EventIterator } from 'event-iterator';
17
+ import type { Queue } from 'event-iterator/lib/event-iterator.js';
12
18
 
13
19
  export type BSONImplementation = typeof BSON;
14
20
 
@@ -20,6 +26,7 @@ export type RemoteConnector = {
20
26
  const POWERSYNC_TRAILING_SLASH_MATCH = /\/+$/;
21
27
  const POWERSYNC_JS_VERSION = PACKAGE.version;
22
28
 
29
+ const SYNC_QUEUE_REQUEST_HIGH_WATER = 10;
23
30
  const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
24
31
 
25
32
  // Keep alive message is sent every period
@@ -39,7 +46,7 @@ export type SyncStreamOptions = {
39
46
  path: string;
40
47
  data: StreamingSyncRequest;
41
48
  headers?: Record<string, string>;
42
- abortSignal?: AbortSignal;
49
+ abortSignal: AbortSignal;
43
50
  fetchOptions?: Request;
44
51
  };
45
52
 
@@ -267,7 +274,7 @@ export abstract class AbstractRemote {
267
274
  * @returns A text decoder decoding UTF-8. This is a method to allow patching it for Hermes which doesn't support the
268
275
  * builtin, without forcing us to bundle a polyfill with `@powersync/common`.
269
276
  */
270
- protected createTextDecoder(): TextDecoder {
277
+ createTextDecoder(): TextDecoder {
271
278
  return new TextDecoder();
272
279
  }
273
280
 
@@ -276,17 +283,17 @@ export abstract class AbstractRemote {
276
283
  }
277
284
 
278
285
  /**
279
- * Returns a data stream of sync line data.
286
+ * Returns a data stream of sync line data, fetched via RSocket-over-WebSocket.
287
+ *
288
+ * The only mechanism to abort the returned stream is to use the abort signal in {@link SocketSyncStreamOptions}.
280
289
  *
281
- * @param map Maps received payload frames to the typed event value.
282
290
  * @param bson A BSON encoder and decoder. When set, the data stream will be requested with a BSON payload
283
291
  * (required for compatibility with older sync services).
284
292
  */
285
- async socketStreamRaw<T>(
293
+ async socketStreamRaw(
286
294
  options: SocketSyncStreamOptions,
287
- map: (buffer: Uint8Array) => T,
288
295
  bson?: typeof BSON
289
- ): Promise<DataStream<T>> {
296
+ ): Promise<SimpleAsyncIterator<Uint8Array>> {
290
297
  const { path, fetchStrategy = FetchStrategy.Buffered } = options;
291
298
  const mimeType = bson == null ? 'application/json' : 'application/bson';
292
299
 
@@ -303,65 +310,67 @@ export abstract class AbstractRemote {
303
310
 
304
311
  const syncQueueRequestSize = fetchStrategy == FetchStrategy.Buffered ? 10 : 1;
305
312
  const request = await this.buildRequest(path);
313
+ const url = this.options.socketUrlTransformer(request.url);
306
314
 
307
315
  // Add the user agent in the setup payload - we can't set custom
308
316
  // headers with websockets on web. The browser userAgent is however added
309
317
  // automatically as a header.
310
318
  const userAgent = this.getUserAgent();
311
319
 
312
- const stream = new DataStream<T, Uint8Array>({
313
- logger: this.logger,
314
- pressure: {
315
- lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER
316
- },
317
- mapLine: map
318
- });
320
+ // While we're connecting (a process that can't be aborted in RSocket), the WebSocket instance to close if we wanted
321
+ // to abort the connection.
322
+ let pendingSocket: WebSocket | null = null;
323
+ let keepAliveTimeout: any;
324
+ let rsocket: RSocket | null = null;
325
+ let queue: Queue<Uint8Array> | null = null;
326
+ let didClose = false;
327
+
328
+ const abortRequest = () => {
329
+ if (didClose) {
330
+ return;
331
+ }
332
+ didClose = true;
333
+
334
+ clearTimeout(keepAliveTimeout);
335
+
336
+ if (pendingSocket) {
337
+ pendingSocket.close();
338
+ }
339
+
340
+ if (rsocket) {
341
+ rsocket.close();
342
+ }
343
+
344
+ if (queue) {
345
+ queue.stop();
346
+ }
347
+ };
319
348
 
320
349
  // Handle upstream abort
321
- if (options.abortSignal?.aborted) {
350
+ if (options.abortSignal.aborted) {
322
351
  throw new AbortOperation('Connection request aborted');
323
352
  } else {
324
- options.abortSignal?.addEventListener(
325
- 'abort',
326
- () => {
327
- stream.close();
328
- },
329
- { once: true }
330
- );
353
+ options.abortSignal.addEventListener('abort', abortRequest);
331
354
  }
332
355
 
333
- let keepAliveTimeout: any;
334
356
  const resetTimeout = () => {
335
357
  clearTimeout(keepAliveTimeout);
336
358
  keepAliveTimeout = setTimeout(() => {
337
359
  this.logger.error(`No data received on WebSocket in ${SOCKET_TIMEOUT_MS}ms, closing connection.`);
338
- stream.close();
360
+ abortRequest();
339
361
  }, SOCKET_TIMEOUT_MS);
340
362
  };
341
363
  resetTimeout();
342
364
 
343
- // Typescript complains about this being `never` if it's not assigned here.
344
- // This is assigned in `wsCreator`.
345
- let disposeSocketConnectionTimeout = () => {};
346
-
347
- const url = this.options.socketUrlTransformer(request.url);
348
365
  const connector = new RSocketConnector({
349
366
  transport: new WebsocketClientTransport({
350
367
  url,
351
368
  wsCreator: (url) => {
352
- const socket = this.createSocket(url);
353
- disposeSocketConnectionTimeout = stream.registerListener({
354
- closed: () => {
355
- // Allow closing the underlying WebSocket if the stream was closed before the
356
- // RSocket connect completed. This should effectively abort the request.
357
- socket.close();
358
- }
359
- });
369
+ const socket = (pendingSocket = this.createSocket(url));
360
370
 
361
- socket.addEventListener('message', (event) => {
371
+ socket.addEventListener('message', () => {
362
372
  resetTimeout();
363
373
  });
364
-
365
374
  return socket;
366
375
  }
367
376
  }),
@@ -380,47 +389,50 @@ export abstract class AbstractRemote {
380
389
  }
381
390
  });
382
391
 
383
- let rsocket: RSocket;
384
392
  try {
385
393
  rsocket = await connector.connect();
386
394
  // The connection is established, we no longer need to monitor the initial timeout
387
- disposeSocketConnectionTimeout();
395
+ pendingSocket = null;
388
396
  } catch (ex) {
389
397
  this.logger.error(`Failed to connect WebSocket`, ex);
390
- clearTimeout(keepAliveTimeout);
391
- if (!stream.closed) {
392
- await stream.close();
393
- }
398
+ abortRequest();
399
+
394
400
  throw ex;
395
401
  }
396
402
 
397
403
  resetTimeout();
398
404
 
399
- let socketIsClosed = false;
400
- const closeSocket = () => {
401
- clearTimeout(keepAliveTimeout);
402
- if (socketIsClosed) {
403
- return;
404
- }
405
- socketIsClosed = true;
406
- rsocket.close();
407
- };
408
405
  // Helps to prevent double close scenarios
409
- rsocket.onClose(() => (socketIsClosed = true));
410
- // We initially request this amount and expect these to arrive eventually
411
- let pendingEventsCount = syncQueueRequestSize;
412
-
413
- const disposeClosedListener = stream.registerListener({
414
- closed: () => {
415
- closeSocket();
416
- disposeClosedListener();
417
- }
418
- });
406
+ rsocket.onClose(() => (rsocket = null));
419
407
 
420
- const socket = await new Promise<Requestable>((resolve, reject) => {
408
+ return await new Promise((resolve, reject) => {
421
409
  let connectionEstablished = false;
410
+ let pendingEventsCount = syncQueueRequestSize;
411
+ let paused = false;
412
+ let res: Requestable | null = null;
413
+
414
+ function requestMore() {
415
+ const delta = syncQueueRequestSize - pendingEventsCount;
416
+ if (!paused && delta > 0) {
417
+ res?.request(delta);
418
+ pendingEventsCount = syncQueueRequestSize;
419
+ }
420
+ }
422
421
 
423
- const res = rsocket.requestStream(
422
+ const events = new EventIterator<Uint8Array>(
423
+ (q) => {
424
+ queue = q;
425
+
426
+ q.on('highWater', () => (paused = true));
427
+ q.on('lowWater', () => {
428
+ paused = false;
429
+ requestMore();
430
+ });
431
+ },
432
+ { highWaterMark: SYNC_QUEUE_REQUEST_HIGH_WATER, lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER }
433
+ )[Symbol.asyncIterator]();
434
+
435
+ res = rsocket!.requestStream(
424
436
  {
425
437
  data: toBuffer(options.data),
426
438
  metadata: toBuffer({
@@ -447,7 +459,7 @@ export abstract class AbstractRemote {
447
459
  }
448
460
  // RSocket will close the RSocket stream automatically
449
461
  // Close the downstream stream as well - this will close the RSocket connection and WebSocket
450
- stream.close();
462
+ abortRequest();
451
463
  // Handles cases where the connection failed e.g. auth error or connection error
452
464
  if (!connectionEstablished) {
453
465
  reject(e);
@@ -457,48 +469,49 @@ export abstract class AbstractRemote {
457
469
  // The connection is active
458
470
  if (!connectionEstablished) {
459
471
  connectionEstablished = true;
460
- resolve(res);
472
+ resolve(events);
461
473
  }
462
474
  const { data } = payload;
475
+
476
+ if (data) {
477
+ queue!.push(data);
478
+ }
479
+
463
480
  // Less events are now pending
464
481
  pendingEventsCount--;
465
- if (!data) {
466
- return;
467
- }
468
482
 
469
- stream.enqueueData(data);
483
+ // Request another event (unless the downstream consumer is paused).
484
+ requestMore();
470
485
  },
471
486
  onComplete: () => {
472
- stream.close();
487
+ abortRequest(); // this will also emit a done event
473
488
  },
474
489
  onExtension: () => {}
475
490
  }
476
491
  );
477
492
  });
493
+ }
478
494
 
479
- const l = stream.registerListener({
480
- lowWater: async () => {
481
- // Request to fill up the queue
482
- const required = syncQueueRequestSize - pendingEventsCount;
483
- if (required > 0) {
484
- socket.request(syncQueueRequestSize - pendingEventsCount);
485
- pendingEventsCount = syncQueueRequestSize;
486
- }
487
- },
488
- closed: () => {
489
- l();
490
- }
491
- });
492
-
493
- return stream;
495
+ /**
496
+ * @returns Whether the HTTP implementation on this platform can receive streamed binary responses. This is true on
497
+ * all platforms except React Native (who would have guessed...), where we must not request BSON responses.
498
+ *
499
+ * @see https://github.com/react-native-community/fetch?tab=readme-ov-file#motivation
500
+ */
501
+ protected get supportsStreamingBinaryResponses(): boolean {
502
+ return true;
494
503
  }
495
504
 
496
505
  /**
497
- * Connects to the sync/stream http endpoint, mapping and emitting each received string line.
506
+ * Posts a `/sync/stream` request, asserts that it completes successfully and returns the streaming response as an
507
+ * async iterator of byte blobs.
508
+ *
509
+ * To cancel the async iterator, use the abort signal from {@link SyncStreamOptions} passed to this method.
498
510
  */
499
- async postStreamRaw<T>(options: SyncStreamOptions, mapLine: (line: string) => T): Promise<DataStream<T>> {
511
+ protected async fetchStreamRaw(
512
+ options: SyncStreamOptions
513
+ ): Promise<{ isBson: boolean; stream: SimpleAsyncIterator<Uint8Array> }> {
500
514
  const { data, path, headers, abortSignal } = options;
501
-
502
515
  const request = await this.buildRequest(path);
503
516
 
504
517
  /**
@@ -510,139 +523,102 @@ export abstract class AbstractRemote {
510
523
  * Aborting the active fetch request while it is being consumed seems to throw
511
524
  * an unhandled exception on the window level.
512
525
  */
513
- if (abortSignal?.aborted) {
514
- throw new AbortOperation('Abort request received before making postStreamRaw request');
526
+ if (abortSignal.aborted) {
527
+ throw new AbortOperation('Abort request received before making fetchStreamRaw request');
515
528
  }
516
529
 
517
530
  const controller = new AbortController();
518
- let requestResolved = false;
519
- abortSignal?.addEventListener('abort', () => {
520
- if (!requestResolved) {
531
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
532
+ abortSignal.addEventListener('abort', () => {
533
+ const reason =
534
+ abortSignal.reason ??
535
+ new AbortOperation('Cancelling network request before it resolves. Abort signal has been received.');
536
+
537
+ if (reader == null) {
521
538
  // Only abort via the abort controller if the request has not resolved yet
522
- controller.abort(
523
- abortSignal.reason ??
524
- new AbortOperation('Cancelling network request before it resolves. Abort signal has been received.')
525
- );
539
+ controller.abort(reason);
540
+ } else {
541
+ reader.cancel(reason).catch(() => {
542
+ // Cancelling the reader might rethrow an exception we would have handled by throwing in next(). So we can
543
+ // ignore it here.
544
+ });
526
545
  }
527
546
  });
528
547
 
529
- const res = await this.fetch(request.url, {
530
- method: 'POST',
531
- headers: { ...headers, ...request.headers },
532
- body: JSON.stringify(data),
533
- signal: controller.signal,
534
- cache: 'no-store',
535
- ...(this.options.fetchOptions ?? {}),
536
- ...options.fetchOptions
537
- }).catch((ex) => {
548
+ let res: Response;
549
+ let responseIsBson = false;
550
+ try {
551
+ const ndJson = 'application/x-ndjson';
552
+ const bson = 'application/vnd.powersync.bson-stream';
553
+
554
+ res = await this.fetch(request.url, {
555
+ method: 'POST',
556
+ headers: {
557
+ ...headers,
558
+ ...request.headers,
559
+ accept: this.supportsStreamingBinaryResponses ? `${bson};q=0.9,${ndJson};q=0.8` : ndJson
560
+ },
561
+ body: JSON.stringify(data),
562
+ signal: controller.signal,
563
+ cache: 'no-store',
564
+ ...(this.options.fetchOptions ?? {}),
565
+ ...options.fetchOptions
566
+ });
567
+
568
+ if (!res.ok || !res.body) {
569
+ const text = await res.text();
570
+ this.logger.error(`Could not POST streaming to ${path} - ${res.status} - ${res.statusText}: ${text}`);
571
+ const error: any = new Error(`HTTP ${res.statusText}: ${text}`);
572
+ error.status = res.status;
573
+ throw error;
574
+ }
575
+
576
+ const contentType = res.headers.get('content-type');
577
+ responseIsBson = contentType == bson;
578
+ } catch (ex) {
538
579
  if (ex.name == 'AbortError') {
539
580
  throw new AbortOperation(`Pending fetch request to ${request.url} has been aborted.`);
540
581
  }
541
582
  throw ex;
542
- });
543
-
544
- if (!res) {
545
- throw new Error('Fetch request was aborted');
546
583
  }
547
584
 
548
- requestResolved = true;
549
-
550
- if (!res.ok || !res.body) {
551
- const text = await res.text();
552
- this.logger.error(`Could not POST streaming to ${path} - ${res.status} - ${res.statusText}: ${text}`);
553
- const error: any = new Error(`HTTP ${res.statusText}: ${text}`);
554
- error.status = res.status;
555
- throw error;
556
- }
557
-
558
- // Create a new stream splitting the response at line endings while also handling cancellations
559
- // by closing the reader.
560
- const reader = res.body.getReader();
561
- let readerReleased = false;
562
- // This will close the network request and read stream
563
- const closeReader = async () => {
564
- try {
565
- readerReleased = true;
566
- await reader.cancel();
567
- } catch (ex) {
568
- // an error will throw if the reader hasn't been used yet
569
- }
570
- reader.releaseLock();
571
- };
572
-
573
-
574
- const stream = new DataStream<T, string>({
575
- logger: this.logger,
576
- mapLine: mapLine,
577
- pressure: {
578
- highWaterMark: 20,
579
- lowWaterMark: 10
580
- }
581
- });
582
-
583
- abortSignal?.addEventListener('abort', () => {
584
- closeReader();
585
- stream.close();
586
- });
587
-
588
- const decoder = this.createTextDecoder();
589
- let buffer = '';
585
+ reader = res.body.getReader();
590
586
 
591
-
592
- const consumeStream = async () => {
593
- while (!stream.closed && !abortSignal?.aborted && !readerReleased) {
594
- const { done, value } = await reader.read();
595
- if (done) {
596
- const remaining = buffer.trim();
597
- if (remaining.length != 0) {
598
- stream.enqueueData(remaining);
599
- }
600
-
601
- stream.close();
602
- await closeReader();
603
- return;
587
+ const stream: SimpleAsyncIterator<Uint8Array> = {
588
+ next: async () => {
589
+ if (controller.signal.aborted) {
590
+ return doneResult;
604
591
  }
605
592
 
606
- const data = decoder.decode(value, { stream: true });
607
- buffer += data;
608
-
609
- const lines = buffer.split('\n');
610
- for (var i = 0; i < lines.length - 1; i++) {
611
- var l = lines[i].trim();
612
- if (l.length > 0) {
613
- stream.enqueueData(l);
593
+ try {
594
+ return await reader.read();
595
+ } catch (ex) {
596
+ if (controller.signal.aborted) {
597
+ // .read() completes with an error if we cancel the reader, which we do to disconnect. So this is just
598
+ // things working as intended, we can return a done event and consider the exception handled.
599
+ return doneResult;
614
600
  }
615
- }
616
601
 
617
- buffer = lines[lines.length - 1];
618
-
619
- // Implement backpressure by waiting for the low water mark to be reached
620
- if (stream.dataQueue.length > stream.highWatermark) {
621
- await new Promise<void>((resolve) => {
622
- const dispose = stream.registerListener({
623
- lowWater: async () => {
624
- resolve();
625
- dispose();
626
- },
627
- closed: () => {
628
- resolve();
629
- dispose();
630
- }
631
- })
632
- })
602
+ throw ex;
633
603
  }
634
604
  }
635
- }
636
-
637
- consumeStream().catch(ex => this.logger.error('Error consuming stream', ex));
605
+ };
638
606
 
639
- const l = stream.registerListener({
640
- closed: () => {
641
- closeReader();
642
- l?.();
643
- }
644
- });
607
+ return { isBson: responseIsBson, stream };
608
+ }
645
609
 
646
- return stream;
610
+ /**
611
+ * Posts a `/sync/stream` request.
612
+ *
613
+ * Depending on the `Content-Type` of the response, this returns strings for sync lines or encoded BSON documents as
614
+ * {@link Uint8Array}s.
615
+ */
616
+ async fetchStream(options: SyncStreamOptions): Promise<SimpleAsyncIterator<Uint8Array | string>> {
617
+ const { isBson, stream } = await this.fetchStreamRaw(options);
618
+ if (isBson) {
619
+ return extractBsonObjects(stream);
620
+ } else {
621
+ return extractJsonLines(stream, this.createTextDecoder());
622
+ }
647
623
  }
648
624
  }