@pol-studios/powersync 1.0.7 → 1.0.11
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 +933 -0
- package/dist/CacheSettingsManager-uz-kbnRH.d.ts +461 -0
- package/dist/attachments/index.d.ts +709 -6
- package/dist/attachments/index.js +133 -5
- package/dist/chunk-24RDMMCL.js +44 -0
- package/dist/chunk-24RDMMCL.js.map +1 -0
- package/dist/chunk-4TXTAEF2.js +2060 -0
- package/dist/chunk-4TXTAEF2.js.map +1 -0
- package/dist/chunk-63PXSPIN.js +358 -0
- package/dist/chunk-63PXSPIN.js.map +1 -0
- package/dist/chunk-654ERHA7.js +1 -0
- package/dist/{chunk-BREGB4WL.js → chunk-BRXQNASY.js} +287 -335
- package/dist/chunk-BRXQNASY.js.map +1 -0
- package/dist/{chunk-DHYUBVP7.js → chunk-CAB26E6F.js} +20 -9
- package/dist/chunk-CAB26E6F.js.map +1 -0
- package/dist/{chunk-H772V6XQ.js → chunk-CUCAYK7Z.js} +7 -43
- package/dist/chunk-CUCAYK7Z.js.map +1 -0
- package/dist/{chunk-4C3RY5SU.js → chunk-HWSNV45P.js} +76 -1
- package/dist/chunk-HWSNV45P.js.map +1 -0
- package/dist/{chunk-HFOFLW5F.js → chunk-KN2IZERF.js} +139 -6
- package/dist/chunk-KN2IZERF.js.map +1 -0
- package/dist/{chunk-UEYRTLKE.js → chunk-P4HZA6ZT.js} +20 -9
- package/dist/chunk-P4HZA6ZT.js.map +1 -0
- package/dist/chunk-T4AO7JIG.js +1 -0
- package/dist/{chunk-XQAJM2MW.js → chunk-VACPAAQZ.js} +33 -2
- package/dist/{chunk-XQAJM2MW.js.map → chunk-VACPAAQZ.js.map} +1 -1
- package/dist/{chunk-53WH2JJV.js → chunk-WN5ZJ3E2.js} +5 -8
- package/dist/chunk-WN5ZJ3E2.js.map +1 -0
- package/dist/chunk-XAEII4ZX.js +456 -0
- package/dist/chunk-XAEII4ZX.js.map +1 -0
- package/dist/chunk-XOY2CJ67.js +289 -0
- package/dist/chunk-XOY2CJ67.js.map +1 -0
- package/dist/chunk-YHTZ7VMV.js +1 -0
- package/dist/{chunk-MKD2VCX3.js → chunk-Z6VOBGTU.js} +8 -8
- package/dist/chunk-Z6VOBGTU.js.map +1 -0
- package/dist/chunk-ZM4ENYMF.js +230 -0
- package/dist/chunk-ZM4ENYMF.js.map +1 -0
- package/dist/connector/index.d.ts +56 -3
- package/dist/connector/index.js +8 -5
- package/dist/core/index.d.ts +12 -1
- package/dist/core/index.js +3 -2
- package/dist/error/index.js +0 -1
- package/dist/generator/cli.js +527 -0
- package/dist/generator/index.d.ts +168 -0
- package/dist/generator/index.js +370 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +191 -29
- package/dist/index.native.d.ts +11 -9
- package/dist/index.native.js +191 -29
- package/dist/index.web.d.ts +11 -9
- package/dist/index.web.js +191 -29
- package/dist/maintenance/index.js +0 -1
- package/dist/platform/index.js +0 -2
- package/dist/platform/index.js.map +1 -1
- package/dist/platform/index.native.js +1 -2
- package/dist/platform/index.web.js +0 -1
- package/dist/pol-attachment-queue-BVAIueoP.d.ts +817 -0
- package/dist/provider/index.d.ts +38 -34
- package/dist/provider/index.js +11 -12
- package/dist/react/index.d.ts +372 -0
- package/dist/react/index.js +25 -0
- package/dist/storage/index.d.ts +3 -3
- package/dist/storage/index.js +22 -8
- package/dist/storage/index.native.d.ts +3 -3
- package/dist/storage/index.native.js +21 -7
- package/dist/storage/index.web.d.ts +3 -3
- package/dist/storage/index.web.js +21 -7
- package/dist/storage/upload/index.d.ts +7 -8
- package/dist/storage/upload/index.js +3 -3
- package/dist/storage/upload/index.native.d.ts +7 -8
- package/dist/storage/upload/index.native.js +4 -3
- package/dist/storage/upload/index.web.d.ts +1 -4
- package/dist/storage/upload/index.web.js +3 -3
- package/dist/supabase-connector-T9vHq_3i.d.ts +202 -0
- package/dist/sync/index.js +3 -3
- package/dist/{supabase-connector-qLm-WHkM.d.ts → types-B212hgfA.d.ts} +48 -170
- package/dist/{types-BVacP54t.d.ts → types-CyvBaAl8.d.ts} +12 -4
- package/dist/types-D0WcHrq6.d.ts +234 -0
- package/package.json +28 -4
- package/dist/CacheSettingsManager-1exbOC6S.d.ts +0 -261
- package/dist/chunk-4C3RY5SU.js.map +0 -1
- package/dist/chunk-53WH2JJV.js.map +0 -1
- package/dist/chunk-BREGB4WL.js.map +0 -1
- package/dist/chunk-DGUM43GV.js +0 -11
- package/dist/chunk-DHYUBVP7.js.map +0 -1
- package/dist/chunk-GKF7TOMT.js +0 -1
- package/dist/chunk-H772V6XQ.js.map +0 -1
- package/dist/chunk-HFOFLW5F.js.map +0 -1
- package/dist/chunk-KGSFAE5B.js +0 -1
- package/dist/chunk-LNL64IJZ.js +0 -1
- package/dist/chunk-MKD2VCX3.js.map +0 -1
- package/dist/chunk-UEYRTLKE.js.map +0 -1
- package/dist/chunk-WQ5MPAVC.js +0 -449
- package/dist/chunk-WQ5MPAVC.js.map +0 -1
- package/dist/chunk-ZEOKPWUC.js +0 -1165
- package/dist/chunk-ZEOKPWUC.js.map +0 -1
- package/dist/pol-attachment-queue-C7YNXXhK.d.ts +0 -676
- package/dist/types-Bgvx7-E8.d.ts +0 -187
- /package/dist/{chunk-DGUM43GV.js.map → chunk-654ERHA7.js.map} +0 -0
- /package/dist/{chunk-GKF7TOMT.js.map → chunk-T4AO7JIG.js.map} +0 -0
- /package/dist/{chunk-KGSFAE5B.js.map → chunk-YHTZ7VMV.js.map} +0 -0
- /package/dist/{chunk-LNL64IJZ.js.map → react/index.js.map} +0 -0
|
@@ -0,0 +1,2060 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AbortError,
|
|
3
|
+
addJitter,
|
|
4
|
+
calculateBackoffDelay
|
|
5
|
+
} from "./chunk-FV2HXEIY.js";
|
|
6
|
+
|
|
7
|
+
// src/attachments/pol-storage-adapter.ts
|
|
8
|
+
import { EncodingType } from "@powersync/attachments";
|
|
9
|
+
var PolStorageAdapter = class {
|
|
10
|
+
platform;
|
|
11
|
+
remoteStorage;
|
|
12
|
+
attachmentDirectoryName;
|
|
13
|
+
userStorageDirectory;
|
|
14
|
+
logger;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.platform = options.platform;
|
|
17
|
+
this.remoteStorage = options.remoteStorage;
|
|
18
|
+
this.attachmentDirectoryName = options.attachmentDirectoryName ?? "attachments";
|
|
19
|
+
this.logger = options.logger;
|
|
20
|
+
const cacheDir = this.platform.fileSystem.getCacheDirectory();
|
|
21
|
+
this.userStorageDirectory = cacheDir.endsWith("/") ? `${cacheDir}${this.attachmentDirectoryName}/` : `${cacheDir}/${this.attachmentDirectoryName}/`;
|
|
22
|
+
}
|
|
23
|
+
// ─── Remote Operations ──────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Upload a file to remote storage.
|
|
26
|
+
*/
|
|
27
|
+
async uploadFile(filePath, data, options) {
|
|
28
|
+
if (!this.remoteStorage.uploadFile) {
|
|
29
|
+
throw new Error("Remote storage adapter does not support uploads");
|
|
30
|
+
}
|
|
31
|
+
const base64 = this._arrayBufferToBase64(data);
|
|
32
|
+
await this.remoteStorage.uploadFile(filePath, base64);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Download a file from remote storage.
|
|
36
|
+
*
|
|
37
|
+
* NOTE: This method implements the official @powersync/attachments StorageAdapter
|
|
38
|
+
* interface which requires returning Promise<Blob>. For the memory-optimized
|
|
39
|
+
* file path passthrough, PolAttachmentQueue.downloadRecord() calls
|
|
40
|
+
* remoteStorage.downloadFile() directly to get the file:// path.
|
|
41
|
+
*
|
|
42
|
+
* This method handles base64 string → Blob conversion for backward compatibility.
|
|
43
|
+
*/
|
|
44
|
+
async downloadFile(filePath) {
|
|
45
|
+
const result = await this.remoteStorage.downloadFile(filePath);
|
|
46
|
+
if (typeof result === "string") {
|
|
47
|
+
if (result.startsWith("file://")) {
|
|
48
|
+
this.logger?.warn(`[PolStorageAdapter] PERFORMANCE WARNING: file:// path hit in downloadFile() - reading entire file into memory. This should be avoided; use direct file copy instead. File: ${filePath}`);
|
|
49
|
+
const base64Content = await this.platform.fileSystem.readFile(result, "base64");
|
|
50
|
+
const binaryString2 = atob(base64Content);
|
|
51
|
+
const bytes2 = new Uint8Array(binaryString2.length);
|
|
52
|
+
for (let i = 0; i < binaryString2.length; i++) {
|
|
53
|
+
bytes2[i] = binaryString2.charCodeAt(i);
|
|
54
|
+
}
|
|
55
|
+
return new Blob([bytes2]);
|
|
56
|
+
}
|
|
57
|
+
const binaryString = atob(result);
|
|
58
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
59
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
60
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
return new Blob([bytes]);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// ─── Local File Operations ──────────────────────────────────────────────────
|
|
67
|
+
/**
|
|
68
|
+
* Write data to a local file.
|
|
69
|
+
*/
|
|
70
|
+
async writeFile(fileUri, base64Data, options) {
|
|
71
|
+
const encoding = options?.encoding === EncodingType.UTF8 ? "utf8" : "base64";
|
|
72
|
+
await this.platform.fileSystem.writeFile(fileUri, base64Data, encoding);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read a local file's contents.
|
|
76
|
+
*/
|
|
77
|
+
async readFile(fileUri, options) {
|
|
78
|
+
const encoding = options?.encoding === EncodingType.UTF8 ? "utf8" : "base64";
|
|
79
|
+
const content = await this.platform.fileSystem.readFile(fileUri, encoding);
|
|
80
|
+
if (encoding === "base64") {
|
|
81
|
+
return this._base64ToArrayBuffer(content);
|
|
82
|
+
} else {
|
|
83
|
+
const encoder = new TextEncoder();
|
|
84
|
+
return encoder.encode(content).buffer;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete a local file.
|
|
89
|
+
*/
|
|
90
|
+
async deleteFile(uri, _options) {
|
|
91
|
+
try {
|
|
92
|
+
await this.platform.fileSystem.deleteFile(uri);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const info = await this.platform.fileSystem.getFileInfo(uri);
|
|
95
|
+
if (info?.exists) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check if a local file exists.
|
|
102
|
+
*/
|
|
103
|
+
async fileExists(fileUri) {
|
|
104
|
+
const info = await this.platform.fileSystem.getFileInfo(fileUri);
|
|
105
|
+
return info?.exists ?? false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create a directory (with intermediate directories).
|
|
109
|
+
*/
|
|
110
|
+
async makeDir(uri) {
|
|
111
|
+
await this.platform.fileSystem.makeDirectory(uri, {
|
|
112
|
+
intermediates: true
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Copy a file from source to destination.
|
|
117
|
+
*/
|
|
118
|
+
async copyFile(sourceUri, targetUri) {
|
|
119
|
+
await this.platform.fileSystem.copyFile(sourceUri, targetUri);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get the user's storage directory for attachments.
|
|
123
|
+
* Returns the cache directory path ending with '/'.
|
|
124
|
+
*/
|
|
125
|
+
getUserStorageDirectory() {
|
|
126
|
+
return this.userStorageDirectory;
|
|
127
|
+
}
|
|
128
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Warning 8: Optimized base64 encoding using batch processing.
|
|
131
|
+
* Processes in chunks of 1024 bytes for better performance with large files.
|
|
132
|
+
*/
|
|
133
|
+
_arrayBufferToBase64(buffer) {
|
|
134
|
+
const bytes = new Uint8Array(buffer);
|
|
135
|
+
const chunkSize = 1024;
|
|
136
|
+
const chunks = [];
|
|
137
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
138
|
+
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
|
|
139
|
+
chunks.push(String.fromCharCode.apply(null, Array.from(chunk)));
|
|
140
|
+
}
|
|
141
|
+
return btoa(chunks.join(""));
|
|
142
|
+
}
|
|
143
|
+
_base64ToArrayBuffer(base64) {
|
|
144
|
+
const binaryString = atob(base64);
|
|
145
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
146
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
147
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
148
|
+
}
|
|
149
|
+
return bytes.buffer;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/attachments/types.ts
|
|
154
|
+
import { ATTACHMENT_TABLE, AttachmentState, AttachmentTable } from "@powersync/attachments";
|
|
155
|
+
import { EncodingType as EncodingType2 } from "@powersync/attachments";
|
|
156
|
+
import { AbstractAttachmentQueue, DEFAULT_ATTACHMENT_QUEUE_OPTIONS } from "@powersync/attachments";
|
|
157
|
+
var PolAttachmentState = /* @__PURE__ */ ((PolAttachmentState2) => {
|
|
158
|
+
PolAttachmentState2[PolAttachmentState2["QUEUED_SYNC"] = 0] = "QUEUED_SYNC";
|
|
159
|
+
PolAttachmentState2[PolAttachmentState2["QUEUED_UPLOAD"] = 1] = "QUEUED_UPLOAD";
|
|
160
|
+
PolAttachmentState2[PolAttachmentState2["QUEUED_DOWNLOAD"] = 2] = "QUEUED_DOWNLOAD";
|
|
161
|
+
PolAttachmentState2[PolAttachmentState2["SYNCED"] = 3] = "SYNCED";
|
|
162
|
+
PolAttachmentState2[PolAttachmentState2["ARCHIVED"] = 4] = "ARCHIVED";
|
|
163
|
+
PolAttachmentState2[PolAttachmentState2["FAILED_PERMANENT"] = 5] = "FAILED_PERMANENT";
|
|
164
|
+
PolAttachmentState2[PolAttachmentState2["DOWNLOAD_SKIPPED"] = 6] = "DOWNLOAD_SKIPPED";
|
|
165
|
+
return PolAttachmentState2;
|
|
166
|
+
})(PolAttachmentState || {});
|
|
167
|
+
var DEFAULT_COMPRESSION_CONFIG = {
|
|
168
|
+
enabled: true,
|
|
169
|
+
quality: 0.7,
|
|
170
|
+
maxWidth: 2048,
|
|
171
|
+
skipSizeBytes: 1e5,
|
|
172
|
+
targetSizeBytes: 3e5
|
|
173
|
+
};
|
|
174
|
+
var DEFAULT_UPLOAD_CONFIG = {
|
|
175
|
+
concurrency: 5,
|
|
176
|
+
timeoutMs: 12e4,
|
|
177
|
+
baseRetryDelayMs: 3e4,
|
|
178
|
+
// 30 seconds base delay for battery efficiency
|
|
179
|
+
maxRetryDelayMs: 36e5,
|
|
180
|
+
// 1 hour max
|
|
181
|
+
staleDaysThreshold: 7,
|
|
182
|
+
maxRetryCount: 100
|
|
183
|
+
};
|
|
184
|
+
var DEFAULT_DOWNLOAD_CONFIG = {
|
|
185
|
+
concurrency: 3,
|
|
186
|
+
timeoutMs: 12e4
|
|
187
|
+
};
|
|
188
|
+
var DEFAULT_CACHE_CONFIG = {
|
|
189
|
+
maxSize: Number.MAX_SAFE_INTEGER,
|
|
190
|
+
// Unlimited by default
|
|
191
|
+
downloadStopThreshold: 0.95,
|
|
192
|
+
evictionTriggerThreshold: 1
|
|
193
|
+
};
|
|
194
|
+
var CACHE_SIZE_PRESETS = {
|
|
195
|
+
/** 250 MB */
|
|
196
|
+
MB_250: 250 * 1024 * 1024,
|
|
197
|
+
/** 500 MB */
|
|
198
|
+
MB_500: 500 * 1024 * 1024,
|
|
199
|
+
/** 1 GB */
|
|
200
|
+
GB_1: 1 * 1024 * 1024 * 1024,
|
|
201
|
+
/** 2 GB */
|
|
202
|
+
GB_2: 2 * 1024 * 1024 * 1024,
|
|
203
|
+
/** 5 GB */
|
|
204
|
+
GB_5: 5 * 1024 * 1024 * 1024,
|
|
205
|
+
/** Unlimited (no cache eviction) */
|
|
206
|
+
UNLIMITED: Number.MAX_SAFE_INTEGER
|
|
207
|
+
};
|
|
208
|
+
function formatCacheSize(bytes) {
|
|
209
|
+
if (bytes === Number.MAX_SAFE_INTEGER) return "Unlimited";
|
|
210
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
211
|
+
const gb = bytes / (1024 * 1024 * 1024);
|
|
212
|
+
return `${gb % 1 === 0 ? gb : gb.toFixed(1)} GB`;
|
|
213
|
+
}
|
|
214
|
+
if (bytes >= 1024 * 1024) {
|
|
215
|
+
const mb = bytes / (1024 * 1024);
|
|
216
|
+
return `${mb % 1 === 0 ? mb : mb.toFixed(1)} MB`;
|
|
217
|
+
}
|
|
218
|
+
if (bytes >= 1024) {
|
|
219
|
+
const kb = bytes / 1024;
|
|
220
|
+
return `${kb % 1 === 0 ? kb : kb.toFixed(1)} KB`;
|
|
221
|
+
}
|
|
222
|
+
return `${bytes} bytes`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/attachments/state-machine.ts
|
|
226
|
+
import { AttachmentState as AttachmentState2 } from "@powersync/attachments";
|
|
227
|
+
var PROTECTED_UPLOAD_STATES = /* @__PURE__ */ new Set([AttachmentState2.QUEUED_UPLOAD, 5 /* FAILED_PERMANENT */]);
|
|
228
|
+
var PENDING_DOWNLOAD_STATES = /* @__PURE__ */ new Set([AttachmentState2.QUEUED_DOWNLOAD, AttachmentState2.QUEUED_SYNC]);
|
|
229
|
+
var LOCALLY_AVAILABLE_STATES = /* @__PURE__ */ new Set([AttachmentState2.SYNCED, AttachmentState2.QUEUED_UPLOAD]);
|
|
230
|
+
function isProtectedUploadState(state) {
|
|
231
|
+
return PROTECTED_UPLOAD_STATES.has(state);
|
|
232
|
+
}
|
|
233
|
+
function isPendingDownloadState(state) {
|
|
234
|
+
return PENDING_DOWNLOAD_STATES.has(state);
|
|
235
|
+
}
|
|
236
|
+
function isLocallyAvailable(state) {
|
|
237
|
+
return LOCALLY_AVAILABLE_STATES.has(state);
|
|
238
|
+
}
|
|
239
|
+
function isStateTransitionAllowed(currentState, newState) {
|
|
240
|
+
if (isProtectedUploadState(currentState) && isPendingDownloadState(newState)) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
function determineAttachmentState(hasLocalUri, hasUploadSourceUri, currentState) {
|
|
246
|
+
if (currentState !== void 0 && isProtectedUploadState(currentState)) {
|
|
247
|
+
return currentState;
|
|
248
|
+
}
|
|
249
|
+
if (hasUploadSourceUri) {
|
|
250
|
+
return AttachmentState2.QUEUED_UPLOAD;
|
|
251
|
+
}
|
|
252
|
+
if (hasLocalUri) {
|
|
253
|
+
return AttachmentState2.SYNCED;
|
|
254
|
+
}
|
|
255
|
+
return AttachmentState2.QUEUED_DOWNLOAD;
|
|
256
|
+
}
|
|
257
|
+
function validateSqlIdentifier(value, name) {
|
|
258
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
|
259
|
+
throw new Error(`[PolAttachmentQueue] Invalid SQL identifier for ${name}: "${value}". Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function getProtectedStatesInClause() {
|
|
263
|
+
return `(${AttachmentState2.QUEUED_UPLOAD}, ${5 /* FAILED_PERMANENT */})`;
|
|
264
|
+
}
|
|
265
|
+
function getExcludeProtectedStatesCondition(stateColumn = "state") {
|
|
266
|
+
return `${stateColumn} NOT IN ${getProtectedStatesInClause()}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/utils/mimeTypes.ts
|
|
270
|
+
var MIME_TYPES = {
|
|
271
|
+
// Images
|
|
272
|
+
jpg: "image/jpeg",
|
|
273
|
+
jpeg: "image/jpeg",
|
|
274
|
+
png: "image/png",
|
|
275
|
+
gif: "image/gif",
|
|
276
|
+
webp: "image/webp",
|
|
277
|
+
heic: "image/heic",
|
|
278
|
+
heif: "image/heic",
|
|
279
|
+
svg: "image/svg+xml",
|
|
280
|
+
bmp: "image/bmp",
|
|
281
|
+
tiff: "image/tiff",
|
|
282
|
+
tif: "image/tiff",
|
|
283
|
+
ico: "image/x-icon",
|
|
284
|
+
avif: "image/avif",
|
|
285
|
+
// Videos
|
|
286
|
+
mp4: "video/mp4",
|
|
287
|
+
mov: "video/quicktime",
|
|
288
|
+
avi: "video/x-msvideo",
|
|
289
|
+
webm: "video/webm",
|
|
290
|
+
mkv: "video/x-matroska",
|
|
291
|
+
m4v: "video/x-m4v",
|
|
292
|
+
"3gp": "video/3gpp",
|
|
293
|
+
flv: "video/x-flv",
|
|
294
|
+
// Audio
|
|
295
|
+
mp3: "audio/mpeg",
|
|
296
|
+
wav: "audio/wav",
|
|
297
|
+
ogg: "audio/ogg",
|
|
298
|
+
m4a: "audio/mp4",
|
|
299
|
+
aac: "audio/aac",
|
|
300
|
+
flac: "audio/flac",
|
|
301
|
+
wma: "audio/x-ms-wma",
|
|
302
|
+
// Documents
|
|
303
|
+
pdf: "application/pdf",
|
|
304
|
+
doc: "application/msword",
|
|
305
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
306
|
+
xls: "application/vnd.ms-excel",
|
|
307
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
308
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
309
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
310
|
+
txt: "text/plain",
|
|
311
|
+
csv: "text/csv",
|
|
312
|
+
rtf: "application/rtf",
|
|
313
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
314
|
+
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
315
|
+
odp: "application/vnd.oasis.opendocument.presentation",
|
|
316
|
+
// Data formats
|
|
317
|
+
json: "application/json",
|
|
318
|
+
xml: "application/xml",
|
|
319
|
+
yaml: "application/x-yaml",
|
|
320
|
+
yml: "application/x-yaml",
|
|
321
|
+
// Archives
|
|
322
|
+
zip: "application/zip",
|
|
323
|
+
rar: "application/vnd.rar",
|
|
324
|
+
"7z": "application/x-7z-compressed",
|
|
325
|
+
tar: "application/x-tar",
|
|
326
|
+
gz: "application/gzip",
|
|
327
|
+
// Code
|
|
328
|
+
js: "text/javascript",
|
|
329
|
+
ts: "text/typescript",
|
|
330
|
+
jsx: "text/jsx",
|
|
331
|
+
tsx: "text/tsx",
|
|
332
|
+
css: "text/css",
|
|
333
|
+
html: "text/html",
|
|
334
|
+
htm: "text/html",
|
|
335
|
+
md: "text/markdown",
|
|
336
|
+
// Other
|
|
337
|
+
woff: "font/woff",
|
|
338
|
+
woff2: "font/woff2",
|
|
339
|
+
ttf: "font/ttf",
|
|
340
|
+
otf: "font/otf",
|
|
341
|
+
eot: "application/vnd.ms-fontobject"
|
|
342
|
+
};
|
|
343
|
+
var DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
344
|
+
function getMimeType(extension) {
|
|
345
|
+
if (!extension) {
|
|
346
|
+
return DEFAULT_MIME_TYPE;
|
|
347
|
+
}
|
|
348
|
+
const ext = extension.replace(/^\./, "").toLowerCase();
|
|
349
|
+
return MIME_TYPES[ext] ?? DEFAULT_MIME_TYPE;
|
|
350
|
+
}
|
|
351
|
+
function getMimeTypeFromPath(filePath) {
|
|
352
|
+
if (!filePath) {
|
|
353
|
+
return DEFAULT_MIME_TYPE;
|
|
354
|
+
}
|
|
355
|
+
const ext = filePath.split(".").pop();
|
|
356
|
+
return getMimeType(ext);
|
|
357
|
+
}
|
|
358
|
+
function isImageMimeType(mimeType) {
|
|
359
|
+
return mimeType.startsWith("image/");
|
|
360
|
+
}
|
|
361
|
+
function isVideoMimeType(mimeType) {
|
|
362
|
+
return mimeType.startsWith("video/");
|
|
363
|
+
}
|
|
364
|
+
function isAudioMimeType(mimeType) {
|
|
365
|
+
return mimeType.startsWith("audio/");
|
|
366
|
+
}
|
|
367
|
+
function isDocumentMimeType(mimeType) {
|
|
368
|
+
return mimeType === "application/pdf" || mimeType.includes("wordprocessingml") || mimeType.includes("spreadsheetml") || mimeType.includes("presentationml") || mimeType.includes("msword") || mimeType.includes("ms-excel") || mimeType.includes("ms-powerpoint") || mimeType.includes("opendocument");
|
|
369
|
+
}
|
|
370
|
+
function getExtensionFromMimeType(mimeType) {
|
|
371
|
+
for (const [ext, mime] of Object.entries(MIME_TYPES)) {
|
|
372
|
+
if (mime === mimeType) {
|
|
373
|
+
return ext;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return void 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/attachments/download-manager.ts
|
|
380
|
+
import { AttachmentState as AttachmentState3, EncodingType as EncodingType3 } from "@powersync/attachments";
|
|
381
|
+
async function blobToArrayBuffer(blob) {
|
|
382
|
+
if (typeof blob.arrayBuffer === "function") {
|
|
383
|
+
return blob.arrayBuffer();
|
|
384
|
+
}
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const reader = new FileReader();
|
|
387
|
+
reader.onloadend = () => {
|
|
388
|
+
if (reader.result instanceof ArrayBuffer) {
|
|
389
|
+
resolve(reader.result);
|
|
390
|
+
} else {
|
|
391
|
+
reject(new Error("FileReader did not return ArrayBuffer"));
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
reader.onerror = () => reject(reader.error ?? new Error("FileReader.readAsArrayBuffer failed - blob conversion error"));
|
|
395
|
+
reader.readAsArrayBuffer(blob);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
async function downloadRecord(deps, record) {
|
|
399
|
+
const {
|
|
400
|
+
logger,
|
|
401
|
+
storage,
|
|
402
|
+
remoteStorage,
|
|
403
|
+
platform
|
|
404
|
+
} = deps;
|
|
405
|
+
if (!deps.downloadAttachments) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
if (!record.local_uri) {
|
|
409
|
+
record.local_uri = deps.getLocalFilePathSuffix(record.filename);
|
|
410
|
+
}
|
|
411
|
+
const localFilePathUri = deps.getLocalUri(record.local_uri);
|
|
412
|
+
if (await storage.fileExists(localFilePathUri)) {
|
|
413
|
+
logger.debug(`[DownloadManager] Local file already downloaded, marking "${record.id}" as synced`);
|
|
414
|
+
await deps.update({
|
|
415
|
+
...record,
|
|
416
|
+
state: AttachmentState3.SYNCED
|
|
417
|
+
});
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const downloadResult = await remoteStorage.downloadFile(record.filename);
|
|
422
|
+
const lastSlash = localFilePathUri.lastIndexOf("/");
|
|
423
|
+
const dirPath = lastSlash >= 0 ? localFilePathUri.substring(0, lastSlash + 1) : localFilePathUri;
|
|
424
|
+
await storage.makeDir(dirPath);
|
|
425
|
+
if (typeof downloadResult === "string" && downloadResult.startsWith("file://")) {
|
|
426
|
+
const tempFilePath = downloadResult;
|
|
427
|
+
try {
|
|
428
|
+
logger.debug(`[DownloadManager] Using direct file copy for "${record.id}"`);
|
|
429
|
+
await storage.copyFile(tempFilePath, localFilePathUri);
|
|
430
|
+
const fileInfo = await platform.fileSystem.getFileInfo(localFilePathUri);
|
|
431
|
+
const fileSize = fileInfo?.exists ? fileInfo.size ?? 0 : 0;
|
|
432
|
+
const ext = record.filename.split(".").pop()?.toLowerCase();
|
|
433
|
+
const mediaType = getMimeType(ext);
|
|
434
|
+
await deps.update({
|
|
435
|
+
...record,
|
|
436
|
+
media_type: mediaType,
|
|
437
|
+
size: fileSize,
|
|
438
|
+
state: AttachmentState3.SYNCED
|
|
439
|
+
});
|
|
440
|
+
logger.debug(`[DownloadManager] Downloaded attachment "${record.id}" (direct copy)`);
|
|
441
|
+
deps.invalidateStatsCache();
|
|
442
|
+
deps.notify(true);
|
|
443
|
+
return true;
|
|
444
|
+
} finally {
|
|
445
|
+
try {
|
|
446
|
+
await platform.fileSystem.deleteFile(tempFilePath);
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let fileBlob;
|
|
452
|
+
if (typeof downloadResult === "string") {
|
|
453
|
+
const binaryString = atob(downloadResult);
|
|
454
|
+
const bytes2 = new Uint8Array(binaryString.length);
|
|
455
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
456
|
+
bytes2[i] = binaryString.charCodeAt(i);
|
|
457
|
+
}
|
|
458
|
+
fileBlob = new Blob([bytes2]);
|
|
459
|
+
} else {
|
|
460
|
+
fileBlob = downloadResult;
|
|
461
|
+
}
|
|
462
|
+
const BLOB_TIMEOUT_MS = 3e4;
|
|
463
|
+
const arrayBuffer = await Promise.race([blobToArrayBuffer(fileBlob), new Promise((_, reject) => setTimeout(() => reject(new Error("Blob conversion timeout - file too large")), BLOB_TIMEOUT_MS))]);
|
|
464
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
465
|
+
const CHUNK_SIZE = 32768;
|
|
466
|
+
const chunks = [];
|
|
467
|
+
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
468
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)));
|
|
469
|
+
}
|
|
470
|
+
const base64Data = btoa(chunks.join(""));
|
|
471
|
+
await storage.writeFile(localFilePathUri, base64Data, {
|
|
472
|
+
encoding: EncodingType3.Base64
|
|
473
|
+
});
|
|
474
|
+
await deps.update({
|
|
475
|
+
...record,
|
|
476
|
+
media_type: fileBlob.type,
|
|
477
|
+
size: bytes.length,
|
|
478
|
+
state: AttachmentState3.SYNCED
|
|
479
|
+
});
|
|
480
|
+
logger.debug(`[DownloadManager] Downloaded attachment "${record.id}"`);
|
|
481
|
+
deps.invalidateStatsCache();
|
|
482
|
+
deps.notify(true);
|
|
483
|
+
return true;
|
|
484
|
+
} catch (e) {
|
|
485
|
+
if (deps.onDownloadError) {
|
|
486
|
+
const {
|
|
487
|
+
retry
|
|
488
|
+
} = await deps.onDownloadError(record, e);
|
|
489
|
+
if (!retry) {
|
|
490
|
+
await deps.update({
|
|
491
|
+
...record,
|
|
492
|
+
state: AttachmentState3.ARCHIVED
|
|
493
|
+
});
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/attachments/upload-manager.ts
|
|
502
|
+
import { AttachmentState as AttachmentState4 } from "@powersync/attachments";
|
|
503
|
+
var UPLOAD_PROCESSING_DELAY_MS = 1e3;
|
|
504
|
+
var MIN_POLL_INTERVAL_MS = 5e3;
|
|
505
|
+
function isPermanentError(error) {
|
|
506
|
+
const message = error.message.toLowerCase();
|
|
507
|
+
const code = extractErrorCode(error);
|
|
508
|
+
const permanentCodes = ["400", "401", "403", "404", "413", "415", "422"];
|
|
509
|
+
if (code && permanentCodes.includes(code)) {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (message.includes("not found") || message.includes("unauthorized") || message.includes("forbidden") || message.includes("access denied")) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
function extractErrorCode(error) {
|
|
518
|
+
const message = error.message;
|
|
519
|
+
const patterns = [
|
|
520
|
+
/\bstatus[:\s]+(\d{3})\b/i,
|
|
521
|
+
// "status: 404" or "status 404"
|
|
522
|
+
/\bHTTP[\/\s]+\d+(?:\.\d+)?\s+(\d{3})/i,
|
|
523
|
+
// "HTTP/1.1 404" or "HTTP 404"
|
|
524
|
+
/\b(\d{3})\s+(?:error|failed|forbidden|unauthorized|not\s+found)/i,
|
|
525
|
+
// "404 not found"
|
|
526
|
+
/\berror[:\s]+(\d{3})\b/i
|
|
527
|
+
// "error: 500" or "error 500"
|
|
528
|
+
];
|
|
529
|
+
for (const pattern of patterns) {
|
|
530
|
+
const match = message.match(pattern);
|
|
531
|
+
if (match) {
|
|
532
|
+
return match[1];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if ("status" in error && typeof error.status === "number") {
|
|
536
|
+
return String(error.status);
|
|
537
|
+
}
|
|
538
|
+
return void 0;
|
|
539
|
+
}
|
|
540
|
+
async function getPendingUploads(powersync, tableName) {
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
return powersync.getAll(`SELECT * FROM ${tableName}
|
|
543
|
+
WHERE state = ? AND (upload_next_retry_at IS NULL OR upload_next_retry_at <= ?)
|
|
544
|
+
ORDER BY timestamp ASC`, [AttachmentState4.QUEUED_UPLOAD, now]);
|
|
545
|
+
}
|
|
546
|
+
async function getSoonestRetryTime(powersync, tableName) {
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
const result = await powersync.getOptional(`SELECT MIN(upload_next_retry_at) as soonest FROM ${tableName}
|
|
549
|
+
WHERE state = ? AND upload_next_retry_at > ?`, [AttachmentState4.QUEUED_UPLOAD, now]);
|
|
550
|
+
return result?.soonest ?? null;
|
|
551
|
+
}
|
|
552
|
+
async function getFailedPermanentUploads(powersync, tableName) {
|
|
553
|
+
return powersync.getAll(`SELECT * FROM ${tableName} WHERE state = ?`, [5 /* FAILED_PERMANENT */]);
|
|
554
|
+
}
|
|
555
|
+
async function getStaleUploads(powersync, tableName, staleDaysThreshold) {
|
|
556
|
+
const threshold = Date.now() - staleDaysThreshold * 24 * 60 * 60 * 1e3;
|
|
557
|
+
return powersync.getAll(`SELECT * FROM ${tableName}
|
|
558
|
+
WHERE state = ? AND timestamp < ?`, [AttachmentState4.QUEUED_UPLOAD, threshold]);
|
|
559
|
+
}
|
|
560
|
+
async function getSyncedUploadsWithPendingCallback(powersync, tableName) {
|
|
561
|
+
return powersync.getAll(`SELECT * FROM ${tableName}
|
|
562
|
+
WHERE state = ? AND upload_metadata LIKE '%"onCompleteCallback":true%'`, [AttachmentState4.SYNCED]);
|
|
563
|
+
}
|
|
564
|
+
async function clearUploadCallback(powersync, tableName, id, uploadMetadata) {
|
|
565
|
+
if (!uploadMetadata) return;
|
|
566
|
+
try {
|
|
567
|
+
const metadata = JSON.parse(uploadMetadata);
|
|
568
|
+
delete metadata.onCompleteCallback;
|
|
569
|
+
await powersync.execute(`UPDATE ${tableName} SET upload_metadata = ? WHERE id = ?`, [JSON.stringify(metadata), id]);
|
|
570
|
+
} catch {
|
|
571
|
+
await powersync.execute(`UPDATE ${tableName} SET upload_metadata = NULL WHERE id = ?`, [id]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function markUploadSynced(deps, record) {
|
|
575
|
+
const {
|
|
576
|
+
powersync,
|
|
577
|
+
tableName,
|
|
578
|
+
logger,
|
|
579
|
+
onUploadComplete
|
|
580
|
+
} = deps;
|
|
581
|
+
await powersync.execute(`UPDATE ${tableName}
|
|
582
|
+
SET state = ?, local_uri = ?, upload_error = NULL, upload_error_code = NULL
|
|
583
|
+
WHERE id = ?`, [AttachmentState4.SYNCED, record.upload_source_uri, record.id]);
|
|
584
|
+
logger.info(`[UploadManager] Upload complete: ${record.id}`);
|
|
585
|
+
if (onUploadComplete) {
|
|
586
|
+
try {
|
|
587
|
+
await onUploadComplete(record);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
logger.error(`[UploadManager] onUploadComplete failed for ${record.id}:`, error);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function markUploadPermanentFailure(deps, record, errorMessage, errorCode) {
|
|
594
|
+
const {
|
|
595
|
+
powersync,
|
|
596
|
+
tableName,
|
|
597
|
+
logger,
|
|
598
|
+
onUploadFailed
|
|
599
|
+
} = deps;
|
|
600
|
+
await powersync.execute(`UPDATE ${tableName}
|
|
601
|
+
SET state = ?, upload_error = ?, upload_error_code = ?
|
|
602
|
+
WHERE id = ?`, [5 /* FAILED_PERMANENT */, errorMessage, errorCode || null, record.id]);
|
|
603
|
+
logger.warn(`[UploadManager] Permanent failure: ${record.id} - ${errorMessage}`);
|
|
604
|
+
if (onUploadFailed) {
|
|
605
|
+
onUploadFailed(record, new Error(errorMessage));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async function scheduleUploadRetry(deps, record, error) {
|
|
609
|
+
const {
|
|
610
|
+
powersync,
|
|
611
|
+
tableName,
|
|
612
|
+
logger,
|
|
613
|
+
uploadConfig
|
|
614
|
+
} = deps;
|
|
615
|
+
const retryCount = (record.upload_retry_count || 0) + 1;
|
|
616
|
+
if (retryCount > uploadConfig.maxRetryCount) {
|
|
617
|
+
logger.warn(`[UploadManager] Max retry count (${uploadConfig.maxRetryCount}) exceeded for ${record.id}`);
|
|
618
|
+
await markUploadPermanentFailure(deps, record, `Max retry count exceeded after ${retryCount - 1} attempts. Last error: ${error.message}`, "MAX_RETRIES");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const baseDelay = calculateBackoffDelay(retryCount - 1, {
|
|
622
|
+
baseDelayMs: uploadConfig.baseRetryDelayMs,
|
|
623
|
+
maxDelayMs: uploadConfig.maxRetryDelayMs,
|
|
624
|
+
backoffMultiplier: 2
|
|
625
|
+
});
|
|
626
|
+
const nextRetryAt = Date.now() + addJitter(baseDelay);
|
|
627
|
+
await powersync.execute(`UPDATE ${tableName}
|
|
628
|
+
SET upload_retry_count = ?, upload_next_retry_at = ?, upload_error = ?
|
|
629
|
+
WHERE id = ?`, [retryCount, nextRetryAt, error.message, record.id]);
|
|
630
|
+
const delaySeconds = Math.round((nextRetryAt - Date.now()) / 1e3);
|
|
631
|
+
logger.info(`[UploadManager] Scheduled retry ${retryCount}/${uploadConfig.maxRetryCount} for ${record.id} in ${delaySeconds}s`);
|
|
632
|
+
}
|
|
633
|
+
async function uploadOne(deps, state, record) {
|
|
634
|
+
const {
|
|
635
|
+
platform,
|
|
636
|
+
logger,
|
|
637
|
+
uploadHandler,
|
|
638
|
+
uploadConfig
|
|
639
|
+
} = deps;
|
|
640
|
+
const signal = state.abortController.signal;
|
|
641
|
+
if (signal.aborted) return;
|
|
642
|
+
if (!uploadHandler) {
|
|
643
|
+
await markUploadPermanentFailure(deps, record, "No upload handler configured");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
state.activeUploads.set(record.id, {
|
|
647
|
+
id: record.id,
|
|
648
|
+
filename: record.filename,
|
|
649
|
+
phase: "uploading"
|
|
650
|
+
});
|
|
651
|
+
try {
|
|
652
|
+
if (!record.upload_source_uri) {
|
|
653
|
+
await markUploadPermanentFailure(deps, record, "No source file URI");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const fileInfo = await platform.fileSystem.getFileInfo(record.upload_source_uri);
|
|
657
|
+
if (!fileInfo || !fileInfo.exists) {
|
|
658
|
+
await markUploadPermanentFailure(deps, record, "Source file no longer available");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await withTimeout(uploadHandler.uploadFile(record.id, record.upload_source_uri, record.media_type || "application/octet-stream", signal), uploadConfig.timeoutMs, "Upload timed out", signal);
|
|
662
|
+
await markUploadSynced(deps, record);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
665
|
+
const isAbortError = err.name === "AbortError" || typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "AbortError";
|
|
666
|
+
if (isAbortError) {
|
|
667
|
+
logger.debug?.("[UploadManager] Upload aborted (pause/dispose), will retry on resume", {
|
|
668
|
+
id: record.id
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
logger.error(`[UploadManager] Upload failed for ${record.id}:`, err.message);
|
|
673
|
+
if (isPermanentError(err)) {
|
|
674
|
+
await markUploadPermanentFailure(deps, record, err.message, extractErrorCode(err));
|
|
675
|
+
} else {
|
|
676
|
+
await scheduleUploadRetry(deps, record, err);
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
state.activeUploads.delete(record.id);
|
|
680
|
+
deps.notify(false);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async function startUploadProcessing(deps, state) {
|
|
684
|
+
const {
|
|
685
|
+
logger,
|
|
686
|
+
uploadHandler,
|
|
687
|
+
uploadConfig,
|
|
688
|
+
tableName,
|
|
689
|
+
powersync
|
|
690
|
+
} = deps;
|
|
691
|
+
if (state.paused || state.processing || !uploadHandler) return;
|
|
692
|
+
logger.info("[UploadManager] Starting upload processing");
|
|
693
|
+
state.processing = true;
|
|
694
|
+
try {
|
|
695
|
+
while (!state.abortController.signal.aborted) {
|
|
696
|
+
const pending = await getPendingUploads(powersync, tableName);
|
|
697
|
+
if (pending.length > 0) {
|
|
698
|
+
for (let i = 0; i < pending.length; i += uploadConfig.concurrency) {
|
|
699
|
+
if (state.abortController.signal.aborted) break;
|
|
700
|
+
const batch = pending.slice(i, i + uploadConfig.concurrency);
|
|
701
|
+
const uploadPromises = batch.map((record) => {
|
|
702
|
+
const promise = uploadOne(deps, state, record);
|
|
703
|
+
state.activePromises.add(promise);
|
|
704
|
+
promise.finally(() => state.activePromises.delete(promise));
|
|
705
|
+
return promise;
|
|
706
|
+
});
|
|
707
|
+
await Promise.allSettled(uploadPromises);
|
|
708
|
+
}
|
|
709
|
+
await sleep(UPLOAD_PROCESSING_DELAY_MS, state.abortController.signal);
|
|
710
|
+
} else {
|
|
711
|
+
const soonestRetry = await getSoonestRetryTime(powersync, tableName);
|
|
712
|
+
if (soonestRetry === null) {
|
|
713
|
+
logger.info("[UploadManager] No pending uploads, stopping processing");
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
const allQueued = await powersync.getAll(`SELECT id, upload_error, upload_retry_count, upload_next_retry_at FROM ${tableName} WHERE state = ?`, [AttachmentState4.QUEUED_UPLOAD]);
|
|
717
|
+
for (const upload of allQueued) {
|
|
718
|
+
const retryIn = upload.upload_next_retry_at ? Math.round((upload.upload_next_retry_at - Date.now()) / 1e3) : 0;
|
|
719
|
+
logger.error(`[UploadManager] Upload in backoff: ${upload.id} (retry ${upload.upload_retry_count}, in ${retryIn}s) - Error: ${upload.upload_error || "unknown"}`);
|
|
720
|
+
}
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
const sleepDuration = Math.max(MIN_POLL_INTERVAL_MS, Math.min(soonestRetry - now, uploadConfig.maxRetryDelayMs));
|
|
723
|
+
logger.info(`[UploadManager] All uploads in backoff, sleeping ${Math.round(sleepDuration / 1e3)}s until next retry`);
|
|
724
|
+
await sleep(sleepDuration, state.abortController.signal);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch (error) {
|
|
728
|
+
logger.error("[UploadManager] Upload loop error:", error);
|
|
729
|
+
} finally {
|
|
730
|
+
state.processing = false;
|
|
731
|
+
logger.info("[UploadManager] Upload processing stopped");
|
|
732
|
+
deps.notify(true);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function sleep(ms, signal) {
|
|
736
|
+
return new Promise((resolve) => {
|
|
737
|
+
const timer = setTimeout(resolve, ms);
|
|
738
|
+
if (signal) {
|
|
739
|
+
signal.addEventListener("abort", () => {
|
|
740
|
+
clearTimeout(timer);
|
|
741
|
+
resolve();
|
|
742
|
+
}, {
|
|
743
|
+
once: true
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
function withTimeout(promise, ms, message, signal) {
|
|
749
|
+
return new Promise((resolve, reject) => {
|
|
750
|
+
if (signal?.aborted) {
|
|
751
|
+
reject(new AbortError());
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const timer = setTimeout(() => {
|
|
755
|
+
reject(new Error(message));
|
|
756
|
+
}, ms);
|
|
757
|
+
const abortHandler = () => {
|
|
758
|
+
clearTimeout(timer);
|
|
759
|
+
reject(new AbortError());
|
|
760
|
+
};
|
|
761
|
+
if (signal) {
|
|
762
|
+
signal.addEventListener("abort", abortHandler, {
|
|
763
|
+
once: true
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
promise.then((result) => {
|
|
767
|
+
clearTimeout(timer);
|
|
768
|
+
if (signal) {
|
|
769
|
+
signal.removeEventListener("abort", abortHandler);
|
|
770
|
+
}
|
|
771
|
+
resolve(result);
|
|
772
|
+
}, (err) => {
|
|
773
|
+
clearTimeout(timer);
|
|
774
|
+
if (signal) {
|
|
775
|
+
signal.removeEventListener("abort", abortHandler);
|
|
776
|
+
}
|
|
777
|
+
reject(err);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function createUploadManagerState() {
|
|
782
|
+
return {
|
|
783
|
+
processing: false,
|
|
784
|
+
paused: false,
|
|
785
|
+
abortController: new AbortController(),
|
|
786
|
+
activeUploads: /* @__PURE__ */ new Map(),
|
|
787
|
+
activePromises: /* @__PURE__ */ new Set()
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
function createUploadManagerDeps(partial) {
|
|
791
|
+
return {
|
|
792
|
+
...partial,
|
|
793
|
+
uploadConfig: {
|
|
794
|
+
...DEFAULT_UPLOAD_CONFIG,
|
|
795
|
+
...partial.uploadConfig
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/attachments/cache-manager.ts
|
|
801
|
+
import { AttachmentState as AttachmentState5 } from "@powersync/attachments";
|
|
802
|
+
function sanitizePathForCache(storagePath) {
|
|
803
|
+
return storagePath.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
804
|
+
}
|
|
805
|
+
function ensureFileUri(path) {
|
|
806
|
+
return path.startsWith("file://") ? path : `file://${path}`;
|
|
807
|
+
}
|
|
808
|
+
function stripFileUri(uri) {
|
|
809
|
+
return uri.replace(/^file:\/\//, "");
|
|
810
|
+
}
|
|
811
|
+
async function getCachedSize(powersync, tableName) {
|
|
812
|
+
const result = await powersync.getOptional(`SELECT COALESCE(SUM(size), 0) as total FROM ${tableName}
|
|
813
|
+
WHERE state = ? AND local_uri IS NOT NULL`, [AttachmentState5.SYNCED]);
|
|
814
|
+
return result?.total ?? 0;
|
|
815
|
+
}
|
|
816
|
+
async function getEvictionCandidates(powersync, tableName) {
|
|
817
|
+
return powersync.getAll(`SELECT id, local_uri, size FROM ${tableName}
|
|
818
|
+
WHERE state = ? AND local_uri IS NOT NULL
|
|
819
|
+
ORDER BY timestamp ASC`, [AttachmentState5.SYNCED]);
|
|
820
|
+
}
|
|
821
|
+
async function enforceCacheLimit(deps) {
|
|
822
|
+
const {
|
|
823
|
+
powersync,
|
|
824
|
+
platform,
|
|
825
|
+
logger,
|
|
826
|
+
tableName,
|
|
827
|
+
cacheConfig,
|
|
828
|
+
getLocalUri
|
|
829
|
+
} = deps;
|
|
830
|
+
if (cacheConfig.maxSize === Number.MAX_SAFE_INTEGER) {
|
|
831
|
+
return {
|
|
832
|
+
evictedCount: 0,
|
|
833
|
+
freedBytes: 0
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const currentSize = await getCachedSize(powersync, tableName);
|
|
837
|
+
const targetSize = cacheConfig.maxSize * cacheConfig.evictionTriggerThreshold;
|
|
838
|
+
if (currentSize <= targetSize) {
|
|
839
|
+
return {
|
|
840
|
+
evictedCount: 0,
|
|
841
|
+
freedBytes: 0
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
logger.info(`[CacheManager] Cache size ${formatBytes(currentSize)} exceeds limit ${formatBytes(cacheConfig.maxSize)}, starting eviction...`);
|
|
845
|
+
const candidates = await getEvictionCandidates(powersync, tableName);
|
|
846
|
+
let evictedCount = 0;
|
|
847
|
+
let freedBytes = 0;
|
|
848
|
+
let remainingSize = currentSize;
|
|
849
|
+
for (const candidate of candidates) {
|
|
850
|
+
if (remainingSize <= cacheConfig.maxSize) {
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
if (candidate.local_uri) {
|
|
854
|
+
try {
|
|
855
|
+
const fullPath = getLocalUri(candidate.local_uri);
|
|
856
|
+
await platform.fileSystem.deleteFile(fullPath);
|
|
857
|
+
} catch {
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
await powersync.execute(`UPDATE ${tableName}
|
|
861
|
+
SET state = ?, local_uri = NULL, size = 0
|
|
862
|
+
WHERE id = ?`, [AttachmentState5.QUEUED_DOWNLOAD, candidate.id]);
|
|
863
|
+
evictedCount++;
|
|
864
|
+
freedBytes += candidate.size || 0;
|
|
865
|
+
remainingSize -= candidate.size || 0;
|
|
866
|
+
}
|
|
867
|
+
if (evictedCount > 0) {
|
|
868
|
+
deps.invalidateStatsCache();
|
|
869
|
+
deps.notify(true);
|
|
870
|
+
logger.info(`[CacheManager] Evicted ${evictedCount} files, freed ${formatBytes(freedBytes)} (cache now ${formatBytes(remainingSize)})`);
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
evictedCount,
|
|
874
|
+
freedBytes
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
async function isCacheNearCapacity(deps) {
|
|
878
|
+
const {
|
|
879
|
+
powersync,
|
|
880
|
+
tableName,
|
|
881
|
+
cacheConfig
|
|
882
|
+
} = deps;
|
|
883
|
+
if (cacheConfig.maxSize === Number.MAX_SAFE_INTEGER) {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
const currentSize = await getCachedSize(powersync, tableName);
|
|
887
|
+
const stopThreshold = cacheConfig.maxSize * cacheConfig.downloadStopThreshold;
|
|
888
|
+
return currentSize >= stopThreshold;
|
|
889
|
+
}
|
|
890
|
+
function formatBytes(bytes) {
|
|
891
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
892
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
893
|
+
}
|
|
894
|
+
if (bytes >= 1024 * 1024) {
|
|
895
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
896
|
+
}
|
|
897
|
+
if (bytes >= 1024) {
|
|
898
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
899
|
+
}
|
|
900
|
+
return `${bytes} bytes`;
|
|
901
|
+
}
|
|
902
|
+
async function clearCache(deps) {
|
|
903
|
+
const {
|
|
904
|
+
powersync,
|
|
905
|
+
platform,
|
|
906
|
+
logger,
|
|
907
|
+
tableName,
|
|
908
|
+
getLocalUri
|
|
909
|
+
} = deps;
|
|
910
|
+
logger.info("[CacheManager] Clearing cache...");
|
|
911
|
+
const records = await powersync.getAll(`SELECT id, local_uri FROM ${tableName}
|
|
912
|
+
WHERE local_uri IS NOT NULL
|
|
913
|
+
AND state IN (?, ?, ?)`, [AttachmentState5.QUEUED_DOWNLOAD, AttachmentState5.SYNCED, AttachmentState5.QUEUED_SYNC]);
|
|
914
|
+
for (const record of records) {
|
|
915
|
+
if (record.local_uri) {
|
|
916
|
+
try {
|
|
917
|
+
const fullPath = getLocalUri(record.local_uri);
|
|
918
|
+
await platform.fileSystem.deleteFile(fullPath);
|
|
919
|
+
} catch {
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
await powersync.execute(`UPDATE ${tableName}
|
|
924
|
+
SET state = ?, local_uri = NULL, size = 0
|
|
925
|
+
WHERE state IN (?, ?, ?)`, [AttachmentState5.QUEUED_DOWNLOAD, AttachmentState5.QUEUED_DOWNLOAD, AttachmentState5.SYNCED, AttachmentState5.QUEUED_SYNC]);
|
|
926
|
+
deps.invalidateStatsCache();
|
|
927
|
+
deps.notify(true);
|
|
928
|
+
logger.info("[CacheManager] Cache cleared");
|
|
929
|
+
}
|
|
930
|
+
async function cacheLocalFile(deps, storagePath, sourceUri) {
|
|
931
|
+
const {
|
|
932
|
+
powersync,
|
|
933
|
+
platform,
|
|
934
|
+
logger,
|
|
935
|
+
tableName,
|
|
936
|
+
getLocalUri
|
|
937
|
+
} = deps;
|
|
938
|
+
try {
|
|
939
|
+
const sourceInfo = await platform.fileSystem.getFileInfo(sourceUri);
|
|
940
|
+
if (!sourceInfo || !sourceInfo.exists) {
|
|
941
|
+
logger.warn(`[CacheManager] Source file does not exist: ${sourceUri}`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const localPath = sanitizePathForCache(storagePath);
|
|
945
|
+
const localUri = getLocalUri(localPath);
|
|
946
|
+
const dir = localUri.substring(0, localUri.lastIndexOf("/"));
|
|
947
|
+
await platform.fileSystem.makeDirectory(dir, {
|
|
948
|
+
intermediates: true
|
|
949
|
+
});
|
|
950
|
+
await platform.fileSystem.copyFile(sourceUri, localUri);
|
|
951
|
+
const info = await platform.fileSystem.getFileInfo(localUri);
|
|
952
|
+
const size = info && info.exists ? info.size : 0;
|
|
953
|
+
const ext = storagePath.split(".").pop()?.toLowerCase() ?? "";
|
|
954
|
+
const mediaTypeMap = {
|
|
955
|
+
jpg: "image/jpeg",
|
|
956
|
+
jpeg: "image/jpeg",
|
|
957
|
+
png: "image/png",
|
|
958
|
+
webp: "image/webp",
|
|
959
|
+
heic: "image/heic",
|
|
960
|
+
heif: "image/heif",
|
|
961
|
+
gif: "image/gif",
|
|
962
|
+
mp4: "video/mp4",
|
|
963
|
+
mov: "video/quicktime"
|
|
964
|
+
};
|
|
965
|
+
const mediaType = mediaTypeMap[ext] ?? "application/octet-stream";
|
|
966
|
+
await powersync.execute(`INSERT INTO ${tableName} (id, filename, media_type, state, local_uri, size, timestamp)
|
|
967
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
968
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
969
|
+
local_uri = excluded.local_uri,
|
|
970
|
+
size = excluded.size,
|
|
971
|
+
state = excluded.state,
|
|
972
|
+
timestamp = excluded.timestamp`, [storagePath, storagePath, mediaType, AttachmentState5.SYNCED, localPath, size, Date.now()]);
|
|
973
|
+
deps.invalidateStatsCache();
|
|
974
|
+
deps.notify(true);
|
|
975
|
+
logger.info(`[CacheManager] Cached local file: ${storagePath} (${Math.round(size / 1024)}KB)`);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
logger.warn(`[CacheManager] Failed to cache local file: ${storagePath}`, err);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
async function getLocalUriForStoragePath(deps, storagePath, storage) {
|
|
981
|
+
const {
|
|
982
|
+
powersync,
|
|
983
|
+
logger,
|
|
984
|
+
tableName,
|
|
985
|
+
getLocalUri
|
|
986
|
+
} = deps;
|
|
987
|
+
let result = null;
|
|
988
|
+
try {
|
|
989
|
+
result = await powersync.getOptional(`SELECT local_uri, upload_source_uri, state FROM ${tableName}
|
|
990
|
+
WHERE id = ? AND (
|
|
991
|
+
(state = ? AND local_uri IS NOT NULL) OR
|
|
992
|
+
(state = ? AND upload_source_uri IS NOT NULL)
|
|
993
|
+
)`, [storagePath, AttachmentState5.SYNCED, AttachmentState5.QUEUED_UPLOAD]);
|
|
994
|
+
} catch (queryError) {
|
|
995
|
+
logger.error(`[CacheManager] getLocalUriForStoragePath query failed:`, {
|
|
996
|
+
storagePath,
|
|
997
|
+
syncedState: AttachmentState5.SYNCED,
|
|
998
|
+
queuedUploadState: AttachmentState5.QUEUED_UPLOAD,
|
|
999
|
+
error: queryError
|
|
1000
|
+
});
|
|
1001
|
+
throw queryError;
|
|
1002
|
+
}
|
|
1003
|
+
if (!result) return null;
|
|
1004
|
+
let localPath = null;
|
|
1005
|
+
if (result.state === AttachmentState5.SYNCED && result.local_uri) {
|
|
1006
|
+
localPath = result.local_uri;
|
|
1007
|
+
} else if (result.state === AttachmentState5.QUEUED_UPLOAD && result.upload_source_uri) {
|
|
1008
|
+
const uri2 = ensureFileUri(result.upload_source_uri);
|
|
1009
|
+
try {
|
|
1010
|
+
const exists = await storage.fileExists(stripFileUri(uri2));
|
|
1011
|
+
if (!exists) {
|
|
1012
|
+
logger.warn(`[CacheManager] Upload source file missing: ${storagePath}`);
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
logger.warn(`[CacheManager] Failed to verify upload source exists: ${storagePath}`, e);
|
|
1017
|
+
}
|
|
1018
|
+
return uri2;
|
|
1019
|
+
}
|
|
1020
|
+
if (!localPath) return null;
|
|
1021
|
+
const fullPath = getLocalUri(localPath);
|
|
1022
|
+
const uri = ensureFileUri(fullPath);
|
|
1023
|
+
try {
|
|
1024
|
+
const exists = await storage.fileExists(stripFileUri(uri));
|
|
1025
|
+
if (!exists) {
|
|
1026
|
+
logger.warn(`[CacheManager] Cached file missing from disk, re-queuing: ${storagePath}`);
|
|
1027
|
+
await powersync.execute(`UPDATE ${tableName} SET state = ?, local_uri = NULL WHERE id = ?`, [AttachmentState5.QUEUED_DOWNLOAD, storagePath]);
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
logger.warn(`[CacheManager] Failed to verify file exists: ${storagePath}`, e);
|
|
1032
|
+
}
|
|
1033
|
+
return uri;
|
|
1034
|
+
}
|
|
1035
|
+
async function copyToManagedCache(platform, logger, sourceUri, storagePath) {
|
|
1036
|
+
const localPath = storagePath.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
1037
|
+
const cacheDir = platform.fileSystem.getCacheDirectory();
|
|
1038
|
+
const targetUri = `${cacheDir}upload_queue/${localPath}`;
|
|
1039
|
+
const dir = targetUri.substring(0, targetUri.lastIndexOf("/"));
|
|
1040
|
+
await platform.fileSystem.makeDirectory(dir, {
|
|
1041
|
+
intermediates: true
|
|
1042
|
+
});
|
|
1043
|
+
await platform.fileSystem.copyFile(sourceUri, targetUri);
|
|
1044
|
+
logger.info(`[CacheManager] Copied to managed cache: ${targetUri}`);
|
|
1045
|
+
return targetUri;
|
|
1046
|
+
}
|
|
1047
|
+
function createCacheManagerDeps(partial) {
|
|
1048
|
+
return {
|
|
1049
|
+
...partial,
|
|
1050
|
+
cacheConfig: {
|
|
1051
|
+
...DEFAULT_CACHE_CONFIG,
|
|
1052
|
+
...partial.cacheConfig
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// src/attachments/pol-attachment-queue.ts
|
|
1058
|
+
import { AbstractAttachmentQueue as AbstractAttachmentQueue2, AttachmentState as AttachmentState6 } from "@powersync/attachments";
|
|
1059
|
+
var NOTIFY_THROTTLE_MS = 500;
|
|
1060
|
+
var STATS_CACHE_TTL_MS = 500;
|
|
1061
|
+
var PolAttachmentQueue = class extends AbstractAttachmentQueue2 {
|
|
1062
|
+
// ─── Private State ─────────────────────────────────────────────────────────
|
|
1063
|
+
platform;
|
|
1064
|
+
polLogger;
|
|
1065
|
+
source;
|
|
1066
|
+
remoteStorage;
|
|
1067
|
+
uploadHandler;
|
|
1068
|
+
uploadConfig;
|
|
1069
|
+
downloadConfig;
|
|
1070
|
+
compressionConfig;
|
|
1071
|
+
cacheConfig;
|
|
1072
|
+
// Upload manager state
|
|
1073
|
+
_uploadState;
|
|
1074
|
+
// Lifecycle state
|
|
1075
|
+
_disposed = false;
|
|
1076
|
+
_initialized = false;
|
|
1077
|
+
_watchGeneration = 0;
|
|
1078
|
+
_watchIdsCleanup = void 0;
|
|
1079
|
+
_watchMutex = Promise.resolve();
|
|
1080
|
+
_networkListenerCleanup = void 0;
|
|
1081
|
+
_wasConnected = null;
|
|
1082
|
+
// Notification state
|
|
1083
|
+
_progressCallbacks = /* @__PURE__ */ new Set();
|
|
1084
|
+
_lastNotifyTime = 0;
|
|
1085
|
+
_notifyTimer = null;
|
|
1086
|
+
// Stats cache
|
|
1087
|
+
_cachedStats = null;
|
|
1088
|
+
_cachedStatsTimestamp = 0;
|
|
1089
|
+
constructor(options) {
|
|
1090
|
+
const storage = new PolStorageAdapter({
|
|
1091
|
+
platform: options.platform,
|
|
1092
|
+
remoteStorage: options.remoteStorage,
|
|
1093
|
+
attachmentDirectoryName: options.attachmentDirectoryName,
|
|
1094
|
+
logger: options.platform.logger
|
|
1095
|
+
});
|
|
1096
|
+
super({
|
|
1097
|
+
...options,
|
|
1098
|
+
storage,
|
|
1099
|
+
syncInterval: 0,
|
|
1100
|
+
cacheLimit: Number.MAX_SAFE_INTEGER
|
|
1101
|
+
});
|
|
1102
|
+
this.platform = options.platform;
|
|
1103
|
+
this.polLogger = options.platform.logger;
|
|
1104
|
+
this.source = options.source;
|
|
1105
|
+
this.remoteStorage = options.remoteStorage;
|
|
1106
|
+
this.uploadHandler = options.uploadHandler;
|
|
1107
|
+
this.uploadConfig = {
|
|
1108
|
+
...DEFAULT_UPLOAD_CONFIG,
|
|
1109
|
+
...options.uploadConfig
|
|
1110
|
+
};
|
|
1111
|
+
this.downloadConfig = {
|
|
1112
|
+
...DEFAULT_DOWNLOAD_CONFIG,
|
|
1113
|
+
...options.downloadConfig
|
|
1114
|
+
};
|
|
1115
|
+
this.compressionConfig = {
|
|
1116
|
+
...DEFAULT_COMPRESSION_CONFIG,
|
|
1117
|
+
...options.compression
|
|
1118
|
+
};
|
|
1119
|
+
this.cacheConfig = {
|
|
1120
|
+
...DEFAULT_CACHE_CONFIG,
|
|
1121
|
+
...options.cache
|
|
1122
|
+
};
|
|
1123
|
+
this._uploadState = createUploadManagerState();
|
|
1124
|
+
}
|
|
1125
|
+
// ─── Parent Overrides ──────────────────────────────────────────────────────
|
|
1126
|
+
watchUploads() {
|
|
1127
|
+
this.polLogger?.debug?.("[PolAttachmentQueue] Parent watchUploads disabled - using custom upload engine");
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Override parent's expireCache to disable count-based cache eviction.
|
|
1131
|
+
*
|
|
1132
|
+
* The parent implementation deletes SYNCED/ARCHIVED records beyond cacheLimit,
|
|
1133
|
+
* which caused a bug where downloads would reset because:
|
|
1134
|
+
* 1. expireCache deleted records beyond cacheLimit (default 100)
|
|
1135
|
+
* 2. watchIds still emitted those IDs
|
|
1136
|
+
* 3. They got re-created as QUEUED_DOWNLOAD
|
|
1137
|
+
* 4. Downloads restarted in an infinite loop
|
|
1138
|
+
*
|
|
1139
|
+
* Our implementation uses size-based cache limits only (via clearCache/cacheConfig.maxSize).
|
|
1140
|
+
*/
|
|
1141
|
+
async expireCache() {
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Override parent's watchDownloads to use concurrent downloads.
|
|
1145
|
+
*
|
|
1146
|
+
* The parent implementation downloads one file at a time.
|
|
1147
|
+
* This override processes downloads in parallel batches for faster sync.
|
|
1148
|
+
*/
|
|
1149
|
+
watchDownloads() {
|
|
1150
|
+
if (!this.options.downloadAttachments) {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
this.idsToDownload(async (ids) => {
|
|
1154
|
+
ids.forEach((id) => this.downloadQueue.add(id));
|
|
1155
|
+
this._downloadRecordsConcurrent();
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Process pending downloads with concurrency control.
|
|
1160
|
+
* Downloads multiple files in parallel based on downloadConfig.concurrency.
|
|
1161
|
+
* Enforces cache size limits after each batch completes.
|
|
1162
|
+
*/
|
|
1163
|
+
async _downloadRecordsConcurrent() {
|
|
1164
|
+
if (!this.options.downloadAttachments || this.downloading || this._disposed) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
this.downloading = true;
|
|
1168
|
+
const concurrency = this.downloadConfig.concurrency;
|
|
1169
|
+
const hasMaxSize = this.cacheConfig.maxSize !== Number.MAX_SAFE_INTEGER;
|
|
1170
|
+
try {
|
|
1171
|
+
while (this.downloadQueue.size > 0 && !this._disposed) {
|
|
1172
|
+
if (hasMaxSize) {
|
|
1173
|
+
const nearCapacity = await isCacheNearCapacity(this._getCacheManagerDeps());
|
|
1174
|
+
if (nearCapacity) {
|
|
1175
|
+
this.polLogger?.warn?.(`[PolAttachmentQueue] Cache at ${Math.round(this.cacheConfig.downloadStopThreshold * 100)}% capacity, pausing downloads`);
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const batch = [];
|
|
1180
|
+
for (const id of this.downloadQueue) {
|
|
1181
|
+
batch.push(id);
|
|
1182
|
+
if (batch.length >= concurrency) break;
|
|
1183
|
+
}
|
|
1184
|
+
if (batch.length === 0) break;
|
|
1185
|
+
batch.forEach((id) => this.downloadQueue.delete(id));
|
|
1186
|
+
const downloadPromises = batch.map(async (id) => {
|
|
1187
|
+
try {
|
|
1188
|
+
const record = await this.record(id);
|
|
1189
|
+
if (record && record.state !== AttachmentState6.SYNCED) {
|
|
1190
|
+
await this.downloadRecord(record);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
this.polLogger?.warn?.(`[PolAttachmentQueue] Download failed for ${id}:`, err);
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
await Promise.all(downloadPromises);
|
|
1197
|
+
if (hasMaxSize) {
|
|
1198
|
+
await enforceCacheLimit(this._getCacheManagerDeps());
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
} finally {
|
|
1202
|
+
this.downloading = false;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
async uploadAttachment(record) {
|
|
1206
|
+
throw new Error(`uploadAttachment() is disabled in PolAttachmentQueue. Use queueUpload() for uploads. Record ID: ${record.id}`);
|
|
1207
|
+
}
|
|
1208
|
+
async watchAttachmentIds() {
|
|
1209
|
+
this.onAttachmentIdsChange(async (ids) => {
|
|
1210
|
+
const generation = ++this._watchGeneration;
|
|
1211
|
+
this.polLogger?.debug?.(`[PolAttachmentQueue] Received ${ids.length} attachment IDs from watch`);
|
|
1212
|
+
let filteredIds = ids;
|
|
1213
|
+
let skippedIds = [];
|
|
1214
|
+
if (this.source.skipDownload && ids.length > 0) {
|
|
1215
|
+
try {
|
|
1216
|
+
const context = {
|
|
1217
|
+
ids,
|
|
1218
|
+
db: this.powersync
|
|
1219
|
+
};
|
|
1220
|
+
skippedIds = await this.source.skipDownload(context);
|
|
1221
|
+
if (generation !== this._watchGeneration) {
|
|
1222
|
+
this.polLogger?.debug?.("[PolAttachmentQueue] Stale watch callback, ignoring");
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const skipSet = new Set(skippedIds);
|
|
1226
|
+
filteredIds = ids.filter((id) => !skipSet.has(id));
|
|
1227
|
+
this.polLogger?.info?.(`[PolAttachmentQueue] Pre-filtered: ${ids.length} total, ${skippedIds.length} skipped, ${filteredIds.length} to sync`);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] skipDownload filter failed, using all IDs:", error);
|
|
1230
|
+
filteredIds = ids;
|
|
1231
|
+
skippedIds = [];
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
this.polLogger?.debug?.(`[PolAttachmentQueue] Syncing attachment IDs: [${filteredIds.length} items]`);
|
|
1235
|
+
if (this.initialSync) {
|
|
1236
|
+
this.initialSync = false;
|
|
1237
|
+
if (filteredIds.length > 0) {
|
|
1238
|
+
const placeholders = filteredIds.map(() => "?").join(",");
|
|
1239
|
+
await this.powersync.execute(`UPDATE ${this.table}
|
|
1240
|
+
SET state = ${AttachmentState6.QUEUED_SYNC}
|
|
1241
|
+
WHERE state < ${AttachmentState6.SYNCED}
|
|
1242
|
+
AND state NOT IN (${AttachmentState6.QUEUED_UPLOAD}, ${5 /* FAILED_PERMANENT */}, ${6 /* DOWNLOAD_SKIPPED */})
|
|
1243
|
+
AND id IN (${placeholders})`, filteredIds);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const attachmentsInDatabase = await this.powersync.getAll(`SELECT * FROM ${this.table} WHERE state < ${AttachmentState6.ARCHIVED}`);
|
|
1247
|
+
for (const id of filteredIds) {
|
|
1248
|
+
const record = attachmentsInDatabase.find((r) => r.id === id);
|
|
1249
|
+
if (!record) {
|
|
1250
|
+
const newRecord = await this.newAttachmentRecord({
|
|
1251
|
+
id,
|
|
1252
|
+
state: AttachmentState6.QUEUED_SYNC
|
|
1253
|
+
});
|
|
1254
|
+
await this.saveToQueue(newRecord);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
if (PROTECTED_UPLOAD_STATES.has(record.state)) continue;
|
|
1258
|
+
if (record.upload_source_uri) continue;
|
|
1259
|
+
if (record.state === 6 /* DOWNLOAD_SKIPPED */) continue;
|
|
1260
|
+
if (record.local_uri == null || !await this.storage.fileExists(this.getLocalUri(record.local_uri))) {
|
|
1261
|
+
await this.update({
|
|
1262
|
+
...record,
|
|
1263
|
+
state: AttachmentState6.QUEUED_DOWNLOAD
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (skippedIds.length > 0) {
|
|
1268
|
+
const placeholders = skippedIds.map(() => "?").join(",");
|
|
1269
|
+
await this.powersync.execute(`UPDATE ${this.table}
|
|
1270
|
+
SET state = ${6 /* DOWNLOAD_SKIPPED */}
|
|
1271
|
+
WHERE id IN (${placeholders})
|
|
1272
|
+
AND state NOT IN (${AttachmentState6.QUEUED_UPLOAD}, ${5 /* FAILED_PERMANENT */}, ${AttachmentState6.SYNCED})`, skippedIds);
|
|
1273
|
+
}
|
|
1274
|
+
const allValidIds = [.../* @__PURE__ */ new Set([...filteredIds, ...skippedIds])];
|
|
1275
|
+
if (allValidIds.length > 0) {
|
|
1276
|
+
const placeholders = allValidIds.map(() => "?").join(",");
|
|
1277
|
+
await this.powersync.execute(`UPDATE ${this.table}
|
|
1278
|
+
SET state = ${AttachmentState6.ARCHIVED}
|
|
1279
|
+
WHERE state < ${AttachmentState6.ARCHIVED}
|
|
1280
|
+
AND state NOT IN (${AttachmentState6.QUEUED_UPLOAD}, ${5 /* FAILED_PERMANENT */})
|
|
1281
|
+
AND upload_source_uri IS NULL
|
|
1282
|
+
AND id NOT IN (${placeholders})`, allValidIds);
|
|
1283
|
+
}
|
|
1284
|
+
this._invalidateStatsCache();
|
|
1285
|
+
this._notify(true);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
async saveToQueue(record) {
|
|
1289
|
+
const updatedRecord = {
|
|
1290
|
+
...record,
|
|
1291
|
+
timestamp: Date.now()
|
|
1292
|
+
};
|
|
1293
|
+
const existing = await this.powersync.getOptional(`SELECT id, timestamp, filename, local_uri, media_type, size, state FROM ${this.table} WHERE id = ?`, [record.id]);
|
|
1294
|
+
if (existing && PROTECTED_UPLOAD_STATES.has(existing.state)) {
|
|
1295
|
+
return existing;
|
|
1296
|
+
}
|
|
1297
|
+
await this.powersync.execute(`INSERT INTO ${this.table} (id, timestamp, filename, local_uri, media_type, size, state)
|
|
1298
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1299
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1300
|
+
timestamp = excluded.timestamp,
|
|
1301
|
+
filename = excluded.filename,
|
|
1302
|
+
local_uri = COALESCE(excluded.local_uri, local_uri),
|
|
1303
|
+
media_type = COALESCE(excluded.media_type, media_type),
|
|
1304
|
+
size = COALESCE(excluded.size, size),
|
|
1305
|
+
state = CASE
|
|
1306
|
+
WHEN state IN (${AttachmentState6.QUEUED_UPLOAD}, ${5 /* FAILED_PERMANENT */})
|
|
1307
|
+
THEN state
|
|
1308
|
+
ELSE excluded.state
|
|
1309
|
+
END`, [updatedRecord.id, updatedRecord.timestamp, updatedRecord.filename, updatedRecord.local_uri || null, updatedRecord.media_type || null, updatedRecord.size || null, updatedRecord.state]);
|
|
1310
|
+
return updatedRecord;
|
|
1311
|
+
}
|
|
1312
|
+
// ─── Abstract Method Implementations ───────────────────────────────────────
|
|
1313
|
+
onAttachmentIdsChange(onUpdate) {
|
|
1314
|
+
this._watchMutex = this._watchMutex.then(async () => {
|
|
1315
|
+
if (this._disposed) return;
|
|
1316
|
+
if (this._watchIdsCleanup) {
|
|
1317
|
+
try {
|
|
1318
|
+
this._watchIdsCleanup();
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] Watch cleanup failed:", error);
|
|
1321
|
+
}
|
|
1322
|
+
this._watchIdsCleanup = void 0;
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
const cleanup = this.source.watchIds(this.powersync, onUpdate);
|
|
1326
|
+
if (typeof cleanup === "function") {
|
|
1327
|
+
this._watchIdsCleanup = cleanup;
|
|
1328
|
+
}
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] watchIds failed:", error);
|
|
1331
|
+
}
|
|
1332
|
+
}).catch((err) => {
|
|
1333
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] Watch mutex error:", err);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
async downloadRecord(record) {
|
|
1337
|
+
return downloadRecord(this._getDownloadManagerDeps(), record);
|
|
1338
|
+
}
|
|
1339
|
+
async newAttachmentRecord(record) {
|
|
1340
|
+
if (!record?.id) {
|
|
1341
|
+
throw new Error("[PolAttachmentQueue] newAttachmentRecord requires a non-empty record.id");
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
id: record.id,
|
|
1345
|
+
filename: record.filename ?? record.id,
|
|
1346
|
+
state: record.state ?? AttachmentState6.QUEUED_SYNC,
|
|
1347
|
+
local_uri: record.local_uri,
|
|
1348
|
+
size: record.size,
|
|
1349
|
+
media_type: record.media_type,
|
|
1350
|
+
timestamp: record.timestamp ?? Date.now()
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
1354
|
+
async init() {
|
|
1355
|
+
await this._createTableIfNotExists();
|
|
1356
|
+
await super.init();
|
|
1357
|
+
this._initialized = true;
|
|
1358
|
+
await this._migrateUploadColumns();
|
|
1359
|
+
if (this.uploadHandler && !this._uploadState.paused) {
|
|
1360
|
+
this._startUploadProcessing();
|
|
1361
|
+
}
|
|
1362
|
+
this.repairAttachmentSizes().catch((err) => {
|
|
1363
|
+
this.polLogger.warn("[PolAttachmentQueue] Failed to repair attachment sizes:", err);
|
|
1364
|
+
});
|
|
1365
|
+
this.platform.network.isConnected().then(async (isConnected) => {
|
|
1366
|
+
if (isConnected && !this._disposed) {
|
|
1367
|
+
const resetCount = await this.resetUploadRetries();
|
|
1368
|
+
if (resetCount > 0) {
|
|
1369
|
+
this.polLogger.info(`[PolAttachmentQueue] Reset ${resetCount} upload retries on startup (network connected)`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}).catch((err) => {
|
|
1373
|
+
this.polLogger.warn("[PolAttachmentQueue] Failed to check network connectivity on startup:", err);
|
|
1374
|
+
});
|
|
1375
|
+
this._networkListenerCleanup = this.platform.network.addConnectionListener(async (isConnected) => {
|
|
1376
|
+
const wasConnected = this._wasConnected;
|
|
1377
|
+
this._wasConnected = isConnected;
|
|
1378
|
+
if (this._disposed) return;
|
|
1379
|
+
const isReconnection = wasConnected === false && isConnected === true;
|
|
1380
|
+
if (!isConnected) {
|
|
1381
|
+
this.polLogger.debug?.("[PolAttachmentQueue] Network disconnected");
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (isReconnection) {
|
|
1385
|
+
this.polLogger.info("[PolAttachmentQueue] Network reconnected, triggering upload/download retry");
|
|
1386
|
+
} else if (wasConnected === null) {
|
|
1387
|
+
this.polLogger.debug?.("[PolAttachmentQueue] Initial network state: connected");
|
|
1388
|
+
return;
|
|
1389
|
+
} else {
|
|
1390
|
+
this.polLogger.debug?.("[PolAttachmentQueue] Network still connected");
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (this.uploadHandler) {
|
|
1394
|
+
await this.retryUploads();
|
|
1395
|
+
}
|
|
1396
|
+
if (this.options.downloadAttachments && !this.downloading) {
|
|
1397
|
+
const pendingDownloads = await this.powersync.getAll(`SELECT id FROM ${this.table} WHERE state IN (?, ?)`, [AttachmentState6.QUEUED_DOWNLOAD, AttachmentState6.QUEUED_SYNC]);
|
|
1398
|
+
if (pendingDownloads.length > 0) {
|
|
1399
|
+
this.polLogger.info(`[PolAttachmentQueue] Resuming ${pendingDownloads.length} pending downloads`);
|
|
1400
|
+
pendingDownloads.forEach((r) => this.downloadQueue.add(r.id));
|
|
1401
|
+
this._downloadRecordsConcurrent();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
this.polLogger.info("[PolAttachmentQueue] Initialized");
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Dispose the attachment queue and clean up resources.
|
|
1409
|
+
* This method is synchronous to ensure callers don't need to await it,
|
|
1410
|
+
* but it fires off async cleanup in the background for graceful shutdown.
|
|
1411
|
+
*/
|
|
1412
|
+
dispose() {
|
|
1413
|
+
this._disposed = true;
|
|
1414
|
+
this._uploadState.abortController.abort();
|
|
1415
|
+
if (this._watchIdsCleanup) {
|
|
1416
|
+
try {
|
|
1417
|
+
this._watchIdsCleanup();
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] watchIds cleanup failed:", error);
|
|
1420
|
+
}
|
|
1421
|
+
this._watchIdsCleanup = void 0;
|
|
1422
|
+
}
|
|
1423
|
+
if (this._networkListenerCleanup) {
|
|
1424
|
+
try {
|
|
1425
|
+
this._networkListenerCleanup();
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] network listener cleanup failed:", error);
|
|
1428
|
+
}
|
|
1429
|
+
this._networkListenerCleanup = void 0;
|
|
1430
|
+
}
|
|
1431
|
+
if (this._notifyTimer) {
|
|
1432
|
+
clearTimeout(this._notifyTimer);
|
|
1433
|
+
this._notifyTimer = null;
|
|
1434
|
+
}
|
|
1435
|
+
this._progressCallbacks.clear();
|
|
1436
|
+
this._uploadState.activeUploads.clear();
|
|
1437
|
+
const parentProto = Object.getPrototypeOf(Object.getPrototypeOf(this));
|
|
1438
|
+
if (parentProto && typeof parentProto.dispose === "function") {
|
|
1439
|
+
parentProto.dispose.call(this);
|
|
1440
|
+
}
|
|
1441
|
+
this._asyncCleanup().catch((err) => {
|
|
1442
|
+
this.polLogger?.warn?.("[PolAttachmentQueue] Async cleanup failed:", err);
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Async cleanup that runs in the background after dispose().
|
|
1447
|
+
* Waits for active upload promises to settle gracefully.
|
|
1448
|
+
*/
|
|
1449
|
+
async _asyncCleanup() {
|
|
1450
|
+
if (this._uploadState.activePromises.size > 0) {
|
|
1451
|
+
this.polLogger?.debug?.(`[PolAttachmentQueue] Waiting for ${this._uploadState.activePromises.size} active uploads to finish`);
|
|
1452
|
+
await Promise.allSettled([...this._uploadState.activePromises]);
|
|
1453
|
+
}
|
|
1454
|
+
this._uploadState.activePromises.clear();
|
|
1455
|
+
}
|
|
1456
|
+
// ─── Upload API ────────────────────────────────────────────────────────────
|
|
1457
|
+
async queueUpload(options) {
|
|
1458
|
+
if (!this.uploadHandler) {
|
|
1459
|
+
throw new Error("[PolAttachmentQueue] Upload not configured. Provide uploadHandler in options.");
|
|
1460
|
+
}
|
|
1461
|
+
if (!this._initialized) {
|
|
1462
|
+
throw new Error("[PolAttachmentQueue] Queue not initialized. Call init() first.");
|
|
1463
|
+
}
|
|
1464
|
+
const managedUri = await copyToManagedCache(this.platform, this.polLogger, options.sourceUri, options.storagePath);
|
|
1465
|
+
const fileInfo = await this.platform.fileSystem.getFileInfo(managedUri);
|
|
1466
|
+
const size = fileInfo?.exists ? fileInfo.size : 0;
|
|
1467
|
+
await this.powersync.execute(`INSERT OR REPLACE INTO ${this.table}
|
|
1468
|
+
(id, filename, media_type, state, local_uri, size, timestamp,
|
|
1469
|
+
upload_source_uri, upload_retry_count, upload_next_retry_at,
|
|
1470
|
+
upload_metadata, upload_bucket_id)
|
|
1471
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [options.storagePath, options.filename, options.mediaType, AttachmentState6.QUEUED_UPLOAD, null, size, Date.now(), managedUri, 0, Date.now(), options.metadata ? JSON.stringify(options.metadata) : null, options.bucketId || null]);
|
|
1472
|
+
this.polLogger.info(`[PolAttachmentQueue] Queued upload: ${options.storagePath}`);
|
|
1473
|
+
if (!this._uploadState.paused && !this._uploadState.processing) {
|
|
1474
|
+
this._startUploadProcessing();
|
|
1475
|
+
}
|
|
1476
|
+
this._notify(true);
|
|
1477
|
+
}
|
|
1478
|
+
async getPendingUploads() {
|
|
1479
|
+
return getPendingUploads(this.powersync, this.table);
|
|
1480
|
+
}
|
|
1481
|
+
async getSoonestRetryTime() {
|
|
1482
|
+
return getSoonestRetryTime(this.powersync, this.table);
|
|
1483
|
+
}
|
|
1484
|
+
async getFailedPermanentUploads() {
|
|
1485
|
+
return getFailedPermanentUploads(this.powersync, this.table);
|
|
1486
|
+
}
|
|
1487
|
+
async getStaleUploads() {
|
|
1488
|
+
return getStaleUploads(this.powersync, this.table, this.uploadConfig.staleDaysThreshold);
|
|
1489
|
+
}
|
|
1490
|
+
async getSyncedUploadsWithPendingCallback() {
|
|
1491
|
+
return getSyncedUploadsWithPendingCallback(this.powersync, this.table);
|
|
1492
|
+
}
|
|
1493
|
+
async clearUploadCallback(id) {
|
|
1494
|
+
const record = await this.record(id);
|
|
1495
|
+
if (!record) return;
|
|
1496
|
+
const polRecord = record;
|
|
1497
|
+
await clearUploadCallback(this.powersync, this.table, id, polRecord.upload_metadata);
|
|
1498
|
+
}
|
|
1499
|
+
async retryUpload(id) {
|
|
1500
|
+
await this.powersync.execute(`UPDATE ${this.table}
|
|
1501
|
+
SET state = ?, upload_retry_count = 0, upload_next_retry_at = ?, upload_error = NULL
|
|
1502
|
+
WHERE id = ?`, [AttachmentState6.QUEUED_UPLOAD, Date.now(), id]);
|
|
1503
|
+
if (!this._uploadState.paused && !this._uploadState.processing) {
|
|
1504
|
+
this._startUploadProcessing();
|
|
1505
|
+
}
|
|
1506
|
+
this._notify(true);
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Reset all uploads in QUEUED_UPLOAD state so they retry immediately.
|
|
1510
|
+
* This clears retry counts and errors, allowing uploads to be retried fresh.
|
|
1511
|
+
*
|
|
1512
|
+
* @returns The number of uploads that were reset
|
|
1513
|
+
*/
|
|
1514
|
+
async resetUploadRetries() {
|
|
1515
|
+
const result = await this.powersync.execute(`UPDATE ${this.table}
|
|
1516
|
+
SET upload_retry_count = 0,
|
|
1517
|
+
upload_next_retry_at = ?,
|
|
1518
|
+
upload_error = NULL
|
|
1519
|
+
WHERE state = ?`, [Date.now(), AttachmentState6.QUEUED_UPLOAD]);
|
|
1520
|
+
const resetCount = result.rowsAffected ?? 0;
|
|
1521
|
+
if (resetCount > 0) {
|
|
1522
|
+
this.polLogger.info(`[PolAttachmentQueue] Reset ${resetCount} upload retries`);
|
|
1523
|
+
if (!this._uploadState.paused && !this._uploadState.processing) {
|
|
1524
|
+
this._startUploadProcessing();
|
|
1525
|
+
}
|
|
1526
|
+
this._notify(true);
|
|
1527
|
+
}
|
|
1528
|
+
return resetCount;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Trigger immediate retry of uploads WITHOUT resetting their retry count.
|
|
1532
|
+
* This preserves backoff state for truly failing uploads while allowing
|
|
1533
|
+
* uploads that were waiting for network to retry immediately.
|
|
1534
|
+
*
|
|
1535
|
+
* Only affects uploads that:
|
|
1536
|
+
* - Are past their retry time (upload_next_retry_at <= now), OR
|
|
1537
|
+
* - Have no retry time set (upload_next_retry_at IS NULL)
|
|
1538
|
+
*
|
|
1539
|
+
* @returns The number of uploads that will be retried
|
|
1540
|
+
*/
|
|
1541
|
+
async retryUploads() {
|
|
1542
|
+
const now = Date.now();
|
|
1543
|
+
const result = await this.powersync.execute(`UPDATE ${this.table}
|
|
1544
|
+
SET upload_next_retry_at = ?
|
|
1545
|
+
WHERE state = ?
|
|
1546
|
+
AND (upload_next_retry_at IS NULL OR upload_next_retry_at <= ?)`, [now, AttachmentState6.QUEUED_UPLOAD, now]);
|
|
1547
|
+
const retryCount = result.rowsAffected ?? 0;
|
|
1548
|
+
if (retryCount > 0) {
|
|
1549
|
+
this.polLogger.info(`[PolAttachmentQueue] Triggering retry for ${retryCount} uploads`);
|
|
1550
|
+
if (!this._uploadState.paused && !this._uploadState.processing) {
|
|
1551
|
+
this._startUploadProcessing();
|
|
1552
|
+
}
|
|
1553
|
+
this._notify(true);
|
|
1554
|
+
} else {
|
|
1555
|
+
this.polLogger.debug?.("[PolAttachmentQueue] No uploads eligible for immediate retry");
|
|
1556
|
+
}
|
|
1557
|
+
return retryCount;
|
|
1558
|
+
}
|
|
1559
|
+
async deleteUpload(id) {
|
|
1560
|
+
const record = await this.record(id);
|
|
1561
|
+
if (record) {
|
|
1562
|
+
const polRecord = record;
|
|
1563
|
+
if (polRecord.upload_source_uri) {
|
|
1564
|
+
try {
|
|
1565
|
+
await this.platform.fileSystem.deleteFile(polRecord.upload_source_uri);
|
|
1566
|
+
} catch {
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
await this.powersync.execute(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
|
|
1571
|
+
this._notify(true);
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Re-queue an upload for an orphaned attachment.
|
|
1575
|
+
* Use this when a database record exists with a storagePath but the file was never uploaded.
|
|
1576
|
+
*
|
|
1577
|
+
* This handles the case where:
|
|
1578
|
+
* - User took a photo and the database record was created with a storagePath
|
|
1579
|
+
* - The attachment queue record was created but disappeared before upload completed
|
|
1580
|
+
* - The photo shows locally but was never uploaded to storage
|
|
1581
|
+
*
|
|
1582
|
+
* @param options.storagePath - The storage path (e.g., "projectId/fileId.jpg")
|
|
1583
|
+
* @param options.localFileUri - URI to the local file to upload
|
|
1584
|
+
* @param options.mediaType - MIME type of the file
|
|
1585
|
+
* @param options.bucketId - Optional bucket ID for multi-bucket setups
|
|
1586
|
+
* @returns true if upload was queued, false if already exists in valid state
|
|
1587
|
+
*/
|
|
1588
|
+
async requeueOrphanedUpload(options) {
|
|
1589
|
+
const {
|
|
1590
|
+
storagePath,
|
|
1591
|
+
localFileUri,
|
|
1592
|
+
mediaType,
|
|
1593
|
+
bucketId
|
|
1594
|
+
} = options;
|
|
1595
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload called for: ${storagePath}`);
|
|
1596
|
+
if (!this.uploadHandler) {
|
|
1597
|
+
this.polLogger.warn("[PolAttachmentQueue] requeueOrphanedUpload: No upload handler configured");
|
|
1598
|
+
throw new Error("[PolAttachmentQueue] Upload not configured. Provide uploadHandler in options.");
|
|
1599
|
+
}
|
|
1600
|
+
if (!this._initialized) {
|
|
1601
|
+
this.polLogger.warn("[PolAttachmentQueue] requeueOrphanedUpload: Queue not initialized");
|
|
1602
|
+
throw new Error("[PolAttachmentQueue] Queue not initialized. Call init() first.");
|
|
1603
|
+
}
|
|
1604
|
+
const existingRecord = await this.powersync.getOptional(`SELECT id, state, upload_source_uri FROM ${this.table} WHERE id = ?`, [storagePath]);
|
|
1605
|
+
if (existingRecord) {
|
|
1606
|
+
this.polLogger.debug(`[PolAttachmentQueue] requeueOrphanedUpload: Found existing record with state=${existingRecord.state}`);
|
|
1607
|
+
if (existingRecord.state === AttachmentState6.QUEUED_UPLOAD) {
|
|
1608
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: Already QUEUED_UPLOAD, skipping: ${storagePath}`);
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
if (existingRecord.state === 5 /* FAILED_PERMANENT */) {
|
|
1612
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: Already FAILED_PERMANENT, skipping: ${storagePath}`);
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
if (existingRecord.state === AttachmentState6.SYNCED) {
|
|
1616
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: Already SYNCED, skipping: ${storagePath}`);
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: Existing record in state=${existingRecord.state}, will re-queue`);
|
|
1620
|
+
} else {
|
|
1621
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: No existing record found, will create new`);
|
|
1622
|
+
}
|
|
1623
|
+
const fileInfo = await this.platform.fileSystem.getFileInfo(localFileUri);
|
|
1624
|
+
if (!fileInfo?.exists) {
|
|
1625
|
+
this.polLogger.error(`[PolAttachmentQueue] requeueOrphanedUpload: Source file not found: ${localFileUri}`);
|
|
1626
|
+
throw new Error(`Source file not found: ${localFileUri}`);
|
|
1627
|
+
}
|
|
1628
|
+
this.polLogger.debug(`[PolAttachmentQueue] requeueOrphanedUpload: Source file exists, size=${fileInfo.size}`);
|
|
1629
|
+
const managedUri = await copyToManagedCache(this.platform, this.polLogger, localFileUri, storagePath);
|
|
1630
|
+
this.polLogger.debug(`[PolAttachmentQueue] requeueOrphanedUpload: Copied to managed cache: ${managedUri}`);
|
|
1631
|
+
const managedFileInfo = await this.platform.fileSystem.getFileInfo(managedUri);
|
|
1632
|
+
const size = managedFileInfo?.exists ? managedFileInfo.size : fileInfo.size;
|
|
1633
|
+
const filename = storagePath.split("/").pop() || storagePath;
|
|
1634
|
+
await this.powersync.execute(`INSERT OR REPLACE INTO ${this.table}
|
|
1635
|
+
(id, filename, media_type, state, local_uri, size, timestamp,
|
|
1636
|
+
upload_source_uri, upload_retry_count, upload_next_retry_at,
|
|
1637
|
+
upload_metadata, upload_bucket_id, upload_error, upload_error_code)
|
|
1638
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1639
|
+
storagePath,
|
|
1640
|
+
filename,
|
|
1641
|
+
mediaType,
|
|
1642
|
+
AttachmentState6.QUEUED_UPLOAD,
|
|
1643
|
+
null,
|
|
1644
|
+
size,
|
|
1645
|
+
Date.now(),
|
|
1646
|
+
managedUri,
|
|
1647
|
+
0,
|
|
1648
|
+
// Reset retry count
|
|
1649
|
+
Date.now(),
|
|
1650
|
+
// Can retry immediately
|
|
1651
|
+
null,
|
|
1652
|
+
// No metadata
|
|
1653
|
+
bucketId || null,
|
|
1654
|
+
null,
|
|
1655
|
+
// Clear any previous error
|
|
1656
|
+
null
|
|
1657
|
+
]);
|
|
1658
|
+
this.polLogger.info(`[PolAttachmentQueue] requeueOrphanedUpload: Queued upload: ${storagePath}`);
|
|
1659
|
+
if (!this._uploadState.paused && !this._uploadState.processing) {
|
|
1660
|
+
this._startUploadProcessing();
|
|
1661
|
+
}
|
|
1662
|
+
this._notify(true);
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
// Compatibility aliases
|
|
1666
|
+
async getRecord(id) {
|
|
1667
|
+
return await this.record(id);
|
|
1668
|
+
}
|
|
1669
|
+
async getFailedUploads() {
|
|
1670
|
+
return this.getFailedPermanentUploads();
|
|
1671
|
+
}
|
|
1672
|
+
async retryFailedUpload(id) {
|
|
1673
|
+
return this.retryUpload(id);
|
|
1674
|
+
}
|
|
1675
|
+
async deleteFailedUpload(id) {
|
|
1676
|
+
return this.deleteUpload(id);
|
|
1677
|
+
}
|
|
1678
|
+
get activeUploads() {
|
|
1679
|
+
return [...this._uploadState.activeUploads.values()];
|
|
1680
|
+
}
|
|
1681
|
+
pauseUploads() {
|
|
1682
|
+
this.polLogger.info("[PolAttachmentQueue] Pausing uploads");
|
|
1683
|
+
this._uploadState.paused = true;
|
|
1684
|
+
this._uploadState.abortController.abort();
|
|
1685
|
+
this._uploadState.abortController = new AbortController();
|
|
1686
|
+
this._notify(true);
|
|
1687
|
+
}
|
|
1688
|
+
resumeUploads() {
|
|
1689
|
+
this.polLogger.info("[PolAttachmentQueue] Resuming uploads");
|
|
1690
|
+
this._uploadState.paused = false;
|
|
1691
|
+
this._uploadState.abortController = new AbortController();
|
|
1692
|
+
if (!this._uploadState.processing) {
|
|
1693
|
+
this._startUploadProcessing();
|
|
1694
|
+
}
|
|
1695
|
+
this._notify(true);
|
|
1696
|
+
}
|
|
1697
|
+
// ─── Cache API ─────────────────────────────────────────────────────────────
|
|
1698
|
+
async clearCache() {
|
|
1699
|
+
return clearCache(this._getCacheManagerDeps());
|
|
1700
|
+
}
|
|
1701
|
+
async cacheLocalFile(storagePath, sourceUri) {
|
|
1702
|
+
return cacheLocalFile(this._getCacheManagerDeps(), storagePath, sourceUri);
|
|
1703
|
+
}
|
|
1704
|
+
async getLocalUriForStoragePath(storagePath) {
|
|
1705
|
+
return getLocalUriForStoragePath(this._getCacheManagerDeps(), storagePath, this.storage);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Purge attachments by their IDs.
|
|
1709
|
+
* Archives and deletes local files for the specified attachment IDs.
|
|
1710
|
+
* Useful for edge cases where external code needs to force-remove attachments.
|
|
1711
|
+
*
|
|
1712
|
+
* @param ids - Array of attachment IDs to purge
|
|
1713
|
+
*/
|
|
1714
|
+
async purgeAttachments(ids) {
|
|
1715
|
+
if (ids.length === 0) return;
|
|
1716
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
1717
|
+
const records = await this.powersync.getAll(`SELECT id, local_uri FROM ${this.table} WHERE id IN (${placeholders})`, ids);
|
|
1718
|
+
for (const record of records) {
|
|
1719
|
+
if (record.local_uri) {
|
|
1720
|
+
try {
|
|
1721
|
+
await this.platform.fileSystem.deleteFile(this.getLocalUri(record.local_uri));
|
|
1722
|
+
} catch {
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
await this.powersync.execute(`UPDATE ${this.table}
|
|
1727
|
+
SET state = ${AttachmentState6.ARCHIVED}
|
|
1728
|
+
WHERE id IN (${placeholders})`, ids);
|
|
1729
|
+
this._invalidateStatsCache();
|
|
1730
|
+
this._notify(true);
|
|
1731
|
+
this.polLogger.info(`[PolAttachmentQueue] Purged ${ids.length} attachments`);
|
|
1732
|
+
}
|
|
1733
|
+
// ─── Progress Subscription ─────────────────────────────────────────────────
|
|
1734
|
+
onProgress(callback) {
|
|
1735
|
+
this._progressCallbacks.add(callback);
|
|
1736
|
+
this._notify(true);
|
|
1737
|
+
return () => {
|
|
1738
|
+
this._progressCallbacks.delete(callback);
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
async getStats() {
|
|
1742
|
+
const now = Date.now();
|
|
1743
|
+
if (this._cachedStats && now - this._cachedStatsTimestamp < STATS_CACHE_TTL_MS) {
|
|
1744
|
+
return {
|
|
1745
|
+
...this._cachedStats,
|
|
1746
|
+
status: this._getStatus(this._cachedStats.pendingCount),
|
|
1747
|
+
isPaused: this._uploadState.paused,
|
|
1748
|
+
isProcessing: this.uploading || this.downloading || this._uploadState.processing,
|
|
1749
|
+
// TODO: activeDownloads tracking not yet implemented - downloads are handled by
|
|
1750
|
+
// the parent AbstractAttachmentQueue which doesn't expose per-download progress.
|
|
1751
|
+
// For now, return empty array. Future: implement download tracking similar to uploads.
|
|
1752
|
+
activeDownloads: [],
|
|
1753
|
+
activeUploads: this.activeUploads
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
const rows = await this.powersync.getAll(`SELECT state, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
|
1757
|
+
FROM ${this.table} GROUP BY state`);
|
|
1758
|
+
let synced = 0, syncedSize = 0, pending = 0, pendingUpload = 0, failedPermanent = 0, skippedDownloadCount = 0;
|
|
1759
|
+
for (const r of rows) {
|
|
1760
|
+
if (r.state === AttachmentState6.SYNCED) {
|
|
1761
|
+
synced = r.cnt;
|
|
1762
|
+
syncedSize = r.sz;
|
|
1763
|
+
}
|
|
1764
|
+
if (r.state === AttachmentState6.QUEUED_DOWNLOAD || r.state === AttachmentState6.QUEUED_SYNC) pending += r.cnt;
|
|
1765
|
+
if (r.state === AttachmentState6.QUEUED_UPLOAD) pendingUpload = r.cnt;
|
|
1766
|
+
if (r.state === 5 /* FAILED_PERMANENT */) failedPermanent = r.cnt;
|
|
1767
|
+
if (r.state === 6 /* DOWNLOAD_SKIPPED */) skippedDownloadCount = r.cnt;
|
|
1768
|
+
}
|
|
1769
|
+
const staleUploads = await this.getStaleUploads();
|
|
1770
|
+
const stats = {
|
|
1771
|
+
syncedCount: synced,
|
|
1772
|
+
syncedSize,
|
|
1773
|
+
pendingCount: pending,
|
|
1774
|
+
totalExpected: synced + pending,
|
|
1775
|
+
maxCacheSize: this.cacheConfig.maxSize,
|
|
1776
|
+
compressionQuality: this.compressionConfig.quality,
|
|
1777
|
+
status: this._getStatus(pending),
|
|
1778
|
+
isPaused: this._uploadState.paused,
|
|
1779
|
+
isProcessing: this.uploading || this.downloading || this._uploadState.processing,
|
|
1780
|
+
// TODO: activeDownloads tracking not yet implemented - downloads are handled by
|
|
1781
|
+
// the parent AbstractAttachmentQueue which doesn't expose per-download progress.
|
|
1782
|
+
// For now, return empty array. Future: implement download tracking similar to uploads.
|
|
1783
|
+
activeDownloads: [],
|
|
1784
|
+
pendingUploadCount: pendingUpload,
|
|
1785
|
+
failedPermanentCount: failedPermanent,
|
|
1786
|
+
staleUploadCount: staleUploads.length,
|
|
1787
|
+
activeUploads: this.activeUploads,
|
|
1788
|
+
skippedDownloadCount
|
|
1789
|
+
};
|
|
1790
|
+
this._cachedStats = stats;
|
|
1791
|
+
this._cachedStatsTimestamp = now;
|
|
1792
|
+
return stats;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Repair attachment sizes for synced records that have size=0.
|
|
1796
|
+
* This backfills sizes from the actual file system for existing downloads.
|
|
1797
|
+
* @returns Number of records repaired
|
|
1798
|
+
*/
|
|
1799
|
+
async repairAttachmentSizes() {
|
|
1800
|
+
const records = await this.powersync.getAll(`SELECT id, local_uri FROM ${this.table} WHERE state = ? AND (size IS NULL OR size = 0) AND local_uri IS NOT NULL`, [AttachmentState6.SYNCED]);
|
|
1801
|
+
let repaired = 0;
|
|
1802
|
+
for (const record of records) {
|
|
1803
|
+
if (!record.local_uri) continue;
|
|
1804
|
+
const localUri = this.getLocalUri(record.local_uri);
|
|
1805
|
+
const fileInfo = await this.platform.fileSystem.getFileInfo(localUri);
|
|
1806
|
+
if (fileInfo?.exists && fileInfo.size && fileInfo.size > 0) {
|
|
1807
|
+
await this.powersync.execute(`UPDATE ${this.table} SET size = ? WHERE id = ?`, [fileInfo.size, record.id]);
|
|
1808
|
+
repaired++;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
if (repaired > 0) {
|
|
1812
|
+
this._invalidateStatsCache();
|
|
1813
|
+
this._notify(true);
|
|
1814
|
+
this.polLogger.info(`[PolAttachmentQueue] Repaired sizes for ${repaired} attachments`);
|
|
1815
|
+
}
|
|
1816
|
+
return repaired;
|
|
1817
|
+
}
|
|
1818
|
+
// ─── Private Helpers ───────────────────────────────────────────────────────
|
|
1819
|
+
_getDownloadManagerDeps() {
|
|
1820
|
+
return {
|
|
1821
|
+
powersync: this.powersync,
|
|
1822
|
+
platform: this.platform,
|
|
1823
|
+
logger: this.polLogger,
|
|
1824
|
+
remoteStorage: this.remoteStorage,
|
|
1825
|
+
storage: this.storage,
|
|
1826
|
+
tableName: this.table,
|
|
1827
|
+
getLocalUri: (p) => this.getLocalUri(p),
|
|
1828
|
+
getLocalFilePathSuffix: (f) => this.getLocalFilePathSuffix(f),
|
|
1829
|
+
update: (r) => this.update(r),
|
|
1830
|
+
downloadAttachments: this.options.downloadAttachments,
|
|
1831
|
+
onDownloadError: this.options.onDownloadError,
|
|
1832
|
+
notify: (imm) => this._notify(imm),
|
|
1833
|
+
invalidateStatsCache: () => this._invalidateStatsCache()
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
_getUploadManagerDeps() {
|
|
1837
|
+
return {
|
|
1838
|
+
powersync: this.powersync,
|
|
1839
|
+
platform: this.platform,
|
|
1840
|
+
logger: this.polLogger,
|
|
1841
|
+
tableName: this.table,
|
|
1842
|
+
uploadHandler: this.uploadHandler,
|
|
1843
|
+
uploadConfig: this.uploadConfig,
|
|
1844
|
+
onUploadComplete: this.options.onUploadComplete,
|
|
1845
|
+
onUploadFailed: this.options.onUploadFailed,
|
|
1846
|
+
notify: (imm) => this._notify(imm)
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
_getCacheManagerDeps() {
|
|
1850
|
+
return {
|
|
1851
|
+
powersync: this.powersync,
|
|
1852
|
+
platform: this.platform,
|
|
1853
|
+
logger: this.polLogger,
|
|
1854
|
+
tableName: this.table,
|
|
1855
|
+
cacheConfig: this.cacheConfig,
|
|
1856
|
+
getLocalUri: (p) => this.getLocalUri(p),
|
|
1857
|
+
notify: (imm) => this._notify(imm),
|
|
1858
|
+
invalidateStatsCache: () => this._invalidateStatsCache()
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
async _startUploadProcessing() {
|
|
1862
|
+
const deps = this._getUploadManagerDeps();
|
|
1863
|
+
await startUploadProcessing(deps, this._uploadState);
|
|
1864
|
+
}
|
|
1865
|
+
async _createTableIfNotExists() {
|
|
1866
|
+
const result = await this.powersync.getOptional(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [this.table]);
|
|
1867
|
+
if (result) return;
|
|
1868
|
+
await this.powersync.execute(`
|
|
1869
|
+
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
1870
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
1871
|
+
filename TEXT,
|
|
1872
|
+
local_uri TEXT,
|
|
1873
|
+
timestamp INTEGER,
|
|
1874
|
+
media_type TEXT,
|
|
1875
|
+
size INTEGER,
|
|
1876
|
+
state INTEGER DEFAULT 0,
|
|
1877
|
+
upload_source_uri TEXT,
|
|
1878
|
+
upload_error TEXT,
|
|
1879
|
+
upload_error_code TEXT,
|
|
1880
|
+
upload_retry_count INTEGER DEFAULT 0,
|
|
1881
|
+
upload_next_retry_at INTEGER,
|
|
1882
|
+
upload_metadata TEXT,
|
|
1883
|
+
upload_bucket_id TEXT
|
|
1884
|
+
)
|
|
1885
|
+
`);
|
|
1886
|
+
}
|
|
1887
|
+
async _migrateUploadColumns() {
|
|
1888
|
+
const tableInfo = await this.powersync.getAll(`PRAGMA table_info(${this.table})`);
|
|
1889
|
+
const existingColumns = new Set(tableInfo.map((r) => r.name));
|
|
1890
|
+
const uploadColumns = [{
|
|
1891
|
+
name: "upload_source_uri",
|
|
1892
|
+
type: "TEXT"
|
|
1893
|
+
}, {
|
|
1894
|
+
name: "upload_error",
|
|
1895
|
+
type: "TEXT"
|
|
1896
|
+
}, {
|
|
1897
|
+
name: "upload_error_code",
|
|
1898
|
+
type: "TEXT"
|
|
1899
|
+
}, {
|
|
1900
|
+
name: "upload_retry_count",
|
|
1901
|
+
type: "INTEGER DEFAULT 0"
|
|
1902
|
+
}, {
|
|
1903
|
+
name: "upload_next_retry_at",
|
|
1904
|
+
type: "INTEGER"
|
|
1905
|
+
}, {
|
|
1906
|
+
name: "upload_metadata",
|
|
1907
|
+
type: "TEXT"
|
|
1908
|
+
}, {
|
|
1909
|
+
name: "upload_bucket_id",
|
|
1910
|
+
type: "TEXT"
|
|
1911
|
+
}];
|
|
1912
|
+
for (const col of uploadColumns) {
|
|
1913
|
+
if (!existingColumns.has(col.name)) {
|
|
1914
|
+
await this.powersync.execute(`ALTER TABLE ${this.table} ADD COLUMN ${col.name} ${col.type}`);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
_invalidateStatsCache() {
|
|
1919
|
+
this._cachedStats = null;
|
|
1920
|
+
this._cachedStatsTimestamp = 0;
|
|
1921
|
+
}
|
|
1922
|
+
_getStatus(_pendingCount) {
|
|
1923
|
+
if (this.uploading || this.downloading || this._uploadState.processing) return "syncing";
|
|
1924
|
+
if (this._uploadState.paused) return "paused";
|
|
1925
|
+
return "complete";
|
|
1926
|
+
}
|
|
1927
|
+
_notify(forceImmediate = false) {
|
|
1928
|
+
if (this._disposed) return;
|
|
1929
|
+
if (this._progressCallbacks.size === 0) return;
|
|
1930
|
+
const now = Date.now();
|
|
1931
|
+
const timeSinceLastNotify = now - this._lastNotifyTime;
|
|
1932
|
+
if (this._notifyTimer) {
|
|
1933
|
+
clearTimeout(this._notifyTimer);
|
|
1934
|
+
this._notifyTimer = null;
|
|
1935
|
+
}
|
|
1936
|
+
const notifyAll = (stats) => {
|
|
1937
|
+
if (this._disposed) return;
|
|
1938
|
+
for (const cb of this._progressCallbacks) {
|
|
1939
|
+
try {
|
|
1940
|
+
cb(stats);
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
this.polLogger.warn("[PolAttachmentQueue] Callback error:", err);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
if (forceImmediate || timeSinceLastNotify >= NOTIFY_THROTTLE_MS) {
|
|
1947
|
+
this._lastNotifyTime = now;
|
|
1948
|
+
this.getStats().then(notifyAll).catch((err) => {
|
|
1949
|
+
if (this._disposed) return;
|
|
1950
|
+
this.polLogger.warn("[PolAttachmentQueue] Stats error:", err);
|
|
1951
|
+
});
|
|
1952
|
+
} else {
|
|
1953
|
+
const delay = NOTIFY_THROTTLE_MS - timeSinceLastNotify;
|
|
1954
|
+
this._notifyTimer = setTimeout(() => {
|
|
1955
|
+
if (this._disposed) return;
|
|
1956
|
+
this._notifyTimer = null;
|
|
1957
|
+
this._lastNotifyTime = Date.now();
|
|
1958
|
+
this.getStats().then(notifyAll).catch((err) => {
|
|
1959
|
+
if (this._disposed) return;
|
|
1960
|
+
this.polLogger.warn("[PolAttachmentQueue] Stats error:", err);
|
|
1961
|
+
});
|
|
1962
|
+
}, delay);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
function createPolAttachmentQueue(powersync, platform, config) {
|
|
1967
|
+
return new PolAttachmentQueue({
|
|
1968
|
+
powersync,
|
|
1969
|
+
storage: void 0,
|
|
1970
|
+
platform,
|
|
1971
|
+
remoteStorage: config.remoteStorage,
|
|
1972
|
+
source: {
|
|
1973
|
+
bucket: config.bucket,
|
|
1974
|
+
watchIds: config.watchIds,
|
|
1975
|
+
skipDownload: config.skipDownload
|
|
1976
|
+
},
|
|
1977
|
+
attachmentDirectoryName: "attachments",
|
|
1978
|
+
attachmentTableName: "attachments",
|
|
1979
|
+
performInitialSync: true,
|
|
1980
|
+
downloadAttachments: true,
|
|
1981
|
+
uploadHandler: config.uploadHandler,
|
|
1982
|
+
uploadConfig: config.uploadConfig,
|
|
1983
|
+
downloadConfig: config.downloadConfig ?? config.download,
|
|
1984
|
+
onUploadComplete: config.onUploadComplete ? async (record) => config.onUploadComplete(record) : void 0,
|
|
1985
|
+
onUploadFailed: config.onUploadFailed,
|
|
1986
|
+
compression: config.compression,
|
|
1987
|
+
cache: config.cache ? {
|
|
1988
|
+
maxSize: config.maxCacheBytes,
|
|
1989
|
+
...config.cache
|
|
1990
|
+
} : config.maxCacheBytes ? {
|
|
1991
|
+
maxSize: config.maxCacheBytes
|
|
1992
|
+
} : void 0
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
export {
|
|
1997
|
+
PolStorageAdapter,
|
|
1998
|
+
PolAttachmentState,
|
|
1999
|
+
DEFAULT_COMPRESSION_CONFIG,
|
|
2000
|
+
DEFAULT_UPLOAD_CONFIG,
|
|
2001
|
+
DEFAULT_DOWNLOAD_CONFIG,
|
|
2002
|
+
DEFAULT_CACHE_CONFIG,
|
|
2003
|
+
CACHE_SIZE_PRESETS,
|
|
2004
|
+
formatCacheSize,
|
|
2005
|
+
ATTACHMENT_TABLE,
|
|
2006
|
+
AttachmentState,
|
|
2007
|
+
AttachmentTable,
|
|
2008
|
+
EncodingType2 as EncodingType,
|
|
2009
|
+
AbstractAttachmentQueue,
|
|
2010
|
+
DEFAULT_ATTACHMENT_QUEUE_OPTIONS,
|
|
2011
|
+
PROTECTED_UPLOAD_STATES,
|
|
2012
|
+
PENDING_DOWNLOAD_STATES,
|
|
2013
|
+
LOCALLY_AVAILABLE_STATES,
|
|
2014
|
+
isProtectedUploadState,
|
|
2015
|
+
isPendingDownloadState,
|
|
2016
|
+
isLocallyAvailable,
|
|
2017
|
+
isStateTransitionAllowed,
|
|
2018
|
+
determineAttachmentState,
|
|
2019
|
+
validateSqlIdentifier,
|
|
2020
|
+
getProtectedStatesInClause,
|
|
2021
|
+
getExcludeProtectedStatesCondition,
|
|
2022
|
+
getMimeType,
|
|
2023
|
+
getMimeTypeFromPath,
|
|
2024
|
+
isImageMimeType,
|
|
2025
|
+
isVideoMimeType,
|
|
2026
|
+
isAudioMimeType,
|
|
2027
|
+
isDocumentMimeType,
|
|
2028
|
+
getExtensionFromMimeType,
|
|
2029
|
+
blobToArrayBuffer,
|
|
2030
|
+
downloadRecord,
|
|
2031
|
+
isPermanentError,
|
|
2032
|
+
extractErrorCode,
|
|
2033
|
+
getPendingUploads,
|
|
2034
|
+
getSoonestRetryTime,
|
|
2035
|
+
getFailedPermanentUploads,
|
|
2036
|
+
getStaleUploads,
|
|
2037
|
+
getSyncedUploadsWithPendingCallback,
|
|
2038
|
+
clearUploadCallback,
|
|
2039
|
+
markUploadSynced,
|
|
2040
|
+
markUploadPermanentFailure,
|
|
2041
|
+
scheduleUploadRetry,
|
|
2042
|
+
uploadOne,
|
|
2043
|
+
startUploadProcessing,
|
|
2044
|
+
createUploadManagerState,
|
|
2045
|
+
createUploadManagerDeps,
|
|
2046
|
+
ensureFileUri,
|
|
2047
|
+
stripFileUri,
|
|
2048
|
+
getCachedSize,
|
|
2049
|
+
getEvictionCandidates,
|
|
2050
|
+
enforceCacheLimit,
|
|
2051
|
+
isCacheNearCapacity,
|
|
2052
|
+
clearCache,
|
|
2053
|
+
cacheLocalFile,
|
|
2054
|
+
getLocalUriForStoragePath,
|
|
2055
|
+
copyToManagedCache,
|
|
2056
|
+
createCacheManagerDeps,
|
|
2057
|
+
PolAttachmentQueue,
|
|
2058
|
+
createPolAttachmentQueue
|
|
2059
|
+
};
|
|
2060
|
+
//# sourceMappingURL=chunk-4TXTAEF2.js.map
|