sqlite-hub 1.2.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.
Files changed (169) hide show
  1. package/README.md +113 -71
  2. package/bin/sqlite-hub.js +723 -116
  3. package/docs/API.md +152 -0
  4. package/docs/CLI.md +249 -0
  5. package/docs/CLI_API_PARITY.md +61 -0
  6. package/docs/changelog.md +149 -0
  7. package/docs/guidelines/AGENTS.md +32 -0
  8. package/docs/guidelines/DESIGN.md +55 -0
  9. package/docs/shortkeys.md +27 -0
  10. package/docs/todo.md +4 -0
  11. package/examples/api/generate-types.js +52 -0
  12. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  14. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  18. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  19. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  22. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  23. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  24. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  26. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  27. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  28. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
  29. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  31. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  33. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  35. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  36. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  37. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  38. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  40. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  43. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  44. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  45. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  46. package/frontend/js/api.js +63 -0
  47. package/frontend/js/app.js +310 -10
  48. package/frontend/js/components/connectionCard.js +4 -2
  49. package/frontend/js/components/emptyState.js +4 -4
  50. package/frontend/js/components/generateTypesDropdown.js +33 -0
  51. package/frontend/js/components/metricCard.js +1 -1
  52. package/frontend/js/components/modal.js +555 -21
  53. package/frontend/js/components/pageHeader.js +1 -1
  54. package/frontend/js/components/queryHistoryDetail.js +3 -3
  55. package/frontend/js/components/queryHistoryHeader.js +1 -1
  56. package/frontend/js/components/queryHistoryPanel.js +1 -1
  57. package/frontend/js/components/rowEditorPanel.js +13 -3
  58. package/frontend/js/components/sidebar.js +41 -7
  59. package/frontend/js/components/topNav.js +2 -14
  60. package/frontend/js/router.js +38 -6
  61. package/frontend/js/store.js +870 -8
  62. package/frontend/js/utils/inputClear.js +36 -0
  63. package/frontend/js/utils/syntheticData.js +240 -0
  64. package/frontend/js/views/backups.js +581 -37
  65. package/frontend/js/views/charts.js +9 -9
  66. package/frontend/js/views/connections.js +2 -2
  67. package/frontend/js/views/data.js +17 -1
  68. package/frontend/js/views/documents.js +3 -3
  69. package/frontend/js/views/editor.js +6 -6
  70. package/frontend/js/views/logs.js +339 -0
  71. package/frontend/js/views/mediaTagging.js +7 -5
  72. package/frontend/js/views/overview.js +3 -3
  73. package/frontend/js/views/settings.js +80 -30
  74. package/frontend/js/views/structure.js +12 -1
  75. package/frontend/js/views/tableAdvisor.js +385 -0
  76. package/frontend/js/views/tableDesigner.js +6 -0
  77. package/frontend/styles/base.css +1 -40
  78. package/frontend/styles/components.css +258 -232
  79. package/frontend/styles/structure-graph.css +32 -68
  80. package/frontend/styles/tailwind.css +8 -4
  81. package/frontend/styles/tailwind.generated.css +1 -1
  82. package/frontend/styles/tokens.css +28 -21
  83. package/frontend/styles/views.css +102 -261
  84. package/package.json +18 -2
  85. package/server/routes/backups.js +18 -2
  86. package/server/routes/data.js +45 -0
  87. package/server/routes/externalApi.js +321 -2
  88. package/server/routes/logs.js +127 -0
  89. package/server/routes/structure.js +22 -0
  90. package/server/server.js +3 -0
  91. package/server/services/databaseCommandService.js +59 -0
  92. package/server/services/sqlite/backupDiff.js +914 -0
  93. package/server/services/sqlite/backupService.js +77 -1
  94. package/server/services/sqlite/dataBrowserService.js +36 -0
  95. package/server/services/sqlite/introspection.js +226 -22
  96. package/server/services/sqlite/structureService.js +7 -0
  97. package/server/services/sqlite/syntheticDataGenerator.js +728 -0
  98. package/server/services/sqlite/tableAdvisor.js +790 -0
  99. package/server/services/storage/appStateStore.js +773 -169
  100. package/server/services/typeGenerationService.js +663 -0
  101. package/tailwind.config.cjs +0 -1
  102. package/.github/funding.yml +0 -2
  103. package/.github/workflows/ci.yml +0 -36
  104. package/database.sqlite +0 -0
  105. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  106. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  107. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  108. package/frontend/assets/mockups/connections_1200.webp +0 -0
  109. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  110. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  111. package/frontend/assets/mockups/documents_1200.webp +0 -0
  112. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  113. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  114. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  115. package/frontend/assets/mockups/overview_1200.webp +0 -0
  116. package/frontend/assets/mockups/settings_1200.webp +0 -0
  117. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  118. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  119. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  120. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  121. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  122. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  123. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  124. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  125. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  126. package/tests/api-token-auth.test.js +0 -279
  127. package/tests/backup-manager.test.js +0 -140
  128. package/tests/backups-view.test.js +0 -70
  129. package/tests/charts-height-preset-storage.test.js +0 -60
  130. package/tests/charts-route-state.test.js +0 -144
  131. package/tests/check-constraint-options.test.js +0 -90
  132. package/tests/cli-args.test.js +0 -113
  133. package/tests/cli-service-delegation.test.js +0 -140
  134. package/tests/connection-removal.test.js +0 -52
  135. package/tests/connections-file-dialog-route.test.js +0 -89
  136. package/tests/copy-column-modal.test.js +0 -131
  137. package/tests/database-command-service.test.js +0 -174
  138. package/tests/database-documents.test.js +0 -85
  139. package/tests/documents-view.test.js +0 -132
  140. package/tests/dropdown-button.test.js +0 -101
  141. package/tests/email-preview.test.js +0 -89
  142. package/tests/export-blob.test.js +0 -99
  143. package/tests/export-filenames.test.js +0 -38
  144. package/tests/file-path-preview.test.js +0 -165
  145. package/tests/form-controls.test.js +0 -34
  146. package/tests/json-preview.test.js +0 -49
  147. package/tests/local-request-security.test.js +0 -85
  148. package/tests/markdown-documents.test.js +0 -103
  149. package/tests/native-file-dialog.test.js +0 -105
  150. package/tests/query-editor.test.js +0 -28
  151. package/tests/query-history-detail.test.js +0 -37
  152. package/tests/query-history-header.test.js +0 -30
  153. package/tests/query-results-truncation.test.js +0 -20
  154. package/tests/risky-sql.test.js +0 -30
  155. package/tests/row-editor-json.test.js +0 -82
  156. package/tests/row-editor-null-values.test.js +0 -184
  157. package/tests/row-editor-timestamp-preview.test.js +0 -192
  158. package/tests/security-paths.test.js +0 -84
  159. package/tests/settings-api-tokens-route.test.js +0 -97
  160. package/tests/settings-metadata.test.js +0 -114
  161. package/tests/settings-view.test.js +0 -108
  162. package/tests/sql-formatter.test.js +0 -173
  163. package/tests/sql-highlight.test.js +0 -38
  164. package/tests/sql-identifier-safety.test.js +0 -171
  165. package/tests/sql-result-limit.test.js +0 -66
  166. package/tests/structure-view.test.js +0 -60
  167. package/tests/table-designer-v2-unique-constraints.test.js +0 -78
  168. package/tests/table-scroll-state.test.js +0 -70
  169. package/tests/text-cell-stats.test.js +0 -38
@@ -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",
@@ -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
- listApiTokens(databaseKey) {
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
- return this.db
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
- .map((row) => this.decorateApiTokenRow(row));
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 }) {