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:
Daniel Hiller
2026-08-26 09:31:18 +02:00
parent 2d289cd3d5
commit d7da44efe4
3 changed files with 188 additions and 0 deletions
+1
View File
@@ -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",
+186
View File
@@ -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();
}
})();
+1
View File
@@ -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",