diff --git a/src/db/database.ts b/src/db/database.ts index 37bb4c9..2b49ddb 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -2,29 +2,7 @@ import Database from "better-sqlite3"; import * as fs from "fs"; import * as path from "path"; -const SCHEMA_SQL = ` -CREATE TABLE IF NOT EXISTS workspaces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id TEXT NOT NULL UNIQUE, - guild_id TEXT NOT NULL, - agent_name TEXT NOT NULL UNIQUE, - workspace_path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER NOT NULL REFERENCES workspaces(id), - discord_msg_id TEXT NOT NULL UNIQUE, - role TEXT NOT NULL, - content TEXT NOT NULL, - author_name TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_conversations_workspace - ON conversations(workspace_id, created_at); -`; +const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8"); export interface Workspace { id: number; @@ -61,7 +39,7 @@ export class DisclawDatabase { } private runMigrations(): void { - this.db.exec(SCHEMA_SQL); + this.db.exec(schema); } // --- Workspace queries --- diff --git a/src/db/schema.sql b/src/db/schema.sql index 5e58204..4836d79 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,3 +1,7 @@ +-- DisClaw SQLite schema (SSOT) +-- All statements use IF NOT EXISTS for idempotent initialization. + +-- Workspaces: maps a Discord channel to a local agent workspace directory. CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL UNIQUE, @@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Conversations: stores per-message history for each workspace. CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id), @@ -17,5 +22,6 @@ CREATE TABLE IF NOT EXISTS conversations ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Index for efficient conversation history retrieval ordered by time. CREATE INDEX IF NOT EXISTS idx_conversations_workspace ON conversations(workspace_id, created_at); diff --git a/tests/integration/db-init.test.ts b/tests/integration/db-init.test.ts new file mode 100644 index 0000000..6a35b70 --- /dev/null +++ b/tests/integration/db-init.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DisclawDatabase } from "../../src/db/database"; + +/** + * Integration tests for DisclawDatabase schema initialization. + * + * Each test uses a fresh SQLite file in os.tmpdir() to ensure full isolation. + * The DB file is cleaned up after each test. + */ + +function makeTmpDbPath(): string { + return path.join(os.tmpdir(), `disclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe("db-init: schema initialization", () => { + let dbPath: string; + let db: DisclawDatabase; + + beforeEach(() => { + dbPath = makeTmpDbPath(); + }); + + afterEach(() => { + try { + db.close(); + } catch { + // ignore if already closed + } + try { + fs.unlinkSync(dbPath); + } catch { + // best-effort cleanup + } + }); + + // (a) All expected tables exist after initialization + it("creates all expected tables on first init", () => { + db = new DisclawDatabase(dbPath); + + // Query sqlite_master for user-created tables + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (b) Initializing the same DB path twice is idempotent (IF NOT EXISTS) + it("is idempotent when initialized twice (IF NOT EXISTS)", () => { + db = new DisclawDatabase(dbPath); + db.close(); + + // Second initialization must not throw + expect(() => { + db = new DisclawDatabase(dbPath); + }).not.toThrow(); + + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (c) Basic CRUD: create a workspace and retrieve it by channel_id + it("supports basic workspace CRUD", () => { + db = new DisclawDatabase(dbPath); + + const created = db.createWorkspace( + "channel-001", + "guild-001", + "test-agent", + "/tmp/test-agent" + ); + + expect(created.id).toBeGreaterThan(0); + expect(created.channel_id).toBe("channel-001"); + expect(created.guild_id).toBe("guild-001"); + expect(created.agent_name).toBe("test-agent"); + expect(created.workspace_path).toBe("/tmp/test-agent"); + expect(created.created_at).toBeTruthy(); + + const fetched = db.getWorkspaceByChannelId("channel-001"); + expect(fetched).toBeDefined(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.agent_name).toBe("test-agent"); + }); +});