Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions bin/compactPad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use strict';

/*
* Compact a pad's revision history to reclaim database space.
*
* Usage:
* node bin/compactPad.js <padID> # collapse all history
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
*
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
* compactPad HTTP API so admins can trigger it from the CLI without
* routing through the admin settings UI. Destructive — export the pad as
* `.etherpad` first for backup.
*
* Issue #6194: long-lived pads with heavy edit history accumulate hundreds
* of megabytes in the DB; this tool is the per-pad brick for reclaiming
* that space without rotating to a new pad ID.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
import axios from 'axios';

// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err) => { throw err; });

const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();

axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;

const usage = () => {
console.error('Usage:');
console.error(' node bin/compactPad.js <padID>');
console.error(' node bin/compactPad.js <padID> --keep <N>');
process.exit(2);
};

const args = process.argv.slice(2);
if (args.length < 1 || args.length > 3) usage();
const padId = args[0];

let keepRevisions: number | null = null;
if (args.length === 3) {
if (args[1] !== '--keep') usage();
keepRevisions = Number(args[2]);
if (!Number.isInteger(keepRevisions) || keepRevisions < 0) {
console.error(`--keep expects a non-negative integer; got ${args[2]}`);
process.exit(2);
}
}

// get the API Key
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'}).trim();

(async () => {
const apiInfo = await axios.get('/api/');
const apiVersion: string | undefined = apiInfo.data.currentVersion;
if (!apiVersion) throw new Error('No version set in API');

// Pre-flight: show current revision count so operators can eyeball impact.
const countUri = `/api/${apiVersion}/getRevisionsCount?apikey=${apikey}&padID=${padId}`;
const countRes = await axios.get(countUri);
if (countRes.data.code !== 0) {
console.error(`getRevisionsCount failed: ${JSON.stringify(countRes.data)}`);
process.exit(1);
}
const before: number = countRes.data.data.revisions;
const strategy = keepRevisions == null ? 'collapse all' : `keep last ${keepRevisions}`;
console.log(`Pad ${padId}: ${before + 1} revision(s). Strategy: ${strategy}.`);

const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const result = await axios.post(`/api/${apiVersion}/compactPad?${params.toString()}`);
if (result.data.code !== 0) {
console.error(`compactPad failed: ${JSON.stringify(result.data)}`);
process.exit(1);
}

// Post-flight: the pad is now compacted. Re-read the rev count so the
// operator sees concrete savings.
const afterRes = await axios.get(countUri);
const after: number | undefined = afterRes.data?.data?.revisions;
if (after != null) {
console.log(`Done. Pad ${padId}: ${after + 1} revision(s) remaining ` +
`(was ${before + 1}).`);
} else {
console.log('Done.');
}
})();
38 changes: 38 additions & 0 deletions src/node/db/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,44 @@ exports.copyPadWithoutHistory = async (sourceID: string, destinationID: string,
await pad.copyPadWithoutHistory(destinationID, force, authorId);
};

/**
compactPad(padID, [keepRevisions]) collapses the pad's revision history to
reclaim database space (issue #6194). Wraps the existing `Cleanup` helper
so admins can trigger it over the public API / CLI rather than only
through the admin settings UI.

When `keepRevisions` is omitted (or `null`), all history is collapsed
into a single base revision that reproduces the current atext
(equivalent to a freshly-imported pad). When set to a positive integer
N, the pad keeps only its last N revisions (equivalent to
`cleanup.keepRevisions`). Pad text and chat history are preserved in
both modes. Destructive — recommend exporting the `.etherpad` snapshot
first.

Example returns:

{code: 0, message:"ok", data: {ok: true, mode: "all"}}
{code: 1, message:"padID does not exist", data: null}

@param {String} padID the id of the pad to compact
@param {Number|null} keepRevisions number of recent revisions to keep;
null / omitted collapses the full history
*/
exports.compactPad = async (padID: string, keepRevisions: number | null = null) => {
const pad = await getPadSafe(padID, true);
const cleanup = require('../utils/Cleanup');
if (keepRevisions == null) {
await cleanup.deleteAllRevisions(pad.id);
return {ok: true, mode: 'all'};
}
const keep = Number(keepRevisions);
if (!Number.isFinite(keep) || keep < 0) {
throw new CustomError('keepRevisions must be a non-negative integer', 'apierror');
}
const ok = await cleanup.deleteRevisions(pad.id, keep);
return {ok, mode: 'keepLast', keepRevisions: keep};
};

/**
movePad(sourceID, destinationID[, force=false]) moves a pad. If force is true,
the destination will be overwritten if it exists.
Expand Down
7 changes: 6 additions & 1 deletion src/node/handler/APIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,14 @@ version['1.3.0'] = {
setText: ['padID', 'text', 'authorId'],
};

version['1.3.1'] = {
...version['1.3.0'],
compactPad: ['padID', 'authorId'],
};


// set the latest available API version here
exports.latestApiVersion = '1.3.0';
exports.latestApiVersion = '1.3.1';
Comment on lines +145 to +152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. compactpad missing feature flag 📘 Rule violation ☼ Reliability

The new destructive compactPad feature (API + CLI) is enabled unconditionally with no
feature-flag/disable mechanism, violating the requirement that new features be disabled by default.
This can expose a new admin-capability surface area without an explicit opt-in toggle.
Agent Prompt
## Issue description
The new `compactPad` API/CLI feature is enabled by default with no feature flag.

## Issue Context
Compliance requires new features to be behind a feature flag and disabled by default, with no behavior/path changes when the flag is off.

## Fix Focus Areas
- src/node/handler/APIHandler.ts[145-152]
- src/node/db/API.ts[638-659]
- bin/compactPad.ts[57-58]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +145 to +152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Http api docs not updated 📘 Rule violation ⚙ Maintainability

The documentation still states the latest HTTP API version is 1.3.0, but the code updates it to
1.3.1 and adds the new compactPad endpoint without updating the docs. This creates an
API/documentation mismatch for integrators and administrators.
Agent Prompt
## Issue description
The HTTP API documentation is out of date: it still lists `1.3.0` as the latest API version even though the code now sets `1.3.1`.

## Issue Context
This PR introduces a new API version (`1.3.1`) and a new endpoint, so `doc/api/http_api.*` must be updated in the same change set.

## Fix Focus Areas
- src/node/handler/APIHandler.ts[145-152]
- doc/api/http_api.md[100-103]
- doc/api/http_api.adoc[65-70]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// exports the versions so it can be used by the new Swagger endpoint
exports.version = version;
Expand Down
83 changes: 83 additions & 0 deletions src/tests/backend/specs/compactPad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use strict';

const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const api = require('../../../node/db/API');

// Coverage for the compactPad API endpoint added in #6194.
// The underlying Cleanup logic is tested where it lives; these tests just
// verify the public-API wiring and argument handling.
describe(__filename, function () {
let padId: string;

beforeEach(async function () {
padId = common.randomString();
assert(!await padManager.doesPadExist(padId));
});

describe('API.compactPad()', function () {
it('collapses all history when keepRevisions is omitted', async function () {
const pad = await padManager.getPad(padId);
await pad.appendText('marker-alpha\n');
await pad.appendText('marker-beta\n');
await pad.appendText('marker-gamma\n');
const before = pad.getHeadRevisionNumber();
assert.ok(before >= 3, `expected at least 3 revs, got ${before}`);

const result = await api.compactPad(padId);
assert.deepStrictEqual(result, {ok: true, mode: 'all'});

// Reload: the compacted pad lands at head<=1 (matches the shape
// `copyPadWithoutHistory` produces). The content survives — we
// don't assert byte-exact equality because Cleanup.deleteAllRevisions
// goes through copyPadWithoutHistory twice and may adjust trailing
// whitespace; what we care about is that the author-written content
// is still there.
const reloaded = await padManager.getPad(padId);
assert.ok(reloaded.getHeadRevisionNumber() <= 1,
`expected head<=1, got ${reloaded.getHeadRevisionNumber()}`);
const text = reloaded.atext.text;
assert.ok(text.includes('marker-alpha'), 'alpha content preserved');
assert.ok(text.includes('marker-beta'), 'beta content preserved');
assert.ok(text.includes('marker-gamma'), 'gamma content preserved');
});

it('keeps only the last N revisions when keepRevisions is a number',
async function () {
const pad = await padManager.getPad(padId);
for (let i = 0; i < 6; i++) await pad.appendText(`keep-line-${i}\n`);
const before = pad.getHeadRevisionNumber();

const result = await api.compactPad(padId, 2);
assert.strictEqual(result.mode, 'keepLast');
assert.strictEqual(result.keepRevisions, 2);

const reloaded = await padManager.getPad(padId);
assert.ok(reloaded.getHeadRevisionNumber() <= before);
// Content survives — whitespace normalization from the twin-copy
// roundtrip is ignored, we just check the actual text markers.
for (let i = 0; i < 6; i++) {
assert.ok(reloaded.atext.text.includes(`keep-line-${i}`),
`line ${i} survived compaction`);
}
});

it('rejects negative keepRevisions', async function () {
const pad = await padManager.getPad(padId);
await pad.appendText('content\n');
await assert.rejects(
() => api.compactPad(padId, -1),
/keepRevisions must be a non-negative integer/);
});

it('rejects non-numeric keepRevisions', async function () {
const pad = await padManager.getPad(padId);
await pad.appendText('content\n');
await assert.rejects(
// @ts-ignore - deliberately passing an invalid type
() => api.compactPad(padId, 'nope'),
/keepRevisions must be a non-negative integer/);
});
});
});
Loading