@powersync/common 1.55.0 → 1.57.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.
package/dist/bundle.cjs CHANGED
@@ -389,6 +389,19 @@ exports.AttachmentState = void 0;
389
389
  AttachmentState[AttachmentState["SYNCED"] = 3] = "SYNCED";
390
390
  AttachmentState[AttachmentState["ARCHIVED"] = 4] = "ARCHIVED"; // Attachment has been orphaned, i.e. the associated record has been deleted
391
391
  })(exports.AttachmentState || (exports.AttachmentState = {}));
392
+ /**
393
+ * @alpha
394
+ */
395
+ const ATTACHMENT_TABLE_COLUMNS = {
396
+ filename: column.text,
397
+ local_uri: column.text,
398
+ timestamp: column.integer,
399
+ size: column.integer,
400
+ media_type: column.text,
401
+ state: column.integer, // Corresponds to AttachmentState
402
+ has_synced: column.integer,
403
+ meta_data: column.text
404
+ };
392
405
  /**
393
406
  * AttachmentTable defines the schema for the attachment queue table.
394
407
  *
@@ -396,16 +409,7 @@ exports.AttachmentState = void 0;
396
409
  */
397
410
  class AttachmentTable extends Table {
398
411
  constructor(options) {
399
- super({
400
- filename: column.text,
401
- local_uri: column.text,
402
- timestamp: column.integer,
403
- size: column.integer,
404
- media_type: column.text,
405
- state: column.integer, // Corresponds to AttachmentState
406
- has_synced: column.integer,
407
- meta_data: column.text
408
- }, {
412
+ super(ATTACHMENT_TABLE_COLUMNS, {
409
413
  ...options,
410
414
  viewName: options?.viewName ?? ATTACHMENT_TABLE,
411
415
  localOnly: true,
@@ -937,31 +941,51 @@ class SyncingService {
937
941
  }
938
942
  /**
939
943
  * Processes attachments based on their state (upload, download, or delete).
940
- * All updates are saved in a single batch after processing.
944
+ *
945
+ * Each attachment's I/O runs outside the attachment-service mutex, and the row's
946
+ * state transition is persisted immediately after it completes. This keeps the
947
+ * mutex available to concurrent `saveFile` / `deleteFile` / watched-attachment
948
+ * processing while a batch is in flight, and means consumer queries against the
949
+ * attachments queue see incremental progress instead of one atomic commit at the
950
+ * end of the batch.
941
951
  *
942
952
  * @param attachments - Array of attachment records to process
943
- * @param context - Attachment context for database operations
944
- * @returns Promise that resolves when all attachments have been processed and saved
945
- */
946
- async processAttachments(attachments, context) {
947
- const updatedAttachments = [];
953
+ * @param options - Optional controls. Pass `signal` (an `AbortSignal`) to interrupt
954
+ * the batch: it is checked between attachments and, once aborted, the
955
+ * loop exits early — letting `stopSync` stop a running batch within
956
+ * one attachment's processing time.
957
+ */
958
+ async processAttachments(attachments, options) {
959
+ const signal = options?.signal;
960
+ this.logger.info(`Starting processAttachments with ${attachments.length} attachments`);
948
961
  for (const attachment of attachments) {
949
- switch (attachment.state) {
950
- case exports.AttachmentState.QUEUED_UPLOAD:
951
- const uploaded = await this.uploadAttachment(attachment);
952
- updatedAttachments.push(uploaded);
953
- break;
954
- case exports.AttachmentState.QUEUED_DOWNLOAD:
955
- const downloaded = await this.downloadAttachment(attachment);
956
- updatedAttachments.push(downloaded);
957
- break;
958
- case exports.AttachmentState.QUEUED_DELETE:
959
- const deleted = await this.deleteAttachment(attachment, context);
960
- updatedAttachments.push(deleted);
961
- break;
962
+ if (signal?.aborted) {
963
+ this.logger.info('Sync cancelled; stopping iteration early');
964
+ return;
965
+ }
966
+ try {
967
+ let updated;
968
+ switch (attachment.state) {
969
+ case exports.AttachmentState.QUEUED_UPLOAD:
970
+ updated = await this.uploadAttachment(attachment);
971
+ break;
972
+ case exports.AttachmentState.QUEUED_DOWNLOAD:
973
+ updated = await this.downloadAttachment(attachment);
974
+ break;
975
+ case exports.AttachmentState.QUEUED_DELETE:
976
+ // `deleteAttachment` needs a context (it removes the row in a
977
+ // transaction); briefly re-acquire the mutex for just this row.
978
+ updated = await this.attachmentService.withContext((ctx) => this.deleteAttachment(attachment, ctx));
979
+ break;
980
+ default:
981
+ continue;
982
+ }
983
+ await this.attachmentService.withContext((ctx) => ctx.saveAttachments([updated]));
984
+ }
985
+ catch (error) {
986
+ this.logger.warn(`Error during sync for ${attachment.id}`, error);
962
987
  }
963
988
  }
964
- await context.saveAttachments(updatedAttachments);
965
989
  }
966
990
  /**
967
991
  * Uploads an attachment from local storage to remote storage.
@@ -1132,6 +1156,19 @@ class AttachmentQueue {
1132
1156
  statusListenerDispose;
1133
1157
  watchActiveAttachments;
1134
1158
  watchAttachmentsAbortController;
1159
+ /**
1160
+ * Serializes concurrent `syncStorage()` triggers (periodic timer, watch-onDiff,
1161
+ * status-changed). Held across the whole batch, but only contended by other
1162
+ * sync triggers — foreground `saveFile` / `deleteFile` / watched-attachment
1163
+ * processing don't take this lock and proceed in parallel via the
1164
+ * `AttachmentService` mutex, which is acquired only briefly per row.
1165
+ */
1166
+ syncLoopMutex = new Mutex();
1167
+ /**
1168
+ * Aborted by `stopSync()` to interrupt an in-flight batch within one
1169
+ * attachment's processing time. Polled between rows by `SyncingService`.
1170
+ */
1171
+ syncAbortController;
1135
1172
  /**
1136
1173
  * Creates a new AttachmentQueue instance.
1137
1174
  *
@@ -1171,6 +1208,7 @@ class AttachmentQueue {
1171
1208
  */
1172
1209
  async startSync() {
1173
1210
  await this.stopSync();
1211
+ this.syncAbortController = new AbortController();
1174
1212
  this.watchActiveAttachments = this.attachmentService.watchActiveAttachments({
1175
1213
  throttleMs: this.syncThrottleDuration
1176
1214
  });
@@ -1285,23 +1323,44 @@ class AttachmentQueue {
1285
1323
  *
1286
1324
  * This is called automatically at regular intervals when sync is started,
1287
1325
  * but can also be called manually to trigger an immediate sync.
1326
+ *
1327
+ * Concurrent invocations are serialized via `syncLoopMutex`.
1288
1328
  */
1289
1329
  async syncStorage() {
1290
- await this.attachmentService.withContext(async (ctx) => {
1291
- const activeAttachments = await ctx.getActiveAttachments();
1292
- await this.localStorage.initialize();
1293
- await this.syncingService.processAttachments(activeAttachments, ctx);
1294
- await this.syncingService.deleteArchivedAttachments(ctx);
1295
- });
1330
+ const signal = this.syncAbortController?.signal;
1331
+ if (signal?.aborted)
1332
+ return;
1333
+ try {
1334
+ await this.syncLoopMutex.runExclusive(async () => {
1335
+ const activeAttachments = await this.attachmentService.withContext((ctx) => ctx.getActiveAttachments());
1336
+ await this.localStorage.initialize();
1337
+ await this.syncingService.processAttachments(activeAttachments, { signal });
1338
+ if (signal?.aborted)
1339
+ return;
1340
+ await this.attachmentService.withContext((ctx) => this.syncingService.deleteArchivedAttachments(ctx));
1341
+ }, signal);
1342
+ }
1343
+ catch (error) {
1344
+ // A queued batch's acquire rejects when `stopSync` aborts — expected, not an error.
1345
+ if (signal?.aborted)
1346
+ return;
1347
+ throw error;
1348
+ }
1296
1349
  }
1297
1350
  /**
1298
1351
  * Stops the attachment synchronization process.
1299
1352
  *
1300
- * Clears the periodic sync timer and closes all active attachment watchers.
1353
+ * Clears the periodic sync timer, closes all active attachment watchers, and
1354
+ * aborts any in-flight `syncStorage()` call so it exits within one
1355
+ * attachment's processing time instead of running the batch to completion.
1301
1356
  */
1302
1357
  async stopSync() {
1303
1358
  clearInterval(this.periodicSyncTimer);
1304
1359
  this.periodicSyncTimer = undefined;
1360
+ if (this.syncAbortController) {
1361
+ this.syncAbortController.abort();
1362
+ this.syncAbortController = undefined;
1363
+ }
1305
1364
  if (this.watchActiveAttachments)
1306
1365
  await this.watchActiveAttachments.close();
1307
1366
  if (this.watchAttachmentsAbortController) {
@@ -2137,11 +2196,7 @@ class SyncStatus {
2137
2196
  */
2138
2197
  const replacer = (_, value) => {
2139
2198
  if (value instanceof Error) {
2140
- return {
2141
- name: value.name,
2142
- message: value.message,
2143
- stack: value.stack
2144
- };
2199
+ return this.serializeError(value);
2145
2200
  }
2146
2201
  return value;
2147
2202
  };
@@ -2184,11 +2239,18 @@ class SyncStatus {
2184
2239
  if (typeof error == 'undefined') {
2185
2240
  return undefined;
2186
2241
  }
2187
- return {
2242
+ const serialized = {
2188
2243
  name: error.name,
2189
2244
  message: error.message,
2190
2245
  stack: error.stack
2191
2246
  };
2247
+ // `Error.cause` can be any value (the spec types it as unknown). Preserve it
2248
+ // so consumers reading uploadError/downloadError keep the failure context.
2249
+ // Recurse for Error causes so the whole chain is flattened the same way.
2250
+ if (typeof error.cause != 'undefined') {
2251
+ serialized.cause = error.cause instanceof Error ? this.serializeError(error.cause) : error.cause;
2252
+ }
2253
+ return serialized;
2192
2254
  }
2193
2255
  static comparePriorities(a, b) {
2194
2256
  return b.priority - a.priority; // Reverse because higher priorities have lower numbers
@@ -10913,7 +10975,7 @@ function requireDist () {
10913
10975
 
10914
10976
  var distExports = requireDist();
10915
10977
 
10916
- var version = "1.55.0";
10978
+ var version = "1.57.0";
10917
10979
  var PACKAGE = {
10918
10980
  version: version};
10919
10981
 
@@ -13044,7 +13106,7 @@ class AbstractPowerSyncDatabase extends BaseObserver {
13044
13106
  this.logger.warn('Schema validation failed. Unexpected behaviour could occur', ex);
13045
13107
  }
13046
13108
  this._schema = schema;
13047
- await this.database.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]);
13109
+ await this.database.writeTransaction((tx) => tx.execute('SELECT powersync_replace_schema(?)', [JSON.stringify(this.schema.toJSON())]));
13048
13110
  await this.database.refreshSchema();
13049
13111
  this.iterateListeners(async (cb) => cb.schemaChanged?.(schema));
13050
13112
  }
@@ -14241,6 +14303,7 @@ const parseQuery = (query, parameters) => {
14241
14303
  };
14242
14304
 
14243
14305
  exports.ATTACHMENT_TABLE = ATTACHMENT_TABLE;
14306
+ exports.ATTACHMENT_TABLE_COLUMNS = ATTACHMENT_TABLE_COLUMNS;
14244
14307
  exports.AbortOperation = AbortOperation;
14245
14308
  exports.AbstractPowerSyncDatabase = AbstractPowerSyncDatabase;
14246
14309
  exports.AbstractPowerSyncDatabaseOpenFactory = AbstractPowerSyncDatabaseOpenFactory;