diff --git a/crates/dbx-core/src/history.rs b/crates/dbx-core/src/history.rs index 0c2bea65b5..673849e016 100644 --- a/crates/dbx-core/src/history.rs +++ b/crates/dbx-core/src/history.rs @@ -4,6 +4,8 @@ use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryEntry { pub id: String, + #[serde(default)] + pub connection_id: String, pub connection_name: String, pub database: String, pub sql: String, @@ -11,10 +13,26 @@ pub struct HistoryEntry { pub execution_time_ms: u128, pub success: bool, pub error: Option, + #[serde(default = "default_activity_kind")] + pub activity_kind: String, + #[serde(default)] + pub operation: String, + #[serde(default)] + pub target: String, + #[serde(default)] + pub affected_rows: Option, + #[serde(default)] + pub rollback_sql: Option, + #[serde(default)] + pub details_json: Option, } pub const MAX_HISTORY: usize = 1000; +fn default_activity_kind() -> String { + "query".to_string() +} + pub fn read_all(path: &Path) -> Result, String> { if !path.exists() { return Ok(vec![]); diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 98c06af727..f567f46595 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -25,13 +25,20 @@ const SCHEMA_STATEMENTS: &[&str] = &[ )", "CREATE TABLE IF NOT EXISTS history ( id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '', database TEXT NOT NULL DEFAULT '', sql_text TEXT NOT NULL DEFAULT '', executed_at TEXT NOT NULL DEFAULT '', execution_time_ms INTEGER NOT NULL DEFAULT 0, success INTEGER NOT NULL DEFAULT 1, - error TEXT + error TEXT, + activity_kind TEXT NOT NULL DEFAULT 'query', + operation TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT '', + affected_rows INTEGER, + rollback_sql TEXT, + details_json TEXT )", "CREATE TABLE IF NOT EXISTS ai_config ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -93,11 +100,40 @@ impl Storage { for statement in SCHEMA_STATEMENTS { sqlx::query(statement).execute(&pool).await.map_err(|e| e.to_string())?; } + ensure_history_columns(&pool).await?; Ok(Self { db: pool }) } } +async fn ensure_history_columns(pool: &SqlitePool) -> Result<(), String> { + const COLUMNS: &[(&str, &str)] = &[ + ("activity_kind", "TEXT NOT NULL DEFAULT 'query'"), + ("connection_id", "TEXT NOT NULL DEFAULT ''"), + ("operation", "TEXT NOT NULL DEFAULT ''"), + ("target", "TEXT NOT NULL DEFAULT ''"), + ("affected_rows", "INTEGER"), + ("rollback_sql", "TEXT"), + ("details_json", "TEXT"), + ]; + + let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info('history')") + .fetch_all(pool) + .await + .map_err(|e| e.to_string())?; + let existing: std::collections::HashSet = rows.into_iter().map(|(name,)| name).collect(); + for (name, definition) in COLUMNS { + if existing.contains(*name) { + continue; + } + sqlx::query(&format!("ALTER TABLE history ADD COLUMN {name} {definition}")) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + // --------------------------------------------------------------------------- // History // --------------------------------------------------------------------------- @@ -105,6 +141,7 @@ impl Storage { #[derive(sqlx::FromRow)] struct HistoryRow { id: String, + connection_id: String, connection_name: String, database: String, sql_text: String, @@ -112,14 +149,21 @@ struct HistoryRow { execution_time_ms: i64, success: bool, error: Option, + activity_kind: String, + operation: String, + target: String, + affected_rows: Option, + rollback_sql: Option, + details_json: Option, } impl Storage { pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> { sqlx::query( "INSERT OR REPLACE INTO history \ - (id, connection_name, database, sql_text, executed_at, execution_time_ms, success, error) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (id, connection_name, database, sql_text, executed_at, execution_time_ms, success, error, \ + activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&entry.id) .bind(&entry.connection_name) @@ -129,6 +173,13 @@ impl Storage { .bind(entry.execution_time_ms as i64) .bind(entry.success) .bind(&entry.error) + .bind(&entry.activity_kind) + .bind(&entry.connection_id) + .bind(&entry.operation) + .bind(&entry.target) + .bind(entry.affected_rows) + .bind(&entry.rollback_sql) + .bind(&entry.details_json) .execute(&self.db) .await .map_err(|e| e.to_string())?; @@ -148,7 +199,8 @@ impl Storage { pub async fn load_history_entries(&self, limit: usize, offset: usize) -> Result, String> { let rows: Vec = sqlx::query_as( "SELECT id, connection_name, database, sql_text, executed_at, \ - execution_time_ms, success, error \ + execution_time_ms, success, error, activity_kind, connection_id, operation, target, \ + affected_rows, rollback_sql, details_json \ FROM history ORDER BY executed_at DESC LIMIT ? OFFSET ?", ) .bind(limit as i64) @@ -161,6 +213,7 @@ impl Storage { .into_iter() .map(|r| HistoryEntry { id: r.id, + connection_id: r.connection_id, connection_name: r.connection_name, database: r.database, sql: r.sql_text, @@ -168,6 +221,12 @@ impl Storage { execution_time_ms: r.execution_time_ms as u128, success: r.success, error: r.error, + activity_kind: if r.activity_kind.is_empty() { "query".to_string() } else { r.activity_kind }, + operation: r.operation, + target: r.target, + affected_rows: r.affected_rows, + rollback_sql: r.rollback_sql, + details_json: r.details_json, }) .collect()) } @@ -737,8 +796,9 @@ impl Storage { sqlx::query( "INSERT OR IGNORE INTO history \ (id, connection_name, database, sql_text, executed_at, \ - execution_time_ms, success, error) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + execution_time_ms, success, error, activity_kind, connection_id, operation, target, \ + affected_rows, rollback_sql, details_json) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&entry.id) .bind(&entry.connection_name) @@ -748,6 +808,13 @@ impl Storage { .bind(entry.execution_time_ms as i64) .bind(entry.success) .bind(&entry.error) + .bind(&entry.activity_kind) + .bind(&entry.connection_id) + .bind(&entry.operation) + .bind(&entry.target) + .bind(entry.affected_rows) + .bind(&entry.rollback_sql) + .bind(&entry.details_json) .execute(&self.db) .await .map_err(|e| e.to_string())?; diff --git a/src/App.vue b/src/App.vue index 51948e0617..ee1519950e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -40,6 +40,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { HistoryEntry } from "@/lib/tauri"; const { t } = useI18n(); const connectionStore = useConnectionStore(); @@ -93,6 +94,21 @@ const activeConnection = computed(() => { return tab ? connectionStore.getConfig(tab.connectionId) : undefined; }); +function restoreHistorySql(sql: string, entry: HistoryEntry) { + const tab = activeTab.value; + if (tab?.mode === "query") { + queryStore.updateSql(tab.id, sql); + return; + } + + const connectionId = entry.connection_id || tab?.connectionId || connectionStore.connections[0]?.id; + if (!connectionId) return; + const config = connectionStore.getConfig(connectionId); + const database = entry.database || tab?.database || (config ? resolveDefaultDatabase(config, []) : ""); + const tabId = queryStore.createTab(connectionId, database || "", t("tabs.sql")); + queryStore.updateSql(tabId, sql); +} + const executableSql = computed(() => { const tab = activeTab.value; return tab @@ -634,14 +650,7 @@ onUnmounted(() => { :style="{ width: historyWidth + 'px' }" >
- +
diff --git a/src/components/editor/QueryHistory.vue b/src/components/editor/QueryHistory.vue index 8057a2071c..fbd12421e5 100644 --- a/src/components/editor/QueryHistory.vue +++ b/src/components/editor/QueryHistory.vue @@ -1,39 +1,60 @@ @@ -76,16 +169,30 @@ onMounted(() => store.load()); -
- - +
+
+ + +
+
+ +
@@ -93,24 +200,36 @@ onMounted(() => store.load());
- {{ entry.connection_name }} - / {{ entry.database }} + + {{ kindShortLabel(entry) }} + + {{ entryTitle(entry) }} {{ formatTime(entry.executed_at) }}
-
{{ truncateSql(entry.sql) }}
+
{{ entrySubtitle(entry) }}
+ + + {{ entry.connection_name }} + {{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
- - {{ t("history.restore") }} - {{ t("history.copy") }} + + {{ t("history.viewDetails") }} + {{ t("history.restore") }} + {{ t("history.copy") }} + {{ + t("history.rollback") + }} {{ t("history.delete") }} @@ -121,5 +240,56 @@ onMounted(() => store.load()); {{ t("history.empty") }}
+ + + + + {{ selectedEntry ? entryTitle(selectedEntry) : t("history.viewDetails") }} + +
+
+ +
+
+
+
SQL
+ +
+
{{ selectedEntry.sql }}
+
+
+
+
{{ t("history.rollbackSql") }}
+ +
+
{{ selectedEntry.rollback_sql }}
+
+
+ + + + +
+
diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 4c1464eba1..1fa17ba336 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -73,7 +73,7 @@ import { type CellSelectionRange, } from "@/lib/gridSelection"; import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; -import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql"; +import { buildDataGridRollbackStatements, buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql"; import { formatMarkdownTable } from "@/lib/markdownTable"; import { buildXlsxWorkbook } from "@/lib/xlsxExport"; import { @@ -84,9 +84,13 @@ import { } from "@/lib/gridRowStatus"; import { useToast } from "@/composables/useToast"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useHistoryStore } from "@/stores/historyStore"; const { t } = useI18n(); const { toast } = useToast(); +const connectionStore = useConnectionStore(); +const historyStore = useHistoryStore(); const props = defineProps<{ result: QueryResult; @@ -1539,28 +1543,81 @@ function deleteSelectedRow() { requestDeleteRow(contextCell.value.rowId); } -function generateSaveStatements(): string[] { - if (!props.tableMeta) return []; - return buildDataGridSaveStatements({ +function saveStatementOptions() { + if (!props.tableMeta) return null; + return { databaseType: props.databaseType, tableMeta: props.tableMeta, columns: props.result.columns, rows: props.result.rows, - dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]), + dirtyRows: [...dirtyRows.value.entries()].map( + ([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>], + ), deletedRows: [...deletedRows.value], newRows: newRows.value, + }; +} + +function tableHistoryTarget() { + if (!props.tableMeta) return ""; + return [props.tableMeta.schema, props.tableMeta.tableName].filter(Boolean).join("."); +} + +function dataChangeOperation() { + const operations = [ + newRows.value.length > 0 ? "INSERT" : "", + dirtyRows.value.size > 0 ? "UPDATE" : "", + deletedRows.value.size > 0 ? "DELETE" : "", + ].filter(Boolean); + return operations.length === 1 ? operations[0] : "DATA CHANGE"; +} + +async function recordDataGridHistory( + statements: string[], + rollbackStatements: string[], + elapsed: number, + result?: { affected_rows?: number }, +) { + if (!props.connectionId || !props.database || !props.tableMeta) return; + const connName = connectionStore.getConfig(props.connectionId)?.name || ""; + const details = { + schema: props.tableMeta.schema, + table: props.tableMeta.tableName, + inserted_rows: newRows.value.length, + updated_rows: dirtyRows.value.size, + deleted_rows: deletedRows.value.size, + statements, + rollback_statements: rollbackStatements, + }; + await historyStore.add({ + connection_id: props.connectionId, + connection_name: connName, + database: props.database, + sql: statements.join("\n"), + execution_time_ms: elapsed, + success: true, + activity_kind: "data_change", + operation: dataChangeOperation(), + target: tableHistoryTarget(), + affected_rows: result?.affected_rows ?? statements.length, + rollback_sql: rollbackStatements.length ? rollbackStatements.join("\n") : undefined, + details_json: JSON.stringify(details), }); } async function saveChanges() { - const stmts = generateSaveStatements(); + const options = saveStatementOptions(); + const stmts = options ? buildDataGridSaveStatements(options) : []; if (stmts.length === 0) return; + const rollbackStmts = options ? buildDataGridRollbackStatements(options) : []; saveError.value = ""; isSaving.value = true; + const start = Date.now(); + let result: { affected_rows?: number } | undefined; if (useTransaction.value && props.connectionId && props.database) { try { - await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema); + result = await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema); } catch (e: any) { saveError.value = String(e.message || e); isSaving.value = false; @@ -1568,7 +1625,7 @@ async function saveChanges() { } } else if (props.connectionId && props.database) { try { - await api.executeBatch(props.connectionId, props.database, stmts); + result = await api.executeBatch(props.connectionId, props.database, stmts); } catch (e: any) { saveError.value = String(e.message || e); isSaving.value = false; @@ -1585,6 +1642,11 @@ async function saveChanges() { return; } } + try { + await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, result); + } catch (e) { + console.warn("[DBX] failed to record data grid history", e); + } dirtyRows.value.clear(); newRows.value = []; deletedRows.value.clear(); diff --git a/src/composables/useSqlExecution.ts b/src/composables/useSqlExecution.ts index 7438d2b5ee..b869bef90c 100644 --- a/src/composables/useSqlExecution.ts +++ b/src/composables/useSqlExecution.ts @@ -7,6 +7,9 @@ import { useToast } from "@/composables/useToast"; import type { ConnectionConfig, QueryTab } from "@/types/database"; const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; +const READ_RE = /^\s*(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i; +const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|MERGE|REPLACE|TRUNCATE)\b/i; +const SCHEMA_RE = /^\s*(CREATE|ALTER|DROP|RENAME)\b/i; export function stripSqlComments(sql: string): string { return sql @@ -20,6 +23,27 @@ export function isDangerousSql(sql: string): boolean { return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt)); } +function primarySqlOperation(sql: string): string { + const cleaned = stripSqlComments(sql); + const statement = cleaned + .split(";") + .map((part) => part.trim()) + .find(Boolean); + return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL"; +} + +function activityKindForSql(sql: string): "query" | "data_change" | "schema_change" { + const cleaned = stripSqlComments(sql); + const statements = cleaned + .split(";") + .map((stmt) => stmt.trim()) + .filter(Boolean); + if (statements.some((stmt) => SCHEMA_RE.test(stmt))) return "schema_change"; + if (statements.some((stmt) => WRITE_RE.test(stmt))) return "data_change"; + if (statements.every((stmt) => READ_RE.test(stmt))) return "query"; + return "query"; +} + export function useSqlExecution(deps: { activeTab: ComputedRef; activeConnection: ComputedRef; @@ -59,12 +83,16 @@ export function useSqlExecution(deps: { const elapsed = Date.now() - start; const success = !tab.result?.columns.includes("Error"); historyStore.add({ + connection_id: tab.connectionId, connection_name: connName, database: tab.database, sql, execution_time_ms: elapsed, success, error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""), + activity_kind: activityKindForSql(sql), + operation: primarySqlOperation(sql), + affected_rows: success ? tab.result?.affected_rows : undefined, }); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 83111e54e8..ee5450be3d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -641,11 +641,51 @@ export default { search: "Search history...", empty: "No history yet", failed: "Failed", + success: "Succeeded", restore: "Restore to editor", copy: "Copy SQL", delete: "Delete", - confirmDelete: "Delete this query history entry?", - confirmClear: "Clear all query history?", + viewDetails: "View details", + rollback: "Rollback", + rollbackSql: "Rollback SQL", + rollbackConfirm: "Execute the rollback SQL generated for this history entry?", + rollbackSuccess: "Rollback executed", + rollbackFailed: "Rollback failed: {message}", + confirmDelete: "Delete this history entry?", + confirmClear: "Clear all history?", + filters: { + all: "All", + query: "Queries", + data_change: "Data changes", + schema_change: "Schema changes", + failed: "Failed", + }, + kinds: { + query: "Query", + data_change: "Data change", + schema_change: "Schema change", + import: "Import", + transfer: "Transfer", + }, + kindShort: { + query: "SQL", + data_change: "Data", + schema_change: "DDL", + import: "Import", + transfer: "Move", + }, + detail: { + kind: "Type", + operation: "Operation", + connection: "Connection", + database: "Database", + target: "Target", + time: "Time", + duration: "Duration", + affectedRows: "Affected rows", + status: "Status", + error: "Error", + }, }, dangerDialog: { title: "Dangerous Operation", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 13f74a1c89..ee65f993ad 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -625,15 +625,55 @@ export default { edit: "编辑", }, history: { - title: "查询历史", + title: "历史", search: "搜索历史...", empty: "暂无历史记录", failed: "失败", + success: "成功", restore: "恢复到编辑器", copy: "复制 SQL", delete: "删除", - confirmDelete: "确认删除这条查询历史吗?", - confirmClear: "确认清空所有查询历史吗?", + viewDetails: "查看详情", + rollback: "回滚", + rollbackSql: "回滚 SQL", + rollbackConfirm: "将执行这条历史记录生成的回滚 SQL,确认继续吗?", + rollbackSuccess: "回滚已执行", + rollbackFailed: "回滚失败:{message}", + confirmDelete: "确认删除这条历史记录吗?", + confirmClear: "确认清空所有历史记录吗?", + filters: { + all: "全部", + query: "查询", + data_change: "数据变更", + schema_change: "结构变更", + failed: "失败", + }, + kinds: { + query: "查询", + data_change: "数据变更", + schema_change: "结构变更", + import: "导入", + transfer: "传输", + }, + kindShort: { + query: "查询", + data_change: "变更", + schema_change: "结构", + import: "导入", + transfer: "传输", + }, + detail: { + kind: "类型", + operation: "操作", + connection: "连接", + database: "数据库", + target: "对象", + time: "时间", + duration: "耗时", + affectedRows: "影响行数", + status: "状态", + error: "错误", + }, }, dangerDialog: { title: "危险操作", diff --git a/src/lib/dataGridSql.ts b/src/lib/dataGridSql.ts index c9b6012767..1843c679a5 100644 --- a/src/lib/dataGridSql.ts +++ b/src/lib/dataGridSql.ts @@ -56,6 +56,54 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption return statements; } +export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] { + const table = qualifiedTableName({ + databaseType: options.databaseType, + schema: options.tableMeta.schema, + tableName: options.tableMeta.tableName, + }); + const statements: string[] = []; + + for (const row of options.newRows) { + const where = buildRowWhere(options.databaseType, options.columns, row); + if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`); + } + + for (const rowIndex of options.deletedRows) { + const row = options.rows[rowIndex]; + if (!row) continue; + const columns = options.columns.map((column) => quoteIdent(options.databaseType, column)).join(", "); + const values = row.map((v) => formatGridSqlLiteral(v, options.databaseType)).join(", "); + statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`); + } + + for (const [rowIndex, changes] of options.dirtyRows) { + const row = options.rows[rowIndex]; + if (!row) continue; + const afterRow = [...row]; + for (const [columnIndex, value] of changes) { + afterRow[columnIndex] = value; + } + const sets = changes + .map( + ([columnIndex]) => + `${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`, + ) + .join(", "); + const where = [ + buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow), + ...changes.map(([columnIndex, value]) => + buildColumnPredicate(options.databaseType, options.columns[columnIndex], value), + ), + ] + .filter(Boolean) + .join(" AND "); + statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`); + } + + return statements; +} + export function formatGridSqlLiteral(value: GridCellValue, databaseType?: DatabaseType): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; @@ -80,6 +128,16 @@ function buildPrimaryKeyWhere( .join(" AND "); } +function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string { + return columns.map((column, index) => buildColumnPredicate(databaseType, column, row[index])).join(" AND "); +} + +function buildColumnPredicate(databaseType: DatabaseType | undefined, column: string, value: GridCellValue): string { + const ident = quoteIdent(databaseType, column); + if (value === null || value === undefined) return `${ident} IS NULL`; + return `${ident} = ${formatGridSqlLiteral(value, databaseType)}`; +} + function quoteIdent(databaseType: DatabaseType | undefined, name: string): string { return quoteTableIdentifier(databaseType, name); } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 85bc251364..2a03c45059 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -498,6 +498,7 @@ export async function mongoDeleteDocument( // --- History --- export interface HistoryEntry { id: string; + connection_id?: string; connection_name: string; database: string; sql: string; @@ -505,6 +506,12 @@ export interface HistoryEntry { execution_time_ms: number; success: boolean; error?: string; + activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer"; + operation?: string; + target?: string; + affected_rows?: number | null; + rollback_sql?: string | null; + details_json?: string | null; } export async function saveHistory(entry: HistoryEntry): Promise {