UNPKG

@athenox/talos

Version:

Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.

98 lines (97 loc) • 4.52 kB
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; /** * The store the runtime writes through, and the credential code it never does. * * Nothing here knows about any particular application: an app declares its * tables and this creates them. Passwords are scrypt with a constant-time * compare, in a plain function, because a password is not something a model * should ever be asked about. */ let db = null; /** Open (once) the database every helper below writes through. */ export function getDb(file = ':memory:') { // ponytail: in-memory, reseeded per process. Pass a path to persist. // Loaded on first use: importing Talos does not load node:sqlite (nor print its warning). db ??= new process.getBuiltinModule('node:sqlite').DatabaseSync(file); return db; } // --- credentials ------------------------------------------------------------- export const hashPassword = (pw, salt = randomBytes(16).toString('hex')) => `${salt}:${scryptSync(pw, salt, 64).toString('hex')}`; export function verifyPassword(pw, stored) { const [salt, key] = stored.split(':'); if (!salt || !key) return false; const a = Buffer.from(key, 'hex'); const b = scryptSync(pw, salt, 64); return a.length === b.length && timingSafeEqual(a, b); } /** Accounts. Every app needs them and none of them should write this twice. */ export function createUserTable() { getDb().exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (date('now'))); `); } export function addUser(name, email, role, password) { return run('INSERT OR IGNORE INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)', name, email, hashPassword(password), role); } export const findUser = (email) => one('SELECT id, name, email, role, password_hash FROM users WHERE lower(email) = ?', email.trim().toLowerCase()); export const userById = (id) => one('SELECT id, name, email, role FROM users WHERE id = ?', id); const sqlType = (t = 'text') => t === 'int' ? 'INTEGER' : t === 'real' ? 'REAL' : 'TEXT'; /** * Create a table from the same column list the model is shown, so the schema in * the prompt and the schema on disk cannot drift apart. */ export function createTable(table, columns, extra = []) { const cols = columns .filter(c => c.as !== 'id') .map(c => `${ident(c.as.replace(/\s+/g, '_'))} ${sqlType(c.type)}`); getDb().exec(`CREATE TABLE IF NOT EXISTS ${ident(table)} (id INTEGER PRIMARY KEY, ` + [...cols, ...extra].join(', ') + ')'); } /** Quote an identifier. Identifiers can never be bound, so they are quoted. */ export const ident = (name) => `"${String(name).replace(/"/g, '""')}"`; // ponytail: module-level because handlers run synchronously between begin/end. // If a handler ever awaits mid-request, move this into the H3 event context. let trace = null; export const beginTrace = () => { trace = []; }; export const endTrace = () => { const t = trace ?? []; trace = null; return t; }; const record = (sql, params, rows) => trace?.push({ sql: sql.replace(/\s+/g, ' ').trim(), params, rows }); export const all = (sql, ...args) => { const r = getDb().prepare(sql).all(...args); record(sql, args, r.length); return r; }; export const one = (sql, ...args) => { const r = getDb().prepare(sql).get(...args); record(sql, args, r ? 1 : 0); return r; }; export const run = (sql, ...args) => { const r = getDb().prepare(sql).run(...args); record(sql, args, Number(r.changes)); return r; }; /** Fuzzy-resolve free text to a row id, for a value that names a record. */ export function lookupIn(table, text) { const q = text.trim().toLowerCase(); if (!q) return null; const cols = getDb().prepare(`PRAGMA table_info(${ident(table)})`).all() .map(c => c.name); for (const col of ['name', 'title', 'slug', 'headline'].filter(c => cols.includes(c))) { const hit = one(`SELECT id FROM ${ident(table)} WHERE lower(${ident(col)}) = ? ` + `OR lower(${ident(col)}) LIKE ? ORDER BY length(${ident(col)}) LIMIT 1`, q, `%${q}%`); if (hit) return hit.id; } return null; } /** The store shape the runtime expects. */ export const store = { all: (sql, ...p) => all(sql, ...p), run: (sql, ...p) => run(sql, ...p), lookup: (table, text) => lookupIn(table, text), };