Files
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

150 lines
7.7 KiB
TypeScript

/**
* Backup/restore of the plugin trees (#plugins). Proves the two-step, restart-safe
* flow: restore STAGES the extracted trees beside the live ones, and a later boot SWAPS
* them in (so we never overwrite a plugin DB the runtime holds open). Covers the no-op
* paths (older archive with no plugin trees) and that the pre-restore tree is replaced.
*/
import {
stageExtractedPluginTrees,
applyStagedPluginTrees,
setStagedRestoreApplier,
applyStagedRestoreNow,
} from '../../../src/nest/plugins/plugin-backup';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-pbackup-'));
process.env.TREK_PLUGINS_DIR = path.join(root, 'plugins');
process.env.TREK_PLUGINS_DATA_DIR = path.join(root, 'plugins-data');
});
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(root, { recursive: true, force: true });
});
const read = (p: string) => fs.readFileSync(p, 'utf8');
const write = (p: string, s: string) => {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, s);
};
describe('plugin backup staging + boot reconcile', () => {
it('stages the extracted trees beside the live ones, then a boot swaps them in', () => {
// A live install with old plugin data + code.
write(path.join(root, 'plugins-data', 'notes', 'plugin.db'), 'OLD-DATA');
write(path.join(root, 'plugins', 'notes', 'server', 'index.js'), 'OLD-CODE');
// A restore extracted a newer backup into an extract dir.
const extract = path.join(root, 'restore-123');
write(path.join(extract, 'plugins-data', 'notes', 'plugin.db'), 'NEW-DATA');
write(path.join(extract, 'plugins-code', 'notes', 'server', 'index.js'), 'NEW-CODE');
// stage: copies next to the live trees, does not touch the live trees yet
expect(stageExtractedPluginTrees(extract)).toBe(true);
expect(fs.existsSync(path.join(root, 'plugins-data.restore', 'notes', 'plugin.db'))).toBe(true);
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('OLD-DATA'); // live untouched
// boot reconcile: swaps staged -> live, drops staging + the old tree
expect(applyStagedPluginTrees().sort()).toEqual(['plugins-code', 'plugins-data']);
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('NEW-DATA');
expect(read(path.join(root, 'plugins', 'notes', 'server', 'index.js'))).toBe('NEW-CODE');
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
expect(fs.existsSync(path.join(root, 'plugins-data.pre-restore'))).toBe(false);
});
it('stage is a no-op for an archive that carries no plugin trees (older backup)', () => {
const extract = path.join(root, 'restore-empty');
write(path.join(extract, 'travel.db'), 'db'); // only core data, no plugin trees
expect(stageExtractedPluginTrees(extract)).toBe(false);
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
});
it('boot reconcile is a no-op when nothing was staged', () => {
write(path.join(root, 'plugins-data', 'notes', 'plugin.db'), 'LIVE');
expect(applyStagedPluginTrees()).toEqual([]);
expect(read(path.join(root, 'plugins-data', 'notes', 'plugin.db'))).toBe('LIVE'); // untouched
});
it('reconcile creates the live tree even when there was none before (fresh restore)', () => {
// no live plugins-data at all; only a staged one
write(path.join(root, 'plugins-data.restore', 'newplug', 'plugin.db'), 'DATA');
expect(applyStagedPluginTrees()).toEqual(['plugins-data']);
expect(read(path.join(root, 'plugins-data', 'newplug', 'plugin.db'))).toBe('DATA');
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
});
it('the swap replaces real entries but PRESERVES a dev-linked plugin the backup excluded', () => {
// a real plugin + a dev-linked plugin (junction to an author source outside the root)
const authorSrc = path.join(root, 'author-src');
write(path.join(authorSrc, 'server', 'index.js'), 'DEV');
write(path.join(root, 'plugins', 'real', 'server', 'index.js'), 'OLD-REAL');
fs.mkdirSync(path.join(root, 'plugins'), { recursive: true });
try {
fs.symlinkSync(authorSrc, path.join(root, 'plugins', 'linked'), 'junction');
} catch {
return;
} // symlink/junction not permitted in this env → skip
// a restore staged only the real plugin (dev-links are never in a backup)
const extract = path.join(root, 'restore-dl');
write(path.join(extract, 'plugins-code', 'real', 'server', 'index.js'), 'NEW-REAL');
stageExtractedPluginTrees(extract);
expect(applyStagedPluginTrees()).toContain('plugins-code');
expect(read(path.join(root, 'plugins', 'real', 'server', 'index.js'))).toBe('NEW-REAL'); // real replaced
expect(fs.existsSync(path.join(root, 'plugins', 'linked'))).toBe(true); // dev-link kept
expect(read(path.join(root, 'plugins', 'linked', 'server', 'index.js'))).toBe('DEV'); // still resolves
});
it('the swap removes a live plugin absent from the backup, and re-running is idempotent (crash-safe)', () => {
// live has an up-to-date plugin + a plugin uninstalled since the backup was taken
write(path.join(root, 'plugins-data', 'keep', 'plugin.db'), 'OLD');
write(path.join(root, 'plugins-data', 'removed-since', 'plugin.db'), 'GONE');
// staged (from the backup) only has 'keep'
write(path.join(root, 'plugins-data.restore', 'keep', 'plugin.db'), 'NEW');
applyStagedPluginTrees();
expect(read(path.join(root, 'plugins-data', 'keep', 'plugin.db'))).toBe('NEW'); // overwritten
expect(fs.existsSync(path.join(root, 'plugins-data', 'removed-since'))).toBe(false); // not in backup → removed
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false); // staging consumed last
// Simulate a crash BEFORE staging was deleted: the staging dir still exists, live is
// already (partly) applied. Copy-not-move means re-running restores correctly, never
// deleting an already-restored entry it can't recreate.
write(path.join(root, 'plugins-data.restore', 'keep', 'plugin.db'), 'NEW');
write(path.join(root, 'plugins-data', 'keep', 'plugin.db'), 'NEW'); // already applied
applyStagedPluginTrees();
expect(read(path.join(root, 'plugins-data', 'keep', 'plugin.db'))).toBe('NEW'); // intact, not lost
expect(fs.existsSync(path.join(root, 'plugins-data.restore'))).toBe(false);
});
it('applyStagedRestoreNow runs the registered applier (runtime quiesce), or reports false when none', async () => {
expect(await applyStagedRestoreNow()).toBe(false); // no applier registered
let ran = false;
setStagedRestoreApplier(async () => {
ran = true;
});
expect(await applyStagedRestoreNow()).toBe(true);
expect(ran).toBe(true);
// an applier that throws is caught and reported as not-applied (falls back to boot reconcile)
setStagedRestoreApplier(() => {
throw new Error('quiesce failed');
});
expect(await applyStagedRestoreNow()).toBe(false);
setStagedRestoreApplier(null);
});
it('a stale staging from an aborted prior restore is overwritten, not merged', () => {
write(path.join(root, 'plugins-data.restore', 'ghost', 'plugin.db'), 'STALE');
const extract = path.join(root, 'restore-x');
write(path.join(extract, 'plugins-data', 'real', 'plugin.db'), 'FRESH');
stageExtractedPluginTrees(extract);
// the stale 'ghost' entry is gone; only the fresh staging remains
expect(fs.existsSync(path.join(root, 'plugins-data.restore', 'ghost'))).toBe(false);
expect(read(path.join(root, 'plugins-data.restore', 'real', 'plugin.db'))).toBe('FRESH');
});
});