sqlite-hub 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +7 -0
- package/frontend/js/api.js +99 -5
- package/frontend/js/app.js +90 -11
- package/frontend/js/components/bottomTabs.js +1 -1
- package/frontend/js/components/dataGrid.js +3 -3
- package/frontend/js/components/queryEditor.js +33 -55
- package/frontend/js/components/queryHistoryDetail.js +263 -0
- package/frontend/js/components/queryHistoryPanel.js +228 -0
- package/frontend/js/components/queryResults.js +32 -46
- package/frontend/js/components/rowEditorPanel.js +73 -14
- package/frontend/js/store.js +545 -21
- package/frontend/js/utils/format.js +23 -0
- package/frontend/js/views/data.js +34 -1
- package/frontend/js/views/editor.js +34 -10
- package/frontend/js/views/overview.js +15 -0
- package/frontend/styles/components.css +106 -0
- package/package.json +1 -1
- package/server/routes/data.js +2 -0
- package/server/routes/export.js +4 -1
- package/server/routes/overview.js +12 -0
- package/server/routes/sql.js +163 -5
- package/server/server.js +1 -1
- package/server/services/sqlite/dataBrowserService.js +4 -16
- package/server/services/sqlite/exportService.js +4 -16
- package/server/services/sqlite/overviewService.js +34 -0
- package/server/services/sqlite/sqlExecutor.js +83 -63
- package/server/services/sqlite/tableSort.js +63 -0
- package/server/services/storage/appStateStore.js +674 -1
- package/server/services/storage/queryHistoryUtils.js +169 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const Database = require("better-sqlite3");
|
|
4
|
-
const { ValidationError } = require("../../utils/errors");
|
|
4
|
+
const { NotFoundError, ValidationError } = require("../../utils/errors");
|
|
5
|
+
const {
|
|
6
|
+
buildAutoTitle,
|
|
7
|
+
buildSqlPreview,
|
|
8
|
+
detectQueryType,
|
|
9
|
+
detectTables,
|
|
10
|
+
isDestructiveQuery,
|
|
11
|
+
normalizeSql,
|
|
12
|
+
} = require("./queryHistoryUtils");
|
|
5
13
|
|
|
6
14
|
const DEFAULT_STATE = {
|
|
7
15
|
recentConnections: [],
|
|
@@ -30,6 +38,7 @@ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
|
|
|
30
38
|
".png": "png",
|
|
31
39
|
".webp": "webp",
|
|
32
40
|
};
|
|
41
|
+
const QUERY_HISTORY_MIGRATION_KEY = "queryHistoryV1Migrated";
|
|
33
42
|
|
|
34
43
|
class AppStateStore {
|
|
35
44
|
constructor(filePath, options = {}) {
|
|
@@ -54,6 +63,8 @@ class AppStateStore {
|
|
|
54
63
|
if (!importedLegacyDatabase && this.shouldImportLegacyState()) {
|
|
55
64
|
this.tryImportLegacyState();
|
|
56
65
|
}
|
|
66
|
+
|
|
67
|
+
this.migrateLegacySqlHistory();
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
configureDatabase() {
|
|
@@ -103,6 +114,62 @@ class AppStateStore {
|
|
|
103
114
|
|
|
104
115
|
CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
|
|
105
116
|
ON sql_history(executedAt DESC, id ASC);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS query_history (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
database_key TEXT NOT NULL,
|
|
121
|
+
normalized_sql TEXT NOT NULL,
|
|
122
|
+
raw_sql TEXT NOT NULL,
|
|
123
|
+
title TEXT,
|
|
124
|
+
notes TEXT,
|
|
125
|
+
query_type TEXT NOT NULL DEFAULT 'other',
|
|
126
|
+
tables_detected TEXT NOT NULL DEFAULT '[]',
|
|
127
|
+
is_favorite INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
is_saved INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
is_destructive INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
use_count INTEGER NOT NULL DEFAULT 1,
|
|
131
|
+
first_executed_at TEXT NOT NULL,
|
|
132
|
+
last_used_at TEXT NOT NULL
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE TABLE IF NOT EXISTS query_runs (
|
|
136
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
+
history_id INTEGER NOT NULL,
|
|
138
|
+
executed_at TEXT NOT NULL,
|
|
139
|
+
duration_ms INTEGER,
|
|
140
|
+
row_count INTEGER,
|
|
141
|
+
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
|
142
|
+
error_message TEXT,
|
|
143
|
+
affected_rows INTEGER,
|
|
144
|
+
FOREIGN KEY (history_id) REFERENCES query_history(id) ON DELETE CASCADE
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_database_key
|
|
148
|
+
ON query_history(database_key);
|
|
149
|
+
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_last_used_at
|
|
151
|
+
ON query_history(last_used_at DESC);
|
|
152
|
+
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_is_saved
|
|
154
|
+
ON query_history(is_saved);
|
|
155
|
+
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_is_favorite
|
|
157
|
+
ON query_history(is_favorite);
|
|
158
|
+
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_query_type
|
|
160
|
+
ON query_history(query_type);
|
|
161
|
+
|
|
162
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_query_history_database_normalized_sql
|
|
163
|
+
ON query_history(database_key, normalized_sql);
|
|
164
|
+
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_history_id
|
|
166
|
+
ON query_runs(history_id);
|
|
167
|
+
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_executed_at
|
|
169
|
+
ON query_runs(executed_at DESC);
|
|
170
|
+
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_status
|
|
172
|
+
ON query_runs(status);
|
|
106
173
|
`);
|
|
107
174
|
|
|
108
175
|
const recentConnectionColumns = new Set(
|
|
@@ -407,6 +474,612 @@ class AppStateStore {
|
|
|
407
474
|
.run(key, String(value));
|
|
408
475
|
}
|
|
409
476
|
|
|
477
|
+
normalizeQueryHistoryText(value) {
|
|
478
|
+
const text = String(value ?? "").trim();
|
|
479
|
+
return text ? text : null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
normalizeQueryHistoryInteger(value) {
|
|
483
|
+
if (value === null || value === undefined || value === "") {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const numericValue = Number(value);
|
|
488
|
+
return Number.isFinite(numericValue) ? Math.round(numericValue) : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
parseTablesDetected(value) {
|
|
492
|
+
if (Array.isArray(value)) {
|
|
493
|
+
return Array.from(
|
|
494
|
+
new Set(
|
|
495
|
+
value
|
|
496
|
+
.map((entry) => String(entry ?? "").trim())
|
|
497
|
+
.filter(Boolean)
|
|
498
|
+
)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!value) {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
return this.parseTablesDetected(JSON.parse(value));
|
|
508
|
+
} catch {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
decorateQueryRun(row = {}) {
|
|
514
|
+
return {
|
|
515
|
+
id: Number(row.id),
|
|
516
|
+
historyId: Number(row.history_id ?? row.historyId),
|
|
517
|
+
executedAt: row.executed_at ?? row.executedAt ?? null,
|
|
518
|
+
durationMs: this.normalizeQueryHistoryInteger(row.duration_ms ?? row.durationMs),
|
|
519
|
+
rowCount: this.normalizeQueryHistoryInteger(row.row_count ?? row.rowCount),
|
|
520
|
+
status: row.status ?? "success",
|
|
521
|
+
errorMessage: this.normalizeQueryHistoryText(row.error_message ?? row.errorMessage),
|
|
522
|
+
affectedRows: this.normalizeQueryHistoryInteger(row.affected_rows ?? row.affectedRows),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
decorateQueryHistoryRow(row = {}) {
|
|
527
|
+
const tablesDetected = this.parseTablesDetected(row.tables_detected ?? row.tablesDetected);
|
|
528
|
+
const queryType = row.query_type ?? row.queryType ?? "other";
|
|
529
|
+
const title = this.normalizeQueryHistoryText(row.title);
|
|
530
|
+
const notes = this.normalizeQueryHistoryText(row.notes);
|
|
531
|
+
const lastRun =
|
|
532
|
+
row.last_run_id ?? row.lastRunId
|
|
533
|
+
? this.decorateQueryRun({
|
|
534
|
+
id: row.last_run_id ?? row.lastRunId,
|
|
535
|
+
history_id: row.id,
|
|
536
|
+
executed_at: row.last_run_executed_at ?? row.lastRunExecutedAt,
|
|
537
|
+
duration_ms: row.last_run_duration_ms ?? row.lastRunDurationMs,
|
|
538
|
+
row_count: row.last_run_row_count ?? row.lastRunRowCount,
|
|
539
|
+
status: row.last_run_status ?? row.lastRunStatus,
|
|
540
|
+
error_message: row.last_run_error_message ?? row.lastRunErrorMessage,
|
|
541
|
+
affected_rows: row.last_run_affected_rows ?? row.lastRunAffectedRows,
|
|
542
|
+
})
|
|
543
|
+
: null;
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
id: Number(row.id),
|
|
547
|
+
databaseKey: row.database_key ?? row.databaseKey,
|
|
548
|
+
normalizedSql: row.normalized_sql ?? row.normalizedSql,
|
|
549
|
+
rawSql: row.raw_sql ?? row.rawSql,
|
|
550
|
+
title,
|
|
551
|
+
notes,
|
|
552
|
+
queryType,
|
|
553
|
+
tablesDetected,
|
|
554
|
+
isFavorite: Boolean(row.is_favorite ?? row.isFavorite),
|
|
555
|
+
isSaved: Boolean(row.is_saved ?? row.isSaved),
|
|
556
|
+
isDestructive: Boolean(row.is_destructive ?? row.isDestructive),
|
|
557
|
+
useCount: Number(row.use_count ?? row.useCount ?? 0),
|
|
558
|
+
firstExecutedAt: row.first_executed_at ?? row.firstExecutedAt ?? null,
|
|
559
|
+
lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
|
|
560
|
+
displayTitle:
|
|
561
|
+
title ||
|
|
562
|
+
buildAutoTitle(row.raw_sql ?? row.rawSql, {
|
|
563
|
+
queryType,
|
|
564
|
+
tablesDetected,
|
|
565
|
+
}),
|
|
566
|
+
previewSql: buildSqlPreview(row.raw_sql ?? row.rawSql),
|
|
567
|
+
lastRun,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
buildQueryHistoryFilters({
|
|
572
|
+
databaseKey,
|
|
573
|
+
search,
|
|
574
|
+
queryType,
|
|
575
|
+
onlySaved = false,
|
|
576
|
+
onlyFavorites = false,
|
|
577
|
+
latestStatus = null,
|
|
578
|
+
} = {}) {
|
|
579
|
+
const clauses = [];
|
|
580
|
+
const params = [];
|
|
581
|
+
const normalizedSearch = String(search ?? "").trim().toLowerCase();
|
|
582
|
+
|
|
583
|
+
if (databaseKey) {
|
|
584
|
+
clauses.push("q.database_key = ?");
|
|
585
|
+
params.push(databaseKey);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (normalizedSearch) {
|
|
589
|
+
const searchPattern = `%${normalizedSearch}%`;
|
|
590
|
+
clauses.push(`
|
|
591
|
+
(
|
|
592
|
+
LOWER(COALESCE(q.title, '')) LIKE ?
|
|
593
|
+
OR LOWER(q.raw_sql) LIKE ?
|
|
594
|
+
OR LOWER(COALESCE(q.notes, '')) LIKE ?
|
|
595
|
+
OR LOWER(q.normalized_sql) LIKE ?
|
|
596
|
+
OR LOWER(q.tables_detected) LIKE ?
|
|
597
|
+
)
|
|
598
|
+
`);
|
|
599
|
+
params.push(
|
|
600
|
+
searchPattern,
|
|
601
|
+
searchPattern,
|
|
602
|
+
searchPattern,
|
|
603
|
+
searchPattern,
|
|
604
|
+
searchPattern
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (queryType) {
|
|
609
|
+
clauses.push("q.query_type = ?");
|
|
610
|
+
params.push(queryType);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (onlySaved) {
|
|
614
|
+
clauses.push("q.is_saved = 1");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (onlyFavorites) {
|
|
618
|
+
clauses.push("q.is_favorite = 1");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (latestStatus) {
|
|
622
|
+
clauses.push("latest.status = ?");
|
|
623
|
+
params.push(latestStatus);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
whereSql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "",
|
|
628
|
+
params,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
getQueryHistoryOrderBy({ onlySaved = false, onlyFavorites = false, latestStatus = null } = {}) {
|
|
633
|
+
if (onlySaved || onlyFavorites) {
|
|
634
|
+
return "ORDER BY q.is_favorite DESC, q.last_used_at DESC, q.id DESC";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (latestStatus === "error") {
|
|
638
|
+
return "ORDER BY latest.executed_at DESC, q.id DESC";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return "ORDER BY q.last_used_at DESC, q.id DESC";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
buildQueryHistoryCollection({
|
|
645
|
+
databaseKey,
|
|
646
|
+
limit = 30,
|
|
647
|
+
offset = 0,
|
|
648
|
+
search = "",
|
|
649
|
+
queryType = null,
|
|
650
|
+
onlySaved = false,
|
|
651
|
+
onlyFavorites = false,
|
|
652
|
+
latestStatus = null,
|
|
653
|
+
} = {}) {
|
|
654
|
+
const normalizedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
|
655
|
+
const normalizedOffset = Math.max(0, Number(offset) || 0);
|
|
656
|
+
|
|
657
|
+
if (!databaseKey) {
|
|
658
|
+
return {
|
|
659
|
+
items: [],
|
|
660
|
+
total: 0,
|
|
661
|
+
limit: normalizedLimit,
|
|
662
|
+
offset: normalizedOffset,
|
|
663
|
+
hasMore: false,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const baseFromSql = `
|
|
668
|
+
FROM query_history q
|
|
669
|
+
LEFT JOIN query_runs latest
|
|
670
|
+
ON latest.id = (
|
|
671
|
+
SELECT runs.id
|
|
672
|
+
FROM query_runs runs
|
|
673
|
+
WHERE runs.history_id = q.id
|
|
674
|
+
ORDER BY runs.executed_at DESC, runs.id DESC
|
|
675
|
+
LIMIT 1
|
|
676
|
+
)
|
|
677
|
+
`;
|
|
678
|
+
const { whereSql, params } = this.buildQueryHistoryFilters({
|
|
679
|
+
databaseKey,
|
|
680
|
+
search,
|
|
681
|
+
queryType,
|
|
682
|
+
onlySaved,
|
|
683
|
+
onlyFavorites,
|
|
684
|
+
latestStatus,
|
|
685
|
+
});
|
|
686
|
+
const orderBySql = this.getQueryHistoryOrderBy({
|
|
687
|
+
onlySaved,
|
|
688
|
+
onlyFavorites,
|
|
689
|
+
latestStatus,
|
|
690
|
+
});
|
|
691
|
+
const rows = this.db
|
|
692
|
+
.prepare(`
|
|
693
|
+
SELECT
|
|
694
|
+
q.id,
|
|
695
|
+
q.database_key,
|
|
696
|
+
q.normalized_sql,
|
|
697
|
+
q.raw_sql,
|
|
698
|
+
q.title,
|
|
699
|
+
q.notes,
|
|
700
|
+
q.query_type,
|
|
701
|
+
q.tables_detected,
|
|
702
|
+
q.is_favorite,
|
|
703
|
+
q.is_saved,
|
|
704
|
+
q.is_destructive,
|
|
705
|
+
q.use_count,
|
|
706
|
+
q.first_executed_at,
|
|
707
|
+
q.last_used_at,
|
|
708
|
+
latest.id AS last_run_id,
|
|
709
|
+
latest.executed_at AS last_run_executed_at,
|
|
710
|
+
latest.duration_ms AS last_run_duration_ms,
|
|
711
|
+
latest.row_count AS last_run_row_count,
|
|
712
|
+
latest.status AS last_run_status,
|
|
713
|
+
latest.error_message AS last_run_error_message,
|
|
714
|
+
latest.affected_rows AS last_run_affected_rows
|
|
715
|
+
${baseFromSql}
|
|
716
|
+
${whereSql}
|
|
717
|
+
${orderBySql}
|
|
718
|
+
LIMIT ?
|
|
719
|
+
OFFSET ?
|
|
720
|
+
`)
|
|
721
|
+
.all(...params, normalizedLimit, normalizedOffset)
|
|
722
|
+
.map((row) => this.decorateQueryHistoryRow(row));
|
|
723
|
+
const countRow = this.db
|
|
724
|
+
.prepare(`
|
|
725
|
+
SELECT COUNT(*) AS count
|
|
726
|
+
${baseFromSql}
|
|
727
|
+
${whereSql}
|
|
728
|
+
`)
|
|
729
|
+
.get(...params);
|
|
730
|
+
const total = Number(countRow?.count ?? 0);
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
items: rows,
|
|
734
|
+
total,
|
|
735
|
+
limit: normalizedLimit,
|
|
736
|
+
offset: normalizedOffset,
|
|
737
|
+
hasMore: normalizedOffset + rows.length < total,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
migrateLegacySqlHistory() {
|
|
742
|
+
if (this.getMetaValue(QUERY_HISTORY_MIGRATION_KEY) === "1") {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const hasLegacyTable = Boolean(
|
|
747
|
+
this.db
|
|
748
|
+
.prepare(
|
|
749
|
+
"SELECT 1 AS exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'sql_history'"
|
|
750
|
+
)
|
|
751
|
+
.get()
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
if (!hasLegacyTable) {
|
|
755
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const legacyCount = Number(
|
|
760
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM sql_history").get()?.count ?? 0
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
if (!legacyCount) {
|
|
764
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const currentHistoryCount = Number(
|
|
769
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM query_history").get()?.count ?? 0
|
|
770
|
+
);
|
|
771
|
+
const currentRunCount = Number(
|
|
772
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM query_runs").get()?.count ?? 0
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
if (currentHistoryCount > 0 || currentRunCount > 0) {
|
|
776
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const legacyRows = this.db
|
|
781
|
+
.prepare(`
|
|
782
|
+
SELECT
|
|
783
|
+
connectionId,
|
|
784
|
+
connectionLabel,
|
|
785
|
+
sql,
|
|
786
|
+
timingMs,
|
|
787
|
+
rowCount,
|
|
788
|
+
affectedRowCount,
|
|
789
|
+
executedAt
|
|
790
|
+
FROM sql_history
|
|
791
|
+
ORDER BY executedAt ASC, id ASC
|
|
792
|
+
`)
|
|
793
|
+
.all();
|
|
794
|
+
|
|
795
|
+
this.db.transaction(() => {
|
|
796
|
+
legacyRows.forEach((entry) => {
|
|
797
|
+
const databaseKey =
|
|
798
|
+
this.normalizeQueryHistoryText(entry.connectionId) ??
|
|
799
|
+
this.normalizeQueryHistoryText(entry.connectionLabel) ??
|
|
800
|
+
"legacy:default";
|
|
801
|
+
const rawSql = String(entry.sql ?? "");
|
|
802
|
+
|
|
803
|
+
if (!normalizeSql(rawSql)) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
this.recordQueryExecutionInTransaction({
|
|
808
|
+
databaseKey,
|
|
809
|
+
rawSql,
|
|
810
|
+
status: "success",
|
|
811
|
+
durationMs: entry.timingMs,
|
|
812
|
+
rowCount: entry.rowCount,
|
|
813
|
+
affectedRows: entry.affectedRowCount,
|
|
814
|
+
executedAt: entry.executedAt ?? new Date().toISOString(),
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
})();
|
|
818
|
+
|
|
819
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
recordQueryExecution(entry = {}) {
|
|
823
|
+
return this.db.transaction(() => this.recordQueryExecutionInTransaction(entry))();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
recordQueryExecutionInTransaction({
|
|
827
|
+
databaseKey,
|
|
828
|
+
rawSql,
|
|
829
|
+
status,
|
|
830
|
+
durationMs = null,
|
|
831
|
+
rowCount = null,
|
|
832
|
+
affectedRows = null,
|
|
833
|
+
errorMessage = null,
|
|
834
|
+
executedAt = null,
|
|
835
|
+
} = {}) {
|
|
836
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
837
|
+
const normalizedRawSql = String(rawSql ?? "");
|
|
838
|
+
const normalizedSql = normalizeSql(normalizedRawSql);
|
|
839
|
+
|
|
840
|
+
if (!normalizedDatabaseKey) {
|
|
841
|
+
throw new ValidationError("Query history requires a database key.");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (!normalizedSql) {
|
|
845
|
+
throw new ValidationError("Query history requires executable SQL.");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!["success", "error"].includes(status)) {
|
|
849
|
+
throw new ValidationError(`Unsupported query run status: ${status}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const queryType = detectQueryType(normalizedRawSql);
|
|
853
|
+
const tablesDetected = detectTables(normalizedRawSql);
|
|
854
|
+
const timestamp = this.normalizeQueryHistoryText(executedAt) ?? new Date().toISOString();
|
|
855
|
+
const destructive = isDestructiveQuery(normalizedRawSql) ? 1 : 0;
|
|
856
|
+
const serializedTables = JSON.stringify(tablesDetected);
|
|
857
|
+
const existing = this.db
|
|
858
|
+
.prepare(
|
|
859
|
+
`
|
|
860
|
+
SELECT id
|
|
861
|
+
FROM query_history
|
|
862
|
+
WHERE database_key = ? AND normalized_sql = ?
|
|
863
|
+
`
|
|
864
|
+
)
|
|
865
|
+
.get(normalizedDatabaseKey, normalizedSql);
|
|
866
|
+
let historyId = Number(existing?.id ?? 0);
|
|
867
|
+
|
|
868
|
+
if (historyId) {
|
|
869
|
+
this.db
|
|
870
|
+
.prepare(`
|
|
871
|
+
UPDATE query_history
|
|
872
|
+
SET
|
|
873
|
+
raw_sql = ?,
|
|
874
|
+
query_type = ?,
|
|
875
|
+
tables_detected = ?,
|
|
876
|
+
is_destructive = ?,
|
|
877
|
+
use_count = use_count + 1,
|
|
878
|
+
last_used_at = ?
|
|
879
|
+
WHERE id = ?
|
|
880
|
+
`)
|
|
881
|
+
.run(
|
|
882
|
+
normalizedRawSql,
|
|
883
|
+
queryType,
|
|
884
|
+
serializedTables,
|
|
885
|
+
destructive,
|
|
886
|
+
timestamp,
|
|
887
|
+
historyId
|
|
888
|
+
);
|
|
889
|
+
} else {
|
|
890
|
+
const insertResult = this.db
|
|
891
|
+
.prepare(`
|
|
892
|
+
INSERT INTO query_history (
|
|
893
|
+
database_key,
|
|
894
|
+
normalized_sql,
|
|
895
|
+
raw_sql,
|
|
896
|
+
query_type,
|
|
897
|
+
tables_detected,
|
|
898
|
+
is_destructive,
|
|
899
|
+
use_count,
|
|
900
|
+
first_executed_at,
|
|
901
|
+
last_used_at
|
|
902
|
+
)
|
|
903
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
904
|
+
`)
|
|
905
|
+
.run(
|
|
906
|
+
normalizedDatabaseKey,
|
|
907
|
+
normalizedSql,
|
|
908
|
+
normalizedRawSql,
|
|
909
|
+
queryType,
|
|
910
|
+
serializedTables,
|
|
911
|
+
destructive,
|
|
912
|
+
timestamp,
|
|
913
|
+
timestamp
|
|
914
|
+
);
|
|
915
|
+
historyId = Number(insertResult.lastInsertRowid);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
this.db
|
|
919
|
+
.prepare(`
|
|
920
|
+
INSERT INTO query_runs (
|
|
921
|
+
history_id,
|
|
922
|
+
executed_at,
|
|
923
|
+
duration_ms,
|
|
924
|
+
row_count,
|
|
925
|
+
status,
|
|
926
|
+
error_message,
|
|
927
|
+
affected_rows
|
|
928
|
+
)
|
|
929
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
930
|
+
`)
|
|
931
|
+
.run(
|
|
932
|
+
historyId,
|
|
933
|
+
timestamp,
|
|
934
|
+
this.normalizeQueryHistoryInteger(durationMs),
|
|
935
|
+
this.normalizeQueryHistoryInteger(rowCount),
|
|
936
|
+
status,
|
|
937
|
+
this.normalizeQueryHistoryText(errorMessage),
|
|
938
|
+
this.normalizeQueryHistoryInteger(affectedRows)
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return historyId;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
getRecentQueries(options = {}) {
|
|
945
|
+
return this.buildQueryHistoryCollection(options);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
getFailedQueries(options = {}) {
|
|
949
|
+
return this.buildQueryHistoryCollection({
|
|
950
|
+
...options,
|
|
951
|
+
latestStatus: "error",
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
getQueryHistoryItemById(historyId) {
|
|
956
|
+
const row = this.db
|
|
957
|
+
.prepare(`
|
|
958
|
+
SELECT
|
|
959
|
+
q.id,
|
|
960
|
+
q.database_key,
|
|
961
|
+
q.normalized_sql,
|
|
962
|
+
q.raw_sql,
|
|
963
|
+
q.title,
|
|
964
|
+
q.notes,
|
|
965
|
+
q.query_type,
|
|
966
|
+
q.tables_detected,
|
|
967
|
+
q.is_favorite,
|
|
968
|
+
q.is_saved,
|
|
969
|
+
q.is_destructive,
|
|
970
|
+
q.use_count,
|
|
971
|
+
q.first_executed_at,
|
|
972
|
+
q.last_used_at,
|
|
973
|
+
latest.id AS last_run_id,
|
|
974
|
+
latest.executed_at AS last_run_executed_at,
|
|
975
|
+
latest.duration_ms AS last_run_duration_ms,
|
|
976
|
+
latest.row_count AS last_run_row_count,
|
|
977
|
+
latest.status AS last_run_status,
|
|
978
|
+
latest.error_message AS last_run_error_message,
|
|
979
|
+
latest.affected_rows AS last_run_affected_rows
|
|
980
|
+
FROM query_history q
|
|
981
|
+
LEFT JOIN query_runs latest
|
|
982
|
+
ON latest.id = (
|
|
983
|
+
SELECT runs.id
|
|
984
|
+
FROM query_runs runs
|
|
985
|
+
WHERE runs.history_id = q.id
|
|
986
|
+
ORDER BY runs.executed_at DESC, runs.id DESC
|
|
987
|
+
LIMIT 1
|
|
988
|
+
)
|
|
989
|
+
WHERE q.id = ?
|
|
990
|
+
`)
|
|
991
|
+
.get(Number(historyId));
|
|
992
|
+
|
|
993
|
+
if (!row) {
|
|
994
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return this.decorateQueryHistoryRow(row);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
getQueryRunsByHistoryId(historyId, limit = 8) {
|
|
1001
|
+
const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
|
|
1002
|
+
|
|
1003
|
+
return this.db
|
|
1004
|
+
.prepare(`
|
|
1005
|
+
SELECT
|
|
1006
|
+
id,
|
|
1007
|
+
history_id,
|
|
1008
|
+
executed_at,
|
|
1009
|
+
duration_ms,
|
|
1010
|
+
row_count,
|
|
1011
|
+
status,
|
|
1012
|
+
error_message,
|
|
1013
|
+
affected_rows
|
|
1014
|
+
FROM query_runs
|
|
1015
|
+
WHERE history_id = ?
|
|
1016
|
+
ORDER BY executed_at DESC, id DESC
|
|
1017
|
+
LIMIT ?
|
|
1018
|
+
`)
|
|
1019
|
+
.all(Number(historyId), normalizedLimit)
|
|
1020
|
+
.map((row) => this.decorateQueryRun(row));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
updateQueryHistoryField(historyId, fieldName, value) {
|
|
1024
|
+
const result = this.db
|
|
1025
|
+
.prepare(`UPDATE query_history SET ${fieldName} = ? WHERE id = ?`)
|
|
1026
|
+
.run(value, Number(historyId));
|
|
1027
|
+
|
|
1028
|
+
if (!result.changes) {
|
|
1029
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return this.getQueryHistoryItemById(historyId);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
toggleFavorite(historyId, nextValue) {
|
|
1036
|
+
return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
toggleSaved(historyId, nextValue) {
|
|
1040
|
+
return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
renameQuery(historyId, title) {
|
|
1044
|
+
return this.updateQueryHistoryField(
|
|
1045
|
+
historyId,
|
|
1046
|
+
"title",
|
|
1047
|
+
this.normalizeQueryHistoryText(title)
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
updateQueryNotes(historyId, notes) {
|
|
1052
|
+
return this.updateQueryHistoryField(
|
|
1053
|
+
historyId,
|
|
1054
|
+
"notes",
|
|
1055
|
+
this.normalizeQueryHistoryText(notes)
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
deleteQueryHistoryItem(historyId) {
|
|
1060
|
+
const result = this.db
|
|
1061
|
+
.prepare("DELETE FROM query_history WHERE id = ?")
|
|
1062
|
+
.run(Number(historyId));
|
|
1063
|
+
|
|
1064
|
+
if (!result.changes) {
|
|
1065
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
clearQueryHistoryForDatabase(databaseKey) {
|
|
1072
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1073
|
+
|
|
1074
|
+
if (!normalizedDatabaseKey) {
|
|
1075
|
+
return 0;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return this.db
|
|
1079
|
+
.prepare("DELETE FROM query_history WHERE database_key = ?")
|
|
1080
|
+
.run(normalizedDatabaseKey).changes;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
410
1083
|
trimRecentConnections() {
|
|
411
1084
|
const maxRecentConnections = Number(
|
|
412
1085
|
this.getSettings().maxRecentConnections ?? DEFAULT_STATE.settings.maxRecentConnections
|