@riligar/elysia-sqlite 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +121 -0
- package/package.json +61 -0
- package/src/core/session.js +90 -0
- package/src/index.js +553 -0
- package/src/ui/bun.lock +612 -0
- package/src/ui/index.html +18 -0
- package/src/ui/package.json +29 -0
- package/src/ui/postcss.config.cjs +14 -0
- package/src/ui/src/App.jsx +2103 -0
- package/src/ui/src/components/DataGrid.jsx +122 -0
- package/src/ui/src/components/EditableCell.jsx +166 -0
- package/src/ui/src/components/ExportButton.jsx +95 -0
- package/src/ui/src/components/FKPreview.jsx +106 -0
- package/src/ui/src/components/Filter.jsx +302 -0
- package/src/ui/src/components/HoldButton.jsx +230 -0
- package/src/ui/src/components/Login.jsx +148 -0
- package/src/ui/src/components/Onboarding.jsx +127 -0
- package/src/ui/src/components/Pagination.jsx +35 -0
- package/src/ui/src/components/SecuritySettings.jsx +273 -0
- package/src/ui/src/components/TableSelector.jsx +75 -0
- package/src/ui/src/hooks/useFilter.js +120 -0
- package/src/ui/src/index.css +123 -0
- package/src/ui/src/main.jsx +115 -0
- package/src/ui/vite.config.js +19 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { writeFile } from 'node:fs/promises';
|
|
6
|
+
import { createSessionManager } from './core/session.js';
|
|
7
|
+
import { authenticator } from 'otplib';
|
|
8
|
+
import QRCode from 'qrcode';
|
|
9
|
+
|
|
10
|
+
// Mapeamento de extensões para MIME types
|
|
11
|
+
const mimeTypes = {
|
|
12
|
+
".html": "text/html",
|
|
13
|
+
".js": "application/javascript",
|
|
14
|
+
".css": "text/css",
|
|
15
|
+
".json": "application/json",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".svg": "image/svg+xml",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Plugin de administração SQLite para ElysiaJS
|
|
23
|
+
* @param {Object} config - Configuração do plugin
|
|
24
|
+
* @param {string} config.dbPath - Caminho para o arquivo do banco SQLite
|
|
25
|
+
* @param {string} config.prefix - Prefixo da rota (ex: '/admin')
|
|
26
|
+
* @param {string} config.configPath - Caminho para o arquivo de configuração de auth (ex: './sqlite-admin-config.json')
|
|
27
|
+
*/
|
|
28
|
+
export const sqliteAdmin = ({ dbPath, prefix = "/admin", configPath = "./sqlite-admin-config.json" }) => {
|
|
29
|
+
const db = new Database(dbPath);
|
|
30
|
+
const uiPath = join(import.meta.dir, "ui", "dist");
|
|
31
|
+
|
|
32
|
+
// Gerenciador de Sessão
|
|
33
|
+
const sessionManager = createSessionManager();
|
|
34
|
+
|
|
35
|
+
// Carregar Configuração
|
|
36
|
+
let config = {};
|
|
37
|
+
const loadConfig = () => {
|
|
38
|
+
if (existsSync(configPath)) {
|
|
39
|
+
try {
|
|
40
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error("Failed to load config", e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
loadConfig();
|
|
47
|
+
|
|
48
|
+
const saveConfig = async (newConfig) => {
|
|
49
|
+
config = { ...config, ...newConfig };
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
52
|
+
return true;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error("Failed to save config", e);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const isConfigured = () => !!(config.username && config.password);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
new Elysia({ prefix })
|
|
63
|
+
// Middleware de Autenticação
|
|
64
|
+
.derive(({ headers }) => {
|
|
65
|
+
const cookies = headers.cookie || '';
|
|
66
|
+
const sessionMatch = cookies.match(/admin-session=([^;]+)/);
|
|
67
|
+
const token = sessionMatch ? sessionMatch[1] : null;
|
|
68
|
+
const session = sessionManager.get(token);
|
|
69
|
+
return { session };
|
|
70
|
+
})
|
|
71
|
+
.onBeforeHandle(({ path, set, session, body }) => {
|
|
72
|
+
// Permitir assets e HTML principal
|
|
73
|
+
if (path.includes('/assets/') || path === prefix || path === prefix + '/') return;
|
|
74
|
+
if (path.endsWith('index.html')) return;
|
|
75
|
+
|
|
76
|
+
// Rotas Públicas de API
|
|
77
|
+
if (path.endsWith('/auth/login') || path.endsWith('/auth/status') || path.endsWith('/auth/logout')) return;
|
|
78
|
+
|
|
79
|
+
// Rota de Setup (só permitida se não configurado)
|
|
80
|
+
if (path.endsWith('/api/setup')) {
|
|
81
|
+
if (isConfigured()) {
|
|
82
|
+
set.status = 403;
|
|
83
|
+
return { success: false, error: "System already configured" };
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Para todas as outras rotas /api/, exigir configuração e autenticação
|
|
89
|
+
if (path.includes('/api/')) {
|
|
90
|
+
if (!isConfigured()) {
|
|
91
|
+
set.status = 403;
|
|
92
|
+
return { success: false, error: "System not configured", code: "NOT_CONFIGURED" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!session) {
|
|
96
|
+
set.status = 401;
|
|
97
|
+
return { success: false, error: "Unauthorized", code: "UNAUTHORIZED" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// AUTH: Status
|
|
103
|
+
.get("/auth/status", ({ session }) => {
|
|
104
|
+
return {
|
|
105
|
+
configured: isConfigured(),
|
|
106
|
+
authenticated: !!session,
|
|
107
|
+
user: session?.username,
|
|
108
|
+
totpEnabled: !!config.totpSecret
|
|
109
|
+
};
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// AUTH: Setup (Onboarding)
|
|
113
|
+
.post("/api/setup", async ({ body }) => {
|
|
114
|
+
if (isConfigured()) {
|
|
115
|
+
return { success: false, error: "Already configured" };
|
|
116
|
+
}
|
|
117
|
+
const { username, password } = body;
|
|
118
|
+
if (!username || !password) {
|
|
119
|
+
return { success: false, error: "Username and password required" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (await saveConfig({ username, password })) {
|
|
123
|
+
// Criar sessão automaticamente
|
|
124
|
+
const { token, expiresAt } = sessionManager.create(username);
|
|
125
|
+
const expiresDate = new Date(expiresAt);
|
|
126
|
+
|
|
127
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
128
|
+
headers: {
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
'Set-Cookie': `admin-session=${token}; Path=${prefix}; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { success: false, error: "Failed to save config" };
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// AUTH: Login
|
|
138
|
+
.post("/auth/login", ({ body, set }) => {
|
|
139
|
+
if (!isConfigured()) {
|
|
140
|
+
set.status = 403;
|
|
141
|
+
return { success: false, error: "Not configured" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { username, password, totpCode } = body;
|
|
145
|
+
|
|
146
|
+
if (username === config.username && password === config.password) {
|
|
147
|
+
// 2FA Verification
|
|
148
|
+
if (config.totpSecret) {
|
|
149
|
+
if (!totpCode) {
|
|
150
|
+
set.status = 401; // Require 2FA
|
|
151
|
+
return { success: false, error: "2FA code required", code: "2FA_REQUIRED" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isValid = authenticator.check(totpCode, config.totpSecret);
|
|
155
|
+
if (!isValid) {
|
|
156
|
+
set.status = 401;
|
|
157
|
+
return { success: false, error: "Invalid 2FA code" };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { token, expiresAt } = sessionManager.create(username);
|
|
162
|
+
const expiresDate = new Date(expiresAt);
|
|
163
|
+
|
|
164
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'Set-Cookie': `admin-session=${token}; Path=${prefix}; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
set.status = 401;
|
|
173
|
+
return { success: false, error: "Invalid credentials" };
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// AUTH: Logout
|
|
177
|
+
.post("/auth/logout", ({ session }) => {
|
|
178
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
179
|
+
headers: {
|
|
180
|
+
'Content-Type': 'application/json',
|
|
181
|
+
'Set-Cookie': `admin-session=; Path=${prefix}; HttpOnly; SameSite=Lax; Max-Age=0`
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// TOTP: Generate
|
|
187
|
+
.post("/api/totp/generate", async ({ session, set }) => {
|
|
188
|
+
if (!session) { set.status = 401; return; }
|
|
189
|
+
const secret = authenticator.generateSecret();
|
|
190
|
+
const otpauth = authenticator.keyuri(session.username, 'SQLite Admin', secret);
|
|
191
|
+
const qrCode = await QRCode.toDataURL(otpauth);
|
|
192
|
+
return { success: true, secret, qrCode };
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// TOTP: Verify & Enable
|
|
196
|
+
.post("/api/totp/verify", async ({ body, session, set }) => {
|
|
197
|
+
if (!session) { set.status = 401; return; }
|
|
198
|
+
const { secret, code } = body;
|
|
199
|
+
|
|
200
|
+
if (!authenticator.check(code, secret)) {
|
|
201
|
+
return { success: false, error: "Invalid code" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (await saveConfig({ totpSecret: secret })) {
|
|
205
|
+
return { success: true };
|
|
206
|
+
}
|
|
207
|
+
return { success: false, error: "Failed to save config" };
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// TOTP: Disable
|
|
211
|
+
.post("/api/totp/disable", async ({ body, session, set }) => {
|
|
212
|
+
if (!session) { set.status = 401; return; }
|
|
213
|
+
const { code } = body; // Confirm with code before disabling
|
|
214
|
+
|
|
215
|
+
if (!authenticator.check(code, config.totpSecret)) {
|
|
216
|
+
return { success: false, error: "Invalid code" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Remove secret
|
|
220
|
+
const newConfig = { ...config };
|
|
221
|
+
delete newConfig.totpSecret;
|
|
222
|
+
config = newConfig; // Local update
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
226
|
+
return { success: true };
|
|
227
|
+
} catch(e) {
|
|
228
|
+
return { success: false, error: "Failed to save" };
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Servir index.html na raiz
|
|
233
|
+
.get("/", async () => {
|
|
234
|
+
const file = Bun.file(join(uiPath, "index.html"));
|
|
235
|
+
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Servir arquivos estáticos da pasta assets
|
|
239
|
+
.get("/assets/*", async ({ params }) => {
|
|
240
|
+
const filePath = join(uiPath, "assets", params["*"]);
|
|
241
|
+
const file = Bun.file(filePath);
|
|
242
|
+
if (!(await file.exists()))
|
|
243
|
+
return new Response("Not found", { status: 404 });
|
|
244
|
+
const ext = filePath.substring(filePath.lastIndexOf("."));
|
|
245
|
+
return new Response(file, {
|
|
246
|
+
headers: {
|
|
247
|
+
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Lista todas as tabelas do banco
|
|
253
|
+
.get("/api/tables", () => {
|
|
254
|
+
const tables = db
|
|
255
|
+
.query(
|
|
256
|
+
`
|
|
257
|
+
SELECT name FROM sqlite_master
|
|
258
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
259
|
+
ORDER BY name
|
|
260
|
+
`
|
|
261
|
+
)
|
|
262
|
+
.all();
|
|
263
|
+
return { tables: tables.map((t) => t.name) };
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Lista linhas de uma tabela com paginação
|
|
267
|
+
.get("/api/table/:name/rows", ({ params, query }) => {
|
|
268
|
+
try {
|
|
269
|
+
const page = parseInt(query.page) || 1;
|
|
270
|
+
const limit = parseInt(query.limit) || 50;
|
|
271
|
+
const offset = (page - 1) * limit;
|
|
272
|
+
|
|
273
|
+
const rows = db.query(`SELECT * FROM ${params.name} LIMIT ${limit} OFFSET ${offset}`).all();
|
|
274
|
+
const countResult = db.query(`SELECT COUNT(*) as count FROM ${params.name}`).get();
|
|
275
|
+
const total = countResult.count;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
success: true,
|
|
279
|
+
rows,
|
|
280
|
+
pagination: {
|
|
281
|
+
page,
|
|
282
|
+
limit,
|
|
283
|
+
total,
|
|
284
|
+
totalPages: Math.ceil(total / limit)
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { success: false, error: error.message };
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Executa query SQL arbitrária
|
|
293
|
+
.post("/api/query", ({ body }) => {
|
|
294
|
+
try {
|
|
295
|
+
const { sql } = body;
|
|
296
|
+
const isSelect = sql.trim().toLowerCase().startsWith("select");
|
|
297
|
+
|
|
298
|
+
if (isSelect) {
|
|
299
|
+
const results = db.query(sql).all();
|
|
300
|
+
const columns = results.length > 0 ? Object.keys(results[0]) : [];
|
|
301
|
+
return { success: true, columns, rows: results };
|
|
302
|
+
} else {
|
|
303
|
+
const result = db.run(sql);
|
|
304
|
+
return {
|
|
305
|
+
success: true,
|
|
306
|
+
message: `Query executada. ${result.changes} linha(s) afetada(s).`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { success: false, error: error.message };
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Conta total de registros de uma tabela
|
|
315
|
+
.get("/api/table/:name/count", ({ params }) => {
|
|
316
|
+
try {
|
|
317
|
+
const result = db
|
|
318
|
+
.query(`SELECT COUNT(*) as count FROM ${params.name}`)
|
|
319
|
+
.get();
|
|
320
|
+
return { success: true, count: result.count };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return { success: false, error: error.message };
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Insere um novo registro
|
|
327
|
+
.post("/api/table/:name/insert", ({ params, body }) => {
|
|
328
|
+
try {
|
|
329
|
+
const columns = Object.keys(body);
|
|
330
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
331
|
+
const values = Object.values(body);
|
|
332
|
+
const sql = `INSERT INTO ${params.name} (${columns.join(
|
|
333
|
+
", "
|
|
334
|
+
)}) VALUES (${placeholders})`;
|
|
335
|
+
const result = db.run(sql, values);
|
|
336
|
+
return { success: true, id: result.lastInsertRowid };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return { success: false, error: error.message };
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// Atualiza um registro (inline edit)
|
|
343
|
+
.post("/api/table/:name/update", ({ params, body }) => {
|
|
344
|
+
try {
|
|
345
|
+
const { column, value, pkColumn, pkValue } = body;
|
|
346
|
+
const sql = `UPDATE ${params.name} SET ${column} = ? WHERE ${pkColumn} = ?`;
|
|
347
|
+
const result = db.run(sql, [value, pkValue]);
|
|
348
|
+
return { success: true, changes: result.changes };
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return { success: false, error: error.message };
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Exclui um registro
|
|
355
|
+
.post("/api/table/:name/delete", ({ params, body }) => {
|
|
356
|
+
try {
|
|
357
|
+
const { column, value } = body;
|
|
358
|
+
const sql = `DELETE FROM ${params.name} WHERE ${column} = ?`;
|
|
359
|
+
const result = db.run(sql, [value]);
|
|
360
|
+
return { success: true, changes: result.changes };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return { success: false, error: error.message };
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// Retorna estrutura de uma tabela com foreign keys
|
|
367
|
+
.get("/api/table/:name", ({ params }) => {
|
|
368
|
+
try {
|
|
369
|
+
const info = db.query(`PRAGMA table_info(${params.name})`).all();
|
|
370
|
+
const foreignKeys = db
|
|
371
|
+
.query(`PRAGMA foreign_key_list(${params.name})`)
|
|
372
|
+
.all();
|
|
373
|
+
|
|
374
|
+
// Enrich columns with FK info
|
|
375
|
+
const columnsWithFK = info.map((col) => {
|
|
376
|
+
const fk = foreignKeys.find((f) => f.from === col.name);
|
|
377
|
+
if (fk) {
|
|
378
|
+
return {
|
|
379
|
+
...col,
|
|
380
|
+
fk: {
|
|
381
|
+
table: fk.table,
|
|
382
|
+
column: fk.to,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return col;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return { success: true, columns: columnsWithFK };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return { success: false, error: error.message };
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// Retorna opções para foreign key (valores da tabela referenciada)
|
|
396
|
+
.get("/api/table/:name/fk-options", ({ params, query }) => {
|
|
397
|
+
try {
|
|
398
|
+
const { refTable, refColumn } = query;
|
|
399
|
+
if (!refTable || !refColumn) {
|
|
400
|
+
return { success: false, error: "Missing refTable or refColumn" };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Try to get a display column (first text column or the pk itself)
|
|
404
|
+
const tableInfo = db.query(`PRAGMA table_info(${refTable})`).all();
|
|
405
|
+
const displayCol =
|
|
406
|
+
tableInfo.find(
|
|
407
|
+
(c) =>
|
|
408
|
+
c.type?.toUpperCase().includes("TEXT") && c.name !== refColumn
|
|
409
|
+
)?.name || refColumn;
|
|
410
|
+
|
|
411
|
+
const sql = `SELECT ${refColumn} as value, ${displayCol} as label FROM ${refTable} ORDER BY ${displayCol}`;
|
|
412
|
+
const options = db.query(sql).all();
|
|
413
|
+
|
|
414
|
+
return { success: true, options };
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return { success: false, error: error.message };
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// Resolve specific FK IDs to display values
|
|
421
|
+
.post("/api/resolve-fk", ({ body }) => {
|
|
422
|
+
try {
|
|
423
|
+
const { table, idColumn, ids } = body;
|
|
424
|
+
if (
|
|
425
|
+
!table ||
|
|
426
|
+
!idColumn ||
|
|
427
|
+
!ids ||
|
|
428
|
+
!Array.isArray(ids) ||
|
|
429
|
+
ids.length === 0
|
|
430
|
+
) {
|
|
431
|
+
return { success: true, values: {} };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Identify display column
|
|
435
|
+
const tableInfo = db.query(`PRAGMA table_info(${table})`).all();
|
|
436
|
+
const displayCol =
|
|
437
|
+
tableInfo.find(
|
|
438
|
+
(c) =>
|
|
439
|
+
c.type?.toUpperCase().includes("TEXT") && c.name !== idColumn
|
|
440
|
+
)?.name || idColumn;
|
|
441
|
+
|
|
442
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
443
|
+
// Handle potential duplicates in ids by using DISTINCT if needed, but Map handles it safely
|
|
444
|
+
const sql = `SELECT ${idColumn} as id, ${displayCol} as label FROM ${table} WHERE ${idColumn} IN (${placeholders})`;
|
|
445
|
+
|
|
446
|
+
const results = db.query(sql).all(...ids);
|
|
447
|
+
|
|
448
|
+
// Convert to map: { [id]: label }
|
|
449
|
+
const values = results.reduce((acc, row) => {
|
|
450
|
+
acc[row.id] = row.label;
|
|
451
|
+
return acc;
|
|
452
|
+
}, {});
|
|
453
|
+
|
|
454
|
+
return { success: true, values };
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return { success: false, error: error.message };
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// AI SQL Generation
|
|
461
|
+
.post("/api/ai/sql", async ({ body }) => {
|
|
462
|
+
try {
|
|
463
|
+
const { prompt } = body;
|
|
464
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
465
|
+
const model =
|
|
466
|
+
process.env.OPENROUTER_MODEL || "meta-llama/llama-3.3-70b-instruct";
|
|
467
|
+
|
|
468
|
+
if (!apiKey) {
|
|
469
|
+
return {
|
|
470
|
+
success: false,
|
|
471
|
+
error: "OpenRouter API Key not configured",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Get database schema context
|
|
476
|
+
const tables = db
|
|
477
|
+
.query(
|
|
478
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
479
|
+
)
|
|
480
|
+
.all();
|
|
481
|
+
let schemaContext = "";
|
|
482
|
+
|
|
483
|
+
for (const t of tables) {
|
|
484
|
+
const cols = db.query(`PRAGMA table_info(${t.name})`).all();
|
|
485
|
+
schemaContext += `Table ${t.name}: ${cols
|
|
486
|
+
.map((c) => c.name + "(" + c.type + ")")
|
|
487
|
+
.join(", ")}\n`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const response = await fetch(
|
|
491
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
492
|
+
{
|
|
493
|
+
method: "POST",
|
|
494
|
+
headers: {
|
|
495
|
+
Authorization: `Bearer ${apiKey}`,
|
|
496
|
+
"Content-Type": "application/json",
|
|
497
|
+
},
|
|
498
|
+
body: JSON.stringify({
|
|
499
|
+
model: model,
|
|
500
|
+
messages: [
|
|
501
|
+
{
|
|
502
|
+
role: "system",
|
|
503
|
+
content: `You are a SQLite expert. Given the following database schema:\n${schemaContext}\nGenerate a valid SQLite query for the user's request. Return ONLY the raw SQL query, no markdown formatting, no explanations.`,
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
role: "user",
|
|
507
|
+
content: prompt,
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
}),
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const data = await response.json();
|
|
515
|
+
const sql = data.choices?.[0]?.message?.content
|
|
516
|
+
?.trim()
|
|
517
|
+
.replace(/```sql/g, "")
|
|
518
|
+
.replace(/```/g, "");
|
|
519
|
+
|
|
520
|
+
return { success: true, sql };
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return { success: false, error: error.message };
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
// Get full database schema for ERD
|
|
527
|
+
.get("/api/meta/schema", () => {
|
|
528
|
+
try {
|
|
529
|
+
const tables = db
|
|
530
|
+
.query(
|
|
531
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
532
|
+
)
|
|
533
|
+
.all();
|
|
534
|
+
const schema = tables.map((t) => {
|
|
535
|
+
const columns = db.query(`PRAGMA table_info(${t.name})`).all();
|
|
536
|
+
const fks = db.query(`PRAGMA foreign_key_list(${t.name})`).all();
|
|
537
|
+
return {
|
|
538
|
+
name: t.name,
|
|
539
|
+
columns,
|
|
540
|
+
fks: fks.map((fk) => ({
|
|
541
|
+
from: fk.from,
|
|
542
|
+
table: fk.table,
|
|
543
|
+
to: fk.to,
|
|
544
|
+
})),
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
return { success: true, schema };
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return { success: false, error: error.message };
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
};
|