virtue-cli
Version:
Personal character development CLI tool for tracking virtues and philosophical alignment
461 lines (457 loc) • 19.2 kB
JavaScript
import SQLite from 'better-sqlite3';
import { readFileSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
// Get current directory in a way that works with both Node.js and Jest
const currentDir = process.cwd();
export class Database {
db;
static migrationCompleted = new Map();
dbPath;
constructor(dbPath = './virtue-cli-data.db') {
this.dbPath = dbPath;
this.db = new SQLite(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.initialize();
}
// Expose database for repositories
getDatabase() {
return this.db;
}
// Wait for migrations to complete
async waitForMigrations() {
// Since we're running migrations synchronously in constructor,
// this method just checks if migrations are completed
if (!Database.migrationCompleted.get(this.dbPath)) {
// Force migration completion if not already done
this.runMigrationsWithLock();
}
}
initialize() {
// Try multiple paths for schema.sql to handle different execution contexts
let schemaPath = join(currentDir, 'src', 'database', 'schema.sql');
try {
const schema = readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
}
catch {
// Fallback for production/built environment
try {
schemaPath = join(currentDir, 'dist', 'database', 'schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
}
catch (fallbackError) {
throw new Error(`Could not find schema.sql at ${schemaPath}: ${fallbackError}`);
}
}
// Run migrations for existing databases with safety lock
this.runMigrationsWithLock();
}
runMigrationsWithLock() {
// If migrations already completed for this database, skip
if (Database.migrationCompleted.get(this.dbPath)) {
return;
}
// Run migrations synchronously for initialization
try {
const transaction = this.db.transaction(() => {
this.runMigrations();
});
transaction();
Database.migrationCompleted.set(this.dbPath, true);
}
catch (error) {
console.error('Migration failed:', error);
throw error;
}
}
runMigrations() {
// Check if output_directory column exists
const tableInfo = this.db.prepare("PRAGMA table_info(profiles)").all();
const hasOutputDirectory = tableInfo.some(col => col.name === 'output_directory');
if (!hasOutputDirectory) {
// Add output_directory column to existing profiles table
this.db.exec(`
ALTER TABLE profiles ADD COLUMN output_directory TEXT;
ALTER TABLE profiles ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
`);
}
// Migration for virtue versioning
this.migrateVirtueVersioning();
// Migration for automatic exports
this.migrateAutoExport();
}
migrateVirtueVersioning() {
// Check if virtue versioning tables exist
const virtueVersionsExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='virtue_versions'
`).get();
if (!virtueVersionsExists) {
// Create virtue versioning tables
this.db.exec(`
-- Add version columns to virtues table
ALTER TABLE virtues ADD COLUMN current_version INTEGER DEFAULT 1;
ALTER TABLE virtues ADD COLUMN total_versions INTEGER DEFAULT 1;
-- Create virtue versions table
CREATE TABLE IF NOT EXISTS virtue_versions (
id TEXT PRIMARY KEY,
virtue_id TEXT NOT NULL,
version_number INTEGER NOT NULL,
name TEXT NOT NULL,
definition TEXT,
sub_dimensions TEXT,
behavioral_indicators TEXT,
source_type TEXT CHECK(source_type IN ('template', 'ai_discovery', 'manual', 'refinement')),
source_context TEXT,
is_active BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_reason TEXT,
FOREIGN KEY (virtue_id) REFERENCES virtues (id) ON DELETE CASCADE,
UNIQUE(virtue_id, version_number)
);
-- Create virtue version comparisons table
CREATE TABLE IF NOT EXISTS virtue_version_comparisons (
id TEXT PRIMARY KEY,
virtue_id TEXT NOT NULL,
from_version INTEGER NOT NULL,
to_version INTEGER NOT NULL,
changes_summary TEXT,
comparison_date DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (virtue_id) REFERENCES virtues (id) ON DELETE CASCADE
);
-- Add version tracking to daily entries
ALTER TABLE daily_entries ADD COLUMN virtue_versions_used TEXT;
-- Create indexes for versioning
CREATE INDEX IF NOT EXISTS idx_virtue_versions_virtue_version ON virtue_versions(virtue_id, version_number);
CREATE INDEX IF NOT EXISTS idx_virtue_versions_active ON virtue_versions(virtue_id, is_active);
CREATE INDEX IF NOT EXISTS idx_virtue_versions_created ON virtue_versions(virtue_id, created_at);
`);
// Migrate existing virtues to version 1
this.migrateExistingVirtuesToVersions();
}
}
migrateExistingVirtuesToVersions() {
const virtues = this.db.prepare('SELECT * FROM virtues').all();
const insertVersionStmt = this.db.prepare(`
INSERT INTO virtue_versions (
id, virtue_id, version_number, name, definition,
sub_dimensions, behavioral_indicators, source_type,
source_context, is_active, created_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const virtue of virtues) {
// Get sub-dimensions and indicators from virtue definition if it's JSON
let subDimensions = null;
let behavioralIndicators = null;
try {
// Try to parse definition as JSON to extract sub-dimensions
if (virtue.definition && virtue.definition.startsWith('{')) {
const parsed = JSON.parse(virtue.definition);
if (parsed.subDimensions) {
subDimensions = JSON.stringify(parsed.subDimensions);
}
if (parsed.behavioralIndicators) {
behavioralIndicators = JSON.stringify(parsed.behavioralIndicators);
}
}
}
catch {
// If not JSON, leave as null
}
// Create version 1 for each existing virtue
insertVersionStmt.run(randomUUID(), virtue.id, 1, // version_number
virtue.name, virtue.definition, subDimensions, behavioralIndicators, virtue.template_source ? 'template' : 'manual', 'Initial version from existing virtue', 1, // is_active = true
'Migration from non-versioned virtue');
}
}
migrateAutoExport() {
// Check if auto export columns exist
const tableInfo = this.db.prepare("PRAGMA table_info(profiles)").all();
const hasAutoExport = tableInfo.some(col => col.name === 'auto_export_enabled');
if (!hasAutoExport) {
this.db.exec(`
-- Add auto export configuration columns
ALTER TABLE profiles ADD COLUMN auto_export_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE profiles ADD COLUMN export_target_path TEXT;
ALTER TABLE profiles ADD COLUMN export_format TEXT DEFAULT 'summary';
ALTER TABLE profiles ADD COLUMN last_export_date TEXT;
ALTER TABLE profiles ADD COLUMN export_frequency TEXT DEFAULT 'daily';
`);
}
}
// Profile methods
async createProfile(profile) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO profiles (id, name, philosophical_template, ascii_theme, color_scheme, output_directory, auto_export_enabled, export_target_path, export_format, export_frequency)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, profile.name, profile.philosophical_template, profile.ascii_theme, profile.color_scheme, profile.output_directory, profile.auto_export_enabled ? 1 : 0, profile.export_target_path || null, profile.export_format || 'summary', profile.export_frequency || 'daily');
return this.getProfile(id);
}
getProfile(id) {
const stmt = this.db.prepare('SELECT * FROM profiles WHERE id = ?');
const profile = stmt.get(id);
if (profile) {
// Convert SQLite boolean
profile.auto_export_enabled = !!profile.auto_export_enabled;
}
return profile;
}
getAllProfiles() {
const stmt = this.db.prepare('SELECT * FROM profiles ORDER BY created_at DESC');
const profiles = stmt.all();
// Convert SQLite booleans
return profiles.map(profile => ({
...profile,
auto_export_enabled: !!profile.auto_export_enabled
}));
}
hasProfiles() {
const stmt = this.db.prepare('SELECT COUNT(*) as count FROM profiles');
const result = stmt.get();
return result.count > 0;
}
updateProfile(id, updates) {
const allowedFields = ['name', 'philosophical_template', 'ascii_theme', 'color_scheme', 'output_directory', 'auto_export_enabled', 'export_target_path', 'export_format', 'last_export_date', 'export_frequency'];
const updateFields = Object.keys(updates)
.filter(key => allowedFields.includes(key) && updates[key] !== undefined)
.map(key => `${key} = ?`);
if (updateFields.length === 0) {
return this.getProfile(id);
}
const values = Object.keys(updates)
.filter(key => allowedFields.includes(key) && updates[key] !== undefined)
.map(key => {
const value = updates[key];
// Convert boolean to integer for SQLite
if (key === 'auto_export_enabled' && typeof value === 'boolean') {
return value ? 1 : 0;
}
return value;
});
const stmt = this.db.prepare(`
UPDATE profiles
SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(...values, id);
return this.getProfile(id);
}
deleteProfile(id) {
// Delete profile and all related data (cascades due to foreign keys)
const stmt = this.db.prepare('DELETE FROM profiles WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
// Virtue methods
async createVirtue(virtue) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO virtues (id, profile_id, name, definition, priority_weight, template_source, emoji, current_version, total_versions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, virtue.profile_id, virtue.name, virtue.definition, virtue.priority_weight, virtue.template_source, virtue.emoji, 1, // current_version
1 // total_versions
);
// Create initial version in virtue_versions table
this.createInitialVirtueVersion(id, virtue);
return this.getVirtue(id);
}
createInitialVirtueVersion(virtueId, virtue) {
const versionId = randomUUID();
// Get sub-dimensions and indicators from virtue definition if it's JSON
let subDimensions = null;
let behavioralIndicators = null;
try {
// Try to parse definition as JSON to extract sub-dimensions
if (virtue.definition && virtue.definition.startsWith('{')) {
const parsed = JSON.parse(virtue.definition);
if (parsed.subDimensions) {
subDimensions = JSON.stringify(parsed.subDimensions);
}
if (parsed.behavioralIndicators) {
behavioralIndicators = JSON.stringify(parsed.behavioralIndicators);
}
}
}
catch {
// If not JSON, leave as null
}
const insertVersionStmt = this.db.prepare(`
INSERT INTO virtue_versions (
id, virtue_id, version_number, name, definition,
sub_dimensions, behavioral_indicators, source_type,
source_context, is_active, created_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertVersionStmt.run(versionId, virtueId, 1, // version_number
virtue.name, virtue.definition, subDimensions, behavioralIndicators, virtue.template_source ? 'template' : 'manual', 'Initial version created with virtue', 1, // is_active = true
'Initial virtue creation');
}
getVirtue(id) {
const stmt = this.db.prepare('SELECT * FROM virtues WHERE id = ?');
return stmt.get(id);
}
getVirtuesForProfile(profileId) {
const stmt = this.db.prepare(`
SELECT * FROM virtues
WHERE profile_id = ?
ORDER BY priority_weight DESC, created_at ASC
`);
return stmt.all(profileId);
}
// Daily entry methods
async createDailyEntry(entry) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO daily_entries (id, profile_id, date, virtue_scores, virtue_versions_used, notes, coherence_score, completion_time_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, entry.profile_id, entry.date, entry.virtue_scores, entry.virtue_versions_used, entry.notes, entry.coherence_score, entry.completion_time_seconds);
return this.getDailyEntry(id);
}
getDailyEntry(id) {
const stmt = this.db.prepare('SELECT * FROM daily_entries WHERE id = ?');
return stmt.get(id);
}
getDailyEntryByDate(profileId, date) {
const stmt = this.db.prepare('SELECT * FROM daily_entries WHERE profile_id = ? AND date = ?');
return stmt.get(profileId, date);
}
getEntriesForProfile(profileId, cutoffDateOrDays) {
let query = 'SELECT * FROM daily_entries WHERE profile_id = ?';
const params = [profileId];
if (cutoffDateOrDays instanceof Date) {
query += ' AND date >= ?';
params.push(cutoffDateOrDays.toISOString().split('T')[0]);
query += ' ORDER BY date DESC';
}
else if (typeof cutoffDateOrDays === 'number') {
query += ' ORDER BY date DESC LIMIT ?';
params.push(cutoffDateOrDays);
}
else {
query += ' ORDER BY date DESC';
}
const stmt = this.db.prepare(query);
const entries = stmt.all(...params);
// Parse virtue_scores JSON for each entry
return entries.map(entry => ({
...entry,
virtue_scores: typeof entry.virtue_scores === 'string'
? JSON.parse(entry.virtue_scores)
: entry.virtue_scores
}));
}
getLatestEntry(profileId) {
const stmt = this.db.prepare(`
SELECT * FROM daily_entries
WHERE profile_id = ?
ORDER BY date DESC
LIMIT 1
`);
return stmt.get(profileId);
}
// Session methods
async createSession(session) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO sessions (id, profile_id, session_type, duration_seconds)
VALUES (?, ?, ?, ?)
`);
stmt.run(id, session.profile_id, session.session_type, session.duration_seconds);
return this.getSession(id);
}
getSession(id) {
const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?');
return stmt.get(id);
}
// Monthly deep-dive methods
async createMonthlyDeepDive(deepDive) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO monthly_deep_dives (id, profile_id, month, analysis_data, intervention_plan)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(id, deepDive.profile_id, deepDive.month, deepDive.analysis_data, deepDive.intervention_plan);
return this.getMonthlyDeepDiveById(id);
}
getMonthlyDeepDiveById(id) {
const stmt = this.db.prepare('SELECT * FROM monthly_deep_dives WHERE id = ?');
return stmt.get(id);
}
getMonthlyDeepDiveByMonth(profileId, month) {
const stmt = this.db.prepare('SELECT * FROM monthly_deep_dives WHERE profile_id = ? AND month = ?');
return stmt.get(profileId, month);
}
// Streak calculation
calculateCurrentStreak(profileId) {
const entries = this.db.prepare(`
SELECT date FROM daily_entries
WHERE profile_id = ?
ORDER BY date DESC
`).all(profileId);
if (entries.length === 0)
return 0;
let streak = 1;
const today = new Date();
const lastDate = new Date(entries[0].date);
// Check if last entry was today or yesterday
const dayDiff = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
if (dayDiff > 1)
return 0;
// Count consecutive days
for (let i = 1; i < entries.length; i++) {
const currentDate = new Date(entries[i].date);
const previousDate = new Date(entries[i - 1].date);
const diff = Math.floor((previousDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 1) {
streak++;
}
else {
break;
}
}
return streak;
}
// Average coherence calculation
calculateAverageCoherence(profileId, days = 30) {
const stmt = this.db.prepare(`
SELECT AVG(coherence_score) as avg_coherence
FROM daily_entries
WHERE profile_id = ?
ORDER BY date DESC
LIMIT ?
`);
const result = stmt.get(profileId, days);
return result.avg_coherence || 0;
}
// Additional monthly deep dive methods
createMonthlyDeepDiveSimple(profileId, month, analysisData, interventionPlan) {
const id = randomUUID();
const stmt = this.db.prepare(`
INSERT INTO monthly_deep_dives (id, profile_id, month, analysis_data, intervention_plan, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
`);
stmt.run(id, profileId, month, analysisData, interventionPlan);
return id;
}
getAllMonthlyDeepDives(profileId) {
const stmt = this.db.prepare(`
SELECT * FROM monthly_deep_dives
WHERE profile_id = ?
ORDER BY month DESC
`);
return stmt.all(profileId);
}
close() {
this.db.close();
// Clear migration tracking for this database path when closing
Database.migrationCompleted.delete(this.dbPath);
}
}
//# sourceMappingURL=Database.js.map