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.
Files changed (45) hide show
  1. package/README.md +151 -47
  2. package/dist/app.d.ts +12 -0
  3. package/dist/app.js +7 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/args.d.ts +28 -0
  6. package/dist/args.js +187 -0
  7. package/dist/args.js.map +1 -0
  8. package/dist/cli.js +85 -15
  9. package/dist/cli.js.map +1 -1
  10. package/dist/db/database.d.ts +58 -37
  11. package/dist/db/database.js +239 -42
  12. package/dist/db/database.js.map +1 -1
  13. package/dist/db/manager.d.ts +8 -2
  14. package/dist/db/manager.js +33 -1
  15. package/dist/db/manager.js.map +1 -1
  16. package/dist/public/css/app.css +1 -1
  17. package/dist/public/css/input.css +53 -0
  18. package/dist/public/js/browse.js +847 -86
  19. package/dist/public/js/databases.js +99 -1
  20. package/dist/routes/api.js +261 -97
  21. package/dist/routes/api.js.map +1 -1
  22. package/dist/routes/databases.d.ts +10 -0
  23. package/dist/routes/databases.js +97 -0
  24. package/dist/routes/databases.js.map +1 -1
  25. package/dist/routes/pages.js +50 -30
  26. package/dist/routes/pages.js.map +1 -1
  27. package/dist/sql/generator.d.ts +0 -22
  28. package/dist/sql/generator.js +0 -48
  29. package/dist/sql/generator.js.map +1 -1
  30. package/dist/test/database.test.js +241 -63
  31. package/dist/test/database.test.js.map +1 -1
  32. package/dist/test/generator.test.js +0 -32
  33. package/dist/test/generator.test.js.map +1 -1
  34. package/dist/util.d.ts +18 -1
  35. package/dist/util.js +37 -2
  36. package/dist/util.js.map +1 -1
  37. package/dist/views/layouts/main.hbs +1 -1
  38. package/dist/views/pages/databases.hbs +41 -0
  39. package/dist/views/pages/table.hbs +81 -28
  40. package/dist/views/partials/sidebar.hbs +0 -12
  41. package/package.json +1 -1
  42. package/dist/public/js/triggers.js +0 -155
  43. package/dist/public/js/views.js +0 -176
  44. package/dist/views/pages/triggers.hbs +0 -162
  45. package/dist/views/pages/views.hbs +0 -134
@@ -1,4 +1,5 @@
1
- /* Databases landing page: create and delete SQLite database files. */
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
+
@@ -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') || p.endsWith('/api/query/export') || p.endsWith('/api/query'))
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[pk],
97
- label: labelCols.length ? labelCols.map((c) => String(row[c] ?? '')).join(' · ') : String(row[pk] ?? ''),
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 fields = buildFields(info, values, { excludePk: true });
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 fields = buildFields(info, values, { excludePk: true });
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);