@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
|
}
|
package/dist/core/operations.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
281
|
+
roomIdsToRefresh.push(room.roomId);
|
|
262
282
|
}
|
|
263
283
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
+
}
|