Notes/src/services/consistency_checks.ts

967 lines
36 KiB
TypeScript
Raw Normal View History

2017-12-14 22:16:26 -05:00
"use strict";
import sql from "./sql.js";
import sqlInit from "./sql_init.js";
import log from "./log.js";
import ws from "./ws.js";
import syncMutexService from "./sync_mutex.js";
import cls from "./cls.js";
import entityChangesService from "./entity_changes.js";
import optionsService from "./options.js";
import BBranch from "../becca/entities/bbranch.js";
import becca from "../becca/becca.js";
import { hash as getHash, hashedBlobId, randomString } from "../services/utils.js";
import eraseService from "../services/erase.js";
import sanitizeAttributeName from "./sanitize_attribute_name.js";
import noteTypesService from "../services/note_types.js";
2025-01-09 18:07:02 +02:00
import { BranchRow } from "../becca/entities/rows.js";
import { EntityChange } from "./entity_changes_interface.js";
import becca_loader from "../becca/becca_loader.js";
2024-04-03 20:47:41 +03:00
const noteTypes = noteTypesService.getNoteTypeNames();
2017-12-14 22:16:26 -05:00
2019-12-10 22:03:00 +01:00
class ConsistencyChecks {
2024-04-03 20:47:41 +03:00
private autoFix: boolean;
private unrecoveredConsistencyErrors: boolean;
private fixedIssues: boolean;
2024-04-03 20:47:41 +03:00
private reloadNeeded: boolean;
2023-12-30 00:34:46 +01:00
/**
2025-01-09 18:07:02 +02:00
* @param autoFix - automatically fix all encountered problems. False is only for debugging during development (fail fast)
*/
2024-04-03 20:47:41 +03:00
constructor(autoFix: boolean) {
2019-12-10 22:03:00 +01:00
this.autoFix = autoFix;
this.unrecoveredConsistencyErrors = false;
this.fixedIssues = false;
this.reloadNeeded = false;
2017-12-14 22:16:26 -05:00
}
2024-04-03 20:47:41 +03:00
findAndFixIssues(query: string, fixerCb: (res: any) => void) {
2020-06-20 12:31:38 +02:00
const results = sql.getRows(query);
2019-12-10 22:03:00 +01:00
for (const res of results) {
try {
2020-07-01 22:42:59 +02:00
sql.transactional(() => fixerCb(res));
2018-01-01 19:41:22 -05:00
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
this.fixedIssues = true;
} else {
this.unrecoveredConsistencyErrors = true;
}
2024-04-03 20:47:41 +03:00
} catch (e: any) {
2019-12-10 22:03:00 +01:00
logError(`Fixer failed with ${e.message} ${e.stack}`);
this.unrecoveredConsistencyErrors = true;
2018-01-01 19:41:22 -05:00
}
}
2019-12-10 22:03:00 +01:00
return results;
2018-01-01 19:41:22 -05:00
}
2018-10-21 21:37:34 +02:00
2020-06-20 12:31:38 +02:00
checkTreeCycles() {
2024-04-03 20:47:41 +03:00
const childToParents: Record<string, string[]> = {};
const rows = sql.getRows<BranchRow>("SELECT noteId, parentNoteId FROM branches WHERE isDeleted = 0");
2018-01-01 19:41:22 -05:00
2019-12-10 22:03:00 +01:00
for (const row of rows) {
const childNoteId = row.noteId;
const parentNoteId = row.parentNoteId;
childToParents[childNoteId] = childToParents[childNoteId] || [];
childToParents[childNoteId].push(parentNoteId);
}
/** @returns true if cycle was found and we should try again */
2024-04-03 20:47:41 +03:00
const checkTreeCycle = (noteId: string, path: string[]) => {
2025-01-09 18:07:02 +02:00
if (noteId === "root") {
2022-05-03 00:30:09 +02:00
return false;
}
2019-12-10 22:03:00 +01:00
for (const parentNoteId of childToParents[noteId]) {
if (path.includes(parentNoteId)) {
2022-05-03 00:30:09 +02:00
if (this.autoFix) {
const branch = becca.getBranchFromChildAndParent(noteId, parentNoteId);
2024-04-03 20:47:41 +03:00
if (branch) {
2025-01-09 18:07:02 +02:00
branch.markAsDeleted("cycle-autofix");
2024-04-03 20:47:41 +03:00
logFix(`Branch '${branch.branchId}' between child '${noteId}' and parent '${parentNoteId}' has been deleted since it was causing a tree cycle.`);
}
2022-05-03 00:30:09 +02:00
return true;
2025-01-09 18:07:02 +02:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`);
2022-05-03 00:30:09 +02:00
this.unrecoveredConsistencyErrors = true;
}
2019-12-10 22:03:00 +01:00
} else {
const newPath = path.slice();
newPath.push(noteId);
2022-05-03 00:30:09 +02:00
const retryNeeded = checkTreeCycle(parentNoteId, newPath);
if (retryNeeded) {
return true;
}
2019-12-10 22:03:00 +01:00
}
}
2022-05-03 00:30:09 +02:00
return false;
2020-03-11 22:43:20 +01:00
};
2019-02-02 10:38:33 +01:00
2019-12-10 22:03:00 +01:00
const noteIds = Object.keys(childToParents);
2019-12-10 22:03:00 +01:00
for (const noteId of noteIds) {
2022-05-03 00:30:09 +02:00
const retryNeeded = checkTreeCycle(noteId, []);
if (retryNeeded) {
return true;
}
}
return false;
}
checkAndRepairTreeCycles() {
let treeFixed = false;
while (this.checkTreeCycles()) {
// fixing cycle means deleting branches, we might need to create a new branch to recover the note
this.findExistencyIssues();
treeFixed = true;
2019-12-10 22:03:00 +01:00
}
2022-05-03 00:30:09 +02:00
if (treeFixed) {
this.reloadNeeded = true;
2019-12-10 22:03:00 +01:00
}
}
2018-11-19 23:11:36 +01:00
2020-06-20 12:31:38 +02:00
findBrokenReferenceIssues() {
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT branchId, branches.noteId
FROM branches
LEFT JOIN notes USING (noteId)
2019-12-10 22:03:00 +01:00
WHERE branches.isDeleted = 0
AND notes.noteId IS NULL`,
2025-01-09 18:07:02 +02:00
({ branchId, noteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const branch = becca.getBranch(branchId);
2024-04-03 20:47:41 +03:00
if (!branch) {
return;
}
branch.markAsDeleted();
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Branch '${branchId}' has been deleted since it references missing note '${noteId}'`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Branch '${branchId}' references missing note '${noteId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2022-11-06 15:18:32 +01:00
SELECT branchId, branches.parentNoteId AS parentNoteId
2019-12-10 22:03:00 +01:00
FROM branches
LEFT JOIN notes ON notes.noteId = branches.parentNoteId
2019-12-10 22:03:00 +01:00
WHERE branches.isDeleted = 0
AND branches.noteId != 'root'
AND notes.noteId IS NULL`,
2025-01-09 18:07:02 +02:00
({ branchId, parentNoteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
// Delete the old branch and recreate it with root as parent.
const oldBranch = becca.getBranch(branchId);
2024-04-03 20:47:41 +03:00
if (!oldBranch) {
return;
}
const noteId = oldBranch.noteId;
oldBranch.markAsDeleted("missing-parent");
2023-06-29 22:10:13 +02:00
let message = `Branch '${branchId}' was missing parent note '${parentNoteId}', so it was deleted. `;
2024-04-03 20:47:41 +03:00
const note = becca.getNote(noteId);
if (!note) {
return;
}
if (note.getParentBranches().length === 0) {
const newBranch = new BBranch({
2025-01-09 18:07:02 +02:00
parentNoteId: "root",
noteId: noteId,
2025-01-09 18:07:02 +02:00
prefix: "recovered"
}).save();
message += `${newBranch.branchId} was created in the root instead.`;
} else {
message += `There is one or more valid branches, so no new one will be created as a replacement.`;
}
this.reloadNeeded = true;
logFix(message);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Branch '${branchId}' references missing parent note '${parentNoteId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId, attributes.noteId
FROM attributes
LEFT JOIN notes USING (noteId)
2019-12-10 22:03:00 +01:00
WHERE attributes.isDeleted = 0
AND notes.noteId IS NULL`,
2025-01-09 18:07:02 +02:00
({ attributeId, noteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const attribute = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!attribute) {
return;
}
attribute.markAsDeleted();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Attribute '${attributeId}' has been deleted since it references missing source note '${noteId}'`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Attribute '${attributeId}' references missing source note '${noteId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId, attributes.value AS noteId
FROM attributes
LEFT JOIN notes ON notes.noteId = attributes.value
2019-12-10 22:03:00 +01:00
WHERE attributes.isDeleted = 0
AND attributes.type = 'relation'
AND notes.noteId IS NULL`,
2025-01-09 18:07:02 +02:00
({ attributeId, noteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const attribute = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!attribute) {
return;
}
attribute.markAsDeleted();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2025-01-09 18:07:02 +02:00
logFix(`Relation '${attributeId}' has been deleted since it references missing note '${noteId}'`);
2019-12-10 22:03:00 +01:00
} else {
2025-01-09 18:07:02 +02:00
logError(`Relation '${attributeId}' references missing note '${noteId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
SELECT attachmentId, attachments.ownerId AS noteId
2023-03-16 13:29:11 +01:00
FROM attachments
WHERE attachments.ownerId NOT IN (
2023-03-16 13:29:11 +01:00
SELECT noteId FROM notes
UNION ALL
SELECT revisionId FROM revisions
2023-03-16 13:29:11 +01:00
)
AND attachments.isDeleted = 0`,
2025-01-09 18:07:02 +02:00
({ attachmentId, ownerId }) => {
2023-03-16 13:29:11 +01:00
if (this.autoFix) {
const attachment = becca.getAttachment(attachmentId);
2024-04-03 20:47:41 +03:00
if (!attachment) {
return;
}
2023-03-16 13:29:11 +01:00
attachment.markAsDeleted();
this.reloadNeeded = false;
logFix(`Attachment '${attachmentId}' has been deleted since it references missing note/revision '${ownerId}'`);
2023-03-16 13:29:11 +01:00
} else {
logError(`Attachment '${attachmentId}' references missing note/revision '${ownerId}'`);
2023-03-16 13:29:11 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
}
2020-06-20 12:31:38 +02:00
findExistencyIssues() {
2023-05-05 23:41:11 +02:00
// the principle for fixing inconsistencies is that if the note itself is deleted (isDeleted=true) then all related
// entities should be also deleted (branches, attributes), but if the note is not deleted,
2023-01-23 16:57:28 +01:00
// then at least one branch should exist.
2019-12-10 22:03:00 +01:00
2023-05-05 23:41:11 +02:00
// the order here is important - first we might need to delete inconsistent branches, and after that
2019-12-10 22:03:00 +01:00
// another check might create missing branch
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT branchId,
noteId
2019-12-10 22:03:00 +01:00
FROM branches
JOIN notes USING (noteId)
2019-12-10 22:03:00 +01:00
WHERE notes.isDeleted = 1
AND branches.isDeleted = 0`,
2025-01-09 18:07:02 +02:00
({ branchId, noteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const branch = becca.getBranch(branchId);
2024-04-03 20:47:41 +03:00
if (!branch) return;
branch.markAsDeleted();
this.reloadNeeded = true;
2023-06-29 22:10:13 +02:00
logFix(`Branch '${branchId}' has been deleted since the associated note '${noteId}' is deleted.`);
2019-12-10 22:03:00 +01:00
} else {
2025-01-09 18:07:02 +02:00
logError(`Branch '${branchId}' is not deleted even though the associated note '${noteId}' is deleted.`);
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT branchId,
parentNoteId
2019-12-10 22:03:00 +01:00
FROM branches
JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId
2019-12-10 22:03:00 +01:00
WHERE parentNote.isDeleted = 1
AND branches.isDeleted = 0
2025-01-09 18:07:02 +02:00
`,
({ branchId, parentNoteId }) => {
if (this.autoFix) {
const branch = becca.getBranch(branchId);
if (!branch) {
return;
}
branch.markAsDeleted();
2025-01-09 18:07:02 +02:00
this.reloadNeeded = true;
2025-01-09 18:07:02 +02:00
logFix(`Branch '${branchId}' has been deleted since the associated parent note '${parentNoteId}' is deleted.`);
} else {
logError(`Branch '${branchId}' is not deleted even though the associated parent note '${parentNoteId}' is deleted.`);
}
}
2025-01-09 18:07:02 +02:00
);
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT DISTINCT notes.noteId
FROM notes
LEFT JOIN branches ON notes.noteId = branches.noteId AND branches.isDeleted = 0
2019-12-10 22:03:00 +01:00
WHERE notes.isDeleted = 0
AND branches.branchId IS NULL
2025-01-09 18:07:02 +02:00
`,
({ noteId }) => {
if (this.autoFix) {
const branch = new BBranch({
parentNoteId: "root",
noteId: noteId,
prefix: "recovered"
}).save();
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.reloadNeeded = true;
2025-01-09 18:07:02 +02:00
logFix(`Created missing branch '${branch.branchId}' for note '${noteId}'`);
} else {
logError(`No undeleted branch found for note '${noteId}'`);
}
}
2025-01-09 18:07:02 +02:00
);
2019-12-10 22:03:00 +01:00
// there should be a unique relationship between note and its parent
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT noteId,
parentNoteId
2019-12-10 22:03:00 +01:00
FROM branches
WHERE branches.isDeleted = 0
GROUP BY branches.parentNoteId,
branches.noteId
2019-12-10 22:03:00 +01:00
HAVING COUNT(1) > 1`,
2025-01-09 18:07:02 +02:00
({ noteId, parentNoteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2024-04-03 20:47:41 +03:00
const branchIds = sql.getColumn<string>(
2025-01-09 18:07:02 +02:00
`SELECT branchId
FROM branches
WHERE noteId = ?
and parentNoteId = ?
and isDeleted = 0
2025-01-09 18:07:02 +02:00
ORDER BY utcDateModified`,
[noteId, parentNoteId]
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
const branches = branchIds.map((branchId) => becca.getBranch(branchId));
2021-05-02 19:59:16 +02:00
2019-12-10 22:03:00 +01:00
// it's not necessarily "original" branch, it's just the only one which will survive
const origBranch = branches[0];
2024-04-03 20:47:41 +03:00
if (!origBranch) {
logError(`Unable to find original branch.`);
return;
}
2019-12-10 22:03:00 +01:00
// delete all but the first branch
for (const branch of branches.slice(1)) {
2024-04-03 20:47:41 +03:00
if (!branch) {
continue;
}
branch.markAsDeleted();
2019-12-10 22:03:00 +01:00
2022-12-27 21:17:40 +01:00
logFix(`Removing branch '${branch.branchId}' since it's a parent-child duplicate of branch '${origBranch.branchId}'`);
2019-12-10 22:03:00 +01:00
}
this.reloadNeeded = true;
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Duplicate branches for note '${noteId}' and parent '${parentNoteId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-03-16 12:17:55 +01:00
SELECT attachmentId,
attachments.ownerId AS noteId
2023-03-16 12:17:55 +01:00
FROM attachments
JOIN notes ON notes.noteId = attachments.ownerId
WHERE notes.isDeleted = 1
AND attachments.isDeleted = 0`,
2025-01-09 18:07:02 +02:00
({ attachmentId, noteId }) => {
if (this.autoFix) {
2023-03-16 12:17:55 +01:00
const attachment = becca.getAttachment(attachmentId);
2024-04-03 20:47:41 +03:00
if (!attachment) return;
2023-03-16 12:17:55 +01:00
attachment.markAsDeleted();
this.reloadNeeded = false;
2023-06-29 22:10:13 +02:00
logFix(`Attachment '${attachmentId}' has been deleted since the associated note '${noteId}' is deleted.`);
} else {
2025-01-09 18:07:02 +02:00
logError(`Attachment '${attachmentId}' is not deleted even though the associated note '${noteId}' is deleted.`);
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
}
2020-06-20 12:31:38 +02:00
findLogicIssues() {
2025-01-09 18:07:02 +02:00
const noteTypesStr = noteTypes.map((nt) => `'${nt}'`).join(", ");
2022-01-31 21:25:18 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT noteId, type
FROM notes
WHERE isDeleted = 0
AND type NOT IN (${noteTypesStr})`,
2025-01-09 18:07:02 +02:00
({ noteId, type }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const note = becca.getNote(noteId);
2024-04-03 20:47:41 +03:00
if (!note) return;
2025-01-09 18:07:02 +02:00
note.type = "file"; // file is a safe option to recover notes if the type is not known
2020-06-20 12:31:38 +02:00
note.save();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2025-01-09 18:07:02 +02:00
logFix(`Note '${noteId}' type has been change to file since it had invalid type '${type}'`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Note '${noteId}' has invalid type '${type}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-03-16 13:29:11 +01:00
SELECT notes.noteId, notes.isProtected, notes.type, notes.mime
FROM notes
LEFT JOIN blobs USING (blobId)
2023-03-16 13:29:11 +01:00
WHERE blobs.blobId IS NULL
AND notes.isDeleted = 0`,
2025-01-09 18:07:02 +02:00
({ noteId, isProtected, type, mime }) => {
2023-03-16 13:29:11 +01:00
if (this.autoFix) {
// it might be possible that the blob is not available only because of the interrupted
// sync, and it will come later. It's therefore important to guarantee that this artificial
2023-03-16 13:29:11 +01:00
// record won't overwrite the real one coming from the sync.
const fakeDate = "2000-01-01 00:00:00Z";
const blankContent = getBlankContent(isProtected, type, mime);
2024-04-03 20:47:41 +03:00
if (!blankContent) {
logError(`Unable to recover note ${noteId} since it's content could not be retrieved (might be protected note).`);
return;
}
const blobId = hashedBlobId(blankContent);
2023-03-16 13:29:11 +01:00
const blobAlreadyExists = !!sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [blobId]);
if (!blobAlreadyExists) {
// manually creating row since this can also affect deleted notes
sql.upsert("blobs", "blobId", {
noteId: noteId,
content: blankContent,
utcDateModified: fakeDate,
dateModified: fakeDate
});
const hash = getHash(randomString(10));
2023-03-16 13:29:11 +01:00
2023-07-29 23:25:02 +02:00
entityChangesService.putEntityChange({
2025-01-09 18:07:02 +02:00
entityName: "blobs",
2023-03-16 13:29:11 +01:00
entityId: blobId,
hash: hash,
isErased: false,
utcDateChanged: fakeDate,
isSynced: true
});
}
sql.execute("UPDATE notes SET blobId = ? WHERE noteId = ?", [blobId, noteId]);
this.reloadNeeded = true;
logFix(`Note '${noteId}' content was set to empty string since there was no corresponding row`);
} else {
logError(`Note '${noteId}' content row does not exist`);
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
if (sqlInit.getDbSize() < 500000) {
// querying for "content IS NULL" is expensive since content is not indexed. See e.g. https://github.com/zadam/trilium/issues/2887
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-03-16 13:29:11 +01:00
SELECT notes.noteId, notes.type, notes.mime
FROM notes
JOIN blobs USING (blobId)
2023-03-16 13:29:11 +01:00
WHERE isDeleted = 0
AND isProtected = 0
AND content IS NULL`,
2025-01-09 18:07:02 +02:00
({ noteId, type, mime }) => {
2023-03-16 13:29:11 +01:00
if (this.autoFix) {
const note = becca.getNote(noteId);
const blankContent = getBlankContent(false, type, mime);
2024-04-03 20:47:41 +03:00
if (!note) return;
if (blankContent) {
note.setContent(blankContent);
}
2023-03-16 13:29:11 +01:00
this.reloadNeeded = true;
logFix(`Note '${noteId}' content was set to '${blankContent}' since it was null even though it is not deleted`);
} else {
logError(`Note '${noteId}' content is null even though it is not deleted`);
}
2025-01-09 18:07:02 +02:00
}
);
}
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
SELECT revisions.revisionId, blobs.blobId
FROM revisions
LEFT JOIN blobs USING (blobId)
2023-03-16 13:29:11 +01:00
WHERE blobs.blobId IS NULL`,
2025-01-09 18:07:02 +02:00
({ revisionId, blobId }) => {
2023-03-16 13:29:11 +01:00
if (this.autoFix) {
2023-12-04 00:11:24 +01:00
eraseService.eraseRevisions([revisionId]);
2023-03-16 13:29:11 +01:00
this.reloadNeeded = true;
logFix(`Note revision '${revisionId}' was erased since the referenced blob '${blobId}' did not exist.`);
2023-03-16 13:29:11 +01:00
} else {
logError(`Note revision '${revisionId}' blob '${blobId}' does not exist`);
}
2025-01-09 18:07:02 +02:00
}
);
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
SELECT attachments.attachmentId, blobs.blobId
FROM attachments
LEFT JOIN blobs USING (blobId)
WHERE blobs.blobId IS NULL`,
2025-01-09 18:07:02 +02:00
({ attachmentId, blobId }) => {
if (this.autoFix) {
eraseService.eraseAttachments([attachmentId]);
this.reloadNeeded = true;
logFix(`Attachment '${attachmentId}' was erased since the referenced blob '${blobId}' did not exist.`);
} else {
logError(`Attachment '${attachmentId}' blob '${blobId}' does not exist`);
2023-03-16 13:29:11 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT parentNoteId
FROM branches
JOIN notes ON notes.noteId = branches.parentNoteId
2019-12-10 22:03:00 +01:00
WHERE notes.isDeleted = 0
AND notes.type == 'search'
AND branches.isDeleted = 0`,
2025-01-09 18:07:02 +02:00
({ parentNoteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2025-01-09 18:07:02 +02:00
const branchIds = sql.getColumn<string>(
`
SELECT branchId
FROM branches
WHERE isDeleted = 0
2025-01-09 18:07:02 +02:00
AND parentNoteId = ?`,
[parentNoteId]
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
const branches = branchIds.map((branchId) => becca.getBranch(branchId));
2021-05-02 19:59:16 +02:00
2019-12-10 22:03:00 +01:00
for (const branch of branches) {
2024-04-03 20:47:41 +03:00
if (!branch) continue;
// delete the old wrong branch
branch.markAsDeleted("parent-is-search");
2019-12-10 22:03:00 +01:00
// create a replacement branch in root parent
2023-03-16 13:29:11 +01:00
new BBranch({
2025-01-09 18:07:02 +02:00
parentNoteId: "root",
noteId: branch.noteId,
2025-01-09 18:07:02 +02:00
prefix: "recovered"
}).save();
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
logFix(`Note '${branch.noteId}' has been moved to root since it was a child of a search note '${parentNoteId}'`);
2019-12-10 22:03:00 +01:00
}
this.reloadNeeded = true;
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Search note '${parentNoteId}' has children`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId
FROM attributes
WHERE isDeleted = 0
AND type = 'relation'
AND value = ''`,
2025-01-09 18:07:02 +02:00
({ attributeId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const relation = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!relation) return;
relation.markAsDeleted();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Removed relation '${relation.attributeId}' of name '${relation.name}' with empty target.`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Relation '${attributeId}' has empty target.`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId,
type
2019-12-10 22:03:00 +01:00
FROM attributes
WHERE isDeleted = 0
AND type != 'label'
AND type != 'relation'`,
2025-01-09 18:07:02 +02:00
({ attributeId, type }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const attribute = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!attribute) return;
2025-01-09 18:07:02 +02:00
attribute.type = "label";
2020-06-20 12:31:38 +02:00
attribute.save();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Attribute '${attributeId}' type was changed to label since it had invalid type '${type}'`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Attribute '${attributeId}' has invalid type '${type}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId,
attributes.noteId
2019-12-10 22:03:00 +01:00
FROM attributes
JOIN notes ON attributes.noteId = notes.noteId
2019-12-10 22:03:00 +01:00
WHERE attributes.isDeleted = 0
AND notes.isDeleted = 1`,
2025-01-09 18:07:02 +02:00
({ attributeId, noteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const attribute = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!attribute) return;
attribute.markAsDeleted();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Removed attribute '${attributeId}' because owning note '${noteId}' is also deleted.`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Attribute '${attributeId}' is not deleted even though owning note '${noteId}' is deleted.`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2019-12-10 22:03:00 +01:00
SELECT attributeId,
attributes.value AS targetNoteId
2019-12-10 22:03:00 +01:00
FROM attributes
JOIN notes ON attributes.value = notes.noteId
2019-12-10 22:03:00 +01:00
WHERE attributes.type = 'relation'
AND attributes.isDeleted = 0
AND notes.isDeleted = 1`,
2025-01-09 18:07:02 +02:00
({ attributeId, targetNoteId }) => {
2019-12-10 22:03:00 +01:00
if (this.autoFix) {
2021-05-02 11:23:58 +02:00
const attribute = becca.getAttribute(attributeId);
2024-04-03 20:47:41 +03:00
if (!attribute) return;
attribute.markAsDeleted();
2019-12-10 22:03:00 +01:00
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Removed attribute '${attributeId}' because target note '${targetNoteId}' is also deleted.`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Attribute '${attributeId}' is not deleted even though target note '${targetNoteId}' is deleted.`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
}
2024-04-03 20:47:41 +03:00
runEntityChangeChecks(entityName: string, key: string) {
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-07-29 21:59:20 +02:00
SELECT ${key} as entityId
FROM ${entityName}
LEFT JOIN entity_changes ec ON ec.entityName = '${entityName}' AND ec.entityId = ${entityName}.${key}
2023-07-29 21:59:20 +02:00
WHERE ec.id IS NULL`,
2025-01-09 18:07:02 +02:00
({ entityId }) => {
2024-04-03 20:47:41 +03:00
const entityRow = sql.getRow<EntityChange>(`SELECT * FROM ${entityName} WHERE ${key} = ?`, [entityId]);
2021-08-07 21:21:30 +02:00
2021-09-08 21:54:26 +02:00
if (this.autoFix) {
2023-07-29 23:25:02 +02:00
entityChangesService.putEntityChange({
2021-09-08 21:54:26 +02:00
entityName,
entityId,
hash: randomString(10), // doesn't matter, will force sync, but that's OK
2023-07-29 21:59:20 +02:00
isErased: false,
2023-06-05 09:23:42 +02:00
utcDateChanged: entityRow.utcDateModified || entityRow.utcDateCreated,
2025-01-09 18:07:02 +02:00
isSynced: entityName !== "options" || entityRow.isSynced
2021-09-08 21:54:26 +02:00
});
2019-02-02 11:26:27 +01:00
2022-12-27 10:22:50 +01:00
logFix(`Created missing entity change for entityName '${entityName}', entityId '${entityId}'`);
2019-12-10 22:03:00 +01:00
} else {
2022-12-27 10:22:50 +01:00
logError(`Missing entity change for entityName '${entityName}', entityId '${entityId}'`);
2019-12-10 22:03:00 +01:00
}
2025-01-09 18:07:02 +02:00
}
);
2019-02-02 11:26:27 +01:00
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-07-29 21:59:20 +02:00
SELECT id, entityId
FROM entity_changes
LEFT JOIN ${entityName} ON entityId = ${entityName}.${key}
2020-12-14 13:15:32 +01:00
WHERE
entity_changes.isErased = 0
AND entity_changes.entityName = '${entityName}'
AND ${entityName}.${key} IS NULL`,
2025-01-09 18:07:02 +02:00
({ id, entityId }) => {
if (this.autoFix) {
sql.execute("DELETE FROM entity_changes WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
2025-01-09 18:07:02 +02:00
logFix(`Deleted extra entity change id '${id}', entityName '${entityName}', entityId '${entityId}'`);
} else {
logError(`Unrecognized entity change id '${id}', entityName '${entityName}', entityId '${entityId}'`);
}
}
);
2025-01-09 18:07:02 +02:00
this.findAndFixIssues(
`
2023-07-29 21:59:20 +02:00
SELECT id, entityId
FROM entity_changes
JOIN ${entityName} ON entityId = ${entityName}.${key}
WHERE
entity_changes.isErased = 1
AND entity_changes.entityName = '${entityName}'`,
2025-01-09 18:07:02 +02:00
({ id, entityId }) => {
if (this.autoFix) {
sql.execute(`DELETE FROM ${entityName} WHERE ${key} = ?`, [entityId]);
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Erasing entityName '${entityName}', entityId '${entityId}' since entity change id '${id}' has it as erased.`);
} else {
2022-12-27 10:22:50 +01:00
logError(`Entity change id '${id}' has entityName '${entityName}', entityId '${entityId}' as erased, but it's not.`);
}
2025-01-09 18:07:02 +02:00
}
);
2019-12-10 22:03:00 +01:00
}
2019-02-02 11:26:27 +01:00
findEntityChangeIssues() {
this.runEntityChangeChecks("notes", "noteId");
this.runEntityChangeChecks("revisions", "revisionId");
2023-03-16 12:17:55 +01:00
this.runEntityChangeChecks("attachments", "attachmentId");
2023-03-16 11:02:07 +01:00
this.runEntityChangeChecks("blobs", "blobId");
this.runEntityChangeChecks("branches", "branchId");
this.runEntityChangeChecks("attributes", "attributeId");
2022-01-10 17:09:20 +01:00
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
this.runEntityChangeChecks("options", "name");
2019-12-10 22:03:00 +01:00
}
2019-02-02 11:26:27 +01:00
findWronglyNamedAttributes() {
2024-04-03 20:47:41 +03:00
const attrNames = sql.getColumn<string>(`SELECT DISTINCT name FROM attributes`);
for (const origName of attrNames) {
const fixedName = sanitizeAttributeName(origName);
2020-11-17 22:35:20 +01:00
if (fixedName !== origName) {
if (this.autoFix) {
// there isn't a good way to update this:
// - just SQL query will fix it in DB but not notify frontend (or other caches) that it has been fixed
// - renaming the attribute would break the invariant that single attribute never changes the name
// - deleting the old attribute and creating new will create duplicates across synchronized cluster (specifically in the initial migration)
2023-05-05 23:41:11 +02:00
// But in general, we assume there won't be many such problems
2025-01-09 18:07:02 +02:00
sql.execute("UPDATE attributes SET name = ? WHERE name = ?", [fixedName, origName]);
this.fixedIssues = true;
this.reloadNeeded = true;
2022-12-27 10:22:50 +01:00
logFix(`Renamed incorrectly named attributes '${origName}' to '${fixedName}'`);
2025-01-09 18:07:02 +02:00
} else {
this.unrecoveredConsistencyErrors = true;
2022-12-27 10:22:50 +01:00
logFix(`There are incorrectly named attributes '${origName}'`);
}
}
}
}
findSyncIssues() {
const lastSyncedPush = parseInt(sql.getValue("SELECT value FROM options WHERE name = 'lastSyncedPush'"));
2024-04-03 20:47:41 +03:00
const maxEntityChangeId = sql.getValue<number>("SELECT MAX(id) FROM entity_changes");
if (lastSyncedPush > maxEntityChangeId) {
if (this.autoFix) {
sql.execute("UPDATE options SET value = ? WHERE name = 'lastSyncedPush'", [maxEntityChangeId]);
this.fixedIssues = true;
logFix(`Fixed incorrect lastSyncedPush - was ${lastSyncedPush}, needs to be at maximum ${maxEntityChangeId}`);
2025-01-09 18:07:02 +02:00
} else {
this.unrecoveredConsistencyErrors = true;
logFix(`Incorrect lastSyncedPush - is ${lastSyncedPush}, needs to be at maximum ${maxEntityChangeId}`);
}
}
}
2020-07-01 22:42:59 +02:00
runAllChecksAndFixers() {
2019-12-10 22:03:00 +01:00
this.unrecoveredConsistencyErrors = false;
this.fixedIssues = false;
this.reloadNeeded = false;
2019-02-02 11:26:27 +01:00
this.findEntityChangeIssues();
2020-06-20 12:31:38 +02:00
this.findBrokenReferenceIssues();
2019-02-02 11:26:27 +01:00
2020-06-20 12:31:38 +02:00
this.findExistencyIssues();
2019-02-02 11:26:27 +01:00
2020-06-20 12:31:38 +02:00
this.findLogicIssues();
2019-02-02 11:26:27 +01:00
this.findWronglyNamedAttributes();
this.findSyncIssues();
// root branch should always be expanded
sql.execute("UPDATE branches SET isExpanded = 1 WHERE noteId = 'root'");
2020-12-09 22:49:55 +01:00
if (!this.unrecoveredConsistencyErrors) {
2019-12-10 22:03:00 +01:00
// we run this only if basic checks passed since this assumes basic data consistency
2018-01-01 19:41:22 -05:00
2022-05-03 00:30:09 +02:00
this.checkAndRepairTreeCycles();
2019-12-10 22:03:00 +01:00
}
2018-01-01 19:41:22 -05:00
if (this.reloadNeeded) {
becca_loader.reload("consistency checks need becca reload");
2021-05-01 11:38:20 +02:00
}
2019-12-10 22:03:00 +01:00
return !this.unrecoveredConsistencyErrors;
}
2020-12-16 15:01:20 +01:00
runDbDiagnostics() {
2024-04-03 20:47:41 +03:00
function getTableRowCount(tableName: string) {
const count = sql.getValue<number>(`SELECT COUNT(1) FROM ${tableName}`);
2020-12-23 21:22:41 +01:00
return `${tableName}: ${count}`;
2020-12-16 15:01:20 +01:00
}
2025-01-09 18:07:02 +02:00
const tables = ["notes", "revisions", "attachments", "branches", "attributes", "etapi_tokens", "blobs"];
2020-12-23 21:22:41 +01:00
2025-01-09 18:07:02 +02:00
log.info(`Table counts: ${tables.map((tableName) => getTableRowCount(tableName)).join(", ")}`);
2019-12-10 22:03:00 +01:00
}
2019-12-10 22:03:00 +01:00
async runChecks() {
let elapsedTimeMs;
2020-06-20 12:31:38 +02:00
await syncMutexService.doExclusively(() => {
2023-09-05 00:30:09 +02:00
const startTimeMs = Date.now();
this.runDbDiagnostics();
this.runAllChecksAndFixers();
elapsedTimeMs = Date.now() - startTimeMs;
2019-12-10 22:03:00 +01:00
});
2019-12-10 22:03:00 +01:00
if (this.unrecoveredConsistencyErrors) {
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms)`);
2018-01-01 19:41:22 -05:00
2025-01-09 18:07:02 +02:00
ws.sendMessageToAllClients({ type: "consistency-checks-failed" });
2019-12-10 22:03:00 +01:00
} else {
2025-01-09 18:07:02 +02:00
log.info(`All consistency checks passed ` + (this.fixedIssues ? "after some fixes" : "with no errors detected") + ` (took ${elapsedTimeMs}ms)`);
2019-12-10 22:03:00 +01:00
}
}
2017-12-14 22:16:26 -05:00
}
2024-04-03 20:47:41 +03:00
function getBlankContent(isProtected: boolean, type: string, mime: string) {
2021-09-11 14:34:37 +02:00
if (isProtected) {
2023-05-05 23:41:11 +02:00
return null; // this is wrong for protected non-erased notes, but we cannot create a valid value without a password
2021-09-11 14:34:37 +02:00
}
2025-01-09 18:07:02 +02:00
if (mime === "application/json") {
return "{}";
2021-09-11 14:34:37 +02:00
}
2025-01-09 18:07:02 +02:00
return ""; // empty string might be a wrong choice for some note types, but it's the best guess
2021-09-11 14:34:37 +02:00
}
2024-04-03 20:47:41 +03:00
function logFix(message: string) {
log.info(`Consistency issue fixed: ${message}`);
2019-02-02 09:26:57 +01:00
}
2024-04-03 20:47:41 +03:00
function logError(message: string) {
log.info(`Consistency error: ${message}`);
2019-02-02 09:26:57 +01:00
}
2020-06-20 12:31:38 +02:00
function runPeriodicChecks() {
2025-01-09 18:07:02 +02:00
const autoFix = optionsService.getOptionBool("autoFixConsistencyIssues");
2020-06-20 12:31:38 +02:00
2019-12-10 22:03:00 +01:00
const consistencyChecks = new ConsistencyChecks(autoFix);
2020-06-20 12:31:38 +02:00
consistencyChecks.runChecks();
2019-12-10 22:03:00 +01:00
}
2024-04-03 20:47:41 +03:00
async function runOnDemandChecks(autoFix: boolean) {
2019-12-10 22:03:00 +01:00
const consistencyChecks = new ConsistencyChecks(autoFix);
await consistencyChecks.runChecks();
}
function runEntityChangesChecks() {
const consistencyChecks = new ConsistencyChecks(true);
consistencyChecks.findEntityChangeIssues();
}
2020-06-20 21:42:41 +02:00
sqlInit.dbReady.then(() => {
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
2017-12-14 22:16:26 -05:00
2020-06-20 21:42:41 +02:00
// kickoff checks soon after startup (to not block the initial load)
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
2020-06-20 21:42:41 +02:00
});
2017-12-14 22:16:26 -05:00
export default {
runOnDemandChecks,
runEntityChangesChecks
2020-06-20 12:31:38 +02:00
};