sqlite-hub 1.4.0 → 2.0.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 (46) hide show
  1. package/README.md +14 -1
  2. package/bin/sqlite-hub-mcp.js +8 -0
  3. package/bin/sqlite-hub.js +555 -122
  4. package/docs/API.md +30 -0
  5. package/docs/CLI.md +22 -0
  6. package/docs/CLI_API_PARITY.md +61 -0
  7. package/docs/MCP.md +85 -0
  8. package/docs/changelog.md +13 -1
  9. package/docs/guidelines/AGENTS.md +32 -0
  10. package/docs/{DESIGN_GUIDELINES.md → guidelines/DESIGN.md} +10 -0
  11. package/docs/todo.md +1 -14
  12. package/frontend/js/api.js +49 -0
  13. package/frontend/js/app.js +202 -2
  14. package/frontend/js/components/connectionCard.js +3 -1
  15. package/frontend/js/components/modal.js +356 -15
  16. package/frontend/js/components/sidebar.js +41 -7
  17. package/frontend/js/components/topNav.js +2 -14
  18. package/frontend/js/router.js +10 -0
  19. package/frontend/js/store.js +483 -7
  20. package/frontend/js/utils/inputClear.js +36 -0
  21. package/frontend/js/utils/syntheticData.js +240 -0
  22. package/frontend/js/views/backups.js +52 -0
  23. package/frontend/js/views/data.js +16 -0
  24. package/frontend/js/views/logs.js +339 -0
  25. package/frontend/js/views/settings.js +266 -30
  26. package/frontend/js/views/structure.js +6 -0
  27. package/frontend/js/views/tableAdvisor.js +385 -0
  28. package/frontend/js/views/tableDesigner.js +6 -0
  29. package/frontend/styles/components.css +149 -0
  30. package/frontend/styles/tailwind.generated.css +1 -1
  31. package/frontend/styles/views.css +31 -0
  32. package/package.json +4 -2
  33. package/server/mcp/stdioServer.js +272 -0
  34. package/server/routes/data.js +45 -0
  35. package/server/routes/externalApi.js +285 -2
  36. package/server/routes/logs.js +127 -0
  37. package/server/routes/settings.js +47 -0
  38. package/server/server.js +3 -0
  39. package/server/services/databaseCommandService.js +284 -2
  40. package/server/services/mcpStatusService.js +140 -0
  41. package/server/services/mcpToolService.js +274 -0
  42. package/server/services/sqlite/dataBrowserService.js +36 -0
  43. package/server/services/sqlite/introspection.js +226 -22
  44. package/server/services/sqlite/syntheticDataGenerator.js +728 -0
  45. package/server/services/sqlite/tableAdvisor.js +790 -0
  46. package/server/services/storage/appStateStore.js +821 -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
- CREATE TABLE IF NOT EXISTS sql_history (
354
- id TEXT PRIMARY KEY,
355
- connectionId TEXT,
356
- connectionLabel TEXT,
357
- sql TEXT NOT NULL,
358
- statementCount INTEGER NOT NULL DEFAULT 0,
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",
@@ -941,6 +1004,54 @@ class AppStateStore {
941
1004
  .run(key, String(value));
942
1005
  }
943
1006
 
1007
+ getJsonMetaValue(key, fallback = null) {
1008
+ const value = this.getMetaValue(key);
1009
+
1010
+ if (!value) {
1011
+ return fallback;
1012
+ }
1013
+
1014
+ try {
1015
+ return JSON.parse(value);
1016
+ } catch {
1017
+ return fallback;
1018
+ }
1019
+ }
1020
+
1021
+ setJsonMetaValue(key, value) {
1022
+ this.setMetaValue(key, JSON.stringify(value ?? null));
1023
+ }
1024
+
1025
+ getMcpStatus(defaultStatus = {}) {
1026
+ const storedStatus = this.getJsonMetaValue("mcpStatus", {});
1027
+ return {
1028
+ ...defaultStatus,
1029
+ ...(storedStatus && typeof storedStatus === "object" && !Array.isArray(storedStatus)
1030
+ ? storedStatus
1031
+ : {}),
1032
+ };
1033
+ }
1034
+
1035
+ setMcpStatus(status, defaultStatus = {}) {
1036
+ const nextStatus = {
1037
+ ...defaultStatus,
1038
+ ...(status && typeof status === "object" && !Array.isArray(status) ? status : {}),
1039
+ };
1040
+
1041
+ this.setJsonMetaValue("mcpStatus", nextStatus);
1042
+ return nextStatus;
1043
+ }
1044
+
1045
+ patchMcpStatus(patch, defaultStatus = {}) {
1046
+ return this.setMcpStatus(
1047
+ {
1048
+ ...this.getMcpStatus(defaultStatus),
1049
+ ...(patch && typeof patch === "object" && !Array.isArray(patch) ? patch : {}),
1050
+ },
1051
+ defaultStatus
1052
+ );
1053
+ }
1054
+
944
1055
  normalizeQueryHistoryText(value) {
945
1056
  const text = String(value ?? "").trim();
946
1057
  return text ? text : null;
@@ -1710,6 +1821,532 @@ class AppStateStore {
1710
1821
  };
1711
1822
  }
1712
1823
 
1824
+ decorateAccessLogRow(row = {}) {
1825
+ let metadata = {};
1826
+
1827
+ try {
1828
+ metadata = JSON.parse(row.metadata_json ?? row.metadataJson ?? "{}");
1829
+ } catch {
1830
+ metadata = {};
1831
+ }
1832
+
1833
+ return {
1834
+ id: Number(row.id ?? 0),
1835
+ source: String(row.source ?? ""),
1836
+ action: String(row.action ?? ""),
1837
+ databaseKey: row.database_key ?? row.databaseKey ?? null,
1838
+ targetType: row.target_type ?? row.targetType ?? null,
1839
+ targetName: row.target_name ?? row.targetName ?? null,
1840
+ status: String(row.status ?? ""),
1841
+ startedAt: row.started_at ?? row.startedAt ?? null,
1842
+ durationMs:
1843
+ row.duration_ms === null || row.duration_ms === undefined
1844
+ ? null
1845
+ : Number(row.duration_ms),
1846
+ errorMessage: row.error_message ?? row.errorMessage ?? null,
1847
+ metadata,
1848
+ };
1849
+ }
1850
+
1851
+ recordAccessLog(entry = {}) {
1852
+ const source = normalizeAccessLogSource(entry.source);
1853
+ const action = requireAccessLogText(entry.action, "Access log action");
1854
+ const databaseKey = normalizeAccessLogText(entry.databaseKey);
1855
+ const targetType = normalizeAccessLogText(entry.targetType);
1856
+ const targetName = normalizeAccessLogText(entry.targetName);
1857
+ const status = normalizeAccessLogStatus(entry.status);
1858
+ const startedAt = normalizeAccessLogText(entry.startedAt) ?? new Date().toISOString();
1859
+ const durationMs = normalizeAccessLogInteger(entry.durationMs);
1860
+ const errorMessage = normalizeAccessLogText(entry.errorMessage);
1861
+ const metadataJson = normalizeAccessLogMetadata(entry.metadata);
1862
+
1863
+ const result = this.db
1864
+ .prepare(`
1865
+ INSERT INTO access_log (
1866
+ source,
1867
+ action,
1868
+ database_key,
1869
+ target_type,
1870
+ target_name,
1871
+ status,
1872
+ started_at,
1873
+ duration_ms,
1874
+ error_message,
1875
+ metadata_json
1876
+ )
1877
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1878
+ `)
1879
+ .run(
1880
+ source,
1881
+ action,
1882
+ databaseKey,
1883
+ targetType,
1884
+ targetName,
1885
+ status,
1886
+ startedAt,
1887
+ durationMs,
1888
+ errorMessage,
1889
+ metadataJson
1890
+ );
1891
+
1892
+ return this.getAccessLogEntry(result.lastInsertRowid);
1893
+ }
1894
+
1895
+ getAccessLogEntry(logId) {
1896
+ const row = this.db
1897
+ .prepare(`
1898
+ SELECT
1899
+ id,
1900
+ source,
1901
+ action,
1902
+ database_key,
1903
+ target_type,
1904
+ target_name,
1905
+ status,
1906
+ started_at,
1907
+ duration_ms,
1908
+ error_message,
1909
+ metadata_json
1910
+ FROM access_log
1911
+ WHERE id = ?
1912
+ `)
1913
+ .get(Number(logId));
1914
+
1915
+ if (!row) {
1916
+ throw new NotFoundError(`Access log entry not found: ${logId}`);
1917
+ }
1918
+
1919
+ return this.decorateAccessLogRow(row);
1920
+ }
1921
+
1922
+ listAccessLogs(options = {}) {
1923
+ const filters = [];
1924
+ const params = [];
1925
+ const source = options.source ? normalizeAccessLogSource(options.source) : null;
1926
+ const status = options.status ? normalizeAccessLogStatus(options.status) : null;
1927
+ const databaseKey = normalizeAccessLogText(options.databaseKey);
1928
+ const limit = Math.max(1, Math.min(200, Number(options.limit) || 100));
1929
+ const offset = Math.max(0, Number(options.offset) || 0);
1930
+
1931
+ if (source) {
1932
+ filters.push("source = ?");
1933
+ params.push(source);
1934
+ }
1935
+
1936
+ if (status) {
1937
+ filters.push("status = ?");
1938
+ params.push(status);
1939
+ }
1940
+
1941
+ if (databaseKey) {
1942
+ filters.push("database_key = ?");
1943
+ params.push(databaseKey);
1944
+ }
1945
+
1946
+ const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
1947
+ const rows = this.db
1948
+ .prepare(`
1949
+ SELECT
1950
+ id,
1951
+ source,
1952
+ action,
1953
+ database_key,
1954
+ target_type,
1955
+ target_name,
1956
+ status,
1957
+ started_at,
1958
+ duration_ms,
1959
+ error_message,
1960
+ metadata_json
1961
+ FROM access_log
1962
+ ${whereClause}
1963
+ ORDER BY started_at DESC, id DESC
1964
+ LIMIT ? OFFSET ?
1965
+ `)
1966
+ .all(...params, limit, offset)
1967
+ .map((row) => this.decorateAccessLogRow(row));
1968
+ const total = Number(
1969
+ this.db
1970
+ .prepare(`SELECT COUNT(*) AS count FROM access_log ${whereClause}`)
1971
+ .get(...params)?.count ?? 0
1972
+ );
1973
+
1974
+ return {
1975
+ items: rows,
1976
+ total,
1977
+ limit,
1978
+ offset,
1979
+ hasMore: offset + rows.length < total,
1980
+ };
1981
+ }
1982
+
1983
+ normalizeActivityLogKind(value) {
1984
+ const normalized = String(value ?? "all").trim().toLowerCase();
1985
+ return ACTIVITY_LOG_KINDS.has(normalized) ? normalized : "all";
1986
+ }
1987
+
1988
+ normalizeActivityLogActor(value) {
1989
+ const normalized = String(value ?? "all").trim().toLowerCase();
1990
+ return ACTIVITY_LOG_ACTORS.has(normalized) ? normalized : null;
1991
+ }
1992
+
1993
+ normalizeActivityLogDestructive(value) {
1994
+ const normalized = String(value ?? "all").trim().toLowerCase();
1995
+ return ACTIVITY_LOG_DESTRUCTIVE_FILTERS.has(normalized) ? normalized : "all";
1996
+ }
1997
+
1998
+ normalizeActivityLogTimestamp(value) {
1999
+ const normalized = normalizeAccessLogText(value);
2000
+
2001
+ if (!normalized) {
2002
+ return null;
2003
+ }
2004
+
2005
+ const timestamp = new Date(normalized).getTime();
2006
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
2007
+ }
2008
+
2009
+ decorateQueryRunLogRow(row = {}) {
2010
+ let tablesDetected = [];
2011
+
2012
+ try {
2013
+ tablesDetected = JSON.parse(row.tables_detected ?? "[]");
2014
+ } catch {
2015
+ tablesDetected = [];
2016
+ }
2017
+
2018
+ return {
2019
+ id: `query:${Number(row.run_id ?? 0)}`,
2020
+ kind: "query",
2021
+ source: "query_history",
2022
+ action: "query.execute",
2023
+ databaseKey: row.database_key ?? null,
2024
+ targetType: "query",
2025
+ targetName: row.title || buildAutoTitle(row.raw_sql ?? "", {
2026
+ queryType: row.query_type ?? "other",
2027
+ tablesDetected,
2028
+ }),
2029
+ status: String(row.status ?? ""),
2030
+ occurredAt: row.executed_at ?? null,
2031
+ durationMs:
2032
+ row.duration_ms === null || row.duration_ms === undefined
2033
+ ? null
2034
+ : Number(row.duration_ms),
2035
+ errorMessage: row.error_message ?? null,
2036
+ executedBy: normalizeQueryExecutionSource(row.executed_by ?? "user"),
2037
+ queryType: row.query_type ?? "other",
2038
+ destructive: Boolean(row.is_destructive),
2039
+ historyId: Number(row.history_id ?? 0),
2040
+ runId: Number(row.run_id ?? 0),
2041
+ rowCount:
2042
+ row.row_count === null || row.row_count === undefined
2043
+ ? null
2044
+ : Number(row.row_count),
2045
+ affectedRows:
2046
+ row.affected_rows === null || row.affected_rows === undefined
2047
+ ? null
2048
+ : Number(row.affected_rows),
2049
+ preview: buildSqlPreview(row.raw_sql ?? ""),
2050
+ rawSql: row.raw_sql ?? "",
2051
+ metadata: {
2052
+ tablesDetected,
2053
+ saved: Boolean(row.is_saved),
2054
+ favorite: Boolean(row.is_favorite),
2055
+ },
2056
+ };
2057
+ }
2058
+
2059
+ decorateAccessLogForActivity(row = {}) {
2060
+ const entry = this.decorateAccessLogRow(row);
2061
+
2062
+ return {
2063
+ id: `access:${entry.id}`,
2064
+ kind: "access",
2065
+ source: entry.source,
2066
+ action: entry.action,
2067
+ databaseKey: entry.databaseKey,
2068
+ targetType: entry.targetType,
2069
+ targetName: entry.targetName,
2070
+ status: entry.status,
2071
+ occurredAt: entry.startedAt,
2072
+ durationMs: entry.durationMs,
2073
+ errorMessage: entry.errorMessage,
2074
+ executedBy: null,
2075
+ queryType: null,
2076
+ destructive: null,
2077
+ historyId: null,
2078
+ runId: null,
2079
+ rowCount: null,
2080
+ affectedRows: null,
2081
+ preview: entry.action,
2082
+ rawSql: "",
2083
+ metadata: entry.metadata,
2084
+ };
2085
+ }
2086
+
2087
+ buildActivityQueryFilters(options = {}) {
2088
+ const filters = [];
2089
+ const params = [];
2090
+ const databaseKey = normalizeAccessLogText(options.databaseKey);
2091
+ const status = options.status ? normalizeAccessLogStatus(options.status) : null;
2092
+ const actor = this.normalizeActivityLogActor(options.actor);
2093
+ const queryType = normalizeAccessLogText(options.queryType);
2094
+ const destructive = this.normalizeActivityLogDestructive(options.destructive);
2095
+ const from = this.normalizeActivityLogTimestamp(options.from);
2096
+ const to = this.normalizeActivityLogTimestamp(options.to);
2097
+ const search = normalizeAccessLogText(options.search);
2098
+
2099
+ if (databaseKey) {
2100
+ filters.push("q.database_key = ?");
2101
+ params.push(databaseKey);
2102
+ }
2103
+
2104
+ if (status) {
2105
+ filters.push("runs.status = ?");
2106
+ params.push(status);
2107
+ }
2108
+
2109
+ if (actor) {
2110
+ filters.push("runs.executed_by = ?");
2111
+ params.push(actor);
2112
+ }
2113
+
2114
+ if (queryType) {
2115
+ filters.push("q.query_type = ?");
2116
+ params.push(queryType);
2117
+ }
2118
+
2119
+ if (destructive === "yes") {
2120
+ filters.push("q.is_destructive = 1");
2121
+ } else if (destructive === "no") {
2122
+ filters.push("q.is_destructive = 0");
2123
+ }
2124
+
2125
+ if (from) {
2126
+ filters.push("runs.executed_at >= ?");
2127
+ params.push(from);
2128
+ }
2129
+
2130
+ if (to) {
2131
+ filters.push("runs.executed_at <= ?");
2132
+ params.push(to);
2133
+ }
2134
+
2135
+ if (search) {
2136
+ const searchPattern = `%${search.toLowerCase()}%`;
2137
+ filters.push(`
2138
+ (
2139
+ LOWER(COALESCE(q.title, '')) LIKE ?
2140
+ OR LOWER(q.raw_sql) LIKE ?
2141
+ OR LOWER(COALESCE(q.notes, '')) LIKE ?
2142
+ OR LOWER(q.tables_detected) LIKE ?
2143
+ OR LOWER(COALESCE(runs.error_message, '')) LIKE ?
2144
+ )
2145
+ `);
2146
+ params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
2147
+ }
2148
+
2149
+ return {
2150
+ whereSql: filters.length ? `WHERE ${filters.join(" AND ")}` : "",
2151
+ params,
2152
+ };
2153
+ }
2154
+
2155
+ buildActivityAccessFilters(options = {}) {
2156
+ const filters = [];
2157
+ const params = [];
2158
+ const databaseKey = normalizeAccessLogText(options.databaseKey);
2159
+ const status = options.status ? normalizeAccessLogStatus(options.status) : null;
2160
+ const actor = this.normalizeActivityLogActor(options.actor);
2161
+ const queryType = normalizeAccessLogText(options.queryType);
2162
+ const destructive = this.normalizeActivityLogDestructive(options.destructive);
2163
+ const from = this.normalizeActivityLogTimestamp(options.from);
2164
+ const to = this.normalizeActivityLogTimestamp(options.to);
2165
+ const search = normalizeAccessLogText(options.search);
2166
+
2167
+ if (queryType || destructive !== "all") {
2168
+ return {
2169
+ whereSql: "WHERE 1 = 0",
2170
+ params: [],
2171
+ };
2172
+ }
2173
+
2174
+ if (databaseKey) {
2175
+ filters.push("database_key = ?");
2176
+ params.push(databaseKey);
2177
+ }
2178
+
2179
+ if (status) {
2180
+ filters.push("status = ?");
2181
+ params.push(status);
2182
+ }
2183
+
2184
+ if (actor) {
2185
+ if (actor !== "api" && actor !== "cli") {
2186
+ return {
2187
+ whereSql: "WHERE 1 = 0",
2188
+ params: [],
2189
+ };
2190
+ }
2191
+
2192
+ filters.push("source = ?");
2193
+ params.push(actor);
2194
+ }
2195
+
2196
+ if (from) {
2197
+ filters.push("started_at >= ?");
2198
+ params.push(from);
2199
+ }
2200
+
2201
+ if (to) {
2202
+ filters.push("started_at <= ?");
2203
+ params.push(to);
2204
+ }
2205
+
2206
+ if (search) {
2207
+ const searchPattern = `%${search.toLowerCase()}%`;
2208
+ filters.push(`
2209
+ (
2210
+ LOWER(action) LIKE ?
2211
+ OR LOWER(COALESCE(target_type, '')) LIKE ?
2212
+ OR LOWER(COALESCE(target_name, '')) LIKE ?
2213
+ OR LOWER(COALESCE(error_message, '')) LIKE ?
2214
+ OR LOWER(metadata_json) LIKE ?
2215
+ )
2216
+ `);
2217
+ params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
2218
+ }
2219
+
2220
+ return {
2221
+ whereSql: filters.length ? `WHERE ${filters.join(" AND ")}` : "",
2222
+ params,
2223
+ };
2224
+ }
2225
+
2226
+ listQueryRunLogs(options = {}) {
2227
+ const { whereSql, params } = this.buildActivityQueryFilters(options);
2228
+
2229
+ return {
2230
+ items: this.db
2231
+ .prepare(`
2232
+ SELECT
2233
+ runs.id AS run_id,
2234
+ runs.history_id,
2235
+ runs.executed_at,
2236
+ runs.executed_by,
2237
+ runs.duration_ms,
2238
+ runs.row_count,
2239
+ runs.status,
2240
+ runs.error_message,
2241
+ runs.affected_rows,
2242
+ q.database_key,
2243
+ q.raw_sql,
2244
+ q.title,
2245
+ q.notes,
2246
+ q.query_type,
2247
+ q.tables_detected,
2248
+ q.is_favorite,
2249
+ q.is_saved,
2250
+ q.is_destructive
2251
+ FROM query_runs runs
2252
+ INNER JOIN query_history q
2253
+ ON q.id = runs.history_id
2254
+ ${whereSql}
2255
+ ORDER BY runs.executed_at DESC, runs.id DESC
2256
+ `)
2257
+ .all(...params)
2258
+ .map((row) => this.decorateQueryRunLogRow(row)),
2259
+ total: Number(
2260
+ this.db
2261
+ .prepare(`
2262
+ SELECT COUNT(*) AS count
2263
+ FROM query_runs runs
2264
+ INNER JOIN query_history q
2265
+ ON q.id = runs.history_id
2266
+ ${whereSql}
2267
+ `)
2268
+ .get(...params)?.count ?? 0
2269
+ ),
2270
+ };
2271
+ }
2272
+
2273
+ listAccessActivityLogs(options = {}) {
2274
+ const { whereSql, params } = this.buildActivityAccessFilters(options);
2275
+
2276
+ return {
2277
+ items: this.db
2278
+ .prepare(`
2279
+ SELECT
2280
+ id,
2281
+ source,
2282
+ action,
2283
+ database_key,
2284
+ target_type,
2285
+ target_name,
2286
+ status,
2287
+ started_at,
2288
+ duration_ms,
2289
+ error_message,
2290
+ metadata_json
2291
+ FROM access_log
2292
+ ${whereSql}
2293
+ ORDER BY started_at DESC, id DESC
2294
+ `)
2295
+ .all(...params)
2296
+ .map((row) => this.decorateAccessLogForActivity(row)),
2297
+ total: Number(
2298
+ this.db
2299
+ .prepare(`SELECT COUNT(*) AS count FROM access_log ${whereSql}`)
2300
+ .get(...params)?.count ?? 0
2301
+ ),
2302
+ };
2303
+ }
2304
+
2305
+ listActivityLogs(options = {}) {
2306
+ const kind = this.normalizeActivityLogKind(options.kind);
2307
+ const limit = Math.max(1, Math.min(200, Number(options.limit) || 100));
2308
+ const offset = Math.max(0, Number(options.offset) || 0);
2309
+ const queryLogs =
2310
+ kind === "access"
2311
+ ? { items: [], total: 0 }
2312
+ : this.listQueryRunLogs(options);
2313
+ const accessLogs =
2314
+ kind === "query"
2315
+ ? { items: [], total: 0 }
2316
+ : this.listAccessActivityLogs(options);
2317
+ const allItems = [...queryLogs.items, ...accessLogs.items].sort((left, right) => {
2318
+ const leftTime = Date.parse(left.occurredAt ?? "") || 0;
2319
+ const rightTime = Date.parse(right.occurredAt ?? "") || 0;
2320
+
2321
+ if (rightTime !== leftTime) {
2322
+ return rightTime - leftTime;
2323
+ }
2324
+
2325
+ return String(right.id).localeCompare(String(left.id));
2326
+ });
2327
+ const items = allItems.slice(offset, offset + limit);
2328
+ const total = queryLogs.total + accessLogs.total;
2329
+
2330
+ return {
2331
+ items,
2332
+ total,
2333
+ limit,
2334
+ offset,
2335
+ hasMore: offset + items.length < total,
2336
+ filters: {
2337
+ kind,
2338
+ actor: this.normalizeActivityLogActor(options.actor) ?? "all",
2339
+ status: options.status ? normalizeAccessLogStatus(options.status) : "all",
2340
+ databaseKey: normalizeAccessLogText(options.databaseKey),
2341
+ queryType: normalizeAccessLogText(options.queryType) ?? "all",
2342
+ destructive: this.normalizeActivityLogDestructive(options.destructive),
2343
+ from: this.normalizeActivityLogTimestamp(options.from),
2344
+ to: this.normalizeActivityLogTimestamp(options.to),
2345
+ search: normalizeAccessLogText(options.search) ?? "",
2346
+ },
2347
+ };
2348
+ }
2349
+
1713
2350
  updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
1714
2351
  const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1715
2352
 
@@ -2272,30 +2909,10 @@ class AppStateStore {
2272
2909
  });
2273
2910
  }
2274
2911
 
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
2912
  getState() {
2295
2913
  return structuredClone({
2296
2914
  recentConnections: this.getRecentConnections(),
2297
2915
  activeConnectionId: this.getActiveConnectionId(),
2298
- sqlHistory: this.getSqlHistory(),
2299
2916
  settings: this.getSettings(),
2300
2917
  });
2301
2918
  }
@@ -2540,75 +3157,6 @@ class AppStateStore {
2540
3157
  return this.getMetaValue("activeConnectionId");
2541
3158
  }
2542
3159
 
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
3160
  getSettings() {
2613
3161
  const rows = this.db.prepare("SELECT key, value FROM settings").all();
2614
3162
  const parsedSettings = Object.fromEntries(
@@ -2647,13 +3195,87 @@ class AppStateStore {
2647
3195
  tokenPrefix: String(row.token_prefix ?? row.tokenPrefix ?? ""),
2648
3196
  createdAt: row.created_at ?? row.createdAt ?? null,
2649
3197
  lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
3198
+ callCount: Number(row.callCount ?? 0),
3199
+ lastCallAt: row.lastCallAt ?? null,
2650
3200
  };
2651
3201
  }
2652
3202
 
2653
- listApiTokens(databaseKey) {
3203
+ listApiTokenUsageStats(databaseKey) {
2654
3204
  const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3205
+ const statsByTokenId = new Map();
3206
+ const rows = this.db
3207
+ .prepare(
3208
+ `
3209
+ SELECT started_at, metadata_json
3210
+ FROM access_log
3211
+ WHERE source = 'api'
3212
+ AND database_key = ?
3213
+ ORDER BY started_at DESC, id DESC
3214
+ `
3215
+ )
3216
+ .all(normalizedDatabaseKey);
2655
3217
 
2656
- return this.db
3218
+ rows.forEach((row) => {
3219
+ let metadata = {};
3220
+
3221
+ try {
3222
+ metadata = JSON.parse(row.metadata_json ?? "{}");
3223
+ } catch {
3224
+ metadata = {};
3225
+ }
3226
+
3227
+ const tokenId = String(metadata.apiTokenId ?? "").trim();
3228
+
3229
+ if (!tokenId) {
3230
+ return;
3231
+ }
3232
+
3233
+ const current = statsByTokenId.get(tokenId) ?? {
3234
+ callCount: 0,
3235
+ lastCallAt: null,
3236
+ };
3237
+ const startedAt = row.started_at ?? null;
3238
+
3239
+ current.callCount += 1;
3240
+
3241
+ if (
3242
+ startedAt &&
3243
+ (!current.lastCallAt || Date.parse(startedAt) > Date.parse(current.lastCallAt))
3244
+ ) {
3245
+ current.lastCallAt = startedAt;
3246
+ }
3247
+
3248
+ statsByTokenId.set(tokenId, current);
3249
+ });
3250
+
3251
+ return statsByTokenId;
3252
+ }
3253
+
3254
+ getDatabaseApiQueryRunUsageStats(databaseKey) {
3255
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3256
+ const row = this.db
3257
+ .prepare(
3258
+ `
3259
+ SELECT COUNT(*) AS call_count, MAX(runs.executed_at) AS last_call_at
3260
+ FROM query_runs runs
3261
+ INNER JOIN query_history q
3262
+ ON q.id = runs.history_id
3263
+ WHERE runs.executed_by = 'api'
3264
+ AND q.database_key = ?
3265
+ `
3266
+ )
3267
+ .get(normalizedDatabaseKey);
3268
+
3269
+ return {
3270
+ callCount: Number(row?.call_count ?? 0),
3271
+ lastCallAt: row?.last_call_at ?? null,
3272
+ };
3273
+ }
3274
+
3275
+ listApiTokens(databaseKey) {
3276
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
3277
+ const usageStatsByTokenId = this.listApiTokenUsageStats(normalizedDatabaseKey);
3278
+ const rows = this.db
2657
3279
  .prepare(
2658
3280
  `
2659
3281
  SELECT id, database_key, name, token_prefix, created_at, last_used_at
@@ -2662,8 +3284,38 @@ class AppStateStore {
2662
3284
  ORDER BY created_at DESC, id ASC
2663
3285
  `
2664
3286
  )
2665
- .all(normalizedDatabaseKey)
2666
- .map((row) => this.decorateApiTokenRow(row));
3287
+ .all(normalizedDatabaseKey);
3288
+ const accessLogCallCount = Array.from(usageStatsByTokenId.values()).reduce(
3289
+ (total, stats) => total + Number(stats.callCount ?? 0),
3290
+ 0
3291
+ );
3292
+ const apiQueryRunUsageStats = this.getDatabaseApiQueryRunUsageStats(normalizedDatabaseKey);
3293
+ const useSingleTokenQueryRunFallback =
3294
+ rows.length === 1 &&
3295
+ accessLogCallCount === 0 &&
3296
+ apiQueryRunUsageStats.callCount > 0;
3297
+
3298
+ return rows.map((row) => {
3299
+ const tokenId = String(row.id ?? "");
3300
+ const usageStats = usageStatsByTokenId.get(tokenId) ?? {};
3301
+ let callCount = Number(usageStats.callCount ?? 0);
3302
+ let lastCallAt = pickLatestTimestamp(usageStats.lastCallAt, row.last_used_at);
3303
+
3304
+ if (!callCount && row.last_used_at) {
3305
+ callCount = 1;
3306
+ }
3307
+
3308
+ if (useSingleTokenQueryRunFallback) {
3309
+ callCount = Math.max(callCount, apiQueryRunUsageStats.callCount);
3310
+ lastCallAt = pickLatestTimestamp(lastCallAt, apiQueryRunUsageStats.lastCallAt);
3311
+ }
3312
+
3313
+ return this.decorateApiTokenRow({
3314
+ ...row,
3315
+ callCount,
3316
+ lastCallAt,
3317
+ });
3318
+ });
2667
3319
  }
2668
3320
 
2669
3321
  createApiToken({ databaseKey, name, tokenHash, tokenPrefix }) {