@mountra/mountra-sdk 0.3.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
@@ -94,6 +94,9 @@ function matchesLocator(request, locator) {
94
94
  }
95
95
  return String(request.ino) === String(locator.ino) && request.path === void 0;
96
96
  }
97
+ function isPathWithin(path, ancestor) {
98
+ return ancestor === "/" || path === ancestor || path.startsWith(`${ancestor}/`);
99
+ }
97
100
  function normalizePath(value) {
98
101
  if (!value || !value.trim()) {
99
102
  throw new TypeError("Mountra file path cannot be empty");
@@ -124,6 +127,73 @@ function normalizeIno(value) {
124
127
  return value;
125
128
  }
126
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
+
127
197
  // src/cache.ts
128
198
  var DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
129
199
  var DEFAULT_MAX_CHUNK_BYTES = 128 * 1024 * 1024;
@@ -149,6 +219,13 @@ var InMemoryDownloadCacheStore = class {
149
219
  async removeFile(workspace, fileKey) {
150
220
  this.files.delete(memoryKey(workspace, fileKey));
151
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
+ }
152
229
  async getChunk(workspace, storageKey) {
153
230
  const key = memoryKey(workspace, storageKey);
154
231
  const entry = this.chunks.get(key);
@@ -212,48 +289,9 @@ var CHUNK_DATA_STORE = "chunk-data";
212
289
  var META_STORE = "meta";
213
290
  var LRU_INDEX = "lastUsed";
214
291
  var USAGE_KEY = "bytes";
215
- var CLOCK_KEY = "clock";
216
- function getIndexedDb() {
217
- return globalThis.indexedDB ?? null;
218
- }
219
- function workspaceRange(workspace) {
220
- return IDBKeyRange.bound([workspace], [workspace, []]);
221
- }
222
292
  function sameKey(left, right) {
223
293
  return Array.isArray(left) && left[0] === right[0] && left[1] === right[1];
224
294
  }
225
- async function nextClock(meta) {
226
- const clock = (await requestResult(meta.get(CLOCK_KEY)) ?? 0) + 1;
227
- await requestResult(meta.put(clock, CLOCK_KEY));
228
- return clock;
229
- }
230
- function requestResult(request) {
231
- return new Promise((resolve, reject) => {
232
- request.onsuccess = () => resolve(request.result);
233
- request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
234
- });
235
- }
236
- async function runTransaction(database, storeNames, mode, body) {
237
- const transaction = database.transaction(storeNames, mode);
238
- const done = new Promise((resolve, reject) => {
239
- transaction.oncomplete = () => resolve();
240
- transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
241
- transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
242
- });
243
- done.catch(() => void 0);
244
- let result;
245
- try {
246
- result = await body(transaction);
247
- } catch (error) {
248
- try {
249
- transaction.abort();
250
- } catch {
251
- }
252
- throw error;
253
- }
254
- await done;
255
- return result;
256
- }
257
295
  var IndexedDBDownloadCacheStore = class {
258
296
  databaseName;
259
297
  fallback = new InMemoryDownloadCacheStore();
@@ -291,6 +329,23 @@ var IndexedDBDownloadCacheStore = class {
291
329
  await requestResult(transaction.objectStore(FILES_STORE).delete([workspace, fileKey]));
292
330
  });
293
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
+ }
294
349
  async getChunk(workspace, storageKey) {
295
350
  const database = await this.openDatabase();
296
351
  if (!database) {
@@ -414,47 +469,27 @@ var IndexedDBDownloadCacheStore = class {
414
469
  });
415
470
  }
416
471
  async openDatabase() {
417
- if (!this.databasePromise) {
418
- const indexedDb = getIndexedDb();
419
- if (!indexedDb) {
420
- this.databasePromise = Promise.resolve(null);
421
- } else {
422
- this.databasePromise = new Promise((resolve) => {
423
- let request;
424
- try {
425
- request = indexedDb.open(this.databaseName, CACHE_DATABASE_VERSION);
426
- } catch {
427
- resolve(null);
428
- return;
429
- }
430
- request.onupgradeneeded = () => {
431
- const database = request.result;
432
- if (!database.objectStoreNames.contains(FILES_STORE)) {
433
- database.createObjectStore(FILES_STORE);
434
- }
435
- if (!database.objectStoreNames.contains(CHUNKS_STORE)) {
436
- database.createObjectStore(CHUNKS_STORE).createIndex(LRU_INDEX, "lastUsed");
437
- }
438
- if (!database.objectStoreNames.contains(CHUNK_DATA_STORE)) {
439
- database.createObjectStore(CHUNK_DATA_STORE);
440
- }
441
- if (!database.objectStoreNames.contains(META_STORE)) {
442
- database.createObjectStore(META_STORE);
443
- }
444
- };
445
- request.onsuccess = () => {
446
- const database = request.result;
447
- database.onversionchange = () => {
448
- database.close();
449
- this.databasePromise = void 0;
450
- };
451
- resolve(database);
452
- };
453
- request.onerror = () => resolve(null);
454
- request.onblocked = () => resolve(null);
455
- });
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;
456
491
  }
457
- }
492
+ );
458
493
  return this.databasePromise;
459
494
  }
460
495
  };
@@ -593,6 +628,20 @@ var DownloadCache = class {
593
628
  fileKeys.map((fileKey) => this.store.removeFile(workspace, fileKey).catch(() => void 0))
594
629
  );
595
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
+ }
596
645
  clear(workspace) {
597
646
  return this.store.clear(workspace);
598
647
  }
@@ -1156,6 +1205,14 @@ var MountraDownloader = class {
1156
1205
  async invalidateCachedFile(selector, target) {
1157
1206
  await this.cachedFileScope(selector, target)?.invalidate();
1158
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
+ }
1159
1216
  /**
1160
1217
  * Cache a file this client just uploaded, so downloading it needs no
1161
1218
  * network. Falls back to invalidation when any chunk cannot be cached.
@@ -1538,11 +1595,7 @@ var MountraDownloader = class {
1538
1595
  }
1539
1596
  };
1540
1597
 
1541
- // src/upload.ts
1542
- var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
1543
- var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
1544
- var DEFAULT_S3_MULTIPART_PART_SIZE_BYTES = 16 * 1024 * 1024;
1545
- var MAX_S3_MULTIPART_PARTS = 1e4;
1598
+ // src/errors.ts
1546
1599
  var MountraApiError = class extends Error {
1547
1600
  status;
1548
1601
  code;
@@ -1555,9 +1608,936 @@ var MountraApiError = class extends Error {
1555
1608
  this.body = input.body;
1556
1609
  }
1557
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
+ });
1996
+ }
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
+ });
2015
+ }
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
+ });
2044
+ }
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
+ });
2056
+ }
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;
2074
+ }
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;
2114
+ }
2115
+ return node;
2116
+ }
2117
+ function rebaseNode(node, from, to) {
2118
+ return {
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))
2123
+ };
2124
+ }
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
+ }
2150
+ }
2151
+ list(path) {
2152
+ const record = this.records.get(recordKey("list", path));
2153
+ return record?.kind === "list" ? record : void 0;
2154
+ }
2155
+ tree(path) {
2156
+ const record = this.records.get(recordKey("tree", path));
2157
+ return record?.kind === "tree" ? record : void 0;
2158
+ }
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
+ );
2164
+ }
2165
+ /** Loaded records at `path` or below it. */
2166
+ within(path) {
2167
+ return [...this.records.values()].filter((record) => isPathWithin(record.path, path));
2168
+ }
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);
2174
+ }
2175
+ /** Mark a loaded record that was changed in place. */
2176
+ touch(record) {
2177
+ this.changed.add(recordKey(record.kind, record.path));
2178
+ }
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 });
2184
+ }
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;
2200
+ }
2201
+ }
2202
+ return void 0;
2203
+ }
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
+ }
2219
+ }
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
+ }
2243
+ }
2244
+ removeSubtree(path) {
2245
+ for (const record of this.within(path)) {
2246
+ this.remove(record);
2247
+ }
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
+ );
2261
+ }
2262
+ }
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
+ };
2272
+ }
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
+ }
2340
+ }
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) {
2374
+ continue;
2375
+ }
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
+ }
2384
+ }
2385
+ }
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);
2410
+ }
2411
+ }
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
+ }
2427
+ }
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
+ };
2532
+
2533
+ // src/upload.ts
2534
+ var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
2535
+ var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
2536
+ var DEFAULT_S3_MULTIPART_PART_SIZE_BYTES = 16 * 1024 * 1024;
2537
+ var MAX_S3_MULTIPART_PARTS = 1e4;
1558
2538
  var memoryCheckpointRecords = /* @__PURE__ */ new Map();
1559
2539
  function parseJumboCheckpoint(value) {
1560
- if (!isRecord2(value) || value.version !== 1 || typeof value.storageKey !== "string") {
2540
+ if (!isRecord3(value) || value.version !== 1 || typeof value.storageKey !== "string") {
1561
2541
  return null;
1562
2542
  }
1563
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)) {
@@ -1565,7 +2545,7 @@ function parseJumboCheckpoint(value) {
1565
2545
  }
1566
2546
  const parts = [];
1567
2547
  for (const part of value.parts) {
1568
- if (!isRecord2(part)) {
2548
+ if (!isRecord3(part)) {
1569
2549
  return null;
1570
2550
  }
1571
2551
  if (typeof part.partNumber !== "number" || typeof part.etag !== "string" || typeof part.size !== "number" || typeof part.sha256 !== "string") {
@@ -1744,11 +2724,11 @@ function throwIfAborted2(signal) {
1744
2724
  }
1745
2725
  throw new DOMException("The upload was aborted", "AbortError");
1746
2726
  }
1747
- function isRecord2(value) {
2727
+ function isRecord3(value) {
1748
2728
  return typeof value === "object" && value !== null;
1749
2729
  }
1750
2730
  function parseApiErrorBody(body) {
1751
- if (!isRecord2(body)) {
2731
+ if (!isRecord3(body)) {
1752
2732
  return {};
1753
2733
  }
1754
2734
  return {
@@ -1757,7 +2737,7 @@ function parseApiErrorBody(body) {
1757
2737
  };
1758
2738
  }
1759
2739
  function ensurePrepareResponse(value) {
1760
- if (!isRecord2(value) || typeof value.upload_id !== "string") {
2740
+ if (!isRecord3(value) || typeof value.upload_id !== "string") {
1761
2741
  throw new TypeError("Mountra prepare response is invalid");
1762
2742
  }
1763
2743
  if (!Array.isArray(value.upload_targets)) {
@@ -1766,13 +2746,13 @@ function ensurePrepareResponse(value) {
1766
2746
  return value;
1767
2747
  }
1768
2748
  function ensureFinishResponse(value) {
1769
- if (!isRecord2(value) || typeof value.upload_id !== "string") {
2749
+ if (!isRecord3(value) || typeof value.upload_id !== "string") {
1770
2750
  throw new TypeError("Mountra finish response is invalid");
1771
2751
  }
1772
2752
  return value;
1773
2753
  }
1774
2754
  function ensureUploadStatusResponse(value) {
1775
- if (!isRecord2(value) || typeof value.upload_id !== "string") {
2755
+ if (!isRecord3(value) || typeof value.upload_id !== "string") {
1776
2756
  throw new TypeError("Mountra upload status response is invalid");
1777
2757
  }
1778
2758
  if (!Array.isArray(value.chunks)) {
@@ -1823,6 +2803,7 @@ var MountraClient = class {
1823
2803
  jumboChunkCheckpointStore;
1824
2804
  jumboPartSize;
1825
2805
  downloader;
2806
+ metadata;
1826
2807
  constructor(options) {
1827
2808
  this.baseUrl = normalizeBaseUrl(options.baseUrl);
1828
2809
  this.apiPrefix = normalizeApiPrefix(options.apiPrefix ?? "/api");
@@ -1849,6 +2830,13 @@ var MountraClient = class {
1849
2830
  cache: options.downloadCache ? new DownloadCache(options.downloadCache) : null,
1850
2831
  requestJson: (path, body, signal) => this.requestJson(path, body, signal)
1851
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
+ });
1852
2840
  }
1853
2841
  async exportFile(options) {
1854
2842
  return this.downloader.exportFile(options);
@@ -1875,6 +2863,33 @@ var MountraClient = class {
1875
2863
  getDownloadCacheUsage(selector) {
1876
2864
  return this.downloader.cacheUsage(selector);
1877
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
+ }
1878
2893
  /** Create a stable resource object for one file in this or another workspace. */
1879
2894
  file(locator) {
1880
2895
  return new MountraFile(
@@ -2055,6 +3070,7 @@ var MountraClient = class {
2055
3070
  if (finished.finish_status !== "completed") {
2056
3071
  throw new Error(`Mountra upload did not finish successfully: ${finished.finish_status}`);
2057
3072
  }
3073
+ await this.metadata.recordUpload(this.cacheWorkspace(options), finished);
2058
3074
  await this.downloader.cacheUploadedFile(
2059
3075
  this.cacheWorkspace(options),
2060
3076
  {
@@ -2257,6 +3273,7 @@ var MountraClient = class {
2257
3273
  })
2258
3274
  );
2259
3275
  if (finished.finish_status === "completed") {
3276
+ await this.metadata.recordUpload(this.cacheWorkspace(selector), finished);
2260
3277
  await this.downloader.invalidateCachedFile(this.cacheWorkspace(selector), {
2261
3278
  path: finished.path,
2262
3279
  ...finished.ino !== void 0 ? { ino: finished.ino } : {}
@@ -2312,8 +3329,10 @@ export {
2312
3329
  InMemoryDownloadCacheStore,
2313
3330
  InMemoryDownloadCheckpointStore,
2314
3331
  InMemoryJumboChunkCheckpointStore,
3332
+ InMemoryMetadataCacheStore,
2315
3333
  IndexedDBDownloadCacheStore,
2316
3334
  IndexedDBDownloadCheckpointStore,
3335
+ IndexedDBMetadataCacheStore,
2317
3336
  JUMBO_CHUNK_THRESHOLD_BYTES,
2318
3337
  LocalStorageJumboChunkCheckpointStore,
2319
3338
  MAX_S3_MULTIPART_PARTS,