admindb 1.0.3 → 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/README.md CHANGED
@@ -9,10 +9,11 @@
9
9
 
10
10
 
11
11
  A browser-based SQLite database administration tool. Manage a SQLite database
12
- entirely from the browser: browse tables, full CRUD, a visual table designer, an
13
- arbitrary SQL query runner, saved named queries, SQL-dump export, a
14
- "generate SQL" preview mode that never executes, a schema editor, and
15
- multi-database support.
12
+ entirely from the browser: browse tables with full CRUD, inline
13
+ spreadsheet-style grid editing, type-aware filters, bulk row operations, a
14
+ visual table designer, an arbitrary SQL query runner, saved named queries,
15
+ SQL-dump export, a "generate SQL" preview mode that never executes, a schema
16
+ editor, and multi-database support.
16
17
 
17
18
  - **Backend:** Node.js + TypeScript (compiled to plain JS) on Express.
18
19
  - **Frontend:** Handlebars server-rendered templates, Tailwind CSS + DaisyUI,
@@ -132,10 +133,18 @@ In multi-db mode:
132
133
  ## Features
133
134
 
134
135
  - Browse every table and its rows (paginated, with primary-key awareness).
135
- - **Filter rows by column** — exact (`=value`), comparison (`>5`, `<=10`), prefix
136
- (`pre*`) or substring (plain text) matching; filters survive sorting and
137
- pagination.
138
- - Insert, edit, and delete rows (full CRUD). FK columns become dropdowns.
136
+ - **Filter rows by column** — type-aware, per-column filter controls: foreign-key
137
+ dropdowns, boolean toggles, date and numeric range inputs, plus the exact
138
+ (`=value`), comparison (`>5`, `<=10`), prefix (`pre*`) and substring (plain
139
+ text) operators on text columns. Filters survive sorting and pagination.
140
+ - **Inline (spreadsheet-style) editing** — double-click any cell to edit it in
141
+ place with a type-aware control (FK dropdown, boolean toggle, date picker,
142
+ number/text input). Changes are staged and highlighted in the grid, then
143
+ applied all at once in a single transaction, or discarded.
144
+ - Insert and delete rows (full CRUD). FK columns become dropdowns.
145
+ - **Bulk row operations** — select rows with checkboxes (or "select all"), then
146
+ **delete** them in one transaction (with a warning listing how many rows in
147
+ other tables reference them) or **export** only the selected rows as CSV/JSON.
139
148
  - **Export** table rows or query results as **CSV or JSON**, and **import a CSV
140
149
  file** (or pasted CSV) into a table.
141
150
  - Create tables visually: name, type, primary key, not-null / unique,
@@ -151,11 +160,10 @@ In multi-db mode:
151
160
  - **Indexes:** create indexes (plain or unique, on one or many columns — pick
152
161
  columns in order, with a live `CREATE INDEX` SQL preview) and drop them from
153
162
  the schema editor; automatic SQLite indexes are protected.
154
- - **Views:** create / drop `CREATE VIEW` definitions, preview the rows they
155
- return, and inspect their SQL — from a dedicated Views page.
156
- - **Triggers:** create / drop triggers with a structured form (timing, single
157
- event, optional `WHEN`, body with a live `CREATE TRIGGER` SQL preview) and
158
- inspect existing trigger SQL — from a dedicated Triggers page.
163
+ - **Related rows:** every table that has a foreign key pointing at a table gets
164
+ its own column at the end of that table's grid — each cell shows how many of
165
+ its rows reference that record; click it to open a nested table with all of
166
+ the referencing table's columns and data for that specific record.
159
167
  - **Read-only mode:** open the database(s) without write access — the file is
160
168
  opened `SQLITE_OPEN_READONLY` + `query_only`, every write route returns `403`,
161
169
  and the UI hides/disables all write controls and shows a banner.
@@ -229,15 +237,19 @@ $env:DB_DIR='./db'; npm start # PowerShell
229
237
  | GET | `/api/tables` | List tables |
230
238
  | GET | `/api/tables/:table/info` | Column + FK metadata, PK columns |
231
239
  | GET | `/api/tables/:table/fk-options` | Values for FK dropdowns |
232
- | GET | `/api/tables/:table/rows?page&limit&f` | Paginated rows (`f` = URL-encoded JSON filters, e.g. `{"age":">35"}`) |
240
+ | GET | `/api/tables/:table/rows?page&limit&f` | Paginated rows (`f` = URL-encoded JSON filters — legacy strings like `{"age":">35"}` or structured conditions like `{"balance":{"op":"gte","value":"100"}}`) |
233
241
  | GET | `/api/tables/:table/row/:id` | Single row by (encoded) PK |
234
242
  | POST | `/api/tables/:table/rows` | Insert row |
235
243
  | POST | `/api/tables/:table/rows/generate` | Generate INSERT SQL (no execute) |
236
244
  | POST | `/api/tables/:table/rows/import` | Import CSV (`{ csv }`, header row must match columns) |
237
245
  | GET | `/api/tables/:table/export?format=` | Download all rows as `csv` or `json` |
238
- | PUT | `/api/tables/:table/row/:id` | Update row |
246
+ | PUT | `/api/tables/:table/row/:id` | Update row (`{ values, nulls? }` — `nulls` explicitly sets columns to NULL) |
239
247
  | PUT | `/api/tables/:table/row/:id/generate` | Generate UPDATE SQL (no execute) |
240
248
  | DELETE | `/api/tables/:table/row/:id` | Delete row |
249
+ | POST | `/api/tables/:table/rows/bulk-impact` | FK-impact preview: how many rows in other tables reference the selected rows |
250
+ | POST | `/api/tables/:table/rows/bulk-delete` | Delete selected rows (`{ ids, confirmImpact }`; transactional) |
251
+ | POST | `/api/tables/:table/rows/bulk-export` | Export only the selected rows as `csv`/`json` (`{ ids, format }`) |
252
+ | POST | `/api/tables/:table/rows/bulk-update` | Apply staged inline edits (`{ updates: [{ id, values?, nulls? }] }`; transactional) |
241
253
  | POST | `/api/tables` | Create table |
242
254
  | POST | `/api/tables/generate` | Generate CREATE SQL (no execute) |
243
255
  | GET | `/api/tables/:table/schema` | Full schema (constraints, indexes, FK refs) |
@@ -248,15 +260,6 @@ $env:DB_DIR='./db'; npm start # PowerShell
248
260
  | DELETE | `/api/tables/:table` | Drop the table (safety-checked) |
249
261
  | POST | `/api/tables/:table/indexes` | Create an index (`{ name?, columns[], unique? }`) |
250
262
  | DELETE | `/api/tables/:table/indexes/:index` | Drop an index (auto indexes refused) |
251
- | GET | `/api/views` | List views |
252
- | GET | `/api/views/:name/rows` | Preview a view's rows |
253
- | POST | `/api/views/generate` | Generate CREATE VIEW SQL (no execute) |
254
- | POST | `/api/views` | Create a view (`{ name, sql }`) |
255
- | DELETE | `/api/views/:name` | Drop a view |
256
- | GET | `/api/triggers` | List triggers |
257
- | POST | `/api/triggers/generate` | Generate CREATE TRIGGER SQL (no execute) |
258
- | POST | `/api/triggers` | Create a trigger (`{ name, table, timing, event, when?, body }`) |
259
- | DELETE | `/api/triggers/:name` | Drop a trigger |
260
263
  | POST | `/api/query` | Run arbitrary SQL |
261
264
  | POST | `/api/query/export` | Run a SELECT and download as `csv`/`json` |
262
265
  | GET | `/api/queries` | List saved queries |
@@ -274,10 +277,26 @@ In multi-db mode, every route is scoped under the database, e.g.
274
277
  - **Insert forms pre-fill defaults.** On the "new row" form, columns that have
275
278
  a schema default are pre-filled (string/number/boolean literals and
276
279
  `CURRENT_TIMESTAMP`-style defaults) so you can see and adjust them.
277
- - **Row filters.** Each column's filter supports exact match (`=value`),
278
- comparison (`>5`, `>=5`, `<5`, `<=5`, `!=value`), prefix (`pre*`) and
279
- case-insensitive substring (plain text). Filters are carried in the URL and
280
- survive sorting and pagination.
280
+ - **Row filters.** The filter panel adapts to each column's type: foreign keys
281
+ become dropdowns (with a "not set / NULL" option), booleans become
282
+ any/true/false toggles, and dates and numbers become min/max range inputs.
283
+ Text columns keep the operator syntax: exact match (`=value`), comparison
284
+ (`>5`, `>=5`, `<5`, `<=5`, `!=value`), prefix (`pre*`) and case-insensitive
285
+ substring (plain text). Filters are carried in the URL (as legacy strings or
286
+ structured conditions) and survive sorting and pagination.
287
+ - **Inline editing is staged, not instant.** Double-click a cell to edit it in
288
+ place; edits are buffered locally and highlighted in the grid rather than
289
+ written immediately. Press **Apply** to write every pending change to the
290
+ database in a single transaction (the page then reloads so related-row counts
291
+ stay accurate), or **Discard** to revert everything back to the saved values.
292
+ Clearing a text/date/number field stages a `NULL`.
293
+ - **Bulk operations.** Checkboxes select rows on the current page; "select all"
294
+ checks every visible row. **Delete** first shows a warning listing each table
295
+ that references the selected rows and how many rows point at them (these may
296
+ be cascaded away or orphaned depending on the foreign-key action, and the
297
+ delete can fail if a constraint blocks it). **Export CSV / JSON** downloads
298
+ only the selected rows. Both delete and export are capped at 1000 rows per
299
+ batch.
281
300
  - **CSV import.** The first row must be a header whose names match existing
282
301
  columns (unknown or duplicate names are rejected). Empty cells are treated as
283
302
  "not set" so database defaults apply. The whole import runs in a single
@@ -311,8 +330,10 @@ npm test # builds and runs the unit tests
311
330
 
312
331
  `npm test` compiles TypeScript to `dist/` and runs the `node:test` suite
313
332
  covering the SQL generator (INSERT / UPDATE / CREATE output, type mapping,
314
- quoting, rejection of unsupported types), the SQL classifier, and the database
315
- manager.
333
+ quoting, rejection of unsupported types), the SQL classifier, CSV parsing and
334
+ serialization, row filters (legacy and structured conditions), the database
335
+ layer (pagination, transactions, bulk update/delete, read-only enforcement),
336
+ and the database manager.
316
337
 
317
338
  ## Publishing to npm
318
339
 
@@ -1,5 +1,8 @@
1
1
  import type { Logger } from '../logger';
2
- import { type ColumnDef, type IndexDef, type TriggerDef } from '../sql/generator';
2
+ import { type ColumnDef, type IndexDef } from '../sql/generator';
3
+ import { type FilterValue } from '../util';
4
+ /** Structured filter types are shared with the API layer. */
5
+ export type { FilterCondition, FilterValue } from '../util';
3
6
  /** SQLite-compatible bind value accepted by better-sqlite3 statements. */
4
7
  export type SQLInputValue = null | number | bigint | string | Uint8Array;
5
8
  export interface Result<T = unknown> {
@@ -53,20 +56,8 @@ export interface IndexInfo {
53
56
  /** The `CREATE INDEX` statement from sqlite_master, when available. */
54
57
  sql?: string | null;
55
58
  }
56
- export interface ViewInfo {
57
- name: string;
58
- /** The `CREATE VIEW …` statement from sqlite_master. */
59
- sql: string | null;
60
- }
61
- export interface TriggerInfo {
62
- name: string;
63
- /** The table (or view) the trigger fires on. */
64
- table: string;
65
- /** The `CREATE TRIGGER …` statement from sqlite_master. */
66
- sql: string | null;
67
- }
68
59
  export interface RowFilters {
69
- [column: string]: string;
60
+ [column: string]: FilterValue;
70
61
  }
71
62
  export interface ColumnDetail extends ColumnInfo {
72
63
  unique: boolean;
@@ -93,16 +84,19 @@ export interface SchemaInfo {
93
84
  to: string;
94
85
  }[];
95
86
  }
87
+ export interface ReferencingTableInfo {
88
+ table: string;
89
+ /** FK columns in `table` that point at this table (`from` → `to`). */
90
+ refs: {
91
+ from: string;
92
+ to: string;
93
+ }[];
94
+ /** Rows in `table` whose FK column(s) actually reference this table. */
95
+ refCount: number;
96
+ }
96
97
  export declare const INTERNAL_TABLES: {
97
98
  readonly savedQueries: "_saved_queries";
98
99
  };
99
- /**
100
- * Build a parameterized WHERE clause from per-column filters.
101
- * Supported syntax in a filter value:
102
- * - `=value` exact match, `!=value` not-equal, `>value`, `>=value`, `<value`, `<=value`
103
- * - `value*` prefix match
104
- * - anything else → case-insensitive substring (LIKE '%value%')
105
- */
106
100
  export declare function buildFilterClause(filters: RowFilters | undefined, availableCols: string[]): {
107
101
  where: string;
108
102
  params: SQLInputValue[];
@@ -137,6 +131,12 @@ export declare class SqliteDatabase {
137
131
  execResult(sql: string): Promise<Result<{
138
132
  changes?: number;
139
133
  }>>;
134
+ /**
135
+ * True when `sql` contains more than one statement. Detection is delegated to
136
+ * SQLite itself via `prepare()` (which refuses multi-statement strings), so
137
+ * comments, string literals and quoted identifiers are handled correctly.
138
+ */
139
+ hasMultipleStatements(sql: string): boolean;
140
140
  /** Run a write statement, reporting row changes when it is a single statement. */
141
141
  runWrite(sql: string): Promise<Result<{
142
142
  changes?: number;
@@ -166,10 +166,33 @@ export declare class SqliteDatabase {
166
166
  changes: number;
167
167
  lastInsertRowid: number | null;
168
168
  }>>;
169
+ /**
170
+ * Apply many row updates (each with its own SET fields + primary-key WHERE
171
+ * clause) inside a single transaction. Used by the bulk "apply changes"
172
+ * flow of inline grid editing. Rolls everything back if any row fails.
173
+ */
174
+ updateRows(table: string, rows: {
175
+ fields: WhereClause[];
176
+ where: WhereClause[];
177
+ }[]): Promise<Result<{
178
+ updated: number;
179
+ }>>;
169
180
  deleteRow(table: string, where: WhereClause[]): Promise<Result<{
170
181
  changes: number;
171
182
  lastInsertRowid: number | null;
172
183
  }>>;
184
+ /**
185
+ * Delete many rows (each identified by its primary-key WHERE clause) inside a
186
+ * single transaction. Used by bulk delete.
187
+ */
188
+ deleteRows(table: string, rows: WhereClause[][]): Promise<Result<{
189
+ deleted: number;
190
+ }>>;
191
+ /**
192
+ * Fetch the rows matching the given primary-key WHERE clauses in one query
193
+ * (`(a = ? AND b = ?) OR …`). Used by bulk export and FK-impact checks.
194
+ */
195
+ getRowsByPks(table: string, pks: WhereClause[][]): Promise<Result<Record<string, unknown>[]>>;
173
196
  getCreateStatement(table: string): Promise<Result<string | null>>;
174
197
  listSavedQueries(): Promise<Result<SavedQuery[]>>;
175
198
  saveQuery(name: string, sql: string): Promise<Result<{
@@ -185,6 +208,20 @@ export declare class SqliteDatabase {
185
208
  * foreign keys, and which other tables reference it.
186
209
  */
187
210
  getSchema(table: string): Promise<Result<SchemaInfo>>;
211
+ /**
212
+ * Other tables with foreign keys pointing at `table`, grouped by table, each
213
+ * with the FK column mapping and a count of rows that actually reference it.
214
+ */
215
+ getReferencingTables(table: string): Promise<Result<ReferencingTableInfo[]>>;
216
+ /**
217
+ * Rows in `table` whose `column` equals `value` — used to show which rows in
218
+ * a referencing table point at a specific row via a foreign key — with the
219
+ * total matching count.
220
+ */
221
+ getRowsByFk(table: string, column: string, value: unknown, limit?: number): Promise<Result<{
222
+ rows: Record<string, unknown>[];
223
+ total: number;
224
+ }>>;
188
225
  renameTable(oldName: string, newName: string): Promise<Result<{
189
226
  changes?: number;
190
227
  }>>;
@@ -206,21 +243,5 @@ export declare class SqliteDatabase {
206
243
  dropIndex(indexName: string): Promise<Result<{
207
244
  changes?: number;
208
245
  }>>;
209
- listViews(): Promise<Result<ViewInfo[]>>;
210
- /** Preview the first `limit` rows of a view (browse). */
211
- getViewRows(viewName: string, limit?: number): Promise<Result<Record<string, unknown>[]>>;
212
- createView(name: string, selectSql: string): Promise<Result<{
213
- changes?: number;
214
- }>>;
215
- dropView(name: string): Promise<Result<{
216
- changes?: number;
217
- }>>;
218
- listTriggers(): Promise<Result<TriggerInfo[]>>;
219
- createTrigger(def: TriggerDef): Promise<Result<{
220
- changes?: number;
221
- }>>;
222
- dropTrigger(name: string): Promise<Result<{
223
- changes?: number;
224
- }>>;
225
246
  private getTableInfoSync;
226
247
  }
@@ -17,20 +17,87 @@ function escapeLike(s) {
17
17
  }
18
18
  /**
19
19
  * Build a parameterized WHERE clause from per-column filters.
20
- * Supported syntax in a filter value:
21
- * - `=value` exact match, `!=value` not-equal, `>value`, `>=value`, `<value`, `<=value`
22
- * - `value*` prefix match
23
- * - anything else → case-insensitive substring (LIKE '%value%')
20
+ *
21
+ * A filter value is either:
22
+ * - the legacy string syntax: `=value` exact, `!=value`, `>value`, `>=value`,
23
+ * `<value`, `<=value`, `value*` prefix, anything else → substring match; or
24
+ * - a structured `FilterCondition` (or array of them) produced by the
25
+ * type-aware filter form (FK dropdowns, boolean toggles, date/number ranges).
24
26
  */
27
+ function pushFilterCondition(conds, params, quotedCol, c) {
28
+ const value = String(c.value ?? '');
29
+ switch (c.op) {
30
+ case 'eq':
31
+ conds.push(`${quotedCol} = ?`);
32
+ params.push(value);
33
+ break;
34
+ case 'neq':
35
+ conds.push(`${quotedCol} != ?`);
36
+ params.push(value);
37
+ break;
38
+ case 'gt':
39
+ conds.push(`${quotedCol} > ?`);
40
+ params.push(value);
41
+ break;
42
+ case 'gte':
43
+ conds.push(`${quotedCol} >= ?`);
44
+ params.push(value);
45
+ break;
46
+ case 'lt':
47
+ conds.push(`${quotedCol} < ?`);
48
+ params.push(value);
49
+ break;
50
+ case 'lte':
51
+ conds.push(`${quotedCol} <= ?`);
52
+ params.push(value);
53
+ break;
54
+ case 'like':
55
+ conds.push(`${quotedCol} LIKE ? ESCAPE '\\'`);
56
+ params.push('%' + escapeLike(value) + '%');
57
+ break;
58
+ case 'prefix':
59
+ conds.push(`${quotedCol} LIKE ? ESCAPE '\\'`);
60
+ params.push(escapeLike(value) + '%');
61
+ break;
62
+ case 'between':
63
+ conds.push(`${quotedCol} >= ?`);
64
+ params.push(value);
65
+ conds.push(`${quotedCol} <= ?`);
66
+ params.push(String(c.max ?? ''));
67
+ break;
68
+ case 'null':
69
+ conds.push(`${quotedCol} IS NULL`);
70
+ break;
71
+ case 'notnull':
72
+ conds.push(`${quotedCol} IS NOT NULL`);
73
+ break;
74
+ default:
75
+ break;
76
+ }
77
+ }
25
78
  function buildFilterClause(filters, availableCols) {
26
79
  const conds = [];
27
80
  const params = [];
28
81
  const colSet = new Set(availableCols);
29
82
  for (const [col, raw] of Object.entries(filters ?? {})) {
30
- const value = String(raw ?? '').trim();
31
- if (value === '' || !colSet.has(col))
83
+ if (!colSet.has(col))
32
84
  continue;
33
85
  const q = (0, generator_1.quoteIdentifier)(col);
86
+ if (Array.isArray(raw)) {
87
+ for (const c of raw) {
88
+ if (c && typeof c === 'object')
89
+ pushFilterCondition(conds, params, q, c);
90
+ }
91
+ continue;
92
+ }
93
+ if (raw && typeof raw === 'object') {
94
+ pushFilterCondition(conds, params, q, raw);
95
+ continue;
96
+ }
97
+ // Legacy string syntax.
98
+ const value = String(raw ?? '').trim();
99
+ if (value === '')
100
+ continue;
34
101
  if (value.startsWith('>=')) {
35
102
  conds.push(`${q} >= ?`);
36
103
  params.push(value.slice(2).trim());
@@ -153,6 +220,23 @@ class SqliteDatabase {
153
220
  return { changes: undefined };
154
221
  });
155
222
  }
223
+ /**
224
+ * True when `sql` contains more than one statement. Detection is delegated to
225
+ * SQLite itself via `prepare()` (which refuses multi-statement strings), so
226
+ * comments, string literals and quoted identifiers are handled correctly.
227
+ */
228
+ hasMultipleStatements(sql) {
229
+ const s = String(sql ?? '').trim();
230
+ if (!s)
231
+ return false;
232
+ try {
233
+ this.db.prepare(s);
234
+ return false;
235
+ }
236
+ catch (err) {
237
+ return /more than one statement/i.test((0, util_1.errorMessage)(err));
238
+ }
239
+ }
156
240
  /** Run a write statement, reporting row changes when it is a single statement. */
157
241
  async runWrite(sql) {
158
242
  if (this.isReadOnly)
@@ -294,11 +378,99 @@ class SqliteDatabase {
294
378
  const sql = `UPDATE ${(0, generator_1.quoteIdentifier)(table)} SET ${sets.join(', ')} WHERE ${conds.join(' AND ')}`;
295
379
  return this.run(sql, [...fields.map((f) => this.toBind(f.value)), ...where.map((w) => this.toBind(w.value))]);
296
380
  }
381
+ /**
382
+ * Apply many row updates (each with its own SET fields + primary-key WHERE
383
+ * clause) inside a single transaction. Used by the bulk "apply changes"
384
+ * flow of inline grid editing. Rolls everything back if any row fails.
385
+ */
386
+ async updateRows(table, rows) {
387
+ if (this.isReadOnly)
388
+ return this.readonlyBlocked();
389
+ return this.tryRun(() => {
390
+ this.db.exec('BEGIN');
391
+ try {
392
+ let updated = 0;
393
+ for (const { fields, where } of rows) {
394
+ if (!fields.length || !where.length)
395
+ continue;
396
+ const sets = fields.map((f) => `${(0, generator_1.quoteIdentifier)(f.column)} = ?`);
397
+ const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
398
+ const sql = `UPDATE ${(0, generator_1.quoteIdentifier)(table)} SET ${sets.join(', ')} WHERE ${conds.join(' AND ')}`;
399
+ const info = this.db.prepare(sql).run(...[...fields.map((f) => this.toBind(f.value)), ...where.map((w) => this.toBind(w.value))]);
400
+ updated += Number(info.changes);
401
+ }
402
+ this.db.exec('COMMIT');
403
+ return { updated };
404
+ }
405
+ catch (err) {
406
+ try {
407
+ this.db.exec('ROLLBACK');
408
+ }
409
+ catch {
410
+ /* ignore */
411
+ }
412
+ throw err;
413
+ }
414
+ });
415
+ }
297
416
  async deleteRow(table, where) {
298
417
  const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
299
418
  const sql = `DELETE FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${conds.join(' AND ')}`;
300
419
  return this.run(sql, where.map((w) => this.toBind(w.value)));
301
420
  }
421
+ /**
422
+ * Delete many rows (each identified by its primary-key WHERE clause) inside a
423
+ * single transaction. Used by bulk delete.
424
+ */
425
+ async deleteRows(table, rows) {
426
+ if (this.isReadOnly)
427
+ return this.readonlyBlocked();
428
+ return this.tryRun(() => {
429
+ this.db.exec('BEGIN');
430
+ try {
431
+ let deleted = 0;
432
+ for (const where of rows) {
433
+ if (!where.length)
434
+ continue;
435
+ const conds = where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`);
436
+ const sql = `DELETE FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${conds.join(' AND ')}`;
437
+ const info = this.db.prepare(sql).run(...where.map((w) => this.toBind(w.value)));
438
+ deleted += Number(info.changes);
439
+ }
440
+ this.db.exec('COMMIT');
441
+ return { deleted };
442
+ }
443
+ catch (err) {
444
+ try {
445
+ this.db.exec('ROLLBACK');
446
+ }
447
+ catch {
448
+ /* ignore */
449
+ }
450
+ throw err;
451
+ }
452
+ });
453
+ }
454
+ /**
455
+ * Fetch the rows matching the given primary-key WHERE clauses in one query
456
+ * (`(a = ? AND b = ?) OR …`). Used by bulk export and FK-impact checks.
457
+ */
458
+ async getRowsByPks(table, pks) {
459
+ return this.tryRun(() => {
460
+ const groups = [];
461
+ const params = [];
462
+ for (const where of pks) {
463
+ if (!where.length)
464
+ continue;
465
+ groups.push(where.map((w) => `${(0, generator_1.quoteIdentifier)(w.column)} = ?`).join(' AND '));
466
+ params.push(...where.map((w) => this.toBind(w.value)));
467
+ }
468
+ if (!groups.length)
469
+ return [];
470
+ const sql = `SELECT * FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${groups.map((g) => `(${g})`).join(' OR ')}`;
471
+ return this.db.prepare(sql).all(...params);
472
+ });
473
+ }
302
474
  async getCreateStatement(table) {
303
475
  return this.tryRun(() => {
304
476
  const row = this.db
@@ -426,6 +598,67 @@ class SqliteDatabase {
426
598
  };
427
599
  });
428
600
  }
601
+ /**
602
+ * Other tables with foreign keys pointing at `table`, grouped by table, each
603
+ * with the FK column mapping and a count of rows that actually reference it.
604
+ */
605
+ async getReferencingTables(table) {
606
+ return this.tryRun(() => {
607
+ const allTables = this.db
608
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
609
+ .all().map((r) => r.name);
610
+ const byTable = new Map();
611
+ for (const t of allTables) {
612
+ if (t === table)
613
+ continue;
614
+ try {
615
+ const fks = this.db.prepare(`PRAGMA foreign_key_list(${(0, generator_1.quoteIdentifier)(t)})`).all();
616
+ for (const fk of fks) {
617
+ if (fk.table === table) {
618
+ const arr = byTable.get(t) ?? [];
619
+ arr.push({ from: fk.from, to: fk.to ?? '' });
620
+ byTable.set(t, arr);
621
+ }
622
+ }
623
+ }
624
+ catch {
625
+ /* ignore unreadable tables */
626
+ }
627
+ }
628
+ const result = [];
629
+ for (const [t, refs] of byTable) {
630
+ let refCount = 0;
631
+ try {
632
+ const conds = refs.map((r) => `${(0, generator_1.quoteIdentifier)(r.from)} IS NOT NULL`);
633
+ const row = this.db.prepare(`SELECT COUNT(*) AS c FROM ${(0, generator_1.quoteIdentifier)(t)} WHERE ${conds.join(' OR ')}`).get();
634
+ refCount = Number(row.c);
635
+ }
636
+ catch {
637
+ /* count is best-effort */
638
+ }
639
+ result.push({ table: t, refs, refCount });
640
+ }
641
+ result.sort((a, b) => a.table.localeCompare(b.table));
642
+ return result;
643
+ });
644
+ }
645
+ /**
646
+ * Rows in `table` whose `column` equals `value` — used to show which rows in
647
+ * a referencing table point at a specific row via a foreign key — with the
648
+ * total matching count.
649
+ */
650
+ async getRowsByFk(table, column, value, limit = 50) {
651
+ return this.tryRun(() => {
652
+ const n = Math.max(1, Math.min(500, Number(limit) || 50));
653
+ const countRow = this.db
654
+ .prepare(`SELECT COUNT(*) AS c FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${(0, generator_1.quoteIdentifier)(column)} = ?`)
655
+ .get(value);
656
+ const rows = this.db
657
+ .prepare(`SELECT * FROM ${(0, generator_1.quoteIdentifier)(table)} WHERE ${(0, generator_1.quoteIdentifier)(column)} = ? LIMIT ${n}`)
658
+ .all(value);
659
+ return { rows, total: Number(countRow.c) };
660
+ });
661
+ }
429
662
  async renameTable(oldName, newName) {
430
663
  return this.execResult((0, generator_1.generateRenameTable)(oldName, newName));
431
664
  }
@@ -447,42 +680,6 @@ class SqliteDatabase {
447
680
  async dropIndex(indexName) {
448
681
  return this.execResult((0, generator_1.generateDropIndex)(indexName));
449
682
  }
450
- // ---- Views & triggers -------------------------------------------------
451
- async listViews() {
452
- return this.tryRun(() => {
453
- const rows = this.db
454
- .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'view' AND name NOT LIKE 'sqlite_%' ORDER BY name")
455
- .all();
456
- return rows.map((r) => ({ name: r.name, sql: r.sql ?? null }));
457
- });
458
- }
459
- /** Preview the first `limit` rows of a view (browse). */
460
- async getViewRows(viewName, limit = 200) {
461
- return this.tryRun(() => {
462
- const n = Math.max(1, Math.min(1000, Number(limit) || 200));
463
- return this.db.prepare(`SELECT * FROM ${(0, generator_1.quoteIdentifier)(viewName)} LIMIT ${n}`).all();
464
- });
465
- }
466
- async createView(name, selectSql) {
467
- return this.execResult((0, generator_1.generateCreateView)(name, selectSql));
468
- }
469
- async dropView(name) {
470
- return this.execResult((0, generator_1.generateDropView)(name));
471
- }
472
- async listTriggers() {
473
- return this.tryRun(() => {
474
- const rows = this.db
475
- .prepare('SELECT name, tbl_name AS "table", sql FROM sqlite_master WHERE type = \'trigger\' AND name NOT LIKE \'sqlite_%\' ORDER BY name')
476
- .all();
477
- return rows.map((r) => ({ name: r.name, table: r.table, sql: r.sql ?? null }));
478
- });
479
- }
480
- async createTrigger(def) {
481
- return this.execResult((0, generator_1.generateCreateTrigger)(def));
482
- }
483
- async dropTrigger(name) {
484
- return this.execResult((0, generator_1.generateDropTrigger)(name));
485
- }
486
683
  getTableInfoSync(table) {
487
684
  try {
488
685
  const cols = this.db.prepare(`PRAGMA table_info(${(0, generator_1.quoteIdentifier)(table)})`).all();