@telecrypt-io/storage 0.5.22 → 0.5.25

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.
@@ -7,6 +7,7 @@ import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError
7
7
  import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_LENGTH, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
8
8
  import { raceWithAbort, readBoundedResponseBody } from "./core/http.js";
9
9
  import { validateName } from "./core/validation.js";
10
+ import { isTreeDeleted } from "./deletion-markers.js";
10
11
  const recoveryMutationQueues = new WeakMap();
11
12
  const treeMutationQueues = new WeakMap();
12
13
  const TREE_SYNC_TIMEOUT_MS = 15000;
@@ -1244,6 +1245,8 @@ export class TeleCryptIOStorage {
1244
1245
  for (const room of rooms) {
1245
1246
  if (signal?.aborted)
1246
1247
  throw new StorageError("operation cancelled");
1248
+ if (isTreeDeleted(this.client, room.roomId))
1249
+ continue;
1247
1250
  const tree = this.client.unstableGetFileTreeSpace(room.roomId);
1248
1251
  if (tree)
1249
1252
  trees.push(this.decorateTreeSpace(tree));
@@ -1373,6 +1376,8 @@ export class TeleCryptIOStorage {
1373
1376
  }, signal);
1374
1377
  }
1375
1378
  getTree(roomId) {
1379
+ if (isTreeDeleted(this.client, roomId))
1380
+ return null;
1376
1381
  const tree = this.client.unstableGetFileTreeSpace(roomId);
1377
1382
  return tree ? this.decorateTreeSpace(tree) : null;
1378
1383
  }
@@ -18,6 +18,7 @@ import { FileTooLargeError, MutationPartialError, MutationOutcomeUnknownError, N
18
18
  import { waitForCondition } from "./poll.js";
19
19
  import { validateMatrixEventId } from "./constants.js";
20
20
  import { validateName } from "./validation.js";
21
+ import { getDeletedTreeIds, isFileDeleted, isTreeDeleted, markFileDeleted, markTreeDeleted, } from "../deletion-markers.js";
21
22
  /**
22
23
  * Production Synapse enforces the built-in rc_messages budget (per-account
23
24
  * burst 10, refill 1/5s) on every room/state mutation the tree operations
@@ -31,6 +32,7 @@ const RATE_LIMIT_DEFAULT_DELAY_MS = 15_000;
31
32
  const RATE_LIMIT_MAX_DELAY_MS = 30_000;
32
33
  const RATE_LIMIT_MAX_TOTAL_DELAY_MS = 90_000;
33
34
  const MAX_DELETION_ROOMS = 4096;
35
+ const DELETION_REFRESH_CONCURRENCY = 8;
34
36
  const MAX_DELETION_DEPTH = 128;
35
37
  const MAX_FILE_VERSION_CHAIN = 128;
36
38
  const MAX_LIST_ITEMS = 10000;
@@ -174,12 +176,27 @@ async function resolveTree(storage, treeId, signal) {
174
176
  throw new StorageError("storage space not found");
175
177
  }
176
178
  }
179
+ function isMarkedTreeDeleted(storage, treeId) {
180
+ if (typeof storage.getClient !== "function")
181
+ return false;
182
+ return isTreeDeleted(storage.getClient(), treeId);
183
+ }
184
+ function isMarkedFileDeleted(storage, treeId, fileId) {
185
+ if (typeof storage.getClient !== "function")
186
+ return false;
187
+ return isFileDeleted(storage.getClient(), treeId, fileId);
188
+ }
177
189
  /** As `resolveTree`, but for a specific file within an already-resolved
178
190
  * vault or folder — covers the same settling window for a file another
179
191
  * process/session just uploaded. */
180
- async function resolveFile(tree, fileId, signal) {
192
+ async function resolveFile(storage, tree, fileId, signal) {
193
+ if (signal?.aborted)
194
+ throw new StorageError("operation cancelled");
195
+ if (isMarkedFileDeleted(storage, tree.id, fileId)) {
196
+ throw new StorageError("file not found");
197
+ }
181
198
  try {
182
- return await waitForCondition(() => tree.getFile(fileId), { timeoutMs: 15000, signal });
199
+ return await waitForCondition(() => (isMarkedFileDeleted(storage, tree.id, fileId) ? null : tree.getFile(fileId)), { timeoutMs: 15000, signal });
183
200
  }
184
201
  catch {
185
202
  if (signal?.aborted)
@@ -224,12 +241,14 @@ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large")
224
241
  * observe each result before removing their containing room. This also
225
242
  * prevents a folder/vault delete from silently deleting a nested shared tree.
226
243
  */
227
- function assertTreeEmptyForDeletion(tree, spaces) {
244
+ function assertTreeEmptyForDeletion(storage, tree, spaces) {
228
245
  if (spaces.length !== 1)
229
246
  throw new NonEmptyTreeError(tree.id);
230
247
  let files;
231
248
  try {
232
- files = tree.listAllFiles();
249
+ files = tree
250
+ .listAllFiles()
251
+ .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
233
252
  }
234
253
  catch {
235
254
  throw new StorageError("could not enumerate storage files safely");
@@ -249,6 +268,7 @@ async function refreshDeletionRooms(storage, signal) {
249
268
  if (roomIds.length > MAX_DELETION_ROOMS)
250
269
  throw new StorageError("delete graph is too large");
251
270
  const joined = new Set(roomIds);
271
+ const roomIdsToRefresh = [];
252
272
  // Matrix has no bounded endpoint that enumerates every invited/non-joined
253
273
  // room carrying a space relation. Refresh every locally visible room and
254
274
  // fail closed for an invite/knock/unknown membership; a joined external
@@ -258,14 +278,34 @@ async function refreshDeletionRooms(storage, signal) {
258
278
  const membership = room.getMyMembership?.();
259
279
  if (membership !== "join")
260
280
  throw new StorageError("delete graph inventory is incomplete");
261
- await storage.refreshRoomState(room.roomId, { signal });
281
+ roomIdsToRefresh.push(room.roomId);
262
282
  }
263
283
  }
264
- for (const roomId of roomIds) {
265
- if (signal?.aborted)
266
- throw new StorageError("operation cancelled");
267
- await storage.refreshRoomState(roomId, { signal });
268
- }
284
+ roomIdsToRefresh.push(...roomIds);
285
+ let nextRoom = 0;
286
+ let failed = false;
287
+ let firstError;
288
+ const refreshWorker = async () => {
289
+ while (!failed && !signal?.aborted) {
290
+ const roomId = roomIdsToRefresh[nextRoom++];
291
+ if (roomId === undefined)
292
+ return;
293
+ try {
294
+ await storage.refreshRoomState(roomId, { signal });
295
+ }
296
+ catch (error) {
297
+ failed = true;
298
+ firstError = error;
299
+ return;
300
+ }
301
+ }
302
+ };
303
+ const workerCount = Math.min(DELETION_REFRESH_CONCURRENCY, roomIdsToRefresh.length);
304
+ await Promise.all(Array.from({ length: workerCount }, () => refreshWorker()));
305
+ if (signal?.aborted)
306
+ throw new StorageError("operation cancelled");
307
+ if (failed)
308
+ throw firstError;
269
309
  }
270
310
  async function refreshTreeSpaces(storage, spaces, signal) {
271
311
  for (const space of spaces) {
@@ -458,8 +498,6 @@ async function relinkExternalParents(storage, rootId, externalParents, signal) {
458
498
  }
459
499
  }
460
500
  }
461
- const deletedTreeIds = new WeakMap();
462
- const deletedRoomIds = new WeakMap();
463
501
  function isGoneError(error) {
464
502
  return (error instanceof MatrixError &&
465
503
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
@@ -518,12 +556,7 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
518
556
  throw error;
519
557
  }
520
558
  removeRoomFromLocalStore(client, roomId);
521
- let deleted = deletedRoomIds.get(client);
522
- if (!deleted) {
523
- deleted = new Set();
524
- deletedRoomIds.set(client, deleted);
525
- }
526
- deleted.add(roomId);
559
+ markTreeDeleted(client, roomId);
527
560
  }
528
561
  /**
529
562
  * The SDK normally evicts a room as part of forget(). Keep the local-store
@@ -671,6 +704,8 @@ export async function listPendingInvites(storage, options) {
671
704
  const invites = [];
672
705
  for (const room of rooms) {
673
706
  ensureOperationActive(signal);
707
+ if (isMarkedTreeDeleted(storage, room.roomId))
708
+ continue;
674
709
  if (room.getMyMembership() !== "invite")
675
710
  continue;
676
711
  if (hasActiveSpaceParent(room))
@@ -969,7 +1004,9 @@ export async function listFiles(storage, treeId, options) {
969
1004
  try {
970
1005
  await storage.refreshRoomState(treeId, { signal });
971
1006
  ensureOperationActive(signal);
972
- const files = tree.listFiles();
1007
+ const files = tree
1008
+ .listFiles()
1009
+ .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
973
1010
  if (files.length > MAX_LIST_ITEMS)
974
1011
  throw new StorageError("file list is too large");
975
1012
  return files.map((f) => ({ id: f.id, name: f.getName() }));
@@ -989,7 +1026,9 @@ export async function listSubfolders(storage, parentId, options) {
989
1026
  const tree = await resolveTree(storage, parentId, signal);
990
1027
  await storage.refreshRoomState(parentId, { signal });
991
1028
  ensureOperationActive(signal);
992
- const directories = tree.getDirectories();
1029
+ const directories = tree
1030
+ .getDirectories()
1031
+ .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
993
1032
  if (directories.length > MAX_LIST_ITEMS)
994
1033
  throw new StorageError("folder list is too large");
995
1034
  return directories.map((d) => ({ id: d.id, name: d.room.name }));
@@ -1062,21 +1101,16 @@ export async function renameFolder(storage, folderId, name, options) {
1062
1101
  async function deleteTree(storage, treeId, options) {
1063
1102
  const operation = createOperationDeadline(options);
1064
1103
  const client = storage.getClient();
1065
- let deleted = deletedTreeIds.get(client);
1066
- if (!deleted) {
1067
- deleted = new Set();
1068
- deletedTreeIds.set(client, deleted);
1069
- }
1070
1104
  try {
1071
1105
  ensureOperationActive(operation.signal);
1072
1106
  const pending = withTreeMutation(client, async () => {
1073
- if (deleted.has(treeId))
1107
+ if (isTreeDeleted(client, treeId))
1074
1108
  return { id: treeId, deleted: true };
1075
1109
  const tree = await resolveTree(storage, treeId, operation.signal);
1076
1110
  // Read reconciliation state after this operation reaches the single
1077
1111
  // client queue; a preceding delete may have populated the set while we
1078
1112
  // were waiting for its turn.
1079
- const removedRooms = deletedRoomIds.get(client) ?? new Set();
1113
+ const removedRooms = getDeletedTreeIds(client);
1080
1114
  let graph;
1081
1115
  try {
1082
1116
  // Re-read every locally visible room before taking the immutable graph
@@ -1088,12 +1122,11 @@ async function deleteTree(storage, treeId, options) {
1088
1122
  const ids = new Set(activeSpaces.map((space) => space.id));
1089
1123
  if (!ids.has(tree.id)) {
1090
1124
  if (removedRooms.has(tree.id)) {
1091
- deleted.add(treeId);
1092
1125
  return { id: treeId, deleted: true };
1093
1126
  }
1094
1127
  throw new StorageError("delete graph is unsafe");
1095
1128
  }
1096
- assertTreeEmptyForDeletion(tree, activeSpaces);
1129
+ assertTreeEmptyForDeletion(storage, tree, activeSpaces);
1097
1130
  graph = validateDeletionGraph(client, tree, ids, removedRooms);
1098
1131
  }
1099
1132
  catch (error) {
@@ -1143,7 +1176,6 @@ async function deleteTree(storage, treeId, options) {
1143
1176
  throw error;
1144
1177
  throw new StorageError("delete failed");
1145
1178
  }
1146
- deleted.add(treeId);
1147
1179
  return { id: treeId, deleted: true };
1148
1180
  }, operation.signal);
1149
1181
  return await raceOperationDeadline(operation, pending, "mutation");
@@ -1164,7 +1196,7 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1164
1196
  validateName(name, "file name");
1165
1197
  return withOperationDeadline(options, async (signal) => {
1166
1198
  const tree = await resolveTree(storage, treeId, signal);
1167
- const branch = await resolveFile(tree, fileId, signal);
1199
+ const branch = await resolveFile(storage, tree, fileId, signal);
1168
1200
  try {
1169
1201
  const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
1170
1202
  await withRateLimitRetry(() => client ? withMatrixMutationAbort(client, () => branch.setName(name), signal) : branch.setName(name), signal);
@@ -1269,7 +1301,7 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1269
1301
  export async function deleteFile(storage, treeId, fileId, options) {
1270
1302
  return withOperationDeadline(options, async (signal) => {
1271
1303
  const tree = await resolveTree(storage, treeId, signal);
1272
- const branch = await resolveFile(tree, fileId, signal);
1304
+ const branch = await resolveFile(storage, tree, fileId, signal);
1273
1305
  const versions = await resolveFileVersions(branch, signal);
1274
1306
  const mediaIds = [...new Set(versions.map(({ mediaId }) => mediaId))];
1275
1307
  await deleteFileMedia(storage, mediaIds, signal);
@@ -1306,6 +1338,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1306
1338
  const detail = "Matrix deletion completed but the inactive file state could not be verified";
1307
1339
  throw new MutationPartialError("delete file", completedIds, detail);
1308
1340
  }
1341
+ for (const { branch: version } of versions) {
1342
+ markFileDeleted(storage.getClient(), tree.id, version.id);
1343
+ }
1309
1344
  ensureOperationActive(signal);
1310
1345
  return { id: fileId, deleted: true };
1311
1346
  }, "mutation");
@@ -1348,7 +1383,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1348
1383
  export async function downloadFile(storage, treeId, fileId, options) {
1349
1384
  return withOperationDeadline(options, async (signal) => {
1350
1385
  const tree = await resolveTree(storage, treeId, signal);
1351
- const branch = await resolveFile(tree, fileId, signal);
1386
+ const branch = await resolveFile(storage, tree, fileId, signal);
1352
1387
  let result;
1353
1388
  try {
1354
1389
  result = await storage.downloadFile(branch, signal);
@@ -1382,7 +1417,7 @@ function tsToIso(ts) {
1382
1417
  export async function getFileDetails(storage, treeId, fileId, options) {
1383
1418
  return withOperationDeadline(options, async (signal) => {
1384
1419
  const tree = await resolveTree(storage, treeId, signal);
1385
- const branch = await resolveFile(tree, fileId, signal);
1420
+ const branch = await resolveFile(storage, tree, fileId, signal);
1386
1421
  const name = branch.getName();
1387
1422
  let mimetype = null;
1388
1423
  let size = null;
@@ -0,0 +1,8 @@
1
+ import type { MatrixClient } from "matrix-js-sdk";
2
+ /** Records a confirmed room deletion for this MatrixClient's lifetime. */
3
+ export declare function markTreeDeleted(client: MatrixClient, roomId: string): void;
4
+ /** Records a confirmed file deletion for this MatrixClient's lifetime. */
5
+ export declare function markFileDeleted(client: MatrixClient, roomId: string, fileId: string): void;
6
+ export declare function isTreeDeleted(client: MatrixClient, roomId: string): boolean;
7
+ export declare function getDeletedTreeIds(client: MatrixClient): ReadonlySet<string>;
8
+ export declare function isFileDeleted(client: MatrixClient, roomId: string, fileId: string): boolean;
@@ -0,0 +1,32 @@
1
+ const markersByClient = new WeakMap();
2
+ function markersFor(client) {
3
+ let markers = markersByClient.get(client);
4
+ if (!markers) {
5
+ markers = { trees: new Set(), files: new Map() };
6
+ markersByClient.set(client, markers);
7
+ }
8
+ return markers;
9
+ }
10
+ /** Records a confirmed room deletion for this MatrixClient's lifetime. */
11
+ export function markTreeDeleted(client, roomId) {
12
+ markersFor(client).trees.add(roomId);
13
+ }
14
+ /** Records a confirmed file deletion for this MatrixClient's lifetime. */
15
+ export function markFileDeleted(client, roomId, fileId) {
16
+ const markers = markersFor(client);
17
+ let files = markers.files.get(roomId);
18
+ if (!files) {
19
+ files = new Set();
20
+ markers.files.set(roomId, files);
21
+ }
22
+ files.add(fileId);
23
+ }
24
+ export function isTreeDeleted(client, roomId) {
25
+ return markersFor(client).trees.has(roomId);
26
+ }
27
+ export function getDeletedTreeIds(client) {
28
+ return markersFor(client).trees;
29
+ }
30
+ export function isFileDeleted(client, roomId, fileId) {
31
+ return markersFor(client).files.get(roomId)?.has(fileId) ?? false;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.5.22",
3
+ "version": "0.5.25",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",