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:
+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