From 0d2370850fb1cb2176e0b69108a6a60c8a70e2c0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 21:48:03 +0000 Subject: [PATCH] feat(admin): DB CRUD panel + /api/admin endpoints (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADMIN button in the mission-control header opens a table picker + row grid with add/edit/delete (components/admin-panel.tsx), deliberately unauthenticated by explicit request - /api/admin/tables and /api/admin/tables/{name}/rows driven off a name→table registry built from lib/db/schema.ts (lib/db/admin.ts) — no per-table code; 404 unknown table, 400 malformed payload, 409 constraint violation - OpenAPI spec updated for all four admin operations + Admin* schemas - unit: lib/db/admin.test.ts (registry, CRUD round-trips, composite PKs, error mapping); e2e: read-only admin.spec.ts --- CHANGELOG.md | 4 + app/api/admin/tables/[name]/rows/route.ts | 55 ++++ app/api/admin/tables/route.ts | 8 + components/admin-panel.tsx | 322 ++++++++++++++++++++++ components/mission-control.tsx | 9 + e2e/admin.spec.ts | 41 +++ lib/db/admin.test.ts | 128 +++++++++ lib/db/admin.ts | 236 ++++++++++++++++ lib/openapi.test.ts | 2 + lib/openapi.ts | 292 ++++++++++++++++++++ package.json | 2 +- 11 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/tables/[name]/rows/route.ts create mode 100644 app/api/admin/tables/route.ts create mode 100644 components/admin-panel.tsx create mode 100644 e2e/admin.spec.ts create mode 100644 lib/db/admin.test.ts create mode 100644 lib/db/admin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 208a272..d45b445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.9.12 — 2026-09-29 21:40 +- ADMIN button in the mission-control header (top right, next to the SHIP NOMINAL / SECTOR SCAN badges) opens a DB CRUD panel (#38): pick any of the ten game tables from `lib/db/schema.ts`, browse its rows in an HTML table, and add/edit/delete rows via a per-column form. Driven by new `/api/admin/` endpoints — `GET /api/admin/tables` (columns + row counts), `GET|POST|PATCH|DELETE /api/admin/tables/{name}/rows` — built off a name→table registry so there is no per-table code; unknown table names get 404, malformed payloads 400, constraint violations 409. Deliberately unauthenticated by explicit request (marked with a `ponytail:` comment). +- e2e: new read-only spec — click ADMIN, assert the panel lists every table the API reports, open `saves` and assert its rows render matching `/api/admin/tables/saves/rows` (no mutations in specs; CRUD is covered by unit tests in lib/db/admin.test.ts). + ## v0.9.11 — 2026-09-29 14:49 - NAVIGATION AI now enables multi-system jumps (#35): once the existing root tech is researched, `/api/jump` accepts targets up to two hops away (BFS over jump routes) and a multi-hop jump costs one turn per hop — single-hop jumps keep their existing cost. On the WORLD MAP chart, two-hop systems become clickable JUMP fields with a distinct `// JUMP ×2` sublabel; without the tech the range stays adjacent-only as before. - e2e: new spec researches NAVIGATION AI (scan loop + SKILL TREE UI) and jumps to a two-hop system in one action, asserting the world state and the +2 turn cost; the world-map button-count spec now derives its expected count from the save's tech state so it holds across retries on the shared CI DB. diff --git a/app/api/admin/tables/[name]/rows/route.ts b/app/api/admin/tables/[name]/rows/route.ts new file mode 100644 index 0000000..97ddcf7 --- /dev/null +++ b/app/api/admin/tables/[name]/rows/route.ts @@ -0,0 +1,55 @@ +import { getGameDb } from '@/lib/db/client' +import { AdminError, deleteAdminRow, insertAdminRow, readAdminRows, updateAdminRow } from '@/lib/db/admin' + +// #38 — rows of one admin table: GET (capped list), POST (insert), PATCH ({key, values}), DELETE ({key}). +async function jsonBody(req: Request): Promise> { + let body: unknown + try { + body = await req.json() + } catch { + throw new AdminError(400, 'Invalid JSON body') + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new AdminError(400, 'Body must be a JSON object') + return body as Record +} + +async function run(fn: () => Promise): Promise { + try { + return Response.json(await fn()) + } catch (err) { + if (err instanceof AdminError) return Response.json({ error: err.message }, { status: err.status }) + throw err + } +} + +export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) { + const db = await getGameDb() + const { name } = await ctx.params + const url = new URL(req.url) + const limitRaw = url.searchParams.get('limit') + const offsetRaw = url.searchParams.get('offset') + const limit = limitRaw === null ? 50 : Number(limitRaw) + const offset = offsetRaw === null ? 0 : Number(offsetRaw) + return run(() => readAdminRows(db, name, limit, offset)) +} + +export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) { + const db = await getGameDb() + const { name } = await ctx.params + return run(async () => insertAdminRow(db, name, await jsonBody(req))) +} + +export async function PATCH(req: Request, ctx: { params: Promise<{ name: string }> }) { + const db = await getGameDb() + const { name } = await ctx.params + return run(async () => { + const body = await jsonBody(req) + return updateAdminRow(db, name, body.key, body.values) + }) +} + +export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) { + const db = await getGameDb() + const { name } = await ctx.params + return run(async () => deleteAdminRow(db, name, (await jsonBody(req)).key)) +} diff --git a/app/api/admin/tables/route.ts b/app/api/admin/tables/route.ts new file mode 100644 index 0000000..58298b1 --- /dev/null +++ b/app/api/admin/tables/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { listAdminTables } from '@/lib/db/admin' + +// #38 — admin DB CRUD, no auth by explicit request (ponytail: add auth before exposing beyond the dev host). +export async function GET() { + const db = await getGameDb() + return Response.json(await listAdminTables(db)) +} diff --git a/components/admin-panel.tsx b/components/admin-panel.tsx new file mode 100644 index 0000000..b1575ae --- /dev/null +++ b/components/admin-panel.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' + +// #38 — direct DB CRUD for the admin. ponytail: deliberately unauthenticated (explicit request); +// add auth before this is reachable beyond the dev host. +interface AdminColumnInfo { name: string; type: string; pk: number } +interface AdminTableInfo { name: string; columns: AdminColumnInfo[]; count: number } +interface AdminRows { rows: Record[]; total: number } + +// Form state is all strings (one input per column); parsed on save against the column type. +function parseValue(raw: string, col: AdminColumnInfo): number | string | null { + const v = raw.trim() + if (v === '' || v.toLowerCase() === 'null') return null + if (/INT|REAL|FLOA|DOUB/.test(col.type)) { + const n = Number(v) + if (!Number.isFinite(n)) throw new Error(`'${col.name}' must be a number, got '${v}'`) + return col.type.includes('INT') ? Math.trunc(n) : n + } + return v +} + +function cellText(value: unknown): string { + return value === null || value === undefined ? '—' : String(value) +} + +export function AdminPanel({ onClose }: { onClose: () => void }) { + const [tables, setTables] = useState(null) + const [selected, setSelected] = useState(null) + const [rows, setRows] = useState(null) + // null = form closed; { key: null } = add row; { key: {...} } = edit that row. + const [editing, setEditing] = useState<{ key: Record | null } | null>(null) + const [form, setForm] = useState>({}) + const [error, setError] = useState(null) + + const table = tables?.find((t) => t.name === selected) ?? null + + const refreshTables = useCallback(async () => { + try { + const res = await fetch('/api/admin/tables') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setTables((await res.json()) as AdminTableInfo[]) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, []) + + const refreshRows = useCallback(async (name: string) => { + try { + const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setRows((await res.json()) as AdminRows) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, []) + + useEffect(() => { + void refreshTables() + }, [refreshTables]) + + useEffect(() => { + if (!selected) return + setRows(null) + void refreshRows(selected) + }, [selected, refreshRows]) + + const pkColumns = (t: AdminTableInfo): AdminColumnInfo[] => t.columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk) + + const rowKey = (t: AdminTableInfo, row: Record): Record => + Object.fromEntries(pkColumns(t).map((c) => [c.name, Number(row[c.name])])) + + const openAdd = () => { + if (!table) return + setEditing({ key: null }) + setForm(Object.fromEntries(table.columns.map((c) => [c.name, '']))) + setError(null) + } + + const openEdit = (row: Record) => { + if (!table) return + setEditing({ key: rowKey(table, row) }) + setForm(Object.fromEntries(table.columns.map((c) => [c.name, row[c.name] == null ? '' : String(row[c.name])]))) + setError(null) + } + + const save = async () => { + if (!table || !editing) return + try { + const payload: Record = {} + for (const col of table.columns) payload[col.name] = parseValue(form[col.name] ?? '', col) + const url = `/api/admin/tables/${encodeURIComponent(table.name)}/rows` + const res = await fetch(url, { + method: editing.key === null ? 'POST' : 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editing.key === null ? payload : { key: editing.key, values: payload }), + }) + const body = (await res.json()) as { error?: string } & Record + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) + setEditing(null) + setError(null) + await Promise.all([refreshRows(table.name), refreshTables()]) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const remove = async (row: Record) => { + if (!table) return + try { + const res = await fetch(`/api/admin/tables/${encodeURIComponent(table.name)}/rows`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: rowKey(table, row) }), + }) + const body = (await res.json()) as { error?: string } + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) + await Promise.all([refreshRows(table.name), refreshTables()]) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const inputStyle: React.CSSProperties = { + width: '100%', + background: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-secondary)', + fontFamily: 'inherit', + fontSize: '0.68rem', + padding: '0.3rem 0.4rem', + outline: 'none', + } + + return ( +
+ + + ADMIN // DB CRUD + NO AUTH +
+ +
+
+ +
+ {/* ── TABLE LIST ── */} +
+ {!tables ? ( + LOADING… + ) : ( + tables.map((t) => ( + + )) + )} +
+ + {/* ── ROWS / EDITOR ── */} +
+ {!table ? ( + + SELECT A TABLE + + ) : ( + <> +
+ {table.name} + {rows && ( + + {rows.rows.length} / {rows.total} + + )} +
+ {!editing && } + {error && ( + {error} + )} +
+
+ + {editing && ( +
+
+ {table.columns.map((c) => ( + + ))} +
+
+ + +
+
+ )} + +
+ {!rows ? ( + + LOADING… + + ) : rows.rows.length === 0 ? ( + + EMPTY TABLE + + ) : ( + + + + {table.columns.map((c) => ( + + ))} + + + + {rows.rows.map((row) => ( + + {table.columns.map((c) => ( + + ))} + + + ))} + +
+ {c.name} + {c.pk > 0 ? ` (PK${c.pk})` : ''} + +
+ {cellText(row[c.name])} + + + + + +
+ )} +
+ + )} +
+
+
+
+ ) +} diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 0608179..9d57581 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -1,7 +1,9 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import { AdminPanel } from '@/components/admin-panel' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Progress } from '@/components/ui/progress' import { StatCard } from '@/components/ui/stat-card' @@ -57,6 +59,8 @@ export function MissionControl() { // #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until // then so SSR and the first client render agree on the default tab (no hydration mismatch). const [activeTab, setActiveTab] = useState(null) + // #38 — admin DB CRUD overlay, opened from the header. + const [adminOpen, setAdminOpen] = useState(false) useEffect(() => { let cancelled = false @@ -210,6 +214,9 @@ export function MissionControl() { SECTOR SCAN + {/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */} + + + {adminOpen && setAdminOpen(false)} />} + {/* ── BODY ── */}
[]; total: number } + +test('ADMIN button opens the panel and lists every table (#38)', async ({ page }) => { + await page.goto('/'); + // The ADMIN button sits in the header next to the SECTOR SCAN badge. + await page.getByRole('button', { name: 'ADMIN' }).click(); + + await expect(page.getByText('ADMIN // DB CRUD')).toBeVisible(); + // Cross-check against the API: every table it lists must render in the panel (and vice versa). + const tables = (await (await page.request.get('/api/admin/tables')).json()) as AdminTableDto[]; + expect(tables.length).toBeGreaterThanOrEqual(10); + for (const t of tables) { + await expect(page.getByRole('button', { name: new RegExp(`^${t.name}\\b`) })).toBeVisible(); + } +}); + +test('opening a table renders its rows from the API (#38)', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'ADMIN' }).click(); + + // saves always has exactly one row (single-player by design) — deterministic across retries. + const api = (await (await page.request.get('/api/admin/tables/saves/rows')).json()) as AdminRowsDto; + expect(api.total).toBeGreaterThan(0); + + await page.getByRole('button', { name: /^saves\b/ }).click(); + // Column headers come from the API's column list. + for (const col of api.rows[0] ? Object.keys(api.rows[0]) : []) { + await expect(page.locator('table th', { hasText: col })).toBeVisible(); + } + // Row count matches what the API reports, and a cell shows real data from it. + const body = page.locator('table tbody tr'); + await expect(body).toHaveCount(api.rows.length); + await expect(page.locator('table tbody').getByText(String(api.rows[0].seed))).toBeVisible(); +}); diff --git a/lib/db/admin.test.ts b/lib/db/admin.test.ts new file mode 100644 index 0000000..1798a8c --- /dev/null +++ b/lib/db/admin.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { getTableName } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' +import { openGameDb, type GameDb } from './client' +import * as schema from './schema' +import { AdminError, deleteAdminRow, insertAdminRow, isAdminTableName, listAdminTables, readAdminRows, updateAdminRow } from './admin' + +const dirs: string[] = [] + +async function freshFile(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-admin-test-')) + dirs.push(dir) + return openGameDb(path.join(dir, 'test.db')) +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +function expectAdminError(promise: Promise, status: number): Promise { + return promise.then( + () => { + throw new Error(`expected AdminError ${status}, resolved instead`) + }, + (err) => { + expect(err).toBeInstanceOf(AdminError) + expect((err as AdminError).status).toBe(status) + }, + ) +} + +describe('admin table registry', () => { + it('exposes every game table from schema.ts, keyed by SQL name', async () => { + const db = await freshFile() + const tables = await listAdminTables(db) + expect(tables.map((t) => t.name).sort()).toEqual( + Object.values(schema) + .map((s) => getTableName(s as Parameters[0])) + .sort(), + ) + expect(tables.every((t) => isAdminTableName(t.name))).toBe(true) + for (const bad of ['_meta', 'nope', 'Saves']) expect(isAdminTableName(bad)).toBe(false) + + const saves = tables.find((t) => t.name === 'saves')! + expect(saves.columns.map((c) => c.name)).toEqual(['id', 'seed', 'turn', 'created_at', 'schema_version']) + expect(saves.count).toBe(0) + }) + + it('rejects unknown tables with 404 on every operation', async () => { + const db = await freshFile() + await expectAdminError(readAdminRows(db, 'nope'), 404) + await expectAdminError(insertAdminRow(db, 'nope', {}), 404) + await expectAdminError(updateAdminRow(db, 'nope', { id: 1 }, {}), 404) + await expectAdminError(deleteAdminRow(db, 'nope', { id: 1 }), 404) + }) +}) + +describe('admin row CRUD (single-PK tables)', () => { + it('inserts, reads back and pages rows with limit/offset validation', async () => { + const db = await freshFile() + const row = await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: '2026-01-01T00:00:00Z' }) + expect(row.id).toBe(1) + expect(row.seed).toBe('abc') + + const all = await readAdminRows(db, 'saves') + expect(all.total).toBe(1) + expect(all.rows[0]).toMatchObject({ id: 1, seed: 'abc', turn: 0, schema_version: null }) + + for (const bad of [0, -1, 201]) await expectAdminError(readAdminRows(db, 'saves', bad), 400) + await expectAdminError(readAdminRows(db, 'saves', 50, -1), 400) + }) + + it('rejects unknown columns, missing required columns and wrong value types with 400', async () => { + const db = await freshFile() + await expectAdminError(insertAdminRow(db, 'saves', { bogus: 1 }), 400) + await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x' }), 400) // created_at missing + await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x', turn: 'nope', created_at: 't' }), 400) + await expectAdminError(insertAdminRow(db, 'saves', ['not', 'an', 'object']), 400) + }) + + it('updates by key and returns the fresh row; unknown keys are 404', async () => { + const db = await freshFile() + await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' }) + + const updated = await updateAdminRow(db, 'saves', { id: 1 }, { turn: 5 }) + expect(updated).toMatchObject({ id: 1, seed: 'abc', turn: 5 }) + + await expectAdminError(updateAdminRow(db, 'saves', { id: 99 }, { turn: 1 }), 404) + await expectAdminError(updateAdminRow(db, 'saves', {}, { turn: 1 }), 400) // missing pk column in key + await expectAdminError(updateAdminRow(db, 'saves', { id: 1 }, {}), 400) // nothing to set + }) + + it('deletes by key and returns the removed row; second delete is 404', async () => { + const db = await freshFile() + await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' }) + + const deleted = await deleteAdminRow(db, 'saves', { id: 1 }) + expect(deleted).toMatchObject({ id: 1, seed: 'abc' }) + expect((await readAdminRows(db, 'saves')).total).toBe(0) + await expectAdminError(deleteAdminRow(db, 'saves', { id: 1 }), 404) + }) + + it('maps SQLite constraint violations to 409', async () => { + const db = await freshFile() + await insertAdminRow(db, 'crew', { save_id: 1, name: 'A', role: 'PILOT', trait_good: 'g', trait_bad: 'b', origin: 'STARTING CREW' }) + // ship_systems has UNIQUE(save_id, type) — a duplicate is a constraint failure, not a crash. + await insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 0, capacity: 0 }) + await expectAdminError(insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 1, capacity: 0 }), 409) + }) +}) + +describe('admin row CRUD (composite-PK tables)', () => { + it('requires every PK column in the key for jump_routes', async () => { + const db = await freshFile() + const row = await insertAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 }) + expect(row).toMatchObject({ from_system: 1, to_system: 2 }) + + // PK columns are NOT NULL without defaults — omitting one is a 400, not a SQLite error. + await expectAdminError(insertAdminRow(db, 'jump_routes', { from_system: 1 }), 400) + await expectAdminError(updateAdminRow(db, 'jump_routes', { from_system: 1 }, {}), 400) + + const deleted = await deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 }) + expect(deleted).toMatchObject({ from_system: 1, to_system: 2 }) + await expectAdminError(deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 3 }), 404) + }) +}) diff --git a/lib/db/admin.ts b/lib/db/admin.ts new file mode 100644 index 0000000..78dfe5b --- /dev/null +++ b/lib/db/admin.ts @@ -0,0 +1,236 @@ +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, +} 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 +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 05c283e..9cb3ff1 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -5,6 +5,8 @@ import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { it('documents every public endpoint', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ + '/api/admin/tables', + '/api/admin/tables/{name}/rows', '/api/crew', '/api/events', '/api/explore', diff --git a/lib/openapi.ts b/lib/openapi.ts index 620b5cc..b22746e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -557,6 +557,240 @@ export const openApiSpec = { }, }, }, + '/api/admin/tables': { + get: { + summary: 'Admin: list DB tables', + description: + "The game's SQLite tables exposed by the admin CRUD (#38) — name, columns (with primary-key positions) and row count. No auth by design.", + operationId: 'adminListTables', + responses: { + 200: { + description: 'Table list with column metadata and row counts', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } }, + }, + }, + }, + }, + }, + }, + '/api/admin/tables/{name}/rows': { + get: { + summary: 'Admin: read rows of a table', + description: 'Rows of one admin table as objects keyed by column name, capped at 200 per page.', + operationId: 'adminReadRows', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: "Table name as listed by /api/admin/tables (e.g. 'saves')", + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 200 }, + description: 'Rows per page (default 50)', + }, + { + name: 'offset', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 0 }, + description: 'Row offset for paging (default 0)', + }, + ], + responses: { + 200: { + description: 'Rows plus the total row count of the table', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminRows' }, + }, + }, + }, + 400: { + description: 'limit/offset out of range', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: "Unknown table name", + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + post: { + summary: 'Admin: insert a row', + description: "Insert one row. Body is an object of column → value (numbers for INTEGER/REAL columns, strings or null for TEXT); autoincrement ids may be omitted.", + operationId: 'adminInsertRow', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: "Table name as listed by /api/admin/tables", + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminRowValues' }, + }, + }, + }, + responses: { + 200: { + description: 'The inserted row', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + 400: { + description: 'Malformed body, unknown column, missing required column or wrong value type', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: "Unknown table name", + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'Constraint violation (UNIQUE or foreign key)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + patch: { + summary: 'Admin: update a row', + description: "Update one row by primary key. Body is { key, values } — key holds the table's PK columns (composite keys supported), values the new column → value pairs.", + operationId: 'adminUpdateRow', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: "Table name as listed by /api/admin/tables", + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminRowUpdate' }, + }, + }, + }, + responses: { + 200: { + description: 'The updated row', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + 400: { + description: 'Malformed body, bad key or unknown column', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: "Unknown table name, or no row with that key", + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'Constraint violation (UNIQUE or foreign key)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + delete: { + summary: 'Admin: delete a row', + description: "Delete one row by primary key. Body is { key } with the table's PK columns.", + operationId: 'adminDeleteRow', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: "Table name as listed by /api/admin/tables", + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminRowKey' }, + }, + }, + }, + responses: { + 200: { + description: 'The deleted row', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + 400: { + description: 'Malformed body or bad key', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: "Unknown table name, or no row with that key", + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -790,6 +1024,64 @@ export const openApiSpec = { }, ], }, + AdminColumn: { + type: 'object', + properties: { + name: { type: 'string', description: "SQL column name, e.g. 'save_id'" }, + type: { type: 'string', enum: ['INTEGER', 'REAL', 'TEXT'], description: 'Declared SQLite type' }, + pk: { type: 'integer', description: 'Position within the primary key (0 = not a PK); composite keys come out ordered' }, + }, + required: ['name', 'type', 'pk'], + }, + AdminTable: { + type: 'object', + properties: { + name: { type: 'string', description: "SQL table name, e.g. 'saves'" }, + columns: { type: 'array', items: { $ref: '#/components/schemas/AdminColumn' } }, + count: { type: 'integer', description: 'Current row count' }, + }, + required: ['name', 'columns', 'count'], + }, + AdminRows: { + type: 'object', + properties: { + rows: { + type: 'array', + description: 'Rows as objects keyed by column name (null for NULL cells)', + items: { type: 'object' }, + }, + total: { type: 'integer', description: 'Total row count of the table (before paging)' }, + }, + required: ['rows', 'total'], + }, + AdminRowValues: { + type: 'object', + additionalProperties: true, + description: "Column → value pairs for an insert — numbers for INTEGER/REAL columns, strings or null for TEXT; autoincrement ids may be omitted", + }, + AdminRowKey: { + type: 'object', + properties: { + key: { + type: 'object', + additionalProperties: { type: 'integer' }, + description: "The table's primary-key columns (composite keys supported)", + }, + }, + required: ['key'], + }, + AdminRowUpdate: { + allOf: [ + { $ref: '#/components/schemas/AdminRowKey' }, + { + type: 'object', + properties: { + values: { $ref: '#/components/schemas/AdminRowValues' }, + }, + required: ['values'], + }, + ], + }, Error: { type: 'object', properties: { diff --git a/package.json b/package.json index 34afb0e..d801706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.9.11", + "version": "0.9.12", "private": true, "scripts": { "dev": "next dev -p 3000",