Files
studyos/server/db.js
renato97 4ff4302a8c feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived:
- Batch 1: UI Polish (10 features, 14 tasks)
- Batch 2: Study System (8 features, 23 tasks)
- Batch 3: Infrastructure (5 features, 22 tasks)
- Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers
- Batch 5: Core Features (5 features, 19 tasks)

37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW):
- SSE streaming now works (event.token check)
- API keys no longer exposed via GET /api/models
- FTS5 injection sanitized
- DB backup/restore with admin auth
- Buddy mode wired (buddy_meta column)
- Exam auto-submit stale closure fixed
- CSS variables aligned with design tokens
- Progress data corruption fixed
- WebSocket protocol auto-detection
- Tests infrastructure completed (vitest + node:test)
2026-06-08 18:18:47 -03:00

309 lines
10 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const DATA_DIR = path.resolve(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'studyos.db');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
let _sqlDb = null;
function saveToDisk() {
if (!_sqlDb) return;
const data = _sqlDb.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
function flatParams(params) {
if (params.length === 1 && Array.isArray(params[0])) return params[0];
return params;
}
function safeAddColumn(table, col, def) {
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); }
catch (e) { /* column exists */ }
}
function createWrapper(sqlDb) {
return {
prepare(sql) {
return {
run(...params) {
const p = flatParams(params);
sqlDb.run(sql, p);
const rowidResult = sqlDb.exec('SELECT last_insert_rowid()');
const lastInsertRowid = rowidResult.length > 0 ? rowidResult[0].values[0][0] : 0;
const changes = sqlDb.getRowsModified();
saveToDisk();
return { changes, lastInsertRowid };
},
get(...params) {
const p = flatParams(params);
let stmt = null;
try {
stmt = sqlDb.prepare(sql);
stmt.bind(p);
let result = null;
if (stmt.step()) result = stmt.getAsObject();
return result;
} finally {
if (stmt) stmt.free();
}
},
all(...params) {
const p = flatParams(params);
let stmt = null;
try {
stmt = sqlDb.prepare(sql);
stmt.bind(p);
const results = [];
while (stmt.step()) results.push(stmt.getAsObject());
return results;
} finally {
if (stmt) stmt.free();
}
},
};
},
exec(sql) {
sqlDb.exec(sql);
saveToDisk();
},
pragma(sql) {
sqlDb.exec('PRAGMA ' + sql + ';');
},
};
}
// Placeholder wrapper — methods are replaced by initDB() after sql.js loads.
// This allows synchronous require('db') in route modules before async init.
const db = createWrapper(null);
async function initDB() {
const SQL = await import('sql.js');
const initSqlJs = SQL.default;
const sqlModule = await initSqlJs();
let sqlDb;
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
sqlDb = new sqlModule.Database(buffer);
} else {
sqlDb = new sqlModule.Database();
}
_sqlDb = sqlDb;
const real = createWrapper(sqlDb);
db.prepare = real.prepare;
db.exec = real.exec;
db.pragma = real.pragma;
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
api_base TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL CHECK(provider IN ('openai', 'anthropic')),
is_default_main INTEGER NOT NULL DEFAULT 0,
is_default_fork INTEGER NOT NULL DEFAULT 0,
is_default_exam INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('main', 'fork')),
parent_id INTEGER REFERENCES conversations(id) ON DELETE SET NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'context_merge')),
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pdfs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '',
pages INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reorder_index INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL UNIQUE,
exercises_done INTEGER NOT NULL DEFAULT 0,
exercises_correct INTEGER NOT NULL DEFAULT 0,
last_session TEXT,
notes TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS exams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
score REAL NOT NULL DEFAULT 0,
topics TEXT NOT NULL DEFAULT '[]',
taken_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flashcards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
answer TEXT NOT NULL,
pdf_id INTEGER REFERENCES pdfs(id) ON DELETE SET NULL,
message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
seen INTEGER NOT NULL DEFAULT 0,
topic TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS study_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_date TEXT NOT NULL,
minutes INTEGER NOT NULL DEFAULT 0,
topic TEXT,
UNIQUE(session_date, topic)
);
CREATE TABLE IF NOT EXISTS topic_relationships (
from_topic TEXT NOT NULL,
to_topic TEXT NOT NULL,
domain TEXT,
PRIMARY KEY(from_topic, to_topic)
);
CREATE TABLE IF NOT EXISTS flashcard_reviews (
flashcard_id INTEGER PRIMARY KEY REFERENCES flashcards(id) ON DELETE CASCADE,
ease_factor REAL NOT NULL DEFAULT 2.5,
interval_days INTEGER NOT NULL DEFAULT 1,
repetitions INTEGER NOT NULL DEFAULT 0,
next_review TEXT NOT NULL DEFAULT (date('now')),
last_review TEXT
);
`);
// Try to create FTS5 virtual tables and sync triggers
let fts5Available = false;
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, content='messages', content_rowid='id');
CREATE VIRTUAL TABLE IF NOT EXISTS pdfs_fts USING fts5(content_markdown, content='pdfs', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
UPDATE messages_fts SET content = new.content WHERE rowid = new.id;
END;
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS pdfs_ai AFTER INSERT ON pdfs BEGIN
INSERT INTO pdfs_fts(rowid, content_markdown) VALUES (new.id, new.content_markdown);
END;
CREATE TRIGGER IF NOT EXISTS pdfs_au AFTER UPDATE ON pdfs BEGIN
UPDATE pdfs_fts SET content_markdown = new.content_markdown WHERE rowid = new.id;
END;
CREATE TRIGGER IF NOT EXISTS pdfs_ad AFTER DELETE ON pdfs BEGIN
DELETE FROM pdfs_fts WHERE rowid = old.id;
END;
`);
fts5Available = true;
} catch (err) {
console.warn('[db] FTS5 setup failed:', err.message, '— search will use LIKE fallback');
}
db._fts5Available = fts5Available;
const modelCount = db.prepare('SELECT COUNT(*) as count FROM models').get();
if (!modelCount || modelCount.count === 0) {
db.prepare(`
INSERT INTO models (name, api_base, api_key, provider, is_default_main)
VALUES (?, ?, ?, ?, ?)
`).run('claude-sonnet-4', 'https://api.anthropic.com', '', 'anthropic', 1);
}
const vlmConfig = db.prepare("SELECT value FROM config WHERE key = ?").get('vlm_endpoint');
if (!vlmConfig) {
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('vlm_endpoint', 'http://localhost:8080/vlm');
}
// Additive schema changes for ai-advanced-batch4
db.exec(`
CREATE TABLE IF NOT EXISTS embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pdf_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
vector BLOB NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pdf_id) REFERENCES pdfs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_embeddings_pdf ON embeddings(pdf_id);
CREATE TABLE IF NOT EXISTS shared_conversations (
token TEXT PRIMARY KEY,
conv_id INTEGER NOT NULL,
role_label TEXT NOT NULL DEFAULT 'compañero',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conv_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS conversation_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
user_name TEXT NOT NULL,
joined_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
`);
safeAddColumn('progress', 'wrong_streak', 'INTEGER DEFAULT 0');
safeAddColumn('progress', 'global_wrong_streak', 'INTEGER DEFAULT 0');
safeAddColumn('progress', 'difficulty_level', 'TEXT DEFAULT \'normal\'');
safeAddColumn('conversations', 'buddy_meta', 'TEXT');
safeAddColumn('exams', 'questions', 'TEXT');
safeAddColumn('exams', 'answers', 'TEXT');
safeAddColumn('exams', 'duration_seconds', 'INTEGER');
safeAddColumn('exams', 'started_at', 'TEXT');
safeAddColumn('exams', 'status', 'TEXT DEFAULT \'pending\'');
safeAddColumn('exams', 'conversation_id', 'INTEGER');
return db;
}
module.exports = db;
module.exports.initDB = initDB;