@telecrypt-io/storage 0.5.23 → 0.5.26

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
@@ -175,12 +176,27 @@ async function resolveTree(storage, treeId, signal) {
175
176
  throw new StorageError("storage space not found");
176
177
  }
177
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
+ }
178
189
  /** As `resolveTree`, but for a specific file within an already-resolved
179
190
  * vault or folder — covers the same settling window for a file another
180
191
  * process/session just uploaded. */
181
- 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
+ }
182
198
  try {
183
- 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 });
184
200
  }
185
201
  catch {
186
202
  if (signal?.aborted)
@@ -225,12 +241,15 @@ function snapshotTreeSpaces(root, tooLargeMessage = "storage tree is too large")
225
241
  * observe each result before removing their containing room. This also
226
242
  * prevents a folder/vault delete from silently deleting a nested shared tree.
227
243
  */
228
- function assertTreeEmptyForDeletion(tree, spaces) {
244
+ function assertTreeEmptyForDeletion(storage, tree, spaces) {
229
245
  if (spaces.length !== 1)
230
246
  throw new NonEmptyTreeError(tree.id);
231
247
  let files;
232
248
  try {
233
- files = tree.listAllFiles();
249
+ files = tree
250
+ .listAllFiles()
251
+ .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id) &&
252
+ !isConfirmedDeletedFile(storage.getClient(), tree.id, file));
234
253
  }
235
254
  catch {
236
255
  throw new StorageError("could not enumerate storage files safely");
@@ -315,6 +334,26 @@ function isActiveRelationEvent(event) {
315
334
  !Array.isArray(content) &&
316
335
  Object.keys(content).length > 0);
317
336
  }
337
+ /**
338
+ * A deleted MSC3089 branch is represented by an authoritative empty state
339
+ * event. Inactive branches with metadata are historical versions, and a
340
+ * missing or malformed state cannot prove that the file was deleted.
341
+ */
342
+ function isConfirmedDeletedFile(client, treeId, file) {
343
+ if (file.isActive)
344
+ return false;
345
+ const stateEvents = readRelationEvents(client, treeId, UNSTABLE_MSC3089_BRANCH.name, file.id);
346
+ if (!stateEvents || stateEvents.length !== 1)
347
+ return false;
348
+ const stateEvent = stateEvents[0];
349
+ if (relationStateKey(stateEvent) !== file.id)
350
+ return false;
351
+ const content = stateEvent.getContent?.();
352
+ return (typeof content === "object" &&
353
+ content !== null &&
354
+ !Array.isArray(content) &&
355
+ Object.keys(content).length === 0);
356
+ }
318
357
  /**
319
358
  * Deletion is intentionally narrower than MSC3089TreeSpace.delete(): a local
320
359
  * child graph is not proof that the rooms are exclusively owned by this tree.
@@ -480,8 +519,6 @@ async function relinkExternalParents(storage, rootId, externalParents, signal) {
480
519
  }
481
520
  }
482
521
  }
483
- const deletedTreeIds = new WeakMap();
484
- const deletedRoomIds = new WeakMap();
485
522
  function isGoneError(error) {
486
523
  return (error instanceof MatrixError &&
487
524
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
@@ -540,12 +577,7 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
540
577
  throw error;
541
578
  }
542
579
  removeRoomFromLocalStore(client, roomId);
543
- let deleted = deletedRoomIds.get(client);
544
- if (!deleted) {
545
- deleted = new Set();
546
- deletedRoomIds.set(client, deleted);
547
- }
548
- deleted.add(roomId);
580
+ markTreeDeleted(client, roomId);
549
581
  }
550
582
  /**
551
583
  * The SDK normally evicts a room as part of forget(). Keep the local-store
@@ -693,6 +725,8 @@ export async function listPendingInvites(storage, options) {
693
725
  const invites = [];
694
726
  for (const room of rooms) {
695
727
  ensureOperationActive(signal);
728
+ if (isMarkedTreeDeleted(storage, room.roomId))
729
+ continue;
696
730
  if (room.getMyMembership() !== "invite")
697
731
  continue;
698
732
  if (hasActiveSpaceParent(room))
@@ -991,7 +1025,9 @@ export async function listFiles(storage, treeId, options) {
991
1025
  try {
992
1026
  await storage.refreshRoomState(treeId, { signal });
993
1027
  ensureOperationActive(signal);
994
- const files = tree.listFiles();
1028
+ const files = tree
1029
+ .listFiles()
1030
+ .filter((file) => !isMarkedFileDeleted(storage, tree.id, file.id));
995
1031
  if (files.length > MAX_LIST_ITEMS)
996
1032
  throw new StorageError("file list is too large");
997
1033
  return files.map((f) => ({ id: f.id, name: f.getName() }));
@@ -1011,7 +1047,9 @@ export async function listSubfolders(storage, parentId, options) {
1011
1047
  const tree = await resolveTree(storage, parentId, signal);
1012
1048
  await storage.refreshRoomState(parentId, { signal });
1013
1049
  ensureOperationActive(signal);
1014
- const directories = tree.getDirectories();
1050
+ const directories = tree
1051
+ .getDirectories()
1052
+ .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
1015
1053
  if (directories.length > MAX_LIST_ITEMS)
1016
1054
  throw new StorageError("folder list is too large");
1017
1055
  return directories.map((d) => ({ id: d.id, name: d.room.name }));
@@ -1084,21 +1122,16 @@ export async function renameFolder(storage, folderId, name, options) {
1084
1122
  async function deleteTree(storage, treeId, options) {
1085
1123
  const operation = createOperationDeadline(options);
1086
1124
  const client = storage.getClient();
1087
- let deleted = deletedTreeIds.get(client);
1088
- if (!deleted) {
1089
- deleted = new Set();
1090
- deletedTreeIds.set(client, deleted);
1091
- }
1092
1125
  try {
1093
1126
  ensureOperationActive(operation.signal);
1094
1127
  const pending = withTreeMutation(client, async () => {
1095
- if (deleted.has(treeId))
1128
+ if (isTreeDeleted(client, treeId))
1096
1129
  return { id: treeId, deleted: true };
1097
1130
  const tree = await resolveTree(storage, treeId, operation.signal);
1098
1131
  // Read reconciliation state after this operation reaches the single
1099
1132
  // client queue; a preceding delete may have populated the set while we
1100
1133
  // were waiting for its turn.
1101
- const removedRooms = deletedRoomIds.get(client) ?? new Set();
1134
+ const removedRooms = getDeletedTreeIds(client);
1102
1135
  let graph;
1103
1136
  try {
1104
1137
  // Re-read every locally visible room before taking the immutable graph
@@ -1110,12 +1143,11 @@ async function deleteTree(storage, treeId, options) {
1110
1143
  const ids = new Set(activeSpaces.map((space) => space.id));
1111
1144
  if (!ids.has(tree.id)) {
1112
1145
  if (removedRooms.has(tree.id)) {
1113
- deleted.add(treeId);
1114
1146
  return { id: treeId, deleted: true };
1115
1147
  }
1116
1148
  throw new StorageError("delete graph is unsafe");
1117
1149
  }
1118
- assertTreeEmptyForDeletion(tree, activeSpaces);
1150
+ assertTreeEmptyForDeletion(storage, tree, activeSpaces);
1119
1151
  graph = validateDeletionGraph(client, tree, ids, removedRooms);
1120
1152
  }
1121
1153
  catch (error) {
@@ -1165,7 +1197,6 @@ async function deleteTree(storage, treeId, options) {
1165
1197
  throw error;
1166
1198
  throw new StorageError("delete failed");
1167
1199
  }
1168
- deleted.add(treeId);
1169
1200
  return { id: treeId, deleted: true };
1170
1201
  }, operation.signal);
1171
1202
  return await raceOperationDeadline(operation, pending, "mutation");
@@ -1186,7 +1217,7 @@ export async function renameFile(storage, treeId, fileId, name, options) {
1186
1217
  validateName(name, "file name");
1187
1218
  return withOperationDeadline(options, async (signal) => {
1188
1219
  const tree = await resolveTree(storage, treeId, signal);
1189
- const branch = await resolveFile(tree, fileId, signal);
1220
+ const branch = await resolveFile(storage, tree, fileId, signal);
1190
1221
  try {
1191
1222
  const client = typeof storage.getClient === "function" ? storage.getClient() : undefined;
1192
1223
  await withRateLimitRetry(() => client ? withMatrixMutationAbort(client, () => branch.setName(name), signal) : branch.setName(name), signal);
@@ -1291,7 +1322,7 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1291
1322
  export async function deleteFile(storage, treeId, fileId, options) {
1292
1323
  return withOperationDeadline(options, async (signal) => {
1293
1324
  const tree = await resolveTree(storage, treeId, signal);
1294
- const branch = await resolveFile(tree, fileId, signal);
1325
+ const branch = await resolveFile(storage, tree, fileId, signal);
1295
1326
  const versions = await resolveFileVersions(branch, signal);
1296
1327
  const mediaIds = [...new Set(versions.map(({ mediaId }) => mediaId))];
1297
1328
  await deleteFileMedia(storage, mediaIds, signal);
@@ -1328,6 +1359,9 @@ export async function deleteFile(storage, treeId, fileId, options) {
1328
1359
  const detail = "Matrix deletion completed but the inactive file state could not be verified";
1329
1360
  throw new MutationPartialError("delete file", completedIds, detail);
1330
1361
  }
1362
+ for (const { branch: version } of versions) {
1363
+ markFileDeleted(storage.getClient(), tree.id, version.id);
1364
+ }
1331
1365
  ensureOperationActive(signal);
1332
1366
  return { id: fileId, deleted: true };
1333
1367
  }, "mutation");
@@ -1370,7 +1404,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1370
1404
  export async function downloadFile(storage, treeId, fileId, options) {
1371
1405
  return withOperationDeadline(options, async (signal) => {
1372
1406
  const tree = await resolveTree(storage, treeId, signal);
1373
- const branch = await resolveFile(tree, fileId, signal);
1407
+ const branch = await resolveFile(storage, tree, fileId, signal);
1374
1408
  let result;
1375
1409
  try {
1376
1410
  result = await storage.downloadFile(branch, signal);
@@ -1404,7 +1438,7 @@ function tsToIso(ts) {
1404
1438
  export async function getFileDetails(storage, treeId, fileId, options) {
1405
1439
  return withOperationDeadline(options, async (signal) => {
1406
1440
  const tree = await resolveTree(storage, treeId, signal);
1407
- const branch = await resolveFile(tree, fileId, signal);
1441
+ const branch = await resolveFile(storage, tree, fileId, signal);
1408
1442
  const name = branch.getName();
1409
1443
  let mimetype = null;
1410
1444
  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.23",
3
+ "version": "0.5.26",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",