From 3ae872d9cb8fa4f7a94bf8c0e0b877f87b696879 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Fri, 31 Jul 2026 16:24:26 +0300 Subject: [PATCH] fix(db): drop the retired @hasna/cloud adapter, restore bun:sqlite PR #16 (1c37dae, merged 2026-07-30T09:05Z) replaced bun:sqlite with the @hasna/cloud SqliteAdapter. @hasna/cloud was already deprecated on npm six days earlier; its notice reads "is retired and no longer supported by Hasna. The source repo has been deleted. Do not add new dependencies on it; services now own their storage (local SQLite / self-hosted API)." The GitHub repo is confirmed gone (246 repos enumerated across both orgs, zero matches), so a defect in that adapter can never be fixed upstream. This package had a working, self-owned local SQLite store before #16, so the correct home named by the deprecation notice is the one it already had: restore it. This is a clean inverse of 1c37dae, which was the tip of main and a purely mechanical adapter swap (Database -> SqliteAdapter, DbAdapter types, array bind params -> variadic, .query().get()/.all() -> .get()/.all(), and db.transaction(fn)() -> db.transaction(fn)). No behaviour beyond the adapter swap is changed. Adds src/db/storage-ownership.test.ts so the swap cannot land again unnoticed. Every assertion carries a positive control, and each was verified to die under targeted mutation: - re-adding the dep to package.json alone kills assertion 1 only - re-adding the import to one source file alone kills assertion 2 only (and names that file) - wrapping the handle in a pass-through adapter kills assertion 3 only Verification (worktree 3281f770-decloud, 177 packages installed, @hasna/cloud absent from node_modules): tsc --noEmit rc=0 bun test 1055 pass, 1 fail, 3969 expect() calls, 18 files The single failure is hooks/codewith-native-common.test.ts, a 5000ms timeout in a destructive-shell-guard test that references no db code and is not in this diff. Confirmed pre-existing: the same file on unmodified origin/main gives an identical 123 pass / 1 fail. Refs: todos 3281f770, knowledge k_ms8ngox0_eb1tet Agent: Vespasian --- package.json | 1 - src/db/index.ts | 14 +-- src/db/legacy-import.ts | 50 ++++---- src/db/migrations/001_initial.ts | 4 +- src/db/migrations/002_session_events.ts | 13 +- .../003_user_prompt_submit_event.ts | 13 +- src/db/migrations/index.ts | 17 ++- src/db/migrations/migrations.test.ts | 56 +++++---- src/db/retention.ts | 9 +- src/db/schema.test.ts | 20 +-- src/db/schema.ts | 6 +- src/db/storage-ownership.test.ts | 116 ++++++++++++++++++ src/db/storage-sync.ts | 33 ++--- 13 files changed, 232 insertions(+), 120 deletions(-) create mode 100644 src/db/storage-ownership.test.ts diff --git a/package.json b/package.json index 5449deb..8fd796f 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "typescript": "^5" }, "dependencies": { - "@hasna/cloud": "^0.1.41", "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.26.0", "chalk": "^5.3.0", diff --git a/src/db/index.ts b/src/db/index.ts index c375c5a..895c279 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,11 +1,11 @@ /** * SQLite DB module for hooks — persistent storage at ~/.hasna/hooks/hooks.db * - * Uses the @hasna/cloud SQLite adapter with WAL mode for concurrent reads. + * Uses bun:sqlite with WAL mode for concurrent reads. * Supports HASNA_HOOKS_DATA_DIR / HOOKS_DATA_DIR and HASNA_HOOKS_DB_PATH / HOOKS_DB_PATH env overrides. */ -import { SqliteAdapter } from "@hasna/cloud"; +import { Database } from "bun:sqlite"; import { existsSync, mkdirSync, cpSync } from "fs"; import { join } from "path"; import { homedir } from "os"; @@ -13,7 +13,7 @@ import { runMigrations } from "./migrations"; import { runLegacyImport } from "./legacy-import"; import { runRetention } from "./retention"; -let instance: SqliteAdapter | null = null; +let instance: Database | null = null; function resolveDataDir(): string { const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR; @@ -46,14 +46,14 @@ function ensureDir(dbPath: string): void { } } -export function getDb(): SqliteAdapter { +export function getDb(): Database { if (instance) return instance; const dbPath = getDbPath(); const isNew = dbPath === ":memory:" || !existsSync(dbPath); ensureDir(dbPath); - instance = new SqliteAdapter(dbPath); + instance = new Database(dbPath); instance.exec("PRAGMA journal_mode=WAL"); instance.exec("PRAGMA foreign_keys=ON"); runMigrations(instance); @@ -82,8 +82,8 @@ export function closeDb(): void { } } -export function createTestDb(): SqliteAdapter { - const db = new SqliteAdapter(":memory:"); +export function createTestDb(): Database { + const db = new Database(":memory:"); db.exec("PRAGMA journal_mode=WAL"); db.exec("PRAGMA foreign_keys=ON"); return db; diff --git a/src/db/legacy-import.ts b/src/db/legacy-import.ts index d906bac..7d2e394 100644 --- a/src/db/legacy-import.ts +++ b/src/db/legacy-import.ts @@ -8,14 +8,14 @@ * Tracks completion via a `_meta` table row keyed "legacy_import_done". */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { existsSync, readdirSync, readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; const META_KEY = "legacy_import_done"; -function ensureMetaTable(db: DbAdapter): void { +function ensureMetaTable(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS _meta ( key TEXT PRIMARY KEY, @@ -24,21 +24,21 @@ function ensureMetaTable(db: DbAdapter): void { `); } -function isAlreadyDone(db: DbAdapter): boolean { +function isAlreadyDone(db: Database): boolean { ensureMetaTable(db); - const row = db.get("SELECT value FROM _meta WHERE key = ?", META_KEY) as { value: string } | undefined; + const row = db.query<{ value: string }, [string]>("SELECT value FROM _meta WHERE key = ?").get(META_KEY); return row?.value === "1"; } -function markDone(db: DbAdapter): void { - db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", META_KEY, "1"); +function markDone(db: Database): void { + db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]); } function nanoid(): string { return crypto.randomUUID().replace(/-/g, "").slice(0, 21); } -function importJsonlFile(db: DbAdapter, filePath: string): number { +function importJsonlFile(db: Database, filePath: string): number { let count = 0; try { const lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean); @@ -49,14 +49,16 @@ function importJsonlFile(db: DbAdapter, filePath: string): number { `INSERT OR IGNORE INTO hook_events (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - nanoid(), - entry.timestamp ?? new Date().toISOString(), - entry.session_id ?? "legacy", - "sessionlog", - "PostToolUse", - entry.tool_name ?? null, - entry.tool_input ? String(entry.tool_input).slice(0, 500) : null, - null, + [ + nanoid(), + entry.timestamp ?? new Date().toISOString(), + entry.session_id ?? "legacy", + "sessionlog", + "PostToolUse", + entry.tool_name ?? null, + entry.tool_input ? String(entry.tool_input).slice(0, 500) : null, + null, + ] ); count++; } catch { @@ -69,7 +71,7 @@ function importJsonlFile(db: DbAdapter, filePath: string): number { return count; } -function importErrorsLog(db: DbAdapter, filePath: string): number { +function importErrorsLog(db: Database, filePath: string): number { let count = 0; try { const lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean); @@ -85,12 +87,14 @@ function importErrorsLog(db: DbAdapter, filePath: string): number { `INSERT OR IGNORE INTO hook_events (id, timestamp, session_id, hook_name, event_type, error) VALUES (?, ?, ?, ?, ?, ?)`, - nanoid(), - timestamp, - sessionPrefix ? `legacy-${sessionPrefix}` : "legacy", - "errornotify", - "PostToolUse", - errorMsg.slice(0, 500), + [ + nanoid(), + timestamp, + sessionPrefix ? `legacy-${sessionPrefix}` : "legacy", + "errornotify", + "PostToolUse", + errorMsg.slice(0, 500), + ] ); count++; } catch { @@ -103,7 +107,7 @@ function importErrorsLog(db: DbAdapter, filePath: string): number { return count; } -export function runLegacyImport(db: DbAdapter): void { +export function runLegacyImport(db: Database): void { try { if (isAlreadyDone(db)) return; diff --git a/src/db/migrations/001_initial.ts b/src/db/migrations/001_initial.ts index 63f42b0..c6d3178 100644 --- a/src/db/migrations/001_initial.ts +++ b/src/db/migrations/001_initial.ts @@ -3,10 +3,10 @@ * Creates hook_events table and indexes. */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { CREATE_HOOK_EVENTS_TABLE, CREATE_INDEXES } from "../schema"; -export function up(db: DbAdapter): void { +export function up(db: Database): void { db.exec(CREATE_HOOK_EVENTS_TABLE); for (const idx of CREATE_INDEXES) { db.exec(idx); diff --git a/src/db/migrations/002_session_events.ts b/src/db/migrations/002_session_events.ts index 70ab523..1178b33 100644 --- a/src/db/migrations/002_session_events.ts +++ b/src/db/migrations/002_session_events.ts @@ -7,14 +7,15 @@ * (fresh databases created from the updated schema.ts). */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { CREATE_HOOK_EVENTS_TABLE, CREATE_INDEXES } from "../schema"; -export function up(db: DbAdapter): void { - const row = db.get( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", - "hook_events", - ) as { sql: string | null } | undefined; +export function up(db: Database): void { + const row = db + .query<{ sql: string | null }, [string]>( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?" + ) + .get("hook_events"); // Table missing (shouldn't happen — 001 creates it) or already current. if (!row?.sql) return; diff --git a/src/db/migrations/003_user_prompt_submit_event.ts b/src/db/migrations/003_user_prompt_submit_event.ts index 4300120..575c6b3 100644 --- a/src/db/migrations/003_user_prompt_submit_event.ts +++ b/src/db/migrations/003_user_prompt_submit_event.ts @@ -7,14 +7,15 @@ * already current (fresh databases or databases rebuilt by a newer 002). */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { CREATE_HOOK_EVENTS_TABLE, CREATE_INDEXES } from "../schema"; -export function up(db: DbAdapter): void { - const row = db.get( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", - "hook_events", - ) as { sql: string | null } | undefined; +export function up(db: Database): void { + const row = db + .query<{ sql: string | null }, [string]>( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?" + ) + .get("hook_events"); if (!row?.sql) return; if (row.sql.includes("UserPromptSubmit")) return; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 850f70d..5629052 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,14 +4,14 @@ * Migrations are additive-only, never destructive. */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { up as migration001 } from "./001_initial"; import { up as migration002 } from "./002_session_events"; import { up as migration003 } from "./003_user_prompt_submit_event"; interface Migration { version: string; - up: (db: DbAdapter) => void; + up: (db: Database) => void; } const MIGRATIONS: Migration[] = [ @@ -20,7 +20,7 @@ const MIGRATIONS: Migration[] = [ { version: "003_user_prompt_submit_event", up: migration003 }, ]; -function ensureMigrationsTable(db: DbAdapter): void { +function ensureMigrationsTable(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, @@ -29,12 +29,12 @@ function ensureMigrationsTable(db: DbAdapter): void { `); } -function getApplied(db: DbAdapter): Set { - const rows = db.all("SELECT version FROM schema_migrations") as Array<{ version: string }>; +function getApplied(db: Database): Set { + const rows = db.query<{ version: string }, []>("SELECT version FROM schema_migrations").all(); return new Set(rows.map((r) => r.version)); } -export function runMigrations(db: DbAdapter): void { +export function runMigrations(db: Database): void { ensureMigrationsTable(db); const applied = getApplied(db); @@ -42,10 +42,9 @@ export function runMigrations(db: DbAdapter): void { if (applied.has(migration.version)) continue; migration.up(db); - db.run( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [ migration.version, new Date().toISOString(), - ); + ]); } } diff --git a/src/db/migrations/migrations.test.ts b/src/db/migrations/migrations.test.ts index 7d54f4d..10f0b10 100644 --- a/src/db/migrations/migrations.test.ts +++ b/src/db/migrations/migrations.test.ts @@ -1,6 +1,5 @@ import { describe, test, expect } from "bun:test"; -import type { DbAdapter } from "@hasna/cloud"; -import { createTestDb } from "../index"; +import { Database } from "bun:sqlite"; import { runMigrations } from "./index"; import { up as migration002 } from "./002_session_events"; import { up as migration003 } from "./003_user_prompt_submit_event"; @@ -41,21 +40,17 @@ const PRE_003_HOOK_EVENTS_TABLE = ` ) `; -function insertEvent(db: DbAdapter, id: string, eventType: string): void { +function insertEvent(db: Database, id: string, eventType: string): void { db.run( `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type) VALUES (?, ?, ?, ?, ?)`, - id, - new Date().toISOString(), - "session-1", - "sessionlog", - eventType, + [id, new Date().toISOString(), "session-1", "sessionlog", eventType] ); } describe("migrations", () => { test("fresh database accepts session and Codewith prompt events", () => { - const db = createTestDb(); + const db = new Database(":memory:"); runMigrations(db); insertEvent(db, "e1", "SessionStart"); @@ -63,13 +58,13 @@ describe("migrations", () => { insertEvent(db, "e3", "UserPromptSubmit"); insertEvent(db, "e4", "PreToolUse"); - const rows = db.all("SELECT event_type FROM hook_events") as Array<{ event_type: string }>; + const rows = db.query<{ event_type: string }, []>("SELECT event_type FROM hook_events").all(); expect(rows.map((r) => r.event_type).sort()).toEqual(["PreToolUse", "SessionEnd", "SessionStart", "UserPromptSubmit"]); db.close(); }); test("002 rebuilds a legacy table so session events are accepted and rows survive", () => { - const db = createTestDb(); + const db = new Database(":memory:"); db.exec(LEGACY_HOOK_EVENTS_TABLE); insertEvent(db, "legacy-1", "PreToolUse"); @@ -79,14 +74,17 @@ describe("migrations", () => { migration002(db); // Existing rows preserved - const kept = (db.all("SELECT id FROM hook_events") as Array<{ id: string }>).map((r) => r.id); + const kept = db + .query<{ id: string }, []>("SELECT id FROM hook_events") + .all() + .map((r) => r.id); expect(kept).toEqual(["legacy-1"]); // New event types accepted after rebuild insertEvent(db, "post-migration", "SessionStart"); insertEvent(db, "post-migration-2", "SessionEnd"); insertEvent(db, "post-migration-3", "UserPromptSubmit"); - const count = db.get("SELECT COUNT(*) as n FROM hook_events") as { n: number } | undefined; + const count = db.query<{ n: number }, []>("SELECT COUNT(*) as n FROM hook_events").get(); expect(count?.n).toBe(4); // Invalid event types still rejected @@ -95,7 +93,7 @@ describe("migrations", () => { }); test("003 rebuilds a pre-003 table so UserPromptSubmit is accepted and rows survive", () => { - const db = createTestDb(); + const db = new Database(":memory:"); db.exec(PRE_003_HOOK_EVENTS_TABLE); insertEvent(db, "legacy-1", "SessionStart"); @@ -104,13 +102,16 @@ describe("migrations", () => { migration003(db); insertEvent(db, "post-migration", "UserPromptSubmit"); - const rows = (db.all("SELECT id FROM hook_events ORDER BY id") as Array<{ id: string }>).map((r) => r.id); + const rows = db + .query<{ id: string }, []>("SELECT id FROM hook_events ORDER BY id") + .all() + .map((r) => r.id); expect(rows).toEqual(["legacy-1", "post-migration"]); db.close(); }); test("002 and 003 are idempotent on an already-current table", () => { - const db = createTestDb(); + const db = new Database(":memory:"); runMigrations(db); insertEvent(db, "e1", "SessionStart"); insertEvent(db, "e2", "UserPromptSubmit"); @@ -118,40 +119,43 @@ describe("migrations", () => { migration002(db); // second run must be a no-op, not a failure migration003(db); - const kept = db.all("SELECT id FROM hook_events") as Array<{ id: string }>; + const kept = db.query<{ id: string }, []>("SELECT id FROM hook_events").all(); expect(kept).toHaveLength(2); db.close(); }); test("runMigrations records all migrations exactly once", () => { - const db = createTestDb(); + const db = new Database(":memory:"); runMigrations(db); runMigrations(db); // re-running must not double-apply - const versions = (db.all("SELECT version FROM schema_migrations ORDER BY version") as Array<{ version: string }>).map( - (r) => r.version, - ); + const versions = db + .query<{ version: string }, []>("SELECT version FROM schema_migrations ORDER BY version") + .all() + .map((r) => r.version); expect(versions).toEqual(["001_initial", "002_session_events", "003_user_prompt_submit_event"]); db.close(); }); test("migrating a legacy DB via runMigrations upgrades the CHECK constraint", () => { - const db = createTestDb(); + const db = new Database(":memory:"); // Simulate a DB created by 001 only (old schema, 001 recorded) db.exec(`CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); db.exec(LEGACY_HOOK_EVENTS_TABLE); - db.run( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [ "001_initial", new Date().toISOString(), - ); + ]); insertEvent(db, "old-row", "Stop"); runMigrations(db); insertEvent(db, "new-row", "SessionEnd"); insertEvent(db, "prompt-row", "UserPromptSubmit"); - const rows = (db.all("SELECT id FROM hook_events ORDER BY id") as Array<{ id: string }>).map((r) => r.id); + const rows = db + .query<{ id: string }, []>("SELECT id FROM hook_events ORDER BY id") + .all() + .map((r) => r.id); expect(rows).toEqual(["new-row", "old-row", "prompt-row"]); db.close(); }); diff --git a/src/db/retention.ts b/src/db/retention.ts index cbadcc7..9a04a33 100644 --- a/src/db/retention.ts +++ b/src/db/retention.ts @@ -5,17 +5,16 @@ * Called on DB open after migrations. */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; -export function runRetention(db: DbAdapter, days?: number): number { +export function runRetention(db: Database, days?: number): number { const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30"); const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays); const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); try { - db.run("DELETE FROM hook_events WHERE timestamp < ?", cutoff); - const row = db.get("SELECT changes() as changes") as { changes: number } | undefined; - const changes = row?.changes ?? 0; + db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]); + const changes = db.query<{ changes: number }, []>("SELECT changes() as changes").get()?.changes ?? 0; return changes; } catch { return 0; diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts index 32b21a5..9aa885f 100644 --- a/src/db/schema.test.ts +++ b/src/db/schema.test.ts @@ -10,31 +10,19 @@ describe("hook_events schema", () => { db.run( `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type) VALUES (?, ?, ?, ?, ?)`, - "evt1", - new Date().toISOString(), - "sess", - "session-start", - "SessionStart", + ["evt1", new Date().toISOString(), "sess", "session-start", "SessionStart"], ); db.run( `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type) VALUES (?, ?, ?, ?, ?)`, - "evt2", - new Date().toISOString(), - "sess", - "prompt-guard", - "UserPromptSubmit", + ["evt2", new Date().toISOString(), "sess", "prompt-guard", "UserPromptSubmit"], ); db.run( `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type) VALUES (?, ?, ?, ?, ?)`, - "evt3", - new Date().toISOString(), - "sess", - "fleet-catchup", - "SessionEnd", + ["evt3", new Date().toISOString(), "sess", "fleet-catchup", "SessionEnd"], ); - const row = db.get("SELECT COUNT(*) as count FROM hook_events") as { count: number } | undefined; + const row = db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM hook_events").get(); expect(row?.count).toBe(3); } finally { db.close(); diff --git a/src/db/schema.ts b/src/db/schema.ts index 2a51daf..ba63010 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,9 +1,9 @@ /** * SQLite schema for @hasna/hooks local data. - * Owned by this package and applied through the shared cloud adapter. + * Owned by this package (no shared runtime dependency). */ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; export const CREATE_HOOK_EVENTS_TABLE = ` CREATE TABLE IF NOT EXISTS hook_events ( @@ -45,7 +45,7 @@ export interface HookEventRow { metadata: string | null; } -export function applySchema(db: DbAdapter): void { +export function applySchema(db: Database): void { db.exec(CREATE_HOOK_EVENTS_TABLE); for (const idx of CREATE_INDEXES) { db.exec(idx); diff --git a/src/db/storage-ownership.test.ts b/src/db/storage-ownership.test.ts new file mode 100644 index 0000000..18d9e63 --- /dev/null +++ b/src/db/storage-ownership.test.ts @@ -0,0 +1,116 @@ +/** + * Storage-ownership guard for @hasna/hooks. + * + * @hasna/cloud is RETIRED. Its npm deprecation notice reads: "is retired and no + * longer supported by Hasna. The source repo has been deleted. Do not add new + * dependencies on it; services now own their storage (local SQLite / + * self-hosted API)." Its GitHub repo is gone, so nothing can be patched there + * ever again — a dependency on it is unfixable by construction. + * + * This package owns its storage directly through bun:sqlite. PR #16 (merged + * 2026-07-30, six days AFTER the deprecation) replaced that with the + * @hasna/cloud SqliteAdapter; this guard exists so the swap cannot land again + * unnoticed. + * + * EVERY assertion below is paired with a positive control asserting that the + * same reader/scanner DOES find something that is genuinely present. Without + * those, a broken reader returning `{}` or an empty file list would make this + * whole file pass while checking nothing. + */ + +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { readFileSync, readdirSync } from "fs"; +import { join } from "path"; +import { createTestDb } from "./index"; + +const RETIRED = "@hasna/cloud"; +/** A dependency this package genuinely declares — the control for the manifest reader. */ +const CONTROL_DEP = "@hasna/events"; +/** A token that genuinely appears in src/ — the control for the tree scanner. */ +const CONTROL_IMPORT = "bun:sqlite"; + +const REPO_ROOT = join(import.meta.dir, "..", ".."); +const SRC_ROOT = join(REPO_ROOT, "src"); + +const MANIFEST_DEP_FIELDS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +function readManifest(): Record { + return JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf-8")); +} + +/** Every package name declared in any dependency field of package.json. */ +function declaredDependencies(): string[] { + const manifest = readManifest(); + const names: string[] = []; + for (const field of MANIFEST_DEP_FIELDS) { + const block = manifest[field]; + if (block && typeof block === "object") names.push(...Object.keys(block)); + } + return names; +} + +/** + * This guard file names the retired package in its own prose and constants, so + * it matches its own scan. Excluding exactly this one path — and nothing else — + * keeps the scan honest: any other file that mentions the package still fails. + */ +const SELF = join(import.meta.dir, import.meta.file); + +/** Every .ts/.tsx file under src/, recursively, except this guard itself. */ +function sourceFiles(dir: string = SRC_ROOT): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) found.push(full); + } + return found.filter((file) => file !== SELF); +} + +function filesContaining(token: string): string[] { + return sourceFiles().filter((file) => readFileSync(file, "utf-8").includes(token)); +} + +describe("storage ownership: no dependency on the retired @hasna/cloud", () => { + test("package.json declares no @hasna/cloud in any dependency field", () => { + const declared = declaredDependencies(); + + // POSITIVE CONTROL: the reader must actually see this manifest's contents. + // If package.json were unreadable or the field names wrong, `declared` would + // be empty and the real assertion below would pass while checking nothing. + expect(declared).toContain(CONTROL_DEP); + + expect(declared).not.toContain(RETIRED); + }); + + test("no source file imports @hasna/cloud", () => { + // POSITIVE CONTROL: the scanner must be able to find a token that IS there. + // A scanner that walked the wrong directory would return [] for everything. + expect(filesContaining(CONTROL_IMPORT).length).toBeGreaterThan(0); + + expect(filesContaining(RETIRED)).toEqual([]); + }); + + test("the database handle is a bun:sqlite Database, owned by this package", () => { + const db = createTestDb(); + try { + // Behavioural, not textual: swapping in any adapter wrapper fails here + // even if the import string were laundered past the scan above. + expect(db).toBeInstanceOf(Database); + + // And it is a working handle, not merely the right class. + db.exec("CREATE TABLE probe (id TEXT PRIMARY KEY)"); + db.run("INSERT INTO probe (id) VALUES (?)", ["row1"]); + const row = db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM probe").get(); + expect(row?.count).toBe(1); + } finally { + db.close(); + } + }); +}); diff --git a/src/db/storage-sync.ts b/src/db/storage-sync.ts index aa71383..ede0f19 100644 --- a/src/db/storage-sync.ts +++ b/src/db/storage-sync.ts @@ -1,4 +1,4 @@ -import type { DbAdapter } from "@hasna/cloud"; +import type { Database } from "bun:sqlite"; import { getDb } from "./index.js"; import { PG_MIGRATIONS } from "./pg-migrations.js"; import { PgAdapterAsync } from "./remote-storage.js"; @@ -227,7 +227,7 @@ export async function storageSync(options?: { tables?: string[] }): Promise<{ pu export function getSyncMetaAll(): SyncMeta[] { const db = getDb(); ensureSyncMetaTable(db); - return db.all("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction") as SyncMeta[]; + return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all() as SyncMeta[]; } export function getStorageStatus(): StorageStatus { @@ -257,11 +257,11 @@ export function parseStorageTables(value?: string | string[] | null): StorageTab return resolveTables(Array.isArray(value) ? value : value.split(",")); } -async function pushTable(db: DbAdapter, remote: PgAdapterAsync, table: StorageTable): Promise { +async function pushTable(db: Database, remote: PgAdapterAsync, table: StorageTable): Promise { const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; try { if (!tableExists(db, table)) return result; - const rows = db.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]; + const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as Row[]; result.rowsRead = rows.length; if (rows.length === 0) return result; const remoteColumns = await getRemoteColumns(remote, table); @@ -273,7 +273,7 @@ async function pushTable(db: DbAdapter, remote: PgAdapterAsync, table: StorageTa return result; } -async function pullTable(remote: PgAdapterAsync, db: DbAdapter, table: StorageTable): Promise { +async function pullTable(remote: PgAdapterAsync, db: Database, table: StorageTable): Promise { const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; try { if (!tableExists(db, table)) return result; @@ -301,8 +301,8 @@ function filterRemoteColumns(remoteColumns: Map, columns: string return columns.filter((column) => remoteColumns.has(column)); } -function filterLocalColumns(db: DbAdapter, table: string, columns: string[]): string[] { - const rows = db.all(`PRAGMA table_info(${quoteIdent(table)})`) as Array<{ name: string }>; +function filterLocalColumns(db: Database, table: string, columns: string[]): string[] { + const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ name: string }>; const allowed = new Set(rows.map((row) => row.name)); return columns.filter((column) => allowed.has(column)); } @@ -329,7 +329,7 @@ async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: st return rows.length; } -function upsertSqlite(db: DbAdapter, table: StorageTable, columns: string[], rows: Row[]): number { +function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows: Row[]): number { if (columns.length === 0) return 0; const primaryKeys = PRIMARY_KEYS[table]; const columnList = columns.map(quoteIdent).join(", "); @@ -340,20 +340,21 @@ function upsertSqlite(db: DbAdapter, table: StorageTable, columns: string[], row const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`; - const statement = db.prepare( + const statement = db.query( `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ); - db.transaction(() => { - for (const row of rows) statement.run(...columns.map((column) => coerceForSqlite(row[column]))); + const insert = db.transaction((batch: Row[]) => { + for (const row of batch) statement.run(...columns.map((column) => coerceForSqlite(row[column]))); }); + insert(rows); return rows.length; } -function recordSyncMeta(db: DbAdapter, direction: "push" | "pull", results: SyncResult[]): void { +function recordSyncMeta(db: Database, direction: "push" | "pull", results: SyncResult[]): void { ensureSyncMetaTable(db); const now = new Date().toISOString(); - const statement = db.prepare(` + const statement = db.query(` INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction) VALUES (?, ?, ?) ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at @@ -364,7 +365,7 @@ function recordSyncMeta(db: DbAdapter, direction: "push" | "pull", results: Sync } } -function ensureSyncMetaTable(db: DbAdapter): void { +function ensureSyncMetaTable(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS _hooks_sync_meta ( table_name TEXT NOT NULL, @@ -375,8 +376,8 @@ function ensureSyncMetaTable(db: DbAdapter): void { `); } -function tableExists(db: DbAdapter, table: string): boolean { - const row = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table); +function tableExists(db: Database, table: string): boolean { + const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table); return Boolean(row); }