feat(admin): DB CRUD panel + /api/admin endpoints (#38)
CI / build + unit + e2e (pull_request) Successful in 3m47s
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
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user