@telecrypt-io/storage 0.5.27 → 0.5.29

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,15 +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
- const DELETION_REFRESH_CONCURRENCY = 8;
36
35
  const MAX_DELETION_DEPTH = 128;
37
- const MAX_FILE_VERSION_CHAIN = 128;
38
- const MAX_LIST_ITEMS = 10000;
39
36
  const DEFAULT_MUTATION_TIMEOUT_MS = 5 * 60_000;
40
37
  const MAX_MUTATION_TIMEOUT_MS = 15 * 60_000;
41
38
  function createOperationDeadline(options) {
@@ -80,24 +77,17 @@ async function raceOperationDeadline(deadline, pending, kind) {
80
77
  finally {
81
78
  if (onAbort)
82
79
  deadline.signal.removeEventListener("abort", onAbort);
83
- void pending.catch(() => undefined);
84
80
  }
85
81
  }
86
82
  async function withOperationDeadline(options, operation, kind = "read") {
87
83
  const deadline = createOperationDeadline(options);
88
- let pending;
89
84
  try {
90
85
  ensureOperationActive(deadline.signal);
91
- pending = Promise.resolve().then(() => operation(deadline.signal));
86
+ const pending = Promise.resolve().then(() => operation(deadline.signal));
92
87
  return await raceOperationDeadline(deadline, pending, kind);
93
88
  }
94
89
  finally {
95
90
  deadline.close();
96
- // A caller-facing deadline must not wait for a broken provider, but the
97
- // late result remains observed so an ignored AbortSignal cannot create an
98
- // unhandled rejection.
99
- if (pending)
100
- void pending.catch(() => undefined);
101
91
  }
102
92
  }
103
93
  function isRateLimited(error) {
@@ -108,16 +98,28 @@ function isRateLimited(error) {
108
98
  const isRateLimitError = error.isRateLimitError;
109
99
  return typeof isRateLimitError === "function" && isRateLimitError.call(error) === true;
110
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
+ }
111
108
  async function withRateLimitRetry(operation, signal) {
112
- let totalDelay = 0;
113
- for (let attempt = 0;; attempt++) {
109
+ for (;;) {
114
110
  if (signal?.aborted)
115
111
  throw new StorageError("operation cancelled");
116
112
  try {
117
113
  return await operation();
118
114
  }
119
115
  catch (error) {
120
- 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))
121
123
  throw error;
122
124
  // The server's retry_after_ms only covers one token; tree operations
123
125
  // need several (kick + leave + forget). Wait at least 15s so the
@@ -132,12 +134,7 @@ async function withRateLimitRetry(operation, signal) {
132
134
  }
133
135
  }
134
136
  }
135
- retryAfter = Math.min(retryAfter, RATE_LIMIT_MAX_DELAY_MS);
136
- const remaining = RATE_LIMIT_MAX_TOTAL_DELAY_MS - totalDelay;
137
- if (remaining <= 0)
138
- throw error;
139
- retryAfter = Math.min(retryAfter, remaining);
140
- totalDelay += retryAfter;
137
+ retryAfter = Math.min(retryAfter, MAX_TIMER_DELAY_MS);
141
138
  await new Promise((resolve, reject) => {
142
139
  let timer;
143
140
  const onAbort = () => {
@@ -170,10 +167,12 @@ async function resolveTree(storage, treeId, signal) {
170
167
  signal,
171
168
  });
172
169
  }
173
- catch {
170
+ catch (error) {
174
171
  if (signal?.aborted)
175
172
  throw new StorageError("operation cancelled");
176
- 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 });
177
176
  }
178
177
  }
179
178
  function isMarkedTreeDeleted(storage, treeId) {
@@ -198,10 +197,12 @@ async function resolveFile(storage, tree, fileId, signal) {
198
197
  try {
199
198
  return await waitForCondition(() => (isMarkedFileDeleted(storage, tree.id, fileId) ? null : tree.getFile(fileId)), { timeoutMs: 15000, signal });
200
199
  }
201
- catch {
200
+ catch (error) {
202
201
  if (signal?.aborted)
203
202
  throw new StorageError("operation cancelled");
204
- throw new StorageError("file not found");
203
+ if (error instanceof ConditionTimeoutError)
204
+ throw new StorageError("file not found");
205
+ throw new StorageError("file lookup failed", { cause: error });
205
206
  }
206
207
  }
207
208
  function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large") {
@@ -224,8 +225,8 @@ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large")
224
225
  try {
225
226
  children = tree.getDirectories();
226
227
  }
227
- catch {
228
- throw new StorageError("could not enumerate storage space descendants safely");
228
+ catch (error) {
229
+ throw new StorageError("could not enumerate storage space descendants safely", { cause: error });
229
230
  }
230
231
  if (children.length > MAX_DELETION_ROOMS) {
231
232
  throw new StorageError(tooLargeMessage);
@@ -251,13 +252,13 @@ function assertTreeEmptyForDeletion(storage, tree, spaces) {
251
252
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id) &&
252
253
  !isConfirmedDeletedFile(storage.getClient(), tree.id, file));
253
254
  }
254
- catch {
255
- throw new StorageError("could not enumerate storage files safely");
255
+ catch (error) {
256
+ throw new StorageError("could not enumerate storage files safely", { cause: error });
256
257
  }
257
258
  if (files.length > 0)
258
259
  throw new NonEmptyTreeError(tree.id);
259
260
  }
260
- async function refreshDeletionRooms(storage, signal) {
261
+ async function refreshDeletionRooms(storage, rootId, signal) {
261
262
  const client = storage.getClient();
262
263
  const http = client.http;
263
264
  // A real MatrixClient always exposes its authenticated HTTP transport. A
@@ -265,48 +266,31 @@ async function refreshDeletionRooms(storage, signal) {
265
266
  if (typeof http?.authedRequest !== "function") {
266
267
  throw new StorageError("delete graph refresh is unavailable");
267
268
  }
268
- const roomIds = await storage.listJoinedRoomIds({ signal });
269
- if (roomIds.length > MAX_DELETION_ROOMS)
270
- throw new StorageError("delete graph is too large");
271
- const joined = new Set(roomIds);
272
- const roomIdsToRefresh = [];
273
- // Matrix has no bounded endpoint that enumerates every invited/non-joined
274
- // room carrying a space relation. Refresh every locally visible room and
275
- // fail closed for an invite/knock/unknown membership; a joined external
276
- // parent remains inspectable and is validated by validateDeletionGraph.
277
- for (const room of client.getRooms()) {
278
- if (!joined.has(room.roomId)) {
279
- const membership = room.getMyMembership?.();
280
- if (membership !== "join")
281
- throw new StorageError("delete graph inventory is incomplete");
282
- roomIdsToRefresh.push(room.roomId);
283
- }
269
+ await storage.refreshRoomState(rootId, { signal });
270
+ if (signal?.aborted)
271
+ throw new StorageError("operation cancelled");
272
+ // The refreshed root owns its parent relation. Verify its one supported
273
+ // external parent directly; unrelated local rooms are outside this deletion.
274
+ const parentEvents = readRelationEvents(client, rootId, EventType.SpaceParent);
275
+ if (parentEvents === null)
276
+ throw new StorageError("delete graph is unsafe");
277
+ const parentIds = new Set();
278
+ for (const event of parentEvents) {
279
+ if (!isActiveRelationEvent(event))
280
+ continue;
281
+ const parentId = relationStateKey(event);
282
+ if (!parentId)
283
+ throw new StorageError("delete graph is unsafe");
284
+ if (parentId !== rootId)
285
+ parentIds.add(parentId);
284
286
  }
285
- roomIdsToRefresh.push(...roomIds);
286
- let nextRoom = 0;
287
- let failed = false;
288
- let firstError;
289
- const refreshWorker = async () => {
290
- while (!failed && !signal?.aborted) {
291
- const roomId = roomIdsToRefresh[nextRoom++];
292
- if (roomId === undefined)
293
- return;
294
- try {
295
- await storage.refreshRoomState(roomId, { signal });
296
- }
297
- catch (error) {
298
- failed = true;
299
- firstError = error;
300
- return;
301
- }
302
- }
303
- };
304
- const workerCount = Math.min(DELETION_REFRESH_CONCURRENCY, roomIdsToRefresh.length);
305
- await Promise.all(Array.from({ length: workerCount }, () => refreshWorker()));
287
+ if (parentIds.size > 1)
288
+ throw new StorageError("delete graph is unsafe");
289
+ const parentId = parentIds.values().next().value;
290
+ if (parentId)
291
+ await storage.refreshRoomState(parentId, { signal });
306
292
  if (signal?.aborted)
307
293
  throw new StorageError("operation cancelled");
308
- if (failed)
309
- throw firstError;
310
294
  }
311
295
  async function refreshTreeSpaces(storage, spaces, signal) {
312
296
  for (const space of spaces) {
@@ -378,8 +362,8 @@ function validateDeletionGraph(client, root, ids, deletedRooms) {
378
362
  try {
379
363
  directories = tree.getDirectories();
380
364
  }
381
- catch {
382
- throw new StorageError("delete graph is unsafe");
365
+ catch (error) {
366
+ throw new StorageError("delete graph is unsafe", { cause: error });
383
367
  }
384
368
  for (const child of directories)
385
369
  addEdge(id, child.id);
@@ -420,31 +404,11 @@ function validateDeletionGraph(client, root, ids, deletedRooms) {
420
404
  if (id !== root.id || externalParents.includes(parentId)) {
421
405
  throw new StorageError("delete graph is unsafe");
422
406
  }
423
- externalParents.push(parentId);
424
- }
425
- }
426
- }
427
- const rooms = client.getRooms();
428
- if (rooms.length > MAX_DELETION_ROOMS)
429
- throw new StorageError("delete graph is too large");
430
- for (const room of rooms) {
431
- const parentId = room.roomId;
432
- const childEvents = readRelationEvents(client, parentId, EventType.SpaceChild);
433
- if (childEvents === null)
434
- throw new StorageError("delete graph is unsafe");
435
- for (const event of childEvents) {
436
- if (!isActiveRelationEvent(event))
437
- continue;
438
- const childId = relationStateKey(event);
439
- if (!childId)
440
- throw new StorageError("delete graph is unsafe");
441
- if (ids.has(childId) && !ids.has(parentId)) {
442
- if (childId === root.id) {
443
- if (!externalParents.includes(parentId))
444
- externalParents.push(parentId);
445
- continue;
407
+ const childLink = readRelationEvents(client, parentId, EventType.SpaceChild, id);
408
+ if (!childLink?.some((candidate) => isActiveRelationEvent(candidate) && relationStateKey(candidate, id) === id)) {
409
+ throw new StorageError("delete graph is unsafe");
446
410
  }
447
- throw new StorageError("delete graph is unsafe");
411
+ externalParents.push(parentId);
448
412
  }
449
413
  }
450
414
  }
@@ -493,8 +457,10 @@ async function unlinkExternalParents(storage, rootId, externalParents, signal) {
493
457
  try {
494
458
  await relinkExternalParents(storage, rootId, externalParents, new AbortController().signal);
495
459
  }
496
- catch {
497
- throw new StorageError("delete graph unlink cleanup is incomplete");
460
+ catch (cleanupError) {
461
+ throw new StorageError("delete graph unlink cleanup is incomplete", {
462
+ cause: new AggregateError([error, cleanupError], "delete graph unlink and relink both failed", { cause: error }),
463
+ });
498
464
  }
499
465
  throw error;
500
466
  }
@@ -524,60 +490,78 @@ function isGoneError(error) {
524
490
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
525
491
  }
526
492
  async function deleteRoomDeterministically(storage, roomId, signal) {
527
- const client = storage.getClient();
528
- const tree = storage.getTree(roomId);
529
- if (!tree)
530
- throw new StorageError("delete graph is unsafe");
531
- const self = client.getUserId();
532
- if (!self)
533
- throw new StorageError("delete graph is unsafe");
534
- const members = await storage.listMembers(tree, { signal });
535
- if (members.some((member) => member.userId !== self && member.role === "owner" &&
536
- (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
537
- throw new StorageError("delete will not kick another room owner");
538
- }
539
- for (const member of members) {
540
- const membership = member.membership;
541
- if (member.userId === self ||
542
- (membership !== "join" && membership !== "invite" && membership !== "knock")) {
543
- continue;
544
- }
545
- try {
546
- await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.kick(roomId, member.userId, "Room deleted"), signal), signal);
547
- }
548
- catch (error) {
549
- if (isGoneError(error))
493
+ const completedRoomIds = [];
494
+ const markRoomMutationComplete = () => {
495
+ if (!completedRoomIds.includes(roomId))
496
+ completedRoomIds.push(roomId);
497
+ };
498
+ try {
499
+ const client = storage.getClient();
500
+ const tree = storage.getTree(roomId);
501
+ if (!tree)
502
+ throw new StorageError("delete graph is unsafe");
503
+ const self = client.getUserId();
504
+ if (!self)
505
+ throw new StorageError("delete graph is unsafe");
506
+ const members = await storage.listMembers(tree, { signal });
507
+ if (members.some((member) => member.userId !== self && member.role === "owner" &&
508
+ (member.membership === "join" || member.membership === "invite" || member.membership === "knock"))) {
509
+ throw new StorageError("delete will not kick another room owner");
510
+ }
511
+ for (const member of members) {
512
+ const membership = member.membership;
513
+ if (member.userId === self ||
514
+ (membership !== "join" && membership !== "invite" && membership !== "knock")) {
550
515
  continue;
551
- if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
552
- const currentMembership = await storage.getRoomMembership(roomId, member.userId, { signal });
553
- if (currentMembership === "leave" ||
554
- currentMembership === "ban" ||
555
- !currentMembership) {
516
+ }
517
+ try {
518
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.kick(roomId, member.userId, "Room deleted"), signal), signal);
519
+ markRoomMutationComplete();
520
+ }
521
+ catch (error) {
522
+ if (isGoneError(error))
556
523
  continue;
524
+ if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
525
+ const currentMembership = await storage.getRoomMembership(roomId, member.userId, { signal });
526
+ if (currentMembership === "leave" ||
527
+ currentMembership === "ban" ||
528
+ !currentMembership) {
529
+ continue;
530
+ }
557
531
  }
532
+ throw error;
533
+ }
534
+ }
535
+ const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
536
+ if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
537
+ try {
538
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.leave(roomId), signal), signal);
539
+ markRoomMutationComplete();
540
+ }
541
+ catch (error) {
542
+ if (!isGoneError(error))
543
+ throw error;
558
544
  }
559
- throw error;
560
545
  }
561
- }
562
- const ownMembership = await storage.getRoomMembership(roomId, undefined, { signal });
563
- if (ownMembership === "join" || ownMembership === "invite" || ownMembership === "knock") {
564
546
  try {
565
- await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.leave(roomId), signal), signal);
547
+ await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.forget(roomId), signal), signal);
548
+ markRoomMutationComplete();
566
549
  }
567
550
  catch (error) {
568
551
  if (!isGoneError(error))
569
552
  throw error;
570
553
  }
571
- }
572
- try {
573
- await withRateLimitRetry(() => withMatrixMutationAbort(client, () => client.forget(roomId), signal), signal);
554
+ removeRoomFromLocalStore(client, roomId);
555
+ markTreeDeleted(client, roomId);
574
556
  }
575
557
  catch (error) {
576
- if (!isGoneError(error))
558
+ if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
577
559
  throw error;
560
+ if (completedRoomIds.length > 0) {
561
+ throw new MutationPartialError("delete", completedRoomIds, "room cleanup stopped", { cause: error });
562
+ }
563
+ throw error;
578
564
  }
579
- removeRoomFromLocalStore(client, roomId);
580
- markTreeDeleted(client, roomId);
581
565
  }
582
566
  /**
583
567
  * The SDK normally evicts a room as part of forget(). Keep the local-store
@@ -585,14 +569,7 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
585
569
  * may otherwise leave a stale invite visible until the next sync.
586
570
  */
587
571
  function removeRoomFromLocalStore(client, roomId) {
588
- try {
589
- client.store?.removeRoom(roomId);
590
- }
591
- catch {
592
- // Server-side leave/forget already completed; a store implementation that
593
- // cannot evict synchronously must not turn authoritative cleanup into a
594
- // false failure.
595
- }
572
+ client.store?.removeRoom(roomId);
596
573
  }
597
574
  function isActiveMembership(membership) {
598
575
  return membership === "join" || membership === "invite";
@@ -602,12 +579,11 @@ function isRevocableMembership(membership) {
602
579
  }
603
580
  function safePartialDetail(error) {
604
581
  // Provider/Matrix errors can contain response bodies, URLs, or credentials.
605
- // StorageError messages are authored by this package and are already
606
- // bounded; retain those local policy details so callers still get useful
607
- // owner/self-target diagnostics.
582
+ // StorageError messages are authored by this package; retain those local
583
+ // policy details so callers still get useful owner/self-target diagnostics.
608
584
  if (!(error instanceof StorageError) || error instanceof MutationOutcomeUnknownError)
609
585
  return undefined;
610
- return error.message.length <= 256 ? error.message : undefined;
586
+ return error.message;
611
587
  }
612
588
  function toArrayBuffer(bytes) {
613
589
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
@@ -619,16 +595,12 @@ export async function createVault(storage, name, options) {
619
595
  tree = await withRateLimitRetry(() => storage.createTree(name, signal), signal);
620
596
  }
621
597
  catch (error) {
622
- if (error instanceof RoomCleanupIncompleteError ||
623
- error instanceof RoomCreationAmbiguousError ||
624
- error instanceof MutationOutcomeUnknownError ||
625
- (error instanceof Error &&
626
- error.cleanupIncomplete === true)) {
598
+ if (requiresMutationReconciliation(error)) {
627
599
  throw error;
628
600
  }
629
601
  if (signal.aborted)
630
602
  throw new StorageError("operation cancelled");
631
- throw new StorageError("create vault failed");
603
+ throw new StorageError("create vault failed", { cause: error });
632
604
  }
633
605
  ensureOperationActive(signal);
634
606
  return { id: tree.id, name };
@@ -666,7 +638,7 @@ export async function joinVault(storage, vaultId, options) {
666
638
  membership = null;
667
639
  }
668
640
  else {
669
- throw new StorageError("join failed");
641
+ throw new StorageError("join failed", { cause: err });
670
642
  }
671
643
  }
672
644
  if (membership === "join")
@@ -684,7 +656,7 @@ export async function joinVault(storage, vaultId, options) {
684
656
  }
685
657
  if (signal.aborted)
686
658
  throw new StorageError("operation cancelled");
687
- throw new StorageError("join failed");
659
+ throw new StorageError("join failed", { cause: err });
688
660
  }
689
661
  ensureOperationActive(signal);
690
662
  return { vaultId, joined: true };
@@ -720,8 +692,6 @@ export async function listPendingInvites(storage, options) {
720
692
  return withOperationDeadline(options, async (signal) => {
721
693
  const client = storage.getClient();
722
694
  const rooms = client.getRooms();
723
- if (rooms.length > MAX_LIST_ITEMS)
724
- throw new StorageError("invite list is too large");
725
695
  const invites = [];
726
696
  for (const room of rooms) {
727
697
  ensureOperationActive(signal);
@@ -840,7 +810,7 @@ export async function declineInvite(storage, vaultId, options) {
840
810
  throw error;
841
811
  if (signal.aborted)
842
812
  throw new StorageError("operation cancelled");
843
- throw new StorageError("decline failed");
813
+ throw new StorageError("decline failed", { cause: error });
844
814
  }
845
815
  ensureOperationActive(signal);
846
816
  return { vaultId, declined: true };
@@ -913,7 +883,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
913
883
  if (error instanceof MutationOutcomeUnknownError)
914
884
  throw error;
915
885
  if (completedRoomIds.size > 0) {
916
- throw new MutationPartialError("share", [...completedRoomIds], safePartialDetail(error));
886
+ throw new MutationPartialError("share", [...completedRoomIds], safePartialDetail(error), { cause: error });
917
887
  }
918
888
  if (error instanceof StorageError &&
919
889
  (error.message === "share failed" ||
@@ -922,7 +892,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
922
892
  error.message === "operation cancelled")) {
923
893
  throw error;
924
894
  }
925
- throw new StorageError("share failed");
895
+ throw new StorageError("share failed", { cause: error });
926
896
  }
927
897
  return { vaultId, userId, role };
928
898
  }, operation.signal);
@@ -988,7 +958,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
988
958
  if (error instanceof MutationOutcomeUnknownError)
989
959
  throw error;
990
960
  if (completedRoomIds.size > 0) {
991
- throw new MutationPartialError("unshare", [...completedRoomIds], safePartialDetail(error));
961
+ throw new MutationPartialError("unshare", [...completedRoomIds], safePartialDetail(error), { cause: error });
992
962
  }
993
963
  if (error instanceof StorageError &&
994
964
  (error.message.includes("owner") ||
@@ -996,7 +966,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
996
966
  error.message === "operation cancelled")) {
997
967
  throw error;
998
968
  }
999
- throw new StorageError("unshare failed");
969
+ throw new StorageError("unshare failed", { cause: error });
1000
970
  }
1001
971
  return { vaultId, userId, removed: true };
1002
972
  }, operation.signal);
@@ -1012,10 +982,10 @@ export async function listMembers(storage, vaultId, options) {
1012
982
  try {
1013
983
  return await withRateLimitRetry(() => storage.listMembers(tree, { signal }), signal);
1014
984
  }
1015
- catch {
985
+ catch (error) {
1016
986
  if (signal.aborted)
1017
987
  throw new StorageError("operation cancelled");
1018
- throw new StorageError("list members failed");
988
+ throw new StorageError("list members failed", { cause: error });
1019
989
  }
1020
990
  });
1021
991
  }
@@ -1028,16 +998,12 @@ export async function listFiles(storage, treeId, options) {
1028
998
  const files = tree
1029
999
  .listFiles()
1030
1000
  .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
1031
- if (files.length > MAX_LIST_ITEMS)
1032
- throw new StorageError("file list is too large");
1033
1001
  return files.map((f) => ({ id: f.id, name: f.getName() }));
1034
1002
  }
1035
1003
  catch (error) {
1036
1004
  if (signal.aborted)
1037
1005
  throw new StorageError("operation cancelled");
1038
- if (error instanceof StorageError && error.message === "file list is too large")
1039
- throw error;
1040
- throw new StorageError("list files failed");
1006
+ throw new StorageError("list files failed", { cause: error });
1041
1007
  }
1042
1008
  });
1043
1009
  }
@@ -1050,8 +1016,6 @@ export async function listSubfolders(storage, parentId, options) {
1050
1016
  const directories = tree
1051
1017
  .getDirectories()
1052
1018
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
1053
- if (directories.length > MAX_LIST_ITEMS)
1054
- throw new StorageError("folder list is too large");
1055
1019
  return directories.map((d) => ({ id: d.id, name: d.room.name }));
1056
1020
  });
1057
1021
  }
@@ -1064,16 +1028,12 @@ export async function createSubfolder(storage, parentId, name, options) {
1064
1028
  sub = await withRateLimitRetry(() => storage.createSubtree(tree, name, signal), signal);
1065
1029
  }
1066
1030
  catch (error) {
1067
- if (error instanceof RoomCleanupIncompleteError ||
1068
- error instanceof RoomCreationAmbiguousError ||
1069
- error instanceof MutationOutcomeUnknownError ||
1070
- (error instanceof Error &&
1071
- error.cleanupIncomplete === true)) {
1031
+ if (requiresMutationReconciliation(error)) {
1072
1032
  throw error;
1073
1033
  }
1074
1034
  if (signal.aborted)
1075
1035
  throw new StorageError("operation cancelled");
1076
- throw new StorageError("create folder failed");
1036
+ throw new StorageError("create folder failed", { cause: error });
1077
1037
  }
1078
1038
  ensureOperationActive(signal);
1079
1039
  return { id: sub.id, name };
@@ -1091,12 +1051,7 @@ async function renameTree(storage, treeId, name, options) {
1091
1051
  const current = storage.getTree(treeId);
1092
1052
  if (current?.room.name === name)
1093
1053
  return current;
1094
- try {
1095
- await storage.refreshRoomState(treeId, { signal });
1096
- }
1097
- catch {
1098
- return null;
1099
- }
1054
+ await storage.refreshRoomState(treeId, { signal });
1100
1055
  const refreshed = storage.getTree(treeId);
1101
1056
  return refreshed?.room.name === name ? refreshed : null;
1102
1057
  }, { timeoutMs: 15000, signal });
@@ -1106,7 +1061,7 @@ async function renameTree(storage, treeId, name, options) {
1106
1061
  throw error;
1107
1062
  if (signal.aborted)
1108
1063
  throw new StorageError("operation cancelled");
1109
- throw new StorageError("rename failed");
1064
+ throw new StorageError("rename failed", { cause: error });
1110
1065
  }
1111
1066
  return { id: treeId, name };
1112
1067
  }, "mutation");
@@ -1134,10 +1089,11 @@ async function deleteTree(storage, treeId, options) {
1134
1089
  const removedRooms = getDeletedTreeIds(client);
1135
1090
  let graph;
1136
1091
  try {
1137
- // Re-read every locally visible room before taking the immutable graph
1138
- // snapshot. Local MSC3089 relations and memberships can lag another
1139
- // session; deleting from that stale view can orphan shared descendants.
1140
- await refreshDeletionRooms(storage, operation.signal);
1092
+ // Re-read the candidate graph and any external parents it identifies
1093
+ // before taking the immutable graph snapshot. Unrelated rooms are not
1094
+ // part of this operation and must not block deletion merely because
1095
+ // their membership is not joined.
1096
+ await refreshDeletionRooms(storage, tree.id, operation.signal);
1141
1097
  const spaces = snapshotTreeSpaces(tree, "delete graph is too large");
1142
1098
  const activeSpaces = spaces.filter((space) => !removedRooms.has(space.id));
1143
1099
  const ids = new Set(activeSpaces.map((space) => space.id));
@@ -1151,13 +1107,9 @@ async function deleteTree(storage, treeId, options) {
1151
1107
  graph = validateDeletionGraph(client, tree, ids, removedRooms);
1152
1108
  }
1153
1109
  catch (error) {
1154
- if (error instanceof MutationOutcomeUnknownError ||
1155
- error instanceof MutationPartialError ||
1156
- error instanceof NonEmptyTreeError)
1157
- throw error;
1158
- if (error instanceof StorageError && error.message === "operation cancelled")
1110
+ if (error instanceof StorageError)
1159
1111
  throw error;
1160
- throw new StorageError("delete failed");
1112
+ throw new StorageError("delete failed", { cause: error });
1161
1113
  }
1162
1114
  // Keep external parent links intact until the room deletion succeeds.
1163
1115
  // Unlinking first would silently detach a live vault on failure.
@@ -1165,16 +1117,9 @@ async function deleteTree(storage, treeId, options) {
1165
1117
  await unlinkExternalParents(storage, tree.id, graph.externalParents, operation.signal);
1166
1118
  }
1167
1119
  catch (error) {
1168
- if (error instanceof MutationOutcomeUnknownError)
1169
- throw error;
1170
- if (error instanceof MutationPartialError)
1171
- throw error;
1172
- if (error instanceof StorageError && error.message === "delete graph unlink cleanup is incomplete") {
1173
- throw error;
1174
- }
1175
- if (error instanceof StorageError && error.message === "operation cancelled")
1120
+ if (error instanceof StorageError)
1176
1121
  throw error;
1177
- throw new StorageError("delete failed");
1122
+ throw new StorageError("delete failed", { cause: error });
1178
1123
  }
1179
1124
  try {
1180
1125
  await deleteRoomDeterministically(storage, tree.id, operation.signal);
@@ -1186,16 +1131,14 @@ async function deleteTree(storage, treeId, options) {
1186
1131
  try {
1187
1132
  await relinkExternalParents(storage, tree.id, graph.externalParents, new AbortController().signal);
1188
1133
  }
1189
- catch {
1190
- throw new StorageError("delete cleanup is incomplete");
1134
+ catch (cleanupError) {
1135
+ throw new StorageError("delete cleanup is incomplete", {
1136
+ cause: new AggregateError([error, cleanupError], "delete and external-parent relink both failed", { cause: error }),
1137
+ });
1191
1138
  }
1192
- if (error instanceof MutationOutcomeUnknownError)
1193
- throw error;
1194
- if (error instanceof MutationPartialError)
1195
- throw error;
1196
- if (error instanceof StorageError && error.message === "operation cancelled")
1139
+ if (error instanceof StorageError)
1197
1140
  throw error;
1198
- throw new StorageError("delete failed");
1141
+ throw new StorageError("delete failed", { cause: error });
1199
1142
  }
1200
1143
  return { id: treeId, deleted: true };
1201
1144
  }, operation.signal);
@@ -1235,7 +1178,7 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1235
1178
  throw error;
1236
1179
  if (signal.aborted)
1237
1180
  throw new StorageError("operation cancelled");
1238
- throw new StorageError("rename file failed");
1181
+ throw new StorageError("rename file failed", { cause: error });
1239
1182
  }
1240
1183
  return { id: fileId, name };
1241
1184
  }, "mutation");
@@ -1254,11 +1197,11 @@ async function resolveFileVersions(branch, signal) {
1254
1197
  try {
1255
1198
  history = await branch.getVersionHistory();
1256
1199
  }
1257
- catch {
1258
- throw new StorageError("could not resolve file version history safely");
1200
+ catch (error) {
1201
+ throw new StorageError("could not resolve file version history safely", { cause: error });
1259
1202
  }
1260
- if (!Array.isArray(history) || history.length === 0 || history.length > MAX_FILE_VERSION_CHAIN) {
1261
- throw new StorageError("file version history is invalid or too large");
1203
+ if (!Array.isArray(history) || history.length === 0) {
1204
+ throw new StorageError("file version history is invalid");
1262
1205
  }
1263
1206
  const eventIds = new Set();
1264
1207
  const mediaIds = new Set();
@@ -1272,8 +1215,8 @@ async function resolveFileVersions(branch, signal) {
1272
1215
  try {
1273
1216
  validateMatrixEventId(version.id, "file version event ID");
1274
1217
  }
1275
- catch {
1276
- throw new StorageError("file version history contains an invalid event ID");
1218
+ catch (error) {
1219
+ throw new StorageError("file version history contains an invalid event ID", { cause: error });
1277
1220
  }
1278
1221
  if (eventIds.has(version.id)) {
1279
1222
  throw new StorageError("file version history contains a cycle");
@@ -1283,17 +1226,14 @@ async function resolveFileVersions(branch, signal) {
1283
1226
  try {
1284
1227
  fileInfo = await version.getFileInfo();
1285
1228
  }
1286
- catch {
1287
- throw new StorageError("could not resolve encrypted file version safely");
1229
+ catch (error) {
1230
+ throw new StorageError("could not resolve encrypted file version safely", { cause: error });
1288
1231
  }
1289
1232
  const mediaId = fileInfo?.info?.url;
1290
1233
  if (typeof mediaId !== "string" || mediaId.length === 0) {
1291
1234
  throw new StorageError("encrypted file version has no media identifier");
1292
1235
  }
1293
1236
  mediaIds.add(mediaId);
1294
- if (mediaIds.size > MAX_FILE_VERSION_CHAIN) {
1295
- throw new StorageError("file media version history is too large");
1296
- }
1297
1237
  versions.push({ branch: version, mediaId });
1298
1238
  }
1299
1239
  return versions;
@@ -1316,7 +1256,7 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1316
1256
  throw error;
1317
1257
  if (signal?.aborted)
1318
1258
  throw new StorageError("operation cancelled");
1319
- throw new StorageError("delete file media failed");
1259
+ throw new StorageError("delete file media failed", { cause: error });
1320
1260
  }
1321
1261
  }
1322
1262
  export async function deleteFile(storage, treeId, fileId, options) {
@@ -1341,9 +1281,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1341
1281
  if (error instanceof MutationOutcomeUnknownError)
1342
1282
  throw error;
1343
1283
  if (signal.aborted) {
1344
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled");
1284
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup was cancelled", { cause: error });
1345
1285
  }
1346
- throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped");
1286
+ throw new MutationPartialError("delete file", completedIds, "media was deleted but Matrix event cleanup stopped", { cause: error });
1347
1287
  }
1348
1288
  try {
1349
1289
  await waitForCondition(async () => {
@@ -1357,7 +1297,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1357
1297
  if (error instanceof MutationOutcomeUnknownError)
1358
1298
  throw error;
1359
1299
  const detail = "Matrix deletion completed but the inactive file state could not be verified";
1360
- throw new MutationPartialError("delete file", completedIds, detail);
1300
+ throw new MutationPartialError("delete file", completedIds, detail, { cause: error });
1361
1301
  }
1362
1302
  for (const { branch: version } of versions) {
1363
1303
  markFileDeleted(storage.getClient(), tree.id, version.id);
@@ -1376,7 +1316,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1376
1316
  throw error;
1377
1317
  if (signal.aborted)
1378
1318
  throw new StorageError("operation cancelled");
1379
- throw new StorageError("upload failed");
1319
+ throw new StorageError("upload failed", { cause: error });
1380
1320
  });
1381
1321
  // The create-file request is acknowledged before the event necessarily
1382
1322
  // arrives in this client's sync timeline. A caller can otherwise report a
@@ -1395,7 +1335,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1395
1335
  catch (error) {
1396
1336
  if (error instanceof MutationOutcomeUnknownError)
1397
1337
  throw error;
1398
- throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally");
1338
+ throw new MutationPartialError("upload file", [fileId], "upload completed but the file could not be observed locally", { cause: error });
1399
1339
  }
1400
1340
  ensureOperationActive(signal);
1401
1341
  return { id: fileId, name, mimetype };
@@ -1410,11 +1350,11 @@ export async function downloadFile(storage, treeId, fileId, options) {
1410
1350
  result = await storage.downloadFile(branch, signal);
1411
1351
  }
1412
1352
  catch (error) {
1413
- if (error instanceof UndecryptableFileError)
1353
+ if (error instanceof StorageError)
1414
1354
  throw error;
1415
1355
  if (signal.aborted)
1416
1356
  throw new StorageError("operation cancelled");
1417
- throw new StorageError("download failed");
1357
+ throw new StorageError("download failed", { cause: error });
1418
1358
  }
1419
1359
  ensureOperationActive(signal);
1420
1360
  return {
@@ -1444,41 +1384,21 @@ export async function getFileDetails(storage, treeId, fileId, options) {
1444
1384
  let size = null;
1445
1385
  let createdAt = null;
1446
1386
  let updatedAt = null;
1447
- try {
1448
- ensureOperationActive(signal);
1449
- const { info } = await branch.getFileInfo();
1450
- if (info) {
1451
- if (typeof info["mimetype"] === "string")
1452
- mimetype = info["mimetype"];
1453
- if (typeof info["size"] === "number")
1454
- size = info["size"];
1455
- }
1456
- }
1457
- catch {
1458
- if (signal.aborted)
1459
- throw new StorageError("operation cancelled");
1460
- // Partial metadata is fine — UI shows "—" for unknown fields.
1461
- }
1462
1387
  try {
1463
1388
  ensureOperationActive(signal);
1464
1389
  const event = await branch.getFileEvent();
1465
- const content = event.getContent();
1466
- const infoBlock = content["info"];
1467
- if (!mimetype && typeof infoBlock?.["mimetype"] === "string") {
1468
- mimetype = infoBlock["mimetype"];
1469
- }
1470
- if (size == null && typeof infoBlock?.["size"] === "number") {
1471
- size = infoBlock["size"];
1472
- }
1473
- const eventAny = event;
1474
- const ts = eventAny.getTs?.() ?? eventAny.origin_server_ts;
1475
- createdAt = tsToIso(ts);
1390
+ const metadata = readFileEventMetadata(event.getContent());
1391
+ mimetype = metadata.mimetype;
1392
+ size = metadata.size;
1393
+ createdAt = tsToIso(event.getTs());
1476
1394
  updatedAt = createdAt;
1477
1395
  }
1478
- catch {
1396
+ catch (error) {
1479
1397
  if (signal.aborted)
1480
1398
  throw new StorageError("operation cancelled");
1481
- // Same as above.
1399
+ if (error instanceof StorageError)
1400
+ throw error;
1401
+ throw new StorageError("get file details failed", { cause: error });
1482
1402
  }
1483
1403
  ensureOperationActive(signal);
1484
1404
  return { name, mimetype, size, createdAt, updatedAt };
@@ -1498,8 +1418,8 @@ async function getTreeDetails(storage, treeId, signal) {
1498
1418
  try {
1499
1419
  memberCount = room.getJoinedMemberCount();
1500
1420
  }
1501
- catch {
1502
- memberCount = null;
1421
+ catch (error) {
1422
+ throw new StorageError("member count lookup failed", { cause: error });
1503
1423
  }
1504
1424
  }
1505
1425
  return {