refactor(rollback): extract performRollback + add 6 tests
CI / test (push) Has been cancelled

- Extract core logic into testable performRollback(opts) function
- CLI action now delegates to performRollback and prints output
- Tests cover: dry-run, event export/delete, git checkout, missing
  commit error, noop when already at target, dump metadata
- All tests use tmpdir, no dependency on ~/.upulse
This commit is contained in:
2026-04-18 01:39:22 +00:00
parent 8754ff51cd
commit 5ccd50221c
2 changed files with 420 additions and 161 deletions
@@ -0,0 +1,213 @@
/**
* rollback.test.ts — Tests for performRollback()
*
* 小橘 🍊 (NEKO Team)
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execSync } from 'node:child_process';
import { mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Database } from 'bun:sqlite';
import { performRollback } from './rollback.js';
function git(cmd: string, cwd: string): string {
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10_000 }).trim();
}
interface TestEngine {
engineDir: string;
eventsPath: string;
v1Commit: string;
v2Commit: string;
v1Timestamp: number;
v2Timestamp: number;
}
function setupTestEngine(tmpDir: string): TestEngine {
const engineDir = join(tmpDir, 'engine');
mkdirSync(engineDir, { recursive: true });
git('init', engineDir);
git('config user.email "test@test.com"', engineDir);
git('config user.name "Test"', engineDir);
// v1 commit
const v1Date = '2025-01-01T00:00:00Z';
const v1Timestamp = new Date(v1Date).getTime();
execSync('echo "v1" > file.txt', { cwd: engineDir });
git('add .', engineDir);
execSync(
`GIT_AUTHOR_DATE="${v1Date}" GIT_COMMITTER_DATE="${v1Date}" git commit -m "v1"`,
{ cwd: engineDir, encoding: 'utf-8' },
);
const v1Commit = git('rev-parse HEAD', engineDir);
// v2 commit
const v2Date = '2025-06-01T00:00:00Z';
const v2Timestamp = new Date(v2Date).getTime();
execSync('echo "v2" > file.txt', { cwd: engineDir });
git('add .', engineDir);
execSync(
`GIT_AUTHOR_DATE="${v2Date}" GIT_COMMITTER_DATE="${v2Date}" git commit -m "v2"`,
{ cwd: engineDir, encoding: 'utf-8' },
);
const v2Commit = git('rev-parse HEAD', engineDir);
// Create events.db with events spanning both commits
const eventsPath = join(tmpDir, 'events.db');
const db = new Database(eventsPath);
db.exec(`
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT,
hash TEXT,
code_rev TEXT,
meta TEXT
);
`);
const insert = db.prepare(
'INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta) VALUES (?, ?, ?, ?, ?, ?)',
);
// 3 events during v1 (all <= v1Timestamp)
insert.run(v1Timestamp - 2000, 'task-created', 'a', 'h1', v1Commit, null);
insert.run(v1Timestamp - 1000, 'task-started', 'a', 'h2', v1Commit, null);
insert.run(v1Timestamp, 'task-done', 'a', 'h3', v1Commit, null);
// 2 events during v2
insert.run(v2Timestamp, 'task-created', 'b', 'h4', v2Commit, null);
insert.run(v2Timestamp + 1000, 'task-started', 'b', 'h5', v2Commit, null);
db.close();
return { engineDir, eventsPath, v1Commit, v2Commit, v1Timestamp, v2Timestamp };
}
let tmpDir: string;
beforeEach(() => {
tmpDir = join(tmpdir(), `rollback-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe('performRollback', () => {
test('dry run 不修改任何东西', () => {
const t = setupTestEngine(tmpDir);
const db = new Database(t.eventsPath, { readonly: true });
const beforeCount = (db.prepare('SELECT COUNT(*) as c FROM events').get() as any).c;
db.close();
const beforeHead = git('rev-parse HEAD', t.engineDir);
const result = performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: t.v1Commit,
dryRun: true,
});
expect(result.status).toBe('dry-run');
expect(result.eventsRemoved).toBe(2);
// Nothing changed
const db2 = new Database(t.eventsPath, { readonly: true });
const afterCount = (db2.prepare('SELECT COUNT(*) as c FROM events').get() as any).c;
db2.close();
expect(afterCount).toBe(beforeCount);
expect(git('rev-parse HEAD', t.engineDir)).toBe(beforeHead);
});
test('rollback 导出并删除正确的 events', () => {
const t = setupTestEngine(tmpDir);
const result = performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: t.v1Commit,
});
expect(result.status).toBe('rolled-back');
expect(result.eventsRemoved).toBe(2);
// Main db should have 3 events left (v1 ones)
const db = new Database(t.eventsPath, { readonly: true });
const remaining = (db.prepare('SELECT COUNT(*) as c FROM events').get() as any).c;
db.close();
expect(remaining).toBe(3);
// Dump should have 2 events
expect(result.dumpPath).toBeTruthy();
const dumpDb = new Database(result.dumpPath!, { readonly: true });
const dumped = (dumpDb.prepare('SELECT COUNT(*) as c FROM events').get() as any).c;
dumpDb.close();
expect(dumped).toBe(2);
});
test('rollback 切换到目标 commit', () => {
const t = setupTestEngine(tmpDir);
performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: t.v1Commit,
});
const head = git('rev-parse HEAD', t.engineDir);
expect(head).toBe(t.v1Commit);
});
test('目标 commit 不存在时报错', () => {
const t = setupTestEngine(tmpDir);
expect(() =>
performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: 'deadbeefdeadbeef',
}),
).toThrow(/Commit not found/);
});
test('已在目标 commit 时无操作', () => {
const t = setupTestEngine(tmpDir);
const head = git('rev-parse HEAD', t.engineDir); // v2
const result = performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: t.v2Commit,
});
expect(result.status).toBe('noop');
expect(result.eventsRemoved).toBe(0);
expect(git('rev-parse HEAD', t.engineDir)).toBe(head);
});
test('rollback dump 包含正确的 metadata', () => {
const t = setupTestEngine(tmpDir);
const result = performRollback({
engineDir: t.engineDir,
eventsPath: t.eventsPath,
targetCommit: t.v1Commit,
});
expect(result.dumpPath).toBeTruthy();
const dumpDb = new Database(result.dumpPath!, { readonly: true });
const getInfo = (key: string) =>
(dumpDb.prepare('SELECT value FROM rollback_info WHERE key = ?').get(key) as any)?.value;
expect(getInfo('from_commit')).toBe(result.fromCommit);
expect(getInfo('to_commit')).toBe(result.toCommit);
expect(getInfo('cutoff_event_id')).toBeTruthy();
expect(getInfo('events_dumped')).toBe('2');
expect(getInfo('rolled_back_at')).toBeTruthy();
dumpDb.close();
});
});
+207 -161
View File
@@ -27,6 +27,181 @@ function run(cmd: string, cwd?: string): string {
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 10_000 }).trim();
}
/* ── Exported pure-logic function ────────────────────────────── */
export interface RollbackOptions {
engineDir: string;
eventsPath: string;
targetCommit: string;
dryRun?: boolean;
}
export interface RollbackResult {
status: 'noop' | 'dry-run' | 'rolled-back';
fromCommit: string;
toCommit: string;
eventsRemoved: number;
dumpPath?: string;
}
export function performRollback(opts: RollbackOptions): RollbackResult {
const { engineDir, eventsPath, targetCommit, dryRun } = opts;
if (!existsSync(engineDir)) {
throw new Error(`Engine directory not found: ${engineDir}`);
}
if (!existsSync(join(engineDir, '.git'))) {
throw new Error(`Engine directory is not a git repo: ${engineDir}`);
}
// 1. Verify target commit
let targetShort: string;
try {
targetShort = run(`git rev-parse --short ${targetCommit}`, engineDir);
run(`git cat-file -e ${targetCommit}`, engineDir);
} catch {
throw new Error(`Commit not found in engine repo: ${targetCommit}`);
}
const currentCommit = run('git rev-parse --short HEAD', engineDir);
if (targetShort === currentCommit) {
return { status: 'noop', fromCommit: currentCommit, toCommit: targetShort, eventsRemoved: 0 };
}
// 2. Find cutoff by target commit author date
const targetTimestamp =
parseInt(run(`git show -s --format=%at ${targetCommit}`, engineDir)) * 1000;
if (!existsSync(eventsPath)) {
if (!dryRun) {
run(`git checkout ${targetCommit}`, engineDir);
}
return {
status: dryRun ? 'dry-run' : 'rolled-back',
fromCommit: currentCommit,
toCommit: targetShort,
eventsRemoved: 0,
};
}
const db = dryRun ? new Database(eventsPath, { readonly: true }) : new Database(eventsPath);
const cutoffRow = db
.prepare('SELECT MAX(id) as maxId FROM events WHERE occurred_at <= ?')
.get(targetTimestamp) as { maxId: number | null } | null;
const cutoffId = cutoffRow?.maxId ?? 0;
const countRow = db
.prepare('SELECT COUNT(*) as cnt FROM events WHERE id > ?')
.get(cutoffId) as { cnt: number };
const eventsToRemove = countRow.cnt;
if (eventsToRemove === 0) {
db.close();
if (!dryRun) {
run(`git checkout ${targetCommit}`, engineDir);
}
return {
status: dryRun ? 'dry-run' : 'rolled-back',
fromCommit: currentCommit,
toCommit: targetShort,
eventsRemoved: 0,
};
}
if (dryRun) {
db.close();
return {
status: 'dry-run',
fromCommit: currentCommit,
toCommit: targetShort,
eventsRemoved: eventsToRemove,
};
}
// 3. Export events to rollback dump
const now = new Date();
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const rollbackDir = join(engineDir, '.pulse', 'rollbacks');
mkdirSync(rollbackDir, { recursive: true });
const dumpPath = join(rollbackDir, `rollback-${dateStr}-${currentCommit}.db`);
const dumpDb = new Database(dumpPath);
dumpDb.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
occurred_at INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT,
hash TEXT,
code_rev TEXT,
meta TEXT
);
CREATE TABLE IF NOT EXISTS rollback_info (
key TEXT PRIMARY KEY,
value TEXT
);
`);
const events = db.prepare('SELECT * FROM events WHERE id > ?').all(cutoffId) as any[];
const insertStmt = dumpDb.prepare(
'INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
const insertTx = dumpDb.transaction(() => {
for (const e of events) {
insertStmt.run(e.id, e.occurred_at, e.kind, e.key, e.hash, e.code_rev, e.meta);
}
});
insertTx();
const infoStmt = dumpDb.prepare('INSERT INTO rollback_info (key, value) VALUES (?, ?)');
infoStmt.run('from_commit', currentCommit);
infoStmt.run('to_commit', targetShort);
infoStmt.run('cutoff_event_id', String(cutoffId));
infoStmt.run('events_dumped', String(eventsToRemove));
infoStmt.run('rolled_back_at', now.toISOString());
dumpDb.close();
// 4. Delete events from main db
db.prepare('DELETE FROM events WHERE id > ?').run(cutoffId);
// 5. Delete projection cache if exists
const projCachePath = join(engineDir, '.pulse', 'projection-cache.db');
if (existsSync(projCachePath)) {
unlinkSync(projCachePath);
}
// Clean projections table if present
try {
const hasTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projections'")
.get();
if (hasTable) {
db.prepare('DELETE FROM projections').run();
}
} catch {
// ignore
}
db.close();
// 6. Git checkout
run(`git checkout ${targetCommit}`, engineDir);
return {
status: 'rolled-back',
fromCommit: currentCommit,
toCommit: targetShort,
eventsRemoved: eventsToRemove,
dumpPath,
};
}
/* ── CLI registration ────────────────────────────────────────── */
export function registerRollbackCommand(program: Command): void {
program
.command('rollback <commit>')
@@ -39,170 +214,41 @@ export function registerRollbackCommand(program: Command): void {
const config = loadConfig(baseDir);
const engineDir = opts.engine || config?.engine?.path || join(baseDir, 'engine');
const eventsPath = opts.events || join(config?.store?.scopesDir || join(baseDir, 'scopes'), 'workflows.db');
const eventsPath =
opts.events ||
join(config?.store?.scopesDir || join(baseDir, 'scopes'), 'workflows.db');
if (!existsSync(engineDir)) {
console.error(`❌ Engine directory not found: ${engineDir}`);
process.exit(1);
}
if (!existsSync(join(engineDir, '.git'))) {
console.error(`❌ Engine directory is not a git repo: ${engineDir}`);
process.exit(1);
}
// 1. Verify target commit
let targetShort: string;
try {
targetShort = run(`git rev-parse --short ${targetCommit}`, engineDir);
run(`git cat-file -e ${targetCommit}`, engineDir);
} catch {
console.error(`❌ Commit not found in engine repo: ${targetCommit}`);
const result = performRollback({
engineDir,
eventsPath,
targetCommit,
dryRun: opts.dryRun,
});
switch (result.status) {
case 'noop':
console.log('ℹ️ Already at target commit, nothing to do.');
break;
case 'dry-run':
console.log(`🔄 Rollback: ${result.fromCommit}${result.toCommit}`);
console.log(
`🏜️ Dry run — would remove ${result.eventsRemoved} events and revert to ${result.toCommit}`,
);
break;
case 'rolled-back':
console.log(`🔄 Rollback: ${result.fromCommit}${result.toCommit}`);
if (result.dumpPath) {
console.log(`💾 Dumped ${result.eventsRemoved} events → ${result.dumpPath}`);
}
console.log(`✅ Engine reverted to ${result.toCommit}`);
console.log('\n🎯 Rollback complete! Restart the daemon to apply changes.');
console.log(' sudo systemctl restart pulse-workflow.service');
break;
}
} catch (err: any) {
console.error(`${err.message}`);
process.exit(1);
}
const currentCommit = run('git rev-parse --short HEAD', engineDir);
if (targetShort === currentCommit) {
console.log('ℹ️ Already at target commit, nothing to do.');
return;
}
console.log(`🔄 Rollback: ${currentCommit}${targetShort}`);
// 2. Find the last event ID at target commit time
// We use the target commit's author date as the cutoff
const targetTimestamp = parseInt(
run(`git show -s --format=%at ${targetCommit}`, engineDir),
) * 1000; // ms
if (!existsSync(eventsPath)) {
console.log('ℹ️ No events.db found, just checking out code.');
if (!opts.dryRun) {
run(`git checkout ${targetCommit}`, engineDir);
console.log(`✅ Engine reverted to ${targetShort}`);
}
return;
}
const db = new Database(eventsPath, { readonly: opts.dryRun });
// Find the last event before/at the target timestamp
const cutoffRow = db.prepare(
'SELECT MAX(id) as maxId FROM events WHERE occurred_at <= ?',
).get(targetTimestamp) as { maxId: number | null } | null;
const cutoffId = cutoffRow?.maxId ?? 0;
// Count events to be removed
const countRow = db.prepare(
'SELECT COUNT(*) as cnt FROM events WHERE id > ?',
).get(cutoffId) as { cnt: number };
const eventsToRemove = countRow.cnt;
console.log(`📊 Events after cutoff (id > ${cutoffId}): ${eventsToRemove}`);
console.log(` Cutoff timestamp: ${new Date(targetTimestamp).toISOString()}`);
if (eventsToRemove === 0) {
console.log('ℹ️ No events to remove, just reverting code.');
if (!opts.dryRun) {
run(`git checkout ${targetCommit}`, engineDir);
console.log(`✅ Engine reverted to ${targetShort}`);
} else {
console.log('🏜️ Dry run — no changes made.');
}
db.close();
return;
}
if (opts.dryRun) {
console.log(`🏜️ Dry run — would remove ${eventsToRemove} events and revert to ${targetShort}`);
db.close();
return;
}
// 3. Export events to rollback dump
const now = new Date();
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const rollbackDir = join(engineDir, '.pulse', 'rollbacks');
mkdirSync(rollbackDir, { recursive: true });
const dumpPath = join(rollbackDir, `rollback-${dateStr}-${currentCommit}.db`);
// Create dump database and copy events
const dumpDb = new Database(dumpPath);
dumpDb.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
occurred_at INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT,
hash TEXT,
code_rev TEXT,
meta TEXT
);
CREATE TABLE IF NOT EXISTS rollback_info (
key TEXT PRIMARY KEY,
value TEXT
);
`);
// Copy events
const events = db.prepare('SELECT * FROM events WHERE id > ?').all(cutoffId) as any[];
const insertStmt = dumpDb.prepare(
'INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
const insertTx = dumpDb.transaction(() => {
for (const e of events) {
insertStmt.run(e.id, e.occurred_at, e.kind, e.key, e.hash, e.code_rev, e.meta);
}
});
insertTx();
// Store rollback metadata
const infoStmt = dumpDb.prepare('INSERT INTO rollback_info (key, value) VALUES (?, ?)');
infoStmt.run('from_commit', currentCommit);
infoStmt.run('to_commit', targetShort);
infoStmt.run('cutoff_event_id', String(cutoffId));
infoStmt.run('events_dumped', String(eventsToRemove));
infoStmt.run('rolled_back_at', now.toISOString());
dumpDb.close();
console.log(`💾 Dumped ${eventsToRemove} events → ${dumpPath}`);
// 4. Delete events from main db
db.prepare('DELETE FROM events WHERE id > ?').run(cutoffId);
console.log(`🗑️ Deleted ${eventsToRemove} events from events.db`);
// 5. Delete projection cache if exists
const projCachePath = join(engineDir, '.pulse', 'projection-cache.db');
if (existsSync(projCachePath)) {
unlinkSync(projCachePath);
console.log('🗑️ Deleted projection-cache.db');
}
// Also clean projections table if it exists in events.db
try {
const hasTable = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='projections'",
).get();
if (hasTable) {
db.prepare('DELETE FROM projections').run();
console.log('🗑️ Cleared projections table');
}
} catch {
// ignore
}
db.close();
// 6. Git checkout
run(`git checkout ${targetCommit}`, engineDir);
console.log(`✅ Engine reverted to ${targetShort}`);
console.log('\n🎯 Rollback complete! Restart the daemon to apply changes.');
console.log(` sudo systemctl restart pulse-workflow.service`);
});
}