sqlite-hub 1.4.0 → 1.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/README.md +9 -0
- package/bin/sqlite-hub.js +555 -122
- package/docs/API.md +30 -0
- package/docs/CLI.md +22 -0
- package/docs/CLI_API_PARITY.md +61 -0
- package/docs/changelog.md +9 -1
- package/docs/guidelines/AGENTS.md +32 -0
- package/docs/{DESIGN_GUIDELINES.md → guidelines/DESIGN.md} +10 -0
- package/docs/todo.md +1 -13
- package/frontend/js/api.js +45 -0
- package/frontend/js/app.js +192 -2
- package/frontend/js/components/connectionCard.js +3 -1
- package/frontend/js/components/modal.js +356 -15
- package/frontend/js/components/sidebar.js +41 -7
- package/frontend/js/components/topNav.js +2 -14
- package/frontend/js/router.js +10 -0
- package/frontend/js/store.js +448 -0
- package/frontend/js/utils/inputClear.js +36 -0
- package/frontend/js/utils/syntheticData.js +240 -0
- package/frontend/js/views/backups.js +52 -0
- package/frontend/js/views/data.js +16 -0
- package/frontend/js/views/logs.js +339 -0
- package/frontend/js/views/settings.js +77 -27
- package/frontend/js/views/structure.js +6 -0
- package/frontend/js/views/tableAdvisor.js +385 -0
- package/frontend/js/views/tableDesigner.js +6 -0
- package/frontend/styles/components.css +149 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +8 -0
- package/package.json +1 -1
- package/server/routes/data.js +45 -0
- package/server/routes/externalApi.js +285 -2
- package/server/routes/logs.js +127 -0
- package/server/server.js +3 -0
- package/server/services/databaseCommandService.js +45 -0
- package/server/services/sqlite/dataBrowserService.js +36 -0
- package/server/services/sqlite/introspection.js +226 -22
- package/server/services/sqlite/syntheticDataGenerator.js +728 -0
- package/server/services/sqlite/tableAdvisor.js +790 -0
- package/server/services/storage/appStateStore.js +773 -169
|
@@ -27,12 +27,10 @@ const {
|
|
|
27
27
|
const DEFAULT_STATE = {
|
|
28
28
|
recentConnections: [],
|
|
29
29
|
activeConnectionId: null,
|
|
30
|
-
sqlHistory: [],
|
|
31
30
|
settings: {
|
|
32
31
|
defaultPageSize: 50,
|
|
33
32
|
maxPageSize: 200,
|
|
34
33
|
maxRecentConnections: 12,
|
|
35
|
-
maxSqlHistory: 100,
|
|
36
34
|
busyTimeoutMs: 5000,
|
|
37
35
|
csvDelimiter: ",",
|
|
38
36
|
},
|
|
@@ -45,6 +43,14 @@ const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
|
45
43
|
const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
46
44
|
const MAX_DOCUMENT_FILENAME_LENGTH = 160;
|
|
47
45
|
const QUERY_EXECUTION_SOURCES = new Set(["api", "cli", "user", "mcp"]);
|
|
46
|
+
const ACCESS_LOG_SOURCES = new Set(["api", "cli"]);
|
|
47
|
+
const ACCESS_LOG_STATUSES = new Set(["success", "error"]);
|
|
48
|
+
const MAX_ACCESS_LOG_TEXT_LENGTH = 500;
|
|
49
|
+
const MAX_ACCESS_LOG_METADATA_BYTES = 16 * 1024;
|
|
50
|
+
const ACTIVITY_LOG_KINDS = new Set(["all", "query", "access"]);
|
|
51
|
+
const ACTIVITY_LOG_ACTORS = new Set(["user", "cli", "api", "mcp"]);
|
|
52
|
+
const ACTIVITY_LOG_DESTRUCTIVE_FILTERS = new Set(["all", "yes", "no"]);
|
|
53
|
+
const REMOVED_SETTING_KEYS = new Set(["maxSqlHistory"]);
|
|
48
54
|
const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
|
|
49
55
|
"image/jpeg": "jpg",
|
|
50
56
|
"image/png": "png",
|
|
@@ -144,6 +150,80 @@ function requireQueryExecutionSource(value) {
|
|
|
144
150
|
return normalized;
|
|
145
151
|
}
|
|
146
152
|
|
|
153
|
+
function normalizeAccessLogText(value, fallback = null) {
|
|
154
|
+
const normalized = String(value ?? fallback ?? "")
|
|
155
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
156
|
+
.replace(/\s+/g, " ")
|
|
157
|
+
.trim();
|
|
158
|
+
|
|
159
|
+
return normalized ? normalized.slice(0, MAX_ACCESS_LOG_TEXT_LENGTH) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function requireAccessLogText(value, label) {
|
|
163
|
+
const normalized = normalizeAccessLogText(value);
|
|
164
|
+
|
|
165
|
+
if (!normalized) {
|
|
166
|
+
throw new ValidationError(`${label} is required.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return normalized;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeAccessLogSource(value) {
|
|
173
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
174
|
+
|
|
175
|
+
if (!ACCESS_LOG_SOURCES.has(normalized)) {
|
|
176
|
+
throw new ValidationError(`Unsupported access log source: ${value}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return normalized;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeAccessLogStatus(value) {
|
|
183
|
+
const normalized = String(value ?? "success").trim().toLowerCase();
|
|
184
|
+
|
|
185
|
+
if (!ACCESS_LOG_STATUSES.has(normalized)) {
|
|
186
|
+
throw new ValidationError(`Unsupported access log status: ${value}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return normalized;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeAccessLogInteger(value) {
|
|
193
|
+
if (value === null || value === undefined || value === "") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const normalized = Number(value);
|
|
198
|
+
return Number.isInteger(normalized) && normalized >= 0 ? normalized : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeAccessLogMetadata(metadata = {}) {
|
|
202
|
+
const safeMetadata =
|
|
203
|
+
metadata && typeof metadata === "object" && !Array.isArray(metadata)
|
|
204
|
+
? metadata
|
|
205
|
+
: {};
|
|
206
|
+
|
|
207
|
+
let serialized = "{}";
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
serialized = JSON.stringify(safeMetadata, (_key, value) =>
|
|
211
|
+
typeof value === "bigint" ? String(value) : value
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
serialized = JSON.stringify({ serializationError: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (Buffer.byteLength(serialized, "utf8") <= MAX_ACCESS_LOG_METADATA_BYTES) {
|
|
218
|
+
return serialized;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return JSON.stringify({
|
|
222
|
+
truncated: true,
|
|
223
|
+
originalBytes: Buffer.byteLength(serialized, "utf8"),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
147
227
|
function normalizeDocumentTitle(value, filename) {
|
|
148
228
|
const title = String(value ?? "").trim();
|
|
149
229
|
|
|
@@ -283,6 +363,30 @@ function readUtf8File(fileUrl) {
|
|
|
283
363
|
}
|
|
284
364
|
}
|
|
285
365
|
|
|
366
|
+
function pickLatestTimestamp(...timestamps) {
|
|
367
|
+
return timestamps.reduce((latest, timestamp) => {
|
|
368
|
+
const normalizedTimestamp = String(timestamp ?? "").trim();
|
|
369
|
+
|
|
370
|
+
if (!normalizedTimestamp) {
|
|
371
|
+
return latest;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const timestampMs = Date.parse(normalizedTimestamp);
|
|
375
|
+
|
|
376
|
+
if (!Number.isFinite(timestampMs)) {
|
|
377
|
+
return latest ?? normalizedTimestamp;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const latestMs = Date.parse(latest ?? "");
|
|
381
|
+
|
|
382
|
+
if (!latest || !Number.isFinite(latestMs) || timestampMs > latestMs) {
|
|
383
|
+
return normalizedTimestamp;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return latest;
|
|
387
|
+
}, null);
|
|
388
|
+
}
|
|
389
|
+
|
|
286
390
|
class AppStateStore {
|
|
287
391
|
constructor(filePath, options = {}) {
|
|
288
392
|
this.filePath = normalizeStateStorePath(filePath, "App state database path");
|
|
@@ -350,25 +454,16 @@ class AppStateStore {
|
|
|
350
454
|
logoPath TEXT
|
|
351
455
|
);
|
|
352
456
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
resultKind TEXT,
|
|
360
|
-
affectedRowCount INTEGER NOT NULL DEFAULT 0,
|
|
361
|
-
rowCount INTEGER NOT NULL DEFAULT 0,
|
|
362
|
-
timingMs INTEGER NOT NULL DEFAULT 0,
|
|
363
|
-
executedAt TEXT NOT NULL
|
|
364
|
-
);
|
|
457
|
+
DROP INDEX IF EXISTS idx_sql_history_executed_at;
|
|
458
|
+
|
|
459
|
+
DROP TABLE IF EXISTS sql_history;
|
|
460
|
+
|
|
461
|
+
DELETE FROM settings
|
|
462
|
+
WHERE key = 'maxSqlHistory';
|
|
365
463
|
|
|
366
464
|
CREATE INDEX IF NOT EXISTS idx_recent_connections_last_opened
|
|
367
465
|
ON recent_connections(lastOpenedAt DESC, id ASC);
|
|
368
466
|
|
|
369
|
-
CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
|
|
370
|
-
ON sql_history(executedAt DESC, id ASC);
|
|
371
|
-
|
|
372
467
|
CREATE TABLE IF NOT EXISTS query_history (
|
|
373
468
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
374
469
|
database_key TEXT NOT NULL,
|
|
@@ -488,6 +583,29 @@ class AppStateStore {
|
|
|
488
583
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_database_created
|
|
489
584
|
ON api_tokens(database_key, created_at DESC, id ASC);
|
|
490
585
|
|
|
586
|
+
CREATE TABLE IF NOT EXISTS access_log (
|
|
587
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
588
|
+
source TEXT NOT NULL CHECK(source IN ('api', 'cli')),
|
|
589
|
+
action TEXT NOT NULL,
|
|
590
|
+
database_key TEXT,
|
|
591
|
+
target_type TEXT,
|
|
592
|
+
target_name TEXT,
|
|
593
|
+
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
|
594
|
+
started_at TEXT NOT NULL,
|
|
595
|
+
duration_ms INTEGER,
|
|
596
|
+
error_message TEXT,
|
|
597
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_started
|
|
601
|
+
ON access_log(started_at DESC, id DESC);
|
|
602
|
+
|
|
603
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_source_started
|
|
604
|
+
ON access_log(source, started_at DESC, id DESC);
|
|
605
|
+
|
|
606
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_database_started
|
|
607
|
+
ON access_log(database_key, started_at DESC, id DESC);
|
|
608
|
+
|
|
491
609
|
CREATE TABLE IF NOT EXISTS backups (
|
|
492
610
|
id TEXT PRIMARY KEY,
|
|
493
611
|
connectionId TEXT,
|
|
@@ -672,25 +790,6 @@ class AppStateStore {
|
|
|
672
790
|
)
|
|
673
791
|
.all()
|
|
674
792
|
: [],
|
|
675
|
-
sqlHistory: tables.has("sql_history")
|
|
676
|
-
? legacyDb
|
|
677
|
-
.prepare(`
|
|
678
|
-
SELECT
|
|
679
|
-
id,
|
|
680
|
-
connectionId,
|
|
681
|
-
connectionLabel,
|
|
682
|
-
sql,
|
|
683
|
-
statementCount,
|
|
684
|
-
resultKind,
|
|
685
|
-
affectedRowCount,
|
|
686
|
-
rowCount,
|
|
687
|
-
timingMs,
|
|
688
|
-
executedAt
|
|
689
|
-
FROM sql_history
|
|
690
|
-
ORDER BY executedAt DESC, id ASC
|
|
691
|
-
`)
|
|
692
|
-
.all()
|
|
693
|
-
: [],
|
|
694
793
|
activeConnectionId: tables.has("app_meta")
|
|
695
794
|
? legacyDb
|
|
696
795
|
.prepare("SELECT value FROM app_meta WHERE key = ?")
|
|
@@ -729,34 +828,13 @@ class AppStateStore {
|
|
|
729
828
|
readOnly = excluded.readOnly,
|
|
730
829
|
logoPath = excluded.logoPath
|
|
731
830
|
`);
|
|
732
|
-
const insertHistory = this.db.prepare(`
|
|
733
|
-
INSERT INTO sql_history (
|
|
734
|
-
id,
|
|
735
|
-
connectionId,
|
|
736
|
-
connectionLabel,
|
|
737
|
-
sql,
|
|
738
|
-
statementCount,
|
|
739
|
-
resultKind,
|
|
740
|
-
affectedRowCount,
|
|
741
|
-
rowCount,
|
|
742
|
-
timingMs,
|
|
743
|
-
executedAt
|
|
744
|
-
)
|
|
745
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
746
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
747
|
-
connectionId = excluded.connectionId,
|
|
748
|
-
connectionLabel = excluded.connectionLabel,
|
|
749
|
-
sql = excluded.sql,
|
|
750
|
-
statementCount = excluded.statementCount,
|
|
751
|
-
resultKind = excluded.resultKind,
|
|
752
|
-
affectedRowCount = excluded.affectedRowCount,
|
|
753
|
-
rowCount = excluded.rowCount,
|
|
754
|
-
timingMs = excluded.timingMs,
|
|
755
|
-
executedAt = excluded.executedAt
|
|
756
|
-
`);
|
|
757
831
|
|
|
758
832
|
this.db.transaction(() => {
|
|
759
833
|
for (const [key, value] of Object.entries(legacyState.settings ?? {})) {
|
|
834
|
+
if (REMOVED_SETTING_KEYS.has(key)) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
760
838
|
const normalizedValue =
|
|
761
839
|
typeof value === "string" ? this.parseStoredValue(value) : value;
|
|
762
840
|
|
|
@@ -776,21 +854,6 @@ class AppStateStore {
|
|
|
776
854
|
);
|
|
777
855
|
}
|
|
778
856
|
|
|
779
|
-
for (const entry of legacyState.sqlHistory ?? []) {
|
|
780
|
-
insertHistory.run(
|
|
781
|
-
entry.id,
|
|
782
|
-
entry.connectionId ?? null,
|
|
783
|
-
entry.connectionLabel ?? null,
|
|
784
|
-
entry.sql ?? "",
|
|
785
|
-
Number(entry.statementCount ?? 0),
|
|
786
|
-
entry.resultKind ?? null,
|
|
787
|
-
Number(entry.affectedRowCount ?? 0),
|
|
788
|
-
Number(entry.rowCount ?? 0),
|
|
789
|
-
Number(entry.timingMs ?? 0),
|
|
790
|
-
entry.executedAt ?? new Date().toISOString()
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
857
|
this.setMetaValue("activeConnectionId", legacyState.activeConnectionId ?? null);
|
|
795
858
|
this.setMetaValue(
|
|
796
859
|
"legacyImportSource",
|
|
@@ -1710,6 +1773,532 @@ class AppStateStore {
|
|
|
1710
1773
|
};
|
|
1711
1774
|
}
|
|
1712
1775
|
|
|
1776
|
+
decorateAccessLogRow(row = {}) {
|
|
1777
|
+
let metadata = {};
|
|
1778
|
+
|
|
1779
|
+
try {
|
|
1780
|
+
metadata = JSON.parse(row.metadata_json ?? row.metadataJson ?? "{}");
|
|
1781
|
+
} catch {
|
|
1782
|
+
metadata = {};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
return {
|
|
1786
|
+
id: Number(row.id ?? 0),
|
|
1787
|
+
source: String(row.source ?? ""),
|
|
1788
|
+
action: String(row.action ?? ""),
|
|
1789
|
+
databaseKey: row.database_key ?? row.databaseKey ?? null,
|
|
1790
|
+
targetType: row.target_type ?? row.targetType ?? null,
|
|
1791
|
+
targetName: row.target_name ?? row.targetName ?? null,
|
|
1792
|
+
status: String(row.status ?? ""),
|
|
1793
|
+
startedAt: row.started_at ?? row.startedAt ?? null,
|
|
1794
|
+
durationMs:
|
|
1795
|
+
row.duration_ms === null || row.duration_ms === undefined
|
|
1796
|
+
? null
|
|
1797
|
+
: Number(row.duration_ms),
|
|
1798
|
+
errorMessage: row.error_message ?? row.errorMessage ?? null,
|
|
1799
|
+
metadata,
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
recordAccessLog(entry = {}) {
|
|
1804
|
+
const source = normalizeAccessLogSource(entry.source);
|
|
1805
|
+
const action = requireAccessLogText(entry.action, "Access log action");
|
|
1806
|
+
const databaseKey = normalizeAccessLogText(entry.databaseKey);
|
|
1807
|
+
const targetType = normalizeAccessLogText(entry.targetType);
|
|
1808
|
+
const targetName = normalizeAccessLogText(entry.targetName);
|
|
1809
|
+
const status = normalizeAccessLogStatus(entry.status);
|
|
1810
|
+
const startedAt = normalizeAccessLogText(entry.startedAt) ?? new Date().toISOString();
|
|
1811
|
+
const durationMs = normalizeAccessLogInteger(entry.durationMs);
|
|
1812
|
+
const errorMessage = normalizeAccessLogText(entry.errorMessage);
|
|
1813
|
+
const metadataJson = normalizeAccessLogMetadata(entry.metadata);
|
|
1814
|
+
|
|
1815
|
+
const result = this.db
|
|
1816
|
+
.prepare(`
|
|
1817
|
+
INSERT INTO access_log (
|
|
1818
|
+
source,
|
|
1819
|
+
action,
|
|
1820
|
+
database_key,
|
|
1821
|
+
target_type,
|
|
1822
|
+
target_name,
|
|
1823
|
+
status,
|
|
1824
|
+
started_at,
|
|
1825
|
+
duration_ms,
|
|
1826
|
+
error_message,
|
|
1827
|
+
metadata_json
|
|
1828
|
+
)
|
|
1829
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1830
|
+
`)
|
|
1831
|
+
.run(
|
|
1832
|
+
source,
|
|
1833
|
+
action,
|
|
1834
|
+
databaseKey,
|
|
1835
|
+
targetType,
|
|
1836
|
+
targetName,
|
|
1837
|
+
status,
|
|
1838
|
+
startedAt,
|
|
1839
|
+
durationMs,
|
|
1840
|
+
errorMessage,
|
|
1841
|
+
metadataJson
|
|
1842
|
+
);
|
|
1843
|
+
|
|
1844
|
+
return this.getAccessLogEntry(result.lastInsertRowid);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
getAccessLogEntry(logId) {
|
|
1848
|
+
const row = this.db
|
|
1849
|
+
.prepare(`
|
|
1850
|
+
SELECT
|
|
1851
|
+
id,
|
|
1852
|
+
source,
|
|
1853
|
+
action,
|
|
1854
|
+
database_key,
|
|
1855
|
+
target_type,
|
|
1856
|
+
target_name,
|
|
1857
|
+
status,
|
|
1858
|
+
started_at,
|
|
1859
|
+
duration_ms,
|
|
1860
|
+
error_message,
|
|
1861
|
+
metadata_json
|
|
1862
|
+
FROM access_log
|
|
1863
|
+
WHERE id = ?
|
|
1864
|
+
`)
|
|
1865
|
+
.get(Number(logId));
|
|
1866
|
+
|
|
1867
|
+
if (!row) {
|
|
1868
|
+
throw new NotFoundError(`Access log entry not found: ${logId}`);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
return this.decorateAccessLogRow(row);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
listAccessLogs(options = {}) {
|
|
1875
|
+
const filters = [];
|
|
1876
|
+
const params = [];
|
|
1877
|
+
const source = options.source ? normalizeAccessLogSource(options.source) : null;
|
|
1878
|
+
const status = options.status ? normalizeAccessLogStatus(options.status) : null;
|
|
1879
|
+
const databaseKey = normalizeAccessLogText(options.databaseKey);
|
|
1880
|
+
const limit = Math.max(1, Math.min(200, Number(options.limit) || 100));
|
|
1881
|
+
const offset = Math.max(0, Number(options.offset) || 0);
|
|
1882
|
+
|
|
1883
|
+
if (source) {
|
|
1884
|
+
filters.push("source = ?");
|
|
1885
|
+
params.push(source);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (status) {
|
|
1889
|
+
filters.push("status = ?");
|
|
1890
|
+
params.push(status);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
if (databaseKey) {
|
|
1894
|
+
filters.push("database_key = ?");
|
|
1895
|
+
params.push(databaseKey);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
|
1899
|
+
const rows = this.db
|
|
1900
|
+
.prepare(`
|
|
1901
|
+
SELECT
|
|
1902
|
+
id,
|
|
1903
|
+
source,
|
|
1904
|
+
action,
|
|
1905
|
+
database_key,
|
|
1906
|
+
target_type,
|
|
1907
|
+
target_name,
|
|
1908
|
+
status,
|
|
1909
|
+
started_at,
|
|
1910
|
+
duration_ms,
|
|
1911
|
+
error_message,
|
|
1912
|
+
metadata_json
|
|
1913
|
+
FROM access_log
|
|
1914
|
+
${whereClause}
|
|
1915
|
+
ORDER BY started_at DESC, id DESC
|
|
1916
|
+
LIMIT ? OFFSET ?
|
|
1917
|
+
`)
|
|
1918
|
+
.all(...params, limit, offset)
|
|
1919
|
+
.map((row) => this.decorateAccessLogRow(row));
|
|
1920
|
+
const total = Number(
|
|
1921
|
+
this.db
|
|
1922
|
+
.prepare(`SELECT COUNT(*) AS count FROM access_log ${whereClause}`)
|
|
1923
|
+
.get(...params)?.count ?? 0
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
return {
|
|
1927
|
+
items: rows,
|
|
1928
|
+
total,
|
|
1929
|
+
limit,
|
|
1930
|
+
offset,
|
|
1931
|
+
hasMore: offset + rows.length < total,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
normalizeActivityLogKind(value) {
|
|
1936
|
+
const normalized = String(value ?? "all").trim().toLowerCase();
|
|
1937
|
+
return ACTIVITY_LOG_KINDS.has(normalized) ? normalized : "all";
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
normalizeActivityLogActor(value) {
|
|
1941
|
+
const normalized = String(value ?? "all").trim().toLowerCase();
|
|
1942
|
+
return ACTIVITY_LOG_ACTORS.has(normalized) ? normalized : null;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
normalizeActivityLogDestructive(value) {
|
|
1946
|
+
const normalized = String(value ?? "all").trim().toLowerCase();
|
|
1947
|
+
return ACTIVITY_LOG_DESTRUCTIVE_FILTERS.has(normalized) ? normalized : "all";
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
normalizeActivityLogTimestamp(value) {
|
|
1951
|
+
const normalized = normalizeAccessLogText(value);
|
|
1952
|
+
|
|
1953
|
+
if (!normalized) {
|
|
1954
|
+
return null;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const timestamp = new Date(normalized).getTime();
|
|
1958
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
decorateQueryRunLogRow(row = {}) {
|
|
1962
|
+
let tablesDetected = [];
|
|
1963
|
+
|
|
1964
|
+
try {
|
|
1965
|
+
tablesDetected = JSON.parse(row.tables_detected ?? "[]");
|
|
1966
|
+
} catch {
|
|
1967
|
+
tablesDetected = [];
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return {
|
|
1971
|
+
id: `query:${Number(row.run_id ?? 0)}`,
|
|
1972
|
+
kind: "query",
|
|
1973
|
+
source: "query_history",
|
|
1974
|
+
action: "query.execute",
|
|
1975
|
+
databaseKey: row.database_key ?? null,
|
|
1976
|
+
targetType: "query",
|
|
1977
|
+
targetName: row.title || buildAutoTitle(row.raw_sql ?? "", {
|
|
1978
|
+
queryType: row.query_type ?? "other",
|
|
1979
|
+
tablesDetected,
|
|
1980
|
+
}),
|
|
1981
|
+
status: String(row.status ?? ""),
|
|
1982
|
+
occurredAt: row.executed_at ?? null,
|
|
1983
|
+
durationMs:
|
|
1984
|
+
row.duration_ms === null || row.duration_ms === undefined
|
|
1985
|
+
? null
|
|
1986
|
+
: Number(row.duration_ms),
|
|
1987
|
+
errorMessage: row.error_message ?? null,
|
|
1988
|
+
executedBy: normalizeQueryExecutionSource(row.executed_by ?? "user"),
|
|
1989
|
+
queryType: row.query_type ?? "other",
|
|
1990
|
+
destructive: Boolean(row.is_destructive),
|
|
1991
|
+
historyId: Number(row.history_id ?? 0),
|
|
1992
|
+
runId: Number(row.run_id ?? 0),
|
|
1993
|
+
rowCount:
|
|
1994
|
+
row.row_count === null || row.row_count === undefined
|
|
1995
|
+
? null
|
|
1996
|
+
: Number(row.row_count),
|
|
1997
|
+
affectedRows:
|
|
1998
|
+
row.affected_rows === null || row.affected_rows === undefined
|
|
1999
|
+
? null
|
|
2000
|
+
: Number(row.affected_rows),
|
|
2001
|
+
preview: buildSqlPreview(row.raw_sql ?? ""),
|
|
2002
|
+
rawSql: row.raw_sql ?? "",
|
|
2003
|
+
metadata: {
|
|
2004
|
+
tablesDetected,
|
|
2005
|
+
saved: Boolean(row.is_saved),
|
|
2006
|
+
favorite: Boolean(row.is_favorite),
|
|
2007
|
+
},
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
decorateAccessLogForActivity(row = {}) {
|
|
2012
|
+
const entry = this.decorateAccessLogRow(row);
|
|
2013
|
+
|
|
2014
|
+
return {
|
|
2015
|
+
id: `access:${entry.id}`,
|
|
2016
|
+
kind: "access",
|
|
2017
|
+
source: entry.source,
|
|
2018
|
+
action: entry.action,
|
|
2019
|
+
databaseKey: entry.databaseKey,
|
|
2020
|
+
targetType: entry.targetType,
|
|
2021
|
+
targetName: entry.targetName,
|
|
2022
|
+
status: entry.status,
|
|
2023
|
+
occurredAt: entry.startedAt,
|
|
2024
|
+
durationMs: entry.durationMs,
|
|
2025
|
+
errorMessage: entry.errorMessage,
|
|
2026
|
+
executedBy: null,
|
|
2027
|
+
queryType: null,
|
|
2028
|
+
destructive: null,
|
|
2029
|
+
historyId: null,
|
|
2030
|
+
runId: null,
|
|
2031
|
+
rowCount: null,
|
|
2032
|
+
affectedRows: null,
|
|
2033
|
+
preview: entry.action,
|
|
2034
|
+
rawSql: "",
|
|
2035
|
+
metadata: entry.metadata,
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
buildActivityQueryFilters(options = {}) {
|
|
2040
|
+
const filters = [];
|
|
2041
|
+
const params = [];
|
|
2042
|
+
const databaseKey = normalizeAccessLogText(options.databaseKey);
|
|
2043
|
+
const status = options.status ? normalizeAccessLogStatus(options.status) : null;
|
|
2044
|
+
const actor = this.normalizeActivityLogActor(options.actor);
|
|
2045
|
+
const queryType = normalizeAccessLogText(options.queryType);
|
|
2046
|
+
const destructive = this.normalizeActivityLogDestructive(options.destructive);
|
|
2047
|
+
const from = this.normalizeActivityLogTimestamp(options.from);
|
|
2048
|
+
const to = this.normalizeActivityLogTimestamp(options.to);
|
|
2049
|
+
const search = normalizeAccessLogText(options.search);
|
|
2050
|
+
|
|
2051
|
+
if (databaseKey) {
|
|
2052
|
+
filters.push("q.database_key = ?");
|
|
2053
|
+
params.push(databaseKey);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
if (status) {
|
|
2057
|
+
filters.push("runs.status = ?");
|
|
2058
|
+
params.push(status);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
if (actor) {
|
|
2062
|
+
filters.push("runs.executed_by = ?");
|
|
2063
|
+
params.push(actor);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (queryType) {
|
|
2067
|
+
filters.push("q.query_type = ?");
|
|
2068
|
+
params.push(queryType);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
if (destructive === "yes") {
|
|
2072
|
+
filters.push("q.is_destructive = 1");
|
|
2073
|
+
} else if (destructive === "no") {
|
|
2074
|
+
filters.push("q.is_destructive = 0");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (from) {
|
|
2078
|
+
filters.push("runs.executed_at >= ?");
|
|
2079
|
+
params.push(from);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (to) {
|
|
2083
|
+
filters.push("runs.executed_at <= ?");
|
|
2084
|
+
params.push(to);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (search) {
|
|
2088
|
+
const searchPattern = `%${search.toLowerCase()}%`;
|
|
2089
|
+
filters.push(`
|
|
2090
|
+
(
|
|
2091
|
+
LOWER(COALESCE(q.title, '')) LIKE ?
|
|
2092
|
+
OR LOWER(q.raw_sql) LIKE ?
|
|
2093
|
+
OR LOWER(COALESCE(q.notes, '')) LIKE ?
|
|
2094
|
+
OR LOWER(q.tables_detected) LIKE ?
|
|
2095
|
+
OR LOWER(COALESCE(runs.error_message, '')) LIKE ?
|
|
2096
|
+
)
|
|
2097
|
+
`);
|
|
2098
|
+
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
return {
|
|
2102
|
+
whereSql: filters.length ? `WHERE ${filters.join(" AND ")}` : "",
|
|
2103
|
+
params,
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
buildActivityAccessFilters(options = {}) {
|
|
2108
|
+
const filters = [];
|
|
2109
|
+
const params = [];
|
|
2110
|
+
const databaseKey = normalizeAccessLogText(options.databaseKey);
|
|
2111
|
+
const status = options.status ? normalizeAccessLogStatus(options.status) : null;
|
|
2112
|
+
const actor = this.normalizeActivityLogActor(options.actor);
|
|
2113
|
+
const queryType = normalizeAccessLogText(options.queryType);
|
|
2114
|
+
const destructive = this.normalizeActivityLogDestructive(options.destructive);
|
|
2115
|
+
const from = this.normalizeActivityLogTimestamp(options.from);
|
|
2116
|
+
const to = this.normalizeActivityLogTimestamp(options.to);
|
|
2117
|
+
const search = normalizeAccessLogText(options.search);
|
|
2118
|
+
|
|
2119
|
+
if (queryType || destructive !== "all") {
|
|
2120
|
+
return {
|
|
2121
|
+
whereSql: "WHERE 1 = 0",
|
|
2122
|
+
params: [],
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
if (databaseKey) {
|
|
2127
|
+
filters.push("database_key = ?");
|
|
2128
|
+
params.push(databaseKey);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (status) {
|
|
2132
|
+
filters.push("status = ?");
|
|
2133
|
+
params.push(status);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (actor) {
|
|
2137
|
+
if (actor !== "api" && actor !== "cli") {
|
|
2138
|
+
return {
|
|
2139
|
+
whereSql: "WHERE 1 = 0",
|
|
2140
|
+
params: [],
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
filters.push("source = ?");
|
|
2145
|
+
params.push(actor);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
if (from) {
|
|
2149
|
+
filters.push("started_at >= ?");
|
|
2150
|
+
params.push(from);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (to) {
|
|
2154
|
+
filters.push("started_at <= ?");
|
|
2155
|
+
params.push(to);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
if (search) {
|
|
2159
|
+
const searchPattern = `%${search.toLowerCase()}%`;
|
|
2160
|
+
filters.push(`
|
|
2161
|
+
(
|
|
2162
|
+
LOWER(action) LIKE ?
|
|
2163
|
+
OR LOWER(COALESCE(target_type, '')) LIKE ?
|
|
2164
|
+
OR LOWER(COALESCE(target_name, '')) LIKE ?
|
|
2165
|
+
OR LOWER(COALESCE(error_message, '')) LIKE ?
|
|
2166
|
+
OR LOWER(metadata_json) LIKE ?
|
|
2167
|
+
)
|
|
2168
|
+
`);
|
|
2169
|
+
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
return {
|
|
2173
|
+
whereSql: filters.length ? `WHERE ${filters.join(" AND ")}` : "",
|
|
2174
|
+
params,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
listQueryRunLogs(options = {}) {
|
|
2179
|
+
const { whereSql, params } = this.buildActivityQueryFilters(options);
|
|
2180
|
+
|
|
2181
|
+
return {
|
|
2182
|
+
items: this.db
|
|
2183
|
+
.prepare(`
|
|
2184
|
+
SELECT
|
|
2185
|
+
runs.id AS run_id,
|
|
2186
|
+
runs.history_id,
|
|
2187
|
+
runs.executed_at,
|
|
2188
|
+
runs.executed_by,
|
|
2189
|
+
runs.duration_ms,
|
|
2190
|
+
runs.row_count,
|
|
2191
|
+
runs.status,
|
|
2192
|
+
runs.error_message,
|
|
2193
|
+
runs.affected_rows,
|
|
2194
|
+
q.database_key,
|
|
2195
|
+
q.raw_sql,
|
|
2196
|
+
q.title,
|
|
2197
|
+
q.notes,
|
|
2198
|
+
q.query_type,
|
|
2199
|
+
q.tables_detected,
|
|
2200
|
+
q.is_favorite,
|
|
2201
|
+
q.is_saved,
|
|
2202
|
+
q.is_destructive
|
|
2203
|
+
FROM query_runs runs
|
|
2204
|
+
INNER JOIN query_history q
|
|
2205
|
+
ON q.id = runs.history_id
|
|
2206
|
+
${whereSql}
|
|
2207
|
+
ORDER BY runs.executed_at DESC, runs.id DESC
|
|
2208
|
+
`)
|
|
2209
|
+
.all(...params)
|
|
2210
|
+
.map((row) => this.decorateQueryRunLogRow(row)),
|
|
2211
|
+
total: Number(
|
|
2212
|
+
this.db
|
|
2213
|
+
.prepare(`
|
|
2214
|
+
SELECT COUNT(*) AS count
|
|
2215
|
+
FROM query_runs runs
|
|
2216
|
+
INNER JOIN query_history q
|
|
2217
|
+
ON q.id = runs.history_id
|
|
2218
|
+
${whereSql}
|
|
2219
|
+
`)
|
|
2220
|
+
.get(...params)?.count ?? 0
|
|
2221
|
+
),
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
listAccessActivityLogs(options = {}) {
|
|
2226
|
+
const { whereSql, params } = this.buildActivityAccessFilters(options);
|
|
2227
|
+
|
|
2228
|
+
return {
|
|
2229
|
+
items: this.db
|
|
2230
|
+
.prepare(`
|
|
2231
|
+
SELECT
|
|
2232
|
+
id,
|
|
2233
|
+
source,
|
|
2234
|
+
action,
|
|
2235
|
+
database_key,
|
|
2236
|
+
target_type,
|
|
2237
|
+
target_name,
|
|
2238
|
+
status,
|
|
2239
|
+
started_at,
|
|
2240
|
+
duration_ms,
|
|
2241
|
+
error_message,
|
|
2242
|
+
metadata_json
|
|
2243
|
+
FROM access_log
|
|
2244
|
+
${whereSql}
|
|
2245
|
+
ORDER BY started_at DESC, id DESC
|
|
2246
|
+
`)
|
|
2247
|
+
.all(...params)
|
|
2248
|
+
.map((row) => this.decorateAccessLogForActivity(row)),
|
|
2249
|
+
total: Number(
|
|
2250
|
+
this.db
|
|
2251
|
+
.prepare(`SELECT COUNT(*) AS count FROM access_log ${whereSql}`)
|
|
2252
|
+
.get(...params)?.count ?? 0
|
|
2253
|
+
),
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
listActivityLogs(options = {}) {
|
|
2258
|
+
const kind = this.normalizeActivityLogKind(options.kind);
|
|
2259
|
+
const limit = Math.max(1, Math.min(200, Number(options.limit) || 100));
|
|
2260
|
+
const offset = Math.max(0, Number(options.offset) || 0);
|
|
2261
|
+
const queryLogs =
|
|
2262
|
+
kind === "access"
|
|
2263
|
+
? { items: [], total: 0 }
|
|
2264
|
+
: this.listQueryRunLogs(options);
|
|
2265
|
+
const accessLogs =
|
|
2266
|
+
kind === "query"
|
|
2267
|
+
? { items: [], total: 0 }
|
|
2268
|
+
: this.listAccessActivityLogs(options);
|
|
2269
|
+
const allItems = [...queryLogs.items, ...accessLogs.items].sort((left, right) => {
|
|
2270
|
+
const leftTime = Date.parse(left.occurredAt ?? "") || 0;
|
|
2271
|
+
const rightTime = Date.parse(right.occurredAt ?? "") || 0;
|
|
2272
|
+
|
|
2273
|
+
if (rightTime !== leftTime) {
|
|
2274
|
+
return rightTime - leftTime;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
return String(right.id).localeCompare(String(left.id));
|
|
2278
|
+
});
|
|
2279
|
+
const items = allItems.slice(offset, offset + limit);
|
|
2280
|
+
const total = queryLogs.total + accessLogs.total;
|
|
2281
|
+
|
|
2282
|
+
return {
|
|
2283
|
+
items,
|
|
2284
|
+
total,
|
|
2285
|
+
limit,
|
|
2286
|
+
offset,
|
|
2287
|
+
hasMore: offset + items.length < total,
|
|
2288
|
+
filters: {
|
|
2289
|
+
kind,
|
|
2290
|
+
actor: this.normalizeActivityLogActor(options.actor) ?? "all",
|
|
2291
|
+
status: options.status ? normalizeAccessLogStatus(options.status) : "all",
|
|
2292
|
+
databaseKey: normalizeAccessLogText(options.databaseKey),
|
|
2293
|
+
queryType: normalizeAccessLogText(options.queryType) ?? "all",
|
|
2294
|
+
destructive: this.normalizeActivityLogDestructive(options.destructive),
|
|
2295
|
+
from: this.normalizeActivityLogTimestamp(options.from),
|
|
2296
|
+
to: this.normalizeActivityLogTimestamp(options.to),
|
|
2297
|
+
search: normalizeAccessLogText(options.search) ?? "",
|
|
2298
|
+
},
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
|
|
1713
2302
|
updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
|
|
1714
2303
|
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1715
2304
|
|
|
@@ -2272,30 +2861,10 @@ class AppStateStore {
|
|
|
2272
2861
|
});
|
|
2273
2862
|
}
|
|
2274
2863
|
|
|
2275
|
-
trimSqlHistory() {
|
|
2276
|
-
const maxSqlHistory = Number(
|
|
2277
|
-
this.getSettings().maxSqlHistory ?? DEFAULT_STATE.settings.maxSqlHistory
|
|
2278
|
-
);
|
|
2279
|
-
|
|
2280
|
-
const staleRows = this.db
|
|
2281
|
-
.prepare(`
|
|
2282
|
-
SELECT id
|
|
2283
|
-
FROM sql_history
|
|
2284
|
-
ORDER BY executedAt DESC, id ASC
|
|
2285
|
-
LIMIT -1 OFFSET ?
|
|
2286
|
-
`)
|
|
2287
|
-
.all(maxSqlHistory);
|
|
2288
|
-
|
|
2289
|
-
for (const row of staleRows) {
|
|
2290
|
-
this.db.prepare("DELETE FROM sql_history WHERE id = ?").run(row.id);
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
2864
|
getState() {
|
|
2295
2865
|
return structuredClone({
|
|
2296
2866
|
recentConnections: this.getRecentConnections(),
|
|
2297
2867
|
activeConnectionId: this.getActiveConnectionId(),
|
|
2298
|
-
sqlHistory: this.getSqlHistory(),
|
|
2299
2868
|
settings: this.getSettings(),
|
|
2300
2869
|
});
|
|
2301
2870
|
}
|
|
@@ -2540,75 +3109,6 @@ class AppStateStore {
|
|
|
2540
3109
|
return this.getMetaValue("activeConnectionId");
|
|
2541
3110
|
}
|
|
2542
3111
|
|
|
2543
|
-
addSqlHistory(entry) {
|
|
2544
|
-
this.db.transaction(() => {
|
|
2545
|
-
this.db
|
|
2546
|
-
.prepare(`
|
|
2547
|
-
INSERT INTO sql_history (
|
|
2548
|
-
id,
|
|
2549
|
-
connectionId,
|
|
2550
|
-
connectionLabel,
|
|
2551
|
-
sql,
|
|
2552
|
-
statementCount,
|
|
2553
|
-
resultKind,
|
|
2554
|
-
affectedRowCount,
|
|
2555
|
-
rowCount,
|
|
2556
|
-
timingMs,
|
|
2557
|
-
executedAt
|
|
2558
|
-
)
|
|
2559
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2560
|
-
`)
|
|
2561
|
-
.run(
|
|
2562
|
-
entry.id,
|
|
2563
|
-
entry.connectionId ?? null,
|
|
2564
|
-
entry.connectionLabel ?? null,
|
|
2565
|
-
entry.sql,
|
|
2566
|
-
Number(entry.statementCount ?? 0),
|
|
2567
|
-
entry.resultKind ?? null,
|
|
2568
|
-
Number(entry.affectedRowCount ?? 0),
|
|
2569
|
-
Number(entry.rowCount ?? 0),
|
|
2570
|
-
Number(entry.timingMs ?? 0),
|
|
2571
|
-
entry.executedAt
|
|
2572
|
-
);
|
|
2573
|
-
|
|
2574
|
-
this.trimSqlHistory();
|
|
2575
|
-
})();
|
|
2576
|
-
|
|
2577
|
-
return this.getSqlHistory();
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
clearSqlHistory() {
|
|
2581
|
-
this.db.prepare("DELETE FROM sql_history").run();
|
|
2582
|
-
return [];
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
getSqlHistory() {
|
|
2586
|
-
return this.db
|
|
2587
|
-
.prepare(`
|
|
2588
|
-
SELECT
|
|
2589
|
-
id,
|
|
2590
|
-
connectionId,
|
|
2591
|
-
connectionLabel,
|
|
2592
|
-
sql,
|
|
2593
|
-
statementCount,
|
|
2594
|
-
resultKind,
|
|
2595
|
-
affectedRowCount,
|
|
2596
|
-
rowCount,
|
|
2597
|
-
timingMs,
|
|
2598
|
-
executedAt
|
|
2599
|
-
FROM sql_history
|
|
2600
|
-
ORDER BY executedAt DESC, id ASC
|
|
2601
|
-
`)
|
|
2602
|
-
.all()
|
|
2603
|
-
.map((entry) => ({
|
|
2604
|
-
...entry,
|
|
2605
|
-
statementCount: Number(entry.statementCount ?? 0),
|
|
2606
|
-
affectedRowCount: Number(entry.affectedRowCount ?? 0),
|
|
2607
|
-
rowCount: Number(entry.rowCount ?? 0),
|
|
2608
|
-
timingMs: Number(entry.timingMs ?? 0),
|
|
2609
|
-
}));
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
3112
|
getSettings() {
|
|
2613
3113
|
const rows = this.db.prepare("SELECT key, value FROM settings").all();
|
|
2614
3114
|
const parsedSettings = Object.fromEntries(
|
|
@@ -2647,13 +3147,87 @@ class AppStateStore {
|
|
|
2647
3147
|
tokenPrefix: String(row.token_prefix ?? row.tokenPrefix ?? ""),
|
|
2648
3148
|
createdAt: row.created_at ?? row.createdAt ?? null,
|
|
2649
3149
|
lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
|
|
3150
|
+
callCount: Number(row.callCount ?? 0),
|
|
3151
|
+
lastCallAt: row.lastCallAt ?? null,
|
|
2650
3152
|
};
|
|
2651
3153
|
}
|
|
2652
3154
|
|
|
2653
|
-
|
|
3155
|
+
listApiTokenUsageStats(databaseKey) {
|
|
2654
3156
|
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3157
|
+
const statsByTokenId = new Map();
|
|
3158
|
+
const rows = this.db
|
|
3159
|
+
.prepare(
|
|
3160
|
+
`
|
|
3161
|
+
SELECT started_at, metadata_json
|
|
3162
|
+
FROM access_log
|
|
3163
|
+
WHERE source = 'api'
|
|
3164
|
+
AND database_key = ?
|
|
3165
|
+
ORDER BY started_at DESC, id DESC
|
|
3166
|
+
`
|
|
3167
|
+
)
|
|
3168
|
+
.all(normalizedDatabaseKey);
|
|
2655
3169
|
|
|
2656
|
-
|
|
3170
|
+
rows.forEach((row) => {
|
|
3171
|
+
let metadata = {};
|
|
3172
|
+
|
|
3173
|
+
try {
|
|
3174
|
+
metadata = JSON.parse(row.metadata_json ?? "{}");
|
|
3175
|
+
} catch {
|
|
3176
|
+
metadata = {};
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
const tokenId = String(metadata.apiTokenId ?? "").trim();
|
|
3180
|
+
|
|
3181
|
+
if (!tokenId) {
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
const current = statsByTokenId.get(tokenId) ?? {
|
|
3186
|
+
callCount: 0,
|
|
3187
|
+
lastCallAt: null,
|
|
3188
|
+
};
|
|
3189
|
+
const startedAt = row.started_at ?? null;
|
|
3190
|
+
|
|
3191
|
+
current.callCount += 1;
|
|
3192
|
+
|
|
3193
|
+
if (
|
|
3194
|
+
startedAt &&
|
|
3195
|
+
(!current.lastCallAt || Date.parse(startedAt) > Date.parse(current.lastCallAt))
|
|
3196
|
+
) {
|
|
3197
|
+
current.lastCallAt = startedAt;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
statsByTokenId.set(tokenId, current);
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
return statsByTokenId;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
getDatabaseApiQueryRunUsageStats(databaseKey) {
|
|
3207
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3208
|
+
const row = this.db
|
|
3209
|
+
.prepare(
|
|
3210
|
+
`
|
|
3211
|
+
SELECT COUNT(*) AS call_count, MAX(runs.executed_at) AS last_call_at
|
|
3212
|
+
FROM query_runs runs
|
|
3213
|
+
INNER JOIN query_history q
|
|
3214
|
+
ON q.id = runs.history_id
|
|
3215
|
+
WHERE runs.executed_by = 'api'
|
|
3216
|
+
AND q.database_key = ?
|
|
3217
|
+
`
|
|
3218
|
+
)
|
|
3219
|
+
.get(normalizedDatabaseKey);
|
|
3220
|
+
|
|
3221
|
+
return {
|
|
3222
|
+
callCount: Number(row?.call_count ?? 0),
|
|
3223
|
+
lastCallAt: row?.last_call_at ?? null,
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
listApiTokens(databaseKey) {
|
|
3228
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3229
|
+
const usageStatsByTokenId = this.listApiTokenUsageStats(normalizedDatabaseKey);
|
|
3230
|
+
const rows = this.db
|
|
2657
3231
|
.prepare(
|
|
2658
3232
|
`
|
|
2659
3233
|
SELECT id, database_key, name, token_prefix, created_at, last_used_at
|
|
@@ -2662,8 +3236,38 @@ class AppStateStore {
|
|
|
2662
3236
|
ORDER BY created_at DESC, id ASC
|
|
2663
3237
|
`
|
|
2664
3238
|
)
|
|
2665
|
-
.all(normalizedDatabaseKey)
|
|
2666
|
-
|
|
3239
|
+
.all(normalizedDatabaseKey);
|
|
3240
|
+
const accessLogCallCount = Array.from(usageStatsByTokenId.values()).reduce(
|
|
3241
|
+
(total, stats) => total + Number(stats.callCount ?? 0),
|
|
3242
|
+
0
|
|
3243
|
+
);
|
|
3244
|
+
const apiQueryRunUsageStats = this.getDatabaseApiQueryRunUsageStats(normalizedDatabaseKey);
|
|
3245
|
+
const useSingleTokenQueryRunFallback =
|
|
3246
|
+
rows.length === 1 &&
|
|
3247
|
+
accessLogCallCount === 0 &&
|
|
3248
|
+
apiQueryRunUsageStats.callCount > 0;
|
|
3249
|
+
|
|
3250
|
+
return rows.map((row) => {
|
|
3251
|
+
const tokenId = String(row.id ?? "");
|
|
3252
|
+
const usageStats = usageStatsByTokenId.get(tokenId) ?? {};
|
|
3253
|
+
let callCount = Number(usageStats.callCount ?? 0);
|
|
3254
|
+
let lastCallAt = pickLatestTimestamp(usageStats.lastCallAt, row.last_used_at);
|
|
3255
|
+
|
|
3256
|
+
if (!callCount && row.last_used_at) {
|
|
3257
|
+
callCount = 1;
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
if (useSingleTokenQueryRunFallback) {
|
|
3261
|
+
callCount = Math.max(callCount, apiQueryRunUsageStats.callCount);
|
|
3262
|
+
lastCallAt = pickLatestTimestamp(lastCallAt, apiQueryRunUsageStats.lastCallAt);
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
return this.decorateApiTokenRow({
|
|
3266
|
+
...row,
|
|
3267
|
+
callCount,
|
|
3268
|
+
lastCallAt,
|
|
3269
|
+
});
|
|
3270
|
+
});
|
|
2667
3271
|
}
|
|
2668
3272
|
|
|
2669
3273
|
createApiToken({ databaseKey, name, tokenHash, tokenPrefix }) {
|