@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/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 getIndexedDb() {
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 = getIndexedDb();
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
- async write(bytes) {
333
- this.parts.push(bytes.slice());
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(exported, chunks, baseUrl, request) {
1116
+ function buildDownloadKey(plan, baseUrl, request) {
457
1117
  return encodeURIComponent(
458
1118
  JSON.stringify({
459
1119
  request: requestKey(request, baseUrl),
460
- path: exported.path,
461
- size: exported.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, exported, chunks) {
467
- if (checkpoint.version !== 1 || checkpoint.path !== exported.path || checkpoint.size !== exported.size) {
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, exported, chunks, fileHandle) {
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: exported.path,
487
- size: exported.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(exported, requested) {
507
- const candidate = requested?.trim() || exported.path.split("/").filter(Boolean).pop() || "download";
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
- const request = downloadRequestFromOptions(options);
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 exported = await this.exportFile(options);
545
- return this.downloadExport(exported, { ...options, ...fileHandle ? { fileHandle } : {} });
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 = downloadRequestFromOptions(
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(exported, request, options, fileHandle, false);
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(exported, request, options, fileHandle, forceResume) {
613
- const useClientAssembly = Boolean(fileHandle && (exported.chunks?.length ?? 0) > 1);
614
- const chunks = buildDownloadChunks(exported, useClientAssembly);
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, exported, chunks) && (forceResume || resumeRequested) && (forceResume || fileHandle && await this.isSameFileHandle(existing.fileHandle, fileHandle)) ? existing : createCheckpoint(key, this.transport.baseUrl, request, exported, chunks, fileHandle);
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(exported.size - downloadedBytes, 0);
1426
+ const remainingBytes = Math.max(plan.size - downloadedBytes, 0);
634
1427
  options.onProgress?.({
635
1428
  phase,
636
1429
  downloadedBytes,
637
- totalBytes: exported.size,
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(chunk.url, {
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
- exported.size,
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) !== exported.size) {
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(exported.size);
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(exported.contentType) : void 0;
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(exported, filename));
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: exported.size,
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
- if (sink instanceof FileSystemDownloadSink) {
800
- await sink.write(payload, position);
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
- // src/file.ts
830
- function normalizeMountraFileLocator(locator, fallbackWorkspace = {}) {
831
- if (!locator || typeof locator !== "object") {
832
- throw new TypeError("Mountra file locator is required");
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
- const hasPath = locator.path !== void 0;
835
- const hasIno = locator.ino !== void 0;
836
- if (hasPath === hasIno) {
837
- throw new TypeError("Mountra file locator requires exactly one of path or ino");
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
- const workspaceId = locator.workspaceId ?? locator.wsid ?? fallbackWorkspace.workspaceId;
840
- if (locator.workspaceId !== void 0 && locator.wsid !== void 0 && String(locator.workspaceId) !== String(locator.wsid)) {
841
- throw new TypeError("workspaceId and wsid must identify the same workspace");
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
- const workspaceCuid = locator.workspaceCuid ?? fallbackWorkspace.workspaceCuid;
844
- if (workspaceCuid === void 0 && workspaceId === void 0) {
845
- throw new TypeError("workspaceCuid, workspaceId, or wsid is required");
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
- if (hasPath) {
848
- const path = normalizePath(locator.path);
849
- return {
850
- ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
851
- ...workspaceId !== void 0 ? { workspaceId } : {},
852
- path
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
- ...workspaceCuid !== void 0 ? { workspaceCuid } : {},
857
- ...workspaceId !== void 0 ? { workspaceId } : {},
858
- ino: normalizeIno(locator.ino)
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
- var MountraFile = class {
862
- constructor(client, locator) {
863
- this.client = client;
864
- this.locator = Object.freeze({ ...locator });
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
- client;
867
- locator;
868
- get workspaceCuid() {
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
- get workspaceId() {
872
- return this.locator.workspaceId;
2203
+ tree(path) {
2204
+ const record = this.records.get(recordKey("tree", path));
2205
+ return record?.kind === "tree" ? record : void 0;
873
2206
  }
874
- /** `wsid` is the short alias used by the file-object API. */
875
- get wsid() {
876
- return this.locator.workspaceId;
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
- get path() {
879
- return "path" in this.locator ? this.locator.path : void 0;
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
- get ino() {
882
- return "ino" in this.locator ? this.locator.ino : void 0;
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
- export(options = {}) {
885
- return this.client.exportFile({ ...options, ...this.locator });
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
- download(options = {}) {
888
- return this.client.downloadFile({ ...options, ...this.locator });
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
- async resume(options = {}) {
891
- const { downloadKey, ...resumeOptions } = options;
892
- const checkpoints = await this.client.listResumableDownloads();
893
- const candidates = checkpoints.filter(
894
- (checkpoint2) => checkpoint2.fileHandle && matchesLocator(checkpoint2.request, this.locator)
895
- );
896
- const checkpoint = downloadKey ? candidates.find((candidate) => candidate.key === downloadKey) : [...candidates].sort((left, right) => right.updatedAt - left.updatedAt)[0];
897
- if (!checkpoint) {
898
- if (downloadKey) {
899
- throw new Error("Download checkpoint does not belong to this Mountra file");
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 this.client.resumeDownload(checkpoint.key, resumeOptions);
2250
+ return void 0;
904
2251
  }
905
- };
906
- function matchesLocator(request, locator) {
907
- if (locator.workspaceCuid !== void 0 && request.workspaceCuid !== locator.workspaceCuid) {
908
- return false;
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
- if (locator.workspaceId !== void 0 && String(request.workspaceId) !== String(locator.workspaceId)) {
911
- return false;
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
- if ("path" in locator) {
914
- if (request.path === void 0 || request.ino !== void 0) {
915
- return false;
2292
+ removeSubtree(path) {
2293
+ for (const record of this.within(path)) {
2294
+ this.remove(record);
916
2295
  }
917
- try {
918
- return normalizePath(request.path) === locator.path;
919
- } catch {
920
- return false;
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
- return String(request.ino) === String(locator.ino) && request.path === void 0;
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 normalizePath(value) {
926
- if (!value || !value.trim()) {
927
- throw new TypeError("Mountra file path cannot be empty");
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
- const raw = value.trim();
930
- const absolute = raw.startsWith("/") ? raw : `/${raw}`;
931
- const segments = absolute.split("/").filter((segment) => segment.length > 0 && segment !== ".");
932
- const stack = [];
933
- for (const segment of segments) {
934
- if (segment === "..") {
935
- stack.pop();
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
- stack.push(segment);
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 normalizeIno(value) {
943
- if (typeof value === "number") {
944
- if (!Number.isSafeInteger(value) || value < 0) {
945
- throw new TypeError("Mountra file ino must be a non-negative safe integer");
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 (!/^\d+$/.test(value)) {
950
- throw new TypeError("Mountra file ino must be a non-negative integer string");
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 (!isRecord2(value) || value.version !== 1 || typeof value.storageKey !== "string") {
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 (!isRecord2(part)) {
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 isRecord2(value) {
2775
+ function isRecord3(value) {
1162
2776
  return typeof value === "object" && value !== null;
1163
2777
  }
1164
2778
  function parseApiErrorBody(body) {
1165
- if (!isRecord2(body)) {
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 (!isRecord2(value) || typeof value.upload_id !== "string") {
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 (!isRecord2(value) || typeof value.upload_id !== "string") {
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 (!isRecord2(value) || typeof value.upload_id !== "string") {
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; index < chunkCount; index += 1) {
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
- return ensureFinishResponse(
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,