diff --git a/src/public/javascripts/dialogs/options/advanced.js b/src/public/javascripts/dialogs/options/advanced.js
index 50ce5eca3..d33b94ba8 100644
--- a/src/public/javascripts/dialogs/options/advanced.js
+++ b/src/public/javascripts/dialogs/options/advanced.js
@@ -13,6 +13,10 @@ const TPL = `
+
Consistency checks
+
+
+
Debugging
@@ -33,9 +37,8 @@ export default class AdvancedOptions {
this.$forceFullSyncButton = $("#force-full-sync-button");
this.$fillSyncRowsButton = $("#fill-sync-rows-button");
this.$anonymizeButton = $("#anonymize-button");
- this.$cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
- this.$cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
this.$vacuumDatabaseButton = $("#vacuum-database-button");
+ this.$findAndFixConsistencyIssuesButton = $("#find-and-fix-consistency-issues-button");
this.$forceFullSyncButton.on('click', async () => {
await server.post('sync/force-full-sync');
@@ -55,26 +58,16 @@ export default class AdvancedOptions {
toastService.showMessage("Created anonymized database");
});
- this.$cleanupSoftDeletedButton.on('click', async () => {
- if (confirm("Do you really want to clean up soft-deleted items?")) {
- await server.post('cleanup/cleanup-soft-deleted-items');
-
- toastService.showMessage("Soft deleted items have been cleaned up");
- }
- });
-
- this.$cleanupUnusedImagesButton.on('click', async () => {
- if (confirm("Do you really want to clean up unused images?")) {
- await server.post('cleanup/cleanup-unused-images');
-
- toastService.showMessage("Unused images have been cleaned up");
- }
- });
-
this.$vacuumDatabaseButton.on('click', async () => {
await server.post('cleanup/vacuum-database');
toastService.showMessage("Database has been vacuumed");
});
+
+ this.$findAndFixConsistencyIssuesButton.on('click', async () => {
+ await server.post('cleanup/find-and-fix-consistency-issues');
+
+ toastService.showMessage("Consistency issues should be fixed.");
+ });
}
}
\ No newline at end of file
diff --git a/src/routes/api/cleanup.js b/src/routes/api/cleanup.js
index 01ef08376..877a90898 100644
--- a/src/routes/api/cleanup.js
+++ b/src/routes/api/cleanup.js
@@ -2,6 +2,7 @@
const sql = require('../../services/sql');
const log = require('../../services/log');
+const consistencyChecksService = require('../../services/consistency_checks');
async function vacuumDatabase() {
await sql.execute("VACUUM");
@@ -9,6 +10,11 @@ async function vacuumDatabase() {
log.info("Database has been vacuumed.");
}
+async function findAndFixConsistencyIssues() {
+ await consistencyChecksService.runOnDemandChecks(true);
+}
+
module.exports = {
- vacuumDatabase
+ vacuumDatabase,
+ findAndFixConsistencyIssues
};
\ No newline at end of file
diff --git a/src/routes/routes.js b/src/routes/routes.js
index eca807938..977a2d581 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -215,10 +215,11 @@ function register(app) {
apiRoute(POST, '/api/sql/execute', sqlRoute.execute);
apiRoute(POST, '/api/anonymization/anonymize', anonymizationRoute.anonymize);
- apiRoute(POST, '/api/cleanup/cleanup-unused-images', cleanupRoute.cleanupUnusedImages);
// VACUUM requires execution outside of transaction
route(POST, '/api/cleanup/vacuum-database', [auth.checkApiAuthOrElectron, csrfMiddleware], cleanupRoute.vacuumDatabase, apiResultHandler, false);
+ route(POST, '/api/cleanup/find-and-fix-consistency-issues', [auth.checkApiAuthOrElectron, csrfMiddleware], cleanupRoute.findAndFixConsistencyIssues, apiResultHandler, false);
+
apiRoute(POST, '/api/script/exec', scriptRoute.exec);
apiRoute(POST, '/api/script/run/:noteId', scriptRoute.run);
apiRoute(GET, '/api/script/startup', scriptRoute.getStartupBundles);
diff --git a/src/services/consistency_checks.js b/src/services/consistency_checks.js
index b587e9f93..9c8a17dae 100644
--- a/src/services/consistency_checks.js
+++ b/src/services/consistency_checks.js
@@ -11,550 +11,514 @@ const syncTableService = require('./sync_table');
const optionsService = require('./options');
const Branch = require('../entities/branch');
-let unrecoveredConsistencyErrors = false;
-let fixedIssues = false;
-
-async function findIssues(query, errorCb) {
- const results = await sql.getRows(query);
-
- for (const res of results) {
- logError(errorCb(res));
-
- unrecoveredConsistencyErrors = true;
+class ConsistencyChecks {
+ constructor(autoFix) {
+ this.autoFix = autoFix;
+ this.unrecoveredConsistencyErrors = false;
+ this.fixedIssues = false;
}
- return results;
-}
+ async findIssues(query, errorCb) {
+ const results = await sql.getRows(query);
-async function findAndFixIssues(query, fixerCb) {
- const results = await sql.getRows(query);
+ for (const res of results) {
+ logError(errorCb(res));
- for (const res of results) {
- const autoFix = await optionsService.getOptionBool('autoFixConsistencyIssues');
+ this.unrecoveredConsistencyErrors = true;
+ }
- try {
- await fixerCb(res, autoFix);
+ return results;
+ }
- if (autoFix) {
- fixedIssues = true;
- } else {
- unrecoveredConsistencyErrors = true;
+ async findAndFixIssues(query, fixerCb) {
+ const results = await sql.getRows(query);
+
+ for (const res of results) {
+ try {
+ await fixerCb(res);
+
+ if (this.autoFix) {
+ this.fixedIssues = true;
+ } else {
+ this.unrecoveredConsistencyErrors = true;
+ }
+ } catch (e) {
+ logError(`Fixer failed with ${e.message} ${e.stack}`);
+ this.unrecoveredConsistencyErrors = true;
}
}
- catch (e) {
- logError(`Fixer failed with ${e.message} ${e.stack}`);
- unrecoveredConsistencyErrors = true;
+
+ return results;
+ }
+
+ async checkTreeCycles() {
+ const childToParents = {};
+ const rows = await sql.getRows("SELECT noteId, parentNoteId FROM branches WHERE isDeleted = 0");
+
+ for (const row of rows) {
+ const childNoteId = row.noteId;
+ const parentNoteId = row.parentNoteId;
+
+ childToParents[childNoteId] = childToParents[childNoteId] || [];
+ childToParents[childNoteId].push(parentNoteId);
+ }
+
+ function checkTreeCycle(noteId, path) {
+ if (noteId === 'root') {
+ return;
+ }
+
+ if (!childToParents[noteId] || childToParents[noteId].length === 0) {
+ logError(`No parents found for note ${noteId}`);
+
+ this.unrecoveredConsistencyErrors = true;
+ return;
+ }
+
+ for (const parentNoteId of childToParents[noteId]) {
+ if (path.includes(parentNoteId)) {
+ logError(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
+
+ this.unrecoveredConsistencyErrors = true;
+ } else {
+ const newPath = path.slice();
+ newPath.push(noteId);
+
+ checkTreeCycle(parentNoteId, newPath);
+ }
+ }
+ }
+
+ const noteIds = Object.keys(childToParents);
+
+ for (const noteId of noteIds) {
+ checkTreeCycle(noteId, []);
+ }
+
+ if (childToParents['root'].length !== 1 || childToParents['root'][0] !== 'none') {
+ logError('Incorrect root parent: ' + JSON.stringify(childToParents['root']));
+ this.unrecoveredConsistencyErrors = true;
}
}
- return results;
-}
+ async findBrokenReferenceIssues() {
+ await this.findAndFixIssues(`
+ SELECT branchId, branches.noteId
+ FROM branches
+ LEFT JOIN notes USING (noteId)
+ WHERE branches.isDeleted = 0
+ AND notes.noteId IS NULL`,
+ async ({branchId, noteId}) => {
+ if (this.autoFix) {
+ const branch = await repository.getBranch(branchId);
+ branch.isDeleted = true;
+ await branch.save();
-async function checkTreeCycles() {
- const childToParents = {};
- const rows = await sql.getRows("SELECT noteId, parentNoteId FROM branches WHERE isDeleted = 0");
+ logFix(`Branch ${branchId} has been deleted since it references missing note ${noteId}`);
+ } else {
+ logError(`Branch ${branchId} references missing note ${noteId}`);
+ }
+ });
- for (const row of rows) {
- const childNoteId = row.noteId;
- const parentNoteId = row.parentNoteId;
-
- childToParents[childNoteId] = childToParents[childNoteId] || [];
- childToParents[childNoteId].push(parentNoteId);
- }
-
- function checkTreeCycle(noteId, path) {
- if (noteId === 'root') {
- return;
- }
-
- if (!childToParents[noteId] || childToParents[noteId].length === 0) {
- logError(`No parents found for note ${noteId}`);
-
- unrecoveredConsistencyErrors = true;
- return;
- }
-
- for (const parentNoteId of childToParents[noteId]) {
- if (path.includes(parentNoteId)) {
- logError(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
-
- unrecoveredConsistencyErrors = true;
- }
- else {
- const newPath = path.slice();
- newPath.push(noteId);
-
- checkTreeCycle(parentNoteId, newPath);
- }
- }
- }
-
- const noteIds = Object.keys(childToParents);
-
- for (const noteId of noteIds) {
- checkTreeCycle(noteId, []);
- }
-
- if (childToParents['root'].length !== 1 || childToParents['root'][0] !== 'none') {
- logError('Incorrect root parent: ' + JSON.stringify(childToParents['root']));
- unrecoveredConsistencyErrors = true;
- }
-}
-
-async function findBrokenReferenceIssues() {
- await findAndFixIssues(`
- SELECT branchId, branches.noteId
- FROM branches LEFT JOIN notes USING(noteId)
- WHERE branches.isDeleted = 0 AND notes.noteId IS NULL`,
- async ({branchId, noteId}, autoFix) => {
- if (autoFix) {
- const branch = await repository.getBranch(branchId);
- branch.isDeleted = true;
- await branch.save();
-
- logFix(`Branch ${branchId} has been deleted since it references missing note ${noteId}`);
- }
- else {
- logError(`Branch ${branchId} references missing note ${noteId}`);
- }
- });
-
- await findAndFixIssues(`
- SELECT branchId, branches.noteId AS parentNoteId
- FROM branches LEFT JOIN notes ON notes.noteId = branches.parentNoteId
- WHERE branches.isDeleted = 0 AND branches.branchId != 'root' AND notes.noteId IS NULL`,
- async ({branchId, parentNoteId}, autoFix) => {
- if (autoFix) {
- const branch = await repository.getBranch(branchId);
- branch.parentNoteId = 'root';
- await branch.save();
-
- logFix(`Branch ${branchId} was set to root parent since it was referencing missing parent note ${parentNoteId}`);
- }
- else {
- logError(`Branch ${branchId} references missing parent note ${parentNoteId}`);
- }
- });
-
- await findAndFixIssues(`
- SELECT attributeId, attributes.noteId
- FROM attributes LEFT JOIN notes USING(noteId)
- WHERE attributes.isDeleted = 0 AND notes.noteId IS NULL`,
- async ({attributeId, noteId}, autoFix) => {
- if (autoFix) {
- const attribute = await repository.getAttribute(attributeId);
- attribute.isDeleted = true;
- await attribute.save();
-
- logFix(`Attribute ${attributeId} has been deleted since it references missing source note ${noteId}`);
- }
- else {
- logError(`Attribute ${attributeId} references missing source note ${noteId}`);
- }
- });
-
- await findAndFixIssues(`
- SELECT attributeId, attributes.value AS noteId
- FROM attributes LEFT JOIN notes ON notes.noteId = attributes.value
- WHERE attributes.isDeleted = 0 AND attributes.type = 'relation'
- AND notes.noteId IS NULL`,
- async ({attributeId, noteId}, autoFix) => {
- if (autoFix) {
- const attribute = await repository.getAttribute(attributeId);
- attribute.isDeleted = true;
- await attribute.save();
-
- logFix(`Relation ${attributeId} has been deleted since it references missing note ${noteId}`)
- }
- else {
- logError(`Relation ${attributeId} references missing note ${noteId}`)
- }
- });
-
- await findIssues(`
- SELECT noteRevisionId, note_revisions.noteId
- FROM note_revisions LEFT JOIN notes USING(noteId)
- WHERE notes.noteId IS NULL`,
- ({noteRevisionId, noteId}) => `Note revision ${noteRevisionId} references missing note ${noteId}`);
-}
-
-async function findExistencyIssues() {
- // 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 note is not deleted, then at least one branch should exist.
-
- // the order here is important - first we might need to delete inconsistent branches and after that
- // another check might create missing branch
- await findAndFixIssues(`
- SELECT
- branchId, noteId
- FROM
- branches
- JOIN notes USING(noteId)
- WHERE
- notes.isDeleted = 1
- AND branches.isDeleted = 0`,
- async ({branchId, noteId}, autoFix) => {
- if (autoFix) {
- const branch = await repository.getBranch(branchId);
- branch.isDeleted = true;
- await branch.save();
-
- logFix(`Branch ${branchId} has been deleted since associated note ${noteId} is deleted.`);
- }
- else {
- logError(`Branch ${branchId} is not deleted even though associated note ${noteId} is deleted.`)
- }
- });
-
- await findAndFixIssues(`
- SELECT
- branchId, parentNoteId
- FROM
- branches
- JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId
- WHERE
- parentNote.isDeleted = 1
- AND branches.isDeleted = 0
- `, async ({branchId, parentNoteId}, autoFix) => {
- if (autoFix) {
- const branch = await repository.getBranch(branchId);
- branch.isDeleted = true;
- await branch.save();
-
- logFix(`Branch ${branchId} has been deleted since associated parent note ${parentNoteId} is deleted.`);
- }
- else {
- logError(`Branch ${branchId} is not deleted even though associated parent note ${parentNoteId} is deleted.`)
- }
- });
-
- await findAndFixIssues(`
- SELECT
- DISTINCT notes.noteId
- FROM
- notes
- LEFT JOIN branches ON notes.noteId = branches.noteId AND branches.isDeleted = 0
- WHERE
- notes.isDeleted = 0
- AND branches.branchId IS NULL
- `, async ({noteId}, autoFix) => {
- if (autoFix) {
- const branch = await new Branch({
- parentNoteId: 'root',
- noteId: noteId,
- prefix: 'recovered'
- }).save();
-
- logFix(`Created missing branch ${branch.branchId} for note ${noteId}`);
- }
- else {
- logError(`No undeleted branch found for note ${noteId}`);
- }
- });
-
- // there should be a unique relationship between note and its parent
- await findAndFixIssues(`
- SELECT
- noteId, parentNoteId
- FROM
- branches
- WHERE
- branches.isDeleted = 0
- GROUP BY
- branches.parentNoteId,
- branches.noteId
- HAVING
- COUNT(1) > 1`,
- async ({noteId, parentNoteId}, autoFix) => {
- if (autoFix) {
- const branches = await repository.getEntities(
- `SELECT *
- FROM branches
- WHERE noteId = ?
- and parentNoteId = ?
- and isDeleted = 0`, [noteId, parentNoteId]);
-
- // it's not necessarily "original" branch, it's just the only one which will survive
- const origBranch = branches[0];
-
- // delete all but the first branch
- for (const branch of branches.slice(1)) {
- branch.isDeleted = true;
- await branch.save();
-
- logFix(`Removing branch ${branch.branchId} since it's parent-child duplicate of branch ${origBranch.branchId}`);
- }
- }
- else {
- logError(`Duplicate branches for note ${noteId} and parent ${parentNoteId}`);
- }
- });
-}
-
-async function findLogicIssues() {
- await findAndFixIssues( `
- SELECT noteId, type
- FROM notes
- WHERE
- isDeleted = 0
- AND type NOT IN ('text', 'code', 'render', 'file', 'image', 'search', 'relation-map', 'book')`,
- async ({noteId, type}, autoFix) => {
- if (autoFix) {
- const note = await repository.getNote(noteId);
- note.type = 'file'; // file is a safe option to recover notes if type is not known
- await note.save();
-
- logFix(`Note ${noteId} type has been change to file since it had invalid type=${type}`)
- }
- else {
- logError(`Note ${noteId} has invalid type=${type}`);
- }
- });
-
- await findAndFixIssues(`
- SELECT notes.noteId
- FROM notes
- LEFT JOIN note_contents USING(noteId)
- WHERE
- note_contents.noteId IS NULL`,
- async ({noteId}, autoFix) => {
- if (autoFix) {
- const note = await repository.getNote(noteId);
- // empty string might be wrong choice for some note types (and protected notes) but it's a best guess
- await note.setContent(note.isErased ? null : '');
-
- logFix(`Note ${noteId} content was set to empty string since there was no corresponding row`);
- }
- else {
- logError(`Note ${noteId} content row does not exist`);
- }
- });
-
- await findAndFixIssues(`
- SELECT noteId
- FROM notes
- JOIN note_contents USING(noteId)
- WHERE
- isDeleted = 0
- AND content IS NULL`,
- async ({noteId}, autoFix) => {
- if (autoFix) {
- const note = await repository.getNote(noteId);
- // empty string might be wrong choice for some note types (and protected notes) but it's a best guess
- await note.setContent('');
-
- logFix(`Note ${noteId} content was set to empty string since it was null even though it is not deleted`);
- }
- else {
- logError(`Note ${noteId} content is null even though it is not deleted`);
- }
- });
-
- await findIssues(`
- SELECT noteId
- FROM notes
- JOIN note_contents USING(noteId)
- WHERE
- isErased = 1
- AND content IS NOT NULL`,
- ({noteId}) => `Note ${noteId} content is not null even though the note is erased`);
-
- await findAndFixIssues(`
- SELECT noteId, noteRevisionId
- FROM notes
- JOIN note_revisions USING(noteId)
- WHERE
- notes.isErased = 1
- AND note_revisions.isErased = 0`,
- async ({noteId, noteRevisionId}, autoFix) => {
- if (autoFix) {
- const noteRevision = await repository.getNoteRevision(noteRevisionId);
- noteRevision.isErased = true;
- await noteRevision.setContent(null);
- await noteRevision.save();
-
- logFix(`Note revision ${noteRevisionId} has been erased since its note ${noteId} is also erased.`);
- }
- else {
- logError(`Note revision ${noteRevisionId} is not erased even though note ${noteId} is erased.`);
- }
- });
-
- await findAndFixIssues(`
- SELECT note_revisions.noteRevisionId
- FROM note_revisions
- LEFT JOIN note_revision_contents USING(noteRevisionId)
- WHERE note_revision_contents.noteRevisionId IS NULL`,
- async ({noteRevisionId}, autoFix) => {
- if (autoFix) {
- const noteRevision = await repository.getNoteRevision(noteRevisionId);
- await noteRevision.setContent(null);
- noteRevision.isErased = true;
- await noteRevision.save();
-
- logFix(`Note revision content ${noteRevisionId} was created and set to erased since it did not exist.`);
- }
- else {
- logError(`Note revision content ${noteRevisionId} does not exist`);
- }
- });
-
- await findAndFixIssues(`
- SELECT noteRevisionId
- FROM note_revisions
- JOIN note_revision_contents USING(noteRevisionId)
- WHERE
- isErased = 0
- AND content IS NULL`,
- async ({noteRevisionId}, autoFix) => {
- if (autoFix) {
- const noteRevision = await repository.getNoteRevision(noteRevisionId);
- noteRevision.isErased = true;
- await noteRevision.save();
-
- logFix(`Note revision ${noteRevisionId} content was set to empty string since it was null even though it is not erased`);
- }
- else {
- logError(`Note revision ${noteRevisionId} content is null even though it is not erased`);
- }
- });
-
- await findIssues(`
- SELECT noteRevisionId
- FROM note_revisions
- JOIN note_revision_contents USING(noteRevisionId)
- WHERE
- isErased = 1
- AND content IS NOT NULL`,
- ({noteRevisionId}) => `Note revision ${noteRevisionId} content is not null even though the note revision is erased`);
-
- await findIssues(`
- SELECT noteId
- FROM notes
- WHERE
- isErased = 1
- AND isDeleted = 0`,
- ({noteId}) => `Note ${noteId} is not deleted even though it is erased`);
-
- await findAndFixIssues(`
- SELECT parentNoteId
- FROM
- branches
- JOIN notes ON notes.noteId = branches.parentNoteId
- WHERE
- notes.isDeleted = 0
- AND notes.type == 'search'
- AND branches.isDeleted = 0`,
- async ({parentNoteId}, autoFix) => {
- if (autoFix) {
- const branches = await repository.getEntities(`SELECT * FROM branches WHERE isDeleted = 0 AND parentNoteId = ?`, [parentNoteId]);
-
- for (const branch of branches) {
+ await this.findAndFixIssues(`
+ SELECT branchId, branches.noteId AS parentNoteId
+ FROM branches
+ LEFT JOIN notes ON notes.noteId = branches.parentNoteId
+ WHERE branches.isDeleted = 0
+ AND branches.branchId != 'root'
+ AND notes.noteId IS NULL`,
+ async ({branchId, parentNoteId}) => {
+ if (this.autoFix) {
+ const branch = await repository.getBranch(branchId);
branch.parentNoteId = 'root';
await branch.save();
- logFix(`Child branch ${branch.branchId} has been moved to root since it was a child of a search note ${parentNoteId}`)
+ logFix(`Branch ${branchId} was set to root parent since it was referencing missing parent note ${parentNoteId}`);
+ } else {
+ logError(`Branch ${branchId} references missing parent note ${parentNoteId}`);
}
- }
- else {
- logError(`Search note ${parentNoteId} has children`);
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId, attributes.noteId
+ FROM attributes
+ LEFT JOIN notes USING (noteId)
+ WHERE attributes.isDeleted = 0
+ AND notes.noteId IS NULL`,
+ async ({attributeId, noteId}) => {
+ if (this.autoFix) {
+ const attribute = await repository.getAttribute(attributeId);
+ attribute.isDeleted = true;
+ await attribute.save();
+
+ logFix(`Attribute ${attributeId} has been deleted since it references missing source note ${noteId}`);
+ } else {
+ logError(`Attribute ${attributeId} references missing source note ${noteId}`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId, attributes.value AS noteId
+ FROM attributes
+ LEFT JOIN notes ON notes.noteId = attributes.value
+ WHERE attributes.isDeleted = 0
+ AND attributes.type = 'relation'
+ AND notes.noteId IS NULL`,
+ async ({attributeId, noteId}) => {
+ if (this.autoFix) {
+ const attribute = await repository.getAttribute(attributeId);
+ attribute.isDeleted = true;
+ await attribute.save();
+
+ logFix(`Relation ${attributeId} has been deleted since it references missing note ${noteId}`)
+ } else {
+ logError(`Relation ${attributeId} references missing note ${noteId}`)
+ }
+ });
+
+ await this.findIssues(`
+ SELECT noteRevisionId, note_revisions.noteId
+ FROM note_revisions
+ LEFT JOIN notes USING (noteId)
+ WHERE notes.noteId IS NULL`,
+ ({noteRevisionId, noteId}) => `Note revision ${noteRevisionId} references missing note ${noteId}`);
+ }
+
+ async findExistencyIssues() {
+ // 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 note is not deleted, then at least one branch should exist.
+
+ // the order here is important - first we might need to delete inconsistent branches and after that
+ // another check might create missing branch
+ await this.findAndFixIssues(`
+ SELECT branchId,
+ noteId
+ FROM branches
+ JOIN notes USING (noteId)
+ WHERE notes.isDeleted = 1
+ AND branches.isDeleted = 0`,
+ async ({branchId, noteId}) => {
+ if (this.autoFix) {
+ const branch = await repository.getBranch(branchId);
+ branch.isDeleted = true;
+ await branch.save();
+
+ logFix(`Branch ${branchId} has been deleted since associated note ${noteId} is deleted.`);
+ } else {
+ logError(`Branch ${branchId} is not deleted even though associated note ${noteId} is deleted.`)
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT branchId,
+ parentNoteId
+ FROM branches
+ JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId
+ WHERE parentNote.isDeleted = 1
+ AND branches.isDeleted = 0
+ `, async ({branchId, parentNoteId}) => {
+ if (this.autoFix) {
+ const branch = await repository.getBranch(branchId);
+ branch.isDeleted = true;
+ await branch.save();
+
+ logFix(`Branch ${branchId} has been deleted since associated parent note ${parentNoteId} is deleted.`);
+ } else {
+ logError(`Branch ${branchId} is not deleted even though associated parent note ${parentNoteId} is deleted.`)
}
});
- await findAndFixIssues(`
- SELECT attributeId
- FROM attributes
- WHERE
- isDeleted = 0
- AND type = 'relation'
- AND value = ''`,
- async ({attributeId}, autoFix) => {
- if (autoFix) {
- const relation = await repository.getAttribute(attributeId);
- relation.isDeleted = true;
- await relation.save();
+ await this.findAndFixIssues(`
+ SELECT DISTINCT notes.noteId
+ FROM notes
+ LEFT JOIN branches ON notes.noteId = branches.noteId AND branches.isDeleted = 0
+ WHERE notes.isDeleted = 0
+ AND branches.branchId IS NULL
+ `, async ({noteId}) => {
+ if (this.autoFix) {
+ const branch = await new Branch({
+ parentNoteId: 'root',
+ noteId: noteId,
+ prefix: 'recovered'
+ }).save();
- logFix(`Removed relation ${relation.attributeId} of name "${relation.name} with empty target.`);
- }
- else {
- logError(`Relation ${attributeId} has empty target.`);
+ logFix(`Created missing branch ${branch.branchId} for note ${noteId}`);
+ } else {
+ logError(`No undeleted branch found for note ${noteId}`);
}
});
- await findAndFixIssues(`
- SELECT
- attributeId,
- type
- FROM attributes
- WHERE
- isDeleted = 0
- AND type != 'label'
- AND type != 'label-definition'
- AND type != 'relation'
- AND type != 'relation-definition'`,
- async ({attributeId, type}, autoFix) => {
- if (autoFix) {
- const attribute = await repository.getAttribute(attributeId);
- attribute.type = 'label';
- await attribute.save();
+ // there should be a unique relationship between note and its parent
+ await this.findAndFixIssues(`
+ SELECT noteId,
+ parentNoteId
+ FROM branches
+ WHERE branches.isDeleted = 0
+ GROUP BY branches.parentNoteId,
+ branches.noteId
+ HAVING COUNT(1) > 1`,
+ async ({noteId, parentNoteId}) => {
+ if (this.autoFix) {
+ const branches = await repository.getEntities(
+ `SELECT *
+ FROM branches
+ WHERE noteId = ?
+ and parentNoteId = ?
+ and isDeleted = 0`, [noteId, parentNoteId]);
- logFix(`Attribute ${attributeId} type was changed to label since it had invalid type '${type}'`);
- }
- else {
- logError(`Attribute ${attributeId} has invalid type '${type}'`);
- }
- });
+ // it's not necessarily "original" branch, it's just the only one which will survive
+ const origBranch = branches[0];
- await findAndFixIssues(`
- SELECT
- attributeId,
- attributes.noteId
- FROM
- attributes
- JOIN notes ON attributes.noteId = notes.noteId
- WHERE
- attributes.isDeleted = 0
- AND notes.isDeleted = 1`,
- async ({attributeId, noteId}, autoFix) => {
- if (autoFix) {
- const attribute = await repository.getAttribute(attributeId);
- attribute.isDeleted = true;
- await attribute.save();
+ // delete all but the first branch
+ for (const branch of branches.slice(1)) {
+ branch.isDeleted = true;
+ await branch.save();
- logFix(`Removed attribute ${attributeId} because owning note ${noteId} is also deleted.`);
- }
- else {
- logError(`Attribute ${attributeId} is not deleted even though owning note ${noteId} is deleted.`);
- }
- });
+ logFix(`Removing branch ${branch.branchId} since it's parent-child duplicate of branch ${origBranch.branchId}`);
+ }
+ } else {
+ logError(`Duplicate branches for note ${noteId} and parent ${parentNoteId}`);
+ }
+ });
+ }
- await findAndFixIssues(`
- SELECT
- attributeId,
- attributes.value AS targetNoteId
- FROM
- attributes
- JOIN notes ON attributes.value = notes.noteId
- WHERE
- attributes.type = 'relation'
- AND attributes.isDeleted = 0
- AND notes.isDeleted = 1`,
- async ({attributeId, targetNoteId}, autoFix) => {
- if (autoFix) {
- const attribute = await repository.getAttribute(attributeId);
- attribute.isDeleted = true;
- await attribute.save();
+ async findLogicIssues() {
+ await this.findAndFixIssues(`
+ SELECT noteId, type
+ FROM notes
+ WHERE isDeleted = 0
+ AND type NOT IN ('text', 'code', 'render', 'file', 'image', 'search', 'relation-map', 'book')`,
+ async ({noteId, type}) => {
+ if (this.autoFix) {
+ const note = await repository.getNote(noteId);
+ note.type = 'file'; // file is a safe option to recover notes if type is not known
+ await note.save();
- logFix(`Removed attribute ${attributeId} because target note ${targetNoteId} is also deleted.`);
- }
- else {
- logError(`Attribute ${attributeId} is not deleted even though target note ${targetNoteId} is deleted.`);
- }
- });
-}
+ logFix(`Note ${noteId} type has been change to file since it had invalid type=${type}`)
+ } else {
+ logError(`Note ${noteId} has invalid type=${type}`);
+ }
+ });
-async function runSyncRowChecks(entityName, key) {
- await findAndFixIssues(`
+ await this.findAndFixIssues(`
+ SELECT notes.noteId
+ FROM notes
+ LEFT JOIN note_contents USING (noteId)
+ WHERE note_contents.noteId IS NULL`,
+ async ({noteId}) => {
+ if (this.autoFix) {
+ const note = await repository.getNote(noteId);
+ // empty string might be wrong choice for some note types (and protected notes) but it's a best guess
+ await note.setContent(note.isErased ? null : '');
+
+ logFix(`Note ${noteId} content was set to empty string since there was no corresponding row`);
+ } else {
+ logError(`Note ${noteId} content row does not exist`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT noteId
+ FROM notes
+ JOIN note_contents USING (noteId)
+ WHERE isDeleted = 0
+ AND content IS NULL`,
+ async ({noteId}) => {
+ if (this.autoFix) {
+ const note = await repository.getNote(noteId);
+ // empty string might be wrong choice for some note types (and protected notes) but it's a best guess
+ await note.setContent('');
+
+ logFix(`Note ${noteId} content was set to empty string since it was null even though it is not deleted`);
+ } else {
+ logError(`Note ${noteId} content is null even though it is not deleted`);
+ }
+ });
+
+ await this.findIssues(`
+ SELECT noteId
+ FROM notes
+ JOIN note_contents USING (noteId)
+ WHERE isErased = 1
+ AND content IS NOT NULL`,
+ ({noteId}) => `Note ${noteId} content is not null even though the note is erased`);
+
+ await this.findAndFixIssues(`
+ SELECT noteId, noteRevisionId
+ FROM notes
+ JOIN note_revisions USING (noteId)
+ WHERE notes.isErased = 1
+ AND note_revisions.isErased = 0`,
+ async ({noteId, noteRevisionId}) => {
+ if (this.autoFix) {
+ const noteRevision = await repository.getNoteRevision(noteRevisionId);
+ noteRevision.isErased = true;
+ await noteRevision.setContent(null);
+ await noteRevision.save();
+
+ logFix(`Note revision ${noteRevisionId} has been erased since its note ${noteId} is also erased.`);
+ } else {
+ logError(`Note revision ${noteRevisionId} is not erased even though note ${noteId} is erased.`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT note_revisions.noteRevisionId
+ FROM note_revisions
+ LEFT JOIN note_revision_contents USING (noteRevisionId)
+ WHERE note_revision_contents.noteRevisionId IS NULL`,
+ async ({noteRevisionId}) => {
+ if (this.autoFix) {
+ const noteRevision = await repository.getNoteRevision(noteRevisionId);
+ await noteRevision.setContent(null);
+ noteRevision.isErased = true;
+ await noteRevision.save();
+
+ logFix(`Note revision content ${noteRevisionId} was created and set to erased since it did not exist.`);
+ } else {
+ logError(`Note revision content ${noteRevisionId} does not exist`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT noteRevisionId
+ FROM note_revisions
+ JOIN note_revision_contents USING (noteRevisionId)
+ WHERE isErased = 0
+ AND content IS NULL`,
+ async ({noteRevisionId}) => {
+ if (this.autoFix) {
+ const noteRevision = await repository.getNoteRevision(noteRevisionId);
+ noteRevision.isErased = true;
+ await noteRevision.save();
+
+ logFix(`Note revision ${noteRevisionId} content was set to empty string since it was null even though it is not erased`);
+ } else {
+ logError(`Note revision ${noteRevisionId} content is null even though it is not erased`);
+ }
+ });
+
+ await this.findIssues(`
+ SELECT noteRevisionId
+ FROM note_revisions
+ JOIN note_revision_contents USING (noteRevisionId)
+ WHERE isErased = 1
+ AND content IS NOT NULL`,
+ ({noteRevisionId}) => `Note revision ${noteRevisionId} content is not null even though the note revision is erased`);
+
+ await this.findIssues(`
+ SELECT noteId
+ FROM notes
+ WHERE isErased = 1
+ AND isDeleted = 0`,
+ ({noteId}) => `Note ${noteId} is not deleted even though it is erased`);
+
+ await this.findAndFixIssues(`
+ SELECT parentNoteId
+ FROM branches
+ JOIN notes ON notes.noteId = branches.parentNoteId
+ WHERE notes.isDeleted = 0
+ AND notes.type == 'search'
+ AND branches.isDeleted = 0`,
+ async ({parentNoteId}) => {
+ if (this.autoFix) {
+ const branches = await repository.getEntities(`SELECT *
+ FROM branches
+ WHERE isDeleted = 0
+ AND parentNoteId = ?`, [parentNoteId]);
+
+ for (const branch of branches) {
+ branch.parentNoteId = 'root';
+ await branch.save();
+
+ logFix(`Child branch ${branch.branchId} has been moved to root since it was a child of a search note ${parentNoteId}`)
+ }
+ } else {
+ logError(`Search note ${parentNoteId} has children`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId
+ FROM attributes
+ WHERE isDeleted = 0
+ AND type = 'relation'
+ AND value = ''`,
+ async ({attributeId}) => {
+ if (this.autoFix) {
+ const relation = await repository.getAttribute(attributeId);
+ relation.isDeleted = true;
+ await relation.save();
+
+ logFix(`Removed relation ${relation.attributeId} of name "${relation.name} with empty target.`);
+ } else {
+ logError(`Relation ${attributeId} has empty target.`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId,
+ type
+ FROM attributes
+ WHERE isDeleted = 0
+ AND type != 'label'
+ AND type != 'label-definition'
+ AND type != 'relation'
+ AND type != 'relation-definition'`,
+ async ({attributeId, type}) => {
+ if (this.autoFix) {
+ const attribute = await repository.getAttribute(attributeId);
+ attribute.type = 'label';
+ await attribute.save();
+
+ logFix(`Attribute ${attributeId} type was changed to label since it had invalid type '${type}'`);
+ } else {
+ logError(`Attribute ${attributeId} has invalid type '${type}'`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId,
+ attributes.noteId
+ FROM attributes
+ JOIN notes ON attributes.noteId = notes.noteId
+ WHERE attributes.isDeleted = 0
+ AND notes.isDeleted = 1`,
+ async ({attributeId, noteId}) => {
+ if (this.autoFix) {
+ const attribute = await repository.getAttribute(attributeId);
+ attribute.isDeleted = true;
+ await attribute.save();
+
+ logFix(`Removed attribute ${attributeId} because owning note ${noteId} is also deleted.`);
+ } else {
+ logError(`Attribute ${attributeId} is not deleted even though owning note ${noteId} is deleted.`);
+ }
+ });
+
+ await this.findAndFixIssues(`
+ SELECT attributeId,
+ attributes.value AS targetNoteId
+ FROM attributes
+ JOIN notes ON attributes.value = notes.noteId
+ WHERE attributes.type = 'relation'
+ AND attributes.isDeleted = 0
+ AND notes.isDeleted = 1`,
+ async ({attributeId, targetNoteId}) => {
+ if (this.autoFix) {
+ const attribute = await repository.getAttribute(attributeId);
+ attribute.isDeleted = true;
+ await attribute.save();
+
+ logFix(`Removed attribute ${attributeId} because target note ${targetNoteId} is also deleted.`);
+ } else {
+ logError(`Attribute ${attributeId} is not deleted even though target note ${targetNoteId} is deleted.`);
+ }
+ });
+ }
+
+ async runSyncRowChecks(entityName, key) {
+ await this.findAndFixIssues(`
SELECT
${key} as entityId
FROM
@@ -562,18 +526,17 @@ async function runSyncRowChecks(entityName, key) {
LEFT JOIN sync ON sync.entityName = '${entityName}' AND entityId = ${key}
WHERE
sync.id IS NULL AND ` + (entityName === 'options' ? 'isSynced = 1' : '1'),
- async ({entityId}, autoFix) => {
- if (autoFix) {
- await syncTableService.addEntitySync(entityName, entityId);
+ async ({entityId}) => {
+ if (this.autoFix) {
+ await syncTableService.addEntitySync(entityName, entityId);
- logFix(`Created missing sync record for entityName=${entityName}, entityId=${entityId}`);
- }
- else {
- logError(`Missing sync record for entityName=${entityName}, entityId=${entityId}`);
- }
- });
+ logFix(`Created missing sync record for entityName=${entityName}, entityId=${entityId}`);
+ } else {
+ logError(`Missing sync record for entityName=${entityName}, entityId=${entityId}`);
+ }
+ });
- await findAndFixIssues(`
+ await this.findAndFixIssues(`
SELECT
id, entityId
FROM
@@ -582,91 +545,100 @@ async function runSyncRowChecks(entityName, key) {
WHERE
sync.entityName = '${entityName}'
AND ${key} IS NULL`,
- async ({id, entityId}, autoFix) => {
- if (autoFix) {
- await sql.execute("DELETE FROM sync WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
+ async ({id, entityId}) => {
+ if (this.autoFix) {
+ await sql.execute("DELETE FROM sync WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
- logFix(`Deleted extra sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
- }
- else {
- logError(`Unrecognized sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
- }
+ logFix(`Deleted extra sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
+ } else {
+ logError(`Unrecognized sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
+ }
+ });
+ }
+
+ async findSyncRowsIssues() {
+ await this.runSyncRowChecks("notes", "noteId");
+ await this.runSyncRowChecks("note_contents", "noteId");
+ await this.runSyncRowChecks("note_revisions", "noteRevisionId");
+ await this.runSyncRowChecks("branches", "branchId");
+ await this.runSyncRowChecks("recent_notes", "noteId");
+ await this.runSyncRowChecks("attributes", "attributeId");
+ await this.runSyncRowChecks("api_tokens", "apiTokenId");
+ await this.runSyncRowChecks("options", "name");
+ }
+
+ async runAllChecks() {
+ this.unrecoveredConsistencyErrors = false;
+ this.fixedIssues = false;
+
+ await this.findBrokenReferenceIssues();
+
+ await this.findExistencyIssues();
+
+ await this.findLogicIssues();
+
+ await this.findSyncRowsIssues();
+
+ if (this.unrecoveredConsistencyErrors) {
+ // we run this only if basic checks passed since this assumes basic data consistency
+
+ await this.checkTreeCycles();
+ }
+
+ return !this.unrecoveredConsistencyErrors;
+ }
+
+ async showEntityStat(name, query) {
+ const map = await sql.getMap(query);
+
+ map[0] = map[0] || 0;
+ map[1] = map[1] || 0;
+
+ log.info(`${name} deleted: ${map[1]}, not deleted ${map[0]}`);
+ }
+
+ async runDbDiagnostics() {
+ await this.showEntityStat("Notes", `SELECT isDeleted, count(1)
+ FROM notes
+ GROUP BY isDeleted`);
+ await this.showEntityStat("Note revisions", `SELECT isErased, count(1)
+ FROM note_revisions
+ GROUP BY isErased`);
+ await this.showEntityStat("Branches", `SELECT isDeleted, count(1)
+ FROM branches
+ GROUP BY isDeleted`);
+ await this.showEntityStat("Attributes", `SELECT isDeleted, count(1)
+ FROM attributes
+ GROUP BY isDeleted`);
+ await this.showEntityStat("API tokens", `SELECT isDeleted, count(1)
+ FROM api_tokens
+ GROUP BY isDeleted`);
+ }
+
+ async runChecks() {
+ let elapsedTimeMs;
+
+ await syncMutexService.doExclusively(async () => {
+ const startTime = new Date();
+
+ await this.runDbDiagnostics();
+
+ await this.runAllChecks();
+
+ elapsedTimeMs = Date.now() - startTime.getTime();
});
-}
-async function findSyncRowsIssues() {
- await runSyncRowChecks("notes", "noteId");
- await runSyncRowChecks("note_contents", "noteId");
- await runSyncRowChecks("note_revisions", "noteRevisionId");
- await runSyncRowChecks("branches", "branchId");
- await runSyncRowChecks("recent_notes", "noteId");
- await runSyncRowChecks("attributes", "attributeId");
- await runSyncRowChecks("api_tokens", "apiTokenId");
- await runSyncRowChecks("options", "name");
-}
+ if (this.fixedIssues) {
+ ws.refreshTree();
+ }
-async function runAllChecks() {
- unrecoveredConsistencyErrors = false;
- fixedIssues = false;
+ if (this.unrecoveredConsistencyErrors) {
+ log.info(`Consistency checks failed (took ${elapsedTimeMs}ms)`);
- await findBrokenReferenceIssues();
-
- await findExistencyIssues();
-
- await findLogicIssues();
-
- await findSyncRowsIssues();
-
- if (unrecoveredConsistencyErrors) {
- // we run this only if basic checks passed since this assumes basic data consistency
-
- await checkTreeCycles();
- }
-
- return !unrecoveredConsistencyErrors;
-}
-
-async function showEntityStat(name, query) {
- const map = await sql.getMap(query);
-
- map[0] = map[0] || 0;
- map[1] = map[1] || 0;
-
- log.info(`${name} deleted: ${map[1]}, not deleted ${map[0]}`);
-}
-
-async function runDbDiagnostics() {
- await showEntityStat("Notes", `SELECT isDeleted, count(1) FROM notes GROUP BY isDeleted`);
- await showEntityStat("Note revisions", `SELECT isErased, count(1) FROM note_revisions GROUP BY isErased`);
- await showEntityStat("Branches", `SELECT isDeleted, count(1) FROM branches GROUP BY isDeleted`);
- await showEntityStat("Attributes", `SELECT isDeleted, count(1) FROM attributes GROUP BY isDeleted`);
- await showEntityStat("API tokens", `SELECT isDeleted, count(1) FROM api_tokens GROUP BY isDeleted`);
-}
-
-async function runChecks() {
- let elapsedTimeMs;
-
- await syncMutexService.doExclusively(async () => {
- const startTime = new Date();
-
- await runDbDiagnostics();
-
- await runAllChecks();
-
- elapsedTimeMs = Date.now() - startTime.getTime();
- });
-
- if (fixedIssues) {
- ws.refreshTree();
- }
-
- if (unrecoveredConsistencyErrors) {
- log.info(`Consistency checks failed (took ${elapsedTimeMs}ms)`);
-
- ws.sendMessageToAllClients({type: 'consistency-checks-failed'});
- }
- else {
- log.info(`All consistency checks passed (took ${elapsedTimeMs}ms)`);
+ ws.sendMessageToAllClients({type: 'consistency-checks-failed'});
+ } else {
+ log.info(`All consistency checks passed (took ${elapsedTimeMs}ms)`);
+ }
}
}
@@ -678,11 +650,25 @@ function logError(message) {
log.info("Consistency error: " + message);
}
+async function runPeriodicChecks() {
+ const autoFix = await optionsService.getOptionBool('autoFixConsistencyIssues');
+
+ const consistencyChecks = new ConsistencyChecks(autoFix);
+ await consistencyChecks.runChecks();
+}
+
+async function runOnDemandChecks(autoFix) {
+ const consistencyChecks = new ConsistencyChecks(autoFix);
+ await consistencyChecks.runChecks();
+}
+
sqlInit.dbReady.then(() => {
- setInterval(cls.wrap(runChecks), 60 * 60 * 1000);
+ setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
// kickoff checks soon after startup (to not block the initial load)
- setTimeout(cls.wrap(runChecks), 20 * 1000);
+ setTimeout(cls.wrap(runPeriodicChecks), 20 * 1000);
});
-module.exports = {};
\ No newline at end of file
+module.exports = {
+ runOnDemandChecks
+};
\ No newline at end of file