115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Single shared SQLite connection.
|
|
*
|
|
* WAL journaling is enabled (the SQLite-native equivalent of "wal-g"): it improves
|
|
* concurrency and is the prerequisite for streaming backups via Litestream. The DB
|
|
* path is configurable (DATABASE_PATH) so Litestream can be pointed at it without
|
|
* any code change.
|
|
*/
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var __drawitDb: Database.Database | undefined;
|
|
}
|
|
|
|
function resolveDbPath(): string {
|
|
const p = process.env.DATABASE_PATH || "./data/drawit.db";
|
|
return path.isAbsolute(p) ? p : path.join(process.cwd(), p);
|
|
}
|
|
|
|
function createConnection(): Database.Database {
|
|
const dbPath = resolveDbPath();
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
|
|
const db = new Database(dbPath);
|
|
// WAL mode: better concurrency + required for Litestream streaming backups.
|
|
db.pragma("journal_mode = WAL");
|
|
db.pragma("synchronous = NORMAL");
|
|
db.pragma("foreign_keys = ON");
|
|
db.pragma("busy_timeout = 5000");
|
|
|
|
migrate(db);
|
|
return db;
|
|
}
|
|
|
|
function migrate(db: Database.Database) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL UNIQUE,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
password_salt TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'learner', -- 'admin' | 'learner' | ...
|
|
level TEXT NOT NULL DEFAULT 'early-beginner',
|
|
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'active' | 'suspended'
|
|
email_verified INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL, -- 'confirm' | 'reset'
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT NOT NULL UNIQUE,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS progress (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
level TEXT NOT NULL,
|
|
sublevel INTEGER NOT NULL,
|
|
step INTEGER NOT NULL,
|
|
completed INTEGER NOT NULL DEFAULT 0,
|
|
completed_at TEXT,
|
|
UNIQUE(user_id, level, sublevel, step)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS badges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
badge_key TEXT NOT NULL,
|
|
awarded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, badge_key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reports (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
level TEXT,
|
|
sublevel TEXT,
|
|
step TEXT,
|
|
message TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'resolved'
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
|
CREATE INDEX IF NOT EXISTS idx_progress_user ON progress(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
|
|
`);
|
|
}
|
|
|
|
export function getDb(): Database.Database {
|
|
if (!global.__drawitDb) {
|
|
global.__drawitDb = createConnection();
|
|
}
|
|
return global.__drawitDb;
|
|
}
|