Skip to content

Commit 1077cbb

Browse files
authored
Merge pull request #420 from timbornemann/codex/add-database-data-management-tab
Add database explorer tab to settings
2 parents 39d8eee + 70eb322 commit 1077cbb

6 files changed

Lines changed: 965 additions & 2 deletions

File tree

server/controllers/database.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { Router } from "express";
2+
import db from "../lib/db.js";
3+
4+
interface ColumnInfo {
5+
name: string;
6+
type: string;
7+
notNull: boolean;
8+
pk: boolean;
9+
defaultValue: string | null;
10+
}
11+
12+
interface TableMetadata {
13+
name: string;
14+
columns: ColumnInfo[];
15+
rowCount: number;
16+
}
17+
18+
interface TableRowsResponse {
19+
rows: Array<Record<string, unknown>>;
20+
columns: ColumnInfo[];
21+
total: number;
22+
}
23+
24+
const router = Router();
25+
const TABLE_NAME_REGEX = /^[A-Za-z0-9_]+$/;
26+
27+
function isValidTableName(name: string): boolean {
28+
return TABLE_NAME_REGEX.test(name);
29+
}
30+
31+
function tableExists(table: string): boolean {
32+
try {
33+
const row = db
34+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
35+
.get(table) as { name?: string } | undefined;
36+
return !!row?.name;
37+
} catch {
38+
return false;
39+
}
40+
}
41+
42+
function getTableColumns(table: string): ColumnInfo[] {
43+
try {
44+
const rows = db
45+
.prepare(
46+
"SELECT name, type, notnull, pk, dflt_value FROM pragma_table_info(?)",
47+
)
48+
.all(table) as Array<{
49+
name: string;
50+
type: string;
51+
notnull: number;
52+
pk: number;
53+
dflt_value: string | null;
54+
}>;
55+
return rows.map((col) => ({
56+
name: col.name,
57+
type: col.type,
58+
notNull: !!col.notnull,
59+
pk: !!col.pk,
60+
defaultValue: col.dflt_value ?? null,
61+
}));
62+
} catch {
63+
return [];
64+
}
65+
}
66+
67+
function serializeTable(table: string): TableMetadata {
68+
const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get() as
69+
| { count?: number }
70+
| undefined;
71+
return {
72+
name: table,
73+
columns: getTableColumns(table),
74+
rowCount: row?.count ?? 0,
75+
};
76+
}
77+
78+
router.get("/tables", (_req, res) => {
79+
const tables = db
80+
.prepare(
81+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
82+
)
83+
.all() as Array<{ name: string }>;
84+
const result = tables
85+
.map((entry) => entry.name)
86+
.filter((name) => isValidTableName(name))
87+
.map((name) => serializeTable(name));
88+
res.json(result);
89+
});
90+
91+
router.get("/tables/:table", (req, res) => {
92+
const { table } = req.params;
93+
if (!isValidTableName(table)) {
94+
res.status(400).json({ error: "Invalid table name" });
95+
return;
96+
}
97+
if (!tableExists(table)) {
98+
res.status(404).json({ error: "Table not found" });
99+
return;
100+
}
101+
const limitParam = Number(req.query.limit) || 200;
102+
const offsetParam = Number(req.query.offset) || 0;
103+
const limit = Math.min(Math.max(limitParam, 1), 500);
104+
const offset = Math.max(offsetParam, 0);
105+
try {
106+
const rows = db
107+
.prepare(
108+
`SELECT rowid as rowid, * FROM ${table} ORDER BY rowid LIMIT ? OFFSET ?`,
109+
)
110+
.all(limit, offset);
111+
const response: TableRowsResponse = {
112+
rows,
113+
columns: getTableColumns(table),
114+
total: serializeTable(table).rowCount,
115+
};
116+
res.json(response);
117+
} catch (err) {
118+
res.status(400).json({
119+
error: err instanceof Error ? err.message : "Failed to load table",
120+
});
121+
}
122+
});
123+
124+
router.put("/tables/:table/:rowid", (req, res) => {
125+
const { table } = req.params;
126+
const rowid = Number(req.params.rowid);
127+
if (!isValidTableName(table) || !Number.isInteger(rowid)) {
128+
res.status(400).json({ error: "Invalid parameters" });
129+
return;
130+
}
131+
if (!tableExists(table)) {
132+
res.status(404).json({ error: "Table not found" });
133+
return;
134+
}
135+
const columns = new Set(getTableColumns(table).map((col) => col.name));
136+
const updates = Object.entries(req.body || {})
137+
.filter(([key]) => columns.has(key))
138+
.map(([key, value]) => ({ key, value }));
139+
if (!updates.length) {
140+
res.status(400).json({ error: "No valid columns provided" });
141+
return;
142+
}
143+
const assignments = updates.map((entry) => `"${entry.key}" = ?`).join(", ");
144+
try {
145+
const stmt = db.prepare(
146+
`UPDATE ${table} SET ${assignments} WHERE rowid = ?`,
147+
);
148+
const result = stmt.run(...updates.map((entry) => entry.value), rowid);
149+
res.json({ changes: result.changes });
150+
} catch (err) {
151+
res.status(400).json({
152+
error: err instanceof Error ? err.message : "Failed to update row",
153+
});
154+
}
155+
});
156+
157+
router.post("/tables/:table", (req, res) => {
158+
const { table } = req.params;
159+
if (!isValidTableName(table)) {
160+
res.status(400).json({ error: "Invalid table name" });
161+
return;
162+
}
163+
if (!tableExists(table)) {
164+
res.status(404).json({ error: "Table not found" });
165+
return;
166+
}
167+
const columns = new Set(getTableColumns(table).map((col) => col.name));
168+
const entries = Object.entries(req.body || {})
169+
.filter(([key]) => columns.has(key))
170+
.map(([key, value]) => ({ key, value }));
171+
if (!entries.length) {
172+
res.status(400).json({ error: "No valid columns provided" });
173+
return;
174+
}
175+
const columnNames = entries.map((entry) => `"${entry.key}"`).join(", ");
176+
const placeholders = entries.map(() => "?").join(", ");
177+
try {
178+
const stmt = db.prepare(
179+
`INSERT INTO ${table} (${columnNames}) VALUES (${placeholders})`,
180+
);
181+
const result = stmt.run(...entries.map((entry) => entry.value));
182+
res.json({ rowid: result.lastInsertRowid });
183+
} catch (err) {
184+
res.status(400).json({
185+
error: err instanceof Error ? err.message : "Failed to insert row",
186+
});
187+
}
188+
});
189+
190+
router.delete("/tables/:table/:rowid", (req, res) => {
191+
const { table } = req.params;
192+
const rowid = Number(req.params.rowid);
193+
if (!isValidTableName(table) || !Number.isInteger(rowid)) {
194+
res.status(400).json({ error: "Invalid parameters" });
195+
return;
196+
}
197+
if (!tableExists(table)) {
198+
res.status(404).json({ error: "Table not found" });
199+
return;
200+
}
201+
try {
202+
const stmt = db.prepare(`DELETE FROM ${table} WHERE rowid = ?`);
203+
const result = stmt.run(rowid);
204+
res.json({ changes: result.changes });
205+
} catch (err) {
206+
res.status(400).json({
207+
error: err instanceof Error ? err.message : "Failed to delete row",
208+
});
209+
}
210+
});
211+
212+
router.post("/query", (req, res) => {
213+
const sql = typeof req.body?.sql === "string" ? req.body.sql : "";
214+
const trimmed = sql.trim();
215+
if (!trimmed) {
216+
res.status(400).json({ error: "SQL query is required" });
217+
return;
218+
}
219+
const normalized = trimmed.replace(/^\(+/, "").toLowerCase();
220+
if (!/^(select|pragma|with|explain)/.test(normalized)) {
221+
res
222+
.status(400)
223+
.json({
224+
error:
225+
"Only read-only SELECT, PRAGMA, WITH or EXPLAIN queries are allowed",
226+
});
227+
return;
228+
}
229+
try {
230+
const stmt = db.prepare(sql);
231+
if (stmt.reader) {
232+
const rows = stmt.all();
233+
res.json({ rows });
234+
} else {
235+
const result = stmt.run();
236+
res.json({
237+
changes: result.changes,
238+
lastInsertRowid: result.lastInsertRowid,
239+
});
240+
}
241+
} catch (err) {
242+
res.status(400).json({
243+
error: err instanceof Error ? err.message : "Failed to execute query",
244+
});
245+
}
246+
});
247+
248+
export default router;

server/controllers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import habitsController from "./habits.js";
88
import healthController from "./health.js";
99
import inventoryController from "./inventory.js";
1010
import llmController from "./llm.js";
11+
import databaseController from "./database.js";
1112
import notesController from "./notes.js";
1213
import pomodoroController from "./pomodoro.js";
1314
import recurringController from "./recurring.js";
@@ -42,6 +43,7 @@ export const routers = {
4243
"/api/serverInfo": serverInfoController,
4344
"/api/sync-status": syncStatusController,
4445
"/api/llm": llmController,
46+
"/api/database": databaseController,
4547
"/": healthController,
4648
};
4749

0 commit comments

Comments
 (0)