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)
309 lines
10 KiB
JavaScript
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;
|