@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
JavaScript
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),
};