'use strict'; /* * Compact every pad on the instance to reclaim database space. * * Usage: * pnpm run --filter bin compactAllPads # collapse all history on every pad * pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad * pnpm run --filter bin compactAllPads --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'; 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 fetch+APIKEY, so the loop logic is exercised against a real // running server without dragging in apikey-file or fetch 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(' pnpm run --filter bin compactAllPads'); console.error(' pnpm run --filter bin compactAllPads --keep '); console.error(' pnpm run --filter bin compactAllPads --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(); const baseURL = `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`; const apiGet = async (p: string): Promise => { const r = await fetch(baseURL + p); if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`); return r.json(); }; const apiPost = async (p: string): Promise => { const r = await fetch(baseURL + p, {method: 'POST'}); if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`); return r.json(); }; 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 fetch + APIKEY auth for the CLI shell. const cliApi: CompactAllApi = { async listAllPads() { const apiInfo = await apiGet('/api/'); const apiVersion: string | undefined = apiInfo.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 apiGet(`/api/${apiVersion}/listAllPads?apikey=${apikey}`); if (r.code !== 0) throw new Error(JSON.stringify(r)); return r.data.padIDs ?? []; }, async getRevisionsCount(padId: string) { const v = (cliApi as any)._apiVersion; const r = await apiGet( `/api/${v}/getRevisionsCount?apikey=${apikey}` + `&padID=${encodeURIComponent(padId)}`); if (r.code !== 0) throw new Error(JSON.stringify(r)); return r.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 apiPost(`/api/${v}/compactPad?${params.toString()}`); if (r.code !== 0) throw new Error(JSON.stringify(r)); }, }; (async () => { const report = await runCompactAll(cliApi, opts!); if (report.failed > 0) process.exit(1); })(); }