diff --git a/bin/compactAllPads.ts b/bin/compactAllPads.ts new file mode 100644 index 00000000000..ac247e8f6c5 --- /dev/null +++ b/bin/compactAllPads.ts @@ -0,0 +1,227 @@ +'use strict'; + +/* + * Compact every pad on the instance to reclaim database space. + * + * Usage: + * node bin/compactAllPads.js # collapse all history on every pad + * node bin/compactAllPads.js --keep N # keep last N revisions per pad + * node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes + * + * Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is + * deliberately no instance-wide HTTP endpoint, because doing this over a + * single request would mean one giant response and a long-held connection. + * Per-pad failures don't stop the run; they're logged and counted, and the + * exit code reflects whether anything failed. + * + * Destructive — `getEtherpad`-export anything you can't afford to lose + * before running. + * + * Issue #6194: per-instance bulk compaction. The per-pad `bin/compactPad` + * is the right tool when you know which pad is fat; this is the right tool + * when you want to reclaim space across the whole instance. + */ +import path from 'node:path'; +import fs from 'node:fs'; +import process from 'node:process'; +import axios from 'axios'; + +export type CompactAllOpts = { + keepRevisions: number | null; + dryRun: boolean; +}; + +// Minimal interface mirroring the API endpoints the script needs. Tests +// substitute their own implementation that goes through supertest+JWT +// instead of axios+APIKEY, so the loop logic is exercised against a real +// running server without dragging in apikey-file or axios setup. +export type CompactAllApi = { + listAllPads(): Promise; + getRevisionsCount(padId: string): Promise; + compactPad(padId: string, keepRevisions: number | null): Promise; +}; + +export type CompactAllReport = { + total: number; + ok: number; + failed: number; + totalRevsBefore: number; + totalRevsAfter: number; +}; + +export type CompactAllLogger = { + info(msg: string): void; + error(msg: string): void; +}; + +const defaultLogger: CompactAllLogger = { + info: (m) => console.log(m), + error: (m) => console.error(m), +}; + +// Pure-ish core: composition + per-pad error tolerance + dry-run + tally. +// Returns a structured report so tests can assert on outcomes; the CLI +// shell maps it to an exit code. +export const runCompactAll = async ( + api: CompactAllApi, opts: CompactAllOpts, + logger: CompactAllLogger = defaultLogger, +): Promise => { + let padIds: string[]; + try { + padIds = await api.listAllPads(); + } catch (e: any) { + logger.error(`listAllPads failed: ${e.message ?? e}`); + return {total: 0, ok: 0, failed: 1, totalRevsBefore: 0, totalRevsAfter: 0}; + } + + if (padIds.length === 0) { + logger.info('No pads on this instance.'); + return {total: 0, ok: 0, failed: 0, totalRevsBefore: 0, totalRevsAfter: 0}; + } + + const strategy = opts.keepRevisions == null + ? 'collapse all history' + : `keep last ${opts.keepRevisions} revisions`; + logger.info(`Found ${padIds.length} pad(s). Strategy: ${strategy}` + + `${opts.dryRun ? ' (dry run — no writes)' : ''}.`); + + const report: CompactAllReport = { + total: padIds.length, ok: 0, failed: 0, + totalRevsBefore: 0, totalRevsAfter: 0, + }; + + for (let i = 0; i < padIds.length; i++) { + const padId = padIds[i]; + const idx = `[${i + 1}/${padIds.length}]`; + + let before: number; + try { + before = await api.getRevisionsCount(padId); + } catch (e: any) { + logger.error(`${idx} ${padId}: getRevisionsCount failed: ${e.message ?? e}`); + report.failed++; + continue; + } + + if (opts.dryRun) { + logger.info(`${idx} ${padId}: ${before + 1} revision(s) — would compact`); + report.totalRevsBefore += before + 1; + continue; + } + + try { + await api.compactPad(padId, opts.keepRevisions); + } catch (e: any) { + logger.error(`${idx} ${padId}: compactPad failed: ${e.message ?? e}`); + report.failed++; + continue; + } + + let after: number | undefined; + try { after = await api.getRevisionsCount(padId); } + catch { /* main op already succeeded; post-count is informational */ } + + if (after != null) { + logger.info(`${idx} ${padId}: ${before + 1} → ${after + 1} revision(s)`); + report.totalRevsBefore += before + 1; + report.totalRevsAfter += after + 1; + } else { + logger.info(`${idx} ${padId}: compacted (post-count unavailable)`); + } + report.ok++; + } + + if (opts.dryRun) { + logger.info(''); + logger.info(`Dry run complete. ${padIds.length} pad(s), ` + + `${report.totalRevsBefore} total revision(s) — re-run ` + + 'without --dry-run to compact.'); + } else { + logger.info(''); + logger.info(`Done. ${report.ok} pad(s) compacted, ${report.failed} failed. ` + + `Revisions: ${report.totalRevsBefore} → ${report.totalRevsAfter} ` + + `(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`); + } + + return report; +}; + +export const parseArgs = (argv: string[]): CompactAllOpts | null => { + const opts: CompactAllOpts = {keepRevisions: null, dryRun: false}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--dry-run') { + opts.dryRun = true; + } else if (a === '--keep') { + const v = argv[++i]; + const n = Number(v); + if (!Number.isInteger(n) || n < 0) { + console.error(`--keep expects a non-negative integer; got ${v}`); + return null; + } + opts.keepRevisions = n; + } else { + return null; + } + } + return opts; +}; + +// CLI entry point. Skipped when this file is imported (e.g. by tests), +// so the test harness can use `runCompactAll` directly without network. +const usage = () => { + console.error('Usage:'); + console.error(' node bin/compactAllPads.js'); + console.error(' node bin/compactAllPads.js --keep '); + console.error(' node bin/compactAllPads.js --dry-run'); + process.exit(2); +}; + +const isMain = require.main === module; +if (isMain) { + process.on('unhandledRejection', (err) => { throw err; }); + + const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings(); + axios.defaults.baseURL = + `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`; + + const opts = parseArgs(process.argv.slice(2)); + if (!opts) usage(); + + const apikey = fs.readFileSync( + path.join(__dirname, '../APIKEY.txt'), {encoding: 'utf-8'}).trim(); + + // Bind the abstract API to axios + APIKEY auth for the CLI shell. + const cliApi: CompactAllApi = { + async listAllPads() { + const apiInfo = await axios.get('/api/'); + const apiVersion: string | undefined = apiInfo.data.currentVersion; + if (!apiVersion) throw new Error('No version set in API'); + // Stash on this for subsequent calls. Avoids a per-call /api/ ping. + (cliApi as any)._apiVersion = apiVersion; + const r = await axios.get(`/api/${apiVersion}/listAllPads?apikey=${apikey}`); + if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); + return r.data.data.padIDs ?? []; + }, + async getRevisionsCount(padId: string) { + const v = (cliApi as any)._apiVersion; + const r = await axios.get( + `/api/${v}/getRevisionsCount?apikey=${apikey}` + + `&padID=${encodeURIComponent(padId)}`); + if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); + return r.data.data.revisions; + }, + async compactPad(padId: string, keepRevisions: number | null) { + const v = (cliApi as any)._apiVersion; + const params = new URLSearchParams({apikey, padID: padId}); + if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions)); + const r = await axios.post(`/api/${v}/compactPad?${params.toString()}`); + if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); + }, + }; + + (async () => { + const report = await runCompactAll(cliApi, opts!); + if (report.failed > 0) process.exit(1); + })(); +} diff --git a/bin/compactPad.ts b/bin/compactPad.ts new file mode 100644 index 00000000000..f808669cbd8 --- /dev/null +++ b/bin/compactPad.ts @@ -0,0 +1,92 @@ +'use strict'; + +/* + * Compact a pad's revision history to reclaim database space. + * + * Usage: + * node bin/compactPad.js # collapse all history + * node bin/compactPad.js --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 = + `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`; + +const usage = () => { + console.error('Usage:'); + console.error(' node bin/compactPad.js '); + console.error(' node bin/compactPad.js --keep '); + 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.'); + } +})(); diff --git a/bin/package.json b/bin/package.json index eefe42d90cd..ebe029a2d15 100644 --- a/bin/package.json +++ b/bin/package.json @@ -23,6 +23,8 @@ "makeDocs": "node --import tsx make_docs.ts", "checkPad": "node --import tsx checkPad.ts", "checkAllPads": "node --import tsx checkAllPads.ts", + "compactPad": "node --import tsx compactPad.ts", + "compactAllPads": "node --import tsx compactAllPads.ts", "createUserSession": "node --import tsx createUserSession.ts", "deletePad": "node --import tsx deletePad.ts", "repairPad": "node --import tsx repairPad.ts", diff --git a/doc/api/http_api.adoc b/doc/api/http_api.adoc index 0246d618144..82313c54aa8 100644 --- a/doc/api/http_api.adoc +++ b/doc/api/http_api.adoc @@ -65,7 +65,7 @@ Portal submits content into new blog post === Usage ==== API version -The latest version is `1.3.0` +The latest version is `1.3.1` The current version can be queried via /api. @@ -588,6 +588,25 @@ _Example returns:_ * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` +==== compactPad(padID, [keepRevisions]) + * API >= 1.3.1 + +collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI. + +*Gated on `settings.cleanup.enabled = true`* (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires. + +When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions. + +Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. *This operation is destructive — export the pad first via `getEtherpad` if you need a backup.* + +_Example returns:_ + + * `{code: 0, message:"ok", data: {ok: true, mode: "all"}}` + * `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}` + * `{code: 1, message:"padID does not exist", data: null}` + * `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}` + * `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}` + ==== getReadOnlyID(padID) * API >= 1 diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 60db60e31bb..35437f23eb1 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -98,7 +98,7 @@ Portal submits content into new blog post ## Usage ### API version -The latest version is `1.3.0` +The latest version is `1.3.1` The current version can be queried via /api. @@ -637,6 +637,24 @@ moves a pad. If force is true and the destination pad exists, it will be overwri * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` +#### compactPad(padID, [keepRevisions]) +* API >= 1.3.1 + +collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI. + +**Gated on `settings.cleanup.enabled = true`** (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires. + +When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions. + +Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.** + +*Example returns:* +* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}` +* `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}` +* `{code: 1, message:"padID does not exist", data: null}` +* `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}` +* `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}` + #### getReadOnlyID(padID) * API >= 1 diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 4de43f52e29..56b5ffa9131 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -654,6 +654,52 @@ 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. + +Gated on `settings.cleanup.enabled` so the public API can't bypass the +same opt-in switch the admin/Cleanup path already requires. + +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} +{code: 1, message:"compactPad requires cleanup.enabled = true ...", 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) => { + if (!settings.cleanup.enabled) { + throw new CustomError( + 'compactPad requires cleanup.enabled = true in settings.json', 'apierror'); + } + 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.isInteger(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. diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index b1e111c471b..ab1f9f563f8 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -142,9 +142,14 @@ version['1.3.0'] = { setText: ['padID', 'text', 'authorId'], }; +version['1.3.1'] = { + ...version['1.3.0'], + compactPad: ['padID', 'keepRevisions'], +}; + // set the latest available API version here -exports.latestApiVersion = '1.3.0'; +exports.latestApiVersion = '1.3.1'; // exports the versions so it can be used by the new Swagger endpoint exports.version = version; diff --git a/src/tests/backend/specs/compactPad.ts b/src/tests/backend/specs/compactPad.ts new file mode 100644 index 00000000000..32d9d69421d --- /dev/null +++ b/src/tests/backend/specs/compactPad.ts @@ -0,0 +1,347 @@ +'use strict'; + +import {generateJWTToken} from "../common"; + +const assert = require('assert').strict; +const common = require('../common'); +const padManager = require('../../../node/db/PadManager'); +const api = require('../../../node/db/API'); +const settings = require('../../../node/utils/Settings'); + +// 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; + let agent: any; + let cleanupEnabledBackup: boolean; + + before(async function () { + agent = await common.init(); + // compactPad is gated on cleanup.enabled (matches the admin/Cleanup + // path). Enable it for the duration of these tests and restore after, + // and add a focused spec below that asserts the gate. + cleanupEnabledBackup = settings.cleanup.enabled; + settings.cleanup.enabled = true; + }); + + after(function () { settings.cleanup.enabled = cleanupEnabledBackup; }); + + 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/); + }); + + it('rejects fractional keepRevisions', async function () { + // 2.5 is finite + non-negative but not an integer — Cleanup.deleteRevisions + // does revision-index arithmetic that assumes integer math, so we + // reject at the API boundary instead of letting it silently misbehave. + const pad = await padManager.getPad(padId); + await pad.appendText('content\n'); + await assert.rejects( + () => api.compactPad(padId, 2.5), + /keepRevisions must be a non-negative integer/); + }); + + it('refuses to run when cleanup.enabled is false', async function () { + // Mirrors the admin/Cleanup-socket path: same opt-in, same surface + // area. An operator who hasn't reviewed the cleanup story shouldn't + // get destructive compaction by default just because the API is + // exposed. + settings.cleanup.enabled = false; + try { + const pad = await padManager.getPad(padId); + await pad.appendText('content\n'); + await assert.rejects( + () => api.compactPad(padId), + /cleanup\.enabled = true/); + } finally { + settings.cleanup.enabled = true; + } + }); + }); + + // Verifies the APIHandler dispatch wiring — i.e. that `keepRevisions` + // travels from the URL query string to the API function under the + // right argument name. This catches regressions where the handler's + // version map gets renamed without updating the function signature. + describe('HTTP API dispatch (1.3.1)', function () { + it('passes keepRevisions from query string into compactPad', async function () { + const pad = await padManager.getPad(padId); + for (let i = 0; i < 5; i++) await pad.appendText(`http-line-${i}\n`); + + const res = await agent.get( + `/api/1.3.1/compactPad?padID=${padId}&keepRevisions=2`) + .set('authorization', await generateJWTToken()) + .expect(200) + .expect('Content-Type', /json/); + + assert.strictEqual(res.body.code, 0, JSON.stringify(res.body)); + assert.strictEqual(res.body.data.mode, 'keepLast'); + assert.strictEqual(res.body.data.keepRevisions, 2); + }); + + it('collapses all history when keepRevisions is absent from URL', async function () { + const pad = await padManager.getPad(padId); + for (let i = 0; i < 3; i++) await pad.appendText(`http-all-${i}\n`); + + const res = await agent.get(`/api/1.3.1/compactPad?padID=${padId}`) + .set('authorization', await generateJWTToken()) + .expect(200) + .expect('Content-Type', /json/); + + assert.strictEqual(res.body.code, 0, JSON.stringify(res.body)); + assert.deepStrictEqual(res.body.data, {ok: true, mode: 'all'}); + }); + }); + + // Coverage for the per-instance bulk-compaction loop in + // bin/compactAllPads.ts. We test the exported `runCompactAll` against + // an in-memory CompactAllApi rather than spawning the script + axios, + // so we don't have to stand up an APIKEY-auth path. The CLI shell that + // wires axios+APIKEY is a thin adapter; the loop logic — error + // tolerance, dry-run, keep-last, tally — is what regresses, and that + // is what this exercises. + describe('runCompactAll (bin/compactAllPads loop)', function () { + // Imported lazily so module-load-time side effects in compactAllPads + // (require.main check) don't trip on the mocha runner. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {runCompactAll, parseArgs} = require('../../../../bin/compactAllPads'); + + const silent = {info: () => {}, error: () => {}}; + + // Builds a stub api that walks the same pad set as a real instance + // would, with optional per-pad failure injection. + type StubFails = {list?: boolean; count?: Set; compact?: Set}; + const makeApi = (padIds: string[], fails: StubFails = {}) => { + const counts = new Map(); + padIds.forEach((p) => counts.set(p, 5)); + return { + async listAllPads() { + if (fails.list) throw new Error('boom'); + return padIds.slice(); + }, + async getRevisionsCount(padId: string) { + if (fails.count?.has(padId)) throw new Error('count-boom'); + const c = counts.get(padId); + if (c == null) throw new Error('unknown pad'); + return c; + }, + async compactPad(padId: string, keepRevisions: number | null) { + if (fails.compact?.has(padId)) throw new Error('compact-boom'); + counts.set(padId, keepRevisions == null ? 0 : Math.min(counts.get(padId)!, keepRevisions)); + }, + }; + }; + + it('parses --keep / --dry-run / no args', function () { + assert.deepStrictEqual(parseArgs([]), + {keepRevisions: null, dryRun: false}); + assert.deepStrictEqual(parseArgs(['--dry-run']), + {keepRevisions: null, dryRun: true}); + assert.deepStrictEqual(parseArgs(['--keep', '3']), + {keepRevisions: 3, dryRun: false}); + assert.deepStrictEqual(parseArgs(['--keep', '3', '--dry-run']), + {keepRevisions: 3, dryRun: true}); + }); + + it('rejects --keep with non-integer / negative / unknown args', function () { + assert.strictEqual(parseArgs(['--keep', 'abc']), null); + assert.strictEqual(parseArgs(['--keep', '-1']), null); + assert.strictEqual(parseArgs(['--unknown']), null); + }); + + it('compacts every pad and tallies before/after revisions', async function () { + const api = makeApi(['p-a', 'p-b', 'p-c']); + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: false}, silent); + assert.strictEqual(report.total, 3); + assert.strictEqual(report.ok, 3); + assert.strictEqual(report.failed, 0); + // Each pad starts with 5 (head) → 6 revisions; collapse → 0 head, 1 rev. + assert.strictEqual(report.totalRevsBefore, 18); + assert.strictEqual(report.totalRevsAfter, 3); + }); + + it('honours --keep N by passing it through to compactPad', async function () { + const seen: Array<[string, number | null]> = []; + const api = { + async listAllPads() { return ['p-x', 'p-y']; }, + async getRevisionsCount() { return 5; }, + async compactPad(padId: string, k: number | null) { seen.push([padId, k]); }, + }; + const report = await runCompactAll(api, + {keepRevisions: 2, dryRun: false}, silent); + assert.strictEqual(report.ok, 2); + assert.deepStrictEqual(seen, [['p-x', 2], ['p-y', 2]]); + }); + + it('--dry-run does not call compactPad', async function () { + let compactCalls = 0; + const api = { + async listAllPads() { return ['p-1', 'p-2']; }, + async getRevisionsCount() { return 4; }, + async compactPad() { compactCalls++; }, + }; + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: true}, silent); + assert.strictEqual(compactCalls, 0); + assert.strictEqual(report.ok, 0); + assert.strictEqual(report.failed, 0); + assert.strictEqual(report.totalRevsBefore, 10); // 2 pads × (4+1) + assert.strictEqual(report.totalRevsAfter, 0); + }); + + it('keeps going when one pad fails to compact', async function () { + const api = makeApi(['ok-1', 'broken', 'ok-2'], + {compact: new Set(['broken'])}); + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: false}, silent); + assert.strictEqual(report.total, 3); + assert.strictEqual(report.ok, 2); + assert.strictEqual(report.failed, 1); + }); + + it('keeps going when one pad fails the pre-flight count', async function () { + const api = makeApi(['ok-1', 'broken'], {count: new Set(['broken'])}); + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: false}, silent); + assert.strictEqual(report.ok, 1); + assert.strictEqual(report.failed, 1); + }); + + it('reports listAllPads failure without iterating', async function () { + const api = makeApi(['a', 'b', 'c'], {list: true}); + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: false}, silent); + assert.strictEqual(report.total, 0); + assert.strictEqual(report.failed, 1); + assert.strictEqual(report.ok, 0); + }); + + it('handles an empty instance', async function () { + const api = makeApi([]); + const report = await runCompactAll(api, + {keepRevisions: null, dryRun: false}, silent); + assert.deepStrictEqual(report, + {total: 0, ok: 0, failed: 0, totalRevsBefore: 0, totalRevsAfter: 0}); + }); + + // Plumbs the loop through the real /api/1.3.1/compactPad endpoint so + // we know the CLI's `cliApi` shape doesn't lie about its contract. + // Auth is JWT (matching the test agent) rather than APIKEY; the + // CLI path is otherwise identical. + it('end-to-end against the real HTTP handler', async function () { + const padA = common.randomString(); + const padB = common.randomString(); + const padObjA = await padManager.getPad(padA); + const padObjB = await padManager.getPad(padB); + for (let i = 0; i < 4; i++) await padObjA.appendText(`a-${i}\n`); + for (let i = 0; i < 4; i++) await padObjB.appendText(`b-${i}\n`); + const beforeA = padObjA.getHeadRevisionNumber(); + const beforeB = padObjB.getHeadRevisionNumber(); + assert.ok(beforeA >= 4 && beforeB >= 4); + + const httpApi = { + async listAllPads() { + // Only act on the pads this test created — the test DB is shared + // across describes, so other specs may have left pads behind. + return [padA, padB]; + }, + async getRevisionsCount(padId: string) { + const r = await agent.get( + `/api/1.3.1/getRevisionsCount?padID=${padId}`) + .set('authorization', await generateJWTToken()) + .expect(200); + if (r.body.code !== 0) throw new Error(JSON.stringify(r.body)); + return r.body.data.revisions; + }, + async compactPad(padId: string, keepRevisions: number | null) { + const url = keepRevisions == null + ? `/api/1.3.1/compactPad?padID=${padId}` + : `/api/1.3.1/compactPad?padID=${padId}&keepRevisions=${keepRevisions}`; + const r = await agent.get(url) + .set('authorization', await generateJWTToken()) + .expect(200); + if (r.body.code !== 0) throw new Error(JSON.stringify(r.body)); + }, + }; + + const report = await runCompactAll(httpApi, + {keepRevisions: null, dryRun: false}, silent); + assert.strictEqual(report.total, 2); + assert.strictEqual(report.ok, 2); + assert.strictEqual(report.failed, 0); + + // Both pads collapsed to head<=1. + const reA = await padManager.getPad(padA); + const reB = await padManager.getPad(padB); + assert.ok(reA.getHeadRevisionNumber() <= 1); + assert.ok(reB.getHeadRevisionNumber() <= 1); + }); + }); +});