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
CI / build + unit + e2e (push) Successful in 2m41s
This commit was merged in pull request #42.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.9.11",
|
||||
"version": "0.9.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user