Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added unit coverage for SQLite/PostgreSQL storage synchronization and one-time legacy flat-file imports, including empty, malformed, and permission-refusal paths.

### Changed

- **BREAKING: deployment modes are gone; hooks storage is a two-value data-backend switch.** `StorageMode = "local" | "hybrid" | "remote"` described *where* something ran, which was never a property of the data layer, and nothing in the codebase ever branched on it — it was reported by `hooks storage status` and the `storage_status` MCP tool and otherwise decorative. It is replaced by `StorageBackend = "sqlite" | "postgresql"`.
Expand Down
39 changes: 27 additions & 12 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
import { join } from "path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { homedir, tmpdir } from "os";
import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";

const CLI = join(import.meta.dir, "index.tsx");
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");

let settingsBackup: string | null = null;
const TEST_HOME = mkdtempSync(join(process.cwd(), ".tmp-cli-home-"));
const SETTINGS_PATH = join(TEST_HOME, ".claude", "settings.json");

const settingsBackups: Array<string | null> = [];

function cliEnv(): Record<string, string | undefined> {
return {
...process.env,
HOME: TEST_HOME,
HASNA_HOOKS_CLAUDE_SETTINGS_PATH: SETTINGS_PATH,
NO_COLOR: "1",
};
}

function backupSettings(): void {
if (existsSync(SETTINGS_PATH)) {
settingsBackup = readFileSync(SETTINGS_PATH, "utf-8");
settingsBackups.push(readFileSync(SETTINGS_PATH, "utf-8"));
} else {
settingsBackup = null;
settingsBackups.push(null);
}
}

function restoreSettings(): void {
const settingsBackup = settingsBackups.pop();
if (settingsBackup === undefined) return;
if (settingsBackup !== null) {
writeFileSync(SETTINGS_PATH, settingsBackup);
} else if (existsSync(SETTINGS_PATH)) {
rmSync(SETTINGS_PATH);
}
settingsBackup = null;
}

async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, NO_COLOR: "1" },
env: cliEnv(),
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
Expand All @@ -39,6 +50,10 @@ async function run(...args: string[]): Promise<{ stdout: string; stderr: string;
return { stdout, stderr, exitCode };
}

afterAll(() => {
rmSync(TEST_HOME, { recursive: true, force: true });
});

async function runJson(...args: string[]): Promise<any> {
const { stdout } = await run(...args, "--json");
return JSON.parse(stdout.trim());
Expand Down Expand Up @@ -318,7 +333,7 @@ describe("CLI", () => {
stdout: "pipe",
stderr: "pipe",
cwd: tmpdir(),
env: { ...process.env, NO_COLOR: "1" },
env: cliEnv(),
});
const stdout = await new Response(proc.stdout).text();
await proc.exited;
Expand All @@ -330,7 +345,7 @@ describe("CLI", () => {
stdout: "pipe",
stderr: "pipe",
cwd: tmpdir(),
env: { ...process.env, NO_COLOR: "1" },
env: cliEnv(),
});
const stdout = await new Response(proc.stdout).text();
await proc.exited;
Expand Down
151 changes: 151 additions & 0 deletions src/db/legacy-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runLegacyImport } from "./legacy-import.js";
import { applySchema } from "./schema.js";

interface ImportedEvent {
timestamp: string;
session_id: string;
hook_name: string;
event_type: string;
tool_name: string | null;
tool_input: string | null;
error: string | null;
}

let db: Database;
let tempHome: string;

function projectsDir(): string {
return join(tempHome, ".claude", "projects");
}

function importedEvents(): ImportedEvent[] {
return db.query<ImportedEvent, []>(
`SELECT timestamp, session_id, hook_name, event_type, tool_name, tool_input, error
FROM hook_events ORDER BY timestamp, hook_name`,
).all();
}

beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), "hooks-legacy-import-test-"));
db = new Database(":memory:");
applySchema(db);
});

afterEach(() => {
db.close();
mock.restore();
rmSync(tempHome, { recursive: true, force: true });
});

describe("runLegacyImport", () => {
test("imports valid JSONL and error entries, skips malformed lines, and only runs once", () => {
const projectDir = join(projectsDir(), "project-a");
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, "session-log-2026-07-29.jsonl"), [
JSON.stringify({
timestamp: "2026-07-29T00:00:00.000Z",
session_id: "session-a",
tool_name: "Read",
tool_input: "src/index.ts",
}),
"not json",
JSON.stringify({
timestamp: "2026-07-29T00:01:00.000Z",
tool_name: "Write",
tool_input: "x".repeat(600),
}),
"",
].join("\n"));
writeFileSync(join(projectDir, "errors.log"), [
"[2026-07-29T00:02:00.000Z] [session:abc] Build — failed to compile",
`[2026-07-29T00:03:00.000Z] Runtime — ${"e".repeat(600)}`,
"malformed error line",
].join("\n"));
writeFileSync(
join(projectDir, "session-log-current.jsonl"),
JSON.stringify({ timestamp: "2026-07-29T00:04:00.000Z" }),
);

runLegacyImport(db, tempHome);

const events = importedEvents();
expect(events).toHaveLength(4);
expect(events[0]).toEqual({
timestamp: "2026-07-29T00:00:00.000Z",
session_id: "session-a",
hook_name: "sessionlog",
event_type: "PostToolUse",
tool_name: "Read",
tool_input: "src/index.ts",
error: null,
});
expect(events[1]).toMatchObject({
session_id: "legacy",
hook_name: "sessionlog",
tool_name: "Write",
});
expect(events[1]?.tool_input).toHaveLength(500);
expect(events[2]).toMatchObject({
session_id: "legacy-abc",
hook_name: "errornotify",
event_type: "PostToolUse",
error: "failed to compile",
});
expect(events[3]).toMatchObject({ session_id: "legacy", hook_name: "errornotify" });
expect(events[3]?.error).toHaveLength(500);
expect(db.query<{ value: string }, []>(
"SELECT value FROM _meta WHERE key = 'legacy_import_done'",
).get()).toEqual({ value: "1" });

writeFileSync(
join(projectDir, "session-log-2026-07-30.jsonl"),
JSON.stringify({ timestamp: "2026-07-30T00:00:00.000Z" }),
);
runLegacyImport(db, tempHome);
expect(importedEvents()).toHaveLength(4);
});

test("marks an absent legacy directory complete without inserting events", () => {
runLegacyImport(db, tempHome);

expect(importedEvents()).toEqual([]);
expect(db.query<{ value: string }, []>(
"SELECT value FROM _meta WHERE key = 'legacy_import_done'",
).get()).toEqual({ value: "1" });
});

test("skips unreadable project entries and files without aborting the import", () => {
mkdirSync(projectsDir(), { recursive: true });
writeFileSync(join(projectsDir(), "not-a-directory"), "not a project directory");
const projectDir = join(projectsDir(), "project-b");
mkdirSync(join(projectDir, "errors.log"), { recursive: true });
writeFileSync(join(projectDir, "session-log-2026-07-29.jsonl"), "{malformed");

expect(() => runLegacyImport(db, tempHome)).not.toThrow();
expect(importedEvents()).toEqual([]);
expect(db.query<{ value: string }, []>(
"SELECT value FROM _meta WHERE key = 'legacy_import_done'",
).get()).toEqual({ value: "1" });
});

test("contains database write failures and still records completion", () => {
db.close();
db = new Database(":memory:");
const projectDir = join(projectsDir(), "project-c");
mkdirSync(projectDir, { recursive: true });
writeFileSync(
join(projectDir, "session-log-2026-07-29.jsonl"),
JSON.stringify({ timestamp: "2026-07-29T00:00:00.000Z" }),
);

expect(() => runLegacyImport(db, tempHome)).not.toThrow();
expect(db.query<{ value: string }, []>(
"SELECT value FROM _meta WHERE key = 'legacy_import_done'",
).get()).toEqual({ value: "1" });
});
});
4 changes: 2 additions & 2 deletions src/db/legacy-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,14 @@ function importErrorsLog(db: Database, filePath: string): number {
return count;
}

export function runLegacyImport(db: Database): void {
export function runLegacyImport(db: Database, homeDir = homedir()): void {
try {
if (isAlreadyDone(db)) return;

let total = 0;

// Scan ~/.claude/projects/<hash>/ directories for session log files
const claudeProjectsDir = join(homedir(), ".claude", "projects");
const claudeProjectsDir = join(homeDir, ".claude", "projects");
if (existsSync(claudeProjectsDir)) {
try {
const projectDirs = readdirSync(claudeProjectsDir);
Expand Down
Loading
Loading