@mountra/mountra-sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +144 -15
- package/dist/index.cjs +1869 -173
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +408 -13
- package/dist/index.d.ts +408 -13
- package/dist/index.js +1865 -173
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -22,9 +22,13 @@ var src_exports = {};
|
|
|
22
22
|
__export(src_exports, {
|
|
23
23
|
DEFAULT_S3_MULTIPART_PART_SIZE_BYTES: () => DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
|
|
24
24
|
DownloadHttpError: () => DownloadHttpError,
|
|
25
|
+
InMemoryDownloadCacheStore: () => InMemoryDownloadCacheStore,
|
|
25
26
|
InMemoryDownloadCheckpointStore: () => InMemoryDownloadCheckpointStore,
|
|
26
27
|
InMemoryJumboChunkCheckpointStore: () => InMemoryJumboChunkCheckpointStore,
|
|
28
|
+
InMemoryMetadataCacheStore: () => InMemoryMetadataCacheStore,
|
|
29
|
+
IndexedDBDownloadCacheStore: () => IndexedDBDownloadCacheStore,
|
|
27
30
|
IndexedDBDownloadCheckpointStore: () => IndexedDBDownloadCheckpointStore,
|
|
31
|
+
IndexedDBMetadataCacheStore: () => IndexedDBMetadataCacheStore,
|
|
28
32
|
JUMBO_CHUNK_THRESHOLD_BYTES: () => JUMBO_CHUNK_THRESHOLD_BYTES,
|
|
29
33
|
LocalStorageJumboChunkCheckpointStore: () => LocalStorageJumboChunkCheckpointStore,
|
|
30
34
|
MAX_S3_MULTIPART_PARTS: () => MAX_S3_MULTIPART_PARTS,
|
|
@@ -42,6 +46,658 @@ __export(src_exports, {
|
|
|
42
46
|
});
|
|
43
47
|
module.exports = __toCommonJS(src_exports);
|
|
44
48
|
|
|
49
|
+
// src/file.ts
|
|
50
|
+
function normalizeMountraFileLocator(locator, fallbackWorkspace = {}) {
|
|
51
|
+
if (!locator || typeof locator !== "object") {
|
|
52
|
+
throw new TypeError("Mountra file locator is required");
|
|
53
|
+
}
|
|
54
|
+
const hasPath = locator.path !== void 0;
|
|
55
|
+
const hasIno = locator.ino !== void 0;
|
|
56
|
+
if (hasPath === hasIno) {
|
|
57
|
+
throw new TypeError("Mountra file locator requires exactly one of path or ino");
|
|
58
|
+
}
|
|
59
|
+
const workspaceId = locator.workspaceId ?? locator.wsid ?? fallbackWorkspace.workspaceId;
|
|
60
|
+
if (locator.workspaceId !== void 0 && locator.wsid !== void 0 && String(locator.workspaceId) !== String(locator.wsid)) {
|
|
61
|
+
throw new TypeError("workspaceId and wsid must identify the same workspace");
|
|
62
|
+
}
|
|
63
|
+
const workspaceCuid = locator.workspaceCuid ?? fallbackWorkspace.workspaceCuid;
|
|
64
|
+
if (workspaceCuid === void 0 && workspaceId === void 0) {
|
|
65
|
+
throw new TypeError("workspaceCuid, workspaceId, or wsid is required");
|
|
66
|
+
}
|
|
67
|
+
if (hasPath) {
|
|
68
|
+
const path = normalizePath(locator.path);
|
|
69
|
+
return {
|
|
70
|
+
...workspaceCuid !== void 0 ? { workspaceCuid } : {},
|
|
71
|
+
...workspaceId !== void 0 ? { workspaceId } : {},
|
|
72
|
+
path
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...workspaceCuid !== void 0 ? { workspaceCuid } : {},
|
|
77
|
+
...workspaceId !== void 0 ? { workspaceId } : {},
|
|
78
|
+
ino: normalizeIno(locator.ino)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
var MountraFile = class {
|
|
82
|
+
constructor(client, locator) {
|
|
83
|
+
this.client = client;
|
|
84
|
+
this.locator = Object.freeze({ ...locator });
|
|
85
|
+
}
|
|
86
|
+
client;
|
|
87
|
+
locator;
|
|
88
|
+
get workspaceCuid() {
|
|
89
|
+
return this.locator.workspaceCuid;
|
|
90
|
+
}
|
|
91
|
+
get workspaceId() {
|
|
92
|
+
return this.locator.workspaceId;
|
|
93
|
+
}
|
|
94
|
+
/** `wsid` is the short alias used by the file-object API. */
|
|
95
|
+
get wsid() {
|
|
96
|
+
return this.locator.workspaceId;
|
|
97
|
+
}
|
|
98
|
+
get path() {
|
|
99
|
+
return "path" in this.locator ? this.locator.path : void 0;
|
|
100
|
+
}
|
|
101
|
+
get ino() {
|
|
102
|
+
return "ino" in this.locator ? this.locator.ino : void 0;
|
|
103
|
+
}
|
|
104
|
+
export(options = {}) {
|
|
105
|
+
return this.client.exportFile({ ...options, ...this.locator });
|
|
106
|
+
}
|
|
107
|
+
download(options = {}) {
|
|
108
|
+
return this.client.downloadFile({ ...options, ...this.locator });
|
|
109
|
+
}
|
|
110
|
+
async resume(options = {}) {
|
|
111
|
+
const { downloadKey, ...resumeOptions } = options;
|
|
112
|
+
const checkpoints = await this.client.listResumableDownloads();
|
|
113
|
+
const candidates = checkpoints.filter(
|
|
114
|
+
(checkpoint2) => checkpoint2.fileHandle && matchesLocator(checkpoint2.request, this.locator)
|
|
115
|
+
);
|
|
116
|
+
const checkpoint = downloadKey ? candidates.find((candidate) => candidate.key === downloadKey) : [...candidates].sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
|
117
|
+
if (!checkpoint) {
|
|
118
|
+
if (downloadKey) {
|
|
119
|
+
throw new Error("Download checkpoint does not belong to this Mountra file");
|
|
120
|
+
}
|
|
121
|
+
throw new Error("No resumable download was found for this Mountra file");
|
|
122
|
+
}
|
|
123
|
+
return this.client.resumeDownload(checkpoint.key, resumeOptions);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
function matchesLocator(request, locator) {
|
|
127
|
+
if (locator.workspaceCuid !== void 0 && request.workspaceCuid !== locator.workspaceCuid) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (locator.workspaceId !== void 0 && String(request.workspaceId) !== String(locator.workspaceId)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if ("path" in locator) {
|
|
134
|
+
if (request.path === void 0 || request.ino !== void 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return normalizePath(request.path) === locator.path;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return String(request.ino) === String(locator.ino) && request.path === void 0;
|
|
144
|
+
}
|
|
145
|
+
function isPathWithin(path, ancestor) {
|
|
146
|
+
return ancestor === "/" || path === ancestor || path.startsWith(`${ancestor}/`);
|
|
147
|
+
}
|
|
148
|
+
function normalizePath(value) {
|
|
149
|
+
if (!value || !value.trim()) {
|
|
150
|
+
throw new TypeError("Mountra file path cannot be empty");
|
|
151
|
+
}
|
|
152
|
+
const raw = value.trim();
|
|
153
|
+
const absolute = raw.startsWith("/") ? raw : `/${raw}`;
|
|
154
|
+
const segments = absolute.split("/").filter((segment) => segment.length > 0 && segment !== ".");
|
|
155
|
+
const stack = [];
|
|
156
|
+
for (const segment of segments) {
|
|
157
|
+
if (segment === "..") {
|
|
158
|
+
stack.pop();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
stack.push(segment);
|
|
162
|
+
}
|
|
163
|
+
return stack.length === 0 ? "/" : `/${stack.join("/")}`;
|
|
164
|
+
}
|
|
165
|
+
function normalizeIno(value) {
|
|
166
|
+
if (typeof value === "number") {
|
|
167
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
168
|
+
throw new TypeError("Mountra file ino must be a non-negative safe integer");
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
if (!/^\d+$/.test(value)) {
|
|
173
|
+
throw new TypeError("Mountra file ino must be a non-negative integer string");
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/idb.ts
|
|
179
|
+
function getIndexedDb() {
|
|
180
|
+
return globalThis.indexedDB ?? null;
|
|
181
|
+
}
|
|
182
|
+
function requestResult(request) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
request.onsuccess = () => resolve(request.result);
|
|
185
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async function runTransaction(database, storeNames, mode, body) {
|
|
189
|
+
const transaction = database.transaction(storeNames, mode);
|
|
190
|
+
const done = new Promise((resolve, reject) => {
|
|
191
|
+
transaction.oncomplete = () => resolve();
|
|
192
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
|
|
193
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
|
|
194
|
+
});
|
|
195
|
+
done.catch(() => void 0);
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await body(transaction);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
try {
|
|
201
|
+
transaction.abort();
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
await done;
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
function workspaceRange(workspace) {
|
|
210
|
+
return IDBKeyRange.bound([workspace], [workspace, []]);
|
|
211
|
+
}
|
|
212
|
+
var CLOCK_KEY = "clock";
|
|
213
|
+
async function nextClock(meta) {
|
|
214
|
+
const clock = (await requestResult(meta.get(CLOCK_KEY)) ?? 0) + 1;
|
|
215
|
+
await requestResult(meta.put(clock, CLOCK_KEY));
|
|
216
|
+
return clock;
|
|
217
|
+
}
|
|
218
|
+
function openDatabase(name, version, upgrade, onClose) {
|
|
219
|
+
const indexedDb = getIndexedDb();
|
|
220
|
+
if (!indexedDb) {
|
|
221
|
+
return Promise.resolve(null);
|
|
222
|
+
}
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
let request;
|
|
225
|
+
try {
|
|
226
|
+
request = indexedDb.open(name, version);
|
|
227
|
+
} catch {
|
|
228
|
+
resolve(null);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
request.onupgradeneeded = () => upgrade(request.result);
|
|
232
|
+
request.onsuccess = () => {
|
|
233
|
+
const database = request.result;
|
|
234
|
+
database.onversionchange = () => {
|
|
235
|
+
database.close();
|
|
236
|
+
onClose();
|
|
237
|
+
};
|
|
238
|
+
resolve(database);
|
|
239
|
+
};
|
|
240
|
+
request.onerror = () => resolve(null);
|
|
241
|
+
request.onblocked = () => resolve(null);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/cache.ts
|
|
246
|
+
var DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
|
|
247
|
+
var DEFAULT_MAX_CHUNK_BYTES = 128 * 1024 * 1024;
|
|
248
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
249
|
+
function copyFile(file) {
|
|
250
|
+
return { ...file, chunks: file.chunks.map((chunk) => ({ ...chunk })) };
|
|
251
|
+
}
|
|
252
|
+
function memoryKey(workspace, key) {
|
|
253
|
+
return JSON.stringify([workspace, key]);
|
|
254
|
+
}
|
|
255
|
+
var InMemoryDownloadCacheStore = class {
|
|
256
|
+
files = /* @__PURE__ */ new Map();
|
|
257
|
+
/** Map iteration order is the LRU order: the first entry is the least recently used. */
|
|
258
|
+
chunks = /* @__PURE__ */ new Map();
|
|
259
|
+
bytes = 0;
|
|
260
|
+
async getFile(workspace, fileKey) {
|
|
261
|
+
const entry = this.files.get(memoryKey(workspace, fileKey));
|
|
262
|
+
return entry ? copyFile(entry.file) : null;
|
|
263
|
+
}
|
|
264
|
+
async putFile(workspace, fileKey, file) {
|
|
265
|
+
this.files.set(memoryKey(workspace, fileKey), { workspace, file: copyFile(file) });
|
|
266
|
+
}
|
|
267
|
+
async removeFile(workspace, fileKey) {
|
|
268
|
+
this.files.delete(memoryKey(workspace, fileKey));
|
|
269
|
+
}
|
|
270
|
+
async removeFilesUnder(workspace, path) {
|
|
271
|
+
for (const [key, entry] of this.files) {
|
|
272
|
+
if (entry.workspace === workspace && isPathWithin(entry.file.path, path)) {
|
|
273
|
+
this.files.delete(key);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async getChunk(workspace, storageKey) {
|
|
278
|
+
const key = memoryKey(workspace, storageKey);
|
|
279
|
+
const entry = this.chunks.get(key);
|
|
280
|
+
if (!entry) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
this.chunks.delete(key);
|
|
284
|
+
this.chunks.set(key, entry);
|
|
285
|
+
return entry.data;
|
|
286
|
+
}
|
|
287
|
+
async putChunk(workspace, storageKey, data, maxBytes) {
|
|
288
|
+
const key = memoryKey(workspace, storageKey);
|
|
289
|
+
this.removeChunk(key);
|
|
290
|
+
if (data.size > maxBytes) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.chunks.set(key, { workspace, data });
|
|
294
|
+
this.bytes += data.size;
|
|
295
|
+
for (const candidate of this.chunks.keys()) {
|
|
296
|
+
if (this.bytes <= maxBytes) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
this.removeChunk(candidate);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async clear(workspace) {
|
|
303
|
+
for (const [key, entry] of this.chunks) {
|
|
304
|
+
if (workspace === void 0 || entry.workspace === workspace) {
|
|
305
|
+
this.removeChunk(key);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const [key, entry] of this.files) {
|
|
309
|
+
if (workspace === void 0 || entry.workspace === workspace) {
|
|
310
|
+
this.files.delete(key);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async usage(workspace) {
|
|
315
|
+
const usage = { bytes: 0, chunks: 0 };
|
|
316
|
+
for (const entry of this.chunks.values()) {
|
|
317
|
+
if (workspace === void 0 || entry.workspace === workspace) {
|
|
318
|
+
usage.bytes += entry.data.size;
|
|
319
|
+
usage.chunks += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return usage;
|
|
323
|
+
}
|
|
324
|
+
removeChunk(key) {
|
|
325
|
+
const entry = this.chunks.get(key);
|
|
326
|
+
if (entry) {
|
|
327
|
+
this.chunks.delete(key);
|
|
328
|
+
this.bytes -= entry.data.size;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
var CACHE_DATABASE_NAME = "mountra-sdk-download-cache";
|
|
333
|
+
var CACHE_DATABASE_VERSION = 1;
|
|
334
|
+
var FILES_STORE = "files";
|
|
335
|
+
var CHUNKS_STORE = "chunks";
|
|
336
|
+
var CHUNK_DATA_STORE = "chunk-data";
|
|
337
|
+
var META_STORE = "meta";
|
|
338
|
+
var LRU_INDEX = "lastUsed";
|
|
339
|
+
var USAGE_KEY = "bytes";
|
|
340
|
+
function sameKey(left, right) {
|
|
341
|
+
return Array.isArray(left) && left[0] === right[0] && left[1] === right[1];
|
|
342
|
+
}
|
|
343
|
+
var IndexedDBDownloadCacheStore = class {
|
|
344
|
+
databaseName;
|
|
345
|
+
fallback = new InMemoryDownloadCacheStore();
|
|
346
|
+
databasePromise;
|
|
347
|
+
constructor(options = {}) {
|
|
348
|
+
this.databaseName = options.databaseName ?? CACHE_DATABASE_NAME;
|
|
349
|
+
}
|
|
350
|
+
async getFile(workspace, fileKey) {
|
|
351
|
+
const database = await this.openDatabase();
|
|
352
|
+
if (!database) {
|
|
353
|
+
return this.fallback.getFile(workspace, fileKey);
|
|
354
|
+
}
|
|
355
|
+
return runTransaction(database, [FILES_STORE], "readonly", async (transaction) => {
|
|
356
|
+
const file = await requestResult(
|
|
357
|
+
transaction.objectStore(FILES_STORE).get([workspace, fileKey])
|
|
358
|
+
);
|
|
359
|
+
return file ? copyFile(file) : null;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async putFile(workspace, fileKey, file) {
|
|
363
|
+
const database = await this.openDatabase();
|
|
364
|
+
if (!database) {
|
|
365
|
+
return this.fallback.putFile(workspace, fileKey, file);
|
|
366
|
+
}
|
|
367
|
+
await runTransaction(database, [FILES_STORE], "readwrite", async (transaction) => {
|
|
368
|
+
await requestResult(transaction.objectStore(FILES_STORE).put(copyFile(file), [workspace, fileKey]));
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async removeFile(workspace, fileKey) {
|
|
372
|
+
const database = await this.openDatabase();
|
|
373
|
+
if (!database) {
|
|
374
|
+
return this.fallback.removeFile(workspace, fileKey);
|
|
375
|
+
}
|
|
376
|
+
await runTransaction(database, [FILES_STORE], "readwrite", async (transaction) => {
|
|
377
|
+
await requestResult(transaction.objectStore(FILES_STORE).delete([workspace, fileKey]));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async removeFilesUnder(workspace, path) {
|
|
381
|
+
const database = await this.openDatabase();
|
|
382
|
+
if (!database) {
|
|
383
|
+
return this.fallback.removeFilesUnder(workspace, path);
|
|
384
|
+
}
|
|
385
|
+
await runTransaction(database, [FILES_STORE], "readwrite", async (transaction) => {
|
|
386
|
+
const cursorRequest = transaction.objectStore(FILES_STORE).openCursor(workspaceRange(workspace));
|
|
387
|
+
let cursor = await requestResult(cursorRequest);
|
|
388
|
+
while (cursor) {
|
|
389
|
+
if (isPathWithin(cursor.value.path, path)) {
|
|
390
|
+
await requestResult(cursor.delete());
|
|
391
|
+
}
|
|
392
|
+
cursor.continue();
|
|
393
|
+
cursor = await requestResult(cursorRequest);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async getChunk(workspace, storageKey) {
|
|
398
|
+
const database = await this.openDatabase();
|
|
399
|
+
if (!database) {
|
|
400
|
+
return this.fallback.getChunk(workspace, storageKey);
|
|
401
|
+
}
|
|
402
|
+
const key = [workspace, storageKey];
|
|
403
|
+
return runTransaction(
|
|
404
|
+
database,
|
|
405
|
+
[CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
|
|
406
|
+
"readwrite",
|
|
407
|
+
async (transaction) => {
|
|
408
|
+
const chunks = transaction.objectStore(CHUNKS_STORE);
|
|
409
|
+
const record = await requestResult(chunks.get(key));
|
|
410
|
+
if (!record) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const data = await requestResult(
|
|
414
|
+
transaction.objectStore(CHUNK_DATA_STORE).get(key)
|
|
415
|
+
);
|
|
416
|
+
const meta = transaction.objectStore(META_STORE);
|
|
417
|
+
if (!data) {
|
|
418
|
+
const bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
|
|
419
|
+
await requestResult(chunks.delete(key));
|
|
420
|
+
await requestResult(meta.put(Math.max(bytes - record.size, 0), USAGE_KEY));
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
await requestResult(chunks.put({ size: record.size, lastUsed: await nextClock(meta) }, key));
|
|
424
|
+
return data;
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
async putChunk(workspace, storageKey, data, maxBytes) {
|
|
429
|
+
const database = await this.openDatabase();
|
|
430
|
+
if (!database) {
|
|
431
|
+
return this.fallback.putChunk(workspace, storageKey, data, maxBytes);
|
|
432
|
+
}
|
|
433
|
+
const key = [workspace, storageKey];
|
|
434
|
+
await runTransaction(
|
|
435
|
+
database,
|
|
436
|
+
[CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
|
|
437
|
+
"readwrite",
|
|
438
|
+
async (transaction) => {
|
|
439
|
+
const chunks = transaction.objectStore(CHUNKS_STORE);
|
|
440
|
+
const chunkData = transaction.objectStore(CHUNK_DATA_STORE);
|
|
441
|
+
const meta = transaction.objectStore(META_STORE);
|
|
442
|
+
let bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
|
|
443
|
+
const existing = await requestResult(chunks.get(key));
|
|
444
|
+
if (existing) {
|
|
445
|
+
bytes -= existing.size;
|
|
446
|
+
await requestResult(chunks.delete(key));
|
|
447
|
+
await requestResult(chunkData.delete(key));
|
|
448
|
+
}
|
|
449
|
+
if (data.size <= maxBytes) {
|
|
450
|
+
await requestResult(chunks.put({ size: data.size, lastUsed: await nextClock(meta) }, key));
|
|
451
|
+
await requestResult(chunkData.put(data, key));
|
|
452
|
+
bytes += data.size;
|
|
453
|
+
}
|
|
454
|
+
if (bytes > maxBytes) {
|
|
455
|
+
const cursorRequest = chunks.index(LRU_INDEX).openCursor();
|
|
456
|
+
let cursor = await requestResult(cursorRequest);
|
|
457
|
+
while (cursor && bytes > maxBytes) {
|
|
458
|
+
if (!sameKey(cursor.primaryKey, key)) {
|
|
459
|
+
bytes -= cursor.value.size;
|
|
460
|
+
await requestResult(chunkData.delete(cursor.primaryKey));
|
|
461
|
+
await requestResult(cursor.delete());
|
|
462
|
+
}
|
|
463
|
+
cursor.continue();
|
|
464
|
+
cursor = await requestResult(cursorRequest);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await requestResult(meta.put(Math.max(bytes, 0), USAGE_KEY));
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
async clear(workspace) {
|
|
472
|
+
const database = await this.openDatabase();
|
|
473
|
+
if (!database) {
|
|
474
|
+
return this.fallback.clear(workspace);
|
|
475
|
+
}
|
|
476
|
+
await runTransaction(
|
|
477
|
+
database,
|
|
478
|
+
[FILES_STORE, CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
|
|
479
|
+
"readwrite",
|
|
480
|
+
async (transaction) => {
|
|
481
|
+
const files = transaction.objectStore(FILES_STORE);
|
|
482
|
+
const chunks = transaction.objectStore(CHUNKS_STORE);
|
|
483
|
+
const chunkData = transaction.objectStore(CHUNK_DATA_STORE);
|
|
484
|
+
const meta = transaction.objectStore(META_STORE);
|
|
485
|
+
if (workspace === void 0) {
|
|
486
|
+
await requestResult(files.clear());
|
|
487
|
+
await requestResult(chunks.clear());
|
|
488
|
+
await requestResult(chunkData.clear());
|
|
489
|
+
await requestResult(meta.put(0, USAGE_KEY));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const range = workspaceRange(workspace);
|
|
493
|
+
const removed = await requestResult(chunks.getAll(range));
|
|
494
|
+
const bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
|
|
495
|
+
await requestResult(files.delete(range));
|
|
496
|
+
await requestResult(chunks.delete(range));
|
|
497
|
+
await requestResult(chunkData.delete(range));
|
|
498
|
+
await requestResult(
|
|
499
|
+
meta.put(Math.max(bytes - removed.reduce((total, record) => total + record.size, 0), 0), USAGE_KEY)
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
async usage(workspace) {
|
|
505
|
+
const database = await this.openDatabase();
|
|
506
|
+
if (!database) {
|
|
507
|
+
return this.fallback.usage(workspace);
|
|
508
|
+
}
|
|
509
|
+
return runTransaction(database, [CHUNKS_STORE], "readonly", async (transaction) => {
|
|
510
|
+
const records = await requestResult(
|
|
511
|
+
transaction.objectStore(CHUNKS_STORE).getAll(workspace === void 0 ? void 0 : workspaceRange(workspace))
|
|
512
|
+
);
|
|
513
|
+
return {
|
|
514
|
+
bytes: records.reduce((total, record) => total + record.size, 0),
|
|
515
|
+
chunks: records.length
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async openDatabase() {
|
|
520
|
+
this.databasePromise ??= openDatabase(
|
|
521
|
+
this.databaseName,
|
|
522
|
+
CACHE_DATABASE_VERSION,
|
|
523
|
+
(database) => {
|
|
524
|
+
if (!database.objectStoreNames.contains(FILES_STORE)) {
|
|
525
|
+
database.createObjectStore(FILES_STORE);
|
|
526
|
+
}
|
|
527
|
+
if (!database.objectStoreNames.contains(CHUNKS_STORE)) {
|
|
528
|
+
database.createObjectStore(CHUNKS_STORE).createIndex(LRU_INDEX, "lastUsed");
|
|
529
|
+
}
|
|
530
|
+
if (!database.objectStoreNames.contains(CHUNK_DATA_STORE)) {
|
|
531
|
+
database.createObjectStore(CHUNK_DATA_STORE);
|
|
532
|
+
}
|
|
533
|
+
if (!database.objectStoreNames.contains(META_STORE)) {
|
|
534
|
+
database.createObjectStore(META_STORE);
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
() => {
|
|
538
|
+
this.databasePromise = void 0;
|
|
539
|
+
}
|
|
540
|
+
);
|
|
541
|
+
return this.databasePromise;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
function isImmutableStorageKey(storageKey) {
|
|
545
|
+
return /(?:^|\/)blobs\/sha256\/[0-9a-f]{64}$/.test(storageKey);
|
|
546
|
+
}
|
|
547
|
+
function workspaceCacheKey(baseUrl, selector) {
|
|
548
|
+
const workspaceCuid = selector.workspaceCuid?.trim();
|
|
549
|
+
const workspaceId = selector.workspaceId !== void 0 ? String(selector.workspaceId).trim() : "";
|
|
550
|
+
const workspace = workspaceCuid || workspaceId;
|
|
551
|
+
return workspace ? JSON.stringify([baseUrl, workspace]) : null;
|
|
552
|
+
}
|
|
553
|
+
function fileCacheKey(target) {
|
|
554
|
+
try {
|
|
555
|
+
if (target.ino !== void 0) {
|
|
556
|
+
return `ino:${BigInt(target.ino).toString()}`;
|
|
557
|
+
}
|
|
558
|
+
if (target.path !== void 0) {
|
|
559
|
+
return `path:${normalizePath(target.path)}`;
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
function isValidLayout(file) {
|
|
566
|
+
let offset = 0;
|
|
567
|
+
for (const [index, chunk] of file.chunks.entries()) {
|
|
568
|
+
if (chunk.index !== index || chunk.offset !== offset || chunk.size < 0) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
offset += chunk.size;
|
|
572
|
+
}
|
|
573
|
+
return file.chunks.length > 0 && offset === file.size;
|
|
574
|
+
}
|
|
575
|
+
var DownloadCache = class {
|
|
576
|
+
maxBytes;
|
|
577
|
+
maxChunkBytes;
|
|
578
|
+
ttlMs;
|
|
579
|
+
store;
|
|
580
|
+
constructor(options) {
|
|
581
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
582
|
+
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes <= 0) {
|
|
583
|
+
throw new RangeError("downloadCache.maxBytes must be a positive safe integer");
|
|
584
|
+
}
|
|
585
|
+
const maxChunkBytes = options.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES;
|
|
586
|
+
if (!Number.isSafeInteger(maxChunkBytes) || maxChunkBytes <= 0) {
|
|
587
|
+
throw new RangeError("downloadCache.maxChunkBytes must be a positive safe integer");
|
|
588
|
+
}
|
|
589
|
+
this.maxChunkBytes = Math.min(maxChunkBytes, this.maxBytes);
|
|
590
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
591
|
+
if (Number.isNaN(this.ttlMs) || this.ttlMs < 0) {
|
|
592
|
+
throw new RangeError("downloadCache.ttlMs must be a non-negative number");
|
|
593
|
+
}
|
|
594
|
+
this.store = options.store ?? new IndexedDBDownloadCacheStore();
|
|
595
|
+
}
|
|
596
|
+
/** Return a file that has not expired together with all of its chunk bytes. */
|
|
597
|
+
async loadFile(workspace, fileKey) {
|
|
598
|
+
try {
|
|
599
|
+
const file = await this.store.getFile(workspace, fileKey);
|
|
600
|
+
if (!file) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (file.expiresAt <= Date.now() || !isValidLayout(file)) {
|
|
604
|
+
await this.store.removeFile(workspace, fileKey);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const chunks = /* @__PURE__ */ new Map();
|
|
608
|
+
for (const chunk of file.chunks) {
|
|
609
|
+
const data = await this.readChunk(workspace, chunk);
|
|
610
|
+
if (!data) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
chunks.set(chunk.index, data);
|
|
614
|
+
}
|
|
615
|
+
return { file, chunks };
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async readChunk(workspace, chunk) {
|
|
621
|
+
if (chunk.size === 0) {
|
|
622
|
+
return new Blob([]);
|
|
623
|
+
}
|
|
624
|
+
if (!isImmutableStorageKey(chunk.storageKey)) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
const data = await this.store.getChunk(workspace, chunk.storageKey);
|
|
629
|
+
return data && data.size === chunk.size ? data : null;
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
canStoreChunk(chunk) {
|
|
635
|
+
return isImmutableStorageKey(chunk.storageKey) && chunk.size <= this.maxChunkBytes;
|
|
636
|
+
}
|
|
637
|
+
/** Returns whether the chunk is now cached. */
|
|
638
|
+
async storeChunk(workspace, chunk, data) {
|
|
639
|
+
if (chunk.size === 0) {
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
if (!this.canStoreChunk(chunk) || data.size !== chunk.size) {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
await this.store.putChunk(workspace, chunk.storageKey, data, this.maxBytes);
|
|
647
|
+
return true;
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async storeFile(workspace, fileKey, file) {
|
|
653
|
+
if (this.ttlMs <= 0) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const cachedAt = Date.now();
|
|
657
|
+
try {
|
|
658
|
+
await this.store.putFile(workspace, fileKey, {
|
|
659
|
+
path: file.path,
|
|
660
|
+
size: file.size,
|
|
661
|
+
...file.contentType ? { contentType: file.contentType } : {},
|
|
662
|
+
chunks: file.chunks.map(({ index, offset, size, storageKey }) => ({
|
|
663
|
+
index,
|
|
664
|
+
offset,
|
|
665
|
+
size,
|
|
666
|
+
storageKey
|
|
667
|
+
})),
|
|
668
|
+
cachedAt,
|
|
669
|
+
expiresAt: cachedAt + this.ttlMs
|
|
670
|
+
});
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async invalidateFiles(workspace, fileKeys) {
|
|
675
|
+
await Promise.all(
|
|
676
|
+
fileKeys.map((fileKey) => this.store.removeFile(workspace, fileKey).catch(() => void 0))
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
/** Drop the files at or below `paths`, which a write changed; their chunks stay cached. */
|
|
680
|
+
async invalidatePaths(workspace, paths) {
|
|
681
|
+
try {
|
|
682
|
+
if (!this.store.removeFilesUnder) {
|
|
683
|
+
await this.store.clear(workspace);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
for (const path of paths) {
|
|
687
|
+
await this.store.removeFilesUnder(workspace, path);
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
await this.store.clear(workspace).catch(() => void 0);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
clear(workspace) {
|
|
694
|
+
return this.store.clear(workspace);
|
|
695
|
+
}
|
|
696
|
+
usage(workspace) {
|
|
697
|
+
return this.store.usage(workspace);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
45
701
|
// src/download.ts
|
|
46
702
|
function copyCheckpoint(checkpoint) {
|
|
47
703
|
return {
|
|
@@ -69,7 +725,7 @@ var InMemoryDownloadCheckpointStore = class {
|
|
|
69
725
|
var DOWNLOAD_DATABASE_NAME = "mountra-sdk";
|
|
70
726
|
var DOWNLOAD_DATABASE_VERSION = 1;
|
|
71
727
|
var DOWNLOAD_STORE_NAME = "download-checkpoints";
|
|
72
|
-
function
|
|
728
|
+
function getIndexedDb2() {
|
|
73
729
|
return globalThis.indexedDB ?? null;
|
|
74
730
|
}
|
|
75
731
|
var IndexedDBDownloadCheckpointStore = class {
|
|
@@ -134,7 +790,7 @@ var IndexedDBDownloadCheckpointStore = class {
|
|
|
134
790
|
}
|
|
135
791
|
async openDatabase() {
|
|
136
792
|
if (!this.databasePromise) {
|
|
137
|
-
const indexedDb =
|
|
793
|
+
const indexedDb = getIndexedDb2();
|
|
138
794
|
if (!indexedDb) {
|
|
139
795
|
this.databasePromise = Promise.resolve(null);
|
|
140
796
|
} else {
|
|
@@ -329,8 +985,9 @@ var SpeedWindow = class {
|
|
|
329
985
|
};
|
|
330
986
|
var BlobDownloadSink = class {
|
|
331
987
|
parts = [];
|
|
332
|
-
|
|
333
|
-
|
|
988
|
+
/** Parts arrive in file order, so the position is implied. */
|
|
989
|
+
async write(bytes, _position) {
|
|
990
|
+
this.parts.push(bytes instanceof Blob ? bytes : bytes.slice());
|
|
334
991
|
}
|
|
335
992
|
async flush() {
|
|
336
993
|
}
|
|
@@ -338,6 +995,9 @@ var BlobDownloadSink = class {
|
|
|
338
995
|
}
|
|
339
996
|
blob(contentType) {
|
|
340
997
|
const buffers = this.parts.map((part) => {
|
|
998
|
+
if (part instanceof Blob) {
|
|
999
|
+
return part;
|
|
1000
|
+
}
|
|
341
1001
|
const buffer = new ArrayBuffer(part.byteLength);
|
|
342
1002
|
new Uint8Array(buffer).set(part);
|
|
343
1003
|
return buffer;
|
|
@@ -367,7 +1027,7 @@ var FileSystemDownloadSink = class {
|
|
|
367
1027
|
throw new Error("File System Access writer position does not match the download checkpoint");
|
|
368
1028
|
}
|
|
369
1029
|
await this.writable.write(bytes);
|
|
370
|
-
this.position += bytes.byteLength;
|
|
1030
|
+
this.position += bytes instanceof Blob ? bytes.size : bytes.byteLength;
|
|
371
1031
|
}
|
|
372
1032
|
async flush() {
|
|
373
1033
|
if (this.writable) {
|
|
@@ -453,39 +1113,39 @@ function buildDownloadChunks(exported, useClientAssembly) {
|
|
|
453
1113
|
}
|
|
454
1114
|
];
|
|
455
1115
|
}
|
|
456
|
-
function buildDownloadKey(
|
|
1116
|
+
function buildDownloadKey(plan, baseUrl, request) {
|
|
457
1117
|
return encodeURIComponent(
|
|
458
1118
|
JSON.stringify({
|
|
459
1119
|
request: requestKey(request, baseUrl),
|
|
460
|
-
path:
|
|
461
|
-
size:
|
|
462
|
-
chunks: chunks.map((chunk) => [chunk.index, chunk.offset, chunk.size, chunk.storageKey])
|
|
1120
|
+
path: plan.path,
|
|
1121
|
+
size: plan.size,
|
|
1122
|
+
chunks: plan.chunks.map((chunk) => [chunk.index, chunk.offset, chunk.size, chunk.storageKey])
|
|
463
1123
|
})
|
|
464
1124
|
);
|
|
465
1125
|
}
|
|
466
|
-
function checkpointMatches(checkpoint,
|
|
467
|
-
if (checkpoint.version !== 1 || checkpoint.path !==
|
|
1126
|
+
function checkpointMatches(checkpoint, plan) {
|
|
1127
|
+
if (checkpoint.version !== 1 || checkpoint.path !== plan.path || checkpoint.size !== plan.size) {
|
|
468
1128
|
return false;
|
|
469
1129
|
}
|
|
470
|
-
if (checkpoint.chunks.length !== chunks.length) {
|
|
1130
|
+
if (checkpoint.chunks.length !== plan.chunks.length) {
|
|
471
1131
|
return false;
|
|
472
1132
|
}
|
|
473
1133
|
return checkpoint.chunks.every((saved, index) => {
|
|
474
|
-
const current = chunks[index];
|
|
1134
|
+
const current = plan.chunks[index];
|
|
475
1135
|
return Boolean(
|
|
476
1136
|
current && saved.index === current.index && saved.offset === current.offset && saved.size === current.size && saved.storageKey === current.storageKey && saved.downloadedBytes >= 0 && saved.downloadedBytes <= saved.size
|
|
477
1137
|
);
|
|
478
1138
|
});
|
|
479
1139
|
}
|
|
480
|
-
function createCheckpoint(key, baseUrl, request,
|
|
1140
|
+
function createCheckpoint(key, baseUrl, request, plan, fileHandle) {
|
|
481
1141
|
return {
|
|
482
1142
|
version: 1,
|
|
483
1143
|
key,
|
|
484
1144
|
requestKey: requestKey(request, baseUrl),
|
|
485
1145
|
request: { ...request },
|
|
486
|
-
path:
|
|
487
|
-
size:
|
|
488
|
-
chunks: chunks.map((chunk) => ({
|
|
1146
|
+
path: plan.path,
|
|
1147
|
+
size: plan.size,
|
|
1148
|
+
chunks: plan.chunks.map((chunk) => ({
|
|
489
1149
|
index: chunk.index,
|
|
490
1150
|
offset: chunk.offset,
|
|
491
1151
|
size: chunk.size,
|
|
@@ -503,8 +1163,8 @@ function totalDownloadedBytes(checkpoint) {
|
|
|
503
1163
|
function completedChunkCount(checkpoint) {
|
|
504
1164
|
return checkpoint.chunks.filter((chunk) => chunk.completed).length;
|
|
505
1165
|
}
|
|
506
|
-
function defaultFilename(
|
|
507
|
-
const candidate = requested?.trim() ||
|
|
1166
|
+
function defaultFilename(path, requested) {
|
|
1167
|
+
const candidate = requested?.trim() || path.split("/").filter(Boolean).pop() || "download";
|
|
508
1168
|
return candidate.replace(/[\\/\r\n]/g, "_") || "download";
|
|
509
1169
|
}
|
|
510
1170
|
function triggerBrowserDownload(blob, filename) {
|
|
@@ -529,33 +1189,38 @@ var MountraDownloader = class {
|
|
|
529
1189
|
this.transport = transport;
|
|
530
1190
|
}
|
|
531
1191
|
async exportFile(options) {
|
|
532
|
-
|
|
533
|
-
return ensureExportFileResponse(
|
|
534
|
-
await this.transport.requestJson(
|
|
535
|
-
"/file/export",
|
|
536
|
-
requestBodyFromCheckpoint(request),
|
|
537
|
-
options.signal
|
|
538
|
-
)
|
|
539
|
-
);
|
|
1192
|
+
return this.exportRequest(this.resolveRequest(options), options.signal);
|
|
540
1193
|
}
|
|
541
1194
|
async downloadFile(options) {
|
|
542
1195
|
const preferFileSystemAccess = options.preferFileSystemAccess !== false;
|
|
543
1196
|
const fileHandle = options.fileHandle ?? (preferFileSystemAccess && getShowSaveFilePicker() ? await this.pickSaveFile(options.downloadFilename ?? options.path?.split("/").filter(Boolean).pop()) : void 0);
|
|
544
|
-
const
|
|
545
|
-
|
|
1197
|
+
const request = this.resolveRequest(options);
|
|
1198
|
+
const plan = await this.loadCachedPlan(request, options.cache) ?? this.planExport(
|
|
1199
|
+
await this.exportRequest(request, options.signal),
|
|
1200
|
+
request,
|
|
1201
|
+
options.cache,
|
|
1202
|
+
fileHandle
|
|
1203
|
+
);
|
|
1204
|
+
return this.downloadResolved(plan, request, options, fileHandle, false);
|
|
546
1205
|
}
|
|
547
1206
|
async downloadExport(exported, options = { path: exported.path }) {
|
|
548
1207
|
let fileHandle = options.fileHandle;
|
|
549
1208
|
if (!fileHandle && options.preferFileSystemAccess !== false && getShowSaveFilePicker()) {
|
|
550
1209
|
fileHandle = await this.pickSaveFile(
|
|
551
|
-
options.downloadFilename ?? defaultFilename(exported),
|
|
1210
|
+
options.downloadFilename ?? defaultFilename(exported.path),
|
|
552
1211
|
exported.contentType
|
|
553
1212
|
);
|
|
554
1213
|
}
|
|
555
|
-
const request =
|
|
1214
|
+
const request = this.resolveRequest(
|
|
556
1215
|
options.path === void 0 && options.ino === void 0 ? { ...options, path: exported.path } : options
|
|
557
1216
|
);
|
|
558
|
-
return this.downloadResolved(
|
|
1217
|
+
return this.downloadResolved(
|
|
1218
|
+
this.planExport(exported, request, options.cache, fileHandle),
|
|
1219
|
+
request,
|
|
1220
|
+
options,
|
|
1221
|
+
fileHandle,
|
|
1222
|
+
false
|
|
1223
|
+
);
|
|
559
1224
|
}
|
|
560
1225
|
async resumeDownload(downloadKey, options = {}) {
|
|
561
1226
|
if (!downloadKey.trim()) {
|
|
@@ -571,7 +1236,7 @@ var MountraDownloader = class {
|
|
|
571
1236
|
signal: options.signal
|
|
572
1237
|
});
|
|
573
1238
|
return this.downloadResolved(
|
|
574
|
-
exported,
|
|
1239
|
+
this.planExport(exported, checkpoint.request, void 0, checkpoint.fileHandle),
|
|
575
1240
|
checkpoint.request,
|
|
576
1241
|
options,
|
|
577
1242
|
checkpoint.fileHandle,
|
|
@@ -584,6 +1249,132 @@ var MountraDownloader = class {
|
|
|
584
1249
|
clearDownloadCheckpoint(downloadKey) {
|
|
585
1250
|
return this.transport.checkpointStore.remove(downloadKey);
|
|
586
1251
|
}
|
|
1252
|
+
/** Drop the cached layout of a file that was just written, so the next download sees the new content. */
|
|
1253
|
+
async invalidateCachedFile(selector, target) {
|
|
1254
|
+
await this.cachedFileScope(selector, target)?.invalidate();
|
|
1255
|
+
}
|
|
1256
|
+
/** Drop the cached files at or below paths that a delete, move, or copy changed. */
|
|
1257
|
+
async invalidateCachedPaths(selector, paths) {
|
|
1258
|
+
const cache = this.transport.cache;
|
|
1259
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
|
|
1260
|
+
if (cache && workspace !== null) {
|
|
1261
|
+
await cache.invalidatePaths(workspace, paths);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Cache a file this client just uploaded, so downloading it needs no
|
|
1266
|
+
* network. Falls back to invalidation when any chunk cannot be cached.
|
|
1267
|
+
*/
|
|
1268
|
+
async cacheUploadedFile(selector, file, mode = "default") {
|
|
1269
|
+
const scope = this.cachedFileScope(selector, file);
|
|
1270
|
+
if (!scope) {
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
await scope.invalidate();
|
|
1274
|
+
if (mode === "no-store") {
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
let complete = true;
|
|
1278
|
+
for (const chunk of file.chunks) {
|
|
1279
|
+
complete = await scope.cache.storeChunk(scope.workspace, chunk, chunk.data) && complete;
|
|
1280
|
+
}
|
|
1281
|
+
if (complete) {
|
|
1282
|
+
for (const fileKey of scope.fileKeys) {
|
|
1283
|
+
await scope.cache.storeFile(scope.workspace, fileKey, file);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
cachedFileScope(selector, target) {
|
|
1288
|
+
const cache = this.transport.cache;
|
|
1289
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
|
|
1290
|
+
if (!cache || workspace === null) {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
const fileKeys = [
|
|
1294
|
+
target.path !== void 0 ? fileCacheKey({ path: target.path }) : null,
|
|
1295
|
+
target.ino !== void 0 ? fileCacheKey({ ino: target.ino }) : null
|
|
1296
|
+
].filter((fileKey) => fileKey !== null);
|
|
1297
|
+
return {
|
|
1298
|
+
cache,
|
|
1299
|
+
workspace,
|
|
1300
|
+
fileKeys,
|
|
1301
|
+
invalidate: () => cache.invalidateFiles(workspace, fileKeys)
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
async clearCache(selector) {
|
|
1305
|
+
const cache = this.transport.cache;
|
|
1306
|
+
if (!cache) {
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
await cache.clear(selector === void 0 ? void 0 : this.requireCacheWorkspace(selector));
|
|
1310
|
+
}
|
|
1311
|
+
async cacheUsage(selector) {
|
|
1312
|
+
const cache = this.transport.cache;
|
|
1313
|
+
if (!cache) {
|
|
1314
|
+
return { bytes: 0, chunks: 0 };
|
|
1315
|
+
}
|
|
1316
|
+
return cache.usage(selector === void 0 ? void 0 : this.requireCacheWorkspace(selector));
|
|
1317
|
+
}
|
|
1318
|
+
requireCacheWorkspace(selector) {
|
|
1319
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
|
|
1320
|
+
if (workspace === null) {
|
|
1321
|
+
throw new TypeError("workspaceCuid or workspaceId is required");
|
|
1322
|
+
}
|
|
1323
|
+
return workspace;
|
|
1324
|
+
}
|
|
1325
|
+
/** Fall back to the client workspace only when the request names none, as uploads do. */
|
|
1326
|
+
resolveRequest(options) {
|
|
1327
|
+
const hasWorkspace = options.workspaceCuid !== void 0 || options.workspaceId !== void 0;
|
|
1328
|
+
return downloadRequestFromOptions(
|
|
1329
|
+
hasWorkspace ? options : {
|
|
1330
|
+
...options,
|
|
1331
|
+
workspaceCuid: this.transport.clientSelector.workspaceCuid,
|
|
1332
|
+
workspaceId: this.transport.clientSelector.workspaceId
|
|
1333
|
+
}
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
async exportRequest(request, signal) {
|
|
1337
|
+
return ensureExportFileResponse(
|
|
1338
|
+
await this.transport.requestJson("/file/export", requestBodyFromCheckpoint(request), signal)
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
cacheScope(request, mode = "default") {
|
|
1342
|
+
const cache = this.transport.cache;
|
|
1343
|
+
if (!cache || mode === "no-store") {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, request);
|
|
1347
|
+
const fileKey = fileCacheKey(request);
|
|
1348
|
+
return workspace !== null && fileKey !== null ? { cache, workspace, fileKey } : null;
|
|
1349
|
+
}
|
|
1350
|
+
/** A plan that needs no network at all, when the cached layout is fresh and every chunk is cached. */
|
|
1351
|
+
async loadCachedPlan(request, mode) {
|
|
1352
|
+
const scope = this.cacheScope(request, mode);
|
|
1353
|
+
if (!scope || mode === "no-cache") {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
const cached = await scope.cache.loadFile(scope.workspace, scope.fileKey);
|
|
1357
|
+
if (!cached) {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
path: cached.file.path,
|
|
1362
|
+
size: cached.file.size,
|
|
1363
|
+
...cached.file.contentType ? { contentType: cached.file.contentType } : {},
|
|
1364
|
+
chunks: cached.file.chunks,
|
|
1365
|
+
cachedChunks: cached.chunks
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
planExport(exported, request, mode, fileHandle) {
|
|
1369
|
+
const useClientAssembly = (exported.chunks?.length ?? 0) > 1 && (Boolean(fileHandle) || this.cacheScope(request, mode) !== null);
|
|
1370
|
+
return {
|
|
1371
|
+
path: exported.path,
|
|
1372
|
+
size: exported.size,
|
|
1373
|
+
...exported.contentType ? { contentType: exported.contentType } : {},
|
|
1374
|
+
chunks: buildDownloadChunks(exported, useClientAssembly),
|
|
1375
|
+
exported
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
587
1378
|
async pickSaveFile(requestedFilename, contentType) {
|
|
588
1379
|
const picker = getShowSaveFilePicker();
|
|
589
1380
|
if (!picker) {
|
|
@@ -609,13 +1400,15 @@ var MountraDownloader = class {
|
|
|
609
1400
|
throw new DOMException("Write permission was not granted", "NotAllowedError");
|
|
610
1401
|
}
|
|
611
1402
|
}
|
|
612
|
-
async downloadResolved(
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
const key = buildDownloadKey(exported, chunks, this.transport.baseUrl, request);
|
|
1403
|
+
async downloadResolved(plan, request, options, fileHandle, forceResume) {
|
|
1404
|
+
const chunks = plan.chunks;
|
|
1405
|
+
const key = buildDownloadKey(plan, this.transport.baseUrl, request);
|
|
616
1406
|
const existing = await this.transport.checkpointStore.load(key);
|
|
617
1407
|
const resumeRequested = !("resume" in options) || options.resume !== false;
|
|
618
|
-
let checkpoint = existing && checkpointMatches(existing,
|
|
1408
|
+
let checkpoint = existing && checkpointMatches(existing, plan) && (forceResume || resumeRequested) && (forceResume || fileHandle && await this.isSameFileHandle(existing.fileHandle, fileHandle)) ? existing : createCheckpoint(key, this.transport.baseUrl, request, plan, fileHandle);
|
|
1409
|
+
const cacheScope = this.cacheScope(request, "cache" in options ? options.cache : void 0);
|
|
1410
|
+
const cachedChunkIndexes = /* @__PURE__ */ new Set();
|
|
1411
|
+
let cachedBytes = 0;
|
|
619
1412
|
if (fileHandle) {
|
|
620
1413
|
checkpoint.fileHandle = fileHandle;
|
|
621
1414
|
}
|
|
@@ -630,11 +1423,11 @@ var MountraDownloader = class {
|
|
|
630
1423
|
const emit = (phase, currentChunkIndex) => {
|
|
631
1424
|
const downloadedBytes = totalDownloadedBytes(checkpoint);
|
|
632
1425
|
const speedBytesPerSecond = speedWindow.record(downloadedBytes, Date.now());
|
|
633
|
-
const remainingBytes = Math.max(
|
|
1426
|
+
const remainingBytes = Math.max(plan.size - downloadedBytes, 0);
|
|
634
1427
|
options.onProgress?.({
|
|
635
1428
|
phase,
|
|
636
1429
|
downloadedBytes,
|
|
637
|
-
totalBytes:
|
|
1430
|
+
totalBytes: plan.size,
|
|
638
1431
|
downloadedChunks: completedChunkCount(checkpoint),
|
|
639
1432
|
totalChunks: checkpoint.chunks.length,
|
|
640
1433
|
...currentChunkIndex !== void 0 ? { currentChunkIndex } : {},
|
|
@@ -664,12 +1457,30 @@ var MountraDownloader = class {
|
|
|
664
1457
|
if (savedChunk.completed) {
|
|
665
1458
|
continue;
|
|
666
1459
|
}
|
|
1460
|
+
const cached = plan.cachedChunks?.get(chunk.index) ?? (cacheScope ? await cacheScope.cache.readChunk(cacheScope.workspace, chunk) : null);
|
|
1461
|
+
if (cached) {
|
|
1462
|
+
throwIfAborted(options.signal);
|
|
1463
|
+
const remaining = cached.slice(savedChunk.downloadedBytes);
|
|
1464
|
+
await sink.write(remaining, chunk.offset + savedChunk.downloadedBytes);
|
|
1465
|
+
cachedBytes += remaining.size;
|
|
1466
|
+
savedChunk.downloadedBytes = chunk.size;
|
|
1467
|
+
savedChunk.completed = true;
|
|
1468
|
+
cachedChunkIndexes.add(chunk.index);
|
|
1469
|
+
emit("downloading", chunk.index);
|
|
1470
|
+
await persist(false);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
const url = chunk.url;
|
|
1474
|
+
if (!url) {
|
|
1475
|
+
throw new Error(`Chunk ${chunk.index} is no longer in the download cache`);
|
|
1476
|
+
}
|
|
1477
|
+
const capture = cacheScope && savedChunk.downloadedBytes === 0 && cacheScope.cache.canStoreChunk(chunk) ? [] : null;
|
|
667
1478
|
let attempt = 0;
|
|
668
1479
|
while (true) {
|
|
669
1480
|
throwIfAborted(options.signal);
|
|
670
1481
|
const rangeStart = savedChunk.downloadedBytes;
|
|
671
1482
|
try {
|
|
672
|
-
const response = await this.transport.fetchImpl(
|
|
1483
|
+
const response = await this.transport.fetchImpl(url, {
|
|
673
1484
|
method: "GET",
|
|
674
1485
|
...rangeStart > 0 ? { headers: { Range: `bytes=${rangeStart}-` } } : {},
|
|
675
1486
|
...options.signal ? { signal: options.signal } : {}
|
|
@@ -685,9 +1496,10 @@ var MountraDownloader = class {
|
|
|
685
1496
|
chunk,
|
|
686
1497
|
savedChunk,
|
|
687
1498
|
sink,
|
|
688
|
-
|
|
1499
|
+
plan.size,
|
|
689
1500
|
emit,
|
|
690
1501
|
persist,
|
|
1502
|
+
capture,
|
|
691
1503
|
options.signal
|
|
692
1504
|
);
|
|
693
1505
|
if (savedChunk.downloadedBytes !== savedChunk.size) {
|
|
@@ -697,6 +1509,9 @@ var MountraDownloader = class {
|
|
|
697
1509
|
}
|
|
698
1510
|
savedChunk.completed = true;
|
|
699
1511
|
await persist(true);
|
|
1512
|
+
if (cacheScope && capture && await cacheScope.cache.storeChunk(cacheScope.workspace, chunk, new Blob(capture))) {
|
|
1513
|
+
cachedChunkIndexes.add(chunk.index);
|
|
1514
|
+
}
|
|
700
1515
|
emit("downloading", chunk.index);
|
|
701
1516
|
break;
|
|
702
1517
|
} catch (error) {
|
|
@@ -711,25 +1526,29 @@ var MountraDownloader = class {
|
|
|
711
1526
|
}
|
|
712
1527
|
}
|
|
713
1528
|
}
|
|
714
|
-
if (totalDownloadedBytes(checkpoint) !==
|
|
1529
|
+
if (totalDownloadedBytes(checkpoint) !== plan.size) {
|
|
715
1530
|
throw new Error("Download completed with an unexpected byte count");
|
|
716
1531
|
}
|
|
717
1532
|
if (fileHandle) {
|
|
718
|
-
await sink.finalize(
|
|
1533
|
+
await sink.finalize(plan.size);
|
|
719
1534
|
} else {
|
|
720
1535
|
await sink.finalize();
|
|
721
1536
|
}
|
|
722
1537
|
await this.transport.checkpointStore.remove(key);
|
|
1538
|
+
if (cacheScope && plan.exported && chunks.every((chunk) => chunk.size === 0 || cachedChunkIndexes.has(chunk.index))) {
|
|
1539
|
+
await cacheScope.cache.storeFile(cacheScope.workspace, cacheScope.fileKey, plan);
|
|
1540
|
+
}
|
|
723
1541
|
emit("completed");
|
|
724
|
-
const blob = sink instanceof BlobDownloadSink ? sink.blob(
|
|
1542
|
+
const blob = sink instanceof BlobDownloadSink ? sink.blob(plan.contentType) : void 0;
|
|
725
1543
|
if (blob) {
|
|
726
1544
|
const filename = "downloadFilename" in options ? options.downloadFilename : void 0;
|
|
727
|
-
triggerBrowserDownload(blob, defaultFilename(
|
|
1545
|
+
triggerBrowserDownload(blob, defaultFilename(plan.path, filename));
|
|
728
1546
|
}
|
|
729
1547
|
return {
|
|
730
|
-
export: exported,
|
|
1548
|
+
...plan.exported ? { export: plan.exported } : {},
|
|
731
1549
|
downloadKey: key,
|
|
732
|
-
downloadedBytes:
|
|
1550
|
+
downloadedBytes: plan.size,
|
|
1551
|
+
cachedBytes,
|
|
733
1552
|
resumed,
|
|
734
1553
|
...fileHandle ? { fileHandle } : {},
|
|
735
1554
|
...blob ? { blob } : {}
|
|
@@ -739,13 +1558,13 @@ var MountraDownloader = class {
|
|
|
739
1558
|
throw error;
|
|
740
1559
|
}
|
|
741
1560
|
}
|
|
742
|
-
async consumeResponse(response, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
|
|
1561
|
+
async consumeResponse(response, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal) {
|
|
743
1562
|
const sourceOffset = savedChunk.downloadedBytes;
|
|
744
1563
|
let bytesToSkip = response.status === 200 && sourceOffset > 0 ? sourceOffset : 0;
|
|
745
1564
|
const reader = response.body?.getReader();
|
|
746
1565
|
if (!reader) {
|
|
747
1566
|
const body = new Uint8Array(await response.arrayBuffer());
|
|
748
|
-
await this.consumeBytes(body, bytesToSkip, chunk, savedChunk, sink, totalSize, emit, persist, signal);
|
|
1567
|
+
await this.consumeBytes(body, bytesToSkip, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal);
|
|
749
1568
|
return;
|
|
750
1569
|
}
|
|
751
1570
|
try {
|
|
@@ -771,10 +1590,11 @@ var MountraDownloader = class {
|
|
|
771
1590
|
totalSize,
|
|
772
1591
|
emit,
|
|
773
1592
|
persist,
|
|
1593
|
+
capture,
|
|
774
1594
|
signal
|
|
775
1595
|
);
|
|
776
1596
|
} else {
|
|
777
|
-
await this.consumeBytes(bytes, 0, chunk, savedChunk, sink, totalSize, emit, persist, signal);
|
|
1597
|
+
await this.consumeBytes(bytes, 0, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal);
|
|
778
1598
|
}
|
|
779
1599
|
}
|
|
780
1600
|
} finally {
|
|
@@ -784,7 +1604,7 @@ var MountraDownloader = class {
|
|
|
784
1604
|
throw new Error(`Download response did not contain the saved offset for chunk ${chunk.index}`);
|
|
785
1605
|
}
|
|
786
1606
|
}
|
|
787
|
-
async consumeBytes(bytes, offset, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
|
|
1607
|
+
async consumeBytes(bytes, offset, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal) {
|
|
788
1608
|
const remaining = chunk.size - savedChunk.downloadedBytes;
|
|
789
1609
|
const writableBytes = bytes.byteLength - offset;
|
|
790
1610
|
if (writableBytes > remaining) {
|
|
@@ -796,11 +1616,8 @@ var MountraDownloader = class {
|
|
|
796
1616
|
throwIfAborted(signal);
|
|
797
1617
|
const payload = bytes.subarray(offset);
|
|
798
1618
|
const position = chunk.offset + savedChunk.downloadedBytes;
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
} else {
|
|
802
|
-
await sink.write(payload);
|
|
803
|
-
}
|
|
1619
|
+
await sink.write(payload, position);
|
|
1620
|
+
capture?.push(payload.slice());
|
|
804
1621
|
savedChunk.downloadedBytes += payload.byteLength;
|
|
805
1622
|
emit("downloading", chunk.index);
|
|
806
1623
|
await persist(false);
|
|
@@ -822,156 +1639,953 @@ var MountraDownloader = class {
|
|
|
822
1639
|
return false;
|
|
823
1640
|
}
|
|
824
1641
|
}
|
|
825
|
-
return false;
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// src/errors.ts
|
|
1647
|
+
var MountraApiError = class extends Error {
|
|
1648
|
+
status;
|
|
1649
|
+
code;
|
|
1650
|
+
body;
|
|
1651
|
+
constructor(message, input) {
|
|
1652
|
+
super(message);
|
|
1653
|
+
this.name = "MountraApiError";
|
|
1654
|
+
this.status = input.status;
|
|
1655
|
+
this.code = input.code;
|
|
1656
|
+
this.body = input.body;
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
// src/metadata.ts
|
|
1661
|
+
function isRecord2(value) {
|
|
1662
|
+
return typeof value === "object" && value !== null;
|
|
1663
|
+
}
|
|
1664
|
+
function invalidField(field) {
|
|
1665
|
+
return new TypeError(`Mountra metadata response field ${field} is invalid`);
|
|
1666
|
+
}
|
|
1667
|
+
function readString2(value, field) {
|
|
1668
|
+
if (typeof value !== "string") {
|
|
1669
|
+
throw invalidField(field);
|
|
1670
|
+
}
|
|
1671
|
+
return value;
|
|
1672
|
+
}
|
|
1673
|
+
function readCount(value, field) {
|
|
1674
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
1675
|
+
throw invalidField(field);
|
|
1676
|
+
}
|
|
1677
|
+
return value;
|
|
1678
|
+
}
|
|
1679
|
+
function readBoolean(value, field) {
|
|
1680
|
+
if (typeof value !== "boolean") {
|
|
1681
|
+
throw invalidField(field);
|
|
1682
|
+
}
|
|
1683
|
+
return value;
|
|
1684
|
+
}
|
|
1685
|
+
function optionalInteger(value) {
|
|
1686
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : void 0;
|
|
1687
|
+
}
|
|
1688
|
+
function optionalText(value) {
|
|
1689
|
+
return typeof value === "string" && value ? value : void 0;
|
|
1690
|
+
}
|
|
1691
|
+
function withoutUndefined(value) {
|
|
1692
|
+
for (const key of Object.keys(value)) {
|
|
1693
|
+
if (value[key] === void 0) {
|
|
1694
|
+
delete value[key];
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return value;
|
|
1698
|
+
}
|
|
1699
|
+
function readNodeFields(raw, field) {
|
|
1700
|
+
return {
|
|
1701
|
+
name: readString2(raw.name, `${field}.name`),
|
|
1702
|
+
path: normalizePath(readString2(raw.path, `${field}.path`)),
|
|
1703
|
+
ino: readCount(raw.ino, `${field}.ino`),
|
|
1704
|
+
isDirectory: readBoolean(raw.is_directory, `${field}.is_directory`),
|
|
1705
|
+
size: readCount(raw.size, `${field}.size`),
|
|
1706
|
+
mtimeMs: readCount(raw.mtime_ms, `${field}.mtime_ms`),
|
|
1707
|
+
uid: optionalInteger(raw.uid),
|
|
1708
|
+
gid: optionalInteger(raw.gid),
|
|
1709
|
+
uidSubjectId: optionalText(raw.uid_subject_id),
|
|
1710
|
+
uidSubjectType: optionalText(raw.uid_subject_type),
|
|
1711
|
+
gidSubjectId: optionalText(raw.gid_subject_id),
|
|
1712
|
+
gidSubjectType: optionalText(raw.gid_subject_type)
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
function readEntry(raw, field) {
|
|
1716
|
+
if (!isRecord2(raw)) {
|
|
1717
|
+
throw invalidField(field);
|
|
1718
|
+
}
|
|
1719
|
+
return withoutUndefined({
|
|
1720
|
+
...readNodeFields(raw, field),
|
|
1721
|
+
atimeMs: readCount(raw.atime_ms, `${field}.atime_ms`),
|
|
1722
|
+
uidDisplayName: optionalText(raw.uid_display_name),
|
|
1723
|
+
uidUsername: optionalText(raw.uid_username),
|
|
1724
|
+
gidDisplayName: optionalText(raw.gid_display_name),
|
|
1725
|
+
gidUsername: optionalText(raw.gid_username)
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
function readTreeNode(raw, field) {
|
|
1729
|
+
if (!isRecord2(raw) || !Array.isArray(raw.children)) {
|
|
1730
|
+
throw invalidField(field);
|
|
1731
|
+
}
|
|
1732
|
+
return withoutUndefined({
|
|
1733
|
+
...readNodeFields(raw, field),
|
|
1734
|
+
children: raw.children.map((child, index) => readTreeNode(child, `${field}.children[${index}]`))
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
function readListResponse(value) {
|
|
1738
|
+
if (!isRecord2(value) || !Array.isArray(value.entries)) {
|
|
1739
|
+
throw invalidField("entries");
|
|
1740
|
+
}
|
|
1741
|
+
return value.entries.map((entry, index) => readEntry(entry, `entries[${index}]`));
|
|
1742
|
+
}
|
|
1743
|
+
function readOperationResponse(value) {
|
|
1744
|
+
if (!isRecord2(value)) {
|
|
1745
|
+
throw new TypeError("Mountra file operation response is invalid");
|
|
1746
|
+
}
|
|
1747
|
+
return {
|
|
1748
|
+
path: normalizePath(readString2(value.path, "path")),
|
|
1749
|
+
ino: readCount(value.ino, "ino")
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
function sameJson(left, right) {
|
|
1753
|
+
if (left === right) {
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1756
|
+
if (!isRecord2(left) || !isRecord2(right) || Array.isArray(left) !== Array.isArray(right)) {
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
const leftKeys = Object.keys(left);
|
|
1760
|
+
return leftKeys.length === Object.keys(right).length && leftKeys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
|
|
1761
|
+
}
|
|
1762
|
+
function sameContent(left, right) {
|
|
1763
|
+
return left.kind === "list" && right.kind === "list" ? sameJson(left.entries, right.entries) : left.kind === "tree" && right.kind === "tree" && sameJson(left.root, right.root);
|
|
1764
|
+
}
|
|
1765
|
+
function workspaceBody(selector) {
|
|
1766
|
+
return {
|
|
1767
|
+
...selector.workspaceCuid !== void 0 ? { workspace_cuid: selector.workspaceCuid } : {},
|
|
1768
|
+
...selector.workspaceId !== void 0 ? { workspace_id: selector.workspaceId } : {}
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
var MountraMetadata = class {
|
|
1772
|
+
transport;
|
|
1773
|
+
constructor(transport) {
|
|
1774
|
+
this.transport = transport;
|
|
1775
|
+
}
|
|
1776
|
+
list(options = {}) {
|
|
1777
|
+
return this.read("list", options, (record, source) => ({
|
|
1778
|
+
path: record.path,
|
|
1779
|
+
entries: record.entries,
|
|
1780
|
+
source,
|
|
1781
|
+
fetchedAt: record.fetchedAt
|
|
1782
|
+
}));
|
|
1783
|
+
}
|
|
1784
|
+
tree(options = {}) {
|
|
1785
|
+
return this.read("tree", options, (record, source) => ({
|
|
1786
|
+
path: record.path,
|
|
1787
|
+
root: record.root,
|
|
1788
|
+
source,
|
|
1789
|
+
fetchedAt: record.fetchedAt
|
|
1790
|
+
}));
|
|
1791
|
+
}
|
|
1792
|
+
async mkdir(options) {
|
|
1793
|
+
const selector = this.resolveWorkspace(options);
|
|
1794
|
+
const created = await this.operate(selector, "/file/mkdir", { path: normalizePath(options.path) }, options.signal);
|
|
1795
|
+
await this.afterWrite(selector, {
|
|
1796
|
+
type: "create",
|
|
1797
|
+
path: created.path,
|
|
1798
|
+
ino: created.ino,
|
|
1799
|
+
isDirectory: true,
|
|
1800
|
+
size: 0
|
|
1801
|
+
});
|
|
1802
|
+
return created;
|
|
1803
|
+
}
|
|
1804
|
+
async rename(options) {
|
|
1805
|
+
const selector = this.resolveWorkspace(options);
|
|
1806
|
+
const fromPath = normalizePath(options.fromPath);
|
|
1807
|
+
const renamed = await this.operate(
|
|
1808
|
+
selector,
|
|
1809
|
+
"/file/rename",
|
|
1810
|
+
{ from_path: fromPath, to_name: options.toName },
|
|
1811
|
+
options.signal
|
|
1812
|
+
);
|
|
1813
|
+
await this.afterWrite(selector, { type: "move", fromPath, toPath: renamed.path, ino: renamed.ino });
|
|
1814
|
+
return renamed;
|
|
1815
|
+
}
|
|
1816
|
+
async move(options) {
|
|
1817
|
+
const selector = this.resolveWorkspace(options);
|
|
1818
|
+
const fromPath = normalizePath(options.fromPath);
|
|
1819
|
+
const moved = await this.operate(
|
|
1820
|
+
selector,
|
|
1821
|
+
"/file/move",
|
|
1822
|
+
{ from_path: fromPath, to_path: normalizePath(options.toPath) },
|
|
1823
|
+
options.signal
|
|
1824
|
+
);
|
|
1825
|
+
await this.afterWrite(selector, { type: "move", fromPath, toPath: moved.path, ino: moved.ino });
|
|
1826
|
+
return moved;
|
|
1827
|
+
}
|
|
1828
|
+
async copy(options) {
|
|
1829
|
+
const selector = this.resolveWorkspace(options);
|
|
1830
|
+
const fromPath = normalizePath(options.fromPath);
|
|
1831
|
+
const copied = await this.operate(
|
|
1832
|
+
selector,
|
|
1833
|
+
"/file/copy",
|
|
1834
|
+
{ from_path: fromPath, to_path: normalizePath(options.toPath) },
|
|
1835
|
+
options.signal
|
|
1836
|
+
);
|
|
1837
|
+
await this.afterWrite(selector, { type: "copy", fromPath, toPath: copied.path, ino: copied.ino });
|
|
1838
|
+
return copied;
|
|
1839
|
+
}
|
|
1840
|
+
async delete(options) {
|
|
1841
|
+
const selector = this.resolveWorkspace(options);
|
|
1842
|
+
const path = normalizePath(options.path);
|
|
1843
|
+
await this.transport.requestJson("/file/delete", { ...workspaceBody(selector), path }, options.signal);
|
|
1844
|
+
await this.afterWrite(selector, { type: "remove", path });
|
|
1845
|
+
}
|
|
1846
|
+
/** Show a file that an upload just finished. The upload updates the download cache itself. */
|
|
1847
|
+
recordUpload(selector, file) {
|
|
1848
|
+
return this.applyChange(selector, {
|
|
1849
|
+
type: "create",
|
|
1850
|
+
path: normalizePath(file.path),
|
|
1851
|
+
...file.ino !== void 0 ? { ino: file.ino } : {},
|
|
1852
|
+
isDirectory: false,
|
|
1853
|
+
size: file.size
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
async clearCache(selector) {
|
|
1857
|
+
const cache = this.transport.cache;
|
|
1858
|
+
if (!cache) {
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (selector === void 0) {
|
|
1862
|
+
await cache.clear();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
|
|
1866
|
+
if (workspace === null) {
|
|
1867
|
+
throw new TypeError("workspaceCuid or workspaceId is required");
|
|
1868
|
+
}
|
|
1869
|
+
await cache.clear(workspace);
|
|
1870
|
+
}
|
|
1871
|
+
async read(kind, options, present) {
|
|
1872
|
+
const selector = this.resolveWorkspace(options);
|
|
1873
|
+
const path = normalizePath(options.path ?? "/");
|
|
1874
|
+
const scope = this.cacheScope(selector);
|
|
1875
|
+
const { signal, onUpdate, onError } = options;
|
|
1876
|
+
const cached = options.mode === "async" && scope ? await scope.cache.get(scope.workspace, kind, path) : null;
|
|
1877
|
+
if (!cached) {
|
|
1878
|
+
return present(await this.fetch(kind, selector, path, signal), "remote");
|
|
1879
|
+
}
|
|
1880
|
+
void this.fetch(kind, selector, path, signal).then(
|
|
1881
|
+
(fresh) => {
|
|
1882
|
+
if (!signal?.aborted && !sameContent(cached, fresh)) {
|
|
1883
|
+
onUpdate?.(present(fresh, "remote"));
|
|
1884
|
+
}
|
|
1885
|
+
},
|
|
1886
|
+
(error) => {
|
|
1887
|
+
if (!signal?.aborted) {
|
|
1888
|
+
onError?.(error);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
);
|
|
1892
|
+
return present(cached, "cache");
|
|
1893
|
+
}
|
|
1894
|
+
/** Fetch from Mountra, and cache the result before returning it. */
|
|
1895
|
+
async fetch(kind, selector, path, signal) {
|
|
1896
|
+
const scope = this.cacheScope(selector);
|
|
1897
|
+
const ticket = scope?.cache.beginFetch(scope.workspace);
|
|
1898
|
+
let body;
|
|
1899
|
+
try {
|
|
1900
|
+
body = await this.transport.requestJson(`/file/${kind}`, { ...workspaceBody(selector), path }, signal);
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
if (scope && ticket && error instanceof MountraApiError && error.status === 404) {
|
|
1903
|
+
await scope.cache.forgetFetched(scope.workspace, path, ticket);
|
|
1904
|
+
}
|
|
1905
|
+
throw error;
|
|
1906
|
+
}
|
|
1907
|
+
const fetchedAt = ticket?.startedAt ?? Date.now();
|
|
1908
|
+
let record;
|
|
1909
|
+
if (kind === "list") {
|
|
1910
|
+
record = { kind, path, entries: readListResponse(body), fetchedAt };
|
|
1911
|
+
} else {
|
|
1912
|
+
if (!isRecord2(body)) {
|
|
1913
|
+
throw invalidField("root");
|
|
1914
|
+
}
|
|
1915
|
+
record = { kind, path, root: readTreeNode(body.root, "root"), fetchedAt };
|
|
1916
|
+
}
|
|
1917
|
+
if (scope && ticket) {
|
|
1918
|
+
await scope.cache.storeFetched(scope.workspace, record, ticket);
|
|
1919
|
+
}
|
|
1920
|
+
return record;
|
|
1921
|
+
}
|
|
1922
|
+
async operate(selector, endpoint, body, signal) {
|
|
1923
|
+
return readOperationResponse(
|
|
1924
|
+
await this.transport.requestJson(endpoint, { ...workspaceBody(selector), ...body }, signal)
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Reflect a write in both caches. Any download cached at a path the write
|
|
1929
|
+
* changed is stale: the file is gone, moved, or a new one now lives there.
|
|
1930
|
+
*/
|
|
1931
|
+
async afterWrite(selector, change) {
|
|
1932
|
+
const paths = change.type === "move" ? [change.fromPath, change.toPath] : change.type === "copy" ? [change.toPath] : [change.path];
|
|
1933
|
+
await Promise.all([
|
|
1934
|
+
this.applyChange(selector, change),
|
|
1935
|
+
this.transport.invalidateDownloads(selector, paths)
|
|
1936
|
+
]);
|
|
1937
|
+
}
|
|
1938
|
+
async applyChange(selector, change) {
|
|
1939
|
+
const scope = this.cacheScope(selector);
|
|
1940
|
+
await scope?.cache.applyChange(scope.workspace, change);
|
|
1941
|
+
}
|
|
1942
|
+
/** Fall back to the client workspace only when the call names none, as downloads do. */
|
|
1943
|
+
resolveWorkspace(selector) {
|
|
1944
|
+
const named = selector.workspaceCuid !== void 0 || selector.workspaceId !== void 0;
|
|
1945
|
+
const { workspaceCuid, workspaceId } = named ? selector : this.transport.clientSelector;
|
|
1946
|
+
if (workspaceCuid === void 0 && workspaceId === void 0) {
|
|
1947
|
+
throw new TypeError("workspaceCuid or workspaceId is required");
|
|
1948
|
+
}
|
|
1949
|
+
return {
|
|
1950
|
+
...workspaceCuid !== void 0 ? { workspaceCuid } : {},
|
|
1951
|
+
...workspaceId !== void 0 ? { workspaceId } : {}
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
cacheScope(selector) {
|
|
1955
|
+
const cache = this.transport.cache;
|
|
1956
|
+
const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
|
|
1957
|
+
return cache && workspace !== null ? { cache, workspace } : null;
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
|
|
1961
|
+
// src/metadata-cache.ts
|
|
1962
|
+
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
1963
|
+
var KINDS = ["list", "tree"];
|
|
1964
|
+
function parentOf(path) {
|
|
1965
|
+
const index = path.lastIndexOf("/");
|
|
1966
|
+
return index <= 0 ? "/" : path.slice(0, index);
|
|
1967
|
+
}
|
|
1968
|
+
function baseName(path) {
|
|
1969
|
+
return path.slice(path.lastIndexOf("/") + 1) || "/";
|
|
1970
|
+
}
|
|
1971
|
+
function selfAndAncestors(path) {
|
|
1972
|
+
const paths = [path];
|
|
1973
|
+
for (let current = path; current !== "/"; ) {
|
|
1974
|
+
current = parentOf(current);
|
|
1975
|
+
paths.push(current);
|
|
1976
|
+
}
|
|
1977
|
+
return paths;
|
|
1978
|
+
}
|
|
1979
|
+
function rebasePath(path, from, to) {
|
|
1980
|
+
return `${to}${path.slice(from.length)}`;
|
|
1981
|
+
}
|
|
1982
|
+
function memoryKey2(workspace, kind, path) {
|
|
1983
|
+
return JSON.stringify([workspace, kind, path]);
|
|
1984
|
+
}
|
|
1985
|
+
var InMemoryMetadataCacheStore = class {
|
|
1986
|
+
/** Map iteration order is the eviction order: the first entry was written least recently. */
|
|
1987
|
+
records = /* @__PURE__ */ new Map();
|
|
1988
|
+
async get(workspace, kind, path) {
|
|
1989
|
+
const entry = this.records.get(memoryKey2(workspace, kind, path));
|
|
1990
|
+
return entry ? structuredClone(entry.record) : null;
|
|
1991
|
+
}
|
|
1992
|
+
async getSubtree(workspace, path, kind) {
|
|
1993
|
+
return [...this.records.values()].filter(
|
|
1994
|
+
(entry) => entry.workspace === workspace && (kind === void 0 || entry.record.kind === kind) && isPathWithin(entry.record.path, path)
|
|
1995
|
+
).map((entry) => structuredClone(entry.record));
|
|
1996
|
+
}
|
|
1997
|
+
async write(workspace, batch, maxEntries) {
|
|
1998
|
+
for (const { kind, path } of batch.remove) {
|
|
1999
|
+
this.records.delete(memoryKey2(workspace, kind, path));
|
|
2000
|
+
}
|
|
2001
|
+
for (const record of batch.put) {
|
|
2002
|
+
const key = memoryKey2(workspace, record.kind, record.path);
|
|
2003
|
+
this.records.delete(key);
|
|
2004
|
+
this.records.set(key, { workspace, record: structuredClone(record) });
|
|
2005
|
+
}
|
|
2006
|
+
for (const key of this.records.keys()) {
|
|
2007
|
+
if (this.records.size <= maxEntries) {
|
|
2008
|
+
break;
|
|
2009
|
+
}
|
|
2010
|
+
this.records.delete(key);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
async clear(workspace) {
|
|
2014
|
+
for (const [key, entry] of this.records) {
|
|
2015
|
+
if (workspace === void 0 || entry.workspace === workspace) {
|
|
2016
|
+
this.records.delete(key);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
var METADATA_DATABASE_NAME = "mountra-sdk-metadata-cache";
|
|
2022
|
+
var METADATA_DATABASE_VERSION = 1;
|
|
2023
|
+
var RECORDS_STORE = "records";
|
|
2024
|
+
var META_STORE2 = "meta";
|
|
2025
|
+
var WRITE_ORDER_INDEX = "written";
|
|
2026
|
+
var IndexedDBMetadataCacheStore = class {
|
|
2027
|
+
databaseName;
|
|
2028
|
+
fallback = new InMemoryMetadataCacheStore();
|
|
2029
|
+
databasePromise;
|
|
2030
|
+
constructor(options = {}) {
|
|
2031
|
+
this.databaseName = options.databaseName ?? METADATA_DATABASE_NAME;
|
|
2032
|
+
}
|
|
2033
|
+
async get(workspace, kind, path) {
|
|
2034
|
+
const database = await this.openDatabase();
|
|
2035
|
+
if (!database) {
|
|
2036
|
+
return this.fallback.get(workspace, kind, path);
|
|
2037
|
+
}
|
|
2038
|
+
return runTransaction(database, [RECORDS_STORE], "readonly", async (transaction) => {
|
|
2039
|
+
const stored = await requestResult(
|
|
2040
|
+
transaction.objectStore(RECORDS_STORE).get([workspace, kind, path])
|
|
2041
|
+
);
|
|
2042
|
+
return stored?.record ?? null;
|
|
2043
|
+
});
|
|
826
2044
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
2045
|
+
async getSubtree(workspace, path, kind) {
|
|
2046
|
+
const database = await this.openDatabase();
|
|
2047
|
+
if (!database) {
|
|
2048
|
+
return this.fallback.getSubtree(workspace, path, kind);
|
|
2049
|
+
}
|
|
2050
|
+
const upper = path === "/" ? "0" : `${path}0`;
|
|
2051
|
+
return runTransaction(database, [RECORDS_STORE], "readonly", async (transaction) => {
|
|
2052
|
+
const records = [];
|
|
2053
|
+
for (const candidate of kind === void 0 ? KINDS : [kind]) {
|
|
2054
|
+
const stored = await requestResult(
|
|
2055
|
+
transaction.objectStore(RECORDS_STORE).getAll(IDBKeyRange.bound([workspace, candidate, path], [workspace, candidate, upper], false, true))
|
|
2056
|
+
);
|
|
2057
|
+
records.push(
|
|
2058
|
+
...stored.map(({ record }) => record).filter((record) => isPathWithin(record.path, path))
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
return records;
|
|
2062
|
+
});
|
|
833
2063
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
2064
|
+
async write(workspace, batch, maxEntries) {
|
|
2065
|
+
const database = await this.openDatabase();
|
|
2066
|
+
if (!database) {
|
|
2067
|
+
return this.fallback.write(workspace, batch, maxEntries);
|
|
2068
|
+
}
|
|
2069
|
+
await runTransaction(database, [RECORDS_STORE, META_STORE2], "readwrite", async (transaction) => {
|
|
2070
|
+
const records = transaction.objectStore(RECORDS_STORE);
|
|
2071
|
+
const meta = transaction.objectStore(META_STORE2);
|
|
2072
|
+
for (const { kind, path } of batch.remove) {
|
|
2073
|
+
await requestResult(records.delete([workspace, kind, path]));
|
|
2074
|
+
}
|
|
2075
|
+
for (const record of batch.put) {
|
|
2076
|
+
const stored = { record, written: await nextClock(meta) };
|
|
2077
|
+
await requestResult(records.put(stored, [workspace, record.kind, record.path]));
|
|
2078
|
+
}
|
|
2079
|
+
let count = await requestResult(records.count());
|
|
2080
|
+
if (count <= maxEntries) {
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
const cursorRequest = records.index(WRITE_ORDER_INDEX).openCursor();
|
|
2084
|
+
let cursor = await requestResult(cursorRequest);
|
|
2085
|
+
while (cursor && count > maxEntries) {
|
|
2086
|
+
await requestResult(cursor.delete());
|
|
2087
|
+
count -= 1;
|
|
2088
|
+
cursor.continue();
|
|
2089
|
+
cursor = await requestResult(cursorRequest);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
838
2092
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
2093
|
+
async clear(workspace) {
|
|
2094
|
+
const database = await this.openDatabase();
|
|
2095
|
+
if (!database) {
|
|
2096
|
+
return this.fallback.clear(workspace);
|
|
2097
|
+
}
|
|
2098
|
+
await runTransaction(database, [RECORDS_STORE], "readwrite", async (transaction) => {
|
|
2099
|
+
const records = transaction.objectStore(RECORDS_STORE);
|
|
2100
|
+
await requestResult(
|
|
2101
|
+
workspace === void 0 ? records.clear() : records.delete(workspaceRange(workspace))
|
|
2102
|
+
);
|
|
2103
|
+
});
|
|
842
2104
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
2105
|
+
async openDatabase() {
|
|
2106
|
+
this.databasePromise ??= openDatabase(
|
|
2107
|
+
this.databaseName,
|
|
2108
|
+
METADATA_DATABASE_VERSION,
|
|
2109
|
+
(database) => {
|
|
2110
|
+
if (!database.objectStoreNames.contains(RECORDS_STORE)) {
|
|
2111
|
+
database.createObjectStore(RECORDS_STORE).createIndex(WRITE_ORDER_INDEX, "written");
|
|
2112
|
+
}
|
|
2113
|
+
if (!database.objectStoreNames.contains(META_STORE2)) {
|
|
2114
|
+
database.createObjectStore(META_STORE2);
|
|
2115
|
+
}
|
|
2116
|
+
},
|
|
2117
|
+
() => {
|
|
2118
|
+
this.databasePromise = void 0;
|
|
2119
|
+
}
|
|
2120
|
+
);
|
|
2121
|
+
return this.databasePromise;
|
|
846
2122
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
2123
|
+
};
|
|
2124
|
+
var NODE_FIELDS = [
|
|
2125
|
+
"name",
|
|
2126
|
+
"path",
|
|
2127
|
+
"ino",
|
|
2128
|
+
"isDirectory",
|
|
2129
|
+
"size",
|
|
2130
|
+
"mtimeMs",
|
|
2131
|
+
"uid",
|
|
2132
|
+
"gid",
|
|
2133
|
+
"uidSubjectId",
|
|
2134
|
+
"uidSubjectType",
|
|
2135
|
+
"gidSubjectId",
|
|
2136
|
+
"gidSubjectType"
|
|
2137
|
+
];
|
|
2138
|
+
function pickDefined(source, keys) {
|
|
2139
|
+
const picked = {};
|
|
2140
|
+
for (const key of keys) {
|
|
2141
|
+
if (source[key] !== void 0) {
|
|
2142
|
+
picked[key] = source[key];
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return picked;
|
|
2146
|
+
}
|
|
2147
|
+
function nodeFromEntry(entry, children) {
|
|
2148
|
+
return { ...pickDefined(entry, NODE_FIELDS), children };
|
|
2149
|
+
}
|
|
2150
|
+
function entryFromNode(node, previous) {
|
|
2151
|
+
return {
|
|
2152
|
+
...pickDefined(node, NODE_FIELDS),
|
|
2153
|
+
atimeMs: previous.atimeMs,
|
|
2154
|
+
...node.uid === previous.uid ? pickDefined(previous, ["uidDisplayName", "uidUsername"]) : {},
|
|
2155
|
+
...node.gid === previous.gid ? pickDefined(previous, ["gidDisplayName", "gidUsername"]) : {}
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function findNode(root, path) {
|
|
2159
|
+
let node = root;
|
|
2160
|
+
while (node && node.path !== path) {
|
|
2161
|
+
node = isPathWithin(path, node.path) ? node.children.find((child) => isPathWithin(path, child.path)) : void 0;
|
|
854
2162
|
}
|
|
2163
|
+
return node;
|
|
2164
|
+
}
|
|
2165
|
+
function rebaseNode(node, from, to) {
|
|
855
2166
|
return {
|
|
856
|
-
...
|
|
857
|
-
...
|
|
858
|
-
|
|
2167
|
+
...node,
|
|
2168
|
+
...node.path === from ? { name: baseName(to) } : {},
|
|
2169
|
+
path: rebasePath(node.path, from, to),
|
|
2170
|
+
children: node.children.map((child) => rebaseNode(child, from, to))
|
|
859
2171
|
};
|
|
860
2172
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
2173
|
+
function compareChildren(left, right) {
|
|
2174
|
+
return Number(right.isDirectory) - Number(left.isDirectory) || left.name.localeCompare(right.name);
|
|
2175
|
+
}
|
|
2176
|
+
function withChild(children, child) {
|
|
2177
|
+
const result = [...children];
|
|
2178
|
+
const existing = result.findIndex((candidate) => candidate.path === child.path);
|
|
2179
|
+
if (existing >= 0) {
|
|
2180
|
+
result[existing] = child;
|
|
2181
|
+
return result;
|
|
2182
|
+
}
|
|
2183
|
+
const position = result.findIndex((candidate) => compareChildren(child, candidate) < 0);
|
|
2184
|
+
result.splice(position < 0 ? result.length : position, 0, child);
|
|
2185
|
+
return result;
|
|
2186
|
+
}
|
|
2187
|
+
function recordKey(kind, path) {
|
|
2188
|
+
return `${kind}:${path}`;
|
|
2189
|
+
}
|
|
2190
|
+
var MetadataDraft = class {
|
|
2191
|
+
records = /* @__PURE__ */ new Map();
|
|
2192
|
+
changed = /* @__PURE__ */ new Set();
|
|
2193
|
+
removed = /* @__PURE__ */ new Map();
|
|
2194
|
+
constructor(records) {
|
|
2195
|
+
for (const record of records) {
|
|
2196
|
+
this.records.set(recordKey(record.kind, record.path), record);
|
|
2197
|
+
}
|
|
865
2198
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
return this.locator.workspaceCuid;
|
|
2199
|
+
list(path) {
|
|
2200
|
+
const record = this.records.get(recordKey("list", path));
|
|
2201
|
+
return record?.kind === "list" ? record : void 0;
|
|
870
2202
|
}
|
|
871
|
-
|
|
872
|
-
|
|
2203
|
+
tree(path) {
|
|
2204
|
+
const record = this.records.get(recordKey("tree", path));
|
|
2205
|
+
return record?.kind === "tree" ? record : void 0;
|
|
873
2206
|
}
|
|
874
|
-
/** `
|
|
875
|
-
|
|
876
|
-
return this.
|
|
2207
|
+
/** Loaded trees that hold `path`: those rooted at `path` or at one of its ancestors. */
|
|
2208
|
+
treesContaining(path) {
|
|
2209
|
+
return [...this.records.values()].filter(
|
|
2210
|
+
(record) => record.kind === "tree" && isPathWithin(path, record.path)
|
|
2211
|
+
);
|
|
877
2212
|
}
|
|
878
|
-
|
|
879
|
-
|
|
2213
|
+
/** Loaded records at `path` or below it. */
|
|
2214
|
+
within(path) {
|
|
2215
|
+
return [...this.records.values()].filter((record) => isPathWithin(record.path, path));
|
|
880
2216
|
}
|
|
881
|
-
|
|
882
|
-
|
|
2217
|
+
put(record) {
|
|
2218
|
+
const key = recordKey(record.kind, record.path);
|
|
2219
|
+
this.records.set(key, record);
|
|
2220
|
+
this.removed.delete(key);
|
|
2221
|
+
this.changed.add(key);
|
|
883
2222
|
}
|
|
884
|
-
|
|
885
|
-
|
|
2223
|
+
/** Mark a loaded record that was changed in place. */
|
|
2224
|
+
touch(record) {
|
|
2225
|
+
this.changed.add(recordKey(record.kind, record.path));
|
|
886
2226
|
}
|
|
887
|
-
|
|
888
|
-
|
|
2227
|
+
remove(record) {
|
|
2228
|
+
const key = recordKey(record.kind, record.path);
|
|
2229
|
+
this.records.delete(key);
|
|
2230
|
+
this.changed.delete(key);
|
|
2231
|
+
this.removed.set(key, { kind: record.kind, path: record.path });
|
|
889
2232
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
2233
|
+
batch() {
|
|
2234
|
+
return {
|
|
2235
|
+
put: [...this.changed].map((key) => this.records.get(key)),
|
|
2236
|
+
remove: [...this.removed.values()]
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
findEntry(path) {
|
|
2240
|
+
return this.list(parentOf(path))?.entries.find((entry) => entry.path === path);
|
|
2241
|
+
}
|
|
2242
|
+
/** The node of `path` with its complete subtree, from any loaded tree. */
|
|
2243
|
+
findNode(path) {
|
|
2244
|
+
for (const tree of this.treesContaining(path)) {
|
|
2245
|
+
const node = findNode(tree.root, path);
|
|
2246
|
+
if (node) {
|
|
2247
|
+
return node;
|
|
900
2248
|
}
|
|
901
|
-
throw new Error("No resumable download was found for this Mountra file");
|
|
902
2249
|
}
|
|
903
|
-
return
|
|
2250
|
+
return void 0;
|
|
904
2251
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
2252
|
+
/** Remove `path` from the listing and the trees that show it. */
|
|
2253
|
+
removeEntry(path) {
|
|
2254
|
+
const parentPath = parentOf(path);
|
|
2255
|
+
const list = this.list(parentPath);
|
|
2256
|
+
if (list?.entries.some((entry) => entry.path === path)) {
|
|
2257
|
+
list.entries = list.entries.filter((entry) => entry.path !== path);
|
|
2258
|
+
this.touch(list);
|
|
2259
|
+
}
|
|
2260
|
+
for (const tree of this.treesContaining(parentPath)) {
|
|
2261
|
+
const parent = findNode(tree.root, parentPath);
|
|
2262
|
+
if (parent?.children.some((child) => child.path === path)) {
|
|
2263
|
+
parent.children = parent.children.filter((child) => child.path !== path);
|
|
2264
|
+
this.touch(tree);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
909
2267
|
}
|
|
910
|
-
|
|
911
|
-
|
|
2268
|
+
/**
|
|
2269
|
+
* Show `path` in its parent's listing and in the trees holding its parent.
|
|
2270
|
+
* A record that cannot show it faithfully, because the entry or the
|
|
2271
|
+
* complete node is unknown, is dropped instead.
|
|
2272
|
+
*/
|
|
2273
|
+
insertEntry(path, entry, node) {
|
|
2274
|
+
const parentPath = parentOf(path);
|
|
2275
|
+
const list = this.list(parentPath);
|
|
2276
|
+
if (list && entry) {
|
|
2277
|
+
list.entries = withChild(list.entries, entry);
|
|
2278
|
+
this.touch(list);
|
|
2279
|
+
} else if (list) {
|
|
2280
|
+
this.remove(list);
|
|
2281
|
+
}
|
|
2282
|
+
for (const tree of this.treesContaining(parentPath)) {
|
|
2283
|
+
const parent = findNode(tree.root, parentPath);
|
|
2284
|
+
if (node && parent?.isDirectory) {
|
|
2285
|
+
parent.children = withChild(parent.children, structuredClone(node));
|
|
2286
|
+
this.touch(tree);
|
|
2287
|
+
} else {
|
|
2288
|
+
this.remove(tree);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
912
2291
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
2292
|
+
removeSubtree(path) {
|
|
2293
|
+
for (const record of this.within(path)) {
|
|
2294
|
+
this.remove(record);
|
|
916
2295
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
2296
|
+
}
|
|
2297
|
+
/** Re-key the records at or below `from` to `to`, rewriting the paths they hold. */
|
|
2298
|
+
moveSubtree(from, to) {
|
|
2299
|
+
for (const record of this.within(from)) {
|
|
2300
|
+
this.remove(record);
|
|
2301
|
+
const path = rebasePath(record.path, from, to);
|
|
2302
|
+
this.put(
|
|
2303
|
+
record.kind === "list" ? {
|
|
2304
|
+
...record,
|
|
2305
|
+
path,
|
|
2306
|
+
entries: record.entries.map((entry) => ({ ...entry, path: rebasePath(entry.path, from, to) }))
|
|
2307
|
+
} : { ...record, path, root: rebaseNode(record.root, from, to) }
|
|
2308
|
+
);
|
|
921
2309
|
}
|
|
922
2310
|
}
|
|
923
|
-
|
|
2311
|
+
};
|
|
2312
|
+
function changeScope(change) {
|
|
2313
|
+
const paths = change.type === "move" || change.type === "copy" ? [change.fromPath, change.toPath] : [change.path];
|
|
2314
|
+
return {
|
|
2315
|
+
lists: paths.map(parentOf),
|
|
2316
|
+
trees: paths.flatMap((path) => selfAndAncestors(parentOf(path))),
|
|
2317
|
+
// A copy keeps its source; any other change makes records below its paths stale or moves them.
|
|
2318
|
+
subtrees: change.type === "copy" ? [change.toPath] : paths
|
|
2319
|
+
};
|
|
924
2320
|
}
|
|
925
|
-
function
|
|
926
|
-
|
|
927
|
-
|
|
2321
|
+
function patchChange(draft, change, now) {
|
|
2322
|
+
switch (change.type) {
|
|
2323
|
+
case "create": {
|
|
2324
|
+
const previousEntry = draft.findEntry(change.path);
|
|
2325
|
+
const previousNode = draft.findNode(change.path);
|
|
2326
|
+
draft.removeSubtree(change.path);
|
|
2327
|
+
if (change.ino === void 0) {
|
|
2328
|
+
draft.insertEntry(change.path, null, null);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
const fields = {
|
|
2332
|
+
name: baseName(change.path),
|
|
2333
|
+
path: change.path,
|
|
2334
|
+
ino: change.ino,
|
|
2335
|
+
isDirectory: change.isDirectory,
|
|
2336
|
+
size: change.size,
|
|
2337
|
+
mtimeMs: now
|
|
2338
|
+
};
|
|
2339
|
+
const sameKind = (previous) => previous?.isDirectory === change.isDirectory;
|
|
2340
|
+
draft.insertEntry(
|
|
2341
|
+
change.path,
|
|
2342
|
+
{ atimeMs: now, ...sameKind(previousEntry) ? previousEntry : {}, ...fields },
|
|
2343
|
+
{ ...sameKind(previousNode) ? previousNode : {}, ...fields, children: [] }
|
|
2344
|
+
);
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
case "remove":
|
|
2348
|
+
draft.removeEntry(change.path);
|
|
2349
|
+
draft.removeSubtree(change.path);
|
|
2350
|
+
return;
|
|
2351
|
+
case "move": {
|
|
2352
|
+
const { fromPath, toPath } = change;
|
|
2353
|
+
if (fromPath === toPath) {
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
const entry = draft.findEntry(fromPath);
|
|
2357
|
+
const node = draft.findNode(fromPath);
|
|
2358
|
+
draft.removeEntry(fromPath);
|
|
2359
|
+
draft.removeSubtree(toPath);
|
|
2360
|
+
draft.moveSubtree(fromPath, toPath);
|
|
2361
|
+
const fields = { name: baseName(toPath), path: toPath, ino: change.ino };
|
|
2362
|
+
const movedEntry = entry ? { ...entry, ...fields } : null;
|
|
2363
|
+
draft.insertEntry(
|
|
2364
|
+
toPath,
|
|
2365
|
+
movedEntry,
|
|
2366
|
+
node ? { ...rebaseNode(node, fromPath, toPath), ino: change.ino } : movedEntry && !movedEntry.isDirectory ? nodeFromEntry(movedEntry, []) : null
|
|
2367
|
+
);
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
case "copy": {
|
|
2371
|
+
const source = draft.findEntry(change.fromPath) ?? draft.findNode(change.fromPath);
|
|
2372
|
+
draft.removeSubtree(change.toPath);
|
|
2373
|
+
if (!source || source.isDirectory) {
|
|
2374
|
+
draft.insertEntry(change.toPath, null, null);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
const fields = {
|
|
2378
|
+
name: baseName(change.toPath),
|
|
2379
|
+
path: change.toPath,
|
|
2380
|
+
ino: change.ino,
|
|
2381
|
+
isDirectory: false,
|
|
2382
|
+
size: source.size,
|
|
2383
|
+
mtimeMs: now
|
|
2384
|
+
};
|
|
2385
|
+
draft.insertEntry(change.toPath, { ...fields, atimeMs: now }, { ...fields, children: [] });
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
928
2388
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
if (
|
|
935
|
-
|
|
2389
|
+
}
|
|
2390
|
+
function childrenFromEntries(directory, entries) {
|
|
2391
|
+
const children = [];
|
|
2392
|
+
for (const entry of entries) {
|
|
2393
|
+
const previous = directory.children.find((child) => child.path === entry.path);
|
|
2394
|
+
if (!entry.isDirectory) {
|
|
2395
|
+
children.push(nodeFromEntry(entry, []));
|
|
2396
|
+
} else if (previous?.isDirectory && previous.ino === entry.ino) {
|
|
2397
|
+
children.push(nodeFromEntry(entry, previous.children));
|
|
2398
|
+
} else {
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
return children;
|
|
2403
|
+
}
|
|
2404
|
+
function entriesFromNodes(entries, nodes) {
|
|
2405
|
+
const previous = new Map(entries.map((entry) => [entry.path, entry]));
|
|
2406
|
+
const fresh = new Map(nodes.map((node) => [node.path, node]));
|
|
2407
|
+
for (const node of nodes) {
|
|
2408
|
+
const entry = previous.get(node.path);
|
|
2409
|
+
if (!entry || entry.ino !== node.ino || entry.isDirectory !== node.isDirectory) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
return entries.filter((entry) => fresh.has(entry.path)).map((entry) => entryFromNode(fresh.get(entry.path), entry));
|
|
2414
|
+
}
|
|
2415
|
+
function patchFetchedList(draft, record) {
|
|
2416
|
+
if ((draft.list(record.path)?.fetchedAt ?? 0) > record.fetchedAt) {
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
draft.put(record);
|
|
2420
|
+
for (const tree of draft.treesContaining(record.path)) {
|
|
2421
|
+
if (tree.fetchedAt > record.fetchedAt) {
|
|
936
2422
|
continue;
|
|
937
2423
|
}
|
|
938
|
-
|
|
2424
|
+
const directory = findNode(tree.root, record.path);
|
|
2425
|
+
const children = directory?.isDirectory ? childrenFromEntries(directory, record.entries) : null;
|
|
2426
|
+
if (directory && children) {
|
|
2427
|
+
directory.children = children;
|
|
2428
|
+
draft.touch(tree);
|
|
2429
|
+
} else {
|
|
2430
|
+
draft.remove(tree);
|
|
2431
|
+
}
|
|
939
2432
|
}
|
|
940
|
-
return stack.length === 0 ? "/" : `/${stack.join("/")}`;
|
|
941
2433
|
}
|
|
942
|
-
function
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
2434
|
+
function patchFetchedTree(draft, record) {
|
|
2435
|
+
if ((draft.tree(record.path)?.fetchedAt ?? 0) > record.fetchedAt) {
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
draft.put(record);
|
|
2439
|
+
for (const other of draft.within(record.path)) {
|
|
2440
|
+
if (other === record || other.fetchedAt > record.fetchedAt) {
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
const node = findNode(record.root, other.path);
|
|
2444
|
+
if (other.kind === "tree") {
|
|
2445
|
+
if (node?.isDirectory) {
|
|
2446
|
+
draft.put({ ...other, root: structuredClone(node), fetchedAt: record.fetchedAt });
|
|
2447
|
+
} else {
|
|
2448
|
+
draft.remove(other);
|
|
2449
|
+
}
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
const entries = node?.isDirectory ? entriesFromNodes(other.entries, node.children) : null;
|
|
2453
|
+
if (entries) {
|
|
2454
|
+
other.entries = entries;
|
|
2455
|
+
draft.touch(other);
|
|
2456
|
+
} else {
|
|
2457
|
+
draft.remove(other);
|
|
946
2458
|
}
|
|
947
|
-
return value;
|
|
948
2459
|
}
|
|
949
|
-
if (
|
|
950
|
-
|
|
2460
|
+
if (record.path === "/") {
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
const parentPath = parentOf(record.path);
|
|
2464
|
+
for (const tree of draft.treesContaining(parentPath)) {
|
|
2465
|
+
if (tree.fetchedAt > record.fetchedAt) {
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
const parent = findNode(tree.root, parentPath);
|
|
2469
|
+
if (parent?.isDirectory) {
|
|
2470
|
+
parent.children = withChild(parent.children, structuredClone(record.root));
|
|
2471
|
+
draft.touch(tree);
|
|
2472
|
+
} else {
|
|
2473
|
+
draft.remove(tree);
|
|
2474
|
+
}
|
|
951
2475
|
}
|
|
952
|
-
return value;
|
|
953
2476
|
}
|
|
2477
|
+
var MetadataCache = class {
|
|
2478
|
+
maxEntries;
|
|
2479
|
+
store;
|
|
2480
|
+
/** Per workspace, how many writes this client has applied. */
|
|
2481
|
+
writeSeqs = /* @__PURE__ */ new Map();
|
|
2482
|
+
/** Per workspace, the tail of the queue that serializes cache updates. */
|
|
2483
|
+
queues = /* @__PURE__ */ new Map();
|
|
2484
|
+
constructor(options) {
|
|
2485
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
2486
|
+
if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries <= 0) {
|
|
2487
|
+
throw new RangeError("metadataCache.maxEntries must be a positive safe integer");
|
|
2488
|
+
}
|
|
2489
|
+
this.store = options.store ?? new IndexedDBMetadataCacheStore();
|
|
2490
|
+
}
|
|
2491
|
+
async get(workspace, kind, path) {
|
|
2492
|
+
try {
|
|
2493
|
+
const record = await this.store.get(workspace, kind, path);
|
|
2494
|
+
return record?.kind === kind && record.path === path ? record : null;
|
|
2495
|
+
} catch {
|
|
2496
|
+
return null;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
beginFetch(workspace) {
|
|
2500
|
+
return { startedAt: Date.now(), writeSeq: this.writeSeqs.get(workspace) ?? 0 };
|
|
2501
|
+
}
|
|
2502
|
+
/** Cache a fetched result, and bring the records it overlaps up to date with it. */
|
|
2503
|
+
async storeFetched(workspace, record, ticket) {
|
|
2504
|
+
await this.update(workspace, ticket, async () => {
|
|
2505
|
+
if (record.kind === "list") {
|
|
2506
|
+
const draft2 = await this.load(workspace, { lists: [record.path], trees: selfAndAncestors(record.path) });
|
|
2507
|
+
patchFetchedList(draft2, record);
|
|
2508
|
+
return draft2;
|
|
2509
|
+
}
|
|
2510
|
+
const draft = await this.load(workspace, { trees: selfAndAncestors(record.path), subtrees: [record.path] });
|
|
2511
|
+
patchFetchedTree(draft, record);
|
|
2512
|
+
return draft;
|
|
2513
|
+
}).catch(() => void 0);
|
|
2514
|
+
}
|
|
2515
|
+
/** Mountra reported that `path` does not exist. */
|
|
2516
|
+
async forgetFetched(workspace, path, ticket) {
|
|
2517
|
+
const change = { type: "remove", path };
|
|
2518
|
+
await this.update(workspace, ticket, async () => {
|
|
2519
|
+
const draft = await this.load(workspace, changeScope(change));
|
|
2520
|
+
patchChange(draft, change, Date.now());
|
|
2521
|
+
return draft;
|
|
2522
|
+
}).catch(() => void 0);
|
|
2523
|
+
}
|
|
2524
|
+
/** Reflect a write that Mountra completed. */
|
|
2525
|
+
async applyChange(workspace, change) {
|
|
2526
|
+
this.writeSeqs.set(workspace, (this.writeSeqs.get(workspace) ?? 0) + 1);
|
|
2527
|
+
await this.update(workspace, null, async () => {
|
|
2528
|
+
const draft = await this.load(workspace, changeScope(change));
|
|
2529
|
+
patchChange(draft, change, Date.now());
|
|
2530
|
+
return draft;
|
|
2531
|
+
}).catch(
|
|
2532
|
+
() => (
|
|
2533
|
+
// A write that cannot be reflected must not leave records that contradict it.
|
|
2534
|
+
this.store.clear(workspace).catch(() => void 0)
|
|
2535
|
+
)
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
clear(workspace) {
|
|
2539
|
+
return this.store.clear(workspace);
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Run updates of one workspace one at a time. An update for a fetch is
|
|
2543
|
+
* dropped when this client completed a write since the fetch started: the
|
|
2544
|
+
* result may predate that write.
|
|
2545
|
+
*/
|
|
2546
|
+
update(workspace, ticket, build) {
|
|
2547
|
+
const run = async () => {
|
|
2548
|
+
if (ticket && ticket.writeSeq !== (this.writeSeqs.get(workspace) ?? 0)) {
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const batch = (await build()).batch();
|
|
2552
|
+
if (batch.put.length > 0 || batch.remove.length > 0) {
|
|
2553
|
+
await this.store.write(workspace, batch, this.maxEntries);
|
|
2554
|
+
}
|
|
2555
|
+
};
|
|
2556
|
+
const result = (this.queues.get(workspace) ?? Promise.resolve()).then(run);
|
|
2557
|
+
const tail = result.catch(() => void 0);
|
|
2558
|
+
this.queues.set(workspace, tail);
|
|
2559
|
+
void tail.then(() => {
|
|
2560
|
+
if (this.queues.get(workspace) === tail) {
|
|
2561
|
+
this.queues.delete(workspace);
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
return result;
|
|
2565
|
+
}
|
|
2566
|
+
async load(workspace, scope) {
|
|
2567
|
+
const [records, subtrees] = await Promise.all([
|
|
2568
|
+
Promise.all([
|
|
2569
|
+
...[...new Set(scope.lists)].map((path) => this.store.get(workspace, "list", path)),
|
|
2570
|
+
...[...new Set(scope.trees)].map((path) => this.store.get(workspace, "tree", path))
|
|
2571
|
+
]),
|
|
2572
|
+
Promise.all([...new Set(scope.subtrees)].map((path) => this.store.getSubtree(workspace, path)))
|
|
2573
|
+
]);
|
|
2574
|
+
return new MetadataDraft([
|
|
2575
|
+
...records.filter((record) => record !== null),
|
|
2576
|
+
...subtrees.flat()
|
|
2577
|
+
]);
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
954
2580
|
|
|
955
2581
|
// src/upload.ts
|
|
956
2582
|
var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
|
|
957
2583
|
var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
|
|
958
2584
|
var DEFAULT_S3_MULTIPART_PART_SIZE_BYTES = 16 * 1024 * 1024;
|
|
959
2585
|
var MAX_S3_MULTIPART_PARTS = 1e4;
|
|
960
|
-
var MountraApiError = class extends Error {
|
|
961
|
-
status;
|
|
962
|
-
code;
|
|
963
|
-
body;
|
|
964
|
-
constructor(message, input) {
|
|
965
|
-
super(message);
|
|
966
|
-
this.name = "MountraApiError";
|
|
967
|
-
this.status = input.status;
|
|
968
|
-
this.code = input.code;
|
|
969
|
-
this.body = input.body;
|
|
970
|
-
}
|
|
971
|
-
};
|
|
972
2586
|
var memoryCheckpointRecords = /* @__PURE__ */ new Map();
|
|
973
2587
|
function parseJumboCheckpoint(value) {
|
|
974
|
-
if (!
|
|
2588
|
+
if (!isRecord3(value) || value.version !== 1 || typeof value.storageKey !== "string") {
|
|
975
2589
|
return null;
|
|
976
2590
|
}
|
|
977
2591
|
if (typeof value.chunkIndex !== "number" || typeof value.chunkSize !== "number" || typeof value.chunkSha256 !== "string" || typeof value.partSize !== "number" || typeof value.uploadId !== "string" || !Array.isArray(value.parts)) {
|
|
@@ -979,7 +2593,7 @@ function parseJumboCheckpoint(value) {
|
|
|
979
2593
|
}
|
|
980
2594
|
const parts = [];
|
|
981
2595
|
for (const part of value.parts) {
|
|
982
|
-
if (!
|
|
2596
|
+
if (!isRecord3(part)) {
|
|
983
2597
|
return null;
|
|
984
2598
|
}
|
|
985
2599
|
if (typeof part.partNumber !== "number" || typeof part.etag !== "string" || typeof part.size !== "number" || typeof part.sha256 !== "string") {
|
|
@@ -1158,11 +2772,11 @@ function throwIfAborted2(signal) {
|
|
|
1158
2772
|
}
|
|
1159
2773
|
throw new DOMException("The upload was aborted", "AbortError");
|
|
1160
2774
|
}
|
|
1161
|
-
function
|
|
2775
|
+
function isRecord3(value) {
|
|
1162
2776
|
return typeof value === "object" && value !== null;
|
|
1163
2777
|
}
|
|
1164
2778
|
function parseApiErrorBody(body) {
|
|
1165
|
-
if (!
|
|
2779
|
+
if (!isRecord3(body)) {
|
|
1166
2780
|
return {};
|
|
1167
2781
|
}
|
|
1168
2782
|
return {
|
|
@@ -1171,7 +2785,7 @@ function parseApiErrorBody(body) {
|
|
|
1171
2785
|
};
|
|
1172
2786
|
}
|
|
1173
2787
|
function ensurePrepareResponse(value) {
|
|
1174
|
-
if (!
|
|
2788
|
+
if (!isRecord3(value) || typeof value.upload_id !== "string") {
|
|
1175
2789
|
throw new TypeError("Mountra prepare response is invalid");
|
|
1176
2790
|
}
|
|
1177
2791
|
if (!Array.isArray(value.upload_targets)) {
|
|
@@ -1180,13 +2794,13 @@ function ensurePrepareResponse(value) {
|
|
|
1180
2794
|
return value;
|
|
1181
2795
|
}
|
|
1182
2796
|
function ensureFinishResponse(value) {
|
|
1183
|
-
if (!
|
|
2797
|
+
if (!isRecord3(value) || typeof value.upload_id !== "string") {
|
|
1184
2798
|
throw new TypeError("Mountra finish response is invalid");
|
|
1185
2799
|
}
|
|
1186
2800
|
return value;
|
|
1187
2801
|
}
|
|
1188
2802
|
function ensureUploadStatusResponse(value) {
|
|
1189
|
-
if (!
|
|
2803
|
+
if (!isRecord3(value) || typeof value.upload_id !== "string") {
|
|
1190
2804
|
throw new TypeError("Mountra upload status response is invalid");
|
|
1191
2805
|
}
|
|
1192
2806
|
if (!Array.isArray(value.chunks)) {
|
|
@@ -1204,8 +2818,7 @@ function splitFileIntoChunks(file, chunkCount = 1) {
|
|
|
1204
2818
|
}
|
|
1205
2819
|
const chunkSize = Math.ceil(file.size / chunkCount);
|
|
1206
2820
|
const chunks = [];
|
|
1207
|
-
for (let index = 0;
|
|
1208
|
-
const offset = index * chunkSize;
|
|
2821
|
+
for (let index = 0, offset = 0; offset < file.size; index += 1, offset += chunkSize) {
|
|
1209
2822
|
const end = Math.min(offset + chunkSize, file.size);
|
|
1210
2823
|
chunks.push({
|
|
1211
2824
|
index,
|
|
@@ -1238,6 +2851,7 @@ var MountraClient = class {
|
|
|
1238
2851
|
jumboChunkCheckpointStore;
|
|
1239
2852
|
jumboPartSize;
|
|
1240
2853
|
downloader;
|
|
2854
|
+
metadata;
|
|
1241
2855
|
constructor(options) {
|
|
1242
2856
|
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
1243
2857
|
this.apiPrefix = normalizeApiPrefix(options.apiPrefix ?? "/api");
|
|
@@ -1261,8 +2875,16 @@ var MountraClient = class {
|
|
|
1261
2875
|
clientSelector: this.clientSelector,
|
|
1262
2876
|
fetchImpl: this.fetchImpl,
|
|
1263
2877
|
checkpointStore: options.downloadCheckpointStore ?? new IndexedDBDownloadCheckpointStore(),
|
|
2878
|
+
cache: options.downloadCache ? new DownloadCache(options.downloadCache) : null,
|
|
1264
2879
|
requestJson: (path, body, signal) => this.requestJson(path, body, signal)
|
|
1265
2880
|
});
|
|
2881
|
+
this.metadata = new MountraMetadata({
|
|
2882
|
+
baseUrl: this.baseUrl,
|
|
2883
|
+
clientSelector: this.clientSelector,
|
|
2884
|
+
cache: options.metadataCache ? new MetadataCache(options.metadataCache) : null,
|
|
2885
|
+
requestJson: (path, body, signal) => this.requestJson(path, body, signal),
|
|
2886
|
+
invalidateDownloads: (selector, paths) => this.downloader.invalidateCachedPaths(selector, paths)
|
|
2887
|
+
});
|
|
1266
2888
|
}
|
|
1267
2889
|
async exportFile(options) {
|
|
1268
2890
|
return this.downloader.exportFile(options);
|
|
@@ -1282,6 +2904,40 @@ var MountraClient = class {
|
|
|
1282
2904
|
clearDownloadCheckpoint(downloadKey) {
|
|
1283
2905
|
return this.downloader.clearDownloadCheckpoint(downloadKey);
|
|
1284
2906
|
}
|
|
2907
|
+
/** Clear the download cache of one workspace, or of every workspace when none is given. */
|
|
2908
|
+
clearDownloadCache(selector) {
|
|
2909
|
+
return this.downloader.clearCache(selector);
|
|
2910
|
+
}
|
|
2911
|
+
getDownloadCacheUsage(selector) {
|
|
2912
|
+
return this.downloader.cacheUsage(selector);
|
|
2913
|
+
}
|
|
2914
|
+
/** List the entries of a directory. See `MetadataReadMode` for how `mode` uses the metadata cache. */
|
|
2915
|
+
list(options) {
|
|
2916
|
+
return this.metadata.list(options);
|
|
2917
|
+
}
|
|
2918
|
+
/** Return a directory with its complete subtree. See `MetadataReadMode` for how `mode` uses the metadata cache. */
|
|
2919
|
+
tree(options) {
|
|
2920
|
+
return this.metadata.tree(options);
|
|
2921
|
+
}
|
|
2922
|
+
mkdir(options) {
|
|
2923
|
+
return this.metadata.mkdir(options);
|
|
2924
|
+
}
|
|
2925
|
+
rename(options) {
|
|
2926
|
+
return this.metadata.rename(options);
|
|
2927
|
+
}
|
|
2928
|
+
move(options) {
|
|
2929
|
+
return this.metadata.move(options);
|
|
2930
|
+
}
|
|
2931
|
+
copy(options) {
|
|
2932
|
+
return this.metadata.copy(options);
|
|
2933
|
+
}
|
|
2934
|
+
delete(options) {
|
|
2935
|
+
return this.metadata.delete(options);
|
|
2936
|
+
}
|
|
2937
|
+
/** Clear the metadata cache of one workspace, or of every workspace when none is given. */
|
|
2938
|
+
clearMetadataCache(selector) {
|
|
2939
|
+
return this.metadata.clearCache(selector);
|
|
2940
|
+
}
|
|
1285
2941
|
/** Create a stable resource object for one file in this or another workspace. */
|
|
1286
2942
|
file(locator) {
|
|
1287
2943
|
return new MountraFile(
|
|
@@ -1462,6 +3118,27 @@ var MountraClient = class {
|
|
|
1462
3118
|
if (finished.finish_status !== "completed") {
|
|
1463
3119
|
throw new Error(`Mountra upload did not finish successfully: ${finished.finish_status}`);
|
|
1464
3120
|
}
|
|
3121
|
+
await this.metadata.recordUpload(this.cacheWorkspace(options), finished);
|
|
3122
|
+
await this.downloader.cacheUploadedFile(
|
|
3123
|
+
this.cacheWorkspace(options),
|
|
3124
|
+
{
|
|
3125
|
+
path: finished.path,
|
|
3126
|
+
...finished.ino !== void 0 ? { ino: finished.ino } : {},
|
|
3127
|
+
size: context.fileSize,
|
|
3128
|
+
...context.contentType ? { contentType: context.contentType } : {},
|
|
3129
|
+
chunks: orderedTargets.map((target) => {
|
|
3130
|
+
const chunk = chunks[target.chunk_index];
|
|
3131
|
+
return {
|
|
3132
|
+
index: chunk.index,
|
|
3133
|
+
offset: chunk.offset,
|
|
3134
|
+
size: chunk.size,
|
|
3135
|
+
storageKey: target.storage_key,
|
|
3136
|
+
data: chunk.blob
|
|
3137
|
+
};
|
|
3138
|
+
})
|
|
3139
|
+
},
|
|
3140
|
+
options.cache
|
|
3141
|
+
);
|
|
1465
3142
|
await Promise.all(
|
|
1466
3143
|
[...jumboCheckpointKeys].map(
|
|
1467
3144
|
(checkpointKey) => this.jumboChunkCheckpointStore.remove(checkpointKey)
|
|
@@ -1637,12 +3314,27 @@ var MountraClient = class {
|
|
|
1637
3314
|
if (!uploadId.trim()) {
|
|
1638
3315
|
throw new TypeError("uploadId is required");
|
|
1639
3316
|
}
|
|
1640
|
-
|
|
3317
|
+
const finished = ensureFinishResponse(
|
|
1641
3318
|
await this.requestJson("/upload/finish", {
|
|
1642
3319
|
...getWorkspaceBody(this.clientSelector, selector),
|
|
1643
3320
|
upload_id: uploadId
|
|
1644
3321
|
})
|
|
1645
3322
|
);
|
|
3323
|
+
if (finished.finish_status === "completed") {
|
|
3324
|
+
await this.metadata.recordUpload(this.cacheWorkspace(selector), finished);
|
|
3325
|
+
await this.downloader.invalidateCachedFile(this.cacheWorkspace(selector), {
|
|
3326
|
+
path: finished.path,
|
|
3327
|
+
...finished.ino !== void 0 ? { ino: finished.ino } : {}
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
return finished;
|
|
3331
|
+
}
|
|
3332
|
+
/** Same workspace resolution as `getWorkspaceBody`, so the cache key matches what Mountra used. */
|
|
3333
|
+
cacheWorkspace(selector) {
|
|
3334
|
+
return {
|
|
3335
|
+
workspaceCuid: selector.workspaceCuid ?? this.clientSelector.workspaceCuid,
|
|
3336
|
+
workspaceId: selector.workspaceId ?? this.clientSelector.workspaceId
|
|
3337
|
+
};
|
|
1646
3338
|
}
|
|
1647
3339
|
endpoint(path) {
|
|
1648
3340
|
return `${this.baseUrl}${this.apiPrefix}${path}`;
|
|
@@ -1683,9 +3375,13 @@ function createMountraClient(options) {
|
|
|
1683
3375
|
0 && (module.exports = {
|
|
1684
3376
|
DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
|
|
1685
3377
|
DownloadHttpError,
|
|
3378
|
+
InMemoryDownloadCacheStore,
|
|
1686
3379
|
InMemoryDownloadCheckpointStore,
|
|
1687
3380
|
InMemoryJumboChunkCheckpointStore,
|
|
3381
|
+
InMemoryMetadataCacheStore,
|
|
3382
|
+
IndexedDBDownloadCacheStore,
|
|
1688
3383
|
IndexedDBDownloadCheckpointStore,
|
|
3384
|
+
IndexedDBMetadataCacheStore,
|
|
1689
3385
|
JUMBO_CHUNK_THRESHOLD_BYTES,
|
|
1690
3386
|
LocalStorageJumboChunkCheckpointStore,
|
|
1691
3387
|
MAX_S3_MULTIPART_PARTS,
|