feat: Add script to clean orphaned reference records
Instances that accumulated project managers, favorites, board memberships or board subscriptions pointing at a project or board that no longer exists had no way to find or clear them. The script reports by default and only deletes with `--apply`, which first asks whether a backup exists and refuses outright without a terminal to ask on. It removes nothing but reference rows: structural orphans such as a board without a project carry user content, and a membership whose denormalized project is stale still grants real access to an existing board, so both are reported and left alone.
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"server:build": "npm run build --prefix server",
|
"server:build": "npm run build --prefix server",
|
||||||
"server:console": "npm run console --prefix server",
|
"server:console": "npm run console --prefix server",
|
||||||
|
"server:db:clean-orphaned-records": "npm run db:clean-orphaned-records --prefix server",
|
||||||
"server:db:create-admin-user": "npm run db:create-admin-user --prefix server",
|
"server:db:create-admin-user": "npm run db:create-admin-user --prefix server",
|
||||||
"server:db:init": "npm run db:init --prefix server",
|
"server:db:init": "npm run db:init --prefix server",
|
||||||
"server:db:migrate": "npm run db:migrate --prefix server",
|
"server:db:migrate": "npm run db:migrate --prefix server",
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
const { read } = require('read');
|
||||||
|
const initKnex = require('knex');
|
||||||
|
|
||||||
|
const knexfile = require('./knexfile');
|
||||||
|
|
||||||
|
const SAMPLE_SIZE = 5;
|
||||||
|
|
||||||
|
// Reference rows that carry no content of their own: once the project or board
|
||||||
|
// they point at is gone they are unusable, and the client builds its project
|
||||||
|
// list from them, so a single leftover row can take the whole home view down
|
||||||
|
const REMOVABLE = [
|
||||||
|
{
|
||||||
|
table: 'project_manager',
|
||||||
|
description: 'project managers of a missing project',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM project WHERE project.id = project_manager.project_id)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'project_favorite',
|
||||||
|
description: 'favorites of a missing project',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM project WHERE project.id = project_favorite.project_id)',
|
||||||
|
},
|
||||||
|
// Only the board is checked here: the project is denormalized onto the row and
|
||||||
|
// the client resolves it through the board, so a stale project alone is a
|
||||||
|
// repairable inconsistency and deleting the row would revoke real board access
|
||||||
|
{
|
||||||
|
table: 'board_membership',
|
||||||
|
description: 'board memberships of a missing board',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM board WHERE board.id = board_membership.board_id)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'board_subscription',
|
||||||
|
description: 'board subscriptions of a missing board',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM board WHERE board.id = board_subscription.board_id)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reported but never touched: removing these would take user content with them,
|
||||||
|
// or the repair depends on which of the two sides is meant to win
|
||||||
|
const REPORTABLE = [
|
||||||
|
{
|
||||||
|
table: 'board',
|
||||||
|
description: 'boards of a missing project',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM project WHERE project.id = board.project_id)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'list',
|
||||||
|
description: 'lists of a missing board',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM board WHERE board.id = list.board_id)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'card',
|
||||||
|
description: 'cards of a missing list',
|
||||||
|
whereRaw: 'NOT EXISTS (SELECT 1 FROM list WHERE list.id = card.list_id)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'notification_service',
|
||||||
|
description: 'notification services of a missing board',
|
||||||
|
whereRaw: `board_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM board WHERE board.id = notification_service.board_id)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'project',
|
||||||
|
description: 'projects owned by a missing project manager',
|
||||||
|
whereRaw: `owner_project_manager_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM project_manager WHERE project_manager.id = project.owner_project_manager_id)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'board_membership',
|
||||||
|
description: 'board memberships whose denormalized project does not match their board',
|
||||||
|
whereRaw: `EXISTS (
|
||||||
|
SELECT 1 FROM board
|
||||||
|
WHERE board.id = board_membership.board_id
|
||||||
|
AND board.project_id <> board_membership.project_id
|
||||||
|
)`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const knex = initKnex(knexfile);
|
||||||
|
|
||||||
|
const findOrphans = async ({ table, whereRaw }) => {
|
||||||
|
const rows = await knex(table).select('id').whereRaw(whereRaw).orderBy('id');
|
||||||
|
return rows.map(({ id }) => id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = (checks, idsByCheck) => {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
checks.forEach((check, index) => {
|
||||||
|
const ids = idsByCheck[index];
|
||||||
|
total += ids.length;
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = ids.slice(0, SAMPLE_SIZE).join(', ');
|
||||||
|
const rest = ids.length > SAMPLE_SIZE ? `, and ${ids.length - SAMPLE_SIZE} more` : '';
|
||||||
|
|
||||||
|
console.log(` ${check.table}: ${ids.length} ${check.description}`);
|
||||||
|
console.log(` ids: ${sample}${rest}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBackup = async () => {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
throw new Error('Refusing to delete without a terminal to confirm the backup on');
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await read({
|
||||||
|
prompt: 'Have you created a backup of this database? Type "yes" to continue: ',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (answer.trim().toLowerCase() !== 'yes') {
|
||||||
|
throw new Error('Aborted, nothing was deleted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const isApplying = process.argv.includes('--apply');
|
||||||
|
|
||||||
|
const removableIds = [];
|
||||||
|
for (const check of REMOVABLE) {
|
||||||
|
removableIds.push(await findOrphans(check));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportableIds = [];
|
||||||
|
for (const check of REPORTABLE) {
|
||||||
|
reportableIds.push(await findOrphans(check));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Removable orphaned records:');
|
||||||
|
const removableTotal = report(REMOVABLE, removableIds);
|
||||||
|
|
||||||
|
if (removableTotal === 0) {
|
||||||
|
console.log(' none');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nOther inconsistencies (reported only, never deleted by this script):');
|
||||||
|
|
||||||
|
if (report(REPORTABLE, reportableIds) === 0) {
|
||||||
|
console.log(' none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isApplying) {
|
||||||
|
console.log('\nDry run, nothing was deleted. Pass --apply to delete the removable records.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removableTotal === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
await confirmBackup();
|
||||||
|
|
||||||
|
await knex.transaction(async (trx) => {
|
||||||
|
for (const [index, check] of REMOVABLE.entries()) {
|
||||||
|
const ids = removableIds[index];
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await trx(check.table).whereIn('id', ids).delete();
|
||||||
|
console.log(`Deleted ${ids.length} from ${check.table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nDeleted ${removableTotal} orphaned records.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await knex.destroy();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"console": "dotenv sails console",
|
"console": "dotenv sails console",
|
||||||
|
"db:clean-orphaned-records": "node db/clean-orphaned-records.js",
|
||||||
"db:create-admin-user": "node db/create-admin-user.js",
|
"db:create-admin-user": "node db/create-admin-user.js",
|
||||||
"db:init": "node db/init.js",
|
"db:init": "node db/init.js",
|
||||||
"db:migrate": "knex migrate:latest --cwd db",
|
"db:migrate": "knex migrate:latest --cwd db",
|
||||||
|
|||||||
Reference in New Issue
Block a user