@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.
- package/README.md +5 -1
- package/dist/bundle.cjs +1266 -360
- package/dist/bundle.cjs.map +1 -1
- package/dist/bundle.mjs +1259 -361
- package/dist/bundle.mjs.map +1 -1
- package/dist/bundle.node.cjs +1266 -360
- package/dist/bundle.node.cjs.map +1 -1
- package/dist/bundle.node.mjs +1259 -361
- package/dist/bundle.node.mjs.map +1 -1
- package/dist/index.d.cts +530 -29
- package/lib/attachments/AttachmentContext.d.ts +86 -0
- package/lib/attachments/AttachmentContext.js +229 -0
- package/lib/attachments/AttachmentContext.js.map +1 -0
- package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
- package/lib/attachments/AttachmentErrorHandler.js +2 -0
- package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
- package/lib/attachments/AttachmentQueue.d.ts +149 -0
- package/lib/attachments/AttachmentQueue.js +362 -0
- package/lib/attachments/AttachmentQueue.js.map +1 -0
- package/lib/attachments/AttachmentService.d.ts +29 -0
- package/lib/attachments/AttachmentService.js +56 -0
- package/lib/attachments/AttachmentService.js.map +1 -0
- package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
- package/lib/attachments/LocalStorageAdapter.js +6 -0
- package/lib/attachments/LocalStorageAdapter.js.map +1 -0
- package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
- package/lib/attachments/RemoteStorageAdapter.js +2 -0
- package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
- package/lib/attachments/Schema.d.ts +50 -0
- package/lib/attachments/Schema.js +62 -0
- package/lib/attachments/Schema.js.map +1 -0
- package/lib/attachments/SyncingService.d.ts +62 -0
- package/lib/attachments/SyncingService.js +168 -0
- package/lib/attachments/SyncingService.js.map +1 -0
- package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
- package/lib/attachments/WatchedAttachmentItem.js +2 -0
- package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -1
- package/lib/utils/mutex.d.ts +1 -1
- package/lib/utils/mutex.js.map +1 -1
- package/package.json +1 -1
- package/src/attachments/AttachmentContext.ts +279 -0
- package/src/attachments/AttachmentErrorHandler.ts +34 -0
- package/src/attachments/AttachmentQueue.ts +472 -0
- package/src/attachments/AttachmentService.ts +62 -0
- package/src/attachments/LocalStorageAdapter.ts +72 -0
- package/src/attachments/README.md +718 -0
- package/src/attachments/RemoteStorageAdapter.ts +30 -0
- package/src/attachments/Schema.ts +87 -0
- package/src/attachments/SyncingService.ts +193 -0
- package/src/attachments/WatchedAttachmentItem.ts +19 -0
- package/src/index.ts +11 -0
- 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
|
+
}
|