@powersync/common 0.0.0-dev-20250625140957 → 0.0.0-dev-20250710151329

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,5 +1,5 @@
1
1
  import { Mutex } from 'async-mutex';
2
- import Logger, { ILogger } from 'js-logger';
2
+ import { ILogger } from 'js-logger';
3
3
  import { DBAdapter, QueryResult, Transaction } from '../db/DBAdapter.js';
4
4
  import { SyncStatus } from '../db/crud/SyncStatus.js';
5
5
  import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js';
@@ -84,7 +84,6 @@ export declare const DEFAULT_POWERSYNC_CLOSE_OPTIONS: PowerSyncCloseOptions;
84
84
  export declare const DEFAULT_WATCH_THROTTLE_MS = 30;
85
85
  export declare const DEFAULT_POWERSYNC_DB_OPTIONS: {
86
86
  retryDelayMs: number;
87
- logger: Logger.ILogger;
88
87
  crudUploadThrottleMs: number;
89
88
  };
90
89
  export declare const DEFAULT_CRUD_BATCH_LIMIT = 100;
@@ -123,6 +122,7 @@ export declare abstract class AbstractPowerSyncDatabase extends BaseObserver<Pow
123
122
  protected _schema: Schema;
124
123
  private _database;
125
124
  protected runExclusiveMutex: Mutex;
125
+ logger: ILogger;
126
126
  constructor(options: PowerSyncDatabaseOptionsWithDBAdapter);
127
127
  constructor(options: PowerSyncDatabaseOptionsWithOpenFactory);
128
128
  constructor(options: PowerSyncDatabaseOptionsWithSettings);
@@ -185,7 +185,6 @@ export declare abstract class AbstractPowerSyncDatabase extends BaseObserver<Pow
185
185
  * Cannot be used while connected - this should only be called before {@link AbstractPowerSyncDatabase.connect}.
186
186
  */
187
187
  updateSchema(schema: Schema): Promise<void>;
188
- get logger(): Logger.ILogger;
189
188
  /**
190
189
  * Wait for initialization to complete.
191
190
  * While initializing is automatic, this helps to catch and report initialization errors.
@@ -27,7 +27,6 @@ export const DEFAULT_POWERSYNC_CLOSE_OPTIONS = {
27
27
  export const DEFAULT_WATCH_THROTTLE_MS = 30;
28
28
  export const DEFAULT_POWERSYNC_DB_OPTIONS = {
29
29
  retryDelayMs: 5000,
30
- logger: Logger.get('PowerSyncDatabase'),
31
30
  crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS
32
31
  };
33
32
  export const DEFAULT_CRUD_BATCH_LIMIT = 100;
@@ -70,6 +69,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
70
69
  _schema;
71
70
  _database;
72
71
  runExclusiveMutex;
72
+ logger;
73
73
  constructor(options) {
74
74
  super();
75
75
  this.options = options;
@@ -89,6 +89,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
89
89
  else {
90
90
  throw new Error('The provided `database` option is invalid.');
91
91
  }
92
+ this.logger = options.logger ?? Logger.get(`PowerSyncDatabase[${this._database.name}]`);
92
93
  this.bucketStorageAdapter = this.generateBucketStorageAdapter();
93
94
  this.closed = false;
94
95
  this.currentStatus = new SyncStatus({});
@@ -268,16 +269,13 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
268
269
  schema.validate();
269
270
  }
270
271
  catch (ex) {
271
- this.options.logger?.warn('Schema validation failed. Unexpected behaviour could occur', ex);
272
+ this.logger.warn('Schema validation failed. Unexpected behaviour could occur', ex);
272
273
  }
273
274
  this._schema = schema;
274
275
  await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
275
276
  await this.database.refreshSchema();
276
277
  this.iterateListeners(async (cb) => cb.schemaChanged?.(schema));
277
278
  }
278
- get logger() {
279
- return this.options.logger;
280
- }
281
279
  /**
282
280
  * Wait for initialization to complete.
283
281
  * While initializing is automatic, this helps to catch and report initialization errors.
@@ -617,7 +615,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
617
615
  * @param options Options for configuring watch behavior
618
616
  */
619
617
  watchWithCallback(sql, parameters, handler, options) {
620
- const { onResult, onError = (e) => this.options.logger?.error(e) } = handler ?? {};
618
+ const { onResult, onError = (e) => this.logger.error(e) } = handler ?? {};
621
619
  if (!onResult) {
622
620
  throw new Error('onResult is required');
623
621
  }
@@ -723,7 +721,7 @@ export class AbstractPowerSyncDatabase extends BaseObserver {
723
721
  * @returns A dispose function to stop watching for changes
724
722
  */
725
723
  onChangeWithCallback(handler, options) {
726
- const { onChange, onError = (e) => this.options.logger?.error(e) } = handler ?? {};
724
+ const { onChange, onError = (e) => this.logger.error(e) } = handler ?? {};
727
725
  if (!onChange) {
728
726
  throw new Error('onChange is required');
729
727
  }
@@ -136,7 +136,6 @@ export class ConnectionManager extends BaseObserver {
136
136
  await this.disconnectingPromise;
137
137
  this.logger.debug('Attempting to connect to PowerSync instance');
138
138
  await this.syncStreamImplementation?.connect(appliedOptions);
139
- this.syncStreamImplementation?.triggerCrudUpload();
140
139
  }
141
140
  /**
142
141
  * Close the sync connection.
@@ -90,5 +90,5 @@ export interface BucketStorageAdapter extends BaseObserver<BucketStorageListener
90
90
  /**
91
91
  * Invokes the `powersync_control` function for the sync client.
92
92
  */
93
- control(op: PowerSyncControlCommand, payload: string | ArrayBuffer | null): Promise<string>;
93
+ control(op: PowerSyncControlCommand, payload: string | Uint8Array | null): Promise<string>;
94
94
  }
@@ -56,7 +56,7 @@ export declare class SqliteBucketStorage extends BaseObserver<BucketStorageListe
56
56
  * Set a target checkpoint.
57
57
  */
58
58
  setTargetCheckpoint(checkpoint: Checkpoint): Promise<void>;
59
- control(op: PowerSyncControlCommand, payload: string | ArrayBuffer | null): Promise<string>;
59
+ control(op: PowerSyncControlCommand, payload: string | Uint8Array | ArrayBuffer | null): Promise<string>;
60
60
  hasMigratedSubkeys(): Promise<boolean>;
61
61
  migrateToFixedSubkeys(): Promise<void>;
62
62
  static _subkeyMigrationKey: string;
@@ -1,5 +1,4 @@
1
1
  import type { BSON } from 'bson';
2
- import { Buffer } from 'buffer';
3
2
  import { type fetch } from 'cross-fetch';
4
3
  import Logger, { ILogger } from 'js-logger';
5
4
  import { DataStream } from '../../../utils/DataStream.js';
@@ -133,7 +132,7 @@ export declare abstract class AbstractRemote {
133
132
  * @param bson A BSON encoder and decoder. When set, the data stream will be requested with a BSON payload
134
133
  * (required for compatibility with older sync services).
135
134
  */
136
- socketStreamRaw<T>(options: SocketSyncStreamOptions, map: (buffer: Buffer) => T, bson?: typeof BSON): Promise<DataStream>;
135
+ socketStreamRaw<T>(options: SocketSyncStreamOptions, map: (buffer: Uint8Array) => T, bson?: typeof BSON): Promise<DataStream<T>>;
137
136
  /**
138
137
  * Connects to the sync/stream http endpoint, parsing lines as JSON.
139
138
  */
@@ -10,8 +10,12 @@ const POWERSYNC_JS_VERSION = PACKAGE.version;
10
10
  const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
11
11
  // Keep alive message is sent every period
12
12
  const KEEP_ALIVE_MS = 20_000;
13
- // The ACK must be received in this period
14
- const KEEP_ALIVE_LIFETIME_MS = 30_000;
13
+ // One message of any type must be received in this period.
14
+ const SOCKET_TIMEOUT_MS = 30_000;
15
+ // One keepalive message must be received in this period.
16
+ // If there is a backlog of messages (for example on slow connections), keepalive messages could be delayed
17
+ // significantly. Therefore this is longer than the socket timeout.
18
+ const KEEP_ALIVE_LIFETIME_MS = 90_000;
15
19
  export const DEFAULT_REMOTE_LOGGER = Logger.get('PowerSyncRemote');
16
20
  export var FetchStrategy;
17
21
  (function (FetchStrategy) {
@@ -208,12 +212,25 @@ export class AbstractRemote {
208
212
  // headers with websockets on web. The browser userAgent is however added
209
213
  // automatically as a header.
210
214
  const userAgent = this.getUserAgent();
215
+ let keepAliveTimeout;
216
+ const resetTimeout = () => {
217
+ clearTimeout(keepAliveTimeout);
218
+ keepAliveTimeout = setTimeout(() => {
219
+ this.logger.error(`No data received on WebSocket in ${SOCKET_TIMEOUT_MS}ms, closing connection.`);
220
+ stream.close();
221
+ }, SOCKET_TIMEOUT_MS);
222
+ };
223
+ resetTimeout();
211
224
  const url = this.options.socketUrlTransformer(request.url);
212
225
  const connector = new RSocketConnector({
213
226
  transport: new WebsocketClientTransport({
214
227
  url,
215
228
  wsCreator: (url) => {
216
- return this.createSocket(url);
229
+ const socket = this.createSocket(url);
230
+ socket.addEventListener('message', (event) => {
231
+ resetTimeout();
232
+ });
233
+ return socket;
217
234
  }
218
235
  }),
219
236
  setup: {
@@ -236,16 +253,20 @@ export class AbstractRemote {
236
253
  }
237
254
  catch (ex) {
238
255
  this.logger.error(`Failed to connect WebSocket`, ex);
256
+ clearTimeout(keepAliveTimeout);
239
257
  throw ex;
240
258
  }
259
+ resetTimeout();
241
260
  const stream = new DataStream({
242
261
  logger: this.logger,
243
262
  pressure: {
244
263
  lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER
245
- }
264
+ },
265
+ mapLine: map
246
266
  });
247
267
  let socketIsClosed = false;
248
268
  const closeSocket = () => {
269
+ clearTimeout(keepAliveTimeout);
249
270
  if (socketIsClosed) {
250
271
  return;
251
272
  }
@@ -307,7 +328,7 @@ export class AbstractRemote {
307
328
  if (!data) {
308
329
  return;
309
330
  }
310
- stream.enqueueData(map(data));
331
+ stream.enqueueData(data);
311
332
  },
312
333
  onComplete: () => {
313
334
  stream.close();
@@ -418,7 +439,8 @@ export class AbstractRemote {
418
439
  const decoder = new TextDecoder();
419
440
  let buffer = '';
420
441
  const stream = new DataStream({
421
- logger: this.logger
442
+ logger: this.logger,
443
+ mapLine: mapLine
422
444
  });
423
445
  const l = stream.registerListener({
424
446
  lowWater: async () => {
@@ -429,7 +451,7 @@ export class AbstractRemote {
429
451
  if (done) {
430
452
  const remaining = buffer.trim();
431
453
  if (remaining.length != 0) {
432
- stream.enqueueData(mapLine(remaining));
454
+ stream.enqueueData(remaining);
433
455
  }
434
456
  stream.close();
435
457
  await closeReader();
@@ -441,7 +463,7 @@ export class AbstractRemote {
441
463
  for (var i = 0; i < lines.length - 1; i++) {
442
464
  var l = lines[i].trim();
443
465
  if (l.length > 0) {
444
- stream.enqueueData(mapLine(l));
466
+ stream.enqueueData(l);
445
467
  didCompleteLine = true;
446
468
  }
447
469
  }
@@ -169,9 +169,12 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
169
169
  }
170
170
  async getWriteCheckpoint() {
171
171
  const clientId = await this.options.adapter.getClientId();
172
+ this.logger.debug(`Creating write checkpoint for ${clientId}`);
172
173
  let path = `/write-checkpoint2.json?client_id=${clientId}`;
173
174
  const response = await this.options.remote.get(path);
174
- return response['data']['write_checkpoint'];
175
+ const checkpoint = response['data']['write_checkpoint'];
176
+ this.logger.debug(`Got write checkpoint: ${checkpoint}`);
177
+ return checkpoint;
175
178
  }
176
179
  async _uploadAllCrud() {
177
180
  return this.obtainLock({
@@ -182,17 +185,17 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
182
185
  */
183
186
  let checkedCrudItem;
184
187
  while (true) {
185
- this.updateSyncStatus({
186
- dataFlow: {
187
- uploading: true
188
- }
189
- });
190
188
  try {
191
189
  /**
192
190
  * This is the first item in the FIFO CRUD queue.
193
191
  */
194
192
  const nextCrudItem = await this.options.adapter.nextCrudItem();
195
193
  if (nextCrudItem) {
194
+ this.updateSyncStatus({
195
+ dataFlow: {
196
+ uploading: true
197
+ }
198
+ });
196
199
  if (nextCrudItem.clientId == checkedCrudItem?.clientId) {
197
200
  // This will force a higher log level than exceptions which are caught here.
198
201
  this.logger.warn(`Potentially previously uploaded CRUD entries are still present in the upload queue.
@@ -210,6 +213,7 @@ The next upload iteration will be delayed.`);
210
213
  }
211
214
  else {
212
215
  // Uploading is completed
216
+ this.logger.debug('Upload complete, updating write checkpoint');
213
217
  await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint());
214
218
  break;
215
219
  }
@@ -247,23 +251,17 @@ The next upload iteration will be delayed.`);
247
251
  const controller = new AbortController();
248
252
  this.abortController = controller;
249
253
  this.streamingSyncPromise = this.streamingSync(this.abortController.signal, options);
250
- // Return a promise that resolves when the connection status is updated
254
+ // Return a promise that resolves when the connection status is updated to indicate that we're connected.
251
255
  return new Promise((resolve) => {
252
256
  const disposer = this.registerListener({
253
- statusUpdated: (update) => {
254
- // This is triggered as soon as a connection is read from
255
- if (typeof update.connected == 'undefined') {
256
- // only concern with connection updates
257
- return;
258
- }
259
- if (update.connected == false) {
260
- /**
261
- * This function does not reject if initial connect attempt failed.
262
- * Connected can be false if the connection attempt was aborted or if the initial connection
263
- * attempt failed.
264
- */
257
+ statusChanged: (status) => {
258
+ if (status.dataFlowStatus.downloadError != null) {
265
259
  this.logger.warn('Initial connect attempt did not successfully connect to server');
266
260
  }
261
+ else if (status.connecting) {
262
+ // Still connecting.
263
+ return;
264
+ }
267
265
  disposer();
268
266
  resolve();
269
267
  }
@@ -655,6 +653,7 @@ The next upload iteration will be delayed.`);
655
653
  const adapter = this.options.adapter;
656
654
  const remote = this.options.remote;
657
655
  let receivingLines = null;
656
+ let hadSyncLine = false;
658
657
  const abortController = new AbortController();
659
658
  signal.addEventListener('abort', () => abortController.abort());
660
659
  // Pending sync lines received from the service, as well as local events that trigger a powersync_control
@@ -668,20 +667,39 @@ The next upload iteration will be delayed.`);
668
667
  data: instr.request
669
668
  };
670
669
  if (resolvedOptions.connectionMethod == SyncStreamConnectionMethod.HTTP) {
671
- controlInvocations = await remote.postStreamRaw(syncOptions, (line) => ({
672
- command: PowerSyncControlCommand.PROCESS_TEXT_LINE,
673
- payload: line
674
- }));
670
+ controlInvocations = await remote.postStreamRaw(syncOptions, (line) => {
671
+ if (typeof line == 'string') {
672
+ return {
673
+ command: PowerSyncControlCommand.PROCESS_TEXT_LINE,
674
+ payload: line
675
+ };
676
+ }
677
+ else {
678
+ // Directly enqueued by us
679
+ return line;
680
+ }
681
+ });
675
682
  }
676
683
  else {
677
684
  controlInvocations = await remote.socketStreamRaw({
678
685
  ...syncOptions,
679
686
  fetchStrategy: resolvedOptions.fetchStrategy
680
- }, (buffer) => ({
681
- command: PowerSyncControlCommand.PROCESS_BSON_LINE,
682
- payload: buffer
683
- }));
687
+ }, (payload) => {
688
+ if (payload instanceof Uint8Array) {
689
+ return {
690
+ command: PowerSyncControlCommand.PROCESS_BSON_LINE,
691
+ payload: payload
692
+ };
693
+ }
694
+ else {
695
+ // Directly enqueued by us
696
+ return payload;
697
+ }
698
+ });
684
699
  }
700
+ // The rust client will set connected: true after the first sync line because that's when it gets invoked, but
701
+ // we're already connected here and can report that.
702
+ syncImplementation.updateSyncStatus({ connected: true });
685
703
  try {
686
704
  while (!controlInvocations.closed) {
687
705
  const line = await controlInvocations.read();
@@ -689,6 +707,10 @@ The next upload iteration will be delayed.`);
689
707
  return;
690
708
  }
691
709
  await control(line.command, line.payload);
710
+ if (!hadSyncLine) {
711
+ syncImplementation.triggerCrudUpload();
712
+ hadSyncLine = true;
713
+ }
692
714
  }
693
715
  }
694
716
  finally {
@@ -726,7 +748,7 @@ The next upload iteration will be delayed.`);
726
748
  return {
727
749
  priority: status.priority,
728
750
  hasSynced: status.has_synced ?? undefined,
729
- lastSyncedAt: status?.last_synced_at != null ? new Date(status.last_synced_at) : undefined
751
+ lastSyncedAt: status?.last_synced_at != null ? new Date(status.last_synced_at * 1000) : undefined
730
752
  };
731
753
  }
732
754
  const info = instruction.UpdateSyncStatus.status;
@@ -1,4 +1,4 @@
1
- import { BucketProgress } from 'src/client/sync/stream/core-instruction.js';
1
+ import type { BucketProgress } from '../../client/sync/stream/core-instruction.js';
2
2
  /** @internal */
3
3
  export type InternalProgressInformation = Record<string, BucketProgress>;
4
4
  /**
@@ -1,6 +1,7 @@
1
1
  import { ILogger } from 'js-logger';
2
2
  import { BaseListener, BaseObserver } from './BaseObserver.js';
3
- export type DataStreamOptions = {
3
+ export type DataStreamOptions<ParsedData, SourceData> = {
4
+ mapLine?: (line: SourceData) => ParsedData;
4
5
  /**
5
6
  * Close the stream if any consumer throws an error
6
7
  */
@@ -28,13 +29,15 @@ export declare const DEFAULT_PRESSURE_LIMITS: {
28
29
  * native JS streams or async iterators.
29
30
  * This is handy for environments such as React Native which need polyfills for the above.
30
31
  */
31
- export declare class DataStream<Data extends any = any> extends BaseObserver<DataStreamListener<Data>> {
32
- protected options?: DataStreamOptions | undefined;
33
- dataQueue: Data[];
32
+ export declare class DataStream<ParsedData, SourceData = any> extends BaseObserver<DataStreamListener<ParsedData>> {
33
+ protected options?: DataStreamOptions<ParsedData, SourceData> | undefined;
34
+ dataQueue: SourceData[];
34
35
  protected isClosed: boolean;
35
36
  protected processingPromise: Promise<void> | null;
37
+ protected notifyDataAdded: (() => void) | null;
36
38
  protected logger: ILogger;
37
- constructor(options?: DataStreamOptions | undefined);
39
+ protected mapLine: (line: SourceData) => ParsedData;
40
+ constructor(options?: DataStreamOptions<ParsedData, SourceData> | undefined);
38
41
  get highWatermark(): number;
39
42
  get lowWatermark(): number;
40
43
  get closed(): boolean;
@@ -42,22 +45,18 @@ export declare class DataStream<Data extends any = any> extends BaseObserver<Dat
42
45
  /**
43
46
  * Enqueues data for the consumers to read
44
47
  */
45
- enqueueData(data: Data): void;
48
+ enqueueData(data: SourceData): void;
46
49
  /**
47
50
  * Reads data once from the data stream
48
51
  * @returns a Data payload or Null if the stream closed.
49
52
  */
50
- read(): Promise<Data | null>;
53
+ read(): Promise<ParsedData | null>;
51
54
  /**
52
55
  * Executes a callback for each data item in the stream
53
56
  */
54
- forEach(callback: DataStreamCallback<Data>): () => void;
55
- protected processQueue(): Promise<void>;
56
- /**
57
- * Creates a new data stream which is a map of the original
58
- */
59
- map<ReturnData>(callback: (data: Data) => ReturnData): DataStream<ReturnData>;
57
+ forEach(callback: DataStreamCallback<ParsedData>): () => void;
58
+ protected processQueue(): Promise<void> | undefined;
60
59
  protected hasDataReader(): boolean;
61
60
  protected _processQueue(): Promise<void>;
62
- protected iterateAsyncErrored(cb: (l: BaseListener) => Promise<void>): Promise<void>;
61
+ protected iterateAsyncErrored(cb: (l: Partial<DataStreamListener<ParsedData>>) => Promise<void>): Promise<void>;
63
62
  }
@@ -14,13 +14,16 @@ export class DataStream extends BaseObserver {
14
14
  dataQueue;
15
15
  isClosed;
16
16
  processingPromise;
17
+ notifyDataAdded;
17
18
  logger;
19
+ mapLine;
18
20
  constructor(options) {
19
21
  super();
20
22
  this.options = options;
21
23
  this.processingPromise = null;
22
24
  this.isClosed = false;
23
25
  this.dataQueue = [];
26
+ this.mapLine = options?.mapLine ?? ((line) => line);
24
27
  this.logger = options?.logger ?? Logger.get('DataStream');
25
28
  if (options?.closeOnError) {
26
29
  const l = this.registerListener({
@@ -56,6 +59,7 @@ export class DataStream extends BaseObserver {
56
59
  throw new Error('Cannot enqueue data into closed stream.');
57
60
  }
58
61
  this.dataQueue.push(data);
62
+ this.notifyDataAdded?.();
59
63
  this.processQueue();
60
64
  }
61
65
  /**
@@ -96,10 +100,20 @@ export class DataStream extends BaseObserver {
96
100
  data: callback
97
101
  });
98
102
  }
99
- async processQueue() {
103
+ processQueue() {
100
104
  if (this.processingPromise) {
101
105
  return;
102
106
  }
107
+ const promise = (this.processingPromise = this._processQueue());
108
+ promise.finally(() => {
109
+ return (this.processingPromise = null);
110
+ });
111
+ return promise;
112
+ }
113
+ hasDataReader() {
114
+ return Array.from(this.listeners.values()).some((l) => !!l.data);
115
+ }
116
+ async _processQueue() {
103
117
  /**
104
118
  * Allow listeners to mutate the queue before processing.
105
119
  * This allows for operations such as dropping or compressing data
@@ -108,47 +122,31 @@ export class DataStream extends BaseObserver {
108
122
  if (this.dataQueue.length >= this.highWatermark) {
109
123
  await this.iterateAsyncErrored(async (l) => l.highWater?.());
110
124
  }
111
- return (this.processingPromise = this._processQueue());
112
- }
113
- /**
114
- * Creates a new data stream which is a map of the original
115
- */
116
- map(callback) {
117
- const stream = new DataStream(this.options);
118
- const l = this.registerListener({
119
- data: async (data) => {
120
- stream.enqueueData(callback(data));
121
- },
122
- closed: () => {
123
- stream.close();
124
- l?.();
125
- }
126
- });
127
- return stream;
128
- }
129
- hasDataReader() {
130
- return Array.from(this.listeners.values()).some((l) => !!l.data);
131
- }
132
- async _processQueue() {
133
125
  if (this.isClosed || !this.hasDataReader()) {
134
- Promise.resolve().then(() => (this.processingPromise = null));
135
126
  return;
136
127
  }
137
128
  if (this.dataQueue.length) {
138
129
  const data = this.dataQueue.shift();
139
- await this.iterateAsyncErrored(async (l) => l.data?.(data));
130
+ const mapped = this.mapLine(data);
131
+ await this.iterateAsyncErrored(async (l) => l.data?.(mapped));
140
132
  }
141
133
  if (this.dataQueue.length <= this.lowWatermark) {
142
- await this.iterateAsyncErrored(async (l) => l.lowWater?.());
134
+ const dataAdded = new Promise((resolve) => {
135
+ this.notifyDataAdded = resolve;
136
+ });
137
+ await Promise.race([this.iterateAsyncErrored(async (l) => l.lowWater?.()), dataAdded]);
138
+ this.notifyDataAdded = null;
143
139
  }
144
- this.processingPromise = null;
145
- if (this.dataQueue.length) {
140
+ if (this.dataQueue.length > 0) {
146
141
  // Next tick
147
142
  setTimeout(() => this.processQueue());
148
143
  }
149
144
  }
150
145
  async iterateAsyncErrored(cb) {
151
- for (let i of Array.from(this.listeners.values())) {
146
+ // Important: We need to copy the listeners, as calling a listener could result in adding another
147
+ // listener, resulting in infinite loops.
148
+ const listeners = Array.from(this.listeners.values());
149
+ for (let i of listeners) {
152
150
  try {
153
151
  await cb(i);
154
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powersync/common",
3
- "version": "0.0.0-dev-20250625140957",
3
+ "version": "0.0.0-dev-20250710151329",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"