@powersync/common 1.56.0 → 1.57.1

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,6 +1,7 @@
1
1
  import { AbstractPowerSyncDatabase } from '../client/AbstractPowerSyncDatabase.js';
2
2
  import { DEFAULT_WATCH_THROTTLE_MS } from '../client/watched/WatchedQuery.js';
3
3
  import { DifferentialWatchedQuery } from '../client/watched/processors/DifferentialQueryProcessor.js';
4
+ import { Mutex } from '../utils/mutex.js';
4
5
  import { Transaction } from '../db/DBAdapter.js';
5
6
  import { ILogger } from '../utils/Logger.js';
6
7
  import { AttachmentContext } from './AttachmentContext.js';
@@ -135,6 +136,21 @@ export class AttachmentQueue {
135
136
 
136
137
  private watchAttachmentsAbortController!: AbortController;
137
138
 
139
+ /**
140
+ * Serializes concurrent `syncStorage()` triggers (periodic timer, watch-onDiff,
141
+ * status-changed). Held across the whole batch, but only contended by other
142
+ * sync triggers — foreground `saveFile` / `deleteFile` / watched-attachment
143
+ * processing don't take this lock and proceed in parallel via the
144
+ * `AttachmentService` mutex, which is acquired only briefly per row.
145
+ */
146
+ private syncLoopMutex = new Mutex();
147
+
148
+ /**
149
+ * Aborted by `stopSync()` to interrupt an in-flight batch within one
150
+ * attachment's processing time. Polled between rows by `SyncingService`.
151
+ */
152
+ private syncAbortController?: AbortController;
153
+
138
154
  /**
139
155
  * Creates a new AttachmentQueue instance.
140
156
  *
@@ -195,6 +211,8 @@ export class AttachmentQueue {
195
211
  async startSync(): Promise<void> {
196
212
  await this.stopSync();
197
213
 
214
+ this.syncAbortController = new AbortController();
215
+
198
216
  this.watchActiveAttachments = this.attachmentService.watchActiveAttachments({
199
217
  throttleMs: this.syncThrottleDuration
200
218
  });
@@ -325,24 +343,45 @@ export class AttachmentQueue {
325
343
  *
326
344
  * This is called automatically at regular intervals when sync is started,
327
345
  * but can also be called manually to trigger an immediate sync.
346
+ *
347
+ * Concurrent invocations are serialized via `syncLoopMutex`.
328
348
  */
329
349
  async syncStorage(): Promise<void> {
330
- await this.attachmentService.withContext(async (ctx) => {
331
- const activeAttachments = await ctx.getActiveAttachments();
332
- await this.localStorage.initialize();
333
- await this.syncingService.processAttachments(activeAttachments, ctx);
334
- await this.syncingService.deleteArchivedAttachments(ctx);
335
- });
350
+ const signal = this.syncAbortController?.signal;
351
+ if (signal?.aborted) return;
352
+
353
+ try {
354
+ await this.syncLoopMutex.runExclusive(async () => {
355
+ const activeAttachments = await this.attachmentService.withContext((ctx) => ctx.getActiveAttachments());
356
+ await this.localStorage.initialize();
357
+
358
+ await this.syncingService.processAttachments(activeAttachments, { signal });
359
+
360
+ if (signal?.aborted) return;
361
+
362
+ await this.attachmentService.withContext((ctx) => this.syncingService.deleteArchivedAttachments(ctx));
363
+ }, signal);
364
+ } catch (error) {
365
+ // A queued batch's acquire rejects when `stopSync` aborts — expected, not an error.
366
+ if (signal?.aborted) return;
367
+ throw error;
368
+ }
336
369
  }
337
370
 
338
371
  /**
339
372
  * Stops the attachment synchronization process.
340
373
  *
341
- * Clears the periodic sync timer and closes all active attachment watchers.
374
+ * Clears the periodic sync timer, closes all active attachment watchers, and
375
+ * aborts any in-flight `syncStorage()` call so it exits within one
376
+ * attachment's processing time instead of running the batch to completion.
342
377
  */
343
378
  async stopSync(): Promise<void> {
344
379
  clearInterval(this.periodicSyncTimer);
345
380
  this.periodicSyncTimer = undefined;
381
+ if (this.syncAbortController) {
382
+ this.syncAbortController.abort();
383
+ this.syncAbortController = undefined;
384
+ }
346
385
  if (this.watchActiveAttachments) await this.watchActiveAttachments.close();
347
386
  if (this.watchAttachmentsAbortController) {
348
387
  this.watchAttachmentsAbortController.abort();
@@ -1,5 +1,5 @@
1
1
  import { column } from '../db/schema/Column.js';
2
- import { Table } from '../db/schema/Table.js';
2
+ import { RowType, Table } from '../db/schema/Table.js';
3
3
  import { TableV2Options } from '../db/schema/Table.js';
4
4
 
5
5
  /**
@@ -66,30 +66,39 @@ export enum AttachmentState {
66
66
  */
67
67
  export interface AttachmentTableOptions extends Omit<TableV2Options, 'name' | 'columns'> {}
68
68
 
69
+ /**
70
+ * @alpha
71
+ */
72
+ export const ATTACHMENT_TABLE_COLUMNS = {
73
+ filename: column.text,
74
+ local_uri: column.text,
75
+ timestamp: column.integer,
76
+ size: column.integer,
77
+ media_type: column.text,
78
+ state: column.integer, // Corresponds to AttachmentState
79
+ has_synced: column.integer,
80
+ meta_data: column.text
81
+ };
82
+
69
83
  /**
70
84
  * AttachmentTable defines the schema for the attachment queue table.
71
85
  *
72
86
  * @alpha
73
87
  */
74
- export class AttachmentTable extends Table {
88
+ export class AttachmentTable extends Table<typeof ATTACHMENT_TABLE_COLUMNS> {
75
89
  constructor(options?: AttachmentTableOptions) {
76
- super(
77
- {
78
- filename: column.text,
79
- local_uri: column.text,
80
- timestamp: column.integer,
81
- size: column.integer,
82
- media_type: column.text,
83
- state: column.integer, // Corresponds to AttachmentState
84
- has_synced: column.integer,
85
- meta_data: column.text
86
- },
87
- {
88
- ...options,
89
- viewName: options?.viewName ?? ATTACHMENT_TABLE,
90
- localOnly: true,
91
- insertOnly: false
92
- }
93
- );
90
+ super(ATTACHMENT_TABLE_COLUMNS, {
91
+ ...options,
92
+ viewName: options?.viewName ?? ATTACHMENT_TABLE,
93
+ localOnly: true,
94
+ insertOnly: false
95
+ });
94
96
  }
95
97
  }
98
+
99
+ /**
100
+ * AttachmentTableRecord represents the row type of the attachment table.
101
+ *
102
+ * @alpha
103
+ */
104
+ export type AttachmentTableRecord = RowType<AttachmentTable>;
@@ -35,35 +35,58 @@ export class SyncingService {
35
35
 
36
36
  /**
37
37
  * Processes attachments based on their state (upload, download, or delete).
38
- * All updates are saved in a single batch after processing.
38
+ *
39
+ * Each attachment's I/O runs outside the attachment-service mutex, and the row's
40
+ * state transition is persisted immediately after it completes. This keeps the
41
+ * mutex available to concurrent `saveFile` / `deleteFile` / watched-attachment
42
+ * processing while a batch is in flight, and means consumer queries against the
43
+ * attachments queue see incremental progress instead of one atomic commit at the
44
+ * end of the batch.
39
45
  *
40
46
  * @param attachments - Array of attachment records to process
41
- * @param context - Attachment context for database operations
42
- * @returns Promise that resolves when all attachments have been processed and saved
47
+ * @param options - Optional controls. Pass `signal` (an `AbortSignal`) to interrupt
48
+ * the batch: it is checked between attachments and, once aborted, the
49
+ * loop exits early — letting `stopSync` stop a running batch within
50
+ * one attachment's processing time.
43
51
  */
44
- async processAttachments(attachments: AttachmentRecord[], context: AttachmentContext): Promise<void> {
45
- const updatedAttachments: AttachmentRecord[] = [];
52
+ async processAttachments(
53
+ attachments: AttachmentRecord[],
54
+ options?: {
55
+ signal?: AbortSignal;
56
+ }
57
+ ): Promise<void> {
58
+ const signal = options?.signal;
59
+ this.logger.info(`Starting processAttachments with ${attachments.length} attachments`);
60
+
46
61
  for (const attachment of attachments) {
47
- switch (attachment.state) {
48
- case AttachmentState.QUEUED_UPLOAD:
49
- const uploaded = await this.uploadAttachment(attachment);
50
- updatedAttachments.push(uploaded);
51
- break;
52
- case AttachmentState.QUEUED_DOWNLOAD:
53
- const downloaded = await this.downloadAttachment(attachment);
54
- updatedAttachments.push(downloaded);
55
- break;
56
- case AttachmentState.QUEUED_DELETE:
57
- const deleted = await this.deleteAttachment(attachment, context);
58
- updatedAttachments.push(deleted);
59
- break;
60
-
61
- default:
62
- break;
62
+ if (signal?.aborted) {
63
+ this.logger.info('Sync cancelled; stopping iteration early');
64
+ return;
63
65
  }
64
- }
65
66
 
66
- await context.saveAttachments(updatedAttachments);
67
+ try {
68
+ let updated: AttachmentRecord;
69
+ switch (attachment.state) {
70
+ case AttachmentState.QUEUED_UPLOAD:
71
+ updated = await this.uploadAttachment(attachment);
72
+ break;
73
+ case AttachmentState.QUEUED_DOWNLOAD:
74
+ updated = await this.downloadAttachment(attachment);
75
+ break;
76
+ case AttachmentState.QUEUED_DELETE:
77
+ // `deleteAttachment` needs a context (it removes the row in a
78
+ // transaction); briefly re-acquire the mutex for just this row.
79
+ updated = await this.attachmentService.withContext((ctx) => this.deleteAttachment(attachment, ctx));
80
+ break;
81
+ default:
82
+ continue;
83
+ }
84
+
85
+ await this.attachmentService.withContext((ctx) => ctx.saveAttachments([updated]));
86
+ } catch (error) {
87
+ this.logger.warn(`Error during sync for ${attachment.id}`, error);
88
+ }
89
+ }
67
90
  }
68
91
 
69
92
  /**
@@ -419,7 +419,12 @@ export class TriggerManagerImpl implements TriggerManager {
419
419
  // destination table consistent.
420
420
  await this.db.writeTransaction(async (tx) => {
421
421
  const callbackResult = await options.onChange({
422
- ...tx,
422
+ execute: (query, params) => tx.execute(query, params),
423
+ executeBatch: (query, params) => tx.executeBatch(query, params),
424
+ executeRaw: (query, params) => tx.executeRaw(query, params),
425
+ get: (query, params) => tx.get(query, params),
426
+ getAll: (query, params) => tx.getAll(query, params),
427
+ getOptional: (query, params) => tx.getOptional(query, params),
423
428
  destinationTable: destination,
424
429
  withDiff: async <T>(query: string, params: any, options?: WithDiffOptions) => {
425
430
  // Wrap the query to expose the destination table
@@ -138,7 +138,7 @@ export class SyncStatus {
138
138
  }
139
139
 
140
140
  /**
141
- * All sync streams currently being tracked in teh database.
141
+ * All sync streams currently being tracked in the database.
142
142
  *
143
143
  * This returns null when the database is currently being opened and we don't have reliable information about all
144
144
  * included streams yet.