import fs from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import type { Action } from "./contracts/action"; import type { Entity } from "./contracts/entity"; import type { SceneRulebook } from "./contracts/rulebook"; import type { Turn } from "./contracts/turn"; import type { ValidationResult } from "./contracts/validation"; import type { WorldState } from "./contracts/world"; export interface DatabaseConfig { dbPath: string; } export interface CharacterGardenDatabase { sqlite: Database.Database; init(): void; close(): void; upsertEntities(entities: Entity[]): void; listEntities(): Entity[]; insertTurn(turn: Turn): void; listTurns(): Turn[]; insertActions(turnId: string, actions: Action[]): void; insertValidationResults(turnId: string, results: ValidationResult[]): void; insertWorldState(turnId: string | null, worldState: WorldState): void; getLatestWorldState(): WorldState | null; upsertRulebook(rulebook: SceneRulebook): void; getRulebook(id: string): SceneRulebook | null; listRulebooks(): SceneRulebook[]; deleteRulebook(id: string): void; wipe(): void; } function ensureParentDirectory(dbPath: string): void { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); } function parseJson(value: string): T { return JSON.parse(value) as T; } export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase { ensureParentDirectory(config.dbPath); const sqlite = new Database(config.dbPath); const initStatements = [ ` CREATE TABLE IF NOT EXISTS turns ( id TEXT PRIMARY KEY, raw_text TEXT NOT NULL, created_at INTEGER NOT NULL ) `, ` CREATE TABLE IF NOT EXISTS actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, turn_id TEXT NOT NULL, action_index INTEGER NOT NULL, actor_id TEXT NOT NULL, type TEXT NOT NULL, target_id TEXT, location_id TEXT, metadata_json TEXT, FOREIGN KEY(turn_id) REFERENCES turns(id) ) `, ` CREATE TABLE IF NOT EXISTS validation_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, turn_id TEXT NOT NULL, action_index INTEGER NOT NULL, success INTEGER NOT NULL, reason TEXT, message TEXT, FOREIGN KEY(turn_id) REFERENCES turns(id) ) `, ` CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, attributes_json TEXT NOT NULL ) `, ` CREATE TABLE IF NOT EXISTS world_states ( id TEXT PRIMARY KEY, turn_id TEXT, state_json TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(turn_id) REFERENCES turns(id) ) `, ` CREATE TABLE IF NOT EXISTS rulebooks ( id TEXT PRIMARY KEY, world_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, rules_json TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `, ]; for (const statement of initStatements) { sqlite.exec(statement); } const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities"); const upsertEntityStatement = sqlite.prepare(` INSERT INTO entities (id, name, type, attributes_json) VALUES (@id, @name, @type, @attributes_json) ON CONFLICT(id) DO UPDATE SET name = excluded.name, type = excluded.type, attributes_json = excluded.attributes_json `); const listEntitiesStatement = sqlite.prepare(` SELECT id, name, type, attributes_json FROM entities ORDER BY id ASC `); const insertTurnStatement = sqlite.prepare(` INSERT INTO turns (id, raw_text, created_at) VALUES (@id, @raw_text, @created_at) `); const listTurnsStatement = sqlite.prepare(` SELECT id, raw_text, created_at FROM turns ORDER BY created_at ASC `); const insertActionStatement = sqlite.prepare(` INSERT INTO actions ( turn_id, action_index, actor_id, type, target_id, location_id, metadata_json ) VALUES ( @turn_id, @action_index, @actor_id, @type, @target_id, @location_id, @metadata_json ) `); const insertValidationStatement = sqlite.prepare(` INSERT INTO validation_results ( turn_id, action_index, success, reason, message ) VALUES ( @turn_id, @action_index, @success, @reason, @message ) `); const insertWorldStateStatement = sqlite.prepare(` INSERT INTO world_states (id, turn_id, state_json, created_at) VALUES (@id, @turn_id, @state_json, @created_at) `); const latestWorldStateStatement = sqlite.prepare(` SELECT state_json FROM world_states ORDER BY created_at DESC LIMIT 1 `); const upsertRulebookStatement = sqlite.prepare(` INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at) VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, rules_json = excluded.rules_json, updated_at = excluded.updated_at `); const getRulebookStatement = sqlite.prepare(` SELECT id, world_id, name, description, rules_json, created_at, updated_at FROM rulebooks WHERE id = @id `); const listRulebooksStatement = sqlite.prepare(` SELECT id, world_id, name, description, rules_json, created_at, updated_at FROM rulebooks ORDER BY created_at ASC `); const deleteRulebookStatement = sqlite.prepare(` DELETE FROM rulebooks WHERE id = @id `); return { sqlite, init() { // Tables are initialized on construction. }, close() { sqlite.close(); }, wipe() { sqlite.exec(` DELETE FROM validation_results; DELETE FROM actions; DELETE FROM world_states; DELETE FROM turns; DELETE FROM entities; `); }, upsertEntities(entities) { const tx = sqlite.transaction((entityList: Entity[]) => { clearEntitiesStatement.run(); for (const entity of entityList) { upsertEntityStatement.run({ id: entity.id, name: entity.name, type: entity.type, attributes_json: JSON.stringify(entity.attributes), }); } }); tx(entities); }, listEntities() { const rows = listEntitiesStatement.all() as Array<{ id: string; name: string; type: string; attributes_json: string; }>; return rows.map((row) => ({ id: row.id, name: row.name, type: row.type, attributes: parseJson>(row.attributes_json), })); }, insertTurn(turn) { insertTurnStatement.run({ id: turn.id, raw_text: turn.rawText, created_at: turn.createdAt, }); }, listTurns() { const rows = listTurnsStatement.all() as Array<{ id: string; raw_text: string; created_at: number; }>; return rows.map((row) => ({ id: row.id, rawText: row.raw_text, actions: [], validation: [], createdAt: row.created_at, })); }, insertActions(turnId, actions) { const tx = sqlite.transaction((actionList: Action[]) => { actionList.forEach((action, index) => { insertActionStatement.run({ turn_id: turnId, action_index: index, actor_id: action.actorId, type: action.type, target_id: action.targetId ?? null, location_id: action.locationId ?? null, metadata_json: action.metadata ? JSON.stringify(action.metadata) : null, }); }); }); tx(actions); }, insertValidationResults(turnId, results) { const tx = sqlite.transaction((validationList: ValidationResult[]) => { for (const result of validationList) { insertValidationStatement.run({ turn_id: turnId, action_index: result.actionIndex, success: result.success ? 1 : 0, reason: result.reason ?? null, message: result.message ?? null, }); } }); tx(results); }, insertWorldState(turnId, worldState) { insertWorldStateStatement.run({ id: worldState.id, turn_id: turnId, state_json: JSON.stringify(worldState), created_at: worldState.createdAt, }); }, getLatestWorldState() { const row = latestWorldStateStatement.get() as { state_json: string } | undefined; if (!row) { return null; } return parseJson(row.state_json); }, upsertRulebook(rulebook) { upsertRulebookStatement.run({ id: rulebook.id, world_id: rulebook.worldId, name: rulebook.name, description: rulebook.description ?? null, rules_json: JSON.stringify(rulebook.rules), created_at: rulebook.createdAt, updated_at: rulebook.updatedAt, }); }, getRulebook(id) { const row = getRulebookStatement.get({ id }) as | { id: string; world_id: string; name: string; description: string | null; rules_json: string; created_at: number; updated_at: number; } | undefined; if (!row) return null; return { id: row.id, worldId: row.world_id, name: row.name, description: row.description ?? undefined, rules: parseJson(row.rules_json), createdAt: row.created_at, updatedAt: row.updated_at, }; }, listRulebooks() { const rows = listRulebooksStatement.all() as Array<{ id: string; world_id: string; name: string; description: string | null; rules_json: string; created_at: number; updated_at: number; }>; return rows.map((row) => ({ id: row.id, worldId: row.world_id, name: row.name, description: row.description ?? undefined, rules: parseJson(row.rules_json), createdAt: row.created_at, updatedAt: row.updated_at, })); }, deleteRulebook(id) { deleteRulebookStatement.run({ id }); }, }; }