Compare commits

..
2 Commits
Author SHA1 Message Date
kamilkrzywda 2a8cb4da06 Merge pull request 'v0.9.12 — admin button + direct DB table CRUD, no auth for now (#38)' (#42) from admin-db-crud into master
CI / build + unit + e2e (push) Successful in 2m41s
2026-09-29 23:53:45 +02:00
kamilkrzywda 0d2370850f feat(admin): DB CRUD panel + /api/admin endpoints (#38)
CI / build + unit + e2e (pull_request) Successful in 3m47s
- 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
2026-09-29 21:48:15 +00:00
11 changed files with 1098 additions and 1 deletions
+4
View File
@@ -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.
+55
View File
@@ -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<Record<string, unknown>> {
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<string, unknown>
}
async function run<T>(fn: () => Promise<T>): Promise<Response> {
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))
}
+8
View File
@@ -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))
}
+322
View File
@@ -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<string, unknown>[]; 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<AdminTableInfo[] | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [rows, setRows] = useState<AdminRows | null>(null)
// null = form closed; { key: null } = add row; { key: {...} } = edit that row.
const [editing, setEditing] = useState<{ key: Record<string, number> | null } | null>(null)
const [form, setForm] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(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<string, unknown>): Record<string, number> =>
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<string, unknown>) => {
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<string, number | string | null> = {}
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<string, unknown>
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<string, unknown>) => {
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 (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<Panel notch="md" style={{ width: 'min(980px, 100%)', height: 'min(640px, 100%)', display: 'flex', flexDirection: 'column' }}>
<PanelHeader>
<PanelTitle>ADMIN // DB CRUD</PanelTitle>
<Badge variant="WARNING">NO AUTH</Badge>
<div style={{ marginLeft: 'auto' }}>
<Button size="SM" onClick={onClose}>CLOSE</Button>
</div>
</PanelHeader>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '200px 1fr', gap: '1px', background: 'var(--border)', minHeight: 0 }}>
{/* ── TABLE LIST ── */}
<div style={{ background: 'var(--surface)', overflowY: 'auto', padding: '0.5rem' }}>
{!tables ? (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
) : (
tables.map((t) => (
<button
key={t.name}
onClick={() => setSelected(t.name)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
gap: '0.5rem',
padding: '0.4rem 0.5rem',
marginBottom: '2px',
background: selected === t.name ? 'var(--surface-raised)' : 'transparent',
border: `1px solid ${selected === t.name ? 'var(--color-green)' : 'var(--border)'}`,
color: selected === t.name ? 'var(--color-green)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontSize: '0.68rem',
letterSpacing: '0.06em',
cursor: 'pointer',
}}
>
<span>{t.name}</span>
<span style={{ color: 'var(--text-muted)' }}>{t.count}</span>
</button>
))
)}
</div>
{/* ── ROWS / EDITOR ── */}
<div style={{ background: 'var(--surface)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{!table ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SELECT A TABLE</span>
</PanelContent>
) : (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: '0.68rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>{table.name}</span>
{rows && (
<Badge variant="ACTIVE">
{rows.rows.length} / {rows.total}
</Badge>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
{!editing && <Button size="SM" variant="EXEC" onClick={openAdd}>ADD ROW</Button>}
{error && (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', alignSelf: 'center' }}>{error}</span>
)}
</div>
</div>
{editing && (
<div style={{ padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.4rem' }}>
{table.columns.map((c) => (
<label key={c.name} style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
<span style={{ fontSize: '0.58rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>
{c.name.toUpperCase()}
{c.pk > 0 ? ` (PK${c.pk})` : ''} · {c.type}
</span>
<input
style={inputStyle}
value={form[c.name] ?? ''}
onChange={(e) => setForm((f) => ({ ...f, [c.name]: e.target.value }))}
/>
</label>
))}
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
<Button size="SM" variant="EXEC" onClick={() => void save()}>SAVE</Button>
<Button size="SM" onClick={() => setEditing(null)}>CANCEL</Button>
</div>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{!rows ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</PanelContent>
) : rows.rows.length === 0 ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY TABLE</span>
</PanelContent>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem' }}>
<thead>
<tr>
{table.columns.map((c) => (
<th
key={c.name}
style={{
position: 'sticky',
top: 0,
background: 'var(--surface-raised)',
color: 'var(--text-muted)',
textAlign: 'left',
padding: '0.35rem 0.5rem',
borderBottom: '1px solid var(--border)',
letterSpacing: '0.08em',
whiteSpace: 'nowrap',
}}
>
{c.name}
{c.pk > 0 ? ` (PK${c.pk})` : ''}
</th>
))}
<th style={{ position: 'sticky', top: 0, background: 'var(--surface-raised)', padding: '0.35rem 0.5rem', borderBottom: '1px solid var(--border)' }} />
</tr>
</thead>
<tbody>
{rows.rows.map((row) => (
<tr key={JSON.stringify(rowKey(table, row))}>
{table.columns.map((c) => (
<td
key={c.name}
style={{
padding: '0.3rem 0.5rem',
borderBottom: '1px solid var(--border)',
color: row[c.name] === null ? 'var(--text-muted)' : 'var(--text-secondary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '220px',
}}
>
{cellText(row[c.name])}
</td>
))}
<td style={{ padding: '0.3rem 0.5rem', borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', gap: '0.3rem' }}>
<Button size="SM" onClick={() => openEdit(row)}>EDIT</Button>
<Button size="SM" variant="ABORT" onClick={() => void remove(row)}>DEL</Button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</div>
</div>
</Panel>
</div>
)
}
+9
View File
@@ -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<string | null>(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() {
<Badge variant="SCANNING">SECTOR SCAN</Badge>
{/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */}
<Button size="SM" onClick={() => setAdminOpen(true)}>ADMIN</Button>
<span
style={{
color: 'var(--color-green)',
@@ -225,6 +232,8 @@ export function MissionControl() {
</span>
</header>
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
{/* ── BODY ── */}
<div
style={{
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
// #38 — ADMIN DB CRUD panel. READ-ONLY on purpose: specs share the CI DB across retries, so no
// insert/update/delete here (the mutation endpoints are covered by unit tests in lib/db/admin.test.ts).
interface AdminColumnDto { name: string; type: string; pk: number }
interface AdminTableDto { name: string; columns: AdminColumnDto[]; count: number }
interface AdminRowsDto { rows: Record<string, unknown>[]; 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();
});
+128
View File
@@ -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<GameDb> {
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<unknown>, status: number): Promise<void> {
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<typeof getTableName>[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)
})
})
+236
View File
@@ -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<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
}
+2
View File
@@ -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',
+292
View File
@@ -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: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space-wanderer",
"version": "0.9.11",
"version": "0.9.12",
"private": true,
"scripts": {
"dev": "next dev -p 3000",