server/commands/documentPermanentDeleter.ts
Rama main · réplica de lectura del repositorio original.
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 { sequelizeReadOnly } 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 sequelizeReadOnly.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)) {
// Unscoped so that drafts and templates are detached too, otherwise the
// destroy below cascades and removes them.
await Document.unscoped().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;
}