@powersync/common 1.56.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) {
@@ -10916,7 +10975,7 @@ function requireDist () {
10916
10975
 
10917
10976
  var distExports = requireDist();
10918
10977
 
10919
- var version = "1.56.0";
10978
+ var version = "1.57.0";
10920
10979
  var PACKAGE = {
10921
10980
  version: version};
10922
10981
 
@@ -14244,6 +14303,7 @@ const parseQuery = (query, parameters) => {
14244
14303
  };
14245
14304
 
14246
14305
  exports.ATTACHMENT_TABLE = ATTACHMENT_TABLE;
14306
+ exports.ATTACHMENT_TABLE_COLUMNS = ATTACHMENT_TABLE_COLUMNS;
14247
14307
  exports.AbortOperation = AbortOperation;
14248
14308
  exports.AbstractPowerSyncDatabase = AbstractPowerSyncDatabase;
14249
14309
  exports.AbstractPowerSyncDatabaseOpenFactory = AbstractPowerSyncDatabaseOpenFactory;