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