Files
outline/server/commands/documentPermanentDeleter.ts
2026-05-26 20:12:26 -04:00

131 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chunk, uniq } from "es-toolkit/compat";
import { Op, QueryTypes } from "sequelize";
import { sleep } from "@shared/utils/timers";
import Logger from "@server/logging/Logger";
import { Document, Attachment } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { sequelize } from "@server/storage/database";
export default async function documentPermanentDeleter(documents: Document[]) {
const activeDocument = documents.find((doc) => !doc.deletedAt);
if (activeDocument) {
throw new Error(
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
);
}
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
for (const document of documents) {
// Find any attachments that are referenced in the text content
const attachmentIdsInText = ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(document)
);
// Find any attachments that were originally uploaded to this document
const attachmentIdsForDocument = (
await Attachment.findAll({
attributes: ["id"],
where: {
teamId: document.teamId,
documentId: document.id,
},
})
).map((attachment) => attachment.id);
const attachmentIds = uniq([
...attachmentIdsInText,
...attachmentIdsForDocument,
]);
await Promise.all(
attachmentIds.map(async (attachmentId) => {
// Check if the attachment is referenced in any other documents this
// is needed as it's easy to copy and paste content between documents.
// An uploaded attachment may end up referenced in multiple documents.
const [{ count }] = await sequelize.query<{ count: string }>(query, {
type: QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
// If the attachment is not referenced in any other documents then
// delete it from the database and the storage provider.
if (parseInt(count) === 0) {
Logger.info(
"commands",
`Attachment ${attachmentId} scheduled for deletion`
);
await new DeleteAttachmentTask().schedule({
attachmentId,
teamId: document.teamId,
});
}
})
);
}
const documentIds = documents.map((document) => document.id);
// Re-check deletedAt in the database to exclude documents that were restored
// between the caller's query and now. Otherwise the parentDocumentId clear
// below would detach children of a restored parent, breaking the hierarchy.
const stillDeleted = await Document.unscoped().findAll({
attributes: ["id"],
where: {
id: documentIds,
deletedAt: { [Op.ne]: null },
},
paranoid: false,
});
const deletedIds = stillDeleted.map((document) => document.id);
for (const batch of chunk(deletedIds, 100)) {
await Document.update(
{
parentDocumentId: null,
},
{
where: {
parentDocumentId: {
[Op.in]: batch,
},
},
paranoid: false,
}
);
}
// Small batch size and inter-batch sleep keep the exclusive lock window short
// enough to avoid blocking concurrent web requests, since each delete
// cascades into vectors, attachments, revisions, comments, and notifications.
const destroyBatches = chunk(deletedIds, 10);
let totalDeleted = 0;
for (let i = 0; i < destroyBatches.length; i++) {
totalDeleted += await Document.scope("withDrafts").destroy({
where: {
id: destroyBatches[i],
deletedAt: { [Op.ne]: null },
},
force: true,
});
if (i < destroyBatches.length - 1) {
await sleep(100);
}
}
return totalDeleted;
}