238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
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<string, SQLiteTable>
|
|
|
|
const TABLE_NAMES: Set<string> = 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<AdminTableInfo[]> {
|
|
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<string, unknown>[]
|
|
total: number
|
|
}
|
|
|
|
const MAX_ROWS = 200
|
|
|
|
export async function readAdminRows(db: GameDb, name: string, limit = 50, offset = 0): Promise<AdminRows> {
|
|
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<string, number> {
|
|
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<string, number> = {}
|
|
for (const col of pk) {
|
|
const v = (key as Record<string, unknown>)[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<string, number>): { 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<string, unknown> | 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<Record<string, unknown>> {
|
|
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<string, unknown>)) {
|
|
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<string, number> = {}
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>)) {
|
|
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<Record<string, unknown>> {
|
|
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
|
|
}
|