admindb 1.0.3 → 1.2.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/README.md +151 -47
- package/dist/app.d.ts +12 -0
- package/dist/app.js +7 -0
- package/dist/app.js.map +1 -1
- package/dist/args.d.ts +28 -0
- package/dist/args.js +187 -0
- package/dist/args.js.map +1 -0
- package/dist/cli.js +85 -15
- package/dist/cli.js.map +1 -1
- package/dist/db/database.d.ts +58 -37
- package/dist/db/database.js +239 -42
- package/dist/db/database.js.map +1 -1
- package/dist/db/manager.d.ts +8 -2
- package/dist/db/manager.js +33 -1
- package/dist/db/manager.js.map +1 -1
- package/dist/public/css/app.css +1 -1
- package/dist/public/css/input.css +53 -0
- package/dist/public/js/browse.js +847 -86
- package/dist/public/js/databases.js +99 -1
- package/dist/routes/api.js +261 -97
- package/dist/routes/api.js.map +1 -1
- package/dist/routes/databases.d.ts +10 -0
- package/dist/routes/databases.js +97 -0
- package/dist/routes/databases.js.map +1 -1
- package/dist/routes/pages.js +50 -30
- package/dist/routes/pages.js.map +1 -1
- package/dist/sql/generator.d.ts +0 -22
- package/dist/sql/generator.js +0 -48
- package/dist/sql/generator.js.map +1 -1
- package/dist/test/database.test.js +241 -63
- package/dist/test/database.test.js.map +1 -1
- package/dist/test/generator.test.js +0 -32
- package/dist/test/generator.test.js.map +1 -1
- package/dist/util.d.ts +18 -1
- package/dist/util.js +37 -2
- package/dist/util.js.map +1 -1
- package/dist/views/layouts/main.hbs +1 -1
- package/dist/views/pages/databases.hbs +41 -0
- package/dist/views/pages/table.hbs +81 -28
- package/dist/views/partials/sidebar.hbs +0 -12
- package/package.json +1 -1
- package/dist/public/js/triggers.js +0 -155
- package/dist/public/js/views.js +0 -176
- package/dist/views/pages/triggers.hbs +0 -162
- package/dist/views/pages/views.hbs +0 -134
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/* Databases landing page: create
|
|
1
|
+
/* Databases landing page: create/delete database files, and a filesystem
|
|
2
|
+
* browser for opening existing SQLite files. */
|
|
2
3
|
(function () {
|
|
3
4
|
'use strict';
|
|
4
5
|
const nameInput = document.getElementById('new-db-name');
|
|
@@ -8,6 +9,18 @@
|
|
|
8
9
|
return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function fmtSize(n) {
|
|
13
|
+
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
14
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
15
|
+
let v = n;
|
|
16
|
+
let u = 0;
|
|
17
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
18
|
+
v /= 1024;
|
|
19
|
+
u += 1;
|
|
20
|
+
}
|
|
21
|
+
return (u === 0 ? Math.round(v) : v.toFixed(1)) + ' ' + units[u];
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
async function createDb() {
|
|
12
25
|
const name = (nameInput.value || '').trim();
|
|
13
26
|
if (!name) return UI.showError('Enter a database name.');
|
|
@@ -42,4 +55,89 @@
|
|
|
42
55
|
.catch((e) => UI.showError(e.message));
|
|
43
56
|
});
|
|
44
57
|
});
|
|
58
|
+
|
|
59
|
+
// ---- Filesystem browser --------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const fsPathInput = document.getElementById('fs-path');
|
|
62
|
+
const fsGoBtn = document.getElementById('fs-go');
|
|
63
|
+
const fsUpBtn = document.getElementById('fs-up');
|
|
64
|
+
const fsCurrent = document.getElementById('fs-current');
|
|
65
|
+
const fsListing = document.getElementById('fs-listing');
|
|
66
|
+
|
|
67
|
+
const FOLDER_ICON =
|
|
68
|
+
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /></svg>';
|
|
69
|
+
const DB_ICON =
|
|
70
|
+
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 1.1.9 2 2 2h12a2 2 0 002-2V7M4 7c0-1.1.9-2 2-2h12a2 2 0 012 2M4 7c0 1.1.9 2 2 2h12a2 2 0 002-2" /></svg>';
|
|
71
|
+
|
|
72
|
+
let fsState = { path: '', parent: null };
|
|
73
|
+
|
|
74
|
+
function renderFs(data) {
|
|
75
|
+
fsState = { path: data.path, parent: data.parent };
|
|
76
|
+
if (fsCurrent) fsCurrent.textContent = data.path;
|
|
77
|
+
if (fsPathInput) fsPathInput.value = data.path;
|
|
78
|
+
if (fsUpBtn) fsUpBtn.disabled = !data.parent;
|
|
79
|
+
if (!fsListing) return;
|
|
80
|
+
fsListing.innerHTML = '';
|
|
81
|
+
if (!data.entries.length) {
|
|
82
|
+
fsListing.innerHTML = '<div class="px-4 py-6 text-center text-sm text-base-content/40">No folders or database files here.</div>';
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
data.entries.forEach((e) => {
|
|
86
|
+
const row = document.createElement('button');
|
|
87
|
+
row.type = 'button';
|
|
88
|
+
row.className = 'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-primary/[0.06]';
|
|
89
|
+
row.title = e.path;
|
|
90
|
+
if (e.isDir) {
|
|
91
|
+
row.innerHTML =
|
|
92
|
+
'<span class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-base-200 text-base-content/50">' + FOLDER_ICON + '</span>' +
|
|
93
|
+
'<span class="min-w-0 flex-1 truncate font-medium text-base-content/80">' + escapeHtml(e.name) + '</span>' +
|
|
94
|
+
'<span class="text-xs text-base-content/35">folder</span>';
|
|
95
|
+
row.addEventListener('click', () => loadFs(e.path));
|
|
96
|
+
} else {
|
|
97
|
+
row.innerHTML =
|
|
98
|
+
'<span class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-primary/10 text-primary">' + DB_ICON + '</span>' +
|
|
99
|
+
'<span class="min-w-0 flex-1 truncate font-mono text-[13px] text-base-content/85">' + escapeHtml(e.name) + '</span>' +
|
|
100
|
+
'<span class="text-xs text-base-content/40">' + fmtSize(e.size) + '</span>';
|
|
101
|
+
row.addEventListener('click', () => openDb(e.path));
|
|
102
|
+
}
|
|
103
|
+
fsListing.appendChild(row);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function loadFs(p) {
|
|
108
|
+
try {
|
|
109
|
+
const data = await Api.get('/api/fs/list' + (p ? '?path=' + encodeURIComponent(p) : ''));
|
|
110
|
+
renderFs(data);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
UI.showError(e.message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function goUp() {
|
|
117
|
+
if (fsState.parent) loadFs(fsState.parent);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function openDb(p) {
|
|
121
|
+
try {
|
|
122
|
+
const data = await Api.post('/api/databases/open', { path: p });
|
|
123
|
+
UI.showToast(data.message || 'Database opened.', 'success');
|
|
124
|
+
window.location.href = Api.basePath + '/' + encodeURIComponent(data.id);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
UI.showError(e.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (fsListing) {
|
|
131
|
+
loadFs('');
|
|
132
|
+
if (fsGoBtn) {
|
|
133
|
+
fsGoBtn.addEventListener('click', () => loadFs((fsPathInput.value || '').trim() || ''));
|
|
134
|
+
}
|
|
135
|
+
if (fsPathInput) {
|
|
136
|
+
fsPathInput.addEventListener('keydown', (e) => {
|
|
137
|
+
if (e.key === 'Enter') loadFs((fsPathInput.value || '').trim() || '');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (fsUpBtn) fsUpBtn.addEventListener('click', goUp);
|
|
141
|
+
}
|
|
45
142
|
})();
|
|
143
|
+
|
package/dist/routes/api.js
CHANGED
|
@@ -43,6 +43,23 @@ function buildFields(info, values, opts = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
return fields;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the SET fields for an UPDATE from form values plus an explicit list of
|
|
48
|
+
* columns to null out (the "clear to NULL" affordance of inline editing).
|
|
49
|
+
*/
|
|
50
|
+
function buildUpdateFields(info, values, nulls) {
|
|
51
|
+
const fields = buildFields(info, values, { excludePk: true });
|
|
52
|
+
const typeMap = new Map(info.columns.map((c) => [c.name, c.type]));
|
|
53
|
+
const pkSet = new Set(info.primaryKey);
|
|
54
|
+
for (const col of nulls) {
|
|
55
|
+
if (pkSet.has(col) || !typeMap.has(col))
|
|
56
|
+
continue;
|
|
57
|
+
if (fields.some((f) => f.column === col))
|
|
58
|
+
continue;
|
|
59
|
+
fields.push({ column: col, value: null });
|
|
60
|
+
}
|
|
61
|
+
return fields;
|
|
62
|
+
}
|
|
46
63
|
function pkWhere(info, encodedId) {
|
|
47
64
|
if (!info.primaryKey.length)
|
|
48
65
|
return null;
|
|
@@ -52,6 +69,63 @@ function pkWhere(info, encodedId) {
|
|
|
52
69
|
const typeMap = new Map(info.columns.map((c) => [c.name, c.type]));
|
|
53
70
|
return info.primaryKey.map((col, i) => ({ column: col, value: (0, util_1.coerceFormValue)(vals[i], typeMap.get(col)) }));
|
|
54
71
|
}
|
|
72
|
+
/** Hard cap on how many rows can be selected for a bulk operation at once. */
|
|
73
|
+
const MAX_BULK_ROWS = 1000;
|
|
74
|
+
/**
|
|
75
|
+
* Turn a list of encoded primary-key ids (from the grid's row checkboxes) into
|
|
76
|
+
* the WHERE clauses needed to address each row. Returns null when the table has
|
|
77
|
+
* no usable primary key or when none of the ids resolve.
|
|
78
|
+
*/
|
|
79
|
+
function resolvePkRows(info, ids) {
|
|
80
|
+
if (!info.primaryKey.length)
|
|
81
|
+
return null;
|
|
82
|
+
const list = Array.isArray(ids) ? ids : ids == null ? [] : [ids];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const id of list) {
|
|
85
|
+
const where = pkWhere(info, String(id));
|
|
86
|
+
if (where)
|
|
87
|
+
out.push(where);
|
|
88
|
+
}
|
|
89
|
+
return out.length ? out : null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How many rows in OTHER tables reference any of the selected rows (via foreign
|
|
93
|
+
* keys pointing at the selected table). Used to warn before a bulk delete —
|
|
94
|
+
* those rows may be cascaded away or orphaned depending on the FK action.
|
|
95
|
+
*/
|
|
96
|
+
async function computeBulkImpact(db, info, wheres) {
|
|
97
|
+
const rowsR = await db.getRowsByPks(info.table, wheres);
|
|
98
|
+
const rows = (rowsR.data ?? []);
|
|
99
|
+
const refsR = await db.getReferencingTables(info.table);
|
|
100
|
+
const references = [];
|
|
101
|
+
const toColumn = (to) => to || info.primaryKey[0] || info.columns[0]?.name || null;
|
|
102
|
+
for (const rt of refsR.data ?? []) {
|
|
103
|
+
if (rt.table.startsWith('_'))
|
|
104
|
+
continue;
|
|
105
|
+
for (const ref of rt.refs) {
|
|
106
|
+
const col = toColumn(ref.to);
|
|
107
|
+
if (!col)
|
|
108
|
+
continue;
|
|
109
|
+
// Deduplicate the referenced values (preserving their native type).
|
|
110
|
+
const byStr = new Map();
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const v = row[col];
|
|
113
|
+
if (v === null || v === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
byStr.set(String((0, util_1.normalizeCell)(v)), v);
|
|
116
|
+
}
|
|
117
|
+
if (!byStr.size)
|
|
118
|
+
continue;
|
|
119
|
+
const values = [...byStr.values()];
|
|
120
|
+
const placeholders = values.map(() => '?').join(', ');
|
|
121
|
+
const cR = await db.all(`SELECT COUNT(*) AS c FROM ${(0, generator_1.quoteIdentifier)(rt.table)} WHERE ${(0, generator_1.quoteIdentifier)(ref.from)} IN (${placeholders})`, values);
|
|
122
|
+
const count = Number((cR.data ?? [])[0]?.c ?? 0);
|
|
123
|
+
if (count > 0)
|
|
124
|
+
references.push({ table: rt.table, from: ref.from, to: ref.to, count });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { references, total: references.reduce((n, r) => n + r.count, 0) };
|
|
128
|
+
}
|
|
55
129
|
function registerApi(router, ctx) {
|
|
56
130
|
const { db, logger } = ctx;
|
|
57
131
|
// In read-only mode every mutating request is rejected (GET, the generate-only
|
|
@@ -61,8 +135,13 @@ function registerApi(router, ctx) {
|
|
|
61
135
|
if (!db.isReadOnly || req.method === 'GET')
|
|
62
136
|
return next();
|
|
63
137
|
const p = req.path;
|
|
64
|
-
if (p.endsWith('/generate') ||
|
|
138
|
+
if (p.endsWith('/generate') ||
|
|
139
|
+
p.endsWith('/api/query/export') ||
|
|
140
|
+
p.endsWith('/api/query') ||
|
|
141
|
+
p.endsWith('/rows/bulk-impact') ||
|
|
142
|
+
p.endsWith('/rows/bulk-export')) {
|
|
65
143
|
return next();
|
|
144
|
+
}
|
|
66
145
|
return res.status(403).json({ success: false, error: 'Database is open in read-only mode — write operations are disabled.' });
|
|
67
146
|
});
|
|
68
147
|
// ---- Tables ------------------------------------------------------------
|
|
@@ -88,13 +167,17 @@ function registerApi(router, ctx) {
|
|
|
88
167
|
const refInfo = await requireTable(db, fk.table);
|
|
89
168
|
if (!refInfo)
|
|
90
169
|
continue;
|
|
170
|
+
// The FK may reference a non-PK unique column (e.g. Users.UserName),
|
|
171
|
+
// so the option value must be the referenced column's value, not the
|
|
172
|
+
// referenced table's primary key.
|
|
173
|
+
const refCol = fk.to || refInfo.primaryKey[0] || refInfo.columns[0]?.name;
|
|
91
174
|
const pk = refInfo.primaryKey[0] ?? refInfo.columns[0]?.name;
|
|
92
175
|
const labelCols = refInfo.columns.filter((c) => c.name !== pk).slice(0, 2).map((c) => c.name);
|
|
93
176
|
const rowsR = await db.getAllRows(fk.table);
|
|
94
177
|
const rows = (rowsR.data ?? []).slice(0, 500);
|
|
95
178
|
options[fk.from] = rows.map((row) => ({
|
|
96
|
-
value: row[
|
|
97
|
-
label: labelCols.length ? labelCols.map((c) => String(row[c] ?? '')).join(' · ') : String(row[
|
|
179
|
+
value: row[refCol],
|
|
180
|
+
label: labelCols.length ? labelCols.map((c) => String(row[c] ?? '')).join(' · ') : String(row[refCol] ?? ''),
|
|
98
181
|
}));
|
|
99
182
|
}
|
|
100
183
|
catch (err) {
|
|
@@ -124,6 +207,52 @@ function registerApi(router, ctx) {
|
|
|
124
207
|
filters,
|
|
125
208
|
});
|
|
126
209
|
}));
|
|
210
|
+
// Related rows: rows in other tables whose foreign keys point at this row.
|
|
211
|
+
router.get('/api/tables/:table/rows/:id/references', wrap(async (req, res) => {
|
|
212
|
+
const info = await requireTable(db, req.params.table);
|
|
213
|
+
if (!info)
|
|
214
|
+
return fail(res, `Table "${req.params.table}" does not exist.`, 404);
|
|
215
|
+
if (!info.primaryKey.length)
|
|
216
|
+
return fail(res, `Table "${req.params.table}" has no primary key, so related rows cannot be resolved.`, 400);
|
|
217
|
+
const pk = (0, util_1.decodePk)(req.params.id);
|
|
218
|
+
if (pk.length !== info.primaryKey.length)
|
|
219
|
+
return fail(res, 'Invalid row id.', 400);
|
|
220
|
+
const typeMap = new Map(info.columns.map((c) => [c.name, c.type]));
|
|
221
|
+
const where = info.primaryKey.map((col, i) => ({ column: col, value: (0, util_1.coerceFormValue)(pk[i], typeMap.get(col)) }));
|
|
222
|
+
const rowR = await db.getRow(req.params.table, where);
|
|
223
|
+
if (!rowR.success || !rowR.data)
|
|
224
|
+
return fail(res, 'Row not found.', 404);
|
|
225
|
+
const row = rowR.data;
|
|
226
|
+
const refsR = await db.getReferencingTables(req.params.table);
|
|
227
|
+
if (!refsR.success)
|
|
228
|
+
return fail(res, refsR.error ?? 'Failed to load references.', 500);
|
|
229
|
+
const references = [];
|
|
230
|
+
for (const rt of refsR.data ?? []) {
|
|
231
|
+
if (rt.table.startsWith('_'))
|
|
232
|
+
continue;
|
|
233
|
+
const refInfo = await requireTable(db, rt.table);
|
|
234
|
+
if (!refInfo)
|
|
235
|
+
continue;
|
|
236
|
+
for (const ref of rt.refs) {
|
|
237
|
+
const value = row[ref.to];
|
|
238
|
+
if (value === null || value === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
const r = await db.getRowsByFk(rt.table, ref.from, value, 50);
|
|
241
|
+
if (!r.success)
|
|
242
|
+
continue;
|
|
243
|
+
references.push({
|
|
244
|
+
table: rt.table,
|
|
245
|
+
from: ref.from,
|
|
246
|
+
to: ref.to,
|
|
247
|
+
value: (0, util_1.normalizeCell)(value),
|
|
248
|
+
count: r.data.total,
|
|
249
|
+
columns: refInfo.columns.map((c) => c.name),
|
|
250
|
+
rows: r.data.rows.map(util_1.normalizeRow),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
ok(res, { references });
|
|
255
|
+
}));
|
|
127
256
|
// ---- Export / import ---------------------------------------------------
|
|
128
257
|
router.get('/api/tables/:table/export', wrap(async (req, res) => {
|
|
129
258
|
const info = await requireTable(db, req.params.table);
|
|
@@ -247,7 +376,8 @@ function registerApi(router, ctx) {
|
|
|
247
376
|
if (!where)
|
|
248
377
|
return fail(res, 'Invalid primary key.', 400);
|
|
249
378
|
const values = (req.body?.values ?? {});
|
|
250
|
-
const
|
|
379
|
+
const nulls = Array.isArray(req.body?.nulls) ? req.body.nulls.map(String) : [];
|
|
380
|
+
const fields = buildUpdateFields(info, values, nulls);
|
|
251
381
|
if (!fields.length)
|
|
252
382
|
return fail(res, 'No fields to update.');
|
|
253
383
|
const sql = (0, generator_1.generateUpdate)(req.params.table, fields, where);
|
|
@@ -264,7 +394,8 @@ function registerApi(router, ctx) {
|
|
|
264
394
|
if (!where)
|
|
265
395
|
return fail(res, 'Invalid primary key.', 400);
|
|
266
396
|
const values = (req.body?.values ?? {});
|
|
267
|
-
const
|
|
397
|
+
const nulls = Array.isArray(req.body?.nulls) ? req.body.nulls.map(String) : [];
|
|
398
|
+
const fields = buildUpdateFields(info, values, nulls);
|
|
268
399
|
if (!fields.length)
|
|
269
400
|
return fail(res, 'No fields to update.');
|
|
270
401
|
ok(res, { sql: (0, generator_1.generateUpdate)(req.params.table, fields, where) });
|
|
@@ -281,6 +412,119 @@ function registerApi(router, ctx) {
|
|
|
281
412
|
return fail(res, r.error ?? 'Delete failed.', 400);
|
|
282
413
|
ok(res, { message: 'Row deleted.' });
|
|
283
414
|
}));
|
|
415
|
+
// ---- Bulk row operations -----------------------------------------------
|
|
416
|
+
/**
|
|
417
|
+
* FK-impact preview: how many rows in other tables reference the selected
|
|
418
|
+
* rows. Shown to the user before a bulk delete so cascades/orphaning are
|
|
419
|
+
* understood up front.
|
|
420
|
+
*/
|
|
421
|
+
router.post('/api/tables/:table/rows/bulk-impact', wrap(async (req, res) => {
|
|
422
|
+
const info = await requireTable(db, req.params.table);
|
|
423
|
+
if (!info)
|
|
424
|
+
return fail(res, `Table "${req.params.table}" does not exist.`, 404);
|
|
425
|
+
const wheres = resolvePkRows(info, (req.body ?? {}).ids);
|
|
426
|
+
if (!wheres || !wheres.length)
|
|
427
|
+
return fail(res, 'No valid rows selected.', 400);
|
|
428
|
+
if (wheres.length > MAX_BULK_ROWS)
|
|
429
|
+
return fail(res, `Select at most ${MAX_BULK_ROWS} rows at a time.`, 400);
|
|
430
|
+
const impact = await computeBulkImpact(db, info, wheres);
|
|
431
|
+
ok(res, { selected: wheres.length, references: impact.references, total: impact.total });
|
|
432
|
+
}));
|
|
433
|
+
/**
|
|
434
|
+
* Bulk delete the selected rows inside a single transaction. When other
|
|
435
|
+
* tables reference them, the client must acknowledge the impact
|
|
436
|
+
* (`confirmImpact: true`) — this is the safety net behind the UI warning.
|
|
437
|
+
*/
|
|
438
|
+
router.post('/api/tables/:table/rows/bulk-delete', wrap(async (req, res) => {
|
|
439
|
+
const info = await requireTable(db, req.params.table);
|
|
440
|
+
if (!info)
|
|
441
|
+
return fail(res, `Table "${req.params.table}" does not exist.`, 404);
|
|
442
|
+
const body = (req.body ?? {});
|
|
443
|
+
const wheres = resolvePkRows(info, body.ids);
|
|
444
|
+
if (!wheres || !wheres.length)
|
|
445
|
+
return fail(res, 'No valid rows selected.', 400);
|
|
446
|
+
if (wheres.length > MAX_BULK_ROWS)
|
|
447
|
+
return fail(res, `Select at most ${MAX_BULK_ROWS} rows at a time.`, 400);
|
|
448
|
+
const impact = await computeBulkImpact(db, info, wheres);
|
|
449
|
+
if (impact.total > 0 && body.confirmImpact !== true) {
|
|
450
|
+
return res.status(409).json({
|
|
451
|
+
success: false,
|
|
452
|
+
error: 'These rows are referenced by other tables. Confirm the impact before deleting.',
|
|
453
|
+
data: { references: impact.references, total: impact.total },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const r = await db.deleteRows(req.params.table, wheres);
|
|
457
|
+
if (!r.success)
|
|
458
|
+
return fail(res, r.error ?? 'Bulk delete failed.', 400);
|
|
459
|
+
ok(res, { message: `Deleted ${r.data?.deleted ?? 0} row(s).`, deleted: r.data?.deleted ?? 0 });
|
|
460
|
+
}));
|
|
461
|
+
/**
|
|
462
|
+
* Export only the selected rows (CSV or JSON). The content is returned in the
|
|
463
|
+
* JSON envelope so the client can trigger a download without a full page reload.
|
|
464
|
+
*/
|
|
465
|
+
router.post('/api/tables/:table/rows/bulk-export', wrap(async (req, res) => {
|
|
466
|
+
const info = await requireTable(db, req.params.table);
|
|
467
|
+
if (!info)
|
|
468
|
+
return fail(res, `Table "${req.params.table}" does not exist.`, 404);
|
|
469
|
+
const body = (req.body ?? {});
|
|
470
|
+
const wheres = resolvePkRows(info, body.ids);
|
|
471
|
+
if (!wheres || !wheres.length)
|
|
472
|
+
return fail(res, 'No valid rows selected.', 400);
|
|
473
|
+
if (wheres.length > MAX_BULK_ROWS)
|
|
474
|
+
return fail(res, `Select at most ${MAX_BULK_ROWS} rows at a time.`, 400);
|
|
475
|
+
const format = String(body.format ?? 'csv').toLowerCase() === 'json' ? 'json' : 'csv';
|
|
476
|
+
const rowsR = await db.getRowsByPks(req.params.table, wheres);
|
|
477
|
+
if (!rowsR.success)
|
|
478
|
+
return fail(res, rowsR.error ?? 'Export failed.', 500);
|
|
479
|
+
const rows = (rowsR.data ?? []).map(util_1.normalizeRow);
|
|
480
|
+
const columns = info.columns.map((c) => c.name);
|
|
481
|
+
const content = format === 'json' ? (0, csv_1.toJson)(rows) : (0, csv_1.toCsv)(rows, columns);
|
|
482
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
483
|
+
ok(res, {
|
|
484
|
+
format,
|
|
485
|
+
filename: `${req.params.table}-selected-${stamp}.${format}`,
|
|
486
|
+
content,
|
|
487
|
+
rowCount: rows.length,
|
|
488
|
+
});
|
|
489
|
+
}));
|
|
490
|
+
/**
|
|
491
|
+
* Apply staged inline-grid edits: update many cells across many rows inside a
|
|
492
|
+
* single transaction. Each entry is `{ id, values?, nulls? }` where `id` is
|
|
493
|
+
* the encoded primary key. This is the "Apply" step of the Neon-style
|
|
494
|
+
* spreadsheet editing flow (edits are buffered client-side until this runs).
|
|
495
|
+
*/
|
|
496
|
+
router.post('/api/tables/:table/rows/bulk-update', wrap(async (req, res) => {
|
|
497
|
+
const info = await requireTable(db, req.params.table);
|
|
498
|
+
if (!info)
|
|
499
|
+
return fail(res, `Table "${req.params.table}" does not exist.`, 404);
|
|
500
|
+
if (!info.primaryKey.length) {
|
|
501
|
+
return fail(res, `Table "${req.params.table}" has no primary key, so inline edits cannot be applied.`, 400);
|
|
502
|
+
}
|
|
503
|
+
const updates = Array.isArray(req.body?.updates) ? req.body.updates : [];
|
|
504
|
+
if (!updates.length)
|
|
505
|
+
return fail(res, 'No changes to apply.', 400);
|
|
506
|
+
if (updates.length > MAX_BULK_ROWS)
|
|
507
|
+
return fail(res, `Select at most ${MAX_BULK_ROWS} rows at a time.`, 400);
|
|
508
|
+
const rows = [];
|
|
509
|
+
for (const u of updates) {
|
|
510
|
+
const entry = (u ?? {});
|
|
511
|
+
const where = pkWhere(info, String(entry.id ?? ''));
|
|
512
|
+
if (!where)
|
|
513
|
+
continue;
|
|
514
|
+
const fields = buildUpdateFields(info, (entry.values ?? {}), Array.isArray(entry.nulls) ? entry.nulls.map(String) : []);
|
|
515
|
+
if (fields.length)
|
|
516
|
+
rows.push({ fields, where });
|
|
517
|
+
}
|
|
518
|
+
if (!rows.length)
|
|
519
|
+
return fail(res, 'No fields to update.', 400);
|
|
520
|
+
const r = await db.updateRows(req.params.table, rows);
|
|
521
|
+
if (!r.success)
|
|
522
|
+
return fail(res, r.error ?? 'Update failed.', 400);
|
|
523
|
+
ok(res, {
|
|
524
|
+
message: `Applied ${r.data?.updated ?? 0} row change(s).`,
|
|
525
|
+
updated: r.data?.updated ?? 0,
|
|
526
|
+
});
|
|
527
|
+
}));
|
|
284
528
|
// ---- Create table (run + generate) ------------------------------------
|
|
285
529
|
router.post('/api/tables', wrap(async (req, res) => {
|
|
286
530
|
const { name, columns } = req.body ?? {};
|
|
@@ -462,103 +706,23 @@ function registerApi(router, ctx) {
|
|
|
462
706
|
return fail(res, r.error ?? 'Failed to drop index.', 400);
|
|
463
707
|
ok(res, { message: `Index "${req.params.index}" dropped.`, sql });
|
|
464
708
|
}));
|
|
465
|
-
// ---- Views & triggers --------------------------------------------------
|
|
466
|
-
router.get('/api/views', wrap(async (_req, res) => {
|
|
467
|
-
const r = await db.listViews();
|
|
468
|
-
if (!r.success)
|
|
469
|
-
return fail(res, r.error ?? 'Failed to list views.', 500);
|
|
470
|
-
ok(res, r.data);
|
|
471
|
-
}));
|
|
472
|
-
router.get('/api/views/:name/rows', wrap(async (req, res) => {
|
|
473
|
-
const r = await db.getViewRows(req.params.name);
|
|
474
|
-
if (!r.success)
|
|
475
|
-
return fail(res, r.error ?? 'Failed to read view.', 400);
|
|
476
|
-
ok(res, { rows: (r.data ?? []).map(util_1.normalizeRow) });
|
|
477
|
-
}));
|
|
478
|
-
router.post('/api/views/generate', wrap(async (req, res) => {
|
|
479
|
-
const name = String(req.body?.name ?? '').trim();
|
|
480
|
-
const sql = String(req.body?.sql ?? '').trim();
|
|
481
|
-
try {
|
|
482
|
-
ok(res, { sql: (0, generator_1.generateCreateView)(name, sql) });
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
fail(res, (0, util_1.errorMessage)(err));
|
|
486
|
-
}
|
|
487
|
-
}));
|
|
488
|
-
router.post('/api/views', wrap(async (req, res) => {
|
|
489
|
-
const name = String(req.body?.name ?? '').trim();
|
|
490
|
-
const sql = String(req.body?.sql ?? '').trim();
|
|
491
|
-
let genSql;
|
|
492
|
-
try {
|
|
493
|
-
genSql = (0, generator_1.generateCreateView)(name, sql);
|
|
494
|
-
}
|
|
495
|
-
catch (err) {
|
|
496
|
-
return fail(res, (0, util_1.errorMessage)(err));
|
|
497
|
-
}
|
|
498
|
-
const r = await db.createView(name, sql);
|
|
499
|
-
if (!r.success)
|
|
500
|
-
return fail(res, r.error ?? 'Failed to create view.', 400);
|
|
501
|
-
ok(res, { message: `View "${name}" created.`, sql: genSql }, 201);
|
|
502
|
-
}));
|
|
503
|
-
router.delete('/api/views/:name', wrap(async (req, res) => {
|
|
504
|
-
let genSql;
|
|
505
|
-
try {
|
|
506
|
-
genSql = (0, generator_1.generateDropView)(req.params.name);
|
|
507
|
-
}
|
|
508
|
-
catch (err) {
|
|
509
|
-
return fail(res, (0, util_1.errorMessage)(err));
|
|
510
|
-
}
|
|
511
|
-
const r = await db.dropView(req.params.name);
|
|
512
|
-
if (!r.success)
|
|
513
|
-
return fail(res, r.error ?? 'Failed to drop view.', 400);
|
|
514
|
-
ok(res, { message: `View "${req.params.name}" dropped.`, sql: genSql });
|
|
515
|
-
}));
|
|
516
|
-
router.get('/api/triggers', wrap(async (_req, res) => {
|
|
517
|
-
const r = await db.listTriggers();
|
|
518
|
-
if (!r.success)
|
|
519
|
-
return fail(res, r.error ?? 'Failed to list triggers.', 500);
|
|
520
|
-
ok(res, r.data);
|
|
521
|
-
}));
|
|
522
|
-
router.post('/api/triggers/generate', wrap(async (req, res) => {
|
|
523
|
-
try {
|
|
524
|
-
ok(res, { sql: (0, generator_1.generateCreateTrigger)((req.body ?? {})) });
|
|
525
|
-
}
|
|
526
|
-
catch (err) {
|
|
527
|
-
fail(res, (0, util_1.errorMessage)(err));
|
|
528
|
-
}
|
|
529
|
-
}));
|
|
530
|
-
router.post('/api/triggers', wrap(async (req, res) => {
|
|
531
|
-
const def = (req.body ?? {});
|
|
532
|
-
let genSql;
|
|
533
|
-
try {
|
|
534
|
-
genSql = (0, generator_1.generateCreateTrigger)(def);
|
|
535
|
-
}
|
|
536
|
-
catch (err) {
|
|
537
|
-
return fail(res, (0, util_1.errorMessage)(err));
|
|
538
|
-
}
|
|
539
|
-
const r = await db.createTrigger(def);
|
|
540
|
-
if (!r.success)
|
|
541
|
-
return fail(res, r.error ?? 'Failed to create trigger.', 400);
|
|
542
|
-
ok(res, { message: `Trigger "${def.name}" created.`, sql: genSql }, 201);
|
|
543
|
-
}));
|
|
544
|
-
router.delete('/api/triggers/:name', wrap(async (req, res) => {
|
|
545
|
-
let genSql;
|
|
546
|
-
try {
|
|
547
|
-
genSql = (0, generator_1.generateDropTrigger)(req.params.name);
|
|
548
|
-
}
|
|
549
|
-
catch (err) {
|
|
550
|
-
return fail(res, (0, util_1.errorMessage)(err));
|
|
551
|
-
}
|
|
552
|
-
const r = await db.dropTrigger(req.params.name);
|
|
553
|
-
if (!r.success)
|
|
554
|
-
return fail(res, r.error ?? 'Failed to drop trigger.', 400);
|
|
555
|
-
ok(res, { message: `Trigger "${req.params.name}" dropped.`, sql: genSql });
|
|
556
|
-
}));
|
|
557
709
|
// ---- Query runner ------------------------------------------------------
|
|
558
710
|
router.post('/api/query', wrap(async (req, res) => {
|
|
559
711
|
const sql = String(req.body?.sql ?? '').trim();
|
|
560
712
|
if (!sql)
|
|
561
713
|
return fail(res, 'SQL is required.');
|
|
714
|
+
// Scripts (more than one statement, e.g. a schema/migration script) cannot
|
|
715
|
+
// be represented as a single prepared statement, so run them via `exec`.
|
|
716
|
+
if (db.hasMultipleStatements(sql)) {
|
|
717
|
+
if (db.isReadOnly) {
|
|
718
|
+
return fail(res, 'Database is open in read-only mode — write statements are disabled.', 403);
|
|
719
|
+
}
|
|
720
|
+
const r = await db.execResult(sql);
|
|
721
|
+
if (!r.success)
|
|
722
|
+
return fail(res, r.error ?? 'Query failed.', 400);
|
|
723
|
+
ok(res, { kind: 'write', changes: null, message: 'Statement(s) executed successfully.' });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
562
726
|
const { kind } = (0, classifier_1.classifySql)(sql);
|
|
563
727
|
if (kind === 'count') {
|
|
564
728
|
const r = await db.all(sql);
|