SysDeck/web/mini-services/fester/store.ts

349 lines
12 KiB
TypeScript

/**
* Fester store — SQLite persistence (bun:sqlite).
* Port of backend/storage/sqlite_db.py: builds / sessions / events /
* node_states tables, WAL mode, serialized writes.
*/
import { Database } from 'bun:sqlite'
import { join } from 'path'
import type { FesterEvent } from './events'
const DB_PATH = process.env.FESTER_DB_PATH ?? join(import.meta.dir, 'fester.db')
export class Store {
private db: Database
private writeLock: Promise<unknown> = Promise.resolve()
constructor() {
this.db = new Database(DB_PATH, { create: true })
this.db.exec('PRAGMA journal_mode = WAL;')
this.db.exec(`
CREATE TABLE IF NOT EXISTS builds (
build_id TEXT PRIMARY KEY,
project TEXT NOT NULL,
targets TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued',
started_at REAL NOT NULL,
finished_at REAL,
actions_total INTEGER DEFAULT 0,
actions_done INTEGER DEFAULT 0,
actions_failed INTEGER DEFAULT 0,
cache_hits INTEGER DEFAULT 0,
critical_path_ms INTEGER,
rc INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
build_id TEXT,
label TEXT,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
build_id TEXT,
type TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS node_states (
name TEXT PRIMARY KEY,
state TEXT NOT NULL,
cpu_load REAL,
memory_load REAL,
temp REAL,
active_jobs INTEGER,
instability REAL,
score REAL,
updated_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_build ON events(build_id, id);
`)
// additive migrations AFTER the base schema exists (fresh DB safe)
this.migrate()
}
/** Additive schema migrations (safe to re-run on every boot / hot reload). */
private migrate() {
const cols = (this.db.query("PRAGMA table_info('builds')").all() as { name: string }[]).map((c) => c.name)
if (!cols.includes('note')) this.db.exec('ALTER TABLE builds ADD COLUMN note TEXT')
if (!cols.includes('meta')) this.db.exec('ALTER TABLE builds ADD COLUMN meta TEXT')
}
/** Boot fix: a build left 'running' by a previous process never finished —
* mark it failed with a "service restarted" note so the UI never shows a
* phantom in-flight build. Returns the builds that were fixed. */
markStaleRunningFailed(): { build_id: string }[] {
const stale = this.db
.query("SELECT build_id FROM builds WHERE state IN ('running','queued')")
.all() as { build_id: string }[]
if (stale.length === 0) return []
const fix = this.db.query(
"UPDATE builds SET state = 'failed', note = 'service restarted', rc = 1 WHERE state IN ('running','queued')",
)
fix.run()
return stale
}
/** Serialize writes through a single promise chain (threading.Lock equivalent). */
private write<T>(fn: () => T): Promise<T> {
const run = this.writeLock.then(fn)
this.writeLock = run.catch(() => undefined)
return run
}
createBuild(
buildId: string,
project: string,
targets: string[],
opts: { startedAt?: number; state?: string; meta?: Record<string, unknown>; note?: string } = {},
) {
return this.write(() => {
this.db
.query(
'INSERT OR REPLACE INTO builds (build_id, project, targets, state, started_at, note, meta) VALUES (?,?,?,?,?,?,?)',
)
.run(
buildId,
project,
JSON.stringify(targets),
opts.state ?? 'queued',
opts.startedAt ?? Date.now() / 1000,
opts.note ?? null,
opts.meta ? JSON.stringify(opts.meta) : null,
)
})
}
updateBuild(buildId: string, fields: Partial<Record<string, string | number | null>>) {
return this.write(() => {
const keys = Object.keys(fields)
if (keys.length === 0) return
const sets = keys.map((k) => `${k} = ?`).join(', ')
this.db
.query(`UPDATE builds SET ${sets} WHERE build_id = ?`)
.run(...keys.map((k) => fields[k] as string | number | null), buildId)
})
}
getBuild(buildId: string) {
const row = this.db.query('SELECT * FROM builds WHERE build_id = ?').get(buildId) as Record<string, unknown> | null
return row ? withParsedMeta(row) : null
}
listBuilds(limit = 50) {
return this.db
.query('SELECT * FROM builds ORDER BY started_at DESC LIMIT ?')
.all(limit) as Record<string, unknown>[]
}
appendEvent(event: FesterEvent) {
return this.write(() => {
const { type, timestamp, ...payload } = event
this.db
.query('INSERT INTO events (ts, build_id, type, payload) VALUES (?,?,?,?)')
.run(timestamp, event.build_id ?? null, type, JSON.stringify(payload))
})
}
listEvents(buildId: string | null, limit = 500) {
if (buildId) {
return (
this.db
.query('SELECT * FROM events WHERE build_id = ? ORDER BY id LIMIT ?')
.all(buildId, limit) as Record<string, unknown>[]
).map(parseEventRow)
}
return (
this.db.query('SELECT * FROM events ORDER BY id DESC LIMIT ?').all(limit) as Record<string, unknown>[]
)
.reverse()
.map(parseEventRow)
}
/** Cursor-based replay: only events with id > after (per build, or global). */
listEventsAfter(buildId: string | null, after: number, limit = 5000) {
if (buildId) {
return (
this.db
.query('SELECT * FROM events WHERE build_id = ? AND id > ? ORDER BY id LIMIT ?')
.all(buildId, after, limit) as Record<string, unknown>[]
).map(parseEventRow)
}
return (
this.db.query('SELECT * FROM events WHERE id > ? ORDER BY id LIMIT ?').all(after, limit) as Record<string, unknown>[]
).map(parseEventRow)
}
countEvents(buildId: string): number {
return (this.db.query('SELECT COUNT(*) AS c FROM events WHERE build_id = ?').get(buildId) as { c: number }).c
}
/** Journal cap: drop the k oldest events of a build. */
trimEvents(buildId: string, k: number) {
return this.write(() => {
this.db
.query('DELETE FROM events WHERE id IN (SELECT id FROM events WHERE build_id = ? ORDER BY id ASC LIMIT ?)')
.run(buildId, k)
})
}
buildCount(): number {
return (this.db.query('SELECT COUNT(*) AS c FROM builds').get() as { c: number }).c
}
// ── sessions (replay sessions, port of backend/session semantics) ─────
/** Synchronous insert (bun:sqlite ops are sync) — POST /api/sessions reads
* the row back immediately after this returns, so it must not be queued
* behind the async write chain. */
createSession(buildId: string, label?: string, opts: { createdAt?: number } = {}): string {
const sessionId = `sess-${Math.floor(Date.now() / 1000).toString(36)}-${Math.random().toString(36).slice(2, 8)}`
try {
this.db
.query('INSERT INTO sessions (session_id, build_id, label, created_at) VALUES (?,?,?,?)')
.run(sessionId, buildId, label ?? null, opts.createdAt ?? Date.now() / 1000)
} catch (err) {
console.error('[fester:store] session insert failed', err)
}
return sessionId
}
listSessions() {
return (
this.db
.query(
`SELECT s.session_id, s.build_id, s.label, s.created_at,
b.project, b.state AS build_state, b.started_at, b.finished_at,
b.actions_total, b.actions_done, b.actions_failed, b.cache_hits,
b.critical_path_ms, b.rc, b.note, b.meta
FROM sessions s LEFT JOIN builds b ON s.build_id = b.build_id
ORDER BY s.created_at DESC`,
)
.all() as Record<string, unknown>[]
).map(withParsedMeta)
}
getSession(sessionId: string) {
const row = this.db
.query(
`SELECT s.session_id, s.build_id, s.label, s.created_at,
b.project, b.state AS build_state, b.started_at, b.finished_at,
b.actions_total, b.actions_done, b.actions_failed, b.cache_hits,
b.critical_path_ms, b.rc, b.note, b.meta
FROM sessions s LEFT JOIN builds b ON s.build_id = b.build_id
WHERE s.session_id = ?`,
)
.get(sessionId) as Record<string, unknown> | null
return row ? withParsedMeta(row) : null
}
// ── metrics aggregation (computed from history) ──────────────────
buildStateCounts(): Record<string, number> {
const rows = this.db.query('SELECT state, COUNT(*) AS c FROM builds GROUP BY state').all() as {
state: string
c: number
}[]
return Object.fromEntries(rows.map((r) => [r.state, r.c]))
}
buildActionSums(): { total: number; done: number; failed: number; cache_hits: number } {
return this.db
.query(
`SELECT COALESCE(SUM(actions_total),0) AS total, COALESCE(SUM(actions_done),0) AS done,
COALESCE(SUM(actions_failed),0) AS failed, COALESCE(SUM(cache_hits),0) AS cache_hits
FROM builds`,
)
.get() as { total: number; done: number; failed: number; cache_hits: number }
}
/** Per-node share of scheduled actions over the whole event history. */
nodeUtilization(): Map<string, number> {
const counts = new Map<string, number>()
let total = 0
const rows = this.db.query("SELECT payload FROM events WHERE type = 'task_update'").all() as {
payload: string
}[]
for (const row of rows) {
try {
const p = JSON.parse(row.payload) as { state?: string; node?: string }
if (p.state === 'scheduled' && p.node) {
counts.set(p.node, (counts.get(p.node) ?? 0) + 1)
total += 1
}
} catch {
/* skip malformed row */
}
}
const pct = new Map<string, number>()
for (const [node, c] of counts) pct.set(node, Math.round((c / Math.max(1, total)) * 1000) / 10)
return pct
}
upsertNodeState(s: {
name: string
state: string
cpu_load?: number
memory_load?: number
temp?: number
active_jobs?: number
instability?: number
score?: number
}) {
return this.write(() => {
this.db
.query(
`INSERT INTO node_states (name, state, cpu_load, memory_load, temp, active_jobs, instability, score, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(name) DO UPDATE SET
state=excluded.state, cpu_load=excluded.cpu_load, memory_load=excluded.memory_load,
temp=excluded.temp, active_jobs=excluded.active_jobs, instability=excluded.instability,
score=excluded.score, updated_at=excluded.updated_at`,
)
.run(
s.name as string,
s.state as string,
(s.cpu_load as number) ?? 0,
(s.memory_load as number) ?? 0,
(s.temp as number) ?? 0,
(s.active_jobs as number) ?? 0,
(s.instability as number) ?? 0,
(s.score as number) ?? 0,
Date.now() / 1000,
)
})
}
listNodeStates() {
return this.db.query('SELECT * FROM node_states ORDER BY name').all() as Record<string, unknown>[]
}
close() {
this.db.close()
}
}
function parseEventRow(row: Record<string, unknown>): Record<string, unknown> {
const payload = JSON.parse(String(row.payload ?? '{}'))
return { id: row.id, ts: row.ts, build_id: row.build_id, type: row.type, ...payload }
}
function withParsedMeta(row: Record<string, unknown>): Record<string, unknown> {
const out = { ...row }
if (typeof out.meta === 'string') {
try {
out.meta = JSON.parse(out.meta)
} catch {
/* keep raw string */
}
}
if (typeof out.targets === 'string') {
try {
out.targets = JSON.parse(out.targets)
} catch {
/* keep raw string */
}
}
return out
}