@pol-studios/powersync 1.0.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/attachments/index.d.ts +399 -0
- package/dist/attachments/index.js +16 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/chunk-32OLICZO.js +1 -0
- package/dist/chunk-32OLICZO.js.map +1 -0
- package/dist/chunk-4FJVBR3X.js +227 -0
- package/dist/chunk-4FJVBR3X.js.map +1 -0
- package/dist/chunk-7BPTGEVG.js +1 -0
- package/dist/chunk-7BPTGEVG.js.map +1 -0
- package/dist/chunk-7JQZBZ5N.js +1 -0
- package/dist/chunk-7JQZBZ5N.js.map +1 -0
- package/dist/chunk-BJ36QDFN.js +290 -0
- package/dist/chunk-BJ36QDFN.js.map +1 -0
- package/dist/chunk-CFCK2LHI.js +1002 -0
- package/dist/chunk-CFCK2LHI.js.map +1 -0
- package/dist/chunk-CHRTN5PF.js +322 -0
- package/dist/chunk-CHRTN5PF.js.map +1 -0
- package/dist/chunk-FLHDT4TS.js +327 -0
- package/dist/chunk-FLHDT4TS.js.map +1 -0
- package/dist/chunk-GBGATW2S.js +749 -0
- package/dist/chunk-GBGATW2S.js.map +1 -0
- package/dist/chunk-NPNBGCRC.js +65 -0
- package/dist/chunk-NPNBGCRC.js.map +1 -0
- package/dist/chunk-Q3LFFMRR.js +925 -0
- package/dist/chunk-Q3LFFMRR.js.map +1 -0
- package/dist/chunk-T225XEML.js +298 -0
- package/dist/chunk-T225XEML.js.map +1 -0
- package/dist/chunk-W7HSR35B.js +1 -0
- package/dist/chunk-W7HSR35B.js.map +1 -0
- package/dist/connector/index.d.ts +5 -0
- package/dist/connector/index.js +14 -0
- package/dist/connector/index.js.map +1 -0
- package/dist/core/index.d.ts +197 -0
- package/dist/core/index.js +96 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index-nae7nzib.d.ts +147 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/index.native.d.ts +14 -0
- package/dist/index.native.js +195 -0
- package/dist/index.native.js.map +1 -0
- package/dist/index.web.d.ts +14 -0
- package/dist/index.web.js +195 -0
- package/dist/index.web.js.map +1 -0
- package/dist/platform/index.d.ts +280 -0
- package/dist/platform/index.js +14 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/index.native.d.ts +37 -0
- package/dist/platform/index.native.js +7 -0
- package/dist/platform/index.native.js.map +1 -0
- package/dist/platform/index.web.d.ts +37 -0
- package/dist/platform/index.web.js +7 -0
- package/dist/platform/index.web.js.map +1 -0
- package/dist/provider/index.d.ts +873 -0
- package/dist/provider/index.js +63 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/supabase-connector-D14-kl5v.d.ts +232 -0
- package/dist/sync/index.d.ts +421 -0
- package/dist/sync/index.js +14 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/types-Cd7RhNqf.d.ts +224 -0
- package/dist/types-afHtE1U_.d.ts +391 -0
- package/package.json +101 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// src/attachments/types.ts
|
|
2
|
+
var AttachmentState = /* @__PURE__ */ ((AttachmentState2) => {
|
|
3
|
+
AttachmentState2[AttachmentState2["QUEUED_DOWNLOAD"] = 0] = "QUEUED_DOWNLOAD";
|
|
4
|
+
AttachmentState2[AttachmentState2["QUEUED_SYNC"] = 1] = "QUEUED_SYNC";
|
|
5
|
+
AttachmentState2[AttachmentState2["QUEUED_UPLOAD"] = 2] = "QUEUED_UPLOAD";
|
|
6
|
+
AttachmentState2[AttachmentState2["SYNCED"] = 3] = "SYNCED";
|
|
7
|
+
AttachmentState2[AttachmentState2["ARCHIVED"] = 4] = "ARCHIVED";
|
|
8
|
+
return AttachmentState2;
|
|
9
|
+
})(AttachmentState || {});
|
|
10
|
+
var DEFAULT_COMPRESSION_CONFIG = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
quality: 0.7,
|
|
13
|
+
maxWidth: 2048,
|
|
14
|
+
skipSizeBytes: 1e5,
|
|
15
|
+
targetSizeBytes: 3e5
|
|
16
|
+
};
|
|
17
|
+
var DEFAULT_DOWNLOAD_CONFIG = {
|
|
18
|
+
concurrency: 50,
|
|
19
|
+
timeoutMs: 6e4,
|
|
20
|
+
retryDelayMs: 5e3
|
|
21
|
+
};
|
|
22
|
+
var DEFAULT_CACHE_CONFIG = {
|
|
23
|
+
maxSize: 5 * 1024 * 1024 * 1024,
|
|
24
|
+
// 5 GB
|
|
25
|
+
downloadStopThreshold: 0.95,
|
|
26
|
+
evictionTriggerThreshold: 1
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/attachments/attachment-queue.ts
|
|
30
|
+
var NOTIFY_THROTTLE_MS = 500;
|
|
31
|
+
var STATS_CACHE_TTL_MS = 500;
|
|
32
|
+
var CACHE_SIZE_TTL_MS = 5e3;
|
|
33
|
+
var AttachmentQueue = class {
|
|
34
|
+
// ─── Dependencies ──────────────────────────────────────────────────────────
|
|
35
|
+
powersync;
|
|
36
|
+
platform;
|
|
37
|
+
config;
|
|
38
|
+
logger;
|
|
39
|
+
tableName;
|
|
40
|
+
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
41
|
+
concurrency;
|
|
42
|
+
downloadTimeoutMs;
|
|
43
|
+
retryDelayMs;
|
|
44
|
+
downloadStopThreshold;
|
|
45
|
+
evictionTriggerThreshold;
|
|
46
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
47
|
+
_initialized = false;
|
|
48
|
+
_paused = false;
|
|
49
|
+
_processing = false;
|
|
50
|
+
_cacheFull = false;
|
|
51
|
+
_resumeRequested = false;
|
|
52
|
+
_maxCacheSize;
|
|
53
|
+
_compressionQuality;
|
|
54
|
+
_abort = new AbortController();
|
|
55
|
+
_downloads = /* @__PURE__ */ new Map();
|
|
56
|
+
// ─── Caching ───────────────────────────────────────────────────────────────
|
|
57
|
+
_cachedSize = 0;
|
|
58
|
+
_cachedSizeTimestamp = 0;
|
|
59
|
+
_cachedStats = null;
|
|
60
|
+
_cachedStatsTimestamp = 0;
|
|
61
|
+
// ─── Notification ──────────────────────────────────────────────────────────
|
|
62
|
+
_lastNotifyTime = 0;
|
|
63
|
+
_notifyTimer = null;
|
|
64
|
+
_progressCallbacks = /* @__PURE__ */ new Set();
|
|
65
|
+
_watcherInterval = null;
|
|
66
|
+
// ─── Constructor ───────────────────────────────────────────────────────────
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.powersync = options.powersync;
|
|
69
|
+
this.platform = options.platform;
|
|
70
|
+
this.config = options.config;
|
|
71
|
+
this.logger = options.platform.logger;
|
|
72
|
+
this.tableName = options.config.attachmentTableName ?? "photo_attachments";
|
|
73
|
+
const downloadConfig = { ...DEFAULT_DOWNLOAD_CONFIG, ...options.config.download };
|
|
74
|
+
const cacheConfig = { ...DEFAULT_CACHE_CONFIG, ...options.config.cache };
|
|
75
|
+
const compressionConfig = { ...DEFAULT_COMPRESSION_CONFIG, ...options.config.compression };
|
|
76
|
+
this.concurrency = downloadConfig.concurrency;
|
|
77
|
+
this.downloadTimeoutMs = downloadConfig.timeoutMs;
|
|
78
|
+
this.retryDelayMs = downloadConfig.retryDelayMs;
|
|
79
|
+
this.downloadStopThreshold = cacheConfig.downloadStopThreshold;
|
|
80
|
+
this.evictionTriggerThreshold = cacheConfig.evictionTriggerThreshold;
|
|
81
|
+
this._maxCacheSize = cacheConfig.maxSize;
|
|
82
|
+
this._compressionQuality = compressionConfig.quality;
|
|
83
|
+
}
|
|
84
|
+
// ─── Initialization ────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Initialize the attachment queue.
|
|
87
|
+
* Creates the attachment table if needed and starts watching for downloads.
|
|
88
|
+
*/
|
|
89
|
+
async init() {
|
|
90
|
+
if (this._initialized) return;
|
|
91
|
+
this.logger.info("[AttachmentQueue] Initializing...");
|
|
92
|
+
await this.powersync.execute(`
|
|
93
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
filename TEXT NOT NULL,
|
|
96
|
+
media_type TEXT,
|
|
97
|
+
state INTEGER NOT NULL DEFAULT ${0 /* QUEUED_DOWNLOAD */},
|
|
98
|
+
local_uri TEXT,
|
|
99
|
+
size INTEGER DEFAULT 0,
|
|
100
|
+
timestamp INTEGER
|
|
101
|
+
)
|
|
102
|
+
`);
|
|
103
|
+
this._initialized = true;
|
|
104
|
+
if (this.config.performInitialSync !== false) {
|
|
105
|
+
this._startDownloadWatcher();
|
|
106
|
+
}
|
|
107
|
+
this.logger.info("[AttachmentQueue] Initialized");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Dispose the attachment queue.
|
|
111
|
+
*/
|
|
112
|
+
dispose() {
|
|
113
|
+
this._abort.abort();
|
|
114
|
+
if (this._watcherInterval) {
|
|
115
|
+
clearInterval(this._watcherInterval);
|
|
116
|
+
this._watcherInterval = null;
|
|
117
|
+
}
|
|
118
|
+
if (this._notifyTimer) {
|
|
119
|
+
clearTimeout(this._notifyTimer);
|
|
120
|
+
}
|
|
121
|
+
this._progressCallbacks.clear();
|
|
122
|
+
this._initialized = false;
|
|
123
|
+
}
|
|
124
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Subscribe to real-time progress updates.
|
|
127
|
+
* Returns an unsubscribe function.
|
|
128
|
+
*/
|
|
129
|
+
onProgress(callback) {
|
|
130
|
+
this._progressCallbacks.add(callback);
|
|
131
|
+
this._notify(true);
|
|
132
|
+
return () => {
|
|
133
|
+
this._progressCallbacks.delete(callback);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Whether downloads are paused */
|
|
137
|
+
get paused() {
|
|
138
|
+
return this._paused;
|
|
139
|
+
}
|
|
140
|
+
/** Whether currently processing downloads */
|
|
141
|
+
get processing() {
|
|
142
|
+
return this._processing;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Set the maximum cache size.
|
|
146
|
+
*/
|
|
147
|
+
async setMaxCacheSize(bytes) {
|
|
148
|
+
const oldLimit = this._maxCacheSize;
|
|
149
|
+
this._maxCacheSize = bytes;
|
|
150
|
+
this.logger.info(
|
|
151
|
+
`[AttachmentQueue] Cache limit changed: ${Math.round(oldLimit / 1024 / 1024)}MB \u2192 ${Math.round(bytes / 1024 / 1024)}MB`
|
|
152
|
+
);
|
|
153
|
+
if (bytes < oldLimit) {
|
|
154
|
+
const currentSize = await this._getCachedSizeWithCache();
|
|
155
|
+
const downloadStopLimit = bytes * this.downloadStopThreshold;
|
|
156
|
+
if (currentSize >= downloadStopLimit) {
|
|
157
|
+
this._cacheFull = true;
|
|
158
|
+
this._abort.abort();
|
|
159
|
+
this._abort = new AbortController();
|
|
160
|
+
this._notify(true);
|
|
161
|
+
await this._evictIfNeeded();
|
|
162
|
+
} else {
|
|
163
|
+
this._notify(true);
|
|
164
|
+
}
|
|
165
|
+
} else if (bytes > oldLimit && this._cacheFull) {
|
|
166
|
+
this._cacheFull = false;
|
|
167
|
+
this._notify(true);
|
|
168
|
+
if (!this._paused && !this._processing) {
|
|
169
|
+
this._startDownloads();
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
this._notify(true);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Set the compression quality (0.1 to 1.0).
|
|
177
|
+
*/
|
|
178
|
+
setCompressionQuality(quality) {
|
|
179
|
+
this._compressionQuality = Math.max(0.1, Math.min(1, quality));
|
|
180
|
+
this._notify(true);
|
|
181
|
+
}
|
|
182
|
+
/** Get the current compression quality */
|
|
183
|
+
getCompressionQuality() {
|
|
184
|
+
return this._compressionQuality;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Pause downloads.
|
|
188
|
+
*/
|
|
189
|
+
pause() {
|
|
190
|
+
this.logger.info("[AttachmentQueue] Pausing downloads");
|
|
191
|
+
this._paused = true;
|
|
192
|
+
this._abort.abort();
|
|
193
|
+
this._abort = new AbortController();
|
|
194
|
+
this._notify(true);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resume downloads.
|
|
198
|
+
*/
|
|
199
|
+
resume() {
|
|
200
|
+
this.logger.info("[AttachmentQueue] Resuming downloads");
|
|
201
|
+
this._paused = false;
|
|
202
|
+
this._abort = new AbortController();
|
|
203
|
+
this._notify(true);
|
|
204
|
+
if (this._processing) {
|
|
205
|
+
this._resumeRequested = true;
|
|
206
|
+
} else {
|
|
207
|
+
this._startDownloads();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get current sync statistics.
|
|
212
|
+
*/
|
|
213
|
+
async getStats() {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
if (this._cachedStats && now - this._cachedStatsTimestamp < STATS_CACHE_TTL_MS) {
|
|
216
|
+
return {
|
|
217
|
+
...this._cachedStats,
|
|
218
|
+
compressionQuality: this._compressionQuality,
|
|
219
|
+
status: this._getStatus(this._cachedStats.pendingCount),
|
|
220
|
+
isPaused: this._paused,
|
|
221
|
+
isProcessing: this._processing,
|
|
222
|
+
activeDownloads: [...this._downloads.values()]
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const rows = await this.powersync.getAll(
|
|
226
|
+
`SELECT state, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
|
227
|
+
FROM ${this.tableName} GROUP BY state`
|
|
228
|
+
);
|
|
229
|
+
let synced = 0;
|
|
230
|
+
let syncedSize = 0;
|
|
231
|
+
let pending = 0;
|
|
232
|
+
for (const r of rows) {
|
|
233
|
+
if (r.state === 3 /* SYNCED */) {
|
|
234
|
+
synced = r.cnt;
|
|
235
|
+
syncedSize = r.sz;
|
|
236
|
+
}
|
|
237
|
+
if (r.state === 0 /* QUEUED_DOWNLOAD */ || r.state === 1 /* QUEUED_SYNC */) {
|
|
238
|
+
pending += r.cnt;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this._cachedSize = syncedSize;
|
|
242
|
+
this._cachedSizeTimestamp = now;
|
|
243
|
+
const stats = {
|
|
244
|
+
syncedCount: synced,
|
|
245
|
+
syncedSize,
|
|
246
|
+
pendingCount: pending,
|
|
247
|
+
totalExpected: synced + pending,
|
|
248
|
+
maxCacheSize: this._maxCacheSize,
|
|
249
|
+
compressionQuality: this._compressionQuality,
|
|
250
|
+
status: this._getStatus(pending),
|
|
251
|
+
isPaused: this._paused,
|
|
252
|
+
isProcessing: this._processing,
|
|
253
|
+
activeDownloads: [...this._downloads.values()]
|
|
254
|
+
};
|
|
255
|
+
this._cachedStats = stats;
|
|
256
|
+
this._cachedStatsTimestamp = now;
|
|
257
|
+
return stats;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Clear all cached files and re-queue for download.
|
|
261
|
+
*/
|
|
262
|
+
async clearCache() {
|
|
263
|
+
const wasPaused = this._paused;
|
|
264
|
+
if (!wasPaused) this.pause();
|
|
265
|
+
try {
|
|
266
|
+
let attempts = 0;
|
|
267
|
+
while (this._processing && attempts < 50) {
|
|
268
|
+
await this._sleep(100, this._abort.signal);
|
|
269
|
+
attempts++;
|
|
270
|
+
}
|
|
271
|
+
const withFiles = await this.powersync.getAll(
|
|
272
|
+
`SELECT id, local_uri FROM ${this.tableName} WHERE local_uri IS NOT NULL`
|
|
273
|
+
);
|
|
274
|
+
for (const row of withFiles) {
|
|
275
|
+
try {
|
|
276
|
+
await this.platform.fileSystem.deleteFile(row.local_uri);
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
await this.powersync.execute(
|
|
281
|
+
`UPDATE ${this.tableName} SET state = ?, local_uri = NULL, size = 0`,
|
|
282
|
+
[0 /* QUEUED_DOWNLOAD */]
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
this._cachedSize = 0;
|
|
286
|
+
this._cachedSizeTimestamp = 0;
|
|
287
|
+
this._cachedStats = null;
|
|
288
|
+
this._cachedStatsTimestamp = 0;
|
|
289
|
+
this._cacheFull = false;
|
|
290
|
+
if (!wasPaused) {
|
|
291
|
+
this.resume();
|
|
292
|
+
} else {
|
|
293
|
+
this._notify(true);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get an attachment record by ID.
|
|
299
|
+
*/
|
|
300
|
+
async getRecord(id) {
|
|
301
|
+
return this.powersync.get(
|
|
302
|
+
`SELECT * FROM ${this.tableName} WHERE id = ?`,
|
|
303
|
+
[id]
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Get the local file URI for an attachment.
|
|
308
|
+
*/
|
|
309
|
+
getLocalUri(localPath) {
|
|
310
|
+
const cacheDir = this.platform.fileSystem.getCacheDirectory();
|
|
311
|
+
return `${cacheDir}attachments/${localPath}`;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Cache a local file (e.g. one just uploaded) into the attachment cache.
|
|
315
|
+
* This avoids a redundant download by copying the source file directly
|
|
316
|
+
* into the cache directory and marking it as SYNCED.
|
|
317
|
+
*/
|
|
318
|
+
async cacheLocalFile(storagePath, sourceUri) {
|
|
319
|
+
if (!this._initialized) {
|
|
320
|
+
this.logger.warn("[AttachmentQueue] cacheLocalFile called before init");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const sourceInfo = await this.platform.fileSystem.getFileInfo(sourceUri);
|
|
325
|
+
if (!sourceInfo || !sourceInfo.exists) {
|
|
326
|
+
this.logger.warn(`[AttachmentQueue] Source file does not exist: ${sourceUri}`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const localPath = storagePath.replace(/[^a-zA-Z0-9]/g, "_");
|
|
330
|
+
const localUri = this.getLocalUri(localPath);
|
|
331
|
+
const dir = localUri.substring(0, localUri.lastIndexOf("/"));
|
|
332
|
+
await this.platform.fileSystem.makeDirectory(dir, { intermediates: true });
|
|
333
|
+
await this.platform.fileSystem.copyFile(sourceUri, localUri);
|
|
334
|
+
const info = await this.platform.fileSystem.getFileInfo(localUri);
|
|
335
|
+
const size = info && info.exists ? info.size : 0;
|
|
336
|
+
const ext = storagePath.split(".").pop()?.toLowerCase() ?? "";
|
|
337
|
+
const mediaTypeMap = {
|
|
338
|
+
jpg: "image/jpeg",
|
|
339
|
+
jpeg: "image/jpeg",
|
|
340
|
+
png: "image/png",
|
|
341
|
+
webp: "image/webp",
|
|
342
|
+
heic: "image/heic",
|
|
343
|
+
heif: "image/heif",
|
|
344
|
+
gif: "image/gif",
|
|
345
|
+
mp4: "video/mp4",
|
|
346
|
+
mov: "video/quicktime"
|
|
347
|
+
};
|
|
348
|
+
const mediaType = mediaTypeMap[ext] ?? "application/octet-stream";
|
|
349
|
+
await this.powersync.execute(
|
|
350
|
+
`INSERT OR REPLACE INTO ${this.tableName} (id, filename, media_type, state, local_uri, size, timestamp)
|
|
351
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
352
|
+
[storagePath, storagePath, mediaType, 3 /* SYNCED */, localPath, size, Date.now()]
|
|
353
|
+
);
|
|
354
|
+
this._cachedStats = null;
|
|
355
|
+
this._cachedStatsTimestamp = 0;
|
|
356
|
+
this._cachedSizeTimestamp = 0;
|
|
357
|
+
this._notify(true);
|
|
358
|
+
this.logger.info(`[AttachmentQueue] Cached local file: ${storagePath} (${Math.round(size / 1024)}KB)`);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
this.logger.warn(`[AttachmentQueue] Failed to cache local file: ${storagePath}`, err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// ─── Download Watcher ──────────────────────────────────────────────────────
|
|
364
|
+
_startDownloadWatcher() {
|
|
365
|
+
const { table, idColumn, projectFilter } = this.config.source;
|
|
366
|
+
let query;
|
|
367
|
+
if (projectFilter) {
|
|
368
|
+
const { foreignKey, intermediaryTable, projectForeignKey } = projectFilter;
|
|
369
|
+
query = `
|
|
370
|
+
SELECT m.${idColumn} as id
|
|
371
|
+
FROM ${table} m
|
|
372
|
+
JOIN ${intermediaryTable} u ON m.${foreignKey} = u.id
|
|
373
|
+
JOIN ProjectDatabase p ON u.${projectForeignKey} = p.id
|
|
374
|
+
WHERE m.${idColumn} IS NOT NULL AND m.${idColumn} != ''
|
|
375
|
+
`;
|
|
376
|
+
} else {
|
|
377
|
+
query = `SELECT ${idColumn} as id FROM ${table}
|
|
378
|
+
WHERE ${idColumn} IS NOT NULL AND ${idColumn} != ''`;
|
|
379
|
+
}
|
|
380
|
+
const checkForNewAttachments = async () => {
|
|
381
|
+
if (this._abort.signal.aborted || !this._initialized) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const result = await this.powersync.getAll(query);
|
|
386
|
+
const ids = (result ?? []).map((r) => r.id);
|
|
387
|
+
if (ids.length === 0) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await this._syncAttachmentIds(ids);
|
|
391
|
+
if (!this._paused && !this._processing) {
|
|
392
|
+
this._startDownloads();
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const errorMessage = String(err);
|
|
396
|
+
if (errorMessage.includes("Result set is empty") || errorMessage.includes("no such table") || errorMessage.includes("SQLITE_EMPTY")) {
|
|
397
|
+
this.logger.debug("[AttachmentQueue] No attachments found in source table (expected for new projects)");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
this.logger.warn("[AttachmentQueue] Watch error:", err);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
checkForNewAttachments();
|
|
404
|
+
this._watcherInterval = setInterval(checkForNewAttachments, 3e4);
|
|
405
|
+
}
|
|
406
|
+
async _syncAttachmentIds(ids) {
|
|
407
|
+
for (const id of ids) {
|
|
408
|
+
const existing = await this.getRecord(id);
|
|
409
|
+
if (!existing) {
|
|
410
|
+
await this.powersync.execute(
|
|
411
|
+
`INSERT OR IGNORE INTO ${this.tableName} (id, filename, state, timestamp)
|
|
412
|
+
VALUES (?, ?, ?, ?)`,
|
|
413
|
+
[id, id, 0 /* QUEUED_DOWNLOAD */, Date.now()]
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ─── Download Engine ───────────────────────────────────────────────────────
|
|
419
|
+
async _startDownloads() {
|
|
420
|
+
if (this._paused || this._processing) {
|
|
421
|
+
if (this._processing) {
|
|
422
|
+
this._resumeRequested = true;
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.logger.info("[AttachmentQueue] Starting downloads");
|
|
427
|
+
this._processing = true;
|
|
428
|
+
this._resumeRequested = false;
|
|
429
|
+
const signal = this._abort.signal;
|
|
430
|
+
try {
|
|
431
|
+
let toProcess = await this._getIdsToDownload();
|
|
432
|
+
let prevQueueSize = 0;
|
|
433
|
+
while (toProcess.length > 0 && !signal.aborted) {
|
|
434
|
+
const ordered = await this._orderByNewest(toProcess);
|
|
435
|
+
let currentCachedSize = await this._getCachedSizeWithCache();
|
|
436
|
+
const downloadStopLimit = this._maxCacheSize * this.downloadStopThreshold;
|
|
437
|
+
if (currentCachedSize >= downloadStopLimit) {
|
|
438
|
+
this._cacheFull = true;
|
|
439
|
+
this.logger.info("[AttachmentQueue] Cache at capacity, stopping downloads");
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
this._cacheFull = false;
|
|
443
|
+
for (let i = 0; i < ordered.length; i += this.concurrency) {
|
|
444
|
+
if (signal.aborted) break;
|
|
445
|
+
if (currentCachedSize >= downloadStopLimit) {
|
|
446
|
+
this._cacheFull = true;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
const chunk = ordered.slice(i, i + this.concurrency);
|
|
450
|
+
const results = await Promise.allSettled(
|
|
451
|
+
chunk.map((id) => this._downloadOne(id, signal))
|
|
452
|
+
);
|
|
453
|
+
const successful = [];
|
|
454
|
+
for (const result of results) {
|
|
455
|
+
if (result.status === "fulfilled" && result.value) {
|
|
456
|
+
successful.push(result.value);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (successful.length > 0) {
|
|
460
|
+
await this._batchUpdateSizes(successful);
|
|
461
|
+
currentCachedSize = await this._getCachedSizeWithCache(true);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (signal.aborted) break;
|
|
465
|
+
toProcess = await this._getIdsToDownload();
|
|
466
|
+
if (toProcess.length > 0 && toProcess.length >= prevQueueSize) {
|
|
467
|
+
await this._sleep(this.retryDelayMs, signal);
|
|
468
|
+
}
|
|
469
|
+
prevQueueSize = toProcess.length;
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
this.logger.error("[AttachmentQueue] Download loop error:", err);
|
|
473
|
+
} finally {
|
|
474
|
+
this._processing = false;
|
|
475
|
+
this._notify(true);
|
|
476
|
+
if (this._resumeRequested && !this._paused) {
|
|
477
|
+
this._resumeRequested = false;
|
|
478
|
+
this._startDownloads();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async _downloadOne(id, signal) {
|
|
483
|
+
if (signal.aborted) return null;
|
|
484
|
+
const record = await this.getRecord(id);
|
|
485
|
+
if (!record || signal.aborted) return null;
|
|
486
|
+
this._downloads.set(id, {
|
|
487
|
+
id,
|
|
488
|
+
filename: record.filename,
|
|
489
|
+
phase: "downloading"
|
|
490
|
+
});
|
|
491
|
+
try {
|
|
492
|
+
const data = await this._withTimeout(
|
|
493
|
+
this.config.storage.downloadFile(record.filename),
|
|
494
|
+
this.downloadTimeoutMs,
|
|
495
|
+
`Download timeout: ${record.filename}`
|
|
496
|
+
);
|
|
497
|
+
if (signal.aborted) return null;
|
|
498
|
+
const localPath = `${id.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
499
|
+
const localUri = this.getLocalUri(localPath);
|
|
500
|
+
const dir = localUri.substring(0, localUri.lastIndexOf("/"));
|
|
501
|
+
await this.platform.fileSystem.makeDirectory(dir, { intermediates: true });
|
|
502
|
+
const content = data instanceof Blob ? await this._blobToBase64(data) : data;
|
|
503
|
+
await this.platform.fileSystem.writeFile(localUri, content, "base64");
|
|
504
|
+
await this._compressImage(localUri, id, record.filename);
|
|
505
|
+
const info = await this.platform.fileSystem.getFileInfo(localUri);
|
|
506
|
+
if (info && info.exists) {
|
|
507
|
+
await this.powersync.execute(
|
|
508
|
+
`UPDATE ${this.tableName}
|
|
509
|
+
SET state = ?, local_uri = ?, size = ?
|
|
510
|
+
WHERE id = ?`,
|
|
511
|
+
[3 /* SYNCED */, localPath, info.size, id]
|
|
512
|
+
);
|
|
513
|
+
return { id, size: info.size };
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
} catch (err) {
|
|
517
|
+
this.logger.warn(`[AttachmentQueue] Failed: ${record.filename}`, err);
|
|
518
|
+
return null;
|
|
519
|
+
} finally {
|
|
520
|
+
this._downloads.delete(id);
|
|
521
|
+
this._notify();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async _batchUpdateSizes(results) {
|
|
525
|
+
if (results.length === 0) return;
|
|
526
|
+
for (const { id, size } of results) {
|
|
527
|
+
await this.powersync.execute(
|
|
528
|
+
`UPDATE ${this.tableName} SET size = ? WHERE id = ? AND state = ?`,
|
|
529
|
+
[size, id, 3 /* SYNCED */]
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
this._cachedSizeTimestamp = 0;
|
|
533
|
+
}
|
|
534
|
+
async _getIdsToDownload() {
|
|
535
|
+
const result = await this.powersync.getAll(
|
|
536
|
+
`SELECT id FROM ${this.tableName}
|
|
537
|
+
WHERE state IN (?, ?)`,
|
|
538
|
+
[0 /* QUEUED_DOWNLOAD */, 1 /* QUEUED_SYNC */]
|
|
539
|
+
);
|
|
540
|
+
return result.map((r) => r.id);
|
|
541
|
+
}
|
|
542
|
+
// ─── Eviction ──────────────────────────────────────────────────────────────
|
|
543
|
+
async _evictIfNeeded() {
|
|
544
|
+
const evictionTrigger = this._maxCacheSize * this.evictionTriggerThreshold;
|
|
545
|
+
const targetSize = this._maxCacheSize * this.downloadStopThreshold;
|
|
546
|
+
let currentSize = await this._getCachedSizeWithCache(true);
|
|
547
|
+
if (currentSize <= evictionTrigger) return;
|
|
548
|
+
this.logger.info("[AttachmentQueue] Starting eviction...");
|
|
549
|
+
while (currentSize > targetSize) {
|
|
550
|
+
const toEvict = await this.powersync.getAll(
|
|
551
|
+
`SELECT id, local_uri, size FROM ${this.tableName}
|
|
552
|
+
WHERE state = ${3 /* SYNCED */} AND size > 0 LIMIT 100`
|
|
553
|
+
);
|
|
554
|
+
if (toEvict.length === 0) break;
|
|
555
|
+
for (const row of toEvict) {
|
|
556
|
+
if (currentSize <= targetSize) break;
|
|
557
|
+
if (row.local_uri) {
|
|
558
|
+
try {
|
|
559
|
+
await this.platform.fileSystem.deleteFile(this.getLocalUri(row.local_uri));
|
|
560
|
+
await this.powersync.execute(
|
|
561
|
+
`UPDATE ${this.tableName}
|
|
562
|
+
SET state = ?, local_uri = NULL, size = 0 WHERE id = ?`,
|
|
563
|
+
[0 /* QUEUED_DOWNLOAD */, row.id]
|
|
564
|
+
);
|
|
565
|
+
currentSize -= row.size;
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this._cachedSizeTimestamp = 0;
|
|
571
|
+
currentSize = await this._getCachedSizeWithCache(true);
|
|
572
|
+
}
|
|
573
|
+
this._cacheFull = currentSize > targetSize;
|
|
574
|
+
this._notify(true);
|
|
575
|
+
if (!this._cacheFull && !this._paused && !this._processing) {
|
|
576
|
+
this._startDownloads();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// ─── Compression ───────────────────────────────────────────────────────────
|
|
580
|
+
async _compressImage(localUri, id, filename) {
|
|
581
|
+
if (!this._isImage(filename)) return;
|
|
582
|
+
if (!this.platform.imageProcessor) return;
|
|
583
|
+
const compressionConfig = { ...DEFAULT_COMPRESSION_CONFIG, ...this.config.compression };
|
|
584
|
+
if (!compressionConfig.enabled) return;
|
|
585
|
+
try {
|
|
586
|
+
const info = await this.platform.fileSystem.getFileInfo(localUri);
|
|
587
|
+
if (!info || !info.exists) return;
|
|
588
|
+
const originalSize = info.size;
|
|
589
|
+
if (originalSize < compressionConfig.skipSizeBytes) return;
|
|
590
|
+
if (originalSize < compressionConfig.targetSizeBytes) return;
|
|
591
|
+
const existing = this._downloads.get(id);
|
|
592
|
+
if (existing) {
|
|
593
|
+
this._downloads.set(id, { ...existing, phase: "compressing" });
|
|
594
|
+
this._notify(false);
|
|
595
|
+
}
|
|
596
|
+
const result = await this.platform.imageProcessor.compress(localUri, {
|
|
597
|
+
quality: this._compressionQuality,
|
|
598
|
+
maxWidth: originalSize > 1e6 ? compressionConfig.maxWidth : void 0,
|
|
599
|
+
format: "jpeg"
|
|
600
|
+
});
|
|
601
|
+
const compressedInfo = await this.platform.fileSystem.getFileInfo(result.uri);
|
|
602
|
+
if (!compressedInfo || !compressedInfo.exists) return;
|
|
603
|
+
if (compressedInfo.size >= originalSize) {
|
|
604
|
+
await this.platform.fileSystem.deleteFile(result.uri);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
await this.platform.fileSystem.deleteFile(localUri);
|
|
608
|
+
await this.platform.fileSystem.moveFile(result.uri, localUri);
|
|
609
|
+
this.logger.info(
|
|
610
|
+
`[AttachmentQueue] Compressed ${filename}: ${Math.round(originalSize / 1024)}KB \u2192 ${Math.round(compressedInfo.size / 1024)}KB`
|
|
611
|
+
);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
this.logger.warn(`[AttachmentQueue] Compression failed for ${filename}:`, err);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
617
|
+
_isImage(filename) {
|
|
618
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
619
|
+
return ["jpg", "jpeg", "png", "webp", "heic", "heif"].includes(ext ?? "");
|
|
620
|
+
}
|
|
621
|
+
_getStatus(pendingCount) {
|
|
622
|
+
if (this._processing) return "syncing";
|
|
623
|
+
if (this._paused) return "paused";
|
|
624
|
+
if (this._cacheFull && pendingCount > 0) return "cache_full";
|
|
625
|
+
return "complete";
|
|
626
|
+
}
|
|
627
|
+
async _getCachedSizeWithCache(forceRefresh = false) {
|
|
628
|
+
const now = Date.now();
|
|
629
|
+
if (!forceRefresh && this._cachedSizeTimestamp > 0 && now - this._cachedSizeTimestamp < CACHE_SIZE_TTL_MS) {
|
|
630
|
+
return this._cachedSize;
|
|
631
|
+
}
|
|
632
|
+
const result = await this.powersync.get(
|
|
633
|
+
`SELECT COALESCE(SUM(size), 0) as total FROM ${this.tableName}
|
|
634
|
+
WHERE state = ${3 /* SYNCED */}`
|
|
635
|
+
);
|
|
636
|
+
this._cachedSize = result?.total ?? 0;
|
|
637
|
+
this._cachedSizeTimestamp = now;
|
|
638
|
+
return this._cachedSize;
|
|
639
|
+
}
|
|
640
|
+
async _orderByNewest(ids) {
|
|
641
|
+
if (ids.length <= 3) return ids;
|
|
642
|
+
const { table, idColumn, orderByColumn } = this.config.source;
|
|
643
|
+
if (!orderByColumn) return ids;
|
|
644
|
+
try {
|
|
645
|
+
const idSet = new Set(ids);
|
|
646
|
+
const ordered = await this.powersync.getAll(
|
|
647
|
+
`SELECT ${idColumn} as id FROM ${table}
|
|
648
|
+
WHERE ${idColumn} IS NOT NULL AND ${idColumn} != ''
|
|
649
|
+
ORDER BY ${orderByColumn} DESC NULLS LAST
|
|
650
|
+
LIMIT ?`,
|
|
651
|
+
[ids.length * 2]
|
|
652
|
+
);
|
|
653
|
+
const result = [];
|
|
654
|
+
for (const row of ordered) {
|
|
655
|
+
if (idSet.has(row.id)) {
|
|
656
|
+
result.push(row.id);
|
|
657
|
+
idSet.delete(row.id);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const id of ids) {
|
|
661
|
+
if (idSet.has(id)) {
|
|
662
|
+
result.push(id);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
} catch {
|
|
667
|
+
return ids;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
_sleep(ms, signal) {
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
const onAbort = () => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
resolve();
|
|
675
|
+
};
|
|
676
|
+
const timer = setTimeout(() => {
|
|
677
|
+
signal.removeEventListener("abort", onAbort);
|
|
678
|
+
resolve();
|
|
679
|
+
}, ms);
|
|
680
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
_withTimeout(promise, ms, message) {
|
|
684
|
+
return new Promise((resolve, reject) => {
|
|
685
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
686
|
+
promise.then(
|
|
687
|
+
(result) => {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
resolve(result);
|
|
690
|
+
},
|
|
691
|
+
(err) => {
|
|
692
|
+
clearTimeout(timer);
|
|
693
|
+
reject(err);
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
async _blobToBase64(blob) {
|
|
699
|
+
return new Promise((resolve, reject) => {
|
|
700
|
+
const reader = new FileReader();
|
|
701
|
+
reader.onloadend = () => {
|
|
702
|
+
const base64 = reader.result.split(",")[1];
|
|
703
|
+
resolve(base64);
|
|
704
|
+
};
|
|
705
|
+
reader.onerror = reject;
|
|
706
|
+
reader.readAsDataURL(blob);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
_notify(forceImmediate = false) {
|
|
710
|
+
if (this._progressCallbacks.size === 0) return;
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
const timeSinceLastNotify = now - this._lastNotifyTime;
|
|
713
|
+
if (this._notifyTimer) {
|
|
714
|
+
clearTimeout(this._notifyTimer);
|
|
715
|
+
this._notifyTimer = null;
|
|
716
|
+
}
|
|
717
|
+
const notifyAll = (stats) => {
|
|
718
|
+
for (const cb of this._progressCallbacks) {
|
|
719
|
+
try {
|
|
720
|
+
cb(stats);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
this.logger.warn("[AttachmentQueue] Callback error:", err);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
if (forceImmediate || timeSinceLastNotify >= NOTIFY_THROTTLE_MS) {
|
|
727
|
+
this._lastNotifyTime = now;
|
|
728
|
+
this.getStats().then(notifyAll).catch(() => {
|
|
729
|
+
});
|
|
730
|
+
} else {
|
|
731
|
+
const delay = NOTIFY_THROTTLE_MS - timeSinceLastNotify;
|
|
732
|
+
this._notifyTimer = setTimeout(() => {
|
|
733
|
+
this._notifyTimer = null;
|
|
734
|
+
this._lastNotifyTime = Date.now();
|
|
735
|
+
this.getStats().then(notifyAll).catch(() => {
|
|
736
|
+
});
|
|
737
|
+
}, delay);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
export {
|
|
743
|
+
AttachmentState,
|
|
744
|
+
DEFAULT_COMPRESSION_CONFIG,
|
|
745
|
+
DEFAULT_DOWNLOAD_CONFIG,
|
|
746
|
+
DEFAULT_CACHE_CONFIG,
|
|
747
|
+
AttachmentQueue
|
|
748
|
+
};
|
|
749
|
+
//# sourceMappingURL=chunk-GBGATW2S.js.map
|