@powersync/common 1.46.0 → 1.47.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 (55) hide show
  1. package/README.md +5 -1
  2. package/dist/bundle.cjs +1266 -360
  3. package/dist/bundle.cjs.map +1 -1
  4. package/dist/bundle.mjs +1259 -361
  5. package/dist/bundle.mjs.map +1 -1
  6. package/dist/bundle.node.cjs +1266 -360
  7. package/dist/bundle.node.cjs.map +1 -1
  8. package/dist/bundle.node.mjs +1259 -361
  9. package/dist/bundle.node.mjs.map +1 -1
  10. package/dist/index.d.cts +530 -29
  11. package/lib/attachments/AttachmentContext.d.ts +86 -0
  12. package/lib/attachments/AttachmentContext.js +229 -0
  13. package/lib/attachments/AttachmentContext.js.map +1 -0
  14. package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
  15. package/lib/attachments/AttachmentErrorHandler.js +2 -0
  16. package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
  17. package/lib/attachments/AttachmentQueue.d.ts +149 -0
  18. package/lib/attachments/AttachmentQueue.js +362 -0
  19. package/lib/attachments/AttachmentQueue.js.map +1 -0
  20. package/lib/attachments/AttachmentService.d.ts +29 -0
  21. package/lib/attachments/AttachmentService.js +56 -0
  22. package/lib/attachments/AttachmentService.js.map +1 -0
  23. package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
  24. package/lib/attachments/LocalStorageAdapter.js +6 -0
  25. package/lib/attachments/LocalStorageAdapter.js.map +1 -0
  26. package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
  27. package/lib/attachments/RemoteStorageAdapter.js +2 -0
  28. package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
  29. package/lib/attachments/Schema.d.ts +50 -0
  30. package/lib/attachments/Schema.js +62 -0
  31. package/lib/attachments/Schema.js.map +1 -0
  32. package/lib/attachments/SyncingService.d.ts +62 -0
  33. package/lib/attachments/SyncingService.js +168 -0
  34. package/lib/attachments/SyncingService.js.map +1 -0
  35. package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
  36. package/lib/attachments/WatchedAttachmentItem.js +2 -0
  37. package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
  38. package/lib/index.d.ts +10 -0
  39. package/lib/index.js +10 -0
  40. package/lib/index.js.map +1 -1
  41. package/lib/utils/mutex.d.ts +1 -1
  42. package/lib/utils/mutex.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/attachments/AttachmentContext.ts +279 -0
  45. package/src/attachments/AttachmentErrorHandler.ts +34 -0
  46. package/src/attachments/AttachmentQueue.ts +472 -0
  47. package/src/attachments/AttachmentService.ts +62 -0
  48. package/src/attachments/LocalStorageAdapter.ts +72 -0
  49. package/src/attachments/README.md +718 -0
  50. package/src/attachments/RemoteStorageAdapter.ts +30 -0
  51. package/src/attachments/Schema.ts +87 -0
  52. package/src/attachments/SyncingService.ts +193 -0
  53. package/src/attachments/WatchedAttachmentItem.ts +19 -0
  54. package/src/index.ts +11 -0
  55. package/src/utils/mutex.ts +1 -1
@@ -0,0 +1,472 @@
1
+ import { AbstractPowerSyncDatabase } from '../client/AbstractPowerSyncDatabase.js';
2
+ import { DEFAULT_WATCH_THROTTLE_MS } from '../client/watched/WatchedQuery.js';
3
+ import { DifferentialWatchedQuery } from '../client/watched/processors/DifferentialQueryProcessor.js';
4
+ import { ILogger } from '../utils/Logger.js';
5
+ import { Transaction } from '../db/DBAdapter.js';
6
+ import { AttachmentData, LocalStorageAdapter } from './LocalStorageAdapter.js';
7
+ import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
8
+ import { ATTACHMENT_TABLE, AttachmentRecord, AttachmentState } from './Schema.js';
9
+ import { SyncingService } from './SyncingService.js';
10
+ import { WatchedAttachmentItem } from './WatchedAttachmentItem.js';
11
+ import { AttachmentService } from './AttachmentService.js';
12
+ import { AttachmentErrorHandler } from './AttachmentErrorHandler.js';
13
+
14
+ /**
15
+ * AttachmentQueue manages the lifecycle and synchronization of attachments
16
+ * between local and remote storage.
17
+ * Provides automatic synchronization, upload/download queuing, attachment monitoring,
18
+ * verification and repair of local files, and cleanup of archived attachments.
19
+ *
20
+ * @experimental
21
+ * @alpha This is currently experimental and may change without a major version bump.
22
+ */
23
+ export class AttachmentQueue {
24
+ /** Timer for periodic synchronization operations */
25
+ private periodicSyncTimer?: ReturnType<typeof setInterval>;
26
+
27
+ /** Service for synchronizing attachments between local and remote storage */
28
+ private readonly syncingService: SyncingService;
29
+
30
+ /** Adapter for local file storage operations */
31
+ readonly localStorage: LocalStorageAdapter;
32
+
33
+ /** Adapter for remote file storage operations */
34
+ readonly remoteStorage: RemoteStorageAdapter;
35
+
36
+ /**
37
+ * Callback function to watch for changes in attachment references in your data model.
38
+ *
39
+ * This should be implemented by the user of AttachmentQueue to monitor changes in your application's
40
+ * data that reference attachments. When attachments are added, removed, or modified,
41
+ * this callback should trigger the onUpdate function with the current set of attachments.
42
+ */
43
+ private readonly watchAttachments: (
44
+ onUpdate: (attachment: WatchedAttachmentItem[]) => Promise<void>,
45
+ signal: AbortSignal
46
+ ) => void;
47
+
48
+ /** Name of the database table storing attachment records */
49
+ readonly tableName: string;
50
+
51
+ /** Logger instance for diagnostic information */
52
+ readonly logger: ILogger;
53
+
54
+ /** Interval in milliseconds between periodic sync operations. Default: 30000 (30 seconds) */
55
+ readonly syncIntervalMs: number = 30 * 1000;
56
+
57
+ /** Duration in milliseconds to throttle sync operations */
58
+ readonly syncThrottleDuration: number;
59
+
60
+ /** Whether to automatically download remote attachments. Default: true */
61
+ readonly downloadAttachments: boolean = true;
62
+
63
+ /** Maximum number of archived attachments to keep before cleanup. Default: 100 */
64
+ readonly archivedCacheLimit: number;
65
+
66
+ /** Service for managing attachment-related database operations */
67
+ private readonly attachmentService: AttachmentService;
68
+
69
+ /** PowerSync database instance */
70
+ private readonly db: AbstractPowerSyncDatabase;
71
+
72
+ /** Cleanup function for status change listener */
73
+ private statusListenerDispose?: () => void;
74
+
75
+ private watchActiveAttachments: DifferentialWatchedQuery<AttachmentRecord>;
76
+
77
+ private watchAttachmentsAbortController: AbortController;
78
+
79
+ /**
80
+ * Creates a new AttachmentQueue instance.
81
+ *
82
+ * @param options - Configuration options
83
+ * @param options.db - PowerSync database instance
84
+ * @param options.remoteStorage - Remote storage adapter for upload/download operations
85
+ * @param options.localStorage - Local storage adapter for file persistence
86
+ * @param options.watchAttachments - Callback for monitoring attachment changes in your data model
87
+ * @param options.tableName - Name of the table to store attachment records. Default: 'ps_attachment_queue'
88
+ * @param options.logger - Logger instance. Defaults to db.logger
89
+ * @param options.syncIntervalMs - Interval between automatic syncs in milliseconds. Default: 30000
90
+ * @param options.syncThrottleDuration - Throttle duration for sync operations in milliseconds. Default: 1000
91
+ * @param options.downloadAttachments - Whether to automatically download remote attachments. Default: true
92
+ * @param options.archivedCacheLimit - Maximum archived attachments before cleanup. Default: 100
93
+ */
94
+ constructor({
95
+ db,
96
+ localStorage,
97
+ remoteStorage,
98
+ watchAttachments,
99
+ logger,
100
+ tableName = ATTACHMENT_TABLE,
101
+ syncIntervalMs = 30 * 1000,
102
+ syncThrottleDuration = DEFAULT_WATCH_THROTTLE_MS,
103
+ downloadAttachments = true,
104
+ archivedCacheLimit = 100,
105
+ errorHandler
106
+ }: {
107
+ db: AbstractPowerSyncDatabase;
108
+ remoteStorage: RemoteStorageAdapter;
109
+ localStorage: LocalStorageAdapter;
110
+ watchAttachments: (onUpdate: (attachment: WatchedAttachmentItem[]) => Promise<void>, signal: AbortSignal) => void;
111
+ tableName?: string;
112
+ logger?: ILogger;
113
+ syncIntervalMs?: number;
114
+ syncThrottleDuration?: number;
115
+ downloadAttachments?: boolean;
116
+ archivedCacheLimit?: number;
117
+ errorHandler?: AttachmentErrorHandler;
118
+ }) {
119
+ this.db = db;
120
+ this.remoteStorage = remoteStorage;
121
+ this.localStorage = localStorage;
122
+ this.watchAttachments = watchAttachments;
123
+ this.tableName = tableName;
124
+ this.syncIntervalMs = syncIntervalMs;
125
+ this.syncThrottleDuration = syncThrottleDuration;
126
+ this.archivedCacheLimit = archivedCacheLimit;
127
+ this.downloadAttachments = downloadAttachments;
128
+ this.logger = logger ?? db.logger;
129
+ this.attachmentService = new AttachmentService(db, this.logger, tableName, archivedCacheLimit);
130
+ this.syncingService = new SyncingService(
131
+ this.attachmentService,
132
+ localStorage,
133
+ remoteStorage,
134
+ this.logger,
135
+ errorHandler
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Generates a new attachment ID using a SQLite UUID function.
141
+ *
142
+ * @returns Promise resolving to the new attachment ID
143
+ */
144
+ async generateAttachmentId(): Promise<string> {
145
+ return this.db.get<{ id: string }>('SELECT uuid() as id').then((row) => row.id);
146
+ }
147
+
148
+ /**
149
+ * Starts the attachment synchronization process.
150
+ *
151
+ * This method:
152
+ * - Stops any existing sync operations
153
+ * - Sets up periodic synchronization based on syncIntervalMs
154
+ * - Registers listeners for active attachment changes
155
+ * - Processes watched attachments to queue uploads/downloads
156
+ * - Handles state transitions for archived and new attachments
157
+ */
158
+ async startSync(): Promise<void> {
159
+ await this.stopSync();
160
+
161
+ this.watchActiveAttachments = this.attachmentService.watchActiveAttachments({
162
+ throttleMs: this.syncThrottleDuration
163
+ });
164
+
165
+ // immediately invoke the sync storage to initialize local storage
166
+ await this.localStorage.initialize();
167
+
168
+ await this.verifyAttachments();
169
+
170
+ // Sync storage periodically
171
+ this.periodicSyncTimer = setInterval(async () => {
172
+ await this.syncStorage();
173
+ }, this.syncIntervalMs);
174
+
175
+ // Sync storage when there is a change in active attachments
176
+ this.watchActiveAttachments.registerListener({
177
+ onDiff: async () => {
178
+ await this.syncStorage();
179
+ }
180
+ });
181
+
182
+ this.statusListenerDispose = this.db.registerListener({
183
+ statusChanged: (status) => {
184
+ if (status.connected) {
185
+ // Device came online, process attachments immediately
186
+ this.syncStorage().catch((error) => {
187
+ this.logger.error('Error syncing storage on connection:', error);
188
+ });
189
+ }
190
+ }
191
+ });
192
+
193
+ this.watchAttachmentsAbortController = new AbortController();
194
+ const signal = this.watchAttachmentsAbortController.signal;
195
+
196
+ // Process attachments when there is a change in watched attachments
197
+ this.watchAttachments(async (watchedAttachments) => {
198
+ // Skip processing if sync has been stopped
199
+ if (signal.aborted) {
200
+ return;
201
+ }
202
+
203
+ await this.attachmentService.withContext(async (ctx) => {
204
+ // Need to get all the attachments which are tracked in the DB.
205
+ // We might need to restore an archived attachment.
206
+ const currentAttachments = await ctx.getAttachments();
207
+ const attachmentUpdates: AttachmentRecord[] = [];
208
+
209
+ for (const watchedAttachment of watchedAttachments) {
210
+ const existingQueueItem = currentAttachments.find((a) => a.id === watchedAttachment.id);
211
+ if (!existingQueueItem) {
212
+ // Item is watched but not in the queue yet. Need to add it.
213
+ if (!this.downloadAttachments) {
214
+ continue;
215
+ }
216
+
217
+ const filename = watchedAttachment.filename ?? `${watchedAttachment.id}.${watchedAttachment.fileExtension}`;
218
+
219
+ attachmentUpdates.push({
220
+ id: watchedAttachment.id,
221
+ filename,
222
+ state: AttachmentState.QUEUED_DOWNLOAD,
223
+ hasSynced: false,
224
+ metaData: watchedAttachment.metaData,
225
+ timestamp: new Date().getTime()
226
+ });
227
+ continue;
228
+ }
229
+
230
+ if (existingQueueItem.state === AttachmentState.ARCHIVED) {
231
+ // The attachment is present again. Need to queue it for sync.
232
+ // We might be able to optimize this in future
233
+ if (existingQueueItem.hasSynced === true) {
234
+ // No remote action required, we can restore the record (avoids deletion)
235
+ attachmentUpdates.push({
236
+ ...existingQueueItem,
237
+ state: AttachmentState.SYNCED
238
+ });
239
+ } else {
240
+ // The localURI should be set if the record was meant to be uploaded
241
+ // and hasSynced is false then
242
+ // it must be an upload operation
243
+ const newState =
244
+ existingQueueItem.localUri == null ? AttachmentState.QUEUED_DOWNLOAD : AttachmentState.QUEUED_UPLOAD;
245
+
246
+ attachmentUpdates.push({
247
+ ...existingQueueItem,
248
+ state: newState
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ for (const attachment of currentAttachments) {
255
+ const notInWatchedItems = watchedAttachments.find((i) => i.id === attachment.id) == null;
256
+ if (notInWatchedItems) {
257
+ switch (attachment.state) {
258
+ case AttachmentState.QUEUED_DELETE:
259
+ case AttachmentState.QUEUED_UPLOAD:
260
+ // Only archive if it has synced
261
+ if (attachment.hasSynced === true) {
262
+ attachmentUpdates.push({
263
+ ...attachment,
264
+ state: AttachmentState.ARCHIVED
265
+ });
266
+ }
267
+ break;
268
+ default:
269
+ // Archive other states such as QUEUED_DOWNLOAD
270
+ attachmentUpdates.push({
271
+ ...attachment,
272
+ state: AttachmentState.ARCHIVED
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ if (attachmentUpdates.length > 0) {
279
+ await ctx.saveAttachments(attachmentUpdates);
280
+ }
281
+ });
282
+ }, signal);
283
+ }
284
+
285
+ /**
286
+ * Synchronizes all active attachments between local and remote storage.
287
+ *
288
+ * This is called automatically at regular intervals when sync is started,
289
+ * but can also be called manually to trigger an immediate sync.
290
+ */
291
+ async syncStorage(): Promise<void> {
292
+ await this.attachmentService.withContext(async (ctx) => {
293
+ const activeAttachments = await ctx.getActiveAttachments();
294
+ await this.localStorage.initialize();
295
+ await this.syncingService.processAttachments(activeAttachments, ctx);
296
+ await this.syncingService.deleteArchivedAttachments(ctx);
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Stops the attachment synchronization process.
302
+ *
303
+ * Clears the periodic sync timer and closes all active attachment watchers.
304
+ */
305
+ async stopSync(): Promise<void> {
306
+ clearInterval(this.periodicSyncTimer);
307
+ this.periodicSyncTimer = undefined;
308
+ if (this.watchActiveAttachments) await this.watchActiveAttachments.close();
309
+ if (this.watchAttachmentsAbortController) {
310
+ this.watchAttachmentsAbortController.abort();
311
+ }
312
+ if (this.statusListenerDispose) {
313
+ this.statusListenerDispose();
314
+ this.statusListenerDispose = undefined;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Saves a file to local storage and queues it for upload to remote storage.
320
+ *
321
+ * @param options - File save options
322
+ * @param options.data - The file data as ArrayBuffer, Blob, or base64 string
323
+ * @param options.fileExtension - File extension (e.g., 'jpg', 'pdf')
324
+ * @param options.mediaType - MIME type of the file (e.g., 'image/jpeg')
325
+ * @param options.metaData - Optional metadata to associate with the attachment
326
+ * @param options.id - Optional custom ID. If not provided, a UUID will be generated
327
+ * @param options.updateHook - Optional callback to execute additional database operations
328
+ * within the same transaction as the attachment creation
329
+ * @returns Promise resolving to the created attachment record
330
+ */
331
+ async saveFile({
332
+ data,
333
+ fileExtension,
334
+ mediaType,
335
+ metaData,
336
+ id,
337
+ updateHook
338
+ }: {
339
+ data: AttachmentData;
340
+ fileExtension: string;
341
+ mediaType?: string;
342
+ metaData?: string;
343
+ id?: string;
344
+ updateHook?: (transaction: Transaction, attachment: AttachmentRecord) => Promise<void>;
345
+ }): Promise<AttachmentRecord> {
346
+ const resolvedId = id ?? (await this.generateAttachmentId());
347
+ const filename = `${resolvedId}.${fileExtension}`;
348
+ const localUri = this.localStorage.getLocalUri(filename);
349
+ const size = await this.localStorage.saveFile(localUri, data);
350
+
351
+ const attachment: AttachmentRecord = {
352
+ id: resolvedId,
353
+ filename,
354
+ mediaType,
355
+ localUri,
356
+ state: AttachmentState.QUEUED_UPLOAD,
357
+ hasSynced: false,
358
+ size,
359
+ timestamp: new Date().getTime(),
360
+ metaData
361
+ };
362
+
363
+ await this.attachmentService.withContext(async (ctx) => {
364
+ await ctx.db.writeTransaction(async (tx) => {
365
+ await updateHook?.(tx, attachment);
366
+ await ctx.upsertAttachment(attachment, tx);
367
+ });
368
+ });
369
+
370
+ return attachment;
371
+ }
372
+
373
+ async deleteFile({
374
+ id,
375
+ updateHook
376
+ }: {
377
+ id: string;
378
+ updateHook?: (transaction: Transaction, attachment: AttachmentRecord) => Promise<void>;
379
+ }): Promise<void> {
380
+ await this.attachmentService.withContext(async (ctx) => {
381
+ const attachment = await ctx.getAttachment(id);
382
+ if (!attachment) {
383
+ throw new Error(`Attachment with id ${id} not found`);
384
+ }
385
+
386
+ await ctx.db.writeTransaction(async (tx) => {
387
+ await updateHook?.(tx, attachment);
388
+ await ctx.upsertAttachment(
389
+ {
390
+ ...attachment,
391
+ state: AttachmentState.QUEUED_DELETE,
392
+ hasSynced: false
393
+ },
394
+ tx
395
+ );
396
+ });
397
+ });
398
+ }
399
+
400
+ async expireCache(): Promise<void> {
401
+ let isDone = false;
402
+ while (!isDone) {
403
+ await this.attachmentService.withContext(async (ctx) => {
404
+ isDone = await this.syncingService.deleteArchivedAttachments(ctx);
405
+ });
406
+ }
407
+ }
408
+
409
+ async clearQueue(): Promise<void> {
410
+ await this.attachmentService.withContext(async (ctx) => {
411
+ await ctx.clearQueue();
412
+ });
413
+ await this.localStorage.clear();
414
+ }
415
+
416
+ /**
417
+ * Verifies the integrity of all attachment records and repairs inconsistencies.
418
+ *
419
+ * This method checks each attachment record against the local filesystem and:
420
+ * - Updates localUri if the file exists at a different path
421
+ * - Archives attachments with missing local files that haven't been uploaded
422
+ * - Requeues synced attachments for download if their local files are missing
423
+ */
424
+ async verifyAttachments(): Promise<void> {
425
+ await this.attachmentService.withContext(async (ctx) => {
426
+ const attachments = await ctx.getAttachments();
427
+ const updates: AttachmentRecord[] = [];
428
+
429
+ for (const attachment of attachments) {
430
+ if (attachment.localUri == null) {
431
+ continue;
432
+ }
433
+
434
+ const exists = await this.localStorage.fileExists(attachment.localUri);
435
+ if (exists) {
436
+ // The file exists, this is correct
437
+ continue;
438
+ }
439
+
440
+ const newLocalUri = this.localStorage.getLocalUri(attachment.filename);
441
+ const newExists = await this.localStorage.fileExists(newLocalUri);
442
+ if (newExists) {
443
+ // The file exists locally but the localUri is broken, we update it.
444
+ updates.push({
445
+ ...attachment,
446
+ localUri: newLocalUri
447
+ });
448
+ } else {
449
+ // the file doesn't exist locally.
450
+ if (attachment.state === AttachmentState.SYNCED) {
451
+ // the file has been successfully synced to remote storage but is missing
452
+ // we download it again
453
+ updates.push({
454
+ ...attachment,
455
+ state: AttachmentState.QUEUED_DOWNLOAD,
456
+ localUri: undefined
457
+ });
458
+ } else {
459
+ // the file wasn't successfully synced to remote storage, we archive it
460
+ updates.push({
461
+ ...attachment,
462
+ state: AttachmentState.ARCHIVED,
463
+ localUri: undefined // Clears the value
464
+ });
465
+ }
466
+ }
467
+ }
468
+
469
+ await ctx.saveAttachments(updates);
470
+ });
471
+ }
472
+ }
@@ -0,0 +1,62 @@
1
+ import { Mutex } from 'async-mutex';
2
+ import { AbstractPowerSyncDatabase } from '../client/AbstractPowerSyncDatabase.js';
3
+ import { DifferentialWatchedQuery } from '../client/watched/processors/DifferentialQueryProcessor.js';
4
+ import { ILogger } from '../utils/Logger.js';
5
+ import { mutexRunExclusive } from '../utils/mutex.js';
6
+ import { AttachmentContext } from './AttachmentContext.js';
7
+ import { AttachmentRecord, AttachmentState } from './Schema.js';
8
+
9
+ /**
10
+ * Service for querying and watching attachment records in the database.
11
+ *
12
+ * @internal
13
+ */
14
+ export class AttachmentService {
15
+ private mutex = new Mutex();
16
+ private context: AttachmentContext;
17
+
18
+ constructor(
19
+ private db: AbstractPowerSyncDatabase,
20
+ private logger: ILogger,
21
+ private tableName: string = 'attachments',
22
+ archivedCacheLimit: number = 100
23
+ ) {
24
+ this.context = new AttachmentContext(db, tableName, logger, archivedCacheLimit);
25
+ }
26
+
27
+ /**
28
+ * Creates a differential watch query for active attachments requiring synchronization.
29
+ * @returns Watch query that emits changes for queued uploads, downloads, and deletes
30
+ */
31
+ watchActiveAttachments({ throttleMs }: { throttleMs?: number } = {}): DifferentialWatchedQuery<AttachmentRecord> {
32
+ this.logger.info('Watching active attachments...');
33
+ const watch = this.db
34
+ .query<AttachmentRecord>({
35
+ sql: /* sql */ `
36
+ SELECT
37
+ *
38
+ FROM
39
+ ${this.tableName}
40
+ WHERE
41
+ state = ?
42
+ OR state = ?
43
+ OR state = ?
44
+ ORDER BY
45
+ timestamp ASC
46
+ `,
47
+ parameters: [AttachmentState.QUEUED_UPLOAD, AttachmentState.QUEUED_DOWNLOAD, AttachmentState.QUEUED_DELETE]
48
+ })
49
+ .differentialWatch({ throttleMs });
50
+
51
+ return watch;
52
+ }
53
+
54
+ /**
55
+ * Executes a callback with exclusive access to the attachment context.
56
+ */
57
+ async withContext<T>(callback: (context: AttachmentContext) => Promise<T>): Promise<T> {
58
+ return mutexRunExclusive(this.mutex, async () => {
59
+ return callback(this.context);
60
+ });
61
+ }
62
+ }
@@ -0,0 +1,72 @@
1
+ export type AttachmentData = ArrayBuffer | string;
2
+
3
+ export enum EncodingType {
4
+ UTF8 = 'utf8',
5
+ Base64 = 'base64'
6
+ }
7
+
8
+ /**
9
+ * LocalStorageAdapter defines the interface for local file storage operations.
10
+ * Implementations handle file I/O, directory management, and storage initialization.
11
+ *
12
+ * @experimental
13
+ * @alpha This is currently experimental and may change without a major version bump.
14
+ */
15
+ export interface LocalStorageAdapter {
16
+ /**
17
+ * Saves data to a local file.
18
+ * @param filePath Path where the file will be stored
19
+ * @param data Data to store (ArrayBuffer, Blob, or string)
20
+ * @returns Number of bytes written
21
+ */
22
+ saveFile(filePath: string, data: AttachmentData): Promise<number>;
23
+
24
+ /**
25
+ * Retrieves file data as an ArrayBuffer.
26
+ * @param filePath Path where the file is stored
27
+ * @returns ArrayBuffer containing the file data
28
+ */
29
+ readFile(filePath: string): Promise<ArrayBuffer>;
30
+
31
+ /**
32
+ * Deletes the file at the given path.
33
+ * @param filePath Path where the file is stored
34
+ */
35
+ deleteFile(filePath: string): Promise<void>;
36
+
37
+ /**
38
+ * Checks if a file exists at the given path.
39
+ * @param filePath Path where the file is stored
40
+ * @returns True if the file exists, false otherwise
41
+ */
42
+ fileExists(filePath: string): Promise<boolean>;
43
+
44
+ /**
45
+ * Creates a directory at the specified path.
46
+ * @param path The full path to the directory
47
+ */
48
+ makeDir(path: string): Promise<void>;
49
+
50
+ /**
51
+ * Removes a directory at the specified path.
52
+ * @param path The full path to the directory
53
+ */
54
+ rmDir(path: string): Promise<void>;
55
+
56
+ /**
57
+ * Initializes the storage adapter (e.g., creating necessary directories).
58
+ */
59
+ initialize(): Promise<void>;
60
+
61
+ /**
62
+ * Clears all files in the storage.
63
+ */
64
+ clear(): Promise<void>;
65
+
66
+ /**
67
+ * Returns the file path for the provided filename in the storage directory.
68
+ * @param filename The filename to get the path for
69
+ * @returns The full file path
70
+ */
71
+ getLocalUri(filename: string): string;
72
+ }