mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge-time fix for #661. A pass listed the folder and parsed every manifest three times: for the recorded-retention fallback, for the change check and for the prune (and restore listed it twice more). On a network folder that is every manifest read three times every five minutes. runConfigBackupPass (config-backup.ts) now reads the folder once, shares the names with the temp sweep and the manifests with withRecordedRetention, the snapshot and the prune, and prepends the snapshot it just made (always the newest, by the date floor) to the listing retention counts. runConfigBackup in the route keeps the settings read and the bounded probe and calls it. restoreConfigBackup lists once for the recorded retention and the pre-restore snapshot. The exported createConfigBackup, latestBackupRetention, withRecordedRetention and pruneConfigBackups keep their signatures (latestBackupRetention and withRecordedRetention take an optional pre-read listing). A recording node:fs test pins one readdir and one read per manifest per pass, and that the new snapshot counts toward retention. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
95 lines
4.3 KiB
TypeScript
95 lines
4.3 KiB
TypeScript
/**
|
|
* @fileoverview A backup pass reads the backup folder once: one directory listing and one read per
|
|
* manifest, shared by the recorded-retention fallback, the change check and the prune. Each used to
|
|
* list the folder itself, which on a network folder meant every manifest read three times every five
|
|
* minutes (found in review of #661).
|
|
*
|
|
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (no server).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const rec = vi.hoisted(() => ({ dir: '', calls: [] as string[] }));
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs')>();
|
|
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
|
|
((path: unknown, ...rest: unknown[]) => {
|
|
if (rec.dir && String(path).startsWith(rec.dir)) rec.calls.push(`${name} ${String(path)}`);
|
|
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
|
}) as unknown as F;
|
|
const wrapped = {
|
|
...actual,
|
|
readdirSync: record('readdirSync', actual.readdirSync),
|
|
readFileSync: record('readFileSync', actual.readFileSync),
|
|
};
|
|
return { ...wrapped, default: wrapped };
|
|
});
|
|
|
|
import { runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
|
|
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
|
import { getDataDir } from '../../src/config/instance.js';
|
|
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
|
|
|
|
const dir = () => defaultConfigBackupDir(getDataDir());
|
|
const DAY = 86_400_000;
|
|
const BASE = Date.UTC(2025, 0, 1);
|
|
|
|
beforeEach(() => {
|
|
rec.dir = '';
|
|
rec.calls.length = 0;
|
|
rmSync(dir(), { recursive: true, force: true });
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
});
|
|
afterEach(() => {
|
|
rec.dir = '';
|
|
rmSync(dir(), { recursive: true, force: true });
|
|
rmSync(SETTINGS_PATH, { force: true });
|
|
});
|
|
|
|
describe('one backup pass', () => {
|
|
it('lists the folder once and reads each manifest once', async () => {
|
|
// Only the OLDEST snapshot recorded a retention, so the fallback has to look through every one.
|
|
for (let i = 0; i < 6; i++) {
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: `v${i}` }));
|
|
createConfigBackup({
|
|
dataDir: getDataDir(),
|
|
backupDir: dir(),
|
|
reason: 'auto',
|
|
now: BASE + i * DAY,
|
|
...(i === 0 && { keepCount: 100, keepDays: 0 }),
|
|
});
|
|
}
|
|
// A change (so the pass writes a snapshot and then prunes) and no retention keys (the fallback reads).
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'changed' }));
|
|
rec.dir = dir();
|
|
const out = await runConfigBackup('auto');
|
|
rec.dir = '';
|
|
|
|
expect(out?.result.status).toBe('created');
|
|
expect(out?.removed).toEqual([]); // the recorded 100 / no age limit, not the defaults
|
|
expect(rec.calls.filter((c) => c.startsWith('readdirSync'))).toHaveLength(1);
|
|
const manifestReads = rec.calls.filter((c) => c.startsWith('readFileSync') && c.endsWith('manifest.json'));
|
|
expect(manifestReads).toHaveLength(6);
|
|
expect(new Set(manifestReads).size).toBe(6);
|
|
expect(listConfigBackups(dir())).toHaveLength(7);
|
|
});
|
|
|
|
it('counts the snapshot it just made when it prunes', async () => {
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 0 }));
|
|
for (let i = 0; i < 3; i++) {
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: i }));
|
|
createConfigBackup({ dataDir: getDataDir(), backupDir: dir(), reason: 'auto', now: BASE + i * DAY });
|
|
}
|
|
const before = listConfigBackups(dir()).map((b) => b.id);
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 9 }));
|
|
const out = await runConfigBackup('auto');
|
|
expect(out?.result.status).toBe('created');
|
|
const kept = listConfigBackups(dir()).map((b) => b.id);
|
|
// keep 2: the new one and the newest before it; the two older ones go
|
|
expect(kept).toEqual([(out?.result as { id: string }).id, before[0]]);
|
|
expect(out?.removed.sort()).toEqual([before[1], before[2]].sort());
|
|
});
|
|
});
|