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
package/dist/cli.js CHANGED
@@ -5,50 +5,120 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const express_1 = __importDefault(require("express"));
8
+ const node_path_1 = __importDefault(require("node:path"));
8
9
  const app_1 = require("./app");
9
10
  const config_1 = require("./config");
10
11
  const logger_1 = require("./logger");
11
12
  const manager_1 = require("./db/manager");
13
+ const args_1 = require("./args");
12
14
  /**
13
15
  * Standalone CLI / server entry point. Run `npm start` (or `npx admindb`).
14
- * Configure via PORT, HOST, DB_PATH, DB_DIR, DB_FILES, BASE_PATH and LOG_LEVEL
15
- * env vars. Set DB_DIR and/or DB_FILES to enable multi-database mode.
16
16
  *
17
- * SECURITY: this server exposes full, unauthenticated database access. It must
18
- * NOT be exposed to the public internet — protect it yourself (bind to
19
- * localhost, put it behind an authenticated reverse proxy, a VPN, etc.).
17
+ * Everything can be set with CLI flags (see `--help`) or environment variables
18
+ * (PORT, HOST, DB_PATH, DB_DIR, DB_FILES, BASE_PATH, READONLY, LOG_LEVEL); flags
19
+ * win over the environment.
20
+ *
21
+ * By default the server runs in "manager" mode: the landing page lets you
22
+ * browse the filesystem and open any SQLite database file, or create new ones.
23
+ * `--open <file>` (or a file path / DB_PATH) opens a single database directly.
24
+ *
25
+ * SECURITY: this server exposes full, unauthenticated database AND filesystem
26
+ * access. It must NOT be exposed to the public internet — protect it yourself
27
+ * (bind to localhost, put it behind an authenticated reverse proxy, a VPN,
28
+ * etc.).
20
29
  */
21
- const config = (0, config_1.loadConfig)();
30
+ const env = (0, config_1.loadConfig)();
31
+ const parsed = (0, args_1.parseArgs)(process.argv.slice(2));
32
+ if (parsed.error) {
33
+ console.error(parsed.error);
34
+ console.error();
35
+ console.error((0, args_1.helpText)());
36
+ process.exit(1);
37
+ }
38
+ const args = parsed.args;
39
+ if (args.help) {
40
+ console.log((0, args_1.helpText)());
41
+ process.exit(0);
42
+ }
43
+ if (args.version) {
44
+ console.log((0, args_1.versionText)());
45
+ process.exit(0);
46
+ }
47
+ // Merge CLI flags over environment variables.
48
+ const config = {
49
+ host: args.host ?? env.host,
50
+ port: args.port ?? env.port,
51
+ dbPath: args.dbPath ?? env.dbPath,
52
+ dbDir: args.dbDir ?? env.dbDir,
53
+ dbFiles: args.dbFiles ?? env.dbFiles,
54
+ basePath: (args.basePath ?? env.basePath).replace(/\/+$/, ''),
55
+ logLevel: args.logLevel ?? env.logLevel,
56
+ readonly: args.readonly || env.readonly,
57
+ };
22
58
  const logger = (0, logger_1.createLogger)(config.logLevel, 'admindb');
23
59
  const app = (0, express_1.default)();
24
60
  app.disable('x-powered-by');
25
- const multiDb = Boolean(config.dbDir || (config.dbFiles && config.dbFiles.length > 0));
26
- const router = multiDb
61
+ // Mode selection:
62
+ // - A specific single file was requested (`--open`, a file path argument, or
63
+ // DB_PATH) and no folder/files were configured → single-database mode.
64
+ // - Otherwise → manager mode with a landing page where you can browse the
65
+ // filesystem and open database files, or create new ones.
66
+ const hasDir = Boolean(config.dbDir);
67
+ const hasFiles = Boolean(config.dbFiles && config.dbFiles.length > 0);
68
+ const explicitFile = args.dbPath ?? (process.env.DB_PATH ? process.env.DB_PATH : undefined);
69
+ const singleFileOnly = Boolean(explicitFile) && !hasDir && !hasFiles;
70
+ const useManager = !singleFileOnly;
71
+ const managerFiles = [...(config.dbFiles ?? [])];
72
+ if (useManager && explicitFile)
73
+ managerFiles.push(explicitFile);
74
+ // File-browser policy for manager mode:
75
+ // - a folder was requested → browsing is allowed but limited to that folder;
76
+ // - specific files were requested (no folder) → browsing is disabled entirely;
77
+ // - nothing requested → browsing is allowed (free); the folder defaults to the
78
+ // current directory so new/created databases land there.
79
+ const managerDir = hasDir ? config.dbDir : hasFiles || explicitFile ? undefined : process.cwd();
80
+ const allowBrowse = useManager && (hasDir || (!hasFiles && !explicitFile));
81
+ const browseRoot = hasDir ? node_path_1.default.resolve(config.dbDir) : undefined;
82
+ const router = useManager
27
83
  ? (0, app_1.createRouter)({
28
- manager: new manager_1.DbManager({ dir: config.dbDir, files: config.dbFiles, readonly: config.readonly }, logger),
84
+ manager: new manager_1.DbManager({ dir: managerDir, files: managerFiles, readonly: config.readonly }, logger),
29
85
  basePath: config.basePath,
30
86
  logger,
31
87
  readonly: config.readonly,
88
+ allowBrowse,
89
+ browseRoot,
32
90
  })
33
- : (0, app_1.createRouter)({ dbPath: config.dbPath, basePath: config.basePath, logger, readonly: config.readonly });
91
+ : (0, app_1.createRouter)({ dbPath: explicitFile, basePath: config.basePath, logger, readonly: config.readonly });
34
92
  app.use(config.basePath || '/', router);
35
93
  const server = app.listen(config.port, config.host, () => {
36
94
  const url = `http://${config.host}:${config.port}${config.basePath}/`;
37
95
  logger.info(`AdminDB listening on ${url}`);
38
- if (multiDb) {
39
- const sources = [config.dbDir ? `dir:${config.dbDir}` : null, config.dbFiles?.length ? `files:[${config.dbFiles.join(', ')}]` : null]
96
+ if (useManager) {
97
+ const sources = [
98
+ managerDir ? `dir:${managerDir}` : null,
99
+ managerFiles.length ? `files:[${managerFiles.join(', ')}]` : null,
100
+ ]
40
101
  .filter(Boolean)
41
102
  .join(' · ');
42
- logger.info(`Multi-database mode — ${sources}`);
103
+ logger.info(`Manager mode — ${sources}`);
104
+ if (!allowBrowse) {
105
+ logger.info('File browsing disabled — only the configured database files are listed.');
106
+ }
107
+ else if (browseRoot) {
108
+ logger.info(`File browsing is limited to: ${browseRoot}`);
109
+ }
110
+ else {
111
+ logger.info('Open the URL above in your browser to browse and open database files.');
112
+ }
43
113
  }
44
114
  else {
45
- logger.info(`Database file: ${config.dbPath}`);
115
+ logger.info(`Database file: ${explicitFile}`);
46
116
  }
47
117
  if (config.readonly) {
48
118
  logger.info('Read-only mode enabled — all writes are disabled.');
49
119
  }
50
120
  if (config.host === '0.0.0.0') {
51
- logger.warn('Listening on 0.0.0.0 — this admin tool has NO authentication. Do not expose it to untrusted networks.');
121
+ logger.warn('Listening on 0.0.0.0 — this admin tool has NO authentication and exposes filesystem access. Do not expose it to untrusted networks.');
52
122
  }
53
123
  });
54
124
  const shutdown = (signal) => {
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -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();