- 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user