import { getTableName } from 'drizzle-orm' import type { SQLiteTable } from 'drizzle-orm/sqlite-core' import * as schema from './schema' import { persist, type GameDb } from './client' // #38 — admin CRUD over the raw DB. Every game table is exposed through this registry; the SQL // names come from the drizzle tables themselves (getTableName) so the list can't drift from // lib/db/schema.ts. All queries run on the raw sql.js handle driven by PRAGMA table_info, so there // is no per-table code — adding a table to schema.ts + this map is all it takes. export const ADMIN_TABLES = { saves: schema.saves, solar_systems: schema.solarSystems, jump_routes: schema.jumpRoutes, planets: schema.planets, inventory_items: schema.inventoryItems, crew: schema.crew, ship_systems: schema.shipSystems, tech: schema.tech, missions: schema.missions, events: schema.events, tasks: schema.tasks, } as const satisfies Record const TABLE_NAMES: Set = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) export function isAdminTableName(name: string): boolean { return TABLE_NAMES.has(name) } // Thrown by the helpers below; routes map it to a JSON error response with its status. export class AdminError extends Error { constructor( public readonly status: number, message: string, ) { super(message) } } function assertTable(name: string): void { if (!TABLE_NAMES.has(name)) throw new AdminError(404, `Unknown table '${name}'`) } type SqlParam = string | number | null // Column metadata straight from SQLite. pk is the position within the primary key (0 = not a PK), // so composite keys like jump_routes(from_system, to_system) come out ordered for free. export interface AdminColumn { name: string type: string notNull: boolean hasDefault: boolean pk: number } function tableColumns(db: GameDb, name: string): AdminColumn[] { const res = db.raw.exec(`PRAGMA table_info(${name})`) // name validated by assertTable — no injection path return (res[0]?.values ?? []).map((v) => ({ name: String(v[1]), type: String(v[2]).toUpperCase(), notNull: v[3] === 1, hasDefault: v[4] !== null && v[4] !== undefined, pk: Number(v[5]) || 0, })) } function rowCount(db: GameDb, name: string): number { return Number(db.raw.exec(`SELECT COUNT(*) FROM ${name}`)[0]?.values[0][0] ?? 0) } export interface AdminTableInfo { name: string // pk mirrors PRAGMA table_info: position within the primary key (0 = not one), so the UI can // build row keys for any table, composite included. columns: { name: string; type: string; pk: number }[] count: number } export async function listAdminTables(db: GameDb): Promise { const out: AdminTableInfo[] = [] for (const table of Object.values(ADMIN_TABLES)) { const name = getTableName(table) const cols = tableColumns(db, name) out.push({ name, columns: cols.map((c) => ({ name: c.name, type: c.type, pk: c.pk })), count: rowCount(db, name) }) } return out.sort((a, b) => a.name.localeCompare(b.name)) } export interface AdminRows { rows: Record[] total: number } const MAX_ROWS = 200 export async function readAdminRows(db: GameDb, name: string, limit = 50, offset = 0): Promise { assertTable(name) if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ROWS) throw new AdminError(400, `limit must be an integer in [1, ${MAX_ROWS}]`) if (!Number.isInteger(offset) || offset < 0) throw new AdminError(400, 'offset must be a non-negative integer') const res = db.raw.exec(`SELECT * FROM ${name} LIMIT ? OFFSET ?`, [limit, offset]) return { rows: (res[0]?.values ?? []).map((v) => Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]]))), total: rowCount(db, name), } } // sql.js throws a plain Error on constraint violations (UNIQUE/FK/NOT NULL) — map those to 409. function runWrite(db: GameDb, sql: string, params: SqlParam[]): void { try { db.raw.run(sql, params) } catch (err) { throw new AdminError(409, err instanceof Error ? err.message : String(err)) } } // JSON scalar → SQLite value for the column's declared type. Booleans fold into INTEGER columns // (the schema stores flags as 0/1); null is only allowed where the column allows it. function coerce(value: unknown, col: AdminColumn): SqlParam { if (value === null) { if (col.notNull && !col.hasDefault) throw new AdminError(400, `column '${col.name}' cannot be NULL`) return null } const isInt = col.type.includes('INT') const isReal = /REAL|FLOA|DOUB|NUM/.test(col.type) if (isInt || isReal) { const n = typeof value === 'boolean' ? (value ? 1 : 0) : typeof value === 'number' ? value : NaN if (!Number.isFinite(n)) throw new AdminError(400, `column '${col.name}' must be a number`) return isInt && !isReal ? Math.trunc(n) : n } if (typeof value !== 'string') throw new AdminError(400, `column '${col.name}' must be a string or null`) return value } function checkKey(name: string, cols: AdminColumn[], key: unknown): Record { const pk = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk) if (typeof key !== 'object' || key === null || Array.isArray(key)) throw new AdminError(400, `key must be an object with the primary key columns (${pk.map((c) => c.name).join(', ')})`) const out: Record = {} for (const col of pk) { const v = (key as Record)[col.name] if (typeof v !== 'number' || !Number.isInteger(v)) throw new AdminError(400, `key column '${col.name}' must be an integer`) out[col.name] = v } return out } function pkWhere(cols: AdminColumn[], key: Record): { sql: string; params: number[] } { const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk) return { sql: pks.map((c) => `${c.name} = ?`).join(' AND '), params: pks.map((c) => key[c.name]), } } function fetchRow(db: GameDb, name: string, whereSql: string, params: SqlParam[]): Record | null { const res = db.raw.exec(`SELECT * FROM ${name} WHERE ${whereSql}`, params) const v = res[0]?.values[0] return v ? Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]])) : null } export async function insertAdminRow(db: GameDb, name: string, values: unknown): Promise> { assertTable(name) if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'body must be a JSON object of column → value') const cols = tableColumns(db, name) const byName = new Map(cols.map((c) => [c.name, c])) const entries: [string, SqlParam][] = [] for (const [k, v] of Object.entries(values as Record)) { const col = byName.get(k) if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`) entries.push([k, coerce(v, col)]) } for (const col of cols) { // Autoincrement single-PK ids are optional; everything else NOT NULL without a default is required. const pks = cols.filter((c) => c.pk > 0) if (!entries.some(([k]) => k === col.name) && col.notNull && !col.hasDefault && !(col.pk > 0 && pks.length === 1)) { throw new AdminError(400, `Missing required column '${col.name}'`) } } const names = entries.map(([k]) => k) runWrite(db, `INSERT INTO ${name} (${names.join(', ')}) VALUES (${entries.map(() => '?').join(', ')})`, entries.map(([, v]) => v)) // Read last_insert_rowid() BEFORE persist(): sql.js's export() resets the per-connection value. const lastId = Number(db.raw.exec('SELECT last_insert_rowid()')[0]?.values[0][0]) await persist(db) // Read the row back: explicit PK values win, otherwise fall back to last_insert_rowid(). const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk) if (pks.length === 1 && !entries.some(([k]) => k === pks[0].name)) { return fetchRow(db, name, `${pks[0].name} = ?`, [lastId])! } const key: Record = {} for (const c of pks) { const e = entries.find(([k]) => k === c.name) if (!e) throw new AdminError(400, `Missing primary key column '${c.name}'`) key[c.name] = Number(e[1]) } const where = pkWhere(cols, key) return fetchRow(db, name, where.sql, where.params)! } export async function updateAdminRow(db: GameDb, name: string, key: unknown, values: unknown): Promise> { assertTable(name) const cols = tableColumns(db, name) const byName = new Map(cols.map((c) => [c.name, c])) const pkKey = checkKey(name, cols, key) if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'values must be a JSON object of column → value') const entries: [string, SqlParam][] = [] for (const [k, v] of Object.entries(values as Record)) { const col = byName.get(k) if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`) entries.push([k, coerce(v, col)]) } if (entries.length === 0) throw new AdminError(400, 'values must contain at least one column') const where = pkWhere(cols, pkKey) runWrite(db, `UPDATE ${name} SET ${entries.map(([k]) => `${k} = ?`).join(', ')} WHERE ${where.sql}`, [...entries.map(([, v]) => v), ...where.params]) await persist(db) const row = fetchRow(db, name, where.sql, where.params) if (!row) throw new AdminError(404, `No row in '${name}' with that key`) return row } export async function deleteAdminRow(db: GameDb, name: string, key: unknown): Promise> { assertTable(name) const cols = tableColumns(db, name) const pkKey = checkKey(name, cols, key) const where = pkWhere(cols, pkKey) const row = fetchRow(db, name, where.sql, where.params) if (!row) throw new AdminError(404, `No row in '${name}' with that key`) runWrite(db, `DELETE FROM ${name} WHERE ${where.sql}`, where.params) await persist(db) return row }