jsql-neo 5.4.2 → 5.5.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 +202 -202
- package/README.md +8097 -7971
- package/bin/jsql +320 -320
- package/bin/jsql-server +90 -90
- package/bin/jsql-server-control.js +177 -177
- package/index.d.ts +385 -357
- package/index.js +124 -118
- package/lib/ast.js +214 -0
- package/lib/btree.js +413 -413
- package/lib/cache.js +328 -328
- package/lib/client.js +125 -125
- package/lib/database.js +1744 -1744
- package/lib/date-types.js +182 -182
- package/lib/errors.js +89 -89
- package/lib/jsql_format.js +497 -497
- package/lib/migrate.js +254 -254
- package/lib/mod.js +292 -292
- package/lib/mongo_server.js +607 -607
- package/lib/multiserver.js +140 -140
- package/lib/mysql_compat.js +413 -413
- package/lib/mysql_server.js +1237 -1237
- package/lib/native_client.js +732 -732
- package/lib/nedb_compat.js +506 -506
- package/lib/pg_server.js +864 -864
- package/lib/plugin.js +34 -34
- package/lib/query.js +815 -815
- package/lib/redis_server.js +571 -571
- package/lib/sql.js +3460 -2925
- package/lib/sqlite_compat.js +323 -323
- package/lib/sqlite_worker.js +264 -264
- package/lib/table.js +1304 -1304
- package/lib/tui.js +502 -502
- package/lib/wasm.js +1 -1
- package/lib/wasm_client.js +718 -718
- package/lib/web_ui.js +257 -257
- package/native/jsql-neo-native.node +0 -0
- package/nativesrc/jsql-neo-core/Cargo.lock +455 -455
- package/nativesrc/jsql-neo-core/Cargo.toml +24 -24
- package/nativesrc/jsql-neo-core/src/engine/hybrid.rs +455 -455
- package/nativesrc/jsql-neo-core/src/engine/memory.rs +168 -168
- package/nativesrc/jsql-neo-core/src/engine/mod.rs +62 -62
- package/nativesrc/jsql-neo-core/src/engine/table.rs +700 -700
- package/nativesrc/jsql-neo-core/src/lib.rs +2 -2
- package/nativesrc/jsql-neo-core/src/storage/mod.rs +2 -2
- package/nativesrc/jsql-neo-core/src/storage/persistent.rs +1 -1
- package/nativesrc/jsql-neo-core/src/storage/wal.rs +85 -85
- package/nativesrc/jsql-neo-core/src/types.rs +93 -93
- package/nativesrc/jsql-neo-native/Cargo.lock +606 -606
- package/nativesrc/jsql-neo-native/Cargo.toml +16 -16
- package/nativesrc/jsql-neo-native/build.rs +3 -3
- package/nativesrc/jsql-neo-native/package.json +7 -7
- package/nativesrc/jsql-neo-native/src/lib.rs +269 -269
- package/nativesrc/jsql-neo-wasm/Cargo.lock +465 -465
- package/nativesrc/jsql-neo-wasm/Cargo.toml +15 -15
- package/nativesrc/jsql-neo-wasm/src/lib.rs +447 -447
- package/package.json +87 -86
- package/postinstall.js +158 -158
- package/test/btree.test.js +128 -128
- package/test/coverage.js +187 -187
- package/test/join.test.js +110 -110
- package/test/native.test.js +158 -158
- package/test/regress-5.1.0.js +271 -271
- package/test/smoke.js +58 -58
- package/test/sql-parser.test.js +273 -0
- package/test/wasm.test.js +110 -110
- package/wasm/browser.d.ts +89 -89
- package/wasm/browser.mjs +569 -569
- package/wasm/browser_bg.mjs +597 -597
- package/wasm/jsql_neo_wasm.d.ts +38 -38
- package/wasm/jsql_neo_wasm.js +520 -520
- package/wasm/jsql_neo_wasm_bg.wasm.d.ts +26 -26
- package/wasm/package.json +11 -11
package/lib/web_ui.js
CHANGED
|
@@ -1,257 +1,257 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* JSQL-NEO built-in web UI: zero-dependency HTTP management console.
|
|
3
|
-
*
|
|
4
|
-
* const { WebUI } = require('jsql-neo');
|
|
5
|
-
* const ui = new WebUI({ port: 8080, dataDir: './data' });
|
|
6
|
-
* await ui.start();
|
|
7
|
-
*
|
|
8
|
-
* Routes:
|
|
9
|
-
* GET / management console (HTML, no external deps)
|
|
10
|
-
* GET /api/databases [{name, tables, rows}]
|
|
11
|
-
* GET /api/tables?db=name {tables: [{name, count}]}
|
|
12
|
-
* POST /api/query {db, sql} {columns, rows, rowCount, ok, error?}
|
|
13
|
-
*/
|
|
14
|
-
const http = require('http');
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const Database = require('./database');
|
|
18
|
-
const { executeSQL } = require('./sql');
|
|
19
|
-
|
|
20
|
-
const PAGE = `<!DOCTYPE html>
|
|
21
|
-
<html lang="en">
|
|
22
|
-
<head>
|
|
23
|
-
<meta charset="utf-8">
|
|
24
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
-
<title>JSQL-NEO</title>
|
|
26
|
-
<style>
|
|
27
|
-
:root { color-scheme: dark; }
|
|
28
|
-
body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0d1117; color: #e6edf3; display: flex; height: 100vh; }
|
|
29
|
-
.side { width: 260px; border-right: 1px solid #21262d; padding: 12px; overflow: auto; }
|
|
30
|
-
.main { flex: 1; display: flex; flex-direction: column; }
|
|
31
|
-
h1 { font-size: 15px; margin: 4px 0 12px; }
|
|
32
|
-
h2 { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; margin: 14px 0 6px; }
|
|
33
|
-
.db { cursor: pointer; padding: 3px 6px; border-radius: 6px; }
|
|
34
|
-
.db:hover, .db.active { background: #1f6feb33; }
|
|
35
|
-
.db .rows { color: #8b949e; font-size: 12px; }
|
|
36
|
-
.tbl { cursor: pointer; padding: 2px 6px 2px 18px; color: #79c0ff; border-radius: 4px; }
|
|
37
|
-
.tbl:hover { background: #21262d; }
|
|
38
|
-
textarea { flex: 1; margin: 12px; padding: 10px; background: #010409; color: #e6edf3; border: 1px solid #21262d; border-radius: 8px; resize: none; font: inherit; }
|
|
39
|
-
.actions { padding: 0 12px; }
|
|
40
|
-
button { background: #238636; border: 0; color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
41
|
-
button:hover { background: #2ea043; }
|
|
42
|
-
button:disabled { background: #21262d; cursor: wait; }
|
|
43
|
-
.status { padding: 0 12px 10px; color: #8b949e; min-height: 20px; }
|
|
44
|
-
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
45
|
-
th, td { border: 1px solid #21262d; padding: 4px 8px; text-align: left; white-space: pre; }
|
|
46
|
-
th { background: #161b22; position: sticky; top: 0; }
|
|
47
|
-
tr:nth-child(even) td { background: #0d1117; }
|
|
48
|
-
.result { flex: 1.4; overflow: auto; margin: 0 12px 12px; border: 1px solid #21262d; border-radius: 8px; background: #010409; }
|
|
49
|
-
.err { color: #f85149; padding: 10px; }
|
|
50
|
-
.ok { color: #3fb950; }
|
|
51
|
-
</style>
|
|
52
|
-
</head>
|
|
53
|
-
<body>
|
|
54
|
-
<div class="side">
|
|
55
|
-
<h1>JSQL-NEO</h1>
|
|
56
|
-
<h2>Databases</h2>
|
|
57
|
-
<div id="dbs"></div>
|
|
58
|
-
<h2>Tables</h2>
|
|
59
|
-
<div id="tbls"></div>
|
|
60
|
-
</div>
|
|
61
|
-
<div class="main">
|
|
62
|
-
<textarea id="sql" spellcheck="false" placeholder="SELECT * FROM t LIMIT 100">SELECT 1</textarea>
|
|
63
|
-
<div class="actions">
|
|
64
|
-
<button id="run" onclick="run()">Run (Ctrl+Enter)</button>
|
|
65
|
-
</div>
|
|
66
|
-
<div class="status" id="status"></div>
|
|
67
|
-
<div class="result" id="res"></div>
|
|
68
|
-
</div>
|
|
69
|
-
<script>
|
|
70
|
-
let dbs = [], cur = null;
|
|
71
|
-
const q = async (u, o) => { const r = await fetch(u, o); return r.json(); };
|
|
72
|
-
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
73
|
-
async function load() {
|
|
74
|
-
dbs = await q('/api/databases');
|
|
75
|
-
const el = document.getElementById('dbs');
|
|
76
|
-
el.innerHTML = dbs.map(d => '<div class="db" onclick="openDb(' + esc(d.name) + ')">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
|
|
77
|
-
if (!cur && dbs.length) openDb(dbs[0].name);
|
|
78
|
-
}
|
|
79
|
-
async function openDb(n) {
|
|
80
|
-
cur = n;
|
|
81
|
-
document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.textContent.indexOf(n) === 0));
|
|
82
|
-
const r = await q('/api/tables?db=' + encodeURIComponent(n));
|
|
83
|
-
document.getElementById('tbls').innerHTML = r.tables.map(t =>
|
|
84
|
-
'<div class="tbl" onclick="sel(' + esc(t.name) + ')">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
|
|
85
|
-
}
|
|
86
|
-
function sel(n) { document.getElementById('sql').value = 'SELECT * FROM ' + n + ' LIMIT 100'; run(); }
|
|
87
|
-
async function run() {
|
|
88
|
-
const sql = document.getElementById('sql').value;
|
|
89
|
-
if (!cur || !sql.trim()) return;
|
|
90
|
-
const btn = document.getElementById('run'); btn.disabled = true;
|
|
91
|
-
const st = document.getElementById('status'); st.innerHTML = 'running...';
|
|
92
|
-
try {
|
|
93
|
-
const r = await q('/api/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db: cur, sql }) });
|
|
94
|
-
st.innerHTML = r.ok ? '<span class="ok">ok</span> ' + r.rowCount + ' row(s), ' + r.ms + 'ms' : '<span class="err">' + esc(r.error) + '</span>';
|
|
95
|
-
if (r.columns && r.columns.length) {
|
|
96
|
-
let h = '<table><tr>' + r.columns.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
|
|
97
|
-
h += r.rows.map(row => '<tr>' + row.map(c => '<td>' + (c === null ? '<i>NULL</i>' : esc(c)) + '</td>').join('') + '</tr>').join('');
|
|
98
|
-
document.getElementById('res').innerHTML = h + '</table>';
|
|
99
|
-
} else if (r.affected !== undefined) {
|
|
100
|
-
document.getElementById('res').innerHTML = '<p class="ok">' + r.affected + ' row(s) affected</p>';
|
|
101
|
-
}
|
|
102
|
-
} catch (e) { st.innerHTML = '<span class="err">' + esc(e.message) + '</span>'; }
|
|
103
|
-
btn.disabled = false;
|
|
104
|
-
}
|
|
105
|
-
document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') run(); });
|
|
106
|
-
load();
|
|
107
|
-
</script>
|
|
108
|
-
</body>
|
|
109
|
-
</html>
|
|
110
|
-
`;
|
|
111
|
-
|
|
112
|
-
class WebUI {
|
|
113
|
-
constructor(opts = {}) {
|
|
114
|
-
this.port = opts.port !== undefined ? opts.port : 8080;
|
|
115
|
-
this.dataDir = opts.dataDir || '.';
|
|
116
|
-
this.readonly = !!opts.readonly;
|
|
117
|
-
this.host = opts.host || '127.0.0.1';
|
|
118
|
-
this.authToken = opts.authToken || null;
|
|
119
|
-
this.allowOrigin = opts.allowOrigin || null;
|
|
120
|
-
this.echoOrigin = !opts.allowOrigin && !!this.authToken;
|
|
121
|
-
this.cache = new Map();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
dbPath(name) {
|
|
125
|
-
if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new Error('invalid database name');
|
|
126
|
-
const p = path.join(this.dataDir, name + '.json');
|
|
127
|
-
if (!fs.existsSync(p)) throw new Error('database not found: ' + name);
|
|
128
|
-
return p;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async db(name) {
|
|
132
|
-
if (this.cache.has(name)) return this.cache.get(name);
|
|
133
|
-
const db = new Database(this.dbPath(name), { autoSave: !this.readonly });
|
|
134
|
-
if (db.loadDatabase) await db.loadDatabase();
|
|
135
|
-
this.cache.set(name, db);
|
|
136
|
-
return db;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
listDatabases() {
|
|
140
|
-
if (!fs.existsSync(this.dataDir)) return [];
|
|
141
|
-
return fs.readdirSync(this.dataDir)
|
|
142
|
-
.filter(f => f.endsWith('.json'))
|
|
143
|
-
.map(f => {
|
|
144
|
-
let tables = 0;
|
|
145
|
-
try { tables = Object.keys(JSON.parse(fs.readFileSync(path.join(this.dataDir, f), 'utf8')).__schema__ || {}).length; } catch (_) {}
|
|
146
|
-
return { name: f.slice(0, -5), tables };
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
tableList(db) {
|
|
151
|
-
const map = db._tables || {};
|
|
152
|
-
return Object.values(map).map(t => ({
|
|
153
|
-
name: t._name || t.name,
|
|
154
|
-
count: (t._rows || t.rows || []).length,
|
|
155
|
-
}));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async handle(req, res) {
|
|
159
|
-
const url = new URL(req.url, 'http://x');
|
|
160
|
-
const reqOrigin = req.headers.origin;
|
|
161
|
-
let corsOrigin = null;
|
|
162
|
-
if (this.allowOrigin) corsOrigin = this.allowOrigin;
|
|
163
|
-
else if (this.echoOrigin && reqOrigin) corsOrigin = reqOrigin;
|
|
164
|
-
const send = (code, obj) => {
|
|
165
|
-
const body = JSON.stringify(obj);
|
|
166
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
167
|
-
if (corsOrigin) {
|
|
168
|
-
headers['Access-Control-Allow-Origin'] = corsOrigin;
|
|
169
|
-
headers['Vary'] = 'Origin';
|
|
170
|
-
}
|
|
171
|
-
res.writeHead(code, headers);
|
|
172
|
-
res.end(body);
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
if (url.pathname === '/' && req.method === 'GET') {
|
|
177
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
178
|
-
return res.end(PAGE);
|
|
179
|
-
}
|
|
180
|
-
if (req.method === 'OPTIONS' && url.pathname.startsWith('/api/')) {
|
|
181
|
-
const h = {};
|
|
182
|
-
if (corsOrigin) {
|
|
183
|
-
h['Access-Control-Allow-Origin'] = corsOrigin;
|
|
184
|
-
h['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS';
|
|
185
|
-
h['Access-Control-Allow-Headers'] = 'Authorization,Content-Type';
|
|
186
|
-
h['Vary'] = 'Origin';
|
|
187
|
-
}
|
|
188
|
-
res.writeHead(204, h);
|
|
189
|
-
return res.end();
|
|
190
|
-
}
|
|
191
|
-
if (this.authToken && url.pathname.startsWith('/api/')) {
|
|
192
|
-
const provided = req.headers.authorization || '';
|
|
193
|
-
const token = provided.startsWith('Bearer ') ? provided.slice(7) : '';
|
|
194
|
-
if (token !== this.authToken) {
|
|
195
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
196
|
-
return res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (url.pathname === '/api/databases' && req.method === 'GET') {
|
|
200
|
-
return send(200, this.listDatabases());
|
|
201
|
-
}
|
|
202
|
-
if (url.pathname === '/api/tables' && req.method === 'GET') {
|
|
203
|
-
const db = await this.db(url.searchParams.get("db"));
|
|
204
|
-
return send(200, { tables: this.tableList(db) });
|
|
205
|
-
}
|
|
206
|
-
if (url.pathname === '/api/query' && req.method === 'POST') {
|
|
207
|
-
let body = '';
|
|
208
|
-
for await (const chunk of req) body += chunk;
|
|
209
|
-
const { db: dbName, sql } = JSON.parse(body || '{}');
|
|
210
|
-
if (!dbName || !sql) return send(400, { ok: false, error: 'db and sql are required' });
|
|
211
|
-
const db = await this.db(dbName);
|
|
212
|
-
const t0 = Date.now();
|
|
213
|
-
try {
|
|
214
|
-
const res2 = await executeSQL(db, sql);
|
|
215
|
-
const ms = Date.now() - t0;
|
|
216
|
-
const out = { ok: true, ms };
|
|
217
|
-
if (res2 && res2.rows) {
|
|
218
|
-
out.columns = res2.columns;
|
|
219
|
-
out.rows = res2.rows;
|
|
220
|
-
out.rowCount = res2.rows.length;
|
|
221
|
-
} else if (res2 && res2.affectedRows !== undefined) {
|
|
222
|
-
out.affected = res2.affectedRows;
|
|
223
|
-
} else {
|
|
224
|
-
out.rowCount = 0;
|
|
225
|
-
}
|
|
226
|
-
return send(200, out);
|
|
227
|
-
} catch (e) {
|
|
228
|
-
return send(200, { ok: false, error: String(e.message || e) });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return send(404, { ok: false, error: 'not found' });
|
|
232
|
-
} catch (e) {
|
|
233
|
-
return send(500, { ok: false, error: String(e.message || e) });
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
start() {
|
|
238
|
-
return new Promise((resolve, reject) => {
|
|
239
|
-
this.server = http.createServer((req, res) => this.handle(req, res).catch(e => {
|
|
240
|
-
try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(e.message || e) })); } catch (_) {}
|
|
241
|
-
}));
|
|
242
|
-
this.server.on('error', reject);
|
|
243
|
-
this.server.listen(this.port, this.host, () => resolve(this.server.address().port));
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
stop() {
|
|
248
|
-
return new Promise(resolve => {
|
|
249
|
-
for (const db of this.cache.values()) db.stop();
|
|
250
|
-
this.cache.clear();
|
|
251
|
-
if (this.server) this.server.close(() => resolve());
|
|
252
|
-
else resolve();
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
module.exports = { WebUI };
|
|
1
|
+
/*
|
|
2
|
+
* JSQL-NEO built-in web UI: zero-dependency HTTP management console.
|
|
3
|
+
*
|
|
4
|
+
* const { WebUI } = require('jsql-neo');
|
|
5
|
+
* const ui = new WebUI({ port: 8080, dataDir: './data' });
|
|
6
|
+
* await ui.start();
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET / management console (HTML, no external deps)
|
|
10
|
+
* GET /api/databases [{name, tables, rows}]
|
|
11
|
+
* GET /api/tables?db=name {tables: [{name, count}]}
|
|
12
|
+
* POST /api/query {db, sql} {columns, rows, rowCount, ok, error?}
|
|
13
|
+
*/
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const Database = require('./database');
|
|
18
|
+
const { executeSQL } = require('./sql');
|
|
19
|
+
|
|
20
|
+
const PAGE = `<!DOCTYPE html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8">
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
+
<title>JSQL-NEO</title>
|
|
26
|
+
<style>
|
|
27
|
+
:root { color-scheme: dark; }
|
|
28
|
+
body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0d1117; color: #e6edf3; display: flex; height: 100vh; }
|
|
29
|
+
.side { width: 260px; border-right: 1px solid #21262d; padding: 12px; overflow: auto; }
|
|
30
|
+
.main { flex: 1; display: flex; flex-direction: column; }
|
|
31
|
+
h1 { font-size: 15px; margin: 4px 0 12px; }
|
|
32
|
+
h2 { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; margin: 14px 0 6px; }
|
|
33
|
+
.db { cursor: pointer; padding: 3px 6px; border-radius: 6px; }
|
|
34
|
+
.db:hover, .db.active { background: #1f6feb33; }
|
|
35
|
+
.db .rows { color: #8b949e; font-size: 12px; }
|
|
36
|
+
.tbl { cursor: pointer; padding: 2px 6px 2px 18px; color: #79c0ff; border-radius: 4px; }
|
|
37
|
+
.tbl:hover { background: #21262d; }
|
|
38
|
+
textarea { flex: 1; margin: 12px; padding: 10px; background: #010409; color: #e6edf3; border: 1px solid #21262d; border-radius: 8px; resize: none; font: inherit; }
|
|
39
|
+
.actions { padding: 0 12px; }
|
|
40
|
+
button { background: #238636; border: 0; color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
41
|
+
button:hover { background: #2ea043; }
|
|
42
|
+
button:disabled { background: #21262d; cursor: wait; }
|
|
43
|
+
.status { padding: 0 12px 10px; color: #8b949e; min-height: 20px; }
|
|
44
|
+
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
45
|
+
th, td { border: 1px solid #21262d; padding: 4px 8px; text-align: left; white-space: pre; }
|
|
46
|
+
th { background: #161b22; position: sticky; top: 0; }
|
|
47
|
+
tr:nth-child(even) td { background: #0d1117; }
|
|
48
|
+
.result { flex: 1.4; overflow: auto; margin: 0 12px 12px; border: 1px solid #21262d; border-radius: 8px; background: #010409; }
|
|
49
|
+
.err { color: #f85149; padding: 10px; }
|
|
50
|
+
.ok { color: #3fb950; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div class="side">
|
|
55
|
+
<h1>JSQL-NEO</h1>
|
|
56
|
+
<h2>Databases</h2>
|
|
57
|
+
<div id="dbs"></div>
|
|
58
|
+
<h2>Tables</h2>
|
|
59
|
+
<div id="tbls"></div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="main">
|
|
62
|
+
<textarea id="sql" spellcheck="false" placeholder="SELECT * FROM t LIMIT 100">SELECT 1</textarea>
|
|
63
|
+
<div class="actions">
|
|
64
|
+
<button id="run" onclick="run()">Run (Ctrl+Enter)</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="status" id="status"></div>
|
|
67
|
+
<div class="result" id="res"></div>
|
|
68
|
+
</div>
|
|
69
|
+
<script>
|
|
70
|
+
let dbs = [], cur = null;
|
|
71
|
+
const q = async (u, o) => { const r = await fetch(u, o); return r.json(); };
|
|
72
|
+
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
73
|
+
async function load() {
|
|
74
|
+
dbs = await q('/api/databases');
|
|
75
|
+
const el = document.getElementById('dbs');
|
|
76
|
+
el.innerHTML = dbs.map(d => '<div class="db" onclick="openDb(' + esc(d.name) + ')">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
|
|
77
|
+
if (!cur && dbs.length) openDb(dbs[0].name);
|
|
78
|
+
}
|
|
79
|
+
async function openDb(n) {
|
|
80
|
+
cur = n;
|
|
81
|
+
document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.textContent.indexOf(n) === 0));
|
|
82
|
+
const r = await q('/api/tables?db=' + encodeURIComponent(n));
|
|
83
|
+
document.getElementById('tbls').innerHTML = r.tables.map(t =>
|
|
84
|
+
'<div class="tbl" onclick="sel(' + esc(t.name) + ')">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
|
|
85
|
+
}
|
|
86
|
+
function sel(n) { document.getElementById('sql').value = 'SELECT * FROM ' + n + ' LIMIT 100'; run(); }
|
|
87
|
+
async function run() {
|
|
88
|
+
const sql = document.getElementById('sql').value;
|
|
89
|
+
if (!cur || !sql.trim()) return;
|
|
90
|
+
const btn = document.getElementById('run'); btn.disabled = true;
|
|
91
|
+
const st = document.getElementById('status'); st.innerHTML = 'running...';
|
|
92
|
+
try {
|
|
93
|
+
const r = await q('/api/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ db: cur, sql }) });
|
|
94
|
+
st.innerHTML = r.ok ? '<span class="ok">ok</span> ' + r.rowCount + ' row(s), ' + r.ms + 'ms' : '<span class="err">' + esc(r.error) + '</span>';
|
|
95
|
+
if (r.columns && r.columns.length) {
|
|
96
|
+
let h = '<table><tr>' + r.columns.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
|
|
97
|
+
h += r.rows.map(row => '<tr>' + row.map(c => '<td>' + (c === null ? '<i>NULL</i>' : esc(c)) + '</td>').join('') + '</tr>').join('');
|
|
98
|
+
document.getElementById('res').innerHTML = h + '</table>';
|
|
99
|
+
} else if (r.affected !== undefined) {
|
|
100
|
+
document.getElementById('res').innerHTML = '<p class="ok">' + r.affected + ' row(s) affected</p>';
|
|
101
|
+
}
|
|
102
|
+
} catch (e) { st.innerHTML = '<span class="err">' + esc(e.message) + '</span>'; }
|
|
103
|
+
btn.disabled = false;
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') run(); });
|
|
106
|
+
load();
|
|
107
|
+
</script>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
class WebUI {
|
|
113
|
+
constructor(opts = {}) {
|
|
114
|
+
this.port = opts.port !== undefined ? opts.port : 8080;
|
|
115
|
+
this.dataDir = opts.dataDir || '.';
|
|
116
|
+
this.readonly = !!opts.readonly;
|
|
117
|
+
this.host = opts.host || '127.0.0.1';
|
|
118
|
+
this.authToken = opts.authToken || null;
|
|
119
|
+
this.allowOrigin = opts.allowOrigin || null;
|
|
120
|
+
this.echoOrigin = !opts.allowOrigin && !!this.authToken;
|
|
121
|
+
this.cache = new Map();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dbPath(name) {
|
|
125
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new Error('invalid database name');
|
|
126
|
+
const p = path.join(this.dataDir, name + '.json');
|
|
127
|
+
if (!fs.existsSync(p)) throw new Error('database not found: ' + name);
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async db(name) {
|
|
132
|
+
if (this.cache.has(name)) return this.cache.get(name);
|
|
133
|
+
const db = new Database(this.dbPath(name), { autoSave: !this.readonly });
|
|
134
|
+
if (db.loadDatabase) await db.loadDatabase();
|
|
135
|
+
this.cache.set(name, db);
|
|
136
|
+
return db;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listDatabases() {
|
|
140
|
+
if (!fs.existsSync(this.dataDir)) return [];
|
|
141
|
+
return fs.readdirSync(this.dataDir)
|
|
142
|
+
.filter(f => f.endsWith('.json'))
|
|
143
|
+
.map(f => {
|
|
144
|
+
let tables = 0;
|
|
145
|
+
try { tables = Object.keys(JSON.parse(fs.readFileSync(path.join(this.dataDir, f), 'utf8')).__schema__ || {}).length; } catch (_) {}
|
|
146
|
+
return { name: f.slice(0, -5), tables };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
tableList(db) {
|
|
151
|
+
const map = db._tables || {};
|
|
152
|
+
return Object.values(map).map(t => ({
|
|
153
|
+
name: t._name || t.name,
|
|
154
|
+
count: (t._rows || t.rows || []).length,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async handle(req, res) {
|
|
159
|
+
const url = new URL(req.url, 'http://x');
|
|
160
|
+
const reqOrigin = req.headers.origin;
|
|
161
|
+
let corsOrigin = null;
|
|
162
|
+
if (this.allowOrigin) corsOrigin = this.allowOrigin;
|
|
163
|
+
else if (this.echoOrigin && reqOrigin) corsOrigin = reqOrigin;
|
|
164
|
+
const send = (code, obj) => {
|
|
165
|
+
const body = JSON.stringify(obj);
|
|
166
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
167
|
+
if (corsOrigin) {
|
|
168
|
+
headers['Access-Control-Allow-Origin'] = corsOrigin;
|
|
169
|
+
headers['Vary'] = 'Origin';
|
|
170
|
+
}
|
|
171
|
+
res.writeHead(code, headers);
|
|
172
|
+
res.end(body);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
177
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
178
|
+
return res.end(PAGE);
|
|
179
|
+
}
|
|
180
|
+
if (req.method === 'OPTIONS' && url.pathname.startsWith('/api/')) {
|
|
181
|
+
const h = {};
|
|
182
|
+
if (corsOrigin) {
|
|
183
|
+
h['Access-Control-Allow-Origin'] = corsOrigin;
|
|
184
|
+
h['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS';
|
|
185
|
+
h['Access-Control-Allow-Headers'] = 'Authorization,Content-Type';
|
|
186
|
+
h['Vary'] = 'Origin';
|
|
187
|
+
}
|
|
188
|
+
res.writeHead(204, h);
|
|
189
|
+
return res.end();
|
|
190
|
+
}
|
|
191
|
+
if (this.authToken && url.pathname.startsWith('/api/')) {
|
|
192
|
+
const provided = req.headers.authorization || '';
|
|
193
|
+
const token = provided.startsWith('Bearer ') ? provided.slice(7) : '';
|
|
194
|
+
if (token !== this.authToken) {
|
|
195
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
196
|
+
return res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (url.pathname === '/api/databases' && req.method === 'GET') {
|
|
200
|
+
return send(200, this.listDatabases());
|
|
201
|
+
}
|
|
202
|
+
if (url.pathname === '/api/tables' && req.method === 'GET') {
|
|
203
|
+
const db = await this.db(url.searchParams.get("db"));
|
|
204
|
+
return send(200, { tables: this.tableList(db) });
|
|
205
|
+
}
|
|
206
|
+
if (url.pathname === '/api/query' && req.method === 'POST') {
|
|
207
|
+
let body = '';
|
|
208
|
+
for await (const chunk of req) body += chunk;
|
|
209
|
+
const { db: dbName, sql } = JSON.parse(body || '{}');
|
|
210
|
+
if (!dbName || !sql) return send(400, { ok: false, error: 'db and sql are required' });
|
|
211
|
+
const db = await this.db(dbName);
|
|
212
|
+
const t0 = Date.now();
|
|
213
|
+
try {
|
|
214
|
+
const res2 = await executeSQL(db, sql);
|
|
215
|
+
const ms = Date.now() - t0;
|
|
216
|
+
const out = { ok: true, ms };
|
|
217
|
+
if (res2 && res2.rows) {
|
|
218
|
+
out.columns = res2.columns;
|
|
219
|
+
out.rows = res2.rows;
|
|
220
|
+
out.rowCount = res2.rows.length;
|
|
221
|
+
} else if (res2 && res2.affectedRows !== undefined) {
|
|
222
|
+
out.affected = res2.affectedRows;
|
|
223
|
+
} else {
|
|
224
|
+
out.rowCount = 0;
|
|
225
|
+
}
|
|
226
|
+
return send(200, out);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return send(200, { ok: false, error: String(e.message || e) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return send(404, { ok: false, error: 'not found' });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return send(500, { ok: false, error: String(e.message || e) });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
start() {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
this.server = http.createServer((req, res) => this.handle(req, res).catch(e => {
|
|
240
|
+
try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(e.message || e) })); } catch (_) {}
|
|
241
|
+
}));
|
|
242
|
+
this.server.on('error', reject);
|
|
243
|
+
this.server.listen(this.port, this.host, () => resolve(this.server.address().port));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
stop() {
|
|
248
|
+
return new Promise(resolve => {
|
|
249
|
+
for (const db of this.cache.values()) db.stop();
|
|
250
|
+
this.cache.clear();
|
|
251
|
+
if (this.server) this.server.close(() => resolve());
|
|
252
|
+
else resolve();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { WebUI };
|
|
File without changes
|