Compare commits
3 commits
419d0990bf
...
67433d63fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 67433d63fa | |||
|
|
aeb4bd5b59 | ||
|
|
3dd75ddc30 |
4 changed files with 107 additions and 26 deletions
|
|
@ -4,9 +4,9 @@
|
||||||
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
|
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsc && node dist/index.js",
|
"dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,7 @@ import Database from "better-sqlite3";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
const SCHEMA_SQL = `
|
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8");
|
||||||
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);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -61,7 +39,7 @@ export class DisclawDatabase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private runMigrations(): void {
|
private runMigrations(): void {
|
||||||
this.db.exec(SCHEMA_SQL);
|
this.db.exec(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Workspace queries ---
|
// --- Workspace queries ---
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
channel_id TEXT NOT NULL UNIQUE,
|
channel_id TEXT NOT NULL UNIQUE,
|
||||||
|
|
@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Conversations: stores per-message history for each workspace.
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
|
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'))
|
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
|
CREATE INDEX IF NOT EXISTS idx_conversations_workspace
|
||||||
ON conversations(workspace_id, created_at);
|
ON conversations(workspace_id, created_at);
|
||||||
|
|
|
||||||
97
tests/integration/db-init.test.ts
Normal file
97
tests/integration/db-init.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue