Compare commits

..

3 commits

Author SHA1 Message Date
dev
67433d63fa Merge pull request 'refactor(DIS-108): extract DB schema to src/db/schema.sql (SSOT)' (#50) from phase-1/db-schema-ssot into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #50
2026-04-09 16:01:48 +00:00
Nick Tabeling
aeb4bd5b59 fix(DIS-108): copy schema.sql to dist/ after tsc build
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
TypeScript does not copy non-.ts files. Add a cpSync step to the
build and dev scripts so dist/db/schema.sql exists at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:01:11 +02:00
Nick Tabeling
3dd75ddc30 refactor(DIS-108): extract DB schema to src/db/schema.sql (SSOT)
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Move all CREATE TABLE/INDEX DDL from database.ts to schema.sql.
database.ts loads and executes schema.sql via db.exec().
Add integration test verifying idempotent schema init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:50:14 +02:00
4 changed files with 107 additions and 26 deletions

View file

@ -4,9 +4,9 @@
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"",
"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:watch": "vitest",
"test:coverage": "vitest run --coverage",

View file

@ -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 ---

View file

@ -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);

View 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");
});
});