From 692ffa97c5641064b905cb3b9db8a9445bd076e2 Mon Sep 17 00:00:00 2001 From: ahmed mohammed Date: Tue, 22 Sep 2026 07:23:20 +0300 Subject: [PATCH] feat: sqlite layer via node:sqlite Co-Authored-By: Claude Opus 4.7 --- lib/db.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- tests/db.test.ts | 39 ++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 lib/db.ts create mode 100644 tests/db.test.ts diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..d0024cf --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,96 @@ +import { DatabaseSync } from "node:sqlite"; +import { mkdirSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +const dir = process.env.DATA_DIR ?? "data"; +mkdirSync(dir, { recursive: true }); + +const g = globalThis as { __qaiDb?: DatabaseSync }; +export const db = (g.__qaiDb ??= (() => { + const d = new DatabaseSync(`${dir}/qai.db`); + d.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, pass TEXT NOT NULL, created_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT PRIMARY KEY, analyst_base_url TEXT NOT NULL DEFAULT '', analyst_key TEXT NOT NULL DEFAULT '', + analyst_model TEXT NOT NULL DEFAULT '', analyst_extra TEXT NOT NULL DEFAULT '{}', + jev_key TEXT NOT NULL DEFAULT '', jev_model TEXT NOT NULL DEFAULT 'jev-latest', + onboarded INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS analyses ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, xml TEXT NOT NULL, + digest TEXT, candidates TEXT, verdict TEXT, oul TEXT, + status TEXT NOT NULL DEFAULT 'running', error TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS ix_analyses_user ON analyses(user_id, created_at DESC);`); + return d; +})()); + +const now = () => Date.now(); +const one = (sql: string, ...p: unknown[]): T | null => (db.prepare(sql).get(...p) as T) ?? null; +// node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans. + +export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string; + analyst_extra: string; jev_key: string; jev_model: string; onboarded: number }; +export type Analysis = { id: string; title: string; xml: string; digest: string | null; candidates: string | null; + verdict: string | null; oul: string | null; status: string; error: string | null; created_at: number }; + +export function createUser(email: string, passHash: string): string { + const id = randomUUID(); + db.prepare("INSERT INTO users (id, email, pass, created_at) VALUES (?,?,?,?)").run(id, email, passHash, now()); + return id; +} +export const userByEmail = (email: string) => + one<{ id: string; pass: string }>("SELECT id, pass FROM users WHERE email = ?", email); +export const userEmail = (id: string) => one<{ email: string }>("SELECT email FROM users WHERE id = ?", id); + +export function putSession(tokenHash: string, userId: string, expiresAt: number) { + db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)") + .run(tokenHash, userId, now(), expiresAt); +} +export function sessionUserId(tokenHash: string): string | null { + return one<{ user_id: string }>("SELECT user_id FROM sessions WHERE token_hash = ? AND expires_at > ?", + tokenHash, now())?.user_id ?? null; +} +export const dropSession = (tokenHash: string) => + db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash); + +export const getSettings = (userId: string) => + one("SELECT * FROM settings WHERE user_id = ?", userId); + +export function saveSettings(userId: string, s: SettingsInput) { + db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra, + jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url, analyst_key=excluded.analyst_key, + analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra, jev_key=excluded.jev_key, + jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`) + .run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model, + s.onboarded, now()); +} + +export function newAnalysis(userId: string, title: string, xml: string): string { + const id = randomUUID(); + db.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)") + .run(id, userId, title, xml, "running", now(), now()); + return id; +} + +// patchAnalysis builds its SET list from object keys, so the keys have to be checked, not trusted. +const PATCHABLE = new Set(["digest", "candidates", "verdict", "oul", "status", "error", "title"]); +export function patchAnalysis(id: string, p: Record) { + const keys = Object.keys(p).filter((k) => PATCHABLE.has(k)); + if (!keys.length) return; + db.prepare(`UPDATE analyses SET ${keys.map((k) => `${k} = ?`).join(", ")}, updated_at = ? WHERE id = ?`) + .run(...keys.map((k) => p[k]), now(), id); +} +export const listAnalyses = (userId: string) => + db.prepare("SELECT id, title, status, created_at FROM analyses WHERE user_id = ? ORDER BY created_at DESC") + .all(userId) as { id: string; title: string; status: string; created_at: number }[]; +export const getAnalysis = (id: string, userId: string) => + one("SELECT * FROM analyses WHERE id = ? AND user_id = ?", id, userId); +export const renameAnalysis = (id: string, userId: string, title: string) => + db.prepare("UPDATE analyses SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?").run(title, now(), id, userId); +export const deleteAnalysis = (id: string, userId: string) => + db.prepare("DELETE FROM analyses WHERE id = ? AND user_id = ?").run(id, userId); diff --git a/package.json b/package.json index ea008a2..28332ab 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "node --test tests" + "test": "node --no-warnings --test tests/*.test.ts" }, "dependencies": { "@openuidev/react-lang": "^0.3.0", diff --git a/tests/db.test.ts b/tests/db.test.ts new file mode 100644 index 0000000..f6a0cd2 --- /dev/null +++ b/tests/db.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-`); +const q = await import("../lib/db.ts"); + +test("user, settings upsert, analysis lifecycle", () => { + const id = q.createUser("a@b.c", "salt:hash"); + assert.equal(q.userByEmail("a@b.c")?.id, id); + assert.equal(q.userByEmail("nope@x.y"), null); + assert.equal(q.userEmail(id)?.email, "a@b.c"); + + const s = { analyst_base_url: "http://x/v1", analyst_key: "k1", analyst_model: "m", + analyst_extra: "{}", jev_key: "j", jev_model: "jev-latest", onboarded: 1 }; + q.saveSettings(id, s); + q.saveSettings(id, { ...s, analyst_key: "k2" }); + assert.equal(q.getSettings(id)?.analyst_key, "k2", "upsert not insert"); + + const aid = q.newAnalysis(id, "Orders scan", ""); + q.patchAnalysis(aid, { status: "done", oul: "root = Report([])", digest: "{}" }); + assert.equal(q.listAnalyses(id)[0].title, "Orders scan"); + assert.equal(q.getAnalysis(aid, id)?.status, "done"); + q.renameAnalysis(aid, id, "Renamed"); + assert.equal(q.getAnalysis(aid, id)?.title, "Renamed"); + q.deleteAnalysis(aid, id); + assert.equal(q.getAnalysis(aid, id), null); +}); + +test("session resolves only while unexpired", () => { + const id = q.createUser("s@b.c", "h"); + q.putSession("tok", id, Date.now() + 1000); + assert.equal(q.sessionUserId("tok"), id); + q.putSession("old", id, Date.now() - 1); + assert.equal(q.sessionUserId("old"), null); + q.dropSession("tok"); + assert.equal(q.sessionUserId("tok"), null); +});