@mountra/mountra-sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,606 @@
1
+ // src/file.ts
2
+ function normalizeMountraFileLocator(locator, fallbackWorkspace = {}) {
3
+ if (!locator || typeof locator !== "object") {
4
+ throw new TypeError("Mountra file locator is required");
5
+ }
6
+ const hasPath = locator.path !== void 0;
7
+ const hasIno = locator.ino !== void 0;
8
+ if (hasPath === hasIno) {
9
+ throw new TypeError("Mountra file locator requires exactly one of path or ino");
10
+ }
11
+ const workspaceId = locator.workspaceId ?? locator.wsid ?? fallbackWorkspace.workspaceId;
12
+ if (locator.workspaceId !== void 0 && locator.wsid !== void 0 && String(locator.workspaceId) !== String(locator.wsid)) {
13
+ throw new TypeError("workspaceId and wsid must identify the same workspace");
14
+ }
15
+ const workspaceCuid = locator.workspaceCuid ?? fallbackWorkspace.workspaceCuid;
16
+ if (workspaceCuid === void 0 && workspaceId === void 0) {
17
+ throw new TypeError("workspaceCuid, workspaceId, or wsid is required");
18
+ }
19
+ if (hasPath) {
20
+ const path = normalizePath(locator.path);
21
+ return {
22
+ ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
23
+ ...workspaceId !== void 0 ? { workspaceId } : {},
24
+ path
25
+ };
26
+ }
27
+ return {
28
+ ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
29
+ ...workspaceId !== void 0 ? { workspaceId } : {},
30
+ ino: normalizeIno(locator.ino)
31
+ };
32
+ }
33
+ var MountraFile = class {
34
+ constructor(client, locator) {
35
+ this.client = client;
36
+ this.locator = Object.freeze({ ...locator });
37
+ }
38
+ client;
39
+ locator;
40
+ get workspaceCuid() {
41
+ return this.locator.workspaceCuid;
42
+ }
43
+ get workspaceId() {
44
+ return this.locator.workspaceId;
45
+ }
46
+ /** `wsid` is the short alias used by the file-object API. */
47
+ get wsid() {
48
+ return this.locator.workspaceId;
49
+ }
50
+ get path() {
51
+ return "path" in this.locator ? this.locator.path : void 0;
52
+ }
53
+ get ino() {
54
+ return "ino" in this.locator ? this.locator.ino : void 0;
55
+ }
56
+ export(options = {}) {
57
+ return this.client.exportFile({ ...options, ...this.locator });
58
+ }
59
+ download(options = {}) {
60
+ return this.client.downloadFile({ ...options, ...this.locator });
61
+ }
62
+ async resume(options = {}) {
63
+ const { downloadKey, ...resumeOptions } = options;
64
+ const checkpoints = await this.client.listResumableDownloads();
65
+ const candidates = checkpoints.filter(
66
+ (checkpoint2) => checkpoint2.fileHandle && matchesLocator(checkpoint2.request, this.locator)
67
+ );
68
+ const checkpoint = downloadKey ? candidates.find((candidate) => candidate.key === downloadKey) : [...candidates].sort((left, right) => right.updatedAt - left.updatedAt)[0];
69
+ if (!checkpoint) {
70
+ if (downloadKey) {
71
+ throw new Error("Download checkpoint does not belong to this Mountra file");
72
+ }
73
+ throw new Error("No resumable download was found for this Mountra file");
74
+ }
75
+ return this.client.resumeDownload(checkpoint.key, resumeOptions);
76
+ }
77
+ };
78
+ function matchesLocator(request, locator) {
79
+ if (locator.workspaceCuid !== void 0 && request.workspaceCuid !== locator.workspaceCuid) {
80
+ return false;
81
+ }
82
+ if (locator.workspaceId !== void 0 && String(request.workspaceId) !== String(locator.workspaceId)) {
83
+ return false;
84
+ }
85
+ if ("path" in locator) {
86
+ if (request.path === void 0 || request.ino !== void 0) {
87
+ return false;
88
+ }
89
+ try {
90
+ return normalizePath(request.path) === locator.path;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ return String(request.ino) === String(locator.ino) && request.path === void 0;
96
+ }
97
+ function normalizePath(value) {
98
+ if (!value || !value.trim()) {
99
+ throw new TypeError("Mountra file path cannot be empty");
100
+ }
101
+ const raw = value.trim();
102
+ const absolute = raw.startsWith("/") ? raw : `/${raw}`;
103
+ const segments = absolute.split("/").filter((segment) => segment.length > 0 && segment !== ".");
104
+ const stack = [];
105
+ for (const segment of segments) {
106
+ if (segment === "..") {
107
+ stack.pop();
108
+ continue;
109
+ }
110
+ stack.push(segment);
111
+ }
112
+ return stack.length === 0 ? "/" : `/${stack.join("/")}`;
113
+ }
114
+ function normalizeIno(value) {
115
+ if (typeof value === "number") {
116
+ if (!Number.isSafeInteger(value) || value < 0) {
117
+ throw new TypeError("Mountra file ino must be a non-negative safe integer");
118
+ }
119
+ return value;
120
+ }
121
+ if (!/^\d+$/.test(value)) {
122
+ throw new TypeError("Mountra file ino must be a non-negative integer string");
123
+ }
124
+ return value;
125
+ }
126
+
127
+ // src/cache.ts
128
+ var DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
129
+ var DEFAULT_MAX_CHUNK_BYTES = 128 * 1024 * 1024;
130
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
131
+ function copyFile(file) {
132
+ return { ...file, chunks: file.chunks.map((chunk) => ({ ...chunk })) };
133
+ }
134
+ function memoryKey(workspace, key) {
135
+ return JSON.stringify([workspace, key]);
136
+ }
137
+ var InMemoryDownloadCacheStore = class {
138
+ files = /* @__PURE__ */ new Map();
139
+ /** Map iteration order is the LRU order: the first entry is the least recently used. */
140
+ chunks = /* @__PURE__ */ new Map();
141
+ bytes = 0;
142
+ async getFile(workspace, fileKey) {
143
+ const entry = this.files.get(memoryKey(workspace, fileKey));
144
+ return entry ? copyFile(entry.file) : null;
145
+ }
146
+ async putFile(workspace, fileKey, file) {
147
+ this.files.set(memoryKey(workspace, fileKey), { workspace, file: copyFile(file) });
148
+ }
149
+ async removeFile(workspace, fileKey) {
150
+ this.files.delete(memoryKey(workspace, fileKey));
151
+ }
152
+ async getChunk(workspace, storageKey) {
153
+ const key = memoryKey(workspace, storageKey);
154
+ const entry = this.chunks.get(key);
155
+ if (!entry) {
156
+ return null;
157
+ }
158
+ this.chunks.delete(key);
159
+ this.chunks.set(key, entry);
160
+ return entry.data;
161
+ }
162
+ async putChunk(workspace, storageKey, data, maxBytes) {
163
+ const key = memoryKey(workspace, storageKey);
164
+ this.removeChunk(key);
165
+ if (data.size > maxBytes) {
166
+ return;
167
+ }
168
+ this.chunks.set(key, { workspace, data });
169
+ this.bytes += data.size;
170
+ for (const candidate of this.chunks.keys()) {
171
+ if (this.bytes <= maxBytes) {
172
+ break;
173
+ }
174
+ this.removeChunk(candidate);
175
+ }
176
+ }
177
+ async clear(workspace) {
178
+ for (const [key, entry] of this.chunks) {
179
+ if (workspace === void 0 || entry.workspace === workspace) {
180
+ this.removeChunk(key);
181
+ }
182
+ }
183
+ for (const [key, entry] of this.files) {
184
+ if (workspace === void 0 || entry.workspace === workspace) {
185
+ this.files.delete(key);
186
+ }
187
+ }
188
+ }
189
+ async usage(workspace) {
190
+ const usage = { bytes: 0, chunks: 0 };
191
+ for (const entry of this.chunks.values()) {
192
+ if (workspace === void 0 || entry.workspace === workspace) {
193
+ usage.bytes += entry.data.size;
194
+ usage.chunks += 1;
195
+ }
196
+ }
197
+ return usage;
198
+ }
199
+ removeChunk(key) {
200
+ const entry = this.chunks.get(key);
201
+ if (entry) {
202
+ this.chunks.delete(key);
203
+ this.bytes -= entry.data.size;
204
+ }
205
+ }
206
+ };
207
+ var CACHE_DATABASE_NAME = "mountra-sdk-download-cache";
208
+ var CACHE_DATABASE_VERSION = 1;
209
+ var FILES_STORE = "files";
210
+ var CHUNKS_STORE = "chunks";
211
+ var CHUNK_DATA_STORE = "chunk-data";
212
+ var META_STORE = "meta";
213
+ var LRU_INDEX = "lastUsed";
214
+ var USAGE_KEY = "bytes";
215
+ var CLOCK_KEY = "clock";
216
+ function getIndexedDb() {
217
+ return globalThis.indexedDB ?? null;
218
+ }
219
+ function workspaceRange(workspace) {
220
+ return IDBKeyRange.bound([workspace], [workspace, []]);
221
+ }
222
+ function sameKey(left, right) {
223
+ return Array.isArray(left) && left[0] === right[0] && left[1] === right[1];
224
+ }
225
+ async function nextClock(meta) {
226
+ const clock = (await requestResult(meta.get(CLOCK_KEY)) ?? 0) + 1;
227
+ await requestResult(meta.put(clock, CLOCK_KEY));
228
+ return clock;
229
+ }
230
+ function requestResult(request) {
231
+ return new Promise((resolve, reject) => {
232
+ request.onsuccess = () => resolve(request.result);
233
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
234
+ });
235
+ }
236
+ async function runTransaction(database, storeNames, mode, body) {
237
+ const transaction = database.transaction(storeNames, mode);
238
+ const done = new Promise((resolve, reject) => {
239
+ transaction.oncomplete = () => resolve();
240
+ transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
241
+ transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
242
+ });
243
+ done.catch(() => void 0);
244
+ let result;
245
+ try {
246
+ result = await body(transaction);
247
+ } catch (error) {
248
+ try {
249
+ transaction.abort();
250
+ } catch {
251
+ }
252
+ throw error;
253
+ }
254
+ await done;
255
+ return result;
256
+ }
257
+ var IndexedDBDownloadCacheStore = class {
258
+ databaseName;
259
+ fallback = new InMemoryDownloadCacheStore();
260
+ databasePromise;
261
+ constructor(options = {}) {
262
+ this.databaseName = options.databaseName ?? CACHE_DATABASE_NAME;
263
+ }
264
+ async getFile(workspace, fileKey) {
265
+ const database = await this.openDatabase();
266
+ if (!database) {
267
+ return this.fallback.getFile(workspace, fileKey);
268
+ }
269
+ return runTransaction(database, [FILES_STORE], "readonly", async (transaction) => {
270
+ const file = await requestResult(
271
+ transaction.objectStore(FILES_STORE).get([workspace, fileKey])
272
+ );
273
+ return file ? copyFile(file) : null;
274
+ });
275
+ }
276
+ async putFile(workspace, fileKey, file) {
277
+ const database = await this.openDatabase();
278
+ if (!database) {
279
+ return this.fallback.putFile(workspace, fileKey, file);
280
+ }
281
+ await runTransaction(database, [FILES_STORE], "readwrite", async (transaction) => {
282
+ await requestResult(transaction.objectStore(FILES_STORE).put(copyFile(file), [workspace, fileKey]));
283
+ });
284
+ }
285
+ async removeFile(workspace, fileKey) {
286
+ const database = await this.openDatabase();
287
+ if (!database) {
288
+ return this.fallback.removeFile(workspace, fileKey);
289
+ }
290
+ await runTransaction(database, [FILES_STORE], "readwrite", async (transaction) => {
291
+ await requestResult(transaction.objectStore(FILES_STORE).delete([workspace, fileKey]));
292
+ });
293
+ }
294
+ async getChunk(workspace, storageKey) {
295
+ const database = await this.openDatabase();
296
+ if (!database) {
297
+ return this.fallback.getChunk(workspace, storageKey);
298
+ }
299
+ const key = [workspace, storageKey];
300
+ return runTransaction(
301
+ database,
302
+ [CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
303
+ "readwrite",
304
+ async (transaction) => {
305
+ const chunks = transaction.objectStore(CHUNKS_STORE);
306
+ const record = await requestResult(chunks.get(key));
307
+ if (!record) {
308
+ return null;
309
+ }
310
+ const data = await requestResult(
311
+ transaction.objectStore(CHUNK_DATA_STORE).get(key)
312
+ );
313
+ const meta = transaction.objectStore(META_STORE);
314
+ if (!data) {
315
+ const bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
316
+ await requestResult(chunks.delete(key));
317
+ await requestResult(meta.put(Math.max(bytes - record.size, 0), USAGE_KEY));
318
+ return null;
319
+ }
320
+ await requestResult(chunks.put({ size: record.size, lastUsed: await nextClock(meta) }, key));
321
+ return data;
322
+ }
323
+ );
324
+ }
325
+ async putChunk(workspace, storageKey, data, maxBytes) {
326
+ const database = await this.openDatabase();
327
+ if (!database) {
328
+ return this.fallback.putChunk(workspace, storageKey, data, maxBytes);
329
+ }
330
+ const key = [workspace, storageKey];
331
+ await runTransaction(
332
+ database,
333
+ [CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
334
+ "readwrite",
335
+ async (transaction) => {
336
+ const chunks = transaction.objectStore(CHUNKS_STORE);
337
+ const chunkData = transaction.objectStore(CHUNK_DATA_STORE);
338
+ const meta = transaction.objectStore(META_STORE);
339
+ let bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
340
+ const existing = await requestResult(chunks.get(key));
341
+ if (existing) {
342
+ bytes -= existing.size;
343
+ await requestResult(chunks.delete(key));
344
+ await requestResult(chunkData.delete(key));
345
+ }
346
+ if (data.size <= maxBytes) {
347
+ await requestResult(chunks.put({ size: data.size, lastUsed: await nextClock(meta) }, key));
348
+ await requestResult(chunkData.put(data, key));
349
+ bytes += data.size;
350
+ }
351
+ if (bytes > maxBytes) {
352
+ const cursorRequest = chunks.index(LRU_INDEX).openCursor();
353
+ let cursor = await requestResult(cursorRequest);
354
+ while (cursor && bytes > maxBytes) {
355
+ if (!sameKey(cursor.primaryKey, key)) {
356
+ bytes -= cursor.value.size;
357
+ await requestResult(chunkData.delete(cursor.primaryKey));
358
+ await requestResult(cursor.delete());
359
+ }
360
+ cursor.continue();
361
+ cursor = await requestResult(cursorRequest);
362
+ }
363
+ }
364
+ await requestResult(meta.put(Math.max(bytes, 0), USAGE_KEY));
365
+ }
366
+ );
367
+ }
368
+ async clear(workspace) {
369
+ const database = await this.openDatabase();
370
+ if (!database) {
371
+ return this.fallback.clear(workspace);
372
+ }
373
+ await runTransaction(
374
+ database,
375
+ [FILES_STORE, CHUNKS_STORE, CHUNK_DATA_STORE, META_STORE],
376
+ "readwrite",
377
+ async (transaction) => {
378
+ const files = transaction.objectStore(FILES_STORE);
379
+ const chunks = transaction.objectStore(CHUNKS_STORE);
380
+ const chunkData = transaction.objectStore(CHUNK_DATA_STORE);
381
+ const meta = transaction.objectStore(META_STORE);
382
+ if (workspace === void 0) {
383
+ await requestResult(files.clear());
384
+ await requestResult(chunks.clear());
385
+ await requestResult(chunkData.clear());
386
+ await requestResult(meta.put(0, USAGE_KEY));
387
+ return;
388
+ }
389
+ const range = workspaceRange(workspace);
390
+ const removed = await requestResult(chunks.getAll(range));
391
+ const bytes = await requestResult(meta.get(USAGE_KEY)) ?? 0;
392
+ await requestResult(files.delete(range));
393
+ await requestResult(chunks.delete(range));
394
+ await requestResult(chunkData.delete(range));
395
+ await requestResult(
396
+ meta.put(Math.max(bytes - removed.reduce((total, record) => total + record.size, 0), 0), USAGE_KEY)
397
+ );
398
+ }
399
+ );
400
+ }
401
+ async usage(workspace) {
402
+ const database = await this.openDatabase();
403
+ if (!database) {
404
+ return this.fallback.usage(workspace);
405
+ }
406
+ return runTransaction(database, [CHUNKS_STORE], "readonly", async (transaction) => {
407
+ const records = await requestResult(
408
+ transaction.objectStore(CHUNKS_STORE).getAll(workspace === void 0 ? void 0 : workspaceRange(workspace))
409
+ );
410
+ return {
411
+ bytes: records.reduce((total, record) => total + record.size, 0),
412
+ chunks: records.length
413
+ };
414
+ });
415
+ }
416
+ async openDatabase() {
417
+ if (!this.databasePromise) {
418
+ const indexedDb = getIndexedDb();
419
+ if (!indexedDb) {
420
+ this.databasePromise = Promise.resolve(null);
421
+ } else {
422
+ this.databasePromise = new Promise((resolve) => {
423
+ let request;
424
+ try {
425
+ request = indexedDb.open(this.databaseName, CACHE_DATABASE_VERSION);
426
+ } catch {
427
+ resolve(null);
428
+ return;
429
+ }
430
+ request.onupgradeneeded = () => {
431
+ const database = request.result;
432
+ if (!database.objectStoreNames.contains(FILES_STORE)) {
433
+ database.createObjectStore(FILES_STORE);
434
+ }
435
+ if (!database.objectStoreNames.contains(CHUNKS_STORE)) {
436
+ database.createObjectStore(CHUNKS_STORE).createIndex(LRU_INDEX, "lastUsed");
437
+ }
438
+ if (!database.objectStoreNames.contains(CHUNK_DATA_STORE)) {
439
+ database.createObjectStore(CHUNK_DATA_STORE);
440
+ }
441
+ if (!database.objectStoreNames.contains(META_STORE)) {
442
+ database.createObjectStore(META_STORE);
443
+ }
444
+ };
445
+ request.onsuccess = () => {
446
+ const database = request.result;
447
+ database.onversionchange = () => {
448
+ database.close();
449
+ this.databasePromise = void 0;
450
+ };
451
+ resolve(database);
452
+ };
453
+ request.onerror = () => resolve(null);
454
+ request.onblocked = () => resolve(null);
455
+ });
456
+ }
457
+ }
458
+ return this.databasePromise;
459
+ }
460
+ };
461
+ function isImmutableStorageKey(storageKey) {
462
+ return /(?:^|\/)blobs\/sha256\/[0-9a-f]{64}$/.test(storageKey);
463
+ }
464
+ function workspaceCacheKey(baseUrl, selector) {
465
+ const workspaceCuid = selector.workspaceCuid?.trim();
466
+ const workspaceId = selector.workspaceId !== void 0 ? String(selector.workspaceId).trim() : "";
467
+ const workspace = workspaceCuid || workspaceId;
468
+ return workspace ? JSON.stringify([baseUrl, workspace]) : null;
469
+ }
470
+ function fileCacheKey(target) {
471
+ try {
472
+ if (target.ino !== void 0) {
473
+ return `ino:${BigInt(target.ino).toString()}`;
474
+ }
475
+ if (target.path !== void 0) {
476
+ return `path:${normalizePath(target.path)}`;
477
+ }
478
+ } catch {
479
+ }
480
+ return null;
481
+ }
482
+ function isValidLayout(file) {
483
+ let offset = 0;
484
+ for (const [index, chunk] of file.chunks.entries()) {
485
+ if (chunk.index !== index || chunk.offset !== offset || chunk.size < 0) {
486
+ return false;
487
+ }
488
+ offset += chunk.size;
489
+ }
490
+ return file.chunks.length > 0 && offset === file.size;
491
+ }
492
+ var DownloadCache = class {
493
+ maxBytes;
494
+ maxChunkBytes;
495
+ ttlMs;
496
+ store;
497
+ constructor(options) {
498
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
499
+ if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes <= 0) {
500
+ throw new RangeError("downloadCache.maxBytes must be a positive safe integer");
501
+ }
502
+ const maxChunkBytes = options.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES;
503
+ if (!Number.isSafeInteger(maxChunkBytes) || maxChunkBytes <= 0) {
504
+ throw new RangeError("downloadCache.maxChunkBytes must be a positive safe integer");
505
+ }
506
+ this.maxChunkBytes = Math.min(maxChunkBytes, this.maxBytes);
507
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
508
+ if (Number.isNaN(this.ttlMs) || this.ttlMs < 0) {
509
+ throw new RangeError("downloadCache.ttlMs must be a non-negative number");
510
+ }
511
+ this.store = options.store ?? new IndexedDBDownloadCacheStore();
512
+ }
513
+ /** Return a file that has not expired together with all of its chunk bytes. */
514
+ async loadFile(workspace, fileKey) {
515
+ try {
516
+ const file = await this.store.getFile(workspace, fileKey);
517
+ if (!file) {
518
+ return null;
519
+ }
520
+ if (file.expiresAt <= Date.now() || !isValidLayout(file)) {
521
+ await this.store.removeFile(workspace, fileKey);
522
+ return null;
523
+ }
524
+ const chunks = /* @__PURE__ */ new Map();
525
+ for (const chunk of file.chunks) {
526
+ const data = await this.readChunk(workspace, chunk);
527
+ if (!data) {
528
+ return null;
529
+ }
530
+ chunks.set(chunk.index, data);
531
+ }
532
+ return { file, chunks };
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+ async readChunk(workspace, chunk) {
538
+ if (chunk.size === 0) {
539
+ return new Blob([]);
540
+ }
541
+ if (!isImmutableStorageKey(chunk.storageKey)) {
542
+ return null;
543
+ }
544
+ try {
545
+ const data = await this.store.getChunk(workspace, chunk.storageKey);
546
+ return data && data.size === chunk.size ? data : null;
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+ canStoreChunk(chunk) {
552
+ return isImmutableStorageKey(chunk.storageKey) && chunk.size <= this.maxChunkBytes;
553
+ }
554
+ /** Returns whether the chunk is now cached. */
555
+ async storeChunk(workspace, chunk, data) {
556
+ if (chunk.size === 0) {
557
+ return true;
558
+ }
559
+ if (!this.canStoreChunk(chunk) || data.size !== chunk.size) {
560
+ return false;
561
+ }
562
+ try {
563
+ await this.store.putChunk(workspace, chunk.storageKey, data, this.maxBytes);
564
+ return true;
565
+ } catch {
566
+ return false;
567
+ }
568
+ }
569
+ async storeFile(workspace, fileKey, file) {
570
+ if (this.ttlMs <= 0) {
571
+ return;
572
+ }
573
+ const cachedAt = Date.now();
574
+ try {
575
+ await this.store.putFile(workspace, fileKey, {
576
+ path: file.path,
577
+ size: file.size,
578
+ ...file.contentType ? { contentType: file.contentType } : {},
579
+ chunks: file.chunks.map(({ index, offset, size, storageKey }) => ({
580
+ index,
581
+ offset,
582
+ size,
583
+ storageKey
584
+ })),
585
+ cachedAt,
586
+ expiresAt: cachedAt + this.ttlMs
587
+ });
588
+ } catch {
589
+ }
590
+ }
591
+ async invalidateFiles(workspace, fileKeys) {
592
+ await Promise.all(
593
+ fileKeys.map((fileKey) => this.store.removeFile(workspace, fileKey).catch(() => void 0))
594
+ );
595
+ }
596
+ clear(workspace) {
597
+ return this.store.clear(workspace);
598
+ }
599
+ usage(workspace) {
600
+ return this.store.usage(workspace);
601
+ }
602
+ };
603
+
1
604
  // src/download.ts
2
605
  function copyCheckpoint(checkpoint) {
3
606
  return {
@@ -25,7 +628,7 @@ var InMemoryDownloadCheckpointStore = class {
25
628
  var DOWNLOAD_DATABASE_NAME = "mountra-sdk";
26
629
  var DOWNLOAD_DATABASE_VERSION = 1;
27
630
  var DOWNLOAD_STORE_NAME = "download-checkpoints";
28
- function getIndexedDb() {
631
+ function getIndexedDb2() {
29
632
  return globalThis.indexedDB ?? null;
30
633
  }
31
634
  var IndexedDBDownloadCheckpointStore = class {
@@ -90,7 +693,7 @@ var IndexedDBDownloadCheckpointStore = class {
90
693
  }
91
694
  async openDatabase() {
92
695
  if (!this.databasePromise) {
93
- const indexedDb = getIndexedDb();
696
+ const indexedDb = getIndexedDb2();
94
697
  if (!indexedDb) {
95
698
  this.databasePromise = Promise.resolve(null);
96
699
  } else {
@@ -285,8 +888,9 @@ var SpeedWindow = class {
285
888
  };
286
889
  var BlobDownloadSink = class {
287
890
  parts = [];
288
- async write(bytes) {
289
- this.parts.push(bytes.slice());
891
+ /** Parts arrive in file order, so the position is implied. */
892
+ async write(bytes, _position) {
893
+ this.parts.push(bytes instanceof Blob ? bytes : bytes.slice());
290
894
  }
291
895
  async flush() {
292
896
  }
@@ -294,6 +898,9 @@ var BlobDownloadSink = class {
294
898
  }
295
899
  blob(contentType) {
296
900
  const buffers = this.parts.map((part) => {
901
+ if (part instanceof Blob) {
902
+ return part;
903
+ }
297
904
  const buffer = new ArrayBuffer(part.byteLength);
298
905
  new Uint8Array(buffer).set(part);
299
906
  return buffer;
@@ -323,7 +930,7 @@ var FileSystemDownloadSink = class {
323
930
  throw new Error("File System Access writer position does not match the download checkpoint");
324
931
  }
325
932
  await this.writable.write(bytes);
326
- this.position += bytes.byteLength;
933
+ this.position += bytes instanceof Blob ? bytes.size : bytes.byteLength;
327
934
  }
328
935
  async flush() {
329
936
  if (this.writable) {
@@ -409,39 +1016,39 @@ function buildDownloadChunks(exported, useClientAssembly) {
409
1016
  }
410
1017
  ];
411
1018
  }
412
- function buildDownloadKey(exported, chunks, baseUrl, request) {
1019
+ function buildDownloadKey(plan, baseUrl, request) {
413
1020
  return encodeURIComponent(
414
1021
  JSON.stringify({
415
1022
  request: requestKey(request, baseUrl),
416
- path: exported.path,
417
- size: exported.size,
418
- chunks: chunks.map((chunk) => [chunk.index, chunk.offset, chunk.size, chunk.storageKey])
1023
+ path: plan.path,
1024
+ size: plan.size,
1025
+ chunks: plan.chunks.map((chunk) => [chunk.index, chunk.offset, chunk.size, chunk.storageKey])
419
1026
  })
420
1027
  );
421
1028
  }
422
- function checkpointMatches(checkpoint, exported, chunks) {
423
- if (checkpoint.version !== 1 || checkpoint.path !== exported.path || checkpoint.size !== exported.size) {
1029
+ function checkpointMatches(checkpoint, plan) {
1030
+ if (checkpoint.version !== 1 || checkpoint.path !== plan.path || checkpoint.size !== plan.size) {
424
1031
  return false;
425
1032
  }
426
- if (checkpoint.chunks.length !== chunks.length) {
1033
+ if (checkpoint.chunks.length !== plan.chunks.length) {
427
1034
  return false;
428
1035
  }
429
1036
  return checkpoint.chunks.every((saved, index) => {
430
- const current = chunks[index];
1037
+ const current = plan.chunks[index];
431
1038
  return Boolean(
432
1039
  current && saved.index === current.index && saved.offset === current.offset && saved.size === current.size && saved.storageKey === current.storageKey && saved.downloadedBytes >= 0 && saved.downloadedBytes <= saved.size
433
1040
  );
434
1041
  });
435
1042
  }
436
- function createCheckpoint(key, baseUrl, request, exported, chunks, fileHandle) {
1043
+ function createCheckpoint(key, baseUrl, request, plan, fileHandle) {
437
1044
  return {
438
1045
  version: 1,
439
1046
  key,
440
1047
  requestKey: requestKey(request, baseUrl),
441
1048
  request: { ...request },
442
- path: exported.path,
443
- size: exported.size,
444
- chunks: chunks.map((chunk) => ({
1049
+ path: plan.path,
1050
+ size: plan.size,
1051
+ chunks: plan.chunks.map((chunk) => ({
445
1052
  index: chunk.index,
446
1053
  offset: chunk.offset,
447
1054
  size: chunk.size,
@@ -459,8 +1066,8 @@ function totalDownloadedBytes(checkpoint) {
459
1066
  function completedChunkCount(checkpoint) {
460
1067
  return checkpoint.chunks.filter((chunk) => chunk.completed).length;
461
1068
  }
462
- function defaultFilename(exported, requested) {
463
- const candidate = requested?.trim() || exported.path.split("/").filter(Boolean).pop() || "download";
1069
+ function defaultFilename(path, requested) {
1070
+ const candidate = requested?.trim() || path.split("/").filter(Boolean).pop() || "download";
464
1071
  return candidate.replace(/[\\/\r\n]/g, "_") || "download";
465
1072
  }
466
1073
  function triggerBrowserDownload(blob, filename) {
@@ -485,33 +1092,38 @@ var MountraDownloader = class {
485
1092
  this.transport = transport;
486
1093
  }
487
1094
  async exportFile(options) {
488
- const request = downloadRequestFromOptions(options);
489
- return ensureExportFileResponse(
490
- await this.transport.requestJson(
491
- "/file/export",
492
- requestBodyFromCheckpoint(request),
493
- options.signal
494
- )
495
- );
1095
+ return this.exportRequest(this.resolveRequest(options), options.signal);
496
1096
  }
497
1097
  async downloadFile(options) {
498
1098
  const preferFileSystemAccess = options.preferFileSystemAccess !== false;
499
1099
  const fileHandle = options.fileHandle ?? (preferFileSystemAccess && getShowSaveFilePicker() ? await this.pickSaveFile(options.downloadFilename ?? options.path?.split("/").filter(Boolean).pop()) : void 0);
500
- const exported = await this.exportFile(options);
501
- return this.downloadExport(exported, { ...options, ...fileHandle ? { fileHandle } : {} });
1100
+ const request = this.resolveRequest(options);
1101
+ const plan = await this.loadCachedPlan(request, options.cache) ?? this.planExport(
1102
+ await this.exportRequest(request, options.signal),
1103
+ request,
1104
+ options.cache,
1105
+ fileHandle
1106
+ );
1107
+ return this.downloadResolved(plan, request, options, fileHandle, false);
502
1108
  }
503
1109
  async downloadExport(exported, options = { path: exported.path }) {
504
1110
  let fileHandle = options.fileHandle;
505
1111
  if (!fileHandle && options.preferFileSystemAccess !== false && getShowSaveFilePicker()) {
506
1112
  fileHandle = await this.pickSaveFile(
507
- options.downloadFilename ?? defaultFilename(exported),
1113
+ options.downloadFilename ?? defaultFilename(exported.path),
508
1114
  exported.contentType
509
1115
  );
510
1116
  }
511
- const request = downloadRequestFromOptions(
1117
+ const request = this.resolveRequest(
512
1118
  options.path === void 0 && options.ino === void 0 ? { ...options, path: exported.path } : options
513
1119
  );
514
- return this.downloadResolved(exported, request, options, fileHandle, false);
1120
+ return this.downloadResolved(
1121
+ this.planExport(exported, request, options.cache, fileHandle),
1122
+ request,
1123
+ options,
1124
+ fileHandle,
1125
+ false
1126
+ );
515
1127
  }
516
1128
  async resumeDownload(downloadKey, options = {}) {
517
1129
  if (!downloadKey.trim()) {
@@ -527,7 +1139,7 @@ var MountraDownloader = class {
527
1139
  signal: options.signal
528
1140
  });
529
1141
  return this.downloadResolved(
530
- exported,
1142
+ this.planExport(exported, checkpoint.request, void 0, checkpoint.fileHandle),
531
1143
  checkpoint.request,
532
1144
  options,
533
1145
  checkpoint.fileHandle,
@@ -540,6 +1152,124 @@ var MountraDownloader = class {
540
1152
  clearDownloadCheckpoint(downloadKey) {
541
1153
  return this.transport.checkpointStore.remove(downloadKey);
542
1154
  }
1155
+ /** Drop the cached layout of a file that was just written, so the next download sees the new content. */
1156
+ async invalidateCachedFile(selector, target) {
1157
+ await this.cachedFileScope(selector, target)?.invalidate();
1158
+ }
1159
+ /**
1160
+ * Cache a file this client just uploaded, so downloading it needs no
1161
+ * network. Falls back to invalidation when any chunk cannot be cached.
1162
+ */
1163
+ async cacheUploadedFile(selector, file, mode = "default") {
1164
+ const scope = this.cachedFileScope(selector, file);
1165
+ if (!scope) {
1166
+ return;
1167
+ }
1168
+ await scope.invalidate();
1169
+ if (mode === "no-store") {
1170
+ return;
1171
+ }
1172
+ let complete = true;
1173
+ for (const chunk of file.chunks) {
1174
+ complete = await scope.cache.storeChunk(scope.workspace, chunk, chunk.data) && complete;
1175
+ }
1176
+ if (complete) {
1177
+ for (const fileKey of scope.fileKeys) {
1178
+ await scope.cache.storeFile(scope.workspace, fileKey, file);
1179
+ }
1180
+ }
1181
+ }
1182
+ cachedFileScope(selector, target) {
1183
+ const cache = this.transport.cache;
1184
+ const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
1185
+ if (!cache || workspace === null) {
1186
+ return null;
1187
+ }
1188
+ const fileKeys = [
1189
+ target.path !== void 0 ? fileCacheKey({ path: target.path }) : null,
1190
+ target.ino !== void 0 ? fileCacheKey({ ino: target.ino }) : null
1191
+ ].filter((fileKey) => fileKey !== null);
1192
+ return {
1193
+ cache,
1194
+ workspace,
1195
+ fileKeys,
1196
+ invalidate: () => cache.invalidateFiles(workspace, fileKeys)
1197
+ };
1198
+ }
1199
+ async clearCache(selector) {
1200
+ const cache = this.transport.cache;
1201
+ if (!cache) {
1202
+ return;
1203
+ }
1204
+ await cache.clear(selector === void 0 ? void 0 : this.requireCacheWorkspace(selector));
1205
+ }
1206
+ async cacheUsage(selector) {
1207
+ const cache = this.transport.cache;
1208
+ if (!cache) {
1209
+ return { bytes: 0, chunks: 0 };
1210
+ }
1211
+ return cache.usage(selector === void 0 ? void 0 : this.requireCacheWorkspace(selector));
1212
+ }
1213
+ requireCacheWorkspace(selector) {
1214
+ const workspace = workspaceCacheKey(this.transport.baseUrl, selector);
1215
+ if (workspace === null) {
1216
+ throw new TypeError("workspaceCuid or workspaceId is required");
1217
+ }
1218
+ return workspace;
1219
+ }
1220
+ /** Fall back to the client workspace only when the request names none, as uploads do. */
1221
+ resolveRequest(options) {
1222
+ const hasWorkspace = options.workspaceCuid !== void 0 || options.workspaceId !== void 0;
1223
+ return downloadRequestFromOptions(
1224
+ hasWorkspace ? options : {
1225
+ ...options,
1226
+ workspaceCuid: this.transport.clientSelector.workspaceCuid,
1227
+ workspaceId: this.transport.clientSelector.workspaceId
1228
+ }
1229
+ );
1230
+ }
1231
+ async exportRequest(request, signal) {
1232
+ return ensureExportFileResponse(
1233
+ await this.transport.requestJson("/file/export", requestBodyFromCheckpoint(request), signal)
1234
+ );
1235
+ }
1236
+ cacheScope(request, mode = "default") {
1237
+ const cache = this.transport.cache;
1238
+ if (!cache || mode === "no-store") {
1239
+ return null;
1240
+ }
1241
+ const workspace = workspaceCacheKey(this.transport.baseUrl, request);
1242
+ const fileKey = fileCacheKey(request);
1243
+ return workspace !== null && fileKey !== null ? { cache, workspace, fileKey } : null;
1244
+ }
1245
+ /** A plan that needs no network at all, when the cached layout is fresh and every chunk is cached. */
1246
+ async loadCachedPlan(request, mode) {
1247
+ const scope = this.cacheScope(request, mode);
1248
+ if (!scope || mode === "no-cache") {
1249
+ return null;
1250
+ }
1251
+ const cached = await scope.cache.loadFile(scope.workspace, scope.fileKey);
1252
+ if (!cached) {
1253
+ return null;
1254
+ }
1255
+ return {
1256
+ path: cached.file.path,
1257
+ size: cached.file.size,
1258
+ ...cached.file.contentType ? { contentType: cached.file.contentType } : {},
1259
+ chunks: cached.file.chunks,
1260
+ cachedChunks: cached.chunks
1261
+ };
1262
+ }
1263
+ planExport(exported, request, mode, fileHandle) {
1264
+ const useClientAssembly = (exported.chunks?.length ?? 0) > 1 && (Boolean(fileHandle) || this.cacheScope(request, mode) !== null);
1265
+ return {
1266
+ path: exported.path,
1267
+ size: exported.size,
1268
+ ...exported.contentType ? { contentType: exported.contentType } : {},
1269
+ chunks: buildDownloadChunks(exported, useClientAssembly),
1270
+ exported
1271
+ };
1272
+ }
543
1273
  async pickSaveFile(requestedFilename, contentType) {
544
1274
  const picker = getShowSaveFilePicker();
545
1275
  if (!picker) {
@@ -565,13 +1295,15 @@ var MountraDownloader = class {
565
1295
  throw new DOMException("Write permission was not granted", "NotAllowedError");
566
1296
  }
567
1297
  }
568
- async downloadResolved(exported, request, options, fileHandle, forceResume) {
569
- const useClientAssembly = Boolean(fileHandle && (exported.chunks?.length ?? 0) > 1);
570
- const chunks = buildDownloadChunks(exported, useClientAssembly);
571
- const key = buildDownloadKey(exported, chunks, this.transport.baseUrl, request);
1298
+ async downloadResolved(plan, request, options, fileHandle, forceResume) {
1299
+ const chunks = plan.chunks;
1300
+ const key = buildDownloadKey(plan, this.transport.baseUrl, request);
572
1301
  const existing = await this.transport.checkpointStore.load(key);
573
1302
  const resumeRequested = !("resume" in options) || options.resume !== false;
574
- let checkpoint = existing && checkpointMatches(existing, exported, chunks) && (forceResume || resumeRequested) && (forceResume || fileHandle && await this.isSameFileHandle(existing.fileHandle, fileHandle)) ? existing : createCheckpoint(key, this.transport.baseUrl, request, exported, chunks, fileHandle);
1303
+ 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);
1304
+ const cacheScope = this.cacheScope(request, "cache" in options ? options.cache : void 0);
1305
+ const cachedChunkIndexes = /* @__PURE__ */ new Set();
1306
+ let cachedBytes = 0;
575
1307
  if (fileHandle) {
576
1308
  checkpoint.fileHandle = fileHandle;
577
1309
  }
@@ -586,11 +1318,11 @@ var MountraDownloader = class {
586
1318
  const emit = (phase, currentChunkIndex) => {
587
1319
  const downloadedBytes = totalDownloadedBytes(checkpoint);
588
1320
  const speedBytesPerSecond = speedWindow.record(downloadedBytes, Date.now());
589
- const remainingBytes = Math.max(exported.size - downloadedBytes, 0);
1321
+ const remainingBytes = Math.max(plan.size - downloadedBytes, 0);
590
1322
  options.onProgress?.({
591
1323
  phase,
592
1324
  downloadedBytes,
593
- totalBytes: exported.size,
1325
+ totalBytes: plan.size,
594
1326
  downloadedChunks: completedChunkCount(checkpoint),
595
1327
  totalChunks: checkpoint.chunks.length,
596
1328
  ...currentChunkIndex !== void 0 ? { currentChunkIndex } : {},
@@ -620,12 +1352,30 @@ var MountraDownloader = class {
620
1352
  if (savedChunk.completed) {
621
1353
  continue;
622
1354
  }
1355
+ const cached = plan.cachedChunks?.get(chunk.index) ?? (cacheScope ? await cacheScope.cache.readChunk(cacheScope.workspace, chunk) : null);
1356
+ if (cached) {
1357
+ throwIfAborted(options.signal);
1358
+ const remaining = cached.slice(savedChunk.downloadedBytes);
1359
+ await sink.write(remaining, chunk.offset + savedChunk.downloadedBytes);
1360
+ cachedBytes += remaining.size;
1361
+ savedChunk.downloadedBytes = chunk.size;
1362
+ savedChunk.completed = true;
1363
+ cachedChunkIndexes.add(chunk.index);
1364
+ emit("downloading", chunk.index);
1365
+ await persist(false);
1366
+ continue;
1367
+ }
1368
+ const url = chunk.url;
1369
+ if (!url) {
1370
+ throw new Error(`Chunk ${chunk.index} is no longer in the download cache`);
1371
+ }
1372
+ const capture = cacheScope && savedChunk.downloadedBytes === 0 && cacheScope.cache.canStoreChunk(chunk) ? [] : null;
623
1373
  let attempt = 0;
624
1374
  while (true) {
625
1375
  throwIfAborted(options.signal);
626
1376
  const rangeStart = savedChunk.downloadedBytes;
627
1377
  try {
628
- const response = await this.transport.fetchImpl(chunk.url, {
1378
+ const response = await this.transport.fetchImpl(url, {
629
1379
  method: "GET",
630
1380
  ...rangeStart > 0 ? { headers: { Range: `bytes=${rangeStart}-` } } : {},
631
1381
  ...options.signal ? { signal: options.signal } : {}
@@ -641,9 +1391,10 @@ var MountraDownloader = class {
641
1391
  chunk,
642
1392
  savedChunk,
643
1393
  sink,
644
- exported.size,
1394
+ plan.size,
645
1395
  emit,
646
1396
  persist,
1397
+ capture,
647
1398
  options.signal
648
1399
  );
649
1400
  if (savedChunk.downloadedBytes !== savedChunk.size) {
@@ -653,6 +1404,9 @@ var MountraDownloader = class {
653
1404
  }
654
1405
  savedChunk.completed = true;
655
1406
  await persist(true);
1407
+ if (cacheScope && capture && await cacheScope.cache.storeChunk(cacheScope.workspace, chunk, new Blob(capture))) {
1408
+ cachedChunkIndexes.add(chunk.index);
1409
+ }
656
1410
  emit("downloading", chunk.index);
657
1411
  break;
658
1412
  } catch (error) {
@@ -667,25 +1421,29 @@ var MountraDownloader = class {
667
1421
  }
668
1422
  }
669
1423
  }
670
- if (totalDownloadedBytes(checkpoint) !== exported.size) {
1424
+ if (totalDownloadedBytes(checkpoint) !== plan.size) {
671
1425
  throw new Error("Download completed with an unexpected byte count");
672
1426
  }
673
1427
  if (fileHandle) {
674
- await sink.finalize(exported.size);
1428
+ await sink.finalize(plan.size);
675
1429
  } else {
676
1430
  await sink.finalize();
677
1431
  }
678
1432
  await this.transport.checkpointStore.remove(key);
1433
+ if (cacheScope && plan.exported && chunks.every((chunk) => chunk.size === 0 || cachedChunkIndexes.has(chunk.index))) {
1434
+ await cacheScope.cache.storeFile(cacheScope.workspace, cacheScope.fileKey, plan);
1435
+ }
679
1436
  emit("completed");
680
- const blob = sink instanceof BlobDownloadSink ? sink.blob(exported.contentType) : void 0;
1437
+ const blob = sink instanceof BlobDownloadSink ? sink.blob(plan.contentType) : void 0;
681
1438
  if (blob) {
682
1439
  const filename = "downloadFilename" in options ? options.downloadFilename : void 0;
683
- triggerBrowserDownload(blob, defaultFilename(exported, filename));
1440
+ triggerBrowserDownload(blob, defaultFilename(plan.path, filename));
684
1441
  }
685
1442
  return {
686
- export: exported,
1443
+ ...plan.exported ? { export: plan.exported } : {},
687
1444
  downloadKey: key,
688
- downloadedBytes: exported.size,
1445
+ downloadedBytes: plan.size,
1446
+ cachedBytes,
689
1447
  resumed,
690
1448
  ...fileHandle ? { fileHandle } : {},
691
1449
  ...blob ? { blob } : {}
@@ -695,13 +1453,13 @@ var MountraDownloader = class {
695
1453
  throw error;
696
1454
  }
697
1455
  }
698
- async consumeResponse(response, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
1456
+ async consumeResponse(response, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal) {
699
1457
  const sourceOffset = savedChunk.downloadedBytes;
700
1458
  let bytesToSkip = response.status === 200 && sourceOffset > 0 ? sourceOffset : 0;
701
1459
  const reader = response.body?.getReader();
702
1460
  if (!reader) {
703
1461
  const body = new Uint8Array(await response.arrayBuffer());
704
- await this.consumeBytes(body, bytesToSkip, chunk, savedChunk, sink, totalSize, emit, persist, signal);
1462
+ await this.consumeBytes(body, bytesToSkip, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal);
705
1463
  return;
706
1464
  }
707
1465
  try {
@@ -727,10 +1485,11 @@ var MountraDownloader = class {
727
1485
  totalSize,
728
1486
  emit,
729
1487
  persist,
1488
+ capture,
730
1489
  signal
731
1490
  );
732
1491
  } else {
733
- await this.consumeBytes(bytes, 0, chunk, savedChunk, sink, totalSize, emit, persist, signal);
1492
+ await this.consumeBytes(bytes, 0, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal);
734
1493
  }
735
1494
  }
736
1495
  } finally {
@@ -740,7 +1499,7 @@ var MountraDownloader = class {
740
1499
  throw new Error(`Download response did not contain the saved offset for chunk ${chunk.index}`);
741
1500
  }
742
1501
  }
743
- async consumeBytes(bytes, offset, chunk, savedChunk, sink, totalSize, emit, persist, signal) {
1502
+ async consumeBytes(bytes, offset, chunk, savedChunk, sink, totalSize, emit, persist, capture, signal) {
744
1503
  const remaining = chunk.size - savedChunk.downloadedBytes;
745
1504
  const writableBytes = bytes.byteLength - offset;
746
1505
  if (writableBytes > remaining) {
@@ -752,11 +1511,8 @@ var MountraDownloader = class {
752
1511
  throwIfAborted(signal);
753
1512
  const payload = bytes.subarray(offset);
754
1513
  const position = chunk.offset + savedChunk.downloadedBytes;
755
- if (sink instanceof FileSystemDownloadSink) {
756
- await sink.write(payload, position);
757
- } else {
758
- await sink.write(payload);
759
- }
1514
+ await sink.write(payload, position);
1515
+ capture?.push(payload.slice());
760
1516
  savedChunk.downloadedBytes += payload.byteLength;
761
1517
  emit("downloading", chunk.index);
762
1518
  await persist(false);
@@ -782,132 +1538,6 @@ var MountraDownloader = class {
782
1538
  }
783
1539
  };
784
1540
 
785
- // src/file.ts
786
- function normalizeMountraFileLocator(locator, fallbackWorkspace = {}) {
787
- if (!locator || typeof locator !== "object") {
788
- throw new TypeError("Mountra file locator is required");
789
- }
790
- const hasPath = locator.path !== void 0;
791
- const hasIno = locator.ino !== void 0;
792
- if (hasPath === hasIno) {
793
- throw new TypeError("Mountra file locator requires exactly one of path or ino");
794
- }
795
- const workspaceId = locator.workspaceId ?? locator.wsid ?? fallbackWorkspace.workspaceId;
796
- if (locator.workspaceId !== void 0 && locator.wsid !== void 0 && String(locator.workspaceId) !== String(locator.wsid)) {
797
- throw new TypeError("workspaceId and wsid must identify the same workspace");
798
- }
799
- const workspaceCuid = locator.workspaceCuid ?? fallbackWorkspace.workspaceCuid;
800
- if (workspaceCuid === void 0 && workspaceId === void 0) {
801
- throw new TypeError("workspaceCuid, workspaceId, or wsid is required");
802
- }
803
- if (hasPath) {
804
- const path = normalizePath(locator.path);
805
- return {
806
- ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
807
- ...workspaceId !== void 0 ? { workspaceId } : {},
808
- path
809
- };
810
- }
811
- return {
812
- ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
813
- ...workspaceId !== void 0 ? { workspaceId } : {},
814
- ino: normalizeIno(locator.ino)
815
- };
816
- }
817
- var MountraFile = class {
818
- constructor(client, locator) {
819
- this.client = client;
820
- this.locator = Object.freeze({ ...locator });
821
- }
822
- client;
823
- locator;
824
- get workspaceCuid() {
825
- return this.locator.workspaceCuid;
826
- }
827
- get workspaceId() {
828
- return this.locator.workspaceId;
829
- }
830
- /** `wsid` is the short alias used by the file-object API. */
831
- get wsid() {
832
- return this.locator.workspaceId;
833
- }
834
- get path() {
835
- return "path" in this.locator ? this.locator.path : void 0;
836
- }
837
- get ino() {
838
- return "ino" in this.locator ? this.locator.ino : void 0;
839
- }
840
- export(options = {}) {
841
- return this.client.exportFile({ ...options, ...this.locator });
842
- }
843
- download(options = {}) {
844
- return this.client.downloadFile({ ...options, ...this.locator });
845
- }
846
- async resume(options = {}) {
847
- const { downloadKey, ...resumeOptions } = options;
848
- const checkpoints = await this.client.listResumableDownloads();
849
- const candidates = checkpoints.filter(
850
- (checkpoint2) => checkpoint2.fileHandle && matchesLocator(checkpoint2.request, this.locator)
851
- );
852
- const checkpoint = downloadKey ? candidates.find((candidate) => candidate.key === downloadKey) : [...candidates].sort((left, right) => right.updatedAt - left.updatedAt)[0];
853
- if (!checkpoint) {
854
- if (downloadKey) {
855
- throw new Error("Download checkpoint does not belong to this Mountra file");
856
- }
857
- throw new Error("No resumable download was found for this Mountra file");
858
- }
859
- return this.client.resumeDownload(checkpoint.key, resumeOptions);
860
- }
861
- };
862
- function matchesLocator(request, locator) {
863
- if (locator.workspaceCuid !== void 0 && request.workspaceCuid !== locator.workspaceCuid) {
864
- return false;
865
- }
866
- if (locator.workspaceId !== void 0 && String(request.workspaceId) !== String(locator.workspaceId)) {
867
- return false;
868
- }
869
- if ("path" in locator) {
870
- if (request.path === void 0 || request.ino !== void 0) {
871
- return false;
872
- }
873
- try {
874
- return normalizePath(request.path) === locator.path;
875
- } catch {
876
- return false;
877
- }
878
- }
879
- return String(request.ino) === String(locator.ino) && request.path === void 0;
880
- }
881
- function normalizePath(value) {
882
- if (!value || !value.trim()) {
883
- throw new TypeError("Mountra file path cannot be empty");
884
- }
885
- const raw = value.trim();
886
- const absolute = raw.startsWith("/") ? raw : `/${raw}`;
887
- const segments = absolute.split("/").filter((segment) => segment.length > 0 && segment !== ".");
888
- const stack = [];
889
- for (const segment of segments) {
890
- if (segment === "..") {
891
- stack.pop();
892
- continue;
893
- }
894
- stack.push(segment);
895
- }
896
- return stack.length === 0 ? "/" : `/${stack.join("/")}`;
897
- }
898
- function normalizeIno(value) {
899
- if (typeof value === "number") {
900
- if (!Number.isSafeInteger(value) || value < 0) {
901
- throw new TypeError("Mountra file ino must be a non-negative safe integer");
902
- }
903
- return value;
904
- }
905
- if (!/^\d+$/.test(value)) {
906
- throw new TypeError("Mountra file ino must be a non-negative integer string");
907
- }
908
- return value;
909
- }
910
-
911
1541
  // src/upload.ts
912
1542
  var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
913
1543
  var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
@@ -1160,8 +1790,7 @@ function splitFileIntoChunks(file, chunkCount = 1) {
1160
1790
  }
1161
1791
  const chunkSize = Math.ceil(file.size / chunkCount);
1162
1792
  const chunks = [];
1163
- for (let index = 0; index < chunkCount; index += 1) {
1164
- const offset = index * chunkSize;
1793
+ for (let index = 0, offset = 0; offset < file.size; index += 1, offset += chunkSize) {
1165
1794
  const end = Math.min(offset + chunkSize, file.size);
1166
1795
  chunks.push({
1167
1796
  index,
@@ -1217,6 +1846,7 @@ var MountraClient = class {
1217
1846
  clientSelector: this.clientSelector,
1218
1847
  fetchImpl: this.fetchImpl,
1219
1848
  checkpointStore: options.downloadCheckpointStore ?? new IndexedDBDownloadCheckpointStore(),
1849
+ cache: options.downloadCache ? new DownloadCache(options.downloadCache) : null,
1220
1850
  requestJson: (path, body, signal) => this.requestJson(path, body, signal)
1221
1851
  });
1222
1852
  }
@@ -1238,6 +1868,13 @@ var MountraClient = class {
1238
1868
  clearDownloadCheckpoint(downloadKey) {
1239
1869
  return this.downloader.clearDownloadCheckpoint(downloadKey);
1240
1870
  }
1871
+ /** Clear the download cache of one workspace, or of every workspace when none is given. */
1872
+ clearDownloadCache(selector) {
1873
+ return this.downloader.clearCache(selector);
1874
+ }
1875
+ getDownloadCacheUsage(selector) {
1876
+ return this.downloader.cacheUsage(selector);
1877
+ }
1241
1878
  /** Create a stable resource object for one file in this or another workspace. */
1242
1879
  file(locator) {
1243
1880
  return new MountraFile(
@@ -1418,6 +2055,26 @@ var MountraClient = class {
1418
2055
  if (finished.finish_status !== "completed") {
1419
2056
  throw new Error(`Mountra upload did not finish successfully: ${finished.finish_status}`);
1420
2057
  }
2058
+ await this.downloader.cacheUploadedFile(
2059
+ this.cacheWorkspace(options),
2060
+ {
2061
+ path: finished.path,
2062
+ ...finished.ino !== void 0 ? { ino: finished.ino } : {},
2063
+ size: context.fileSize,
2064
+ ...context.contentType ? { contentType: context.contentType } : {},
2065
+ chunks: orderedTargets.map((target) => {
2066
+ const chunk = chunks[target.chunk_index];
2067
+ return {
2068
+ index: chunk.index,
2069
+ offset: chunk.offset,
2070
+ size: chunk.size,
2071
+ storageKey: target.storage_key,
2072
+ data: chunk.blob
2073
+ };
2074
+ })
2075
+ },
2076
+ options.cache
2077
+ );
1421
2078
  await Promise.all(
1422
2079
  [...jumboCheckpointKeys].map(
1423
2080
  (checkpointKey) => this.jumboChunkCheckpointStore.remove(checkpointKey)
@@ -1593,12 +2250,26 @@ var MountraClient = class {
1593
2250
  if (!uploadId.trim()) {
1594
2251
  throw new TypeError("uploadId is required");
1595
2252
  }
1596
- return ensureFinishResponse(
2253
+ const finished = ensureFinishResponse(
1597
2254
  await this.requestJson("/upload/finish", {
1598
2255
  ...getWorkspaceBody(this.clientSelector, selector),
1599
2256
  upload_id: uploadId
1600
2257
  })
1601
2258
  );
2259
+ if (finished.finish_status === "completed") {
2260
+ await this.downloader.invalidateCachedFile(this.cacheWorkspace(selector), {
2261
+ path: finished.path,
2262
+ ...finished.ino !== void 0 ? { ino: finished.ino } : {}
2263
+ });
2264
+ }
2265
+ return finished;
2266
+ }
2267
+ /** Same workspace resolution as `getWorkspaceBody`, so the cache key matches what Mountra used. */
2268
+ cacheWorkspace(selector) {
2269
+ return {
2270
+ workspaceCuid: selector.workspaceCuid ?? this.clientSelector.workspaceCuid,
2271
+ workspaceId: selector.workspaceId ?? this.clientSelector.workspaceId
2272
+ };
1602
2273
  }
1603
2274
  endpoint(path) {
1604
2275
  return `${this.baseUrl}${this.apiPrefix}${path}`;
@@ -1638,8 +2309,10 @@ function createMountraClient(options) {
1638
2309
  export {
1639
2310
  DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
1640
2311
  DownloadHttpError,
2312
+ InMemoryDownloadCacheStore,
1641
2313
  InMemoryDownloadCheckpointStore,
1642
2314
  InMemoryJumboChunkCheckpointStore,
2315
+ IndexedDBDownloadCacheStore,
1643
2316
  IndexedDBDownloadCheckpointStore,
1644
2317
  JUMBO_CHUNK_THRESHOLD_BYTES,
1645
2318
  LocalStorageJumboChunkCheckpointStore,