@telecrypt-io/storage 0.5.28 → 0.5.30

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.
@@ -10,12 +10,12 @@
10
10
  * config (persistent crypto store, session credentials, etc.) is
11
11
  * platform-specific and stays with the caller.
12
12
  */
13
- import { withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
13
+ import { readFileEventMetadata, withMatrixMutationAbort, withTreeMutation, } from "../TeleCryptIOStorage.js";
14
14
  import { EventType, MatrixError, RoomCreateTypeField, RoomType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
15
15
  import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
16
16
  import { Method } from "matrix-js-sdk/lib/http-api/method.js";
17
- import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./errors.js";
18
- import { waitForCondition } from "./poll.js";
17
+ import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, NonEmptyTreeError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, } from "./errors.js";
18
+ import { ConditionTimeoutError, waitForCondition } from "./poll.js";
19
19
  import { validateMatrixEventId } from "./constants.js";
20
20
  import { validateName } from "./validation.js";
21
21
  import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
@@ -27,14 +27,12 @@ import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markT
27
27
  * server-advised backoff. Mirrors the harness library suite's
28
28
  * `withRateLimitRetry`; must not mask non-rate-limit failures.
29
29
  */
30
- const RATE_LIMIT_RETRIES = 6;
31
30
  const RATE_LIMIT_DEFAULT_DELAY_MS = 15_000;
32
- const RATE_LIMIT_MAX_DELAY_MS = 30_000;
33
- const RATE_LIMIT_MAX_TOTAL_DELAY_MS = 90_000;
31
+ // JavaScript timers cannot represent a longer delay reliably. The operation's
32
+ // own deadline remains the authority that stops retrying.
33
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
34
34
  const MAX_DELETION_ROOMS = 4096;
35
35
  const MAX_DELETION_DEPTH = 128;
36
- const MAX_FILE_VERSION_CHAIN = 128;
37
- const MAX_LIST_ITEMS = 10000;
38
36
  const DEFAULT_MUTATION_TIMEOUT_MS = 5 * 60_000;
39
37
  const MAX_MUTATION_TIMEOUT_MS = 15 * 60_000;
40
38
  function createOperationDeadline(options) {
@@ -79,24 +77,17 @@ async function raceOperationDeadline(deadline, pending, kind) {
79
77
  finally {
80
78
  if (onAbort)
81
79
  deadline.signal.removeEventListener("abort", onAbort);
82
- void pending.catch(() => undefined);
83
80
  }
84
81
  }
85
82
  async function withOperationDeadline(options, operation, kind = "read") {
86
83
  const deadline = createOperationDeadline(options);
87
- let pending;
88
84
  try {
89
85
  ensureOperationActive(deadline.signal);
90
- pending = Promise.resolve().then(() => operation(deadline.signal));
86
+ const pending = Promise.resolve().then(() => operation(deadline.signal));
91
87
  return await raceOperationDeadline(deadline, pending, kind);
92
88
  }
93
89
  finally {
94
90
  deadline.close();
95
- // A caller-facing deadline must not wait for a broken provider, but the
96
- // late result remains observed so an ignored AbortSignal cannot create an
97
- // unhandled rejection.
98
- if (pending)
99
- void pending.catch(() => undefined);
100
91
  }
101
92
  }
102
93
  function isRateLimited(error) {
@@ -107,16 +98,28 @@ function isRateLimited(error) {
107
98
  const isRateLimitError = error.isRateLimitError;
108
99
  return typeof isRateLimitError === "function" && isRateLimitError.call(error) === true;
109
100
  }
101
+ function requiresMutationReconciliation(error) {
102
+ return (error instanceof MutationOutcomeUnknownError ||
103
+ error instanceof RoomCreationAmbiguousError ||
104
+ error instanceof RoomCleanupIncompleteError ||
105
+ (error instanceof Error &&
106
+ error.cleanupIncomplete === true));
107
+ }
110
108
  async function withRateLimitRetry(operation, signal) {
111
- let totalDelay = 0;
112
- for (let attempt = 0;; attempt++) {
109
+ for (;;) {
113
110
  if (signal?.aborted)
114
111
  throw new StorageError("operation cancelled");
115
112
  try {
116
113
  return await operation();
117
114
  }
118
115
  catch (error) {
119
- if (!isRateLimited(error) || attempt >= RATE_LIMIT_RETRIES)
116
+ // A mutation that may have committed, or whose compensating cleanup is
117
+ // incomplete, must be reconciled by its owner before any retry. A
118
+ // server rate-limit response does not make repeating room creation safe.
119
+ if (requiresMutationReconciliation(error)) {
120
+ throw error;
121
+ }
122
+ if (!isRateLimited(error))
120
123
  throw error;
121
124
  // The server's retry_after_ms only covers one token; tree operations
122
125
  // need several (kick + leave + forget). Wait at least 15s so the
@@ -131,12 +134,7 @@ async function withRateLimitRetry(operation, signal) {
131
134
  }
132
135
  }
133
136
  }
134
- retryAfter = Math.min(retryAfter, RATE_LIMIT_MAX_DELAY_MS);
135
- const remaining = RATE_LIMIT_MAX_TOTAL_DELAY_MS - totalDelay;
136
- if (remaining <= 0)
137
- throw error;
138
- retryAfter = Math.min(retryAfter, remaining);
139
- totalDelay += retryAfter;
137
+ retryAfter = Math.min(retryAfter, MAX_TIMER_DELAY_MS);
140
138
  await new Promise((resolve, reject) => {
141
139
  let timer;
142
140
  const onAbort = () => {
@@ -169,10 +167,12 @@ async function resolveTree(storage, treeId, signal) {
169
167
  signal,
170
168
  });
171
169
  }
172
- catch {
170
+ catch (error) {
173
171
  if (signal?.aborted)
174
172
  throw new StorageError("operation cancelled");
175
- throw new StorageError("storage space not found");
173
+ if (error instanceof ConditionTimeoutError)
174
+ throw new StorageError("storage space not found");
175
+ throw new StorageError("storage space lookup failed", { cause: error });
176
176
  }
177
177
  }
178
178
  function isMarkedTreeDeleted(storage, treeId) {
@@ -197,42 +197,13 @@ async function resolveFile(storage, tree, fileId, signal) {
197
197
  try {
198
198
  return await waitForCondition(() => (isMarkedFileDeleted(storage, tree.id, fileId) ? null : tree.getFile(fileId)), { timeoutMs: 15000, signal });
199
199
  }
200
- catch {
200
+ catch (error) {
201
201
  if (signal?.aborted)
202
202
  throw new StorageError("operation cancelled");
203
- throw new StorageError("file not found");
204
- }
205
- }
206
- function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large") {
207
- const spaces = [];
208
- const seen = new Set();
209
- const pending = [{ tree: root, depth: 0 }];
210
- while (pending.length > 0) {
211
- const next = pending.pop();
212
- if (!next)
213
- continue;
214
- const { tree, depth } = next;
215
- if (seen.has(tree.id))
216
- continue;
217
- if (depth > MAX_DELETION_DEPTH || spaces.length >= MAX_DELETION_ROOMS) {
218
- throw new StorageError(tooLargeMessage);
219
- }
220
- seen.add(tree.id);
221
- spaces.push(tree);
222
- let children;
223
- try {
224
- children = tree.getDirectories();
225
- }
226
- catch {
227
- throw new StorageError("could not enumerate storage space descendants safely");
228
- }
229
- if (children.length > MAX_DELETION_ROOMS) {
230
- throw new StorageError(tooLargeMessage);
231
- }
232
- for (const child of children)
233
- pending.push({ tree: child, depth: depth + 1 });
203
+ if (error instanceof ConditionTimeoutError)
204
+ throw new StorageError("file not found");
205
+ throw new StorageError("file lookup failed", { cause: error });
234
206
  }
235
- return spaces;
236
207
  }
237
208
  /**
238
209
  * Deleting a tree is deliberately a one-room operation. File versions are
@@ -250,8 +221,8 @@ function assertTreeEmptyForDeletion(storage, tree, spaces) {
250
221
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id) &&
251
222
  !isConfirmedDeletedFile(storage.getClient(), tree.id, file));
252
223
  }
253
- catch {
254
- throw new StorageError("could not enumerate storage files safely");
224
+ catch (error) {
225
+ throw new StorageError("could not enumerate storage files safely", { cause: error });
255
226
  }
256
227
  if (files.length > 0)
257
228
  throw new NonEmptyTreeError(tree.id);
@@ -316,6 +287,62 @@ function isActiveRelationEvent(event) {
316
287
  !Array.isArray(content) &&
317
288
  Object.keys(content).length > 0);
318
289
  }
290
+ function activeTreeDirectories(client, tree) {
291
+ const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
292
+ if (childEvents === null)
293
+ throw new StorageError("delete graph is unsafe");
294
+ const activeChildIds = new Set();
295
+ for (const event of childEvents) {
296
+ if (!isActiveRelationEvent(event))
297
+ continue;
298
+ const childId = relationStateKey(event);
299
+ if (!childId)
300
+ throw new StorageError("delete graph is unsafe");
301
+ activeChildIds.add(childId);
302
+ }
303
+ const knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
304
+ return [...activeChildIds].map((childId) => {
305
+ const child = knownChildren.get(childId);
306
+ if (!child)
307
+ throw new StorageError("delete graph is unsafe");
308
+ return child;
309
+ });
310
+ }
311
+ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large", activeRelationClient) {
312
+ const spaces = [];
313
+ const seen = new Set();
314
+ const pending = [{ tree: root, depth: 0 }];
315
+ while (pending.length > 0) {
316
+ const next = pending.pop();
317
+ if (!next)
318
+ continue;
319
+ const { tree, depth } = next;
320
+ if (seen.has(tree.id))
321
+ continue;
322
+ if (depth > MAX_DELETION_DEPTH || spaces.length >= MAX_DELETION_ROOMS) {
323
+ throw new StorageError(tooLargeMessage);
324
+ }
325
+ seen.add(tree.id);
326
+ spaces.push(tree);
327
+ let children;
328
+ try {
329
+ children = activeRelationClient
330
+ ? activeTreeDirectories(activeRelationClient, tree)
331
+ : tree.getDirectories();
332
+ }
333
+ catch (error) {
334
+ if (error instanceof StorageError)
335
+ throw error;
336
+ throw new StorageError("could not enumerate storage space descendants safely", { cause: error });
337
+ }
338
+ if (children.length > MAX_DELETION_ROOMS) {
339
+ throw new StorageError(tooLargeMessage);
340
+ }
341
+ for (const child of children)
342
+ pending.push({ tree: child, depth: depth + 1 });
343
+ }
344
+ return spaces;
345
+ }
319
346
  /**
320
347
  * A deleted MSC3089 branch is represented by an authoritative empty state
321
348
  * event. Inactive branches with metadata are historical versions, and a
@@ -356,13 +383,7 @@ function validateDeletionGraph(client, root, ids, deletedRooms) {
356
383
  const tree = client.unstableGetFileTreeSpace(id);
357
384
  if (!tree)
358
385
  throw new StorageError("delete graph is unsafe");
359
- let directories;
360
- try {
361
- directories = tree.getDirectories();
362
- }
363
- catch {
364
- throw new StorageError("delete graph is unsafe");
365
- }
386
+ const directories = activeTreeDirectories(client, tree);
366
387
  for (const child of directories)
367
388
  addEdge(id, child.id);
368
389
  const childEvents = readRelationEvents(client, id, EventType.SpaceChild);
@@ -455,8 +476,10 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
455
476
  try {
456
477
  await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
457
478
  }
458
- catch {
459
- throw new StorageError("delete graph unlink cleanup is incomplete");
479
+ catch (cleanupError) {
480
+ throw new StorageError("delete graph unlink cleanup is incomplete", {
481
+ cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
482
+ });
460
483
  }
461
484
  throw error;
462
485
  }
@@ -554,7 +577,7 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
554
577
  if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
555
578
  throw error;
556
579
  if (completedRoomIds.length > 0) {
557
- throw new MutationPartialError("delete", completedRoomIds, "room cleanup stopped");
580
+ throw new MutationPartialError("delete", completedRoomIds, "room cleanup stopped", { cause: error });
558
581
  }
559
582
  throw error;
560
583
  }
@@ -565,14 +588,7 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
565
588
  * may otherwise leave a stale invite visible until the next sync.
566
589
  */
567
590
  function removeRoomFromLocalStore(client, roomId) {
568
- try {
569
- client.store?.removeRoom(roomId);
570
- }
571
- catch {
572
- // Server-side leave/forget already completed; a store implementation that
573
- // cannot evict synchronously must not turn authoritative cleanup into a
574
- // false failure.
575
- }
591
+ client.store?.removeRoom(roomId);
576
592
  }
577
593
  function isActiveMembership(membership) {
578
594
  return membership === "join" || membership === "invite";
@@ -582,12 +598,11 @@ function isRevocableMembership(membership) {
582
598
  }
583
599
  function safePartialDetail(error) {
584
600
  // Provider/Matrix errors can contain response bodies, URLs, or credentials.
585
- // StorageError messages are authored by this package and are already
586
- // bounded; retain those local policy details so callers still get useful
587
- // owner/self-target diagnostics.
601
+ // StorageError messages are authored by this package; retain those local
602
+ // policy details so callers still get useful owner/self-target diagnostics.
588
603
  if (!(error instanceof StorageError) || error instanceof MutationOutcomeUnknownError)
589
604
  return undefined;
590
- return error.message.length <= 256 ? error.message : undefined;
605
+ return error.message;
591
606
  }
592
607
  function toArrayBuffer(bytes) {
593
608
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
@@ -599,16 +614,12 @@ export async function createVault(storage, name, options) {
599
614
  tree = await withRateLimitRetry(() => storage.createTree(name, signal), signal);
600
615
  }
601
616
  catch (error) {
602
- if (error instanceof RoomCleanupIncompleteError ||
603
- error instanceof RoomCreationAmbiguousError ||
604
- error instanceof MutationOutcomeUnknownError ||
605
- (error instanceof Error &&
606
- error.cleanupIncomplete === true)) {
617
+ if (requiresMutationReconciliation(error)) {
607
618
  throw error;
608
619
  }
609
620
  if (signal.aborted)
610
621
  throw new StorageError("operation cancelled");
611
- throw new StorageError("create vault failed");
622
+ throw new StorageError("create vault failed", { cause: error });
612
623
  }
613
624
  ensureOperationActive(signal);
614
625
  return { id: tree.id, name };
@@ -646,7 +657,7 @@ export async function joinVault(storage, vaultId, options) {
646
657
  membership = null;
647
658
  }
648
659
  else {
649
- throw new StorageError("join failed");
660
+ throw new StorageError("join failed", { cause: err });
650
661
  }
651
662
  }
652
663
  if (membership === "join")
@@ -664,7 +675,7 @@ export async function joinVault(storage, vaultId, options) {
664
675
  }
665
676
  if (signal.aborted)
666
677
  throw new StorageError("operation cancelled");
667
- throw new StorageError("join failed");
678
+ throw new StorageError("join failed", { cause: err });
668
679
  }
669
680
  ensureOperationActive(signal);
670
681
  return { vaultId, joined: true };
@@ -700,8 +711,6 @@ export async function listPendingInvites(storage, options) {
700
711
  return withOperationDeadline(options, async (signal) => {
701
712
  const client = storage.getClient();
702
713
  const rooms = client.getRooms();
703
- if (rooms.length > MAX_LIST_ITEMS)
704
- throw new StorageError("invite list is too large");
705
714
  const invites = [];
706
715
  for (const room of rooms) {
707
716
  ensureOperationActive(signal);
@@ -820,7 +829,7 @@ export async function declineInvite(storage, vaultId, options) {
820
829
  throw error;
821
830
  if (signal.aborted)
822
831
  throw new StorageError("operation cancelled");
823
- throw new StorageError("decline failed");
832
+ throw new StorageError("decline failed", { cause: error });
824
833
  }
825
834
  ensureOperationActive(signal);
826
835
  return { vaultId, declined: true };
@@ -893,7 +902,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
893
902
  if (error instanceof MutationOutcomeUnknownError)
894
903
  throw error;
895
904
  if (completedRoomIds.size > 0) {
896
- throw new MutationPartialError("share", [...completedRoomIds], safePartialDetail(error));
905
+ throw new MutationPartialError("share", [...completedRoomIds], safePartialDetail(error), { cause: error });
897
906
  }
898
907
  if (error instanceof StorageError &&
899
908
  (error.message === "share failed" ||
@@ -902,7 +911,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
902
911
  error.message === "operation cancelled")) {
903
912
  throw error;
904
913
  }
905
- throw new StorageError("share failed");
914
+ throw new StorageError("share failed", { cause: error });
906
915
  }
907
916
  return { vaultId, userId, role };
908
917
  }, operation.signal);
@@ -968,7 +977,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
968
977
  if (error instanceof MutationOutcomeUnknownError)
969
978
  throw error;
970
979
  if (completedRoomIds.size > 0) {
971
- throw new MutationPartialError("unshare", [...completedRoomIds], safePartialDetail(error));
980
+ throw new MutationPartialError("unshare", [...completedRoomIds], safePartialDetail(error), { cause: error });
972
981
  }
973
982
  if (error instanceof StorageError &&
974
983
  (error.message.includes("owner") ||
@@ -976,7 +985,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
976
985
  error.message === "operation cancelled")) {
977
986
  throw error;
978
987
  }
979
- throw new StorageError("unshare failed");
988
+ throw new StorageError("unshare failed", { cause: error });
980
989
  }
981
990
  return { vaultId, userId, removed: true };
982
991
  }, operation.signal);
@@ -992,10 +1001,10 @@ export async function listMembers(storage, vaultId, options) {
992
1001
  try {
993
1002
  return await withRateLimitRetry(() => storage.listMembers(tree, { signal }), signal);
994
1003
  }
995
- catch {
1004
+ catch (error) {
996
1005
  if (signal.aborted)
997
1006
  throw new StorageError("operation cancelled");
998
- throw new StorageError("list members failed");
1007
+ throw new StorageError("list members failed", { cause: error });
999
1008
  }
1000
1009
  });
1001
1010
  }
@@ -1008,16 +1017,12 @@ export async function listFiles(storage, treeId, options) {
1008
1017
  const files = tree
1009
1018
  .listFiles()
1010
1019
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
1011
- if (files.length > MAX_LIST_ITEMS)
1012
- throw new StorageError("file list is too large");
1013
1020
  return files.map((f) => ({ id: f.id, name: f.getName() }));
1014
1021
  }
1015
1022
  catch (error) {
1016
1023
  if (signal.aborted)
1017
1024
  throw new StorageError("operation cancelled");
1018
- if (error instanceof StorageError && error.message === "file list is too large")
1019
- throw error;
1020
- throw new StorageError("list files failed");
1025
+ throw new StorageError("list files failed", { cause: error });
1021
1026
  }
1022
1027
  });
1023
1028
  }
@@ -1027,11 +1032,8 @@ export async function listSubfolders(storage, parentId, options) {
1027
1032
  const tree = await resolveTree(storage, parentId, signal);
1028
1033
  await storage.refreshRoomState(parentId, { signal });
1029
1034
  ensureOperationActive(signal);
1030
- const directories = tree
1031
- .getDirectories()
1035
+ const directories = activeTreeDirectories(storage.getClient(), tree)
1032
1036
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
1033
- if (directories.length > MAX_LIST_ITEMS)
1034
- throw new StorageError("folder list is too large");
1035
1037
  return directories.map((d) => ({ id: d.id, name: d.room.name }));
1036
1038
  });
1037
1039
  }
@@ -1044,16 +1046,12 @@ export async function createSubfolder(storage, parentId, name, options) {
1044
1046
  sub = await withRateLimitRetry(() => storage.createSubtree(tree, name, signal), signal);
1045
1047
  }
1046
1048
  catch (error) {
1047
- if (error instanceof RoomCleanupIncompleteError ||
1048
- error instanceof RoomCreationAmbiguousError ||
1049
- error instanceof MutationOutcomeUnknownError ||
1050
- (error instanceof Error &&
1051
- error.cleanupIncomplete === true)) {
1049
+ if (requiresMutationReconciliation(error)) {
1052
1050
  throw error;
1053
1051
  }
1054
1052
  if (signal.aborted)
1055
1053
  throw new StorageError("operation cancelled");
1056
- throw new StorageError("create folder failed");
1054
+ throw new StorageError("create folder failed", { cause: error });
1057
1055
  }
1058
1056
  ensureOperationActive(signal);
1059
1057
  return { id: sub.id, name };
@@ -1071,12 +1069,7 @@ async function renameTree(storage, treeId, name, options) {
1071
1069
  const current = storage.getTree(treeId);
1072
1070
  if (current?.room.name === name)
1073
1071
  return current;
1074
- try {
1075
- await storage.refreshRoomState(treeId, { signal });
1076
- }
1077
- catch {
1078
- return null;
1079
- }
1072
+ await storage.refreshRoomState(treeId, { signal });
1080
1073
  const refreshed = storage.getTree(treeId);
1081
1074
  return refreshed?.room.name === name ? refreshed : null;
1082
1075
  }, { timeoutMs: 15000, signal });
@@ -1086,7 +1079,7 @@ async function renameTree(storage, treeId, name, options) {
1086
1079
  throw error;
1087
1080
  if (signal.aborted)
1088
1081
  throw new StorageError("operation cancelled");
1089
- throw new StorageError("rename failed");
1082
+ throw new StorageError("rename failed", { cause: error });
1090
1083
  }
1091
1084
  return { id: treeId, name };
1092
1085
  }, "mutation");
@@ -1119,7 +1112,7 @@ async function deleteTree(storage, treeId, options) {
1119
1112
  // part of this operation and must not block deletion merely because
1120
1113
  // their membership is not joined.
1121
1114
  await refreshDeletionRooms(storage, tree.id, operation.signal);
1122
- const spaces = snapshotTreeSpaces(tree, "delete graph is too large");
1115
+ const spaces = snapshotTreeSpaces(tree, "delete graph is too large", client);
1123
1116
  const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
1124
1117
  const ids = new Set(activeSpaces.map((space) => space.id));
1125
1118
  if (!ids.has(tree.id)) {
@@ -1132,13 +1125,9 @@ async function deleteTree(storage, treeId, options) {
1132
1125
  graph = validateDeletionGraph(client, tree, ids, removedRooms);
1133
1126
  }
1134
1127
  catch (error) {
1135
- if (error instanceof MutationOutcomeUnknownError ||
1136
- error instanceof MutationPartialError ||
1137
- error instanceof NonEmptyTreeError)
1138
- throw error;
1139
1128
  if (error instanceof StorageError)
1140
1129
  throw error;
1141
- throw new StorageError("delete failed");
1130
+ throw new StorageError("delete failed", { cause: error });
1142
1131
  }
1143
1132
  // Keep external parent links intact until the room deletion succeeds.
1144
1133
  // Unlinking first would silently detach a live vault on failure.
@@ -1146,16 +1135,9 @@ async function deleteTree(storage, treeId, options) {
1146
1135
  await unlinkExternalParents(storage, tree.id, graph.externalParents, operation.signal);
1147
1136
  }
1148
1137
  catch (error) {
1149
- if (error instanceof MutationOutcomeUnknownError)
1150
- throw error;
1151
- if (error instanceof MutationPartialError)
1152
- throw error;
1153
- if (error instanceof StorageError && error.message === "delete graph unlink cleanup is incomplete") {
1154
- throw error;
1155
- }
1156
1138
  if (error instanceof StorageError)
1157
1139
  throw error;
1158
- throw new StorageError("delete failed");
1140
+ throw new StorageError("delete failed", { cause: error });
1159
1141
  }
1160
1142
  try {
1161
1143
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
@@ -1167,16 +1149,14 @@ async function deleteTree(storage, treeId, options) {
1167
1149
  try {
1168
1150
  await relinkExternalParents(storage, tree.id, graph.externalParents, new AbortController().signal);
1169
1151
  }
1170
- catch {
1171
- throw new StorageError("delete cleanup is incomplete");
1152
+ catch (cleanupError) {
1153
+ throw new StorageError("delete cleanup is incomplete", {
1154
+ cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
1155
+ });
1172
1156
  }
1173
- if (error instanceof MutationOutcomeUnknownError)
1174
- throw error;
1175
- if (error instanceof MutationPartialError)
1176
- throw error;
1177
1157
  if (error instanceof StorageError)
1178
1158
  throw error;
1179
- throw new StorageError("delete failed");
1159
+ throw new StorageError("delete failed", { cause: error });
1180
1160
  }
1181
1161
  return { id: treeId, deleted: true };
1182
1162
  }, operation.signal);
@@ -1216,7 +1196,7 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1216
1196
  throw error;
1217
1197
  if (signal.aborted)
1218
1198
  throw new StorageError("operation cancelled");
1219
- throw new StorageError("rename file failed");
1199
+ throw new StorageError("rename file failed", { cause: error });
1220
1200
  }
1221
1201
  return { id: fileId, name };
1222
1202
  }, "mutation");
@@ -1235,11 +1215,11 @@ async function resolveFileVersions(branch, signal) {
1235
1215
  try {
1236
1216
  history = await branch.getVersionHistory();
1237
1217
  }
1238
- catch {
1239
- throw new StorageError("could not resolve file version history safely");
1218
+ catch (error) {
1219
+ throw new StorageError("could not resolve file version history safely", { cause: error });
1240
1220
  }
1241
- if (!Array.isArray(history) || history.length === 0 || history.length > MAX_FILE_VERSION_CHAIN) {
1242
- throw new StorageError("file version history is invalid or too large");
1221
+ if (!Array.isArray(history) || history.length === 0) {
1222
+ throw new StorageError("file version history is invalid");
1243
1223
  }
1244
1224
  const eventIds = new Set();
1245
1225
  const mediaIds = new Set();
@@ -1253,8 +1233,8 @@ async function resolveFileVersions(branch, signal) {
1253
1233
  try {
1254
1234
  validateMatrixEventId(version.id, "file version event ID");
1255
1235
  }
1256
- catch {
1257
- throw new StorageError("file version history contains an invalid event ID");
1236
+ catch (error) {
1237
+ throw new StorageError("file version history contains an invalid event ID", { cause: error });
1258
1238
  }
1259
1239
  if (eventIds.has(version.id)) {
1260
1240
  throw new StorageError("file version history contains a cycle");
@@ -1264,17 +1244,14 @@ async function resolveFileVersions(branch, signal) {
1264
1244
  try {
1265
1245
  fileInfo = await version.getFileInfo();
1266
1246
  }
1267
- catch {
1268
- throw new StorageError("could not resolve encrypted file version safely");
1247
+ catch (error) {
1248
+ throw new StorageError("could not resolve encrypted file version safely", { cause: error });
1269
1249
  }
1270
1250
  const mediaId = fileInfo?.info?.url;
1271
1251
  if (typeof mediaId !== "string" || mediaId.length === 0) {
1272
1252
  throw new StorageError("encrypted file version has no media identifier");
1273
1253
  }
1274
1254
  mediaIds.add(mediaId);
1275
- if (mediaIds.size > MAX_FILE_VERSION_CHAIN) {
1276
- throw new StorageError("file media version history is too large");
1277
- }
1278
1255
  versions.push({ branch: version, mediaId });
1279
1256
  }
1280
1257
  return versions;
@@ -1297,7 +1274,7 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1297
1274
  throw error;
1298
1275
  if (signal?.aborted)
1299
1276
  throw new StorageError("operation cancelled");
1300
- throw new StorageError("delete file media failed");
1277
+ throw new StorageError("delete file media failed", { cause: error });
1301
1278
  }
1302
1279
  }
1303
1280
  export async function deleteFile(storage, treeId, fileId, options) {
@@ -1322,9 +1299,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1322
1299
  if (error instanceof MutationOutcomeUnknownError)
1323
1300
  throw error;
1324
1301
  if (signal.aborted) {
1325
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled");
1302
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
1326
1303
  }
1327
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped");
1304
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
1328
1305
  }
1329
1306
  try {
1330
1307
  await waitForCondition(async () => {
@@ -1338,7 +1315,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1338
1315
  if (error instanceof MutationOutcomeUnknownError)
1339
1316
  throw error;
1340
1317
  const detail = "Matrix deletion completed but the inactive file state could not be verified";
1341
- throw new MutationPartialError("delete file", completedIds, detail);
1318
+ throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
1342
1319
  }
1343
1320
  for (const { branch: version } of versions) {
1344
1321
  markFileDeleted(storage.getClient(), tree.id, version.id);
@@ -1357,7 +1334,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1357
1334
  throw error;
1358
1335
  if (signal.aborted)
1359
1336
  throw new StorageError("operation cancelled");
1360
- throw new StorageError("upload failed");
1337
+ throw new StorageError("upload failed", { cause: error });
1361
1338
  });
1362
1339
  // The create-file request is acknowledged before the event necessarily
1363
1340
  // arrives in this client's sync timeline. A caller can otherwise report a
@@ -1376,7 +1353,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1376
1353
  catch (error) {
1377
1354
  if (error instanceof MutationOutcomeUnknownError)
1378
1355
  throw error;
1379
- throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally");
1356
+ throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error });
1380
1357
  }
1381
1358
  ensureOperationActive(signal);
1382
1359
  return { id: fileId, name, mimetype };
@@ -1391,11 +1368,11 @@ export async function downloadFile(storage, treeId, fileId, options) {
1391
1368
  result = await storage.downloadFile(branch, signal);
1392
1369
  }
1393
1370
  catch (error) {
1394
- if (error instanceof UndecryptableFileError)
1371
+ if (error instanceof StorageError)
1395
1372
  throw error;
1396
1373
  if (signal.aborted)
1397
1374
  throw new StorageError("operation cancelled");
1398
- throw new StorageError("download failed");
1375
+ throw new StorageError("download failed", { cause: error });
1399
1376
  }
1400
1377
  ensureOperationActive(signal);
1401
1378
  return {
@@ -1425,41 +1402,21 @@ export async function getFileDetails(storage, treeId, fileId, options) {
1425
1402
  let size = null;
1426
1403
  let createdAt = null;
1427
1404
  let updatedAt = null;
1428
- try {
1429
- ensureOperationActive(signal);
1430
- const { info } = await branch.getFileInfo();
1431
- if (info) {
1432
- if (typeof info["mimetype"] === "string")
1433
- mimetype = info["mimetype"];
1434
- if (typeof info["size"] === "number")
1435
- size = info["size"];
1436
- }
1437
- }
1438
- catch {
1439
- if (signal.aborted)
1440
- throw new StorageError("operation cancelled");
1441
- // Partial metadata is fine — UI shows "—" for unknown fields.
1442
- }
1443
1405
  try {
1444
1406
  ensureOperationActive(signal);
1445
1407
  const event = await branch.getFileEvent();
1446
- const content = event.getContent();
1447
- const infoBlock = content["info"];
1448
- if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
1449
- mimetype = infoBlock["mimetype"];
1450
- }
1451
- if (size == null && typeof infoBlock?.["size"] === "number") {
1452
- size = infoBlock["size"];
1453
- }
1454
- const eventAny = event;
1455
- const ts = eventAny.getTs?.() ?? eventAny.origin_server_ts;
1456
- createdAt = tsToIso(ts);
1408
+ const metadata = readFileEventMetadata(event.getContent());
1409
+ mimetype = metadata.mimetype;
1410
+ size = metadata.size;
1411
+ createdAt = tsToIso(event.getTs());
1457
1412
  updatedAt = createdAt;
1458
1413
  }
1459
- catch {
1414
+ catch (error) {
1460
1415
  if (signal.aborted)
1461
1416
  throw new StorageError("operation cancelled");
1462
- // Same as above.
1417
+ if (error instanceof StorageError)
1418
+ throw error;
1419
+ throw new StorageError("get file details failed", { cause: error });
1463
1420
  }
1464
1421
  ensureOperationActive(signal);
1465
1422
  return { name, mimetype, size, createdAt, updatedAt };
@@ -1479,8 +1436,8 @@ async function getTreeDetails(storage, treeId, signal) {
1479
1436
  try {
1480
1437
  memberCount = room.getJoinedMemberCount();
1481
1438
  }
1482
- catch {
1483
- memberCount = null;
1439
+ catch (error) {
1440
+ throw new StorageError("member count lookup failed", { cause: error });
1484
1441
  }
1485
1442
  }
1486
1443
  return {